withruntime 0.8.4 → 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 (132) hide show
  1. package/BLAXEL.md +4 -4
  2. package/COMPATIBILITY.md +145 -0
  3. package/DAYTONA.md +13 -7
  4. package/E2B.md +34 -20
  5. package/README.md +22 -11
  6. package/README.src.md +22 -11
  7. package/VERCEL.md +13 -3
  8. package/compatibility-lock.json +357 -0
  9. package/dist/ai-harness/index.js +2 -2
  10. package/dist/api-defaults.d.ts +17 -0
  11. package/dist/api-defaults.js +22 -0
  12. package/dist/blaxel/errors.js +1 -1
  13. package/dist/blaxel/lookup.d.ts +3 -2
  14. package/dist/blaxel/lookup.js +3 -2
  15. package/dist/blaxel/pagination.d.ts +2 -2
  16. package/dist/blaxel/pagination.js +45 -17
  17. package/dist/blaxel/preview.d.ts +3 -2
  18. package/dist/blaxel/preview.js +16 -14
  19. package/dist/blaxel/process.d.ts +1 -1
  20. package/dist/blaxel/process.js +66 -11
  21. package/dist/blaxel/sandbox.d.ts +2 -0
  22. package/dist/blaxel/sandbox.js +2 -0
  23. package/dist/blaxel/snapshot.js +3 -5
  24. package/dist/cli-words.d.ts +27 -0
  25. package/dist/cli-words.js +135 -0
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.js +351 -85
  28. package/dist/client.d.ts +33 -4
  29. package/dist/client.js +47 -12
  30. package/dist/cloudflare/identity.d.ts +7 -0
  31. package/dist/cloudflare/identity.js +13 -0
  32. package/dist/cloudflare/index.d.ts +303 -0
  33. package/dist/cloudflare/index.js +592 -0
  34. package/dist/cloudflare/worker.d.ts +419 -0
  35. package/dist/cloudflare/worker.js +447 -0
  36. package/dist/codesandbox/guest-service.d.ts +31 -0
  37. package/dist/codesandbox/guest-service.js +490 -0
  38. package/dist/codesandbox/index.d.ts +386 -0
  39. package/dist/codesandbox/index.js +1347 -0
  40. package/dist/compat/core.d.ts +40 -0
  41. package/dist/compat/core.js +238 -0
  42. package/dist/compat/files.d.ts +26 -0
  43. package/dist/compat/files.js +133 -0
  44. package/dist/compat/shell.d.ts +4 -0
  45. package/dist/compat/shell.js +165 -0
  46. package/dist/credentials.d.ts +6 -1
  47. package/dist/credentials.js +107 -38
  48. package/dist/daytona/context.js +2 -1
  49. package/dist/daytona/daytona.js +11 -4
  50. package/dist/daytona/filesystem.d.ts +17 -4
  51. package/dist/daytona/filesystem.js +121 -38
  52. package/dist/daytona/index.d.ts +1 -1
  53. package/dist/daytona/process.d.ts +1 -1
  54. package/dist/daytona/process.js +27 -4
  55. package/dist/daytona/sandbox.d.ts +1 -1
  56. package/dist/daytona/sandbox.js +16 -7
  57. package/dist/e2b/client.js +1 -1
  58. package/dist/e2b/code-interpreter.d.ts +4 -5
  59. package/dist/e2b/code-interpreter.js +54 -56
  60. package/dist/e2b/commands.d.ts +35 -12
  61. package/dist/e2b/commands.js +169 -73
  62. package/dist/e2b/errors.js +1 -1
  63. package/dist/e2b/filesystem.d.ts +8 -6
  64. package/dist/e2b/filesystem.js +127 -28
  65. package/dist/e2b/index.d.ts +1 -0
  66. package/dist/e2b/index.js +1 -0
  67. package/dist/e2b/pty.d.ts +24 -0
  68. package/dist/e2b/pty.js +80 -0
  69. package/dist/e2b/sandbox.d.ts +6 -4
  70. package/dist/e2b/sandbox.js +59 -25
  71. package/dist/errors.d.ts +8 -0
  72. package/dist/errors.js +15 -2
  73. package/dist/file-source.d.ts +16 -0
  74. package/dist/file-source.js +162 -0
  75. package/dist/freestyle/index.d.ts +309 -0
  76. package/dist/freestyle/index.js +343 -0
  77. package/dist/index.d.ts +3 -1
  78. package/dist/login.js +16 -10
  79. package/dist/mcp.js +2 -2
  80. package/dist/modal/index.d.ts +205 -0
  81. package/dist/modal/index.js +486 -0
  82. package/dist/network-cli.js +112 -44
  83. package/dist/openai-agents/index.js +18 -71
  84. package/dist/page.d.ts +1 -1
  85. package/dist/page.js +2 -2
  86. package/dist/products/audit.d.ts +3 -0
  87. package/dist/products/index.d.ts +14 -0
  88. package/dist/products/index.js +12 -0
  89. package/dist/products/interpreter.d.ts +11 -4
  90. package/dist/products/interpreter.js +33 -9
  91. package/dist/products/jobs.js +3 -2
  92. package/dist/products/limits.d.ts +10 -0
  93. package/dist/products/observability.d.ts +44 -0
  94. package/dist/products/observability.js +6 -0
  95. package/dist/products/previews.d.ts +18 -4
  96. package/dist/products/previews.js +14 -5
  97. package/dist/products/tailscale.d.ts +57 -0
  98. package/dist/products/tailscale.js +17 -0
  99. package/dist/products/volumes.d.ts +42 -0
  100. package/dist/products/volumes.js +36 -0
  101. package/dist/products/watch.d.ts +25 -4
  102. package/dist/products/watch.js +132 -18
  103. package/dist/proxy.js +3 -1
  104. package/dist/runloop/index.d.ts +326 -0
  105. package/dist/runloop/index.js +535 -0
  106. package/dist/runloop/pagination.d.ts +46 -0
  107. package/dist/runloop/pagination.js +82 -0
  108. package/dist/runloop/snapshots.d.ts +52 -0
  109. package/dist/runloop/snapshots.js +244 -0
  110. package/dist/sandbox.d.ts +76 -16
  111. package/dist/sandbox.js +696 -135
  112. package/dist/snapshots.d.ts +20 -5
  113. package/dist/snapshots.js +11 -1
  114. package/dist/sprites/index.d.ts +251 -0
  115. package/dist/sprites/index.js +588 -0
  116. package/dist/tar.d.ts +10 -1
  117. package/dist/tar.js +311 -35
  118. package/dist/tools/index.js +2 -1
  119. package/dist/transport.d.ts +23 -8
  120. package/dist/transport.js +247 -127
  121. package/dist/types.d.ts +52 -3
  122. package/dist/vercel/command.d.ts +1 -1
  123. package/dist/vercel/command.js +15 -3
  124. package/dist/vercel/errors.d.ts +2 -2
  125. package/dist/vercel/errors.js +19 -2
  126. package/dist/vercel/filesystem.d.ts +21 -18
  127. package/dist/vercel/filesystem.js +95 -48
  128. package/dist/vercel/sandbox.d.ts +8 -8
  129. package/dist/vercel/sandbox.js +159 -64
  130. package/dist/vercel/snapshot.d.ts +3 -2
  131. package/dist/vercel/snapshot.js +9 -5
  132. package/package.json +46 -3
@@ -0,0 +1,486 @@
1
+ import { all, client, CompatibilityError, create, destroy, execOptions, only, region, } from "../compat/core.js";
2
+ import { GuestFiles } from "../compat/files.js";
3
+ export { CompatibilityError };
4
+ export class NotFoundError extends Error {
5
+ }
6
+ export class TimeoutError extends Error {
7
+ }
8
+ export class SandboxTimeoutError extends TimeoutError {
9
+ }
10
+ export class App {
11
+ appId;
12
+ name;
13
+ environmentName;
14
+ constructor(appId, name, environmentName) {
15
+ this.appId = appId;
16
+ this.name = name;
17
+ this.environmentName = environmentName;
18
+ }
19
+ }
20
+ export class Secret {
21
+ values;
22
+ constructor(values) {
23
+ this.values = values;
24
+ }
25
+ }
26
+ export class ModalClient {
27
+ runtime;
28
+ apps;
29
+ images;
30
+ sandboxes;
31
+ secrets = { fromObject: (values) => new Secret(values) };
32
+ constructor(options = {}) {
33
+ only("Modal client", options, [
34
+ "apiKey",
35
+ "baseUrl",
36
+ "fetch",
37
+ "client",
38
+ "timeoutMs",
39
+ "maxRetries",
40
+ "tokenId",
41
+ "tokenSecret",
42
+ "environment",
43
+ ]);
44
+ this.runtime = client(options);
45
+ const runtime = this.runtime;
46
+ this.apps = {
47
+ fromName: async (name, params = {}) => {
48
+ only("Modal app", params, ["createIfMissing", "environment"]);
49
+ if (params.environment || options.environment)
50
+ throw new CompatibilityError("Modal", "named Modal environments");
51
+ if (!params.createIfMissing &&
52
+ !(await all(runtime, "modal")).some((s) => s.info.labels["modal.app"] === name))
53
+ throw new NotFoundError(`App ${name} has no Runtime sandboxes; pass createIfMissing: true.`);
54
+ return new App(name, name);
55
+ },
56
+ };
57
+ this.images = {
58
+ fromRegistry: (tag, secret) => {
59
+ if (secret)
60
+ throw new CompatibilityError("Modal", "registry Secret; configure Runtime image registry credentials");
61
+ return new Image(runtime, tag);
62
+ },
63
+ fromId: async (id) => new Image(runtime, undefined, (await runtime.images.get(id)).id),
64
+ fromName: async (name) => new Image(runtime, undefined, (await runtime.images.resolve(name)).id),
65
+ delete: (id) => runtime.images.delete(id),
66
+ };
67
+ this.sandboxes = new SandboxService(runtime);
68
+ }
69
+ close() {
70
+ /* Transport has no owned background connection to close. */
71
+ }
72
+ }
73
+ export class Image {
74
+ runtime;
75
+ tag;
76
+ commands;
77
+ env;
78
+ cache;
79
+ built;
80
+ constructor(runtime, tag, id, commands = [], env = {}, cache = true) {
81
+ this.runtime = runtime;
82
+ this.tag = tag;
83
+ this.commands = commands;
84
+ this.env = env;
85
+ this.cache = cache;
86
+ this.built = id;
87
+ }
88
+ get imageId() {
89
+ if (!this.built)
90
+ throw new Error("Image is not built; await image.build(app) first.");
91
+ return this.built;
92
+ }
93
+ dockerfileCommands(commands, params = {}) {
94
+ only("Modal image", params, ["env", "forceBuild"]);
95
+ if (!this.tag)
96
+ throw new CompatibilityError("Modal", "extending a prebuilt Runtime image through Dockerfile layers");
97
+ return new Image(this.runtime, this.tag, undefined, [...this.commands, ...commands], { ...this.env, ...params.env }, params.forceBuild ? false : this.cache);
98
+ }
99
+ async build(_app) {
100
+ if (!this.built) {
101
+ const built = this.commands.length
102
+ ? await this.runtime.images.build({
103
+ dockerfile: [`FROM ${this.tag}`, ...this.commands].join("\n"),
104
+ env: this.env,
105
+ cache: this.cache,
106
+ })
107
+ : await this.runtime.images.build({ image: this.tag, env: this.env, cache: this.cache });
108
+ this.built = built.id;
109
+ }
110
+ return this;
111
+ }
112
+ }
113
+ export class SandboxService {
114
+ runtime;
115
+ constructor(runtime) {
116
+ this.runtime = runtime;
117
+ }
118
+ async create(app, image, params = {}) {
119
+ only("Modal sandbox", params, [
120
+ "name",
121
+ "cpu",
122
+ "cpuLimit",
123
+ "memoryMiB",
124
+ "memoryLimitMiB",
125
+ "timeoutMs",
126
+ "workdir",
127
+ "command",
128
+ "env",
129
+ "encryptedPorts",
130
+ "blockNetwork",
131
+ "outboundDomainAllowlist",
132
+ "outboundCidrAllowlist",
133
+ "tags",
134
+ "regions",
135
+ "gpu",
136
+ "pty",
137
+ "secrets",
138
+ "experimentalEnableSnapshot",
139
+ ]);
140
+ if (params.gpu)
141
+ throw new CompatibilityError("Modal", "GPU allocation");
142
+ if (params.cpuLimit !== undefined && params.cpuLimit !== params.cpu)
143
+ throw new CompatibilityError("Modal", "separate CPU reservation and hard limit");
144
+ if (params.memoryLimitMiB !== undefined && params.memoryLimitMiB !== params.memoryMiB)
145
+ throw new CompatibilityError("Modal", "separate memory reservation and hard limit");
146
+ if (params.regions && params.regions.length !== 1)
147
+ throw new CompatibilityError("Modal", "multiple region placement");
148
+ if (params.cpu !== undefined && !Number.isInteger(params.cpu))
149
+ throw new CompatibilityError("Modal", "fractional CPU reservations");
150
+ if (params.blockNetwork && (params.outboundDomainAllowlist || params.outboundCidrAllowlist))
151
+ throw new TypeError("blockNetwork cannot be combined with allowlists");
152
+ const placement = region("Modal", params.regions?.[0]);
153
+ const built = await image.build(app);
154
+ let primary;
155
+ const s = await create(this.runtime, "modal", {
156
+ name: params.name ? `${app.name ?? app.appId}-${params.name}` : undefined,
157
+ labels: {
158
+ "modal.app": app.name ?? app.appId,
159
+ ...(params.name ? { "modal.name": params.name } : {}),
160
+ "modal.tags": JSON.stringify(params.tags ?? {}),
161
+ },
162
+ image: built.imageId,
163
+ cpu: "reserved",
164
+ vcpu: params.cpu,
165
+ memoryMiB: params.memoryMiB,
166
+ region: placement,
167
+ timeoutSeconds: Math.ceil((params.timeoutMs ?? 300_000) / 1000),
168
+ idlePauseSeconds: 0,
169
+ onLeaseEnd: "stop",
170
+ network: {
171
+ internet: !params.blockNetwork,
172
+ allow: [
173
+ ...(params.outboundDomainAllowlist ?? []),
174
+ ...(params.outboundCidrAllowlist ?? []),
175
+ ],
176
+ },
177
+ }, Object.assign({}, ...(params.secrets?.map((s) => s.values) ?? []), params.env ?? {}), async (s) => {
178
+ primary = await s.spawn(params.command ?? ["sleep", "infinity"], {
179
+ ...(await execOptions(s, { cwd: params.workdir })),
180
+ stdin: "pipe",
181
+ pty: params.pty ? {} : undefined,
182
+ });
183
+ await s.update({ labels: { ...s.info.labels, "modal.entrypoint": primary.id } });
184
+ for (const port of params.encryptedPorts ?? [])
185
+ await s.previews.create(port, { visibility: "public" });
186
+ });
187
+ const sandbox = new Sandbox(s, new ContainerProcess(primary, "text"));
188
+ sandbox.monitorEntrypoint();
189
+ return sandbox;
190
+ }
191
+ async fromId(id) {
192
+ const s = await this.runtime.sandboxes.get(id);
193
+ const p = s.info.labels["modal.entrypoint"];
194
+ if (!p)
195
+ throw new NotFoundError("Sandbox is not a Modal-compatible sandbox");
196
+ return new Sandbox(s, new ContainerProcess(await s.processes.get(p), "text"));
197
+ }
198
+ async fromName(appName, name) {
199
+ const s = (await all(this.runtime, "modal")).find((s) => s.info.labels["modal.app"] === appName &&
200
+ s.info.labels["modal.name"] === name &&
201
+ s.state === "running");
202
+ if (!s)
203
+ throw new NotFoundError(`Sandbox ${name} not found`);
204
+ return this.fromId(s.id);
205
+ }
206
+ async *list(params = {}) {
207
+ only("Modal list", params, ["appId", "tags"]);
208
+ for (const s of await all(this.runtime, "modal"))
209
+ if (s.state === "running" &&
210
+ (!params.appId || s.info.labels["modal.app"] === params.appId) &&
211
+ Object.entries(params.tags ?? {}).every(([k, v]) => JSON.parse(s.info.labels["modal.tags"] ?? "{}")[k] === v))
212
+ yield await this.fromId(s.id);
213
+ }
214
+ }
215
+ export class ModalReadStream extends ReadableStream {
216
+ constructor(process, channel, mode, ignore = false) {
217
+ const iterator = mode === "binary" ? process.outputBytes() : process.output();
218
+ super({
219
+ async pull(controller) {
220
+ if (ignore) {
221
+ controller.close();
222
+ return;
223
+ }
224
+ try {
225
+ while (true) {
226
+ const next = await iterator.next();
227
+ if (next.done) {
228
+ controller.close();
229
+ return;
230
+ }
231
+ if (next.value.type === "truncated")
232
+ throw new Error("Process output was truncated");
233
+ if (next.value.type === channel) {
234
+ controller.enqueue(next.value.data);
235
+ return;
236
+ }
237
+ }
238
+ }
239
+ catch (error) {
240
+ controller.error(error);
241
+ }
242
+ },
243
+ async cancel() {
244
+ await iterator.return(undefined);
245
+ },
246
+ });
247
+ }
248
+ async readBytes() {
249
+ const reader = this.getReader();
250
+ const chunks = [];
251
+ let size = 0;
252
+ try {
253
+ while (true) {
254
+ const next = await reader.read();
255
+ if (next.done)
256
+ break;
257
+ const bytes = typeof next.value === "string" ? new TextEncoder().encode(next.value) : next.value;
258
+ chunks.push(bytes);
259
+ size += bytes.length;
260
+ }
261
+ }
262
+ finally {
263
+ reader.releaseLock();
264
+ }
265
+ const output = new Uint8Array(size);
266
+ let offset = 0;
267
+ for (const chunk of chunks) {
268
+ output.set(chunk, offset);
269
+ offset += chunk.length;
270
+ }
271
+ return output;
272
+ }
273
+ async readText() {
274
+ return new TextDecoder().decode(await this.readBytes());
275
+ }
276
+ }
277
+ export class ModalWriteStream extends WritableStream {
278
+ process;
279
+ constructor(process) {
280
+ super({ write: (data) => process.write(data), close: () => process.write("", { eof: true }) });
281
+ this.process = process;
282
+ }
283
+ async writeText(text) {
284
+ const writer = this.getWriter();
285
+ try {
286
+ await writer.write(text);
287
+ }
288
+ finally {
289
+ writer.releaseLock();
290
+ }
291
+ }
292
+ async writeBytes(bytes) {
293
+ const writer = this.getWriter();
294
+ try {
295
+ await writer.write(bytes);
296
+ }
297
+ finally {
298
+ writer.releaseLock();
299
+ }
300
+ }
301
+ }
302
+ export class ContainerProcess {
303
+ native;
304
+ stdin;
305
+ stdout;
306
+ stderr;
307
+ constructor(native, mode, params = {}) {
308
+ this.native = native;
309
+ this.stdin = new ModalWriteStream(native);
310
+ this.stdout = new ModalReadStream(native, "stdout", mode, params.stdout === "ignore");
311
+ this.stderr = new ModalReadStream(native, "stderr", mode, params.stderr === "ignore");
312
+ }
313
+ async wait() {
314
+ const r = await this.native.wait();
315
+ if (r.timedOut)
316
+ throw new SandboxTimeoutError("Process timed out");
317
+ return r.exitCode ?? -1;
318
+ }
319
+ closeStdin() {
320
+ return this.native.write("", { eof: true });
321
+ }
322
+ async _poll() {
323
+ const p = await this.native.refresh();
324
+ return p.state === "running" ? null : p.exitCode;
325
+ }
326
+ }
327
+ export class Sandbox {
328
+ native;
329
+ primary;
330
+ filesystem;
331
+ stdin;
332
+ stdout;
333
+ stderr;
334
+ detached = false;
335
+ lifecycle;
336
+ monitorEntrypoint() {
337
+ this.lifecycle = this.primary.wait().then(async (code) => {
338
+ await destroy(this.native);
339
+ return code;
340
+ });
341
+ this.lifecycle.catch(() => undefined);
342
+ }
343
+ constructor(native, primary) {
344
+ this.native = native;
345
+ this.primary = primary;
346
+ this.filesystem = new SandboxFilesystem(native);
347
+ this.stdin = primary.stdin;
348
+ this.stdout = primary.stdout;
349
+ this.stderr = primary.stderr;
350
+ }
351
+ get sandboxId() {
352
+ return this.native.id;
353
+ }
354
+ async exec(command, params = {}) {
355
+ if (this.detached)
356
+ throw new Error("Sandbox is detached");
357
+ only("Modal exec", params, [
358
+ "mode",
359
+ "stdout",
360
+ "stderr",
361
+ "workdir",
362
+ "timeoutMs",
363
+ "env",
364
+ "pty",
365
+ "secrets",
366
+ ]);
367
+ const p = await this.native.spawn(command, {
368
+ ...(await execOptions(this.native, {
369
+ cwd: params.workdir,
370
+ timeoutMs: params.timeoutMs === 0 ? 86_400_000 : params.timeoutMs,
371
+ env: Object.assign({}, ...(params.secrets?.map((s) => s.values) ?? []), params.env ?? {}),
372
+ })),
373
+ stdin: "pipe",
374
+ ...(params.mode === "binary" ? { outputEncoding: "base64" } : {}),
375
+ pty: params.pty ? {} : undefined,
376
+ });
377
+ return new ContainerProcess(p, params.mode ?? "text", params);
378
+ }
379
+ async setTags(tags) {
380
+ if (this.detached)
381
+ throw new Error("Sandbox is detached");
382
+ await this.native.update({
383
+ labels: {
384
+ ...Object.fromEntries(Object.entries(this.native.info.labels).filter(([k]) => k.startsWith("modal.") || k === "compat.provider")),
385
+ "modal.tags": JSON.stringify(tags),
386
+ },
387
+ });
388
+ }
389
+ async getTags() {
390
+ if (this.detached)
391
+ throw new Error("Sandbox is detached");
392
+ await this.native.refresh();
393
+ return JSON.parse(this.native.info.labels["modal.tags"] ?? "{}");
394
+ }
395
+ async terminate(params = {}) {
396
+ await destroy(this.native);
397
+ return params.wait ? this.primary.wait() : undefined;
398
+ }
399
+ detach() {
400
+ this.detached = true;
401
+ }
402
+ async wait() {
403
+ if (this.lifecycle)
404
+ return this.lifecycle;
405
+ const code = await this.primary.wait();
406
+ await destroy(this.native);
407
+ return code;
408
+ }
409
+ poll() {
410
+ return this.primary._poll();
411
+ }
412
+ async tunnels() {
413
+ return Object.fromEntries((await this.native.previews.list()).map((p) => [
414
+ p.port,
415
+ { host: new URL(p.url).hostname, port: 443, url: p.urlWithToken ?? p.url },
416
+ ]));
417
+ }
418
+ async updateNetworkPolicy(params) {
419
+ await this.native.network.set({
420
+ internet: true,
421
+ allow: [...params.outboundCidrAllowlist, ...params.outboundDomainAllowlist],
422
+ });
423
+ }
424
+ async experimentalSnapshot() {
425
+ const snapshot = await this.native.snapshot();
426
+ return { sandboxSnapshotId: snapshot.id };
427
+ }
428
+ }
429
+ export class SandboxFilesystem {
430
+ native;
431
+ files;
432
+ constructor(native) {
433
+ this.native = native;
434
+ this.files = new GuestFiles(async () => native, "/");
435
+ }
436
+ readBytes(path) {
437
+ return this.files.readFile(path);
438
+ }
439
+ readText(path) {
440
+ return this.files.readTextFile(path);
441
+ }
442
+ writeBytes(content, path) {
443
+ return this.files.writeFile(path, content instanceof Uint8Array ? content : new Uint8Array(content));
444
+ }
445
+ writeText(content, path) {
446
+ return this.files.writeTextFile(path, content);
447
+ }
448
+ makeDirectory(path, options = {}) {
449
+ return this.files.mkdir(path, options.createParents ?? true);
450
+ }
451
+ remove(path, options = {}) {
452
+ return this.files.remove(path, options.recursive);
453
+ }
454
+ copyFromLocal(localPath, remotePath) {
455
+ return this.native.files.upload(localPath, remotePath);
456
+ }
457
+ copyToLocal(remotePath, localPath) {
458
+ return this.native.files.download(remotePath, localPath);
459
+ }
460
+ async listFiles(path) {
461
+ return Promise.all((await this.files.readdir(path))
462
+ .sort((a, b) => a.name.localeCompare(b.name))
463
+ .map((e) => this.stat(e.path)));
464
+ }
465
+ async stat(path) {
466
+ const e = await this.files.stat(path);
467
+ const r = await this.native.exec(["stat", "-c", "%U:%G", "--", this.files.path(path)], {
468
+ check: true,
469
+ });
470
+ const [owner, group] = r.stdout.trim().split(":");
471
+ return {
472
+ name: e.name,
473
+ path: e.path,
474
+ type: e.type,
475
+ size: e.size,
476
+ mode: parseInt(e.mode, 8),
477
+ permissions: e.mode,
478
+ owner: owner,
479
+ group: group,
480
+ modifiedTime: Date.parse(e.modifiedAt) / 1000,
481
+ symlinkTarget: e.type === "symlink"
482
+ ? (await this.native.exec(["readlink", "--", this.files.path(path)], { check: true })).stdout.trimEnd()
483
+ : null,
484
+ };
485
+ }
486
+ }
@@ -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";
@@ -30,7 +33,7 @@ export const NETWORK_HELP = {
30
33
  Redis, a game server, anything that is not HTTP
31
34
  Prints address:port for your client
32
35
  ls [--sandbox <id>] Your open ports
33
- close <portId> Close one; open connections end within seconds
36
+ close <portId> Close one, its open connections too
34
37
  Paid accounts only. TCP, carried as it is: use your service's own password and TLS.
35
38
  `,
36
39
  address: `runtime address <command>
@@ -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"));