@agent-relay/sandbox 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +226 -5
  2. package/dist/agent37/client.d.ts +115 -0
  3. package/dist/agent37/client.d.ts.map +1 -0
  4. package/dist/agent37/client.js +270 -0
  5. package/dist/agent37/client.js.map +1 -0
  6. package/dist/agent37/runtime.d.ts +415 -0
  7. package/dist/agent37/runtime.d.ts.map +1 -0
  8. package/dist/agent37/runtime.js +855 -0
  9. package/dist/agent37/runtime.js.map +1 -0
  10. package/dist/agentcore/bench.d.ts +93 -0
  11. package/dist/agentcore/bench.d.ts.map +1 -0
  12. package/dist/agentcore/bench.js +125 -0
  13. package/dist/agentcore/bench.js.map +1 -0
  14. package/dist/agentcore/capabilities.d.ts +169 -0
  15. package/dist/agentcore/capabilities.d.ts.map +1 -0
  16. package/dist/agentcore/capabilities.js +153 -0
  17. package/dist/agentcore/capabilities.js.map +1 -0
  18. package/dist/agentcore/config.d.ts +156 -0
  19. package/dist/agentcore/config.d.ts.map +1 -0
  20. package/dist/agentcore/config.js +12 -0
  21. package/dist/agentcore/config.js.map +1 -0
  22. package/dist/agentcore/internal/sdk.d.ts +181 -0
  23. package/dist/agentcore/internal/sdk.d.ts.map +1 -0
  24. package/dist/agentcore/internal/sdk.js +223 -0
  25. package/dist/agentcore/internal/sdk.js.map +1 -0
  26. package/dist/agentcore/runtime.d.ts +177 -0
  27. package/dist/agentcore/runtime.d.ts.map +1 -0
  28. package/dist/agentcore/runtime.js +706 -0
  29. package/dist/agentcore/runtime.js.map +1 -0
  30. package/dist/daytona/runtime.d.ts +23 -0
  31. package/dist/daytona/runtime.d.ts.map +1 -1
  32. package/dist/daytona/runtime.js +167 -5
  33. package/dist/daytona/runtime.js.map +1 -1
  34. package/dist/daytona/wire-supplement.d.ts +25 -0
  35. package/dist/daytona/wire-supplement.d.ts.map +1 -0
  36. package/dist/daytona/wire-supplement.js +16 -0
  37. package/dist/daytona/wire-supplement.js.map +1 -0
  38. package/dist/e2b/runtime.d.ts +107 -72
  39. package/dist/e2b/runtime.d.ts.map +1 -1
  40. package/dist/e2b/runtime.js +675 -190
  41. package/dist/e2b/runtime.js.map +1 -1
  42. package/dist/freestyle/capabilities.d.ts +28 -0
  43. package/dist/freestyle/capabilities.d.ts.map +1 -0
  44. package/dist/freestyle/capabilities.js +42 -0
  45. package/dist/freestyle/capabilities.js.map +1 -0
  46. package/dist/freestyle/config.d.ts +36 -0
  47. package/dist/freestyle/config.d.ts.map +1 -0
  48. package/dist/freestyle/config.js +2 -0
  49. package/dist/freestyle/config.js.map +1 -0
  50. package/dist/freestyle/internal/sdk.d.ts +69 -0
  51. package/dist/freestyle/internal/sdk.d.ts.map +1 -0
  52. package/dist/freestyle/internal/sdk.js +19 -0
  53. package/dist/freestyle/internal/sdk.js.map +1 -0
  54. package/dist/freestyle/runtime.d.ts +163 -0
  55. package/dist/freestyle/runtime.d.ts.map +1 -0
  56. package/dist/freestyle/runtime.js +798 -0
  57. package/dist/freestyle/runtime.js.map +1 -0
  58. package/dist/index.d.ts +35 -4
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +16 -2
  61. package/dist/index.js.map +1 -1
  62. package/dist/microsandbox/runtime.d.ts +793 -0
  63. package/dist/microsandbox/runtime.d.ts.map +1 -0
  64. package/dist/microsandbox/runtime.js +2303 -0
  65. package/dist/microsandbox/runtime.js.map +1 -0
  66. package/dist/modal/bench.d.ts +199 -0
  67. package/dist/modal/bench.d.ts.map +1 -0
  68. package/dist/modal/bench.js +450 -0
  69. package/dist/modal/bench.js.map +1 -0
  70. package/dist/modal/capabilities.d.ts +180 -0
  71. package/dist/modal/capabilities.d.ts.map +1 -0
  72. package/dist/modal/capabilities.js +159 -0
  73. package/dist/modal/capabilities.js.map +1 -0
  74. package/dist/modal/config.d.ts +192 -0
  75. package/dist/modal/config.d.ts.map +1 -0
  76. package/dist/modal/config.js +169 -0
  77. package/dist/modal/config.js.map +1 -0
  78. package/dist/modal/internal/sdk.d.ts +141 -0
  79. package/dist/modal/internal/sdk.d.ts.map +1 -0
  80. package/dist/modal/internal/sdk.js +51 -0
  81. package/dist/modal/internal/sdk.js.map +1 -0
  82. package/dist/modal/runtime.d.ts +271 -0
  83. package/dist/modal/runtime.d.ts.map +1 -0
  84. package/dist/modal/runtime.js +871 -0
  85. package/dist/modal/runtime.js.map +1 -0
  86. package/dist/mount-script.d.ts +16 -1
  87. package/dist/mount-script.d.ts.map +1 -1
  88. package/dist/mount-script.js +76 -15
  89. package/dist/mount-script.js.map +1 -1
  90. package/dist/orchestrator.d.ts +15 -0
  91. package/dist/orchestrator.d.ts.map +1 -1
  92. package/dist/orchestrator.js +13 -1
  93. package/dist/orchestrator.js.map +1 -1
  94. package/dist/port.d.ts +144 -6
  95. package/dist/port.d.ts.map +1 -1
  96. package/dist/port.js +27 -5
  97. package/dist/port.js.map +1 -1
  98. package/dist/types.d.ts +50 -1
  99. package/dist/types.d.ts.map +1 -1
  100. package/dist/vercel/bench.d.ts +169 -0
  101. package/dist/vercel/bench.d.ts.map +1 -0
  102. package/dist/vercel/bench.js +337 -0
  103. package/dist/vercel/bench.js.map +1 -0
  104. package/dist/vercel/capabilities.d.ts +106 -0
  105. package/dist/vercel/capabilities.d.ts.map +1 -0
  106. package/dist/vercel/capabilities.js +128 -0
  107. package/dist/vercel/capabilities.js.map +1 -0
  108. package/dist/vercel/config.d.ts +119 -0
  109. package/dist/vercel/config.d.ts.map +1 -0
  110. package/dist/vercel/config.js +8 -0
  111. package/dist/vercel/config.js.map +1 -0
  112. package/dist/vercel/internal/sdk.d.ts +173 -0
  113. package/dist/vercel/internal/sdk.d.ts.map +1 -0
  114. package/dist/vercel/internal/sdk.js +117 -0
  115. package/dist/vercel/internal/sdk.js.map +1 -0
  116. package/dist/vercel/runtime.d.ts +305 -0
  117. package/dist/vercel/runtime.d.ts.map +1 -0
  118. package/dist/vercel/runtime.js +1169 -0
  119. package/dist/vercel/runtime.js.map +1 -0
  120. package/docs/freestyle.md +110 -0
  121. package/package.json +35 -4
@@ -0,0 +1,855 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { Agent37ApiError, Agent37Client } from "./client.js";
4
+ /**
5
+ * The provider's hard ceiling on a single command, in milliseconds. Every
6
+ * command is bounded by this whether the caller asks for it or not, which is
7
+ * what makes a `timeoutMs` of at least this value honestly satisfiable.
8
+ */
9
+ export const AGENT37_COMMAND_CAP_MS = 280_000;
10
+ /** Raised when `env` would be rejected by the provider's own create validation. */
11
+ export class Agent37EnvValidationError extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "Agent37EnvValidationError";
15
+ }
16
+ }
17
+ /**
18
+ * Raised when start / stop / destroy is handed an instance this runtime knows
19
+ * nothing about.
20
+ *
21
+ * Ownership here is *caller-declared attachment*, not in-process provenance:
22
+ * `launch` owns what it creates, and `getById` / `findAllByLabels` attach as
23
+ * unowned unless the caller passes `owned: true`. An instance that was never
24
+ * resolved through any of those is refused outright rather than mutated on a
25
+ * guess.
26
+ *
27
+ * This is deliberately louder than the Daytona adapter, which silently returns
28
+ * for an unregistered handle. A destroy that quietly does nothing reads to the
29
+ * caller as a completed teardown that never happened, which is the more
30
+ * expensive of the two failure modes.
31
+ */
32
+ export class Agent37ForeignHandleError extends Error {
33
+ constructor(id, operation) {
34
+ super(`Agent37 refused to ${operation} instance "${id}": this runtime has no ` +
35
+ "registration for it. Resolve it with getById(id, { owned: true }) to " +
36
+ "declare ownership before mutating or destroying it.");
37
+ this.name = "Agent37ForeignHandleError";
38
+ }
39
+ }
40
+ /**
41
+ * Raised when a command completes but the provider's reply carries no
42
+ * `exit_code`, so its outcome is unknown.
43
+ *
44
+ * `runScript` reports this as `exitCode: null`, which the port models. `exec`
45
+ * cannot: `ExecResult.exitCode` is a number, and the only numbers available are
46
+ * lies — `0` would report a command that may have failed as a success.
47
+ */
48
+ export class Agent37UnknownExitCodeError extends Error {
49
+ output;
50
+ constructor(id, output) {
51
+ super(`Agent37 exec on instance "${id}" returned no exit_code, so the command's ` +
52
+ "outcome is unknown; refusing to report it as success");
53
+ this.name = "Agent37UnknownExitCodeError";
54
+ this.output = output;
55
+ }
56
+ }
57
+ /**
58
+ * Raised when a caller asks for a command lifetime Agent37 cannot enforce.
59
+ *
60
+ * `timeoutMs` on the port means the command is no longer running once it
61
+ * elapses. Agent37's `exec` accepts no timeout, and aborting the HTTP request
62
+ * only abandons the response — the command keeps running inside the instance.
63
+ * The single lifetime bound Agent37 genuinely enforces is its own
64
+ * {@link AGENT37_COMMAND_CAP_MS} cap, so anything shorter is refused instead of
65
+ * being faked with an `AbortSignal`.
66
+ *
67
+ * Callers that want to stop *waiting* (as opposed to stopping the command) want
68
+ * `requestTimeoutMs`, which is named for what it actually does.
69
+ */
70
+ export class Agent37CommandTimeoutUnsupportedError extends Error {
71
+ constructor(timeoutMs) {
72
+ super(`Agent37 cannot enforce a ${timeoutMs}ms command timeout: exec takes no ` +
73
+ `timeout, and aborting the request leaves the command running. The only ` +
74
+ `lifetime bound is the provider's own ${AGENT37_COMMAND_CAP_MS}ms cap, so ` +
75
+ `timeoutMs must be at least that. To bound how long this client waits ` +
76
+ `— which does not stop the command — use requestTimeoutMs instead.`);
77
+ this.name = "Agent37CommandTimeoutUnsupportedError";
78
+ }
79
+ }
80
+ /**
81
+ * Raised when a 2xx reply does not carry the shape the endpoint documents.
82
+ *
83
+ * The alternative is worse than an error, because a tolerant decode turns a
84
+ * broken response into a *confident negative*: `GET /v1/instances` answering
85
+ * with HTML from a proxy would read as "no instance matches these labels",
86
+ * which is the exact answer that makes a caller abandon a running warm lease
87
+ * and pay to launch a replacement. Similarly, a create reply with no `id`
88
+ * would register ownership under `undefined` and aim a later DELETE at a
89
+ * garbage path.
90
+ *
91
+ * So the adapter fails closed: an unusable reply is reported as unusable, and
92
+ * the caller retries or escalates instead of acting on a fiction. Only `id` and
93
+ * `status` are required to be non-empty strings — an *unrecognized* status is
94
+ * forward compatibility, not corruption, and {@link normalizeStatus} already
95
+ * treats anything but `running` as not ready.
96
+ */
97
+ export class Agent37MalformedResponseError extends Error {
98
+ /** Method and path only, matching {@link Agent37ApiError.request}. */
99
+ request;
100
+ constructor(request, detail) {
101
+ super(`Agent37 ${request} returned a malformed 2xx response: ${detail}`);
102
+ this.name = "Agent37MalformedResponseError";
103
+ this.request = request;
104
+ }
105
+ }
106
+ /**
107
+ * Raised when a caller asks `launch` to bound how long instance creation may
108
+ * take.
109
+ *
110
+ * HOLD — not implementable against the documented contract. `POST
111
+ * /v1/instances` is synchronous: it returns 201 only once the instance reports
112
+ * `running`. Abandoning that request client-side does not un-allocate anything
113
+ * the provider has already started building, and the reply that would have
114
+ * carried the new instance's `id` is discarded with it. The result is an
115
+ * orphan the caller cannot name, let alone delete — a billed leak dressed up
116
+ * as a timeout.
117
+ *
118
+ * Cleaning up afterwards would need one of three things the API does not
119
+ * document: an idempotency key to correlate a retry with the original create,
120
+ * a create call that returns an id before the instance is ready, or a
121
+ * guarantee about whether a still-provisioning instance appears in `GET
122
+ * /v1/instances` (and carries the `metadata` it was created with) so a bounded
123
+ * sweep could identify exactly the one that was abandoned. Sweeping on labels
124
+ * alone is not a substitute: labels are reused precisely so warm leases can be
125
+ * found by them, so a sweep could delete a healthy instance belonging to
126
+ * someone else.
127
+ *
128
+ * Verifying any of that needs live calls against the provider, which this work
129
+ * is not authorized to make. Until it is verified, the option is refused rather
130
+ * than shipped as an `AbortSignal` that reads like a cancellation and is not
131
+ * one.
132
+ *
133
+ * The refusal is purely a runtime one, and deliberately so. `createTimeoutSeconds`
134
+ * lives on the shared `LaunchOptions` and on `SandboxRuntime.launch` because
135
+ * other providers honour it, so it typechecks against this adapter too and no
136
+ * compile error is available to lean on. Narrowing this one signature would not
137
+ * change that — TypeScript compares method parameters bivariantly, so a caller
138
+ * holding a `SandboxRuntime` reference could still pass the option — and it
139
+ * would trade a real guarantee for the appearance of one while breaking the
140
+ * substitutability the port exists to provide.
141
+ *
142
+ * Callers that need a bound should launch with labels unique to the attempt,
143
+ * apply their own deadline around `launch`, and reconcile with
144
+ * `findAllByLabels(..., { owned: true })` — a recovery they can reason about,
145
+ * because they chose the labels.
146
+ */
147
+ export class Agent37CreateTimeoutUnsupportedError extends Error {
148
+ constructor() {
149
+ super("Agent37 cannot bound instance creation: POST /v1/instances returns only " +
150
+ "once the instance is running, and aborting that request neither stops " +
151
+ "the allocation nor yields the id needed to delete it, so the timeout " +
152
+ "would leak a billed orphan. The API documents no idempotency key and no " +
153
+ "way to identify an abandoned create, so no cleanup is possible either. " +
154
+ "Apply your own deadline around launch(), using labels unique to the " +
155
+ "attempt, and reconcile with findAllByLabels(labels, { owned: true }).");
156
+ this.name = "Agent37CreateTimeoutUnsupportedError";
157
+ }
158
+ }
159
+ // The provider documents `env` as at most 32 entries, keys of uppercase
160
+ // letters, digits, and underscores starting with a letter, values up to 4096
161
+ // characters. Checking locally turns a 400 after a round trip into an
162
+ // immediate, specific error — and, more importantly, keeps a malformed key
163
+ // from ever being interpolated into a shell.
164
+ const ENV_MAX_ENTRIES = 32;
165
+ const ENV_MAX_VALUE_LENGTH = 4096;
166
+ const ENV_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
167
+ const DEFAULT_EXEC_UPLOAD_MAX_BYTES = 262_144; // 256 KiB
168
+ export class Agent37Runtime {
169
+ id = "agent37";
170
+ /**
171
+ * Bootstrap-plane capabilities (the live in-sandbox session), distinct from
172
+ * `SandboxRuntimeCapabilities`.
173
+ *
174
+ * `pty: false` and `streamingLogs: false` are facts, not caution: `exec` is a
175
+ * single buffered request/response with no TTY, and the one log endpoint
176
+ * (`GET /v1/instances/{id}/logs`) returns a snapshot with a `tail` count, not
177
+ * a stream. `snapshots: false` likewise — templates are built from a
178
+ * Dockerfile ahead of time; there is no documented call that captures a
179
+ * running instance as a reusable image.
180
+ */
181
+ capabilities = {
182
+ pty: false,
183
+ snapshots: false,
184
+ isolation: "strong",
185
+ persistentHandle: true,
186
+ streamingLogs: false,
187
+ };
188
+ /**
189
+ * `lifecycle: true` because `start` and `stop` are real hosting-plane calls
190
+ * that change `status`, not the no-ops some providers ship.
191
+ *
192
+ * `warmLease: true` because label lookup returns real matches. `GET
193
+ * /v1/instances` documents no server-side filter, so the filtering happens
194
+ * here — but it happens over a complete, unpaginated list, which makes an
195
+ * empty result mean "no match" and nothing else. That is the ambiguity the
196
+ * capability exists to rule out, and it is ruled out.
197
+ *
198
+ * `asyncExec` is not declared here; it is derived from method presence by
199
+ * `resolveSandboxRuntimeCapabilities`. Async exec is absent from this adapter
200
+ * because nohup+/tmp emulation cannot provide durable, idempotent guarantees.
201
+ */
202
+ declaredCapabilities = { warmLease: true, lifecycle: true };
203
+ client;
204
+ defaultHomeDir;
205
+ template;
206
+ resources;
207
+ instanceType;
208
+ budget;
209
+ autoSleep;
210
+ idleTimeoutSeconds;
211
+ publicPorts;
212
+ user;
213
+ execUploadMaxBytes;
214
+ // An instance's own origin is only knowable from its instance object. Cache
215
+ // what has been seen so the file plane does not re-GET on every transfer.
216
+ instanceUrls = new Map();
217
+ // What this runtime has been told about each instance it has resolved:
218
+ // `true` for one it launched or that the caller explicitly claimed, `false`
219
+ // for a plain attachment. Absent means "never seen", which is refused rather
220
+ // than mutated. Deliberately not a provenance set — a process that crashed
221
+ // and restarted launched nothing, yet must still be able to delete its own
222
+ // instances, which it does by resolving them with `owned: true`.
223
+ ownership = new Map();
224
+ constructor(options) {
225
+ if (!options.defaultHomeDir?.trim()) {
226
+ throw new Error("Agent37Runtime requires an explicit defaultHomeDir: it is template-specific");
227
+ }
228
+ this.client = options.client ?? new Agent37Client(toClientOptions(options));
229
+ this.defaultHomeDir = options.defaultHomeDir.trim();
230
+ if (options.template !== undefined) {
231
+ this.template = options.template;
232
+ }
233
+ if (options.resources !== undefined) {
234
+ this.resources = options.resources;
235
+ }
236
+ if (options.type !== undefined) {
237
+ this.instanceType = options.type;
238
+ }
239
+ if (options.budget !== undefined) {
240
+ this.budget = options.budget;
241
+ }
242
+ if (options.autoSleep !== undefined) {
243
+ this.autoSleep = options.autoSleep;
244
+ }
245
+ if (options.idleTimeoutSeconds !== undefined) {
246
+ this.idleTimeoutSeconds = options.idleTimeoutSeconds;
247
+ }
248
+ if (options.publicPorts !== undefined) {
249
+ this.publicPorts = options.publicPorts;
250
+ }
251
+ if (options.user !== undefined) {
252
+ this.user = options.user;
253
+ }
254
+ this.execUploadMaxBytes = options.execUploadMaxBytes ?? DEFAULT_EXEC_UPLOAD_MAX_BYTES;
255
+ }
256
+ // --- discovery ----------------------------------------------------------
257
+ async findByLabels(labels, options = {}) {
258
+ const excluded = new Set(options.excludeIds ?? []);
259
+ const handles = await this.findAllByLabels(labels, options);
260
+ return handles.find((handle) => !excluded.has(handle.id)) ?? null;
261
+ }
262
+ async findAllByLabels(labels, options = {}) {
263
+ const states = options.states === undefined ? ["STARTED"] : options.states;
264
+ const limit = options.limit ?? options.pageSize;
265
+ // A non-positive cap means "zero handles wanted": the post-push break at
266
+ // the loop tail runs one iteration late, so limit:0 leaked one handle. Fold
267
+ // that into the pre-fetch short circuit — no lookup, no ownership claim.
268
+ if (limit !== undefined && limit <= 0) {
269
+ return [];
270
+ }
271
+ const excluded = new Set(options.excludeIds ?? []);
272
+ const instances = await this.listInstances(options.timeoutMs);
273
+ const handles = [];
274
+ for (const instance of instances) {
275
+ if (excluded.has(instance.id)) {
276
+ continue;
277
+ }
278
+ if (!matchesLabels(instance, labels)) {
279
+ continue;
280
+ }
281
+ if (!matchesState(instance.status, states)) {
282
+ continue;
283
+ }
284
+ this.declareOwnership(instance.id, options.owned === true);
285
+ handles.push(this.registerInstance(instance));
286
+ if (limit !== undefined && handles.length >= limit) {
287
+ break;
288
+ }
289
+ }
290
+ return handles;
291
+ }
292
+ async countByLabels(labels, options = {}) {
293
+ const maxCount = options.maxCount === undefined
294
+ ? Number.POSITIVE_INFINITY
295
+ : Math.max(0, Math.floor(options.maxCount));
296
+ if (maxCount === 0) {
297
+ return 0;
298
+ }
299
+ const lookup = {
300
+ ...(options.states === undefined ? {} : { states: options.states }),
301
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
302
+ ...(Number.isFinite(maxCount) ? { limit: maxCount } : {}),
303
+ };
304
+ const handles = await this.findAllByLabels(labels, lookup);
305
+ return handles.length;
306
+ }
307
+ async getById(id, options = {}) {
308
+ const instance = await this.fetchInstance(id);
309
+ if (!instance) {
310
+ return null;
311
+ }
312
+ // `states: undefined` means "no filter" on reattach: a caller resolving a
313
+ // known id wants the instance whatever state it is in, so it can decide
314
+ // whether to start it. That is the opposite of the label search default,
315
+ // which is looking for something ready to use right now.
316
+ const states = options.states === undefined ? null : options.states;
317
+ if (!matchesState(instance.status, states)) {
318
+ return null;
319
+ }
320
+ // `owned: true` is the caller asserting this instance is theirs — the one
321
+ // way a restarted process reacquires the right to tear down what a previous
322
+ // run created.
323
+ this.declareOwnership(id, options.owned === true);
324
+ const handle = this.registerInstance(instance);
325
+ return {
326
+ ...handle,
327
+ ...(options.homeDir ? { homeDir: options.homeDir } : {}),
328
+ ...(options.workdir ? { workdir: options.workdir } : {}),
329
+ };
330
+ }
331
+ // --- lifecycle ----------------------------------------------------------
332
+ async launch(options = {}) {
333
+ // Refused before anything is sent. This is the *only* line that stops the
334
+ // option, because it is part of the shared cross-provider `LaunchOptions`
335
+ // and so typechecks against `launch` and against the `SandboxRuntime`
336
+ // interface alike. Silently dropping it would leave a caller that had
337
+ // configured a creation bound believing it was in force.
338
+ if (options.createTimeoutSeconds !== undefined) {
339
+ throw new Agent37CreateTimeoutUnsupportedError();
340
+ }
341
+ const env = options.env;
342
+ if (env) {
343
+ assertValidEnv(env);
344
+ }
345
+ // The singular `label` from LaunchOptions populates a "label" metadata key.
346
+ // Explicit `labels` entries merge on top and take precedence if both are provided.
347
+ const rawMetadata = {};
348
+ if (options.label !== undefined) {
349
+ rawMetadata["label"] = options.label;
350
+ }
351
+ if (options.labels) {
352
+ Object.assign(rawMetadata, options.labels);
353
+ }
354
+ // Reap-ephemeral contract (see LaunchOptions docs and
355
+ // scripts/reap-ephemeral.ts). Persisted under reserved metadata keys so the
356
+ // out-of-process reaper can identify safe-to-reap leaks after a caller
357
+ // crash. Written LAST so caller labels cannot shadow them.
358
+ if (options.attributionTag !== undefined) {
359
+ rawMetadata["_sandbox.attributionTag"] = options.attributionTag;
360
+ }
361
+ if (options.ephemeralUntil !== undefined) {
362
+ rawMetadata["_sandbox.ephemeralUntil"] = String(typeof options.ephemeralUntil === "number"
363
+ ? options.ephemeralUntil
364
+ : Date.parse(options.ephemeralUntil));
365
+ }
366
+ const metadata = Object.keys(rawMetadata).length > 0 ? rawMetadata : undefined;
367
+ const body = {
368
+ ...(this.template === undefined ? {} : { template: this.template }),
369
+ ...(this.resources === undefined ? {} : { resources: this.resources }),
370
+ ...(this.instanceType === undefined ? {} : { type: this.instanceType }),
371
+ ...(this.budget === undefined ? {} : { budget: this.budget }),
372
+ ...(this.autoSleep === undefined ? {} : { auto_sleep: this.autoSleep }),
373
+ ...(this.idleTimeoutSeconds === undefined
374
+ ? {}
375
+ : { idle_timeout_seconds: this.idleTimeoutSeconds }),
376
+ ...(this.publicPorts === undefined ? {} : { public_ports: this.publicPorts }),
377
+ ...(this.user === undefined ? {} : { user: this.user }),
378
+ ...(options.name === undefined ? {} : { name: options.name }),
379
+ ...(metadata ? { metadata } : {}),
380
+ ...(env && Object.keys(env).length > 0 ? { env } : {}),
381
+ };
382
+ const payload = await this.client.hosting("POST", "/v1/instances", { body });
383
+ // Validated before the id is recorded. Registering ownership off an
384
+ // unusable reply would key it on `undefined` and aim a later DELETE at
385
+ // `/v1/instances/undefined`.
386
+ const instance = parseInstance(payload, "POST /v1/instances");
387
+ this.declareOwnership(instance.id, true);
388
+ const handle = this.registerInstance(instance);
389
+ return {
390
+ ...handle,
391
+ homeDir: this.defaultHomeDir,
392
+ ...(options.workdir ? { workdir: options.workdir } : {}),
393
+ };
394
+ }
395
+ async start(handle) {
396
+ if (!this.requireOwnership(handle.id, "start")) {
397
+ // Attached, not owned: the instance's lifecycle belongs to whoever does
398
+ // own it. Returning the handle unchanged is honest; claiming STARTED
399
+ // would not be.
400
+ return handle;
401
+ }
402
+ const request = `POST /v1/instances/${handle.id}/start`;
403
+ const ack = await this.client.hosting("POST", `/v1/instances/${encodeURIComponent(handle.id)}/start`);
404
+ // An ack with no status must not be normalized: `normalizeStatus(undefined)`
405
+ // is STOPPED, which would report a successful start as a failed one.
406
+ const status = readNonBlankString(ack, "status");
407
+ if (status === null) {
408
+ throw new Agent37MalformedResponseError(request, "no `status` on the start acknowledgement");
409
+ }
410
+ return { ...handle, state: normalizeStatus(status) };
411
+ }
412
+ async stop(handle) {
413
+ if (!this.requireOwnership(handle.id, "stop")) {
414
+ return;
415
+ }
416
+ await this.client.hosting("POST", `/v1/instances/${encodeURIComponent(handle.id)}/stop`);
417
+ }
418
+ async destroy(handle) {
419
+ if (!this.requireOwnership(handle.id, "destroy")) {
420
+ // Destroying an attachment releases this runtime's reference to it and
421
+ // nothing else. The instance is caller-managed, so deleting it here would
422
+ // be destroying someone else's sandbox.
423
+ this.forget(handle.id);
424
+ return;
425
+ }
426
+ try {
427
+ await this.client.hosting("DELETE", `/v1/instances/${encodeURIComponent(handle.id)}`);
428
+ }
429
+ catch (error) {
430
+ // Delete acts once: a repeat returns 404. An already-gone instance is
431
+ // the caller's desired end state, so absorb it rather than making every
432
+ // teardown path handle a race it cannot prevent.
433
+ if (isNotFound(error)) {
434
+ this.forget(handle.id);
435
+ return;
436
+ }
437
+ // Order matters: the registration is dropped only on a terminal outcome.
438
+ // Forgetting first would strip ownership from a handle whose delete has
439
+ // not happened yet, leaving the caller unable to retry its own teardown.
440
+ throw error;
441
+ }
442
+ this.forget(handle.id);
443
+ }
444
+ // --- execution ----------------------------------------------------------
445
+ async exec(handle, command, options = {}) {
446
+ const result = await this.runScript(handle, {
447
+ command,
448
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
449
+ ...(options.env === undefined ? {} : { env: options.env }),
450
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
451
+ ...(options.requestTimeoutMs === undefined
452
+ ? {}
453
+ : { requestTimeoutMs: options.requestTimeoutMs }),
454
+ });
455
+ if (result.exitCode === null) {
456
+ throw new Agent37UnknownExitCodeError(handle.id, result.output);
457
+ }
458
+ return {
459
+ output: result.output,
460
+ exitCode: result.exitCode,
461
+ ...(result.truncated === true ? { truncated: true } : {}),
462
+ };
463
+ }
464
+ async runScript(handle, options) {
465
+ assertHonourableCommandTimeout(options.timeoutMs);
466
+ if (options.env) {
467
+ assertValidEnv(options.env);
468
+ }
469
+ const cwd = options.cwd ?? handle.workdir;
470
+ const script = composeScript(options.command, {
471
+ ...(cwd ? { cwd } : {}),
472
+ ...(options.env ? { env: options.env } : {}),
473
+ });
474
+ // Only `requestTimeoutMs` becomes an abort signal. `timeoutMs` is a command
475
+ // lifetime, and it got here only because the provider's own cap already
476
+ // satisfies it — turning it into an HTTP abort would abandon the response
477
+ // while the command ran on.
478
+ const result = await this.execRaw(handle.id, script, options.requestTimeoutMs);
479
+ return {
480
+ output: combineOutput(result.stdout, result.stderr),
481
+ ...(result.stdout ? { stdout: result.stdout } : {}),
482
+ ...(result.stderr ? { stderr: result.stderr } : {}),
483
+ exitCode: result.exit_code,
484
+ ...(result.truncated === true ? { truncated: true } : {}),
485
+ };
486
+ }
487
+ // --- files --------------------------------------------------------------
488
+ async uploadBundle(handle, options) {
489
+ for (const file of options.files) {
490
+ await this.uploadFile(handle, file.source, file.destination);
491
+ }
492
+ }
493
+ async uploadFile(handle, source, destination) {
494
+ const bytes = typeof source === "string" ? Buffer.from(source, "utf8") : source;
495
+ const instanceUrl = await this.resolveInstanceUrl(handle.id);
496
+ if (instanceUrl) {
497
+ await this.client.instance(instanceUrl, "PUT", "/v1/files/content", {
498
+ query: { path: destination },
499
+ body: new Uint8Array(bytes),
500
+ contentType: "application/octet-stream",
501
+ });
502
+ return;
503
+ }
504
+ // A template registered with no ports has no URL of its own, so the file
505
+ // plane is unreachable and the only way in is through the hosting plane's
506
+ // exec. That path carries the payload inside a shell command, so it is
507
+ // bounded rather than silently attempted at any size.
508
+ if (bytes.byteLength > this.execUploadMaxBytes) {
509
+ throw new Error(`Agent37 instance "${handle.id}" exposes no URL, so uploads fall back to exec, ` +
510
+ `which is capped at ${this.execUploadMaxBytes} bytes; "${destination}" is ` +
511
+ `${bytes.byteLength} bytes. Register the template with a port to use the file API.`);
512
+ }
513
+ const dir = posixDirname(destination);
514
+ const command = [
515
+ ...(dir ? [`mkdir -p ${shellQuote(dir)}`] : []),
516
+ writeFileViaBase64(destination, bytes),
517
+ ].join("\n");
518
+ const result = await this.execRaw(handle.id, command);
519
+ if (result.exit_code !== 0) {
520
+ throw new Error(`Agent37 exec upload of "${destination}" failed (exit ${result.exit_code}): ${combineOutput(result.stdout, result.stderr).slice(0, 2000)}`);
521
+ }
522
+ }
523
+ async downloadFile(handle, source, destination) {
524
+ const instanceUrl = await this.resolveInstanceUrl(handle.id);
525
+ const buffer = instanceUrl
526
+ ? Buffer.from(await this.client.instanceBytes(instanceUrl, "GET", "/v1/files/content", {
527
+ query: { path: source, disposition: "attachment" },
528
+ }))
529
+ : await this.downloadFileViaExec(handle.id, source);
530
+ if (destination === undefined) {
531
+ return buffer;
532
+ }
533
+ await writeFile(destination, buffer);
534
+ }
535
+ /**
536
+ * Read-side counterpart to `uploadFile`'s exec fallback: templates registered
537
+ * with no ports have no URL of their own, so the file plane is unreachable
538
+ * and the only way out is through the hosting plane's exec. The payload is
539
+ * carried inside a bounded shell script that first sizes the file, refuses
540
+ * anything larger than `execUploadMaxBytes` (the same cap as the upload
541
+ * path), then emits base64 on stdout. Truncated exec output is rejected
542
+ * rather than decoded — a truncated base64 stream would decode into the
543
+ * wrong bytes without error.
544
+ */
545
+ async downloadFileViaExec(id, source) {
546
+ const cap = this.execUploadMaxBytes;
547
+ const quoted = shellQuote(source);
548
+ const script = [
549
+ `set -e`,
550
+ `if [ ! -f ${quoted} ]; then echo "agent37: missing file" >&2; exit 66; fi`,
551
+ `size=$(wc -c < ${quoted})`,
552
+ `if [ "$size" -gt ${cap} ]; then`,
553
+ ` echo "agent37: file exceeds exec download cap of ${cap} bytes ($size bytes)" >&2`,
554
+ ` exit 67`,
555
+ `fi`,
556
+ // `base64 -w0` on GNU coreutils avoids the 76-column wrap. macOS/BSD
557
+ // `base64` has no `-w` but also does not wrap unless `-b` is given, so a
558
+ // graceful fallback keeps both platforms honest without try/catch.
559
+ `if base64 -w0 < ${quoted} 2>/dev/null; then :; else base64 < ${quoted}; fi`,
560
+ ].join("\n");
561
+ const result = await this.execRaw(id, script);
562
+ if (result.truncated) {
563
+ throw new Error(`Agent37 exec download of "${source}" was truncated by the exec plane; ` +
564
+ "register the template with a port to use the file API.");
565
+ }
566
+ if (result.exit_code !== 0) {
567
+ throw new Error(`Agent37 exec download of "${source}" failed (exit ${result.exit_code}): ${combineOutput(result.stdout, result.stderr).slice(0, 2000)}`);
568
+ }
569
+ // `base64` prints only the payload on stdout; ignore stderr (empty on
570
+ // success). Any whitespace is harmless — `Buffer.from(..., "base64")` skips
571
+ // characters outside the base64 alphabet.
572
+ return Buffer.from(result.stdout, "base64");
573
+ }
574
+ async getHomeDir(handle) {
575
+ return handle.homeDir ?? this.defaultHomeDir;
576
+ }
577
+ // --- diagnostics --------------------------------------------------------
578
+ /**
579
+ * Container boot and runtime logs. Unlike `exec`, this works in any state,
580
+ * which is what makes it the tool for an instance that will not stay up.
581
+ */
582
+ async getContainerLogs(handle, options = {}) {
583
+ return this.client.hosting("GET", `/v1/instances/${encodeURIComponent(handle.id)}/logs`, {
584
+ ...(options.tail === undefined ? {} : { query: { tail: String(options.tail) } }),
585
+ });
586
+ }
587
+ // --- internals ----------------------------------------------------------
588
+ /**
589
+ * Record what the caller declared about an instance.
590
+ *
591
+ * Monotonic on purpose: once ownership is claimed it survives later read-only
592
+ * resolutions of the same id. A plain `findAllByLabels` sweep listing an
593
+ * instance must not quietly revoke the delete rights a caller already
594
+ * declared through `getById(id, { owned: true })`.
595
+ */
596
+ declareOwnership(id, owned) {
597
+ if (owned) {
598
+ this.ownership.set(id, true);
599
+ return;
600
+ }
601
+ if (!this.ownership.has(id)) {
602
+ this.ownership.set(id, false);
603
+ }
604
+ }
605
+ /**
606
+ * Resolve what may be done to `id`: `true` to mutate it, `false` for an
607
+ * attachment this runtime must leave alone. Throws when the id was never
608
+ * registered, because that is a caller mistake rather than a policy decision.
609
+ */
610
+ requireOwnership(id, operation) {
611
+ const owned = this.ownership.get(id);
612
+ if (owned === undefined) {
613
+ throw new Agent37ForeignHandleError(id, operation);
614
+ }
615
+ return owned;
616
+ }
617
+ /** Drop every trace of an instance this runtime is done with. */
618
+ forget(id) {
619
+ this.ownership.delete(id);
620
+ this.instanceUrls.delete(id);
621
+ }
622
+ async execRaw(id, command, timeoutMs) {
623
+ const response = await this.client.hosting("POST", `/v1/instances/${encodeURIComponent(id)}/exec`, {
624
+ body: { command },
625
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
626
+ });
627
+ return {
628
+ // A response that omits `exit_code` is an unknown outcome, never a
629
+ // success. Defaulting it to 0 would let a malformed reply read as a
630
+ // command that passed.
631
+ exit_code: typeof response.exit_code === "number" ? response.exit_code : null,
632
+ stdout: response.stdout ?? "",
633
+ stderr: response.stderr ?? "",
634
+ ...(response.truncated === undefined ? {} : { truncated: response.truncated }),
635
+ };
636
+ }
637
+ async listInstances(timeoutMs) {
638
+ const request = "GET /v1/instances";
639
+ const payload = await this.client.hosting("GET", "/v1/instances", {
640
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
641
+ });
642
+ // `{ "data": [...] }` is the documented envelope. Anything else is a broken
643
+ // response, and coercing it to `[]` would answer "no warm lease" — the one
644
+ // answer that makes a caller discard a running instance and launch another.
645
+ // An empty `data` array still means exactly that, honestly.
646
+ if (!isPlainObject(payload) || !Array.isArray(payload["data"])) {
647
+ throw new Agent37MalformedResponseError(request, "no `data` array on the instance list");
648
+ }
649
+ return payload["data"].map((entry, index) => parseInstance(entry, `${request}[${index}]`));
650
+ }
651
+ async fetchInstance(id) {
652
+ try {
653
+ const payload = await this.client.hosting("GET", `/v1/instances/${encodeURIComponent(id)}`);
654
+ return parseInstance(payload, `GET /v1/instances/${id}`);
655
+ }
656
+ catch (error) {
657
+ if (isNotFound(error)) {
658
+ return null;
659
+ }
660
+ throw error;
661
+ }
662
+ }
663
+ async resolveInstanceUrl(id) {
664
+ const cached = this.instanceUrls.get(id);
665
+ if (cached) {
666
+ return cached;
667
+ }
668
+ const instance = await this.fetchInstance(id);
669
+ if (!instance) {
670
+ throw new Error(`Agent37 instance "${id}" is no longer available`);
671
+ }
672
+ this.registerInstance(instance);
673
+ return this.instanceUrls.get(id) ?? null;
674
+ }
675
+ registerInstance(instance) {
676
+ if (instance.url) {
677
+ this.instanceUrls.set(instance.id, instance.url.replace(/\/+$/, ""));
678
+ }
679
+ return {
680
+ id: instance.id,
681
+ state: normalizeStatus(instance.status),
682
+ ...(typeof instance.created === "number"
683
+ ? { createdAt: new Date(instance.created * 1000).toISOString() }
684
+ : {}),
685
+ };
686
+ }
687
+ }
688
+ // --- helpers --------------------------------------------------------------
689
+ function toClientOptions(options) {
690
+ return {
691
+ apiKey: options.apiKey,
692
+ baseUrl: options.baseUrl,
693
+ ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
694
+ ...(options.maxAttempts === undefined ? {} : { maxAttempts: options.maxAttempts }),
695
+ ...(options.retryBaseDelayMs === undefined
696
+ ? {}
697
+ : { retryBaseDelayMs: options.retryBaseDelayMs }),
698
+ ...(options.sleep === undefined ? {} : { sleep: options.sleep }),
699
+ };
700
+ }
701
+ /**
702
+ * Map a provider status onto the normalized vocabulary the delivery path
703
+ * reasons about.
704
+ *
705
+ * Only `running` is `STARTED`. Every transitional status — `provisioning`,
706
+ * `starting`, `waking`, `restarting`, `updating` — reads as `STOPPED`, so the
707
+ * default `["STARTED"]` filter can never hand a caller an instance that is not
708
+ * yet able to accept an exec.
709
+ */
710
+ export function normalizeStatus(status) {
711
+ return status === "running" ? "STARTED" : "STOPPED";
712
+ }
713
+ function matchesState(status, states) {
714
+ if (states === null) {
715
+ return true;
716
+ }
717
+ return states.includes(normalizeStatus(status));
718
+ }
719
+ function matchesLabels(instance, labels) {
720
+ const entries = Object.entries(labels);
721
+ if (entries.length === 0) {
722
+ return true;
723
+ }
724
+ const metadata = instance.metadata;
725
+ if (!metadata) {
726
+ return false;
727
+ }
728
+ return entries.every(([key, value]) => metadata[key] === value);
729
+ }
730
+ function isPlainObject(value) {
731
+ return typeof value === "object" && value !== null && !Array.isArray(value);
732
+ }
733
+ /**
734
+ * The property as a non-blank string, or `null` if it is anything else.
735
+ *
736
+ * Blank, not merely empty: an id of `" "` is exactly as unusable as `""`,
737
+ * but it would survive an `!== ""` check and go on to address
738
+ * `/v1/instances/%20%20%20`. The value is returned unmodified rather than
739
+ * trimmed — a provider id is opaque, so refusing a blank one is this
740
+ * function's business while rewriting a non-blank one is not.
741
+ */
742
+ function readNonBlankString(value, key) {
743
+ if (!isPlainObject(value)) {
744
+ return null;
745
+ }
746
+ const field = value[key];
747
+ return typeof field === "string" && field.trim() !== "" ? field : null;
748
+ }
749
+ /**
750
+ * Accept a 2xx body as an instance object, or refuse it.
751
+ *
752
+ * Only `id` and `status` are checked, and only for being non-blank strings.
753
+ * That is the whole load-bearing minimum: `id` is what every subsequent call
754
+ * addresses and what ownership is keyed on, and `status` is what decides
755
+ * whether the instance is handed out as ready. Every other field is optional in
756
+ * the contract and already treated as such. Notably the *value* of `status` is
757
+ * not constrained to the known set — a status this adapter has not heard of is
758
+ * the provider moving forward, and {@link normalizeStatus} maps anything but
759
+ * `running` to STOPPED, so an unknown one is already handled conservatively.
760
+ */
761
+ function parseInstance(value, request) {
762
+ const id = readNonBlankString(value, "id");
763
+ if (id === null) {
764
+ throw new Agent37MalformedResponseError(request, "no `id` on the instance object");
765
+ }
766
+ const status = readNonBlankString(value, "status");
767
+ if (status === null) {
768
+ throw new Agent37MalformedResponseError(request, `no \`status\` on instance "${id}"`);
769
+ }
770
+ return { ...value, id, status: status };
771
+ }
772
+ function isNotFound(error) {
773
+ return error instanceof Agent37ApiError && (error.status === 404 || error.code === "not_found");
774
+ }
775
+ /**
776
+ * Reject a command lifetime Agent37 cannot deliver.
777
+ *
778
+ * Anything at or above the provider's cap is genuinely satisfied — the command
779
+ * cannot outlive the cap, so it certainly cannot outlive a longer deadline.
780
+ * Anything below it would need real cancellation, which `exec` does not offer.
781
+ */
782
+ export function assertHonourableCommandTimeout(timeoutMs) {
783
+ if (timeoutMs !== undefined && timeoutMs < AGENT37_COMMAND_CAP_MS) {
784
+ throw new Agent37CommandTimeoutUnsupportedError(timeoutMs);
785
+ }
786
+ }
787
+ export function assertValidEnv(env) {
788
+ const keys = Object.keys(env);
789
+ if (keys.length > ENV_MAX_ENTRIES) {
790
+ throw new Agent37EnvValidationError(`Agent37 accepts at most ${ENV_MAX_ENTRIES} env entries; received ${keys.length}`);
791
+ }
792
+ for (const key of keys) {
793
+ if (!ENV_KEY_RE.test(key)) {
794
+ // The name is safe to quote (it failed a character-class test, so it is
795
+ // reported as-is); the value never appears in the message.
796
+ throw new Agent37EnvValidationError(`Agent37 env name ${JSON.stringify(key)} is not a valid shell identifier`);
797
+ }
798
+ const value = env[key];
799
+ if (typeof value !== "string") {
800
+ throw new Agent37EnvValidationError(`Agent37 env value for ${JSON.stringify(key)} must be a string`);
801
+ }
802
+ if (value.length > ENV_MAX_VALUE_LENGTH) {
803
+ throw new Agent37EnvValidationError(`Agent37 env value for ${JSON.stringify(key)} exceeds ${ENV_MAX_VALUE_LENGTH} characters`);
804
+ }
805
+ }
806
+ }
807
+ /**
808
+ * Build the shell program for one run.
809
+ *
810
+ * `exec` accepts a command and nothing else — no cwd, no env, no timeout — so
811
+ * both are expressed in the program itself. Values are single-quoted, and a
812
+ * failed `cd` aborts rather than silently running the command in the wrong
813
+ * directory.
814
+ */
815
+ export function composeScript(command, options = {}) {
816
+ const lines = [];
817
+ if (options.cwd) {
818
+ lines.push(`cd ${shellQuote(options.cwd)} || exit 1`);
819
+ }
820
+ for (const [key, value] of Object.entries(options.env ?? {})) {
821
+ lines.push(`export ${key}=${shellQuote(value)}`);
822
+ }
823
+ lines.push(command);
824
+ return `${lines.join("\n")}\n`;
825
+ }
826
+ /**
827
+ * A shell line that materializes `contents` at `path`.
828
+ *
829
+ * The payload is base64, whose alphabet cannot escape a single-quoted word, so
830
+ * no caller-supplied byte is ever parsed as shell syntax.
831
+ */
832
+ function writeFileViaBase64(path, contents) {
833
+ const encoded = (typeof contents === "string" ? Buffer.from(contents, "utf8") : contents)
834
+ .toString("base64");
835
+ return `printf %s '${encoded}' | base64 -d > ${shellQuote(path)}`;
836
+ }
837
+ function posixDirname(path) {
838
+ const index = path.lastIndexOf("/");
839
+ if (index <= 0) {
840
+ return "";
841
+ }
842
+ return path.slice(0, index);
843
+ }
844
+ function combineOutput(stdout, stderr) {
845
+ if (stdout && stderr) {
846
+ return stdout.endsWith("\n") || stderr.startsWith("\n")
847
+ ? `${stdout}${stderr}`
848
+ : `${stdout}\n${stderr}`;
849
+ }
850
+ return stdout || stderr || "";
851
+ }
852
+ export function shellQuote(value) {
853
+ return `'${value.replace(/'/g, `'\\''`)}'`;
854
+ }
855
+ //# sourceMappingURL=runtime.js.map