withruntime 0.12.0 → 0.16.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 (94) hide show
  1. package/BLAXEL.md +97 -32
  2. package/COMPATIBILITY.md +120 -5
  3. package/DAYTONA.md +34 -3
  4. package/E2B.md +34 -5
  5. package/README.md +36 -3
  6. package/README.src.md +35 -2
  7. package/VERCEL.md +52 -5
  8. package/compatibility-lock.json +71 -111
  9. package/dist/billing-cli.js +8 -4
  10. package/dist/blaxel/client.d.ts +11 -0
  11. package/dist/blaxel/client.js +28 -0
  12. package/dist/blaxel/errors.d.ts +65 -0
  13. package/dist/blaxel/errors.js +114 -0
  14. package/dist/blaxel/index.d.ts +10 -3
  15. package/dist/blaxel/index.js +23 -4
  16. package/dist/blaxel/preview.d.ts +29 -4
  17. package/dist/blaxel/preview.js +79 -5
  18. package/dist/blaxel/sandbox.d.ts +1 -1
  19. package/dist/blaxel/sandbox.js +2 -1
  20. package/dist/blaxel/session.js +4 -2
  21. package/dist/cli-extras.js +2 -1
  22. package/dist/cli-words.js +1 -0
  23. package/dist/cli.d.ts +17 -2
  24. package/dist/cli.js +291 -85
  25. package/dist/client.d.ts +1 -1
  26. package/dist/credentials.d.ts +3 -0
  27. package/dist/credentials.js +15 -0
  28. package/dist/daytona/client.d.ts +4 -0
  29. package/dist/daytona/client.js +8 -3
  30. package/dist/daytona/daytona.d.ts +4 -1
  31. package/dist/daytona/daytona.js +3 -2
  32. package/dist/e2b/sandbox.d.ts +7 -6
  33. package/dist/e2b/sandbox.js +9 -12
  34. package/dist/errors.d.ts +1 -1
  35. package/dist/errors.js +1 -1
  36. package/dist/freestyle/index.d.ts +77 -5
  37. package/dist/freestyle/index.js +380 -48
  38. package/dist/identity.js +36 -18
  39. package/dist/index.d.ts +4 -3
  40. package/dist/machine.d.ts +39 -13
  41. package/dist/machine.js +72 -25
  42. package/dist/mcp.js +157 -20
  43. package/dist/modal/index.d.ts +15 -0
  44. package/dist/modal/index.js +35 -7
  45. package/dist/morph/index.d.ts +255 -0
  46. package/dist/morph/index.js +641 -0
  47. package/dist/network-cli.js +2 -2
  48. package/dist/product-names.d.ts +2 -2
  49. package/dist/product-names.js +2 -2
  50. package/dist/products/api-keys.d.ts +73 -0
  51. package/dist/products/api-keys.js +23 -0
  52. package/dist/products/apps.d.ts +12 -31
  53. package/dist/products/apps.js +8 -113
  54. package/dist/products/balancers.d.ts +87 -0
  55. package/dist/products/balancers.js +8 -0
  56. package/dist/products/builds.d.ts +30 -0
  57. package/dist/products/builds.js +6 -0
  58. package/dist/products/index.d.ts +2 -0
  59. package/dist/products/index.js +2 -0
  60. package/dist/products/jobs.d.ts +2 -2
  61. package/dist/products/jobs.js +1 -1
  62. package/dist/products/limits.d.ts +16 -2
  63. package/dist/products/limits.js +2 -2
  64. package/dist/products/mcp.d.ts +5 -0
  65. package/dist/products/network-products.d.ts +21 -3
  66. package/dist/products/network-products.js +3 -1
  67. package/dist/products/observability.d.ts +7 -2
  68. package/dist/products/previews.d.ts +6 -3
  69. package/dist/products/previews.js +1 -1
  70. package/dist/products/resources.d.ts +54 -0
  71. package/dist/products/resources.js +122 -0
  72. package/dist/products/secrets.d.ts +22 -0
  73. package/dist/products/secrets.js +8 -0
  74. package/dist/products/volumes.d.ts +3 -0
  75. package/dist/sandbox.d.ts +4 -0
  76. package/dist/sandbox.js +27 -2
  77. package/dist/snapshots.d.ts +4 -0
  78. package/dist/ssh.d.ts +35 -0
  79. package/dist/ssh.js +93 -1
  80. package/dist/transport.d.ts +14 -8
  81. package/dist/transport.js +70 -15
  82. package/dist/types.d.ts +33 -5
  83. package/dist/vercel/command.d.ts +48 -1
  84. package/dist/vercel/command.js +237 -6
  85. package/dist/vercel/sandbox-user.js +2 -1
  86. package/dist/vercel/sandbox.d.ts +17 -3
  87. package/dist/vercel/sandbox.js +46 -3
  88. package/dist/wallet.d.ts +4 -1
  89. package/dist/wallet.js +2 -1
  90. package/package.json +7 -14
  91. package/dist/cli/logs.test.d.ts +0 -1
  92. package/dist/cli/logs.test.js +0 -272
  93. package/dist/products/logs.test.d.ts +0 -1
  94. package/dist/products/logs.test.js +0 -343
@@ -8,7 +8,10 @@ export type DnsRecord = {
8
8
  export type Domain = {
9
9
  id: string;
10
10
  hostname: string;
11
- sandboxId: string;
11
+ /** The sandbox it serves; null for an App's. */
12
+ sandboxId: string | null;
13
+ /** The App it serves, through every deploy and rollback; null for a sandbox's. */
14
+ appId?: string | null;
12
15
  port: number;
13
16
  /** pending until the TXT record proves the name is yours, then active. */
14
17
  state: "pending" | "active" | "released";
@@ -20,6 +23,13 @@ export type Domain = {
20
23
  verifiedAt: string | null;
21
24
  checkedAt: string | null;
22
25
  checkError: string | null;
26
+ /** An App's domain's certificate, issued before visitors arrive: point the
27
+ * name at the App once `ready` is true. Null for a sandbox's. */
28
+ certificate?: {
29
+ ready: boolean;
30
+ expiresAt: string | null;
31
+ issuers: string[];
32
+ } | null;
23
33
  createdAt: string;
24
34
  };
25
35
  export type CheckedDomain = Domain & {
@@ -97,12 +107,20 @@ export type TunnelPeerCreated = {
97
107
  /** `runtime.domains`: serve a sandbox's port at your own hostname, with HTTPS. */
98
108
  export declare function domains(t: Transport): {
99
109
  /** Claims the hostname and answers the DNS records to set. Calling it again
100
- * with another sandbox or port points the name there. */
110
+ * with another sandbox or port points the name there. With `appId`, an App
111
+ * serves it: add the `_acme-challenge` CNAME too, and point the name at
112
+ * the App once `certificate.ready` is true. */
101
113
  add: (input: {
102
114
  hostname: string;
115
+ } & ({
103
116
  sandboxId: string;
104
117
  port: number;
105
- }, options?: RequestOptions) => Promise<CheckedDomain>;
118
+ appId?: never;
119
+ } | {
120
+ appId: string;
121
+ sandboxId?: never;
122
+ port?: never;
123
+ }), options?: RequestOptions) => Promise<CheckedDomain>;
106
124
  /** Checks the TXT record now; the name goes live when it matches. */
107
125
  verify: (hostname: string, options?: RequestOptions) => Promise<CheckedDomain>;
108
126
  get: (hostname: string, options?: RequestOptions) => Promise<Domain>;
@@ -5,7 +5,9 @@ const enc = encodeURIComponent;
5
5
  export function domains(t) {
6
6
  return {
7
7
  /** Claims the hostname and answers the DNS records to set. Calling it again
8
- * with another sandbox or port points the name there. */
8
+ * with another sandbox or port points the name there. With `appId`, an App
9
+ * serves it: add the `_acme-challenge` CNAME too, and point the name at
10
+ * the App once `certificate.ready` is true. */
9
11
  add: (input, options) => t.json({ method: "POST", path: "/v1/domains", body: input, ...options }),
10
12
  /** Checks the TXT record now; the name goes live when it matches. */
11
13
  verify: (hostname, options) => t.json({
@@ -35,7 +35,7 @@ export type SandboxMetrics = {
35
35
  latest: MetricPoint | null;
36
36
  points: MetricPoint[];
37
37
  };
38
- export type WebhookEventType = "sandbox.created" | "sandbox.running" | "sandbox.paused" | "sandbox.woken" | "sandbox.stopped" | "sandbox.start_failed" | "sandbox.wake_failed" | "sandbox.host_unreachable" | "sandbox.host_reachable" | "sandbox.recovered" | "sandbox.recovery_failed" | "sandbox.files.changed" | "sandbox.out_of_memory" | "sandbox.secret_not_applied" | "snapshot.ready" | "snapshot.failed" | "snapshot.deleted" | "volume.ready" | "volume.failed" | "volume.deleted" | "egress_secret.created" | "egress_secret.updated" | "egress_secret.deleted" | "upstream_proxy.set" | "upstream_proxy.removed";
38
+ export type WebhookEventType = "sandbox.created" | "sandbox.running" | "sandbox.paused" | "sandbox.woken" | "sandbox.stopped" | "sandbox.start_failed" | "sandbox.wake_failed" | "sandbox.host_unreachable" | "sandbox.host_reachable" | "sandbox.recovered" | "sandbox.recovery_failed" | "sandbox.files.changed" | "sandbox.out_of_memory" | "sandbox.secret_not_applied" | "snapshot.ready" | "snapshot.failed" | "snapshot.deleted" | "volume.ready" | "volume.failed" | "volume.deleted" | "volume.recovered" | "egress_secret.created" | "egress_secret.updated" | "egress_secret.deleted" | "upstream_proxy.set" | "upstream_proxy.removed";
39
39
  /** An event as /v1/events lists it and a webhook delivers it. */
40
40
  export type RuntimeEvent = {
41
41
  id: string;
@@ -113,7 +113,7 @@ export type UsageExportRow = {
113
113
  name: string | null;
114
114
  kind: string;
115
115
  meter: string;
116
- /** credit, the trial's hours, or the month's included usage. */
116
+ /** credit, the trial's hours, or the month's free usage. */
117
117
  fundedBy: "credit" | "trial" | "allowance";
118
118
  startsAt: string;
119
119
  endsAt: string;
@@ -128,12 +128,17 @@ export type UsageExportRow = {
128
128
  /** Exact JSON text, preserving integers beyond JavaScript's number range. */
129
129
  measurementsJson: string | null;
130
130
  evidence: "measured" | "partial" | "missing" | null;
131
+ /** The resource is deleted and every charge it will ever have settled
132
+ * before finalThrough. A volume's backups bill on until retention ends. */
133
+ final: boolean;
131
134
  };
132
135
  export type UsageExportPage = {
133
136
  since: string;
134
137
  until: string;
135
138
  data: UsageExportRow[];
136
139
  nextCursor: string | null;
140
+ /** No row with settledAt before this can still appear or change. */
141
+ finalThrough: string | null;
137
142
  /** Concatenate pages in order; only the first page includes the header. */
138
143
  csv: string;
139
144
  };
@@ -9,9 +9,12 @@ export type Preview = {
9
9
  /** `https://<port>-<sandbox>.<preview domain>/`. A private preview also
10
10
  * needs the token. */
11
11
  url: string;
12
- /** Private previews: a fresh signed token. Send it as the
13
- * `x-runtime-preview-token` header. */
12
+ /** Private previews: a token signed for this answer. Send it as the
13
+ * `x-runtime-preview-token` header. Every create and read signs its own;
14
+ * one signed before keeps working until its own expiry or a rotate. */
14
15
  token: string | null;
16
+ /** When this answer's token stops working. Not stored on the preview: a
17
+ * later read signs another token and leaves this one as it is. */
15
18
  tokenExpiresAt: string | null;
16
19
  /** Private previews: a link that carries the token once, for a browser. */
17
20
  urlWithToken: string | null;
@@ -48,7 +51,7 @@ export declare function sandboxPreviews(t: Transport, sandbox: Sandbox): {
48
51
  /** Shares `port`, or changes its visibility and embedOrigins if it is
49
52
  * shared already. */
50
53
  create: (port: number, input?: CreatePreview, options?: RequestOptions) => Promise<Preview>;
51
- /** Every shared port, each private one with a fresh token. */
54
+ /** Every shared port, each private one with a fresh token that lasts a day; tokens signed before keep their own expiry. */
52
55
  list: (options?: RequestOptions) => Promise<Preview[]>;
53
56
  /** One preview, with a fresh private token. `expiresAt` bounds its expiry
54
57
  * to an absolute ISO deadline; with no TTL, that deadline is used exactly. */
@@ -10,7 +10,7 @@ export function sandboxPreviews(t, sandbox) {
10
10
  /** Shares `port`, or changes its visibility and embedOrigins if it is
11
11
  * shared already. */
12
12
  create: (port, input = {}, options) => t.json({ method: "POST", path: base(), body: { port, ...input }, ...options }),
13
- /** Every shared port, each private one with a fresh token. */
13
+ /** Every shared port, each private one with a fresh token that lasts a day; tokens signed before keep their own expiry. */
14
14
  list: async (options) => (await t.json({ method: "GET", path: base(), ...options })).data,
15
15
  /** One preview, with a fresh private token. `expiresAt` bounds its expiry
16
16
  * to an absolute ISO deadline; with no TTL, that deadline is used exactly. */
@@ -0,0 +1,54 @@
1
+ import { Page } from "../page.js";
2
+ import type { RequestOptions, Transport } from "../transport.js";
3
+ /** The five verbs every resource product shares (CLOUD.md section 4, "One
4
+ * shape for every interface"; packages/cloud/src/api/resource-product.ts),
5
+ * and following one to a state. A product builds its client on these and
6
+ * adds only its own verbs. */
7
+ export type ResourceRecord = {
8
+ id: string;
9
+ state: string;
10
+ error: {
11
+ code: string;
12
+ message: string;
13
+ } | null;
14
+ };
15
+ export type ResourceListQuery<S extends string = string> = {
16
+ name?: string;
17
+ /** Repeated key:value filters; all must match. */
18
+ label?: readonly string[];
19
+ /** Defaults to the product's live states. */
20
+ state?: readonly S[];
21
+ /** 1 to 100; defaults to 50. */
22
+ limit?: number;
23
+ cursor?: string;
24
+ };
25
+ export type ResourceGetOptions<S extends string = string> = RequestOptions & {
26
+ waitFor?: S;
27
+ /** Server wait, 1 to 60 seconds; defaults to 30. Check the returned state. */
28
+ timeoutSeconds?: number;
29
+ };
30
+ export type ResourceWaitOptions = RequestOptions & {
31
+ /** Whole wait, including retries; defaults to 300000 ms. 0 disables it. */
32
+ timeoutMs?: number;
33
+ /** Delay between reads; defaults to 250 ms. */
34
+ pollMs?: number;
35
+ };
36
+ export type ResourceDeleteOptions = ResourceWaitOptions & {
37
+ wait?: boolean;
38
+ };
39
+ export declare const enc: typeof encodeURIComponent;
40
+ /** One deadline covers the write, retries and every read. A timed-out delete
41
+ * can be retried after a process restart with this same key. */
42
+ export declare function following<T>(options: ResourceWaitOptions, work: (request: RequestOptions, pause: () => Promise<void>) => Promise<T>): Promise<T>;
43
+ /** A resource product's client at `/v1/<path>`. A wait ends at its target
44
+ * or at any of `settled`, which answers with the resource's error. */
45
+ export declare function resource<R extends ResourceRecord, C, U, S extends string = R["state"]>(t: Transport, path: string, settled: readonly S[]): {
46
+ base: string;
47
+ /** Defaults belong to the API, so the SDK does not override them. */
48
+ create: (input: C, options?: RequestOptions) => Promise<R>;
49
+ get: (id: string, options?: ResourceGetOptions<S>) => Promise<R>;
50
+ list: (query?: ResourceListQuery<S>, options?: RequestOptions) => Promise<Page<R>>;
51
+ update: (id: string, input: U, options?: RequestOptions) => Promise<R>;
52
+ wait: (id: string, target: S, options?: ResourceWaitOptions) => Promise<R>;
53
+ delete: (id: string, options?: ResourceDeleteOptions) => Promise<R>;
54
+ };
@@ -0,0 +1,122 @@
1
+ import { RuntimeError } from "../errors.js";
2
+ import { Page } from "../page.js";
3
+ import { pause } from "../wait.js";
4
+ export const enc = encodeURIComponent;
5
+ function invalid(field) {
6
+ return new RuntimeError({
7
+ code: "invalid_request",
8
+ status: 0,
9
+ message: `${field} must be a finite, nonnegative number.`,
10
+ hint: "Fix the option and call again; nothing was sent.",
11
+ details: { field },
12
+ });
13
+ }
14
+ /** One deadline covers the write, retries and every read. A timed-out delete
15
+ * can be retried after a process restart with this same key. */
16
+ export async function following(options, work) {
17
+ const { pollMs = 250, timeoutMs = 300_000, signal, ...rest } = options;
18
+ if (!Number.isFinite(pollMs) || pollMs < 0)
19
+ throw invalid("pollMs");
20
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
21
+ throw invalid("timeoutMs");
22
+ const deadline = new AbortController();
23
+ const combined = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
24
+ const timer = timeoutMs > 0 ? setTimeout(() => deadline.abort(), timeoutMs) : undefined;
25
+ const request = { ...rest, timeoutMs: 0, signal: combined };
26
+ const poll = () => pause(pollMs, combined);
27
+ try {
28
+ combined.throwIfAborted();
29
+ const result = await work(request, poll);
30
+ combined.throwIfAborted();
31
+ return result;
32
+ }
33
+ catch (cause) {
34
+ if (!combined.aborted) {
35
+ // A read failure still belongs to the write that started this wait.
36
+ // Keep its key so a caller can recover after losing the reply.
37
+ if (cause instanceof RuntimeError && rest.idempotencyKey && !cause.idempotencyKey)
38
+ throw new RuntimeError({
39
+ message: cause.message,
40
+ code: cause.code,
41
+ status: cause.status,
42
+ requestId: cause.requestId,
43
+ hint: cause.hint,
44
+ details: cause.details,
45
+ retryAfterMs: cause.retryAfterMs,
46
+ idempotencyKey: rest.idempotencyKey,
47
+ cause,
48
+ });
49
+ throw cause;
50
+ }
51
+ throw new RuntimeError({
52
+ code: "timeout",
53
+ status: 0,
54
+ message: "Waiting was cancelled or reached its deadline; the resource may still change.",
55
+ hint: "Read the resource to resume waiting. Retry a write with the same idempotencyKey.",
56
+ ...(rest.idempotencyKey === undefined ? {} : { idempotencyKey: rest.idempotencyKey }),
57
+ cause,
58
+ });
59
+ }
60
+ finally {
61
+ clearTimeout(timer);
62
+ }
63
+ }
64
+ /** A resource product's client at `/v1/<path>`. A wait ends at its target
65
+ * or at any of `settled`, which answers with the resource's error. */
66
+ export function resource(t, path, settled) {
67
+ const base = `/v1/${path}`;
68
+ const get = (id, options = {}) => {
69
+ const { waitFor, timeoutSeconds, ...request } = options;
70
+ return t.json({
71
+ method: "GET",
72
+ path: `${base}/${enc(id)}`,
73
+ query: { waitFor, timeoutSeconds },
74
+ ...request,
75
+ });
76
+ };
77
+ const list = async (query = {}, options) => {
78
+ const page = await t.json({
79
+ method: "GET",
80
+ path: base,
81
+ query,
82
+ ...options,
83
+ });
84
+ return new Page(page.data, page.nextCursor, (cursor) => list({ ...query, cursor }, options));
85
+ };
86
+ const follow = async (id, target, request, pause, initial) => {
87
+ let record = initial ?? (await get(id, request));
88
+ for (;;) {
89
+ if (record.state === target)
90
+ return record;
91
+ if (settled.includes(record.state))
92
+ throw new RuntimeError({
93
+ code: record.error?.code ?? "unexpected_state",
94
+ status: 0,
95
+ message: record.error?.message ?? `Expected ${target}, but ${id} reached ${record.state}.`,
96
+ hint: "Read the resource and inspect its error before starting another change.",
97
+ details: { id, state: record.state, target, resource: record },
98
+ ...(request.idempotencyKey === undefined
99
+ ? {}
100
+ : { idempotencyKey: request.idempotencyKey }),
101
+ });
102
+ await pause();
103
+ record = await get(id, request);
104
+ }
105
+ };
106
+ return {
107
+ base,
108
+ /** Defaults belong to the API, so the SDK does not override them. */
109
+ create: (input, options) => t.json({ method: "POST", path: base, body: input, ...options }),
110
+ get,
111
+ list,
112
+ update: (id, input, options) => t.json({ method: "POST", path: `${base}/${enc(id)}:update`, body: input, ...options }),
113
+ wait: (id, target, options = {}) => following(options, (request, pause) => follow(id, target, request, pause)),
114
+ delete: (id, options = {}) => {
115
+ const { wait = false, pollMs, ...request } = options;
116
+ const remove = (rest) => t.json({ method: "DELETE", path: `${base}/${enc(id)}`, ...rest });
117
+ if (!wait)
118
+ return remove(request);
119
+ return following({ ...request, pollMs, idempotencyKey: request.idempotencyKey ?? crypto.randomUUID() }, async (rest, pause) => follow(id, "deleted", rest, pause, await remove(rest)));
120
+ },
121
+ };
122
+ }
@@ -9,6 +9,17 @@ export type SecretRule = {
9
9
  methods?: string[];
10
10
  paths: string[];
11
11
  };
12
+ /** Which sandboxes get a secret: those whose labels hold every pair of any one
13
+ * selector in `labels` (including sandboxes created or labelled later), and
14
+ * those named in `sandboxes`. Any other sandbox of the organization sees no
15
+ * placeholder, and its requests never carry the value. Absent on a secret:
16
+ * every sandbox of the organization. */
17
+ export type SecretScope = {
18
+ /** 1 to 8 selectors of 1 to 8 key/value pairs: `[{ role: "hub" }]`. */
19
+ labels?: Array<Record<string, string>>;
20
+ /** 1 to 100 sandbox ids of the organization. */
21
+ sandboxes?: string[];
22
+ };
12
23
  /** A secret your sandboxes use without seeing it. Every sandbox of your
13
24
  * organization has an environment variable of the secret's name holding
14
25
  * `placeholder`; the egress proxy sets `header` on every HTTPS request to
@@ -22,6 +33,8 @@ export type Secret = {
22
33
  header?: string;
23
34
  format?: string;
24
35
  rules?: SecretRule[];
36
+ /** Absent: every sandbox of the organization. */
37
+ only?: SecretScope;
25
38
  placeholder: string;
26
39
  valueBytes: number;
27
40
  createdAt: string;
@@ -45,6 +58,10 @@ export type SetSecret = {
45
58
  * Absent: every request to the hosts; replacing a secret without rules
46
59
  * clears them. */
47
60
  rules?: SecretRule[];
61
+ /** Only these sandboxes get it. Absent when replacing a secret: the scope it
62
+ * has stays, so a new value never widens who gets it. `{}`: every sandbox of
63
+ * the organization again. */
64
+ only?: SecretScope;
48
65
  /** Also keep a copy for scheduled jobs, which a job puts into its run's
49
66
  * environment (`jobs.create({ secrets: [...] })`). Unlike the sandboxes'
50
67
  * copy, the jobs copy is yours rather than the account's, versioned, and can
@@ -98,6 +115,11 @@ export declare function secrets(t: Transport): {
98
115
  enforced: boolean;
99
116
  }>>;
100
117
  };
118
+ /** Changes which sandboxes get a secret, without its value: `{}` is every
119
+ * sandbox of the organization again. */
120
+ scope: (name: string, only: SecretScope, options?: RequestOptions) => Promise<Secret & {
121
+ enforced: boolean;
122
+ }>;
101
123
  /** The sandboxes' secrets: names, hosts and placeholders. Never values.
102
124
  * `all()` includes the jobs copies. */
103
125
  list: (options?: RequestOptions) => Promise<Secret[]>;
@@ -153,6 +153,14 @@ export function secrets(t) {
153
153
  * at once store both; if only the first lands, `SecretPartlyStoredError`
154
154
  * says so and the same call again finishes it. */
155
155
  set,
156
+ /** Changes which sandboxes get a secret, without its value: `{}` is every
157
+ * sandbox of the organization again. */
158
+ scope: (name, only, options) => t.json({
159
+ method: "PUT",
160
+ path: `${path(name)}/sandboxes`,
161
+ body: only,
162
+ ...options,
163
+ }),
156
164
  /** The sandboxes' secrets: names, hosts and placeholders. Never values.
157
165
  * `all()` includes the jobs copies. */
158
166
  list,
@@ -90,6 +90,9 @@ export type VolumeBackup = {
90
90
  * the host never began; back it up again. */
91
91
  errorCode?: string | null;
92
92
  createdAt: string;
93
+ /** When its copy off the volume's server began; null while it waits its
94
+ * turn behind other copies there. */
95
+ copyStartedAt?: string | null;
93
96
  readyAt: string | null;
94
97
  expiresAt: string;
95
98
  };
package/dist/sandbox.d.ts CHANGED
@@ -8,6 +8,10 @@ import type { StreamUploadSource } from "./file-source.js";
8
8
  import { Machine, type MachineProduct } from "./machine.js";
9
9
  /** Sandboxes, as a product of the machine engine (machine.ts). */
10
10
  export declare const SANDBOXES: MachineProduct;
11
+ /** How long past its timeoutMs a streamed exec waits for an exit that never came. */
12
+ export declare const EXEC_EXIT_GRACE: {
13
+ ms: number;
14
+ };
11
15
  /** A running (or stopped) sandbox. Every method is one API call or a short
12
16
  * sequence of them; `await using sbx = await Sandbox.create()` deletes it at
13
17
  * the end of the block, or stops it, keeping its disk, if created with
package/dist/sandbox.js CHANGED
@@ -87,6 +87,8 @@ function commandBody(command, options) {
87
87
  ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
88
88
  };
89
89
  }
90
+ /** How long past its timeoutMs a streamed exec waits for an exit that never came. */
91
+ export const EXEC_EXIT_GRACE = { ms: 120_000 };
90
92
  // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
91
93
  export class Sandbox extends Machine {
92
94
  #t;
@@ -147,8 +149,22 @@ export class Sandbox extends Machine {
147
149
  let exit;
148
150
  let processId;
149
151
  let dropped = false;
152
+ // A command with a time limit ends by it, the server killing it if need
153
+ // be; an exit that never comes after (a follow that resumes forever)
154
+ // ends the wait past the limit and EXEC_EXIT_GRACE.ms, as a timeout,
155
+ // its process named and sent SIGTERM. The hub's release train waited
156
+ // 29 min on a 10 min command (15 October 2026).
157
+ // A timer cleared when the call ends, so none outlives the exec.
158
+ const deadline = options.timeoutMs === undefined ? undefined : new AbortController();
159
+ const timer = deadline
160
+ ? setTimeout(() => deadline.abort(), options.timeoutMs + EXEC_EXIT_GRACE.ms)
161
+ : undefined;
162
+ timer?.unref?.();
163
+ const signal = deadline && options.signal
164
+ ? AbortSignal.any([options.signal, deadline.signal])
165
+ : (deadline?.signal ?? options.signal);
150
166
  try {
151
- for await (const event of this.execStream(command, options)) {
167
+ for await (const event of this.execStream(command, { ...options, signal })) {
152
168
  if (event.type === "start")
153
169
  processId = event.processId;
154
170
  else if (event.type === "stdout" || event.type === "stderr") {
@@ -162,7 +178,16 @@ export class Sandbox extends Machine {
162
178
  }
163
179
  }
164
180
  catch (error) {
165
- throw await this.#unfinished(error, processId, options.signal);
181
+ if (!deadline?.signal.aborted || options.signal?.aborted)
182
+ throw await this.#unfinished(error, processId, signal);
183
+ if (processId)
184
+ await new Process(this.#t, this.id, { id: processId })
185
+ .kill("SIGTERM")
186
+ .catch(() => { });
187
+ exit = { type: "exit", exitCode: null, timedOut: true };
188
+ }
189
+ finally {
190
+ clearTimeout(timer);
166
191
  }
167
192
  result = {
168
193
  exitCode: exit?.exitCode ?? null,
@@ -37,6 +37,10 @@ export type Snapshot = {
37
37
  durableAt: string | null;
38
38
  storedBytes: number | null;
39
39
  };
40
+ /** An opaque tag for the server it is kept on, like `pl_3f9a0c1b2d4e5f60`.
41
+ * The same tag as a sandbox of yours means the same server. Null before it
42
+ * is placed. Absent from older servers. */
43
+ placement?: string | null;
40
44
  error: string | null;
41
45
  createdAt: string;
42
46
  readyAt: string | null;
package/dist/ssh.d.ts CHANGED
@@ -16,6 +16,41 @@ export declare function sshKey(env: NodeJS.ProcessEnv): Promise<{
16
16
  export declare function resolveSandbox(runtime: Runtime, target: string): Promise<Sandbox>;
17
17
  export declare function sshConfig(env: NodeJS.ProcessEnv): Promise<string>;
18
18
  export declare function sshCommand(argv: string[], env: NodeJS.ProcessEnv, out: Out, connect: () => Promise<Runtime>, me: string): Promise<number>;
19
+ /** The editors `runtime sandboxes code` opens: each one's command line program
20
+ * and the URL scheme its app registers. All three are VS Code underneath and
21
+ * name an SSH remote the same way, `ssh-remote+<host>`. */
22
+ export declare const EDITORS: {
23
+ readonly vscode: {
24
+ readonly name: "VS Code";
25
+ readonly bin: "code";
26
+ readonly scheme: "vscode";
27
+ };
28
+ readonly cursor: {
29
+ readonly name: "Cursor";
30
+ readonly bin: "cursor";
31
+ readonly scheme: "cursor";
32
+ };
33
+ readonly windsurf: {
34
+ readonly name: "Windsurf";
35
+ readonly bin: "windsurf";
36
+ readonly scheme: "windsurf";
37
+ };
38
+ };
39
+ export type EditorName = keyof typeof EDITORS;
40
+ /** What opens a sandbox's folder in an editor over the `*.runtime` ssh host:
41
+ * the editor's own program and its arguments, or, without the program on the
42
+ * PATH, the link its app answers. */
43
+ export declare function editorLaunch(editor: EditorName, sandboxId: string, path: string): {
44
+ host: string;
45
+ bin: "code" | "cursor" | "windsurf";
46
+ args: string[];
47
+ link: string;
48
+ };
49
+ /** `runtime sandboxes code <id|name> [--editor cursor] [--path /workspace] [--print]`:
50
+ * a sandbox opened in VS Code, Cursor or Windsurf in one step. It makes sure
51
+ * the sandbox has an SSH server and this machine has the `*.runtime` ssh
52
+ * entry, then hands the editor the SSH remote. */
53
+ export declare function editorCommand(argv: string[], env: NodeJS.ProcessEnv, out: Out, connect: () => Promise<Runtime>, me: string): Promise<number>;
19
54
  /** `runtime sandboxes port-forward <id> 5432 8080:3000 ...` */
20
55
  export declare function portForwardCommand(argv: string[], out: Out, connect: () => Promise<Runtime>, me: string): Promise<number>;
21
56
  export {};
package/dist/ssh.js CHANGED
@@ -11,6 +11,9 @@ ${me} sandboxes ssh config [--install] The ssh config for \`ssh <id|nam
11
11
  VS Code Remote-SSH and JetBrains Gateway; --install adds
12
12
  it to ~/.ssh/config
13
13
  ${me} sandboxes ssh proxy <id|name>[.runtime] ProxyCommand mode: SSH on stdin and stdout
14
+ ${me} sandboxes code <id|name> [--editor vscode|cursor|windsurf] [--path /workspace] [--print]
15
+ Open it in your editor over SSH; installs the ssh config
16
+ above once. --print gives the link instead
14
17
  ${me} sandboxes port-forward <id|name> <port|local:remote>... [--address 127.0.0.1]
15
18
  Forward local ports to ports in the sandbox, until Ctrl-C
16
19
 
@@ -98,7 +101,7 @@ Host *.runtime
98
101
  async function installConfig(env) {
99
102
  const file = join(configDirectory(env), "ssh", "config");
100
103
  await writeFile(file, await sshConfig(env), { mode: 0o600 });
101
- const sshDir = join(homedir(), ".ssh");
104
+ const sshDir = join(env.HOME || homedir(), ".ssh");
102
105
  await mkdir(sshDir, { recursive: true, mode: 0o700 });
103
106
  const main = join(sshDir, "config");
104
107
  const current = await readFile(main, "utf8").catch(() => "");
@@ -236,6 +239,95 @@ function usageError(message, me) {
236
239
  hint: `Run \`${me} sandboxes ssh help\`.`,
237
240
  });
238
241
  }
242
+ /** The editors `runtime sandboxes code` opens: each one's command line program
243
+ * and the URL scheme its app registers. All three are VS Code underneath and
244
+ * name an SSH remote the same way, `ssh-remote+<host>`. */
245
+ export const EDITORS = {
246
+ vscode: { name: "VS Code", bin: "code", scheme: "vscode" },
247
+ cursor: { name: "Cursor", bin: "cursor", scheme: "cursor" },
248
+ windsurf: { name: "Windsurf", bin: "windsurf", scheme: "windsurf" },
249
+ };
250
+ /** What opens a sandbox's folder in an editor over the `*.runtime` ssh host:
251
+ * the editor's own program and its arguments, or, without the program on the
252
+ * PATH, the link its app answers. */
253
+ export function editorLaunch(editor, sandboxId, path) {
254
+ const { bin, scheme } = EDITORS[editor];
255
+ const remote = `ssh-remote+${sandboxId}.runtime`;
256
+ return {
257
+ host: `${sandboxId}.runtime`,
258
+ bin,
259
+ args: ["--remote", remote, path],
260
+ link: `${scheme}://vscode-remote/${remote}${path}`,
261
+ };
262
+ }
263
+ /** Runs a program, answering whether it started and exited 0; a program
264
+ * missing from the PATH is false, not an error. */
265
+ function launched(bin, args, env) {
266
+ return new Promise((resolve) => {
267
+ const child = spawn(bin, args, {
268
+ env,
269
+ stdio: "ignore",
270
+ // `code` and `cursor` are .cmd scripts on Windows.
271
+ shell: process.platform === "win32",
272
+ });
273
+ child.on("error", () => resolve(false));
274
+ child.on("exit", (code) => resolve(code === 0));
275
+ });
276
+ }
277
+ /** `runtime sandboxes code <id|name> [--editor cursor] [--path /workspace] [--print]`:
278
+ * a sandbox opened in VS Code, Cursor or Windsurf in one step. It makes sure
279
+ * the sandbox has an SSH server and this machine has the `*.runtime` ssh
280
+ * entry, then hands the editor the SSH remote. */
281
+ export async function editorCommand(argv, env, out, connect, me) {
282
+ const valueOf = (flag) => {
283
+ const at = argv.indexOf(flag);
284
+ return at === -1 ? undefined : argv[at + 1];
285
+ };
286
+ const editor = (valueOf("--editor") ?? "vscode").toLowerCase().replace(/^code$/, "vscode");
287
+ const path = valueOf("--path") ?? "/workspace";
288
+ const valued = new Set(["--editor", "--path"]);
289
+ const [target] = argv.filter((word, i) => !word.startsWith("--") && !valued.has(argv[i - 1] ?? ""));
290
+ if (!target || target === "help") {
291
+ out.write(SSH_HELP(me));
292
+ return target ? 0 : 64;
293
+ }
294
+ if (!(editor in EDITORS))
295
+ throw new RuntimeError({
296
+ message: `${editor} is not an editor this opens.`,
297
+ code: "usage",
298
+ status: 0,
299
+ hint: `Use --editor ${Object.keys(EDITORS).join(", --editor ")}; JetBrains Gateway connects to <id>.runtime after \`${me} sandboxes ssh config --install\`.`,
300
+ });
301
+ if (!path.startsWith("/"))
302
+ throw usageError(`--path is a folder in the sandbox, from /: ${path}`, me);
303
+ const sbx = await resolveSandbox(await connect(), target);
304
+ await ensureSshd(sbx, out);
305
+ await installConfig(env);
306
+ const how = editorLaunch(editor, sbx.id, path);
307
+ const { name } = EDITORS[editor];
308
+ const print = argv.includes("--print");
309
+ // Without the editor's program on the PATH (VS Code's `code` is opt-in on a
310
+ // Mac), the desktop opens the editor's link.
311
+ const [opener, ...openerArgs] = process.platform === "darwin"
312
+ ? ["open", how.link]
313
+ : process.platform === "win32"
314
+ ? ["cmd", "/c", "start", '""', how.link]
315
+ : ["xdg-open", how.link];
316
+ const opened = print
317
+ ? false
318
+ : (await launched(how.bin, how.args, env))
319
+ ? "program"
320
+ : (await launched(opener, openerArgs, env))
321
+ ? "link"
322
+ : false;
323
+ if (out.json)
324
+ out.write(JSON.stringify({ sandboxId: sbx.id, editor, host: how.host, path, link: how.link, opened }));
325
+ else if (opened)
326
+ out.write(`Opening ${path} of ${sbx.id} in ${name} over SSH (host ${how.host}). It needs ${name === "VS Code" ? "the Remote - SSH extension" : "its Remote - SSH extension"}; the first connection installs the editor's server in the sandbox.`);
327
+ else
328
+ out.write(`${print ? "" : `Could not start ${how.bin} or open a link here. `}Open this link on this machine:\n${how.link}\nor run: ${how.bin} ${how.args.join(" ")}`);
329
+ return opened || print ? 0 : 1;
330
+ }
239
331
  /** `runtime sandboxes port-forward <id> 5432 8080:3000 ...` */
240
332
  export async function portForwardCommand(argv, out, connect, me) {
241
333
  const at = argv.indexOf("--address");