@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
@@ -1,58 +1,106 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHash } from "node:crypto";
1
3
  // ---------------------------------------------------------------------------
2
- // E2B provider for the SandboxRuntime contract.
4
+ // E2B provider for both public runtime planes.
3
5
  //
4
- // The `e2b` npm package is imported LAZILY (`await import("e2b")`), never at
5
- // module top level, so consumers using a different provider never bundle it.
6
- // The SDK surface is modeled structurally below (from e2b@2.19.0) so typecheck
7
- // and unit tests do not hard-couple to the SDK type graph, and a fake factory
8
- // can drive every path without a real E2B API key.
6
+ // The `e2b` package is imported lazily so a consumer using another provider
7
+ // does not eagerly load it. The structural surface below follows the installed
8
+ // e2b v2 contract while keeping tests independent from an API key.
9
9
  // ---------------------------------------------------------------------------
10
- // Command-LIFETIME budget for a background run (NOT a request timeout).
11
- //
12
- // Callers commonly pass a short `options.timeoutMs` meaning "how long to wait
13
- // for the START call to return". For E2B that value is the SDK *request* cap,
14
- // not the command lifetime — mapping it onto the background command's
15
- // `timeoutMs` would kill every long-running command when the start request
16
- // deadline elapses. We instead set the command lifetime and the microVM
17
- // timeout to this budget, and route the caller's short timeout to
18
- // `requestTimeoutMs`.
19
10
  const DEFAULT_RUN_BUDGET_MS = 30 * 60_000;
20
11
  const DEFAULT_CREATE_TIMEOUT_MS = 120_000;
21
- const SCRIPT_LOG_READ_MAX_BYTES = 200_000;
12
+ const DEFAULT_LOOKUP_TIMEOUT_MS = 10_000;
13
+ const ADMISSION_RECONCILIATION_TIMEOUT_MS = 1_000;
14
+ const ADMISSION_RECONCILIATION_INTERVAL_MS = 25;
15
+ const SCRIPT_LOG_READ_MAX_BYTES = 262_144;
16
+ const ASYNC_SESSION_ENV = "AGENTWORKFORCE_E2B_ASYNC_SESSION";
17
+ const ASYNC_REQUEST_ENV = "AGENTWORKFORCE_E2B_ASYNC_REQUEST";
18
+ /** Terminal status used when E2B confirms that an admitted process is gone
19
+ * before it can publish an exit sidecar. */
20
+ export const E2B_ASYNC_PROCESS_LOST_EXIT_CODE = 255;
22
21
  export class E2BSandboxRuntime {
23
22
  id = "e2b";
24
- // `start`/`stop` below are unconditional no-ops (pause/resume is a warm-reuse
25
- // follow-up), so lifecycle must be declared false rather than inferred from
26
- // their presence. Label search IS real server-side (`Sandbox.list` with a
27
- // metadata query), so warm leases are meaningful; `collectByLabels` keeps its
28
- // own `[]`-degrade for an SDK build that lacks `list`.
29
- declaredCapabilities = { warmLease: true, lifecycle: false };
23
+ capabilities = {
24
+ // The SDK has a PTY API, but this adapter does not expose one through the
25
+ // public runtime contract.
26
+ pty: false,
27
+ // Launches always use the caller-provided template/snapshot identifier.
28
+ snapshots: true,
29
+ isolation: "strong",
30
+ // Handles can be resolved by id in a later process/request.
31
+ persistentHandle: true,
32
+ // Polling returns bounded snapshots; it does not expose a streaming API.
33
+ streamingLogs: false,
34
+ };
35
+ // E2B v2 performs metadata-filtered server-side lookup, and stop/start map
36
+ // to pause/connect (connect resumes a paused sandbox).
37
+ declaredCapabilities = { warmLease: true, lifecycle: true };
30
38
  apiKey;
31
39
  template;
32
40
  runBudgetMs;
41
+ syncRunBudgetMs;
42
+ sandboxLifetimeMs;
33
43
  createTimeoutMs;
34
44
  injectedStatics;
35
45
  staticsPromise;
36
- // Cache live Sandbox instances launched this process; cross-request access
37
- // (the poll ticks) resolves via Sandbox.connect(id) — the reattach that lets
38
- // a run outlive the request that started it.
39
- sandboxes = new Map();
46
+ registrations = new Map();
47
+ asyncAdmissions = new Map();
40
48
  constructor(options) {
41
- this.apiKey = options.apiKey;
42
- this.template = options.template.trim();
43
- this.runBudgetMs = options.runBudgetMs ?? DEFAULT_RUN_BUDGET_MS;
44
- this.createTimeoutMs = options.createTimeoutMs ?? DEFAULT_CREATE_TIMEOUT_MS;
49
+ const apiKey = options.apiKey.trim();
50
+ if (!apiKey) {
51
+ throw new Error("E2B API key is required");
52
+ }
53
+ const template = options.template.trim();
54
+ if (!template) {
55
+ throw new Error("E2B sandbox template is required");
56
+ }
57
+ this.apiKey = apiKey;
58
+ this.template = template;
59
+ this.runBudgetMs = positiveDuration(options.runBudgetMs, DEFAULT_RUN_BUDGET_MS);
60
+ this.syncRunBudgetMs = positiveDuration(options.syncRunBudgetMs, this.runBudgetMs);
61
+ this.sandboxLifetimeMs = positiveDuration(options.sandboxLifetimeMs, this.runBudgetMs);
62
+ this.createTimeoutMs = positiveDuration(options.createTimeoutMs, DEFAULT_CREATE_TIMEOUT_MS);
45
63
  this.injectedStatics = options.sandbox;
46
64
  }
47
65
  async findByLabels(labels, options = {}) {
48
- const handles = await this.findAllByLabels(labels, options);
49
- const excluded = new Set(options.excludeIds ?? []);
50
- return handles.find((handle) => !excluded.has(handle.id)) ?? null;
66
+ const states = options.states === undefined ? ["STARTED"] : options.states;
67
+ const providerStates = toE2BStates(states);
68
+ if (providerStates?.length === 0) {
69
+ return null;
70
+ }
71
+ const excludedIds = new Set(options.excludeIds ?? []);
72
+ const deadline = lookupDeadline(options.timeoutMs);
73
+ const paginator = await this.listSandboxes(labels, providerStates, pageSize(options));
74
+ while (paginator.hasNext) {
75
+ const page = await awaitLookupOperation(paginator.nextItems(), deadline, "listing matching E2B sandboxes");
76
+ for (const info of page) {
77
+ if (!matchesState(info.state, states) || excludedIds.has(info.sandboxId)) {
78
+ continue;
79
+ }
80
+ return this.registerInfo(info, options);
81
+ }
82
+ }
83
+ return null;
51
84
  }
52
85
  async findAllByLabels(labels, options = {}) {
53
86
  const states = options.states === undefined ? ["STARTED"] : options.states;
54
- const limit = options.limit ?? options.pageSize;
55
- return this.collectByLabels(labels, states, limit);
87
+ const providerStates = toE2BStates(states);
88
+ if (providerStates?.length === 0) {
89
+ return [];
90
+ }
91
+ const deadline = lookupDeadline(options.timeoutMs);
92
+ const paginator = await this.listSandboxes(labels, providerStates, pageSize(options));
93
+ const handles = [];
94
+ const excludedIds = new Set(options.excludeIds ?? []);
95
+ while (paginator.hasNext) {
96
+ const page = await awaitLookupOperation(paginator.nextItems(), deadline, "listing matching E2B sandboxes");
97
+ for (const info of page) {
98
+ if (matchesState(info.state, states) && !excludedIds.has(info.sandboxId)) {
99
+ handles.push(this.registerInfo(info, options));
100
+ }
101
+ }
102
+ }
103
+ return handles;
56
104
  }
57
105
  async countByLabels(labels, options = {}) {
58
106
  const maxCount = options.maxCount === undefined
@@ -62,100 +110,100 @@ export class E2BSandboxRuntime {
62
110
  return 0;
63
111
  }
64
112
  const states = options.states === undefined ? ["STARTED"] : options.states;
65
- const cap = Number.isFinite(maxCount) ? maxCount : undefined;
66
- const handles = await this.collectByLabels(labels, states, cap);
67
- return handles.length;
68
- }
69
- /**
70
- * Drain the E2B sandbox list (server-side metadata-filtered), keep the ones
71
- * matching `states`, and stop once `cap` handles are collected. Returns `[]`
72
- * when the provider build lacks server-side label search — an EXPLICIT
73
- * warm-lease-degrades-to-cold gate, not a silent behavior change.
74
- */
75
- async collectByLabels(labels, states, cap) {
76
- const statics = await this.statics();
77
- if (typeof statics.list !== "function") {
78
- return [];
113
+ const providerStates = toE2BStates(states);
114
+ if (providerStates?.length === 0) {
115
+ return 0;
79
116
  }
80
- const query = serializeMetadataQuery(labels);
81
- const paginator = statics.list({
82
- apiKey: this.apiKey,
83
- ...(query ? { query: { metadata: query } } : {}),
84
- });
85
- const handles = [];
117
+ const deadline = lookupDeadline(options.timeoutMs);
118
+ const paginator = await this.listSandboxes(labels, providerStates, pageSize(options));
119
+ let count = 0;
86
120
  while (paginator.hasNext) {
87
- const page = await paginator.nextItems();
121
+ const page = await awaitLookupOperation(paginator.nextItems(), deadline, "counting matching E2B sandboxes");
88
122
  for (const info of page) {
89
123
  if (!matchesState(info.state, states)) {
90
124
  continue;
91
125
  }
92
- handles.push(handleFromE2BInfo(info));
93
- if (cap !== undefined && handles.length >= cap) {
94
- return handles;
126
+ count += 1;
127
+ if (count >= maxCount) {
128
+ return count;
95
129
  }
96
130
  }
97
131
  }
98
- return handles;
132
+ return count;
99
133
  }
100
134
  async launch(options = {}) {
101
135
  const statics = await this.statics();
136
+ const name = options.name?.trim() || options.label?.trim();
102
137
  const metadata = {
103
138
  ...(options.labels ?? {}),
104
- ...(options.name ? { name: options.name } : {}),
139
+ ...(name ? { name } : {}),
105
140
  };
106
- const timeoutMs = options.createTimeoutSeconds
107
- ? options.createTimeoutSeconds * 1000
141
+ const requestTimeoutMs = options.createTimeoutSeconds && options.createTimeoutSeconds > 0
142
+ ? Math.ceil(options.createTimeoutSeconds * 1000)
108
143
  : this.createTimeoutMs;
109
144
  const sandbox = await statics.create(this.template, {
110
145
  apiKey: this.apiKey,
111
- ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
146
+ requestTimeoutMs,
147
+ timeoutMs: this.sandboxLifetimeMs,
148
+ ...(hasEntries(metadata) ? { metadata } : {}),
112
149
  ...(hasEntries(options.env) ? { envs: options.env } : {}),
113
- timeoutMs,
114
150
  });
115
- this.sandboxes.set(sandbox.sandboxId, sandbox);
116
- return { id: sandbox.sandboxId, state: "STARTED" };
117
- }
118
- async launchDetached(options = {}) {
119
- // E2B sandboxes persist server-side the instant create() returns and are
120
- // re-attachable by id via Sandbox.connect — so a "detached" launch is just
121
- // launch. This is what lets a short-lived request context return before
122
- // the run finishes and a later tick reconnect, instead of holding a
123
- // request open until it hits a gateway timeout.
124
- return this.launch(options);
151
+ return this.registerSandbox(sandbox, {
152
+ owned: true,
153
+ workdir: options.workdir,
154
+ });
125
155
  }
126
156
  async getById(id, options = {}) {
127
- const sandbox = await this.connectSandbox(id);
128
- if (!sandbox) {
129
- return null;
157
+ const statics = await this.statics();
158
+ let info;
159
+ try {
160
+ info = await statics.getInfo(id, { apiKey: this.apiKey });
161
+ }
162
+ catch (error) {
163
+ if (isSandboxNotFound(error)) {
164
+ return null;
165
+ }
166
+ throw error;
130
167
  }
131
- // Sandbox.connect only succeeds against a running microVM; a paused/killed
132
- // one throws NotFound (→ null above). So a resolved handle is "STARTED".
133
- const handle = { id: sandbox.sandboxId, state: "STARTED" };
134
168
  const states = options.states === undefined ? null : options.states;
135
- return matchesState("running", states) ? handle : null;
136
- }
137
- async uploadBundle(handle, options) {
138
- const sandbox = await this.requireSandbox(handle);
139
- for (const file of options.files) {
140
- const data = typeof file.source === "string" ? file.source : toArrayBuffer(file.source);
141
- await sandbox.files.write(file.destination, data);
169
+ if (!matchesState(info.state, states)) {
170
+ return null;
142
171
  }
172
+ return this.registerInfo(info, options);
173
+ }
174
+ attachSandbox(sandbox, options = {}) {
175
+ return this.registerSandbox(sandbox, {
176
+ owned: options.owned ?? false,
177
+ homeDir: options.homeDir,
178
+ workdir: options.workdir,
179
+ });
180
+ }
181
+ async exec(handle, command, options = {}) {
182
+ const result = await this.runScript(handle, { command, ...options });
183
+ return {
184
+ output: result.output,
185
+ exitCode: result.exitCode ?? 0,
186
+ };
143
187
  }
144
188
  async runScript(handle, options) {
145
189
  const sandbox = await this.requireSandbox(handle);
146
- // Synchronous foreground run: here options.timeoutMs IS the intended command
147
- // lifetime (a bounded sync exec), so mapping it to `timeoutMs` is correct.
190
+ const explicitTimeoutMs = options.timeoutMs && options.timeoutMs > 0
191
+ ? options.timeoutMs
192
+ : undefined;
148
193
  try {
194
+ // Only an explicit caller timeout may push the sandbox past its
195
+ // configured lifetime; the implicit budget below must not.
196
+ await sandbox.setTimeout(Math.max(this.sandboxLifetimeMs, explicitTimeoutMs ?? 0));
149
197
  const result = await sandbox.commands.run(options.command, {
150
- ...(options.timeoutMs && options.timeoutMs > 0 ? { timeoutMs: options.timeoutMs } : {}),
198
+ ...(options.cwd ? { cwd: options.cwd } : {}),
199
+ // Always send a timeout. E2B applies its own 60s command default when
200
+ // the field is omitted, which no other provider imposes and which the
201
+ // orchestrator does not expect: it omits `timeoutMs` on most execs, so
202
+ // an inherited 60s wall would kill long synchronous work on E2B alone.
203
+ timeoutMs: explicitTimeoutMs ?? this.syncRunBudgetMs,
151
204
  ...(hasEntries(options.env) ? { envs: options.env } : {}),
152
205
  });
153
- return {
154
- output: combineOutput(result.stdout, result.stderr),
155
- ...(result.stdout ? { stdout: result.stdout } : {}),
156
- ...(result.stderr ? { stderr: result.stderr } : {}),
157
- exitCode: typeof result.exitCode === "number" ? result.exitCode : null,
158
- };
206
+ return commandResult(result);
159
207
  }
160
208
  catch (error) {
161
209
  const normalized = normalizeCommandError(error);
@@ -168,128 +216,407 @@ export class E2BSandboxRuntime {
168
216
  async startScript(handle, options) {
169
217
  const sandbox = await this.requireSandbox(handle);
170
218
  const sessionId = options.sessionId ?? `run-${handle.id}-${Date.now()}`;
171
- const dir = scriptRunDir(sessionId);
172
- const outPath = `${dir}/out`;
173
- const exitPath = `${dir}/exit`;
174
- // Durable-file wrapper: capture combined stdout/stderr + the final exit code
175
- // to files under /tmp so getScriptStatus/getScriptLogs survive disconnect
176
- // and reconnect across poll ticks (mirrors Daytona's per-session redirect).
177
- const wrapped = `mkdir -p ${shellSingleQuote(dir)}; ` +
178
- `{ ${options.command}\n} > ${shellSingleQuote(outPath)} 2>&1; ` +
179
- `echo $? > ${shellSingleQuote(exitPath)}`;
180
- // Keep the microVM alive for the whole run, independent of the submit call.
181
- await sandbox.setTimeout(this.runBudgetMs);
182
- // The critical distinction: command LIFETIME is the run budget
183
- // (`timeoutMs`), while the caller's short `options.timeoutMs` bounds only
184
- // the submit HTTP request (`requestTimeoutMs`). A naive
185
- // `timeoutMs: options.timeoutMs` would kill every run the moment the
186
- // submit deadline elapsed.
187
- const started = await sandbox.commands.run(wrapped, {
188
- background: true,
189
- timeoutMs: this.runBudgetMs,
190
- ...(options.timeoutMs && options.timeoutMs > 0 ? { requestTimeoutMs: options.timeoutMs } : {}),
191
- ...(hasEntries(options.env) ? { envs: options.env } : {}),
219
+ const paths = scriptRunPaths(sessionId);
220
+ const fingerprint = asyncRequestFingerprint(options);
221
+ const admissionKey = `${handle.id}\0${paths.sessionToken}`;
222
+ const known = this.asyncAdmissions.get(admissionKey);
223
+ if (known) {
224
+ if (known.fingerprint !== fingerprint) {
225
+ throw asyncSessionConflict(sessionId);
226
+ }
227
+ await this.killDuplicateProcesses(sandbox, paths.sessionToken, fingerprint, known.pid);
228
+ return { sessionId, commandId: String(known.pid), reconciled: true };
229
+ }
230
+ // mkdir is the atomic admission claim. An existing directory is immutable:
231
+ // retries reconcile its durable request/process identity and never erase it
232
+ // or submit a second copy.
233
+ const claim = await this.runScript(handle, {
234
+ command: [
235
+ "# AGENTWORKFORCE_E2B_ASYNC_CLAIM",
236
+ `mkdir -p ${shellSingleQuote(paths.root)} || exit $?`,
237
+ `if mkdir ${shellSingleQuote(paths.dir)} 2>/dev/null; then`,
238
+ ` printf '%s\\n' ${shellSingleQuote(fingerprint)} > ${shellSingleQuote(paths.requestTmp)} &&`,
239
+ ` mv ${shellSingleQuote(paths.requestTmp)} ${shellSingleQuote(paths.request)} &&`,
240
+ " printf '%s\\n' created",
241
+ "else",
242
+ " printf '%s\\n' existing",
243
+ "fi",
244
+ ].join("\n"),
245
+ cwd: options.cwd,
246
+ env: options.env,
247
+ timeoutMs: 30_000,
192
248
  });
193
- return { sessionId, commandId: String(started.pid) };
249
+ if (claim.exitCode !== 0) {
250
+ throw new Error(`Failed to claim E2B async session ${sessionId}: ${claim.output || "claim failed"}`);
251
+ }
252
+ const claimState = claim.stdout?.trim() ?? claim.output.trim();
253
+ if (claimState === "existing") {
254
+ const reconciled = await this.reconcileExistingAdmission(sandbox, sessionId, paths, fingerprint);
255
+ this.asyncAdmissions.set(admissionKey, reconciled);
256
+ return { sessionId, commandId: String(reconciled.pid), reconciled: true };
257
+ }
258
+ if (claimState !== "created") {
259
+ throw new Error(`E2B async session ${sessionId} returned an invalid claim result`);
260
+ }
261
+ const wrapped = [
262
+ `printf '%s\\n%s\\n' "$$" ${shellSingleQuote(fingerprint)} > ${shellSingleQuote(paths.admissionTmp)}`,
263
+ `mv ${shellSingleQuote(paths.admissionTmp)} ${shellSingleQuote(paths.admission)}`,
264
+ "{",
265
+ options.command,
266
+ `} > ${shellSingleQuote(paths.output)} 2>&1`,
267
+ "e2b_run_status=$?",
268
+ `printf '%s\\n' "$e2b_run_status" > ${shellSingleQuote(paths.exitTmp)}`,
269
+ `mv ${shellSingleQuote(paths.exitTmp)} ${shellSingleQuote(paths.exit)}`,
270
+ 'exit "$e2b_run_status"',
271
+ ].join("\n");
272
+ await sandbox.setTimeout(Math.max(this.sandboxLifetimeMs, this.runBudgetMs));
273
+ try {
274
+ const started = await sandbox.commands.run(wrapped, {
275
+ background: true,
276
+ timeoutMs: this.runBudgetMs,
277
+ ...(options.timeoutMs && options.timeoutMs > 0
278
+ ? { requestTimeoutMs: options.timeoutMs }
279
+ : {}),
280
+ ...(options.cwd ? { cwd: options.cwd } : {}),
281
+ envs: {
282
+ ...(options.env ?? {}),
283
+ [ASYNC_SESSION_ENV]: paths.sessionToken,
284
+ [ASYNC_REQUEST_ENV]: fingerprint,
285
+ },
286
+ });
287
+ if (!Number.isInteger(started.pid) || started.pid <= 0) {
288
+ throw new Error("E2B async command did not return a process id");
289
+ }
290
+ this.asyncAdmissions.set(admissionKey, { fingerprint, pid: started.pid });
291
+ return { sessionId, commandId: String(started.pid) };
292
+ }
293
+ catch (error) {
294
+ if (isOutcomeUnknownAdmissionError(error)) {
295
+ const reconciled = await this.reconcileOutcomeUnknownAdmission(sandbox, paths, fingerprint);
296
+ if (reconciled) {
297
+ this.asyncAdmissions.set(admissionKey, reconciled);
298
+ return {
299
+ sessionId,
300
+ commandId: String(reconciled.pid),
301
+ reconciled: true,
302
+ };
303
+ }
304
+ }
305
+ throw error;
306
+ }
194
307
  }
195
- async getScriptStatus(handle, sessionId, _commandId) {
308
+ async getScriptStatus(handle, sessionId, commandId) {
196
309
  const sandbox = await this.requireSandbox(handle);
197
- const exitPath = `${scriptRunDir(sessionId)}/exit`;
198
- // Durable exit file is the source of truth: empty/missing → still running.
199
- const raw = await this.readFileBestEffort(sandbox, exitPath, 64);
200
- const trimmed = raw.trim();
201
- if (!trimmed) {
310
+ const paths = scriptRunPaths(sessionId);
311
+ const firstExit = parseShellExitCode(await this.readOptionalTextFile(sandbox, paths.exit) ?? "");
312
+ if (firstExit !== null) {
313
+ return { exitCode: firstExit };
314
+ }
315
+ const pid = parseProcessId(commandId);
316
+ if (pid === null) {
317
+ throw new Error(`Invalid E2B async command id "${commandId}"`);
318
+ }
319
+ const processes = await sandbox.commands.list();
320
+ if (processes.some((process) => isSessionProcess(process, paths.sessionToken, pid))) {
202
321
  return { exitCode: null };
203
322
  }
204
- const parsed = Number.parseInt(trimmed, 10);
205
- return { exitCode: Number.isFinite(parsed) ? parsed : null };
323
+ // Close the race where the process exits after list() but before its
324
+ // wrapper atomically publishes the exit sidecar.
325
+ const finalExit = parseShellExitCode(await this.readOptionalTextFile(sandbox, paths.exit) ?? "");
326
+ return {
327
+ exitCode: finalExit ?? E2B_ASYNC_PROCESS_LOST_EXIT_CODE,
328
+ };
206
329
  }
207
330
  async getScriptLogs(handle, sessionId, commandId) {
208
331
  const sandbox = await this.requireSandbox(handle);
209
- const outPath = `${scriptRunDir(sessionId)}/out`;
210
- const output = await this.readFileBestEffort(sandbox, outPath, SCRIPT_LOG_READ_MAX_BYTES);
211
- // exitCode:null — status is the source of truth for the exit code (matches
212
- // Daytona/Local getScriptLogs).
332
+ const output = await this.readBoundedFile(sandbox, scriptRunPaths(sessionId).output, SCRIPT_LOG_READ_MAX_BYTES);
213
333
  return { output, exitCode: null, cmdId: commandId };
214
334
  }
335
+ startExec(handle, command, options = {}) {
336
+ return this.startScript(handle, {
337
+ command,
338
+ cwd: options.cwd,
339
+ env: options.env,
340
+ timeoutMs: options.timeoutMs,
341
+ sessionId: options.sessionId,
342
+ suppressInputEcho: true,
343
+ });
344
+ }
345
+ getExecStatus(handle, sessionId, commandId) {
346
+ return this.getScriptStatus(handle, sessionId, commandId);
347
+ }
348
+ async getExecLogs(handle, sessionId, commandId) {
349
+ const status = await this.getScriptStatus(handle, sessionId, commandId);
350
+ if (status.exitCode === null) {
351
+ throw new Error(`E2B async command "${commandId}" is still running`);
352
+ }
353
+ const logs = await this.getScriptLogs(handle, sessionId, commandId);
354
+ return { output: logs.output, exitCode: status.exitCode };
355
+ }
356
+ async uploadFile(handle, source, destination) {
357
+ const sandbox = await this.requireSandbox(handle);
358
+ const bytes = typeof source === "string"
359
+ ? await import("node:fs/promises").then((fs) => fs.readFile(source))
360
+ : source;
361
+ await sandbox.files.write(destination, toArrayBuffer(bytes));
362
+ }
363
+ async uploadBundle(handle, options) {
364
+ for (const file of options.files) {
365
+ await this.uploadFile(handle, file.source, file.destination);
366
+ }
367
+ const destinations = options.files.map((file) => file.destination);
368
+ if (options.manifest !== undefined) {
369
+ const manifestPath = options.manifestPath ?? "/workspace/manifest.json";
370
+ await this.uploadFile(handle, Buffer.from(JSON.stringify(options.manifest, null, 2), "utf8"), manifestPath);
371
+ destinations.push(manifestPath);
372
+ }
373
+ await this.verifyUploadedBundleFiles(handle, destinations);
374
+ }
375
+ async downloadFile(handle, source, destination) {
376
+ const sandbox = await this.requireSandbox(handle);
377
+ const bytes = await sandbox.files.read(source, { format: "bytes" });
378
+ const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
379
+ if (destination) {
380
+ await import("node:fs/promises").then((fs) => fs.writeFile(destination, buffer));
381
+ return;
382
+ }
383
+ return buffer;
384
+ }
385
+ async getHomeDir(handle) {
386
+ if (handle.homeDir) {
387
+ return handle.homeDir;
388
+ }
389
+ const result = await this.runScript(handle, {
390
+ command: `printf '%s' "$HOME"`,
391
+ timeoutMs: 30_000,
392
+ });
393
+ const homeDir = result.exitCode === 0 ? result.stdout?.trim() ?? "" : "";
394
+ if (!homeDir) {
395
+ throw new Error(`Failed to resolve home directory for E2B sandbox "${handle.id}"`);
396
+ }
397
+ handle.homeDir = homeDir;
398
+ return homeDir;
399
+ }
400
+ async stop(handle) {
401
+ const entry = this.registrations.get(handle.id);
402
+ if (!entry || !entry.owned) {
403
+ return;
404
+ }
405
+ const statics = await this.statics();
406
+ await statics.pause(handle.id, { apiKey: this.apiKey });
407
+ entry.sandbox = undefined;
408
+ entry.state = "paused";
409
+ handle.state = "STOPPED";
410
+ }
215
411
  async start(handle) {
216
- // E2B pause/resume is a warm-reuse follow-up (see PR needs-key list); until
217
- // then start is a no-op that returns the handle, matching Local's 501 no-op.
412
+ const entry = this.registrations.get(handle.id);
413
+ if (!entry || !entry.owned) {
414
+ return handle;
415
+ }
416
+ const statics = await this.statics();
417
+ const sandbox = await statics.connect(handle.id, {
418
+ apiKey: this.apiKey,
419
+ timeoutMs: this.sandboxLifetimeMs,
420
+ });
421
+ entry.sandbox = sandbox;
422
+ entry.state = "running";
423
+ handle.state = "STARTED";
218
424
  return handle;
219
425
  }
220
- async stop(_handle) {
221
- // No-op (see start): pause/resume lifecycle deferred to warm-reuse follow-up.
222
- }
223
426
  async destroy(handle) {
224
- const sandbox = await this.connectSandbox(handle.id);
225
- this.sandboxes.delete(handle.id);
226
- if (!sandbox) {
427
+ const entry = this.registrations.get(handle.id);
428
+ if (!entry) {
429
+ return;
430
+ }
431
+ if (!entry.owned) {
432
+ this.registrations.delete(handle.id);
227
433
  return;
228
434
  }
229
- await sandbox.kill();
435
+ const statics = await this.statics();
436
+ await statics.kill(handle.id, { apiKey: this.apiKey });
437
+ this.registrations.delete(handle.id);
230
438
  }
231
- // --- internals ----------------------------------------------------------
232
439
  async statics() {
233
440
  if (this.injectedStatics) {
234
441
  return this.injectedStatics;
235
442
  }
236
443
  if (!this.staticsPromise) {
237
- // Lazy — keeps `e2b` out of every non-E2B bundle and off the eager-import
238
- // ban list.
239
444
  this.staticsPromise = import("e2b").then((mod) => mod.Sandbox);
240
445
  }
241
446
  return this.staticsPromise;
242
447
  }
448
+ async listSandboxes(labels, states, limit) {
449
+ const statics = await this.statics();
450
+ const query = {
451
+ ...(hasEntries(labels) ? { metadata: labels } : {}),
452
+ ...(states ? { state: states } : {}),
453
+ };
454
+ return statics.list({
455
+ apiKey: this.apiKey,
456
+ limit,
457
+ ...(hasEntries(query) ? { query } : {}),
458
+ });
459
+ }
460
+ registerInfo(info, options) {
461
+ const existing = this.registrations.get(info.sandboxId);
462
+ this.registrations.set(info.sandboxId, {
463
+ ...(existing?.sandbox ? { sandbox: existing.sandbox } : {}),
464
+ owned: options.owned ?? false,
465
+ state: info.state,
466
+ });
467
+ return {
468
+ ...handleFromE2BInfo(info),
469
+ ...(options.homeDir ? { homeDir: options.homeDir } : {}),
470
+ ...(options.workdir ? { workdir: options.workdir } : {}),
471
+ };
472
+ }
473
+ registerSandbox(sandbox, options) {
474
+ this.registrations.set(sandbox.sandboxId, {
475
+ sandbox,
476
+ owned: options.owned,
477
+ state: "running",
478
+ });
479
+ return {
480
+ id: sandbox.sandboxId,
481
+ state: "STARTED",
482
+ ...(options.homeDir ? { homeDir: options.homeDir } : {}),
483
+ ...(options.workdir ? { workdir: options.workdir } : {}),
484
+ };
485
+ }
243
486
  async requireSandbox(handle) {
244
- const sandbox = await this.connectSandbox(handle.id);
245
- if (!sandbox) {
246
- throw new Error(`E2B sandbox "${handle.id}" is no longer available`);
487
+ const registered = this.registrations.get(handle.id);
488
+ if (registered?.state === "paused") {
489
+ throw new Error(`E2B sandbox "${handle.id}" is paused; call start() before using it`);
247
490
  }
248
- return sandbox;
249
- }
250
- async connectSandbox(id) {
251
- const cached = this.sandboxes.get(id);
252
- if (cached) {
253
- return cached;
491
+ if (registered?.sandbox) {
492
+ return registered.sandbox;
254
493
  }
255
494
  const statics = await this.statics();
256
495
  try {
257
- const sandbox = await statics.connect(id, { apiKey: this.apiKey });
258
- this.sandboxes.set(id, sandbox);
496
+ const sandbox = await statics.connect(handle.id, {
497
+ apiKey: this.apiKey,
498
+ timeoutMs: this.sandboxLifetimeMs,
499
+ });
500
+ this.registrations.set(handle.id, {
501
+ sandbox,
502
+ owned: registered?.owned ?? false,
503
+ state: "running",
504
+ });
259
505
  return sandbox;
260
506
  }
261
507
  catch (error) {
262
508
  if (isSandboxNotFound(error)) {
263
- return null;
509
+ throw new Error(`E2B sandbox "${handle.id}" is no longer available`, { cause: error });
264
510
  }
265
511
  throw error;
266
512
  }
267
513
  }
268
- async readFileBestEffort(sandbox, path, maxBytes) {
269
- // Read via a bounded foreground `tail -c` rather than files.read: it returns
270
- // "" (never throws) when the file is still absent, and caps the bytes pulled
271
- // back into the Worker.
514
+ async verifyUploadedBundleFiles(handle, destinations) {
515
+ if (destinations.length === 0) {
516
+ return;
517
+ }
518
+ const result = await this.runScript(handle, {
519
+ command: destinations
520
+ .map((destination) => `test -f ${shellSingleQuote(destination)}`)
521
+ .join(" && "),
522
+ timeoutMs: 30_000,
523
+ });
524
+ if (result.exitCode !== 0) {
525
+ throw new Error(`Failed to verify uploaded E2B bundle files: ${result.output || "remote file check failed"}`);
526
+ }
527
+ }
528
+ async reconcileExistingAdmission(sandbox, sessionId, paths, fingerprint) {
529
+ const recordedRequest = (await this.readOptionalTextFile(sandbox, paths.request))?.trim();
530
+ if (!recordedRequest) {
531
+ throw new Error(`E2B async session ${sessionId} is already claimed but has no authoritative request record`);
532
+ }
533
+ if (recordedRequest !== fingerprint) {
534
+ throw asyncSessionConflict(sessionId);
535
+ }
536
+ const recordedAdmission = parseAdmissionRecord(await this.readOptionalTextFile(sandbox, paths.admission));
537
+ if (recordedAdmission && recordedAdmission.fingerprint !== fingerprint) {
538
+ throw new Error(`E2B async session ${sessionId} has a mismatched admission record`);
539
+ }
540
+ const processes = matchingSessionProcesses(await sandbox.commands.list(), paths.sessionToken, fingerprint);
541
+ if (recordedAdmission) {
542
+ await this.killProcessesExcept(sandbox, processes, recordedAdmission.pid);
543
+ return recordedAdmission;
544
+ }
545
+ if (processes.length === 1) {
546
+ const admission = { fingerprint, pid: processes[0].pid };
547
+ await sandbox.files.write(paths.admission, `${admission.pid}\n${admission.fingerprint}\n`);
548
+ return admission;
549
+ }
550
+ if (processes.length > 1) {
551
+ await this.killProcessesExcept(sandbox, processes, null);
552
+ }
553
+ throw new Error(`E2B async session ${sessionId} is already claimed but admission cannot be proven`);
554
+ }
555
+ async reconcileOutcomeUnknownAdmission(sandbox, paths, fingerprint) {
556
+ const deadline = Date.now() + ADMISSION_RECONCILIATION_TIMEOUT_MS;
557
+ do {
558
+ const recorded = parseAdmissionRecord(await this.readOptionalTextFile(sandbox, paths.admission));
559
+ const processes = matchingSessionProcesses(await sandbox.commands.list(), paths.sessionToken, fingerprint);
560
+ if (recorded?.fingerprint === fingerprint) {
561
+ await this.killProcessesExcept(sandbox, processes, recorded.pid);
562
+ return recorded;
563
+ }
564
+ if (processes.length === 1) {
565
+ const admission = { fingerprint, pid: processes[0].pid };
566
+ await sandbox.files.write(paths.admission, `${admission.pid}\n${admission.fingerprint}\n`);
567
+ return admission;
568
+ }
569
+ if (processes.length > 1) {
570
+ await this.killProcessesExcept(sandbox, processes, null);
571
+ return null;
572
+ }
573
+ if (Date.now() < deadline) {
574
+ await delay(ADMISSION_RECONCILIATION_INTERVAL_MS);
575
+ }
576
+ } while (Date.now() < deadline);
577
+ return null;
578
+ }
579
+ async killDuplicateProcesses(sandbox, sessionToken, fingerprint, admittedPid) {
580
+ const matches = matchingSessionProcesses(await sandbox.commands.list(), sessionToken, fingerprint);
581
+ await this.killProcessesExcept(sandbox, matches, admittedPid);
582
+ }
583
+ async killProcessesExcept(sandbox, processes, admittedPid) {
584
+ for (const process of processes) {
585
+ if (process.pid !== admittedPid) {
586
+ await sandbox.commands.kill(process.pid);
587
+ }
588
+ }
589
+ }
590
+ async readOptionalTextFile(sandbox, path, requestTimeoutMs) {
272
591
  try {
273
- const result = await sandbox.commands.run(`tail -c ${maxBytes} ${shellSingleQuote(path)} 2>/dev/null || true`);
274
- return result.stdout ?? "";
592
+ return await sandbox.files.read(path, {
593
+ format: "text",
594
+ ...(requestTimeoutMs ? { requestTimeoutMs } : {}),
595
+ });
275
596
  }
276
- catch {
277
- return "";
597
+ catch (error) {
598
+ if (isFileNotFound(error)) {
599
+ return null;
600
+ }
601
+ throw error;
278
602
  }
279
603
  }
604
+ async readBoundedFile(sandbox, path, maxBytes, requestTimeoutMs) {
605
+ // Unlike `runScript`, this runs a fixed bounded `tail`, not caller work, so
606
+ // E2B's own command default is an appropriate wall when no cap is given.
607
+ const result = await sandbox.commands.run(`tail -c ${maxBytes} ${shellSingleQuote(path)} 2>/dev/null || true`, requestTimeoutMs
608
+ ? { timeoutMs: requestTimeoutMs, requestTimeoutMs }
609
+ : undefined);
610
+ return result.stdout ?? "";
611
+ }
280
612
  }
281
- // --- helpers --------------------------------------------------------------
282
- function serializeMetadataQuery(labels) {
283
- const entries = Object.entries(labels).filter(([key]) => key.length > 0);
284
- if (entries.length === 0) {
285
- return "";
286
- }
287
- // e2b's metadata filter is a URL-encoded "k=v&k2=v2" string, not an object.
288
- const params = new URLSearchParams();
289
- for (const [key, value] of entries) {
290
- params.append(key, value);
291
- }
292
- return params.toString();
613
+ function commandResult(result) {
614
+ return {
615
+ output: combineOutput(result.stdout, result.stderr),
616
+ ...(result.stdout ? { stdout: result.stdout } : {}),
617
+ ...(result.stderr ? { stderr: result.stderr } : {}),
618
+ exitCode: typeof result.exitCode === "number" ? result.exitCode : null,
619
+ };
293
620
  }
294
621
  function handleFromE2BInfo(info) {
295
622
  return {
@@ -299,22 +626,49 @@ function handleFromE2BInfo(info) {
299
626
  };
300
627
  }
301
628
  function normalizeE2BState(state) {
302
- // Map to the normalized vocabulary the delivery path reasons about (Daytona
303
- // STARTED/STOPPED). A paused E2B sandbox is not cold-reusable in this PR
304
- // (warm resume is the follow-up) so it reads as STOPPED and is excluded by the
305
- // default ["STARTED"] filter → cold provisioning.
306
629
  return state === "running" ? "STARTED" : "STOPPED";
307
630
  }
631
+ function normalizeRequestedState(state) {
632
+ const normalized = state.toUpperCase();
633
+ if (normalized === "RUNNING") {
634
+ return "STARTED";
635
+ }
636
+ if (normalized === "PAUSED") {
637
+ return "STOPPED";
638
+ }
639
+ return normalized;
640
+ }
308
641
  function matchesState(state, states) {
309
642
  if (states === null) {
310
643
  return true;
311
644
  }
312
- const normalized = normalizeE2BState(state ?? "running");
313
- return states.includes(normalized);
645
+ if (!state) {
646
+ return false;
647
+ }
648
+ const actual = normalizeE2BState(state);
649
+ return states.some((candidate) => normalizeRequestedState(candidate) === actual);
650
+ }
651
+ function toE2BStates(states) {
652
+ if (states === null) {
653
+ return null;
654
+ }
655
+ const providerStates = new Set();
656
+ for (const state of states) {
657
+ const normalized = normalizeRequestedState(state);
658
+ if (normalized === "STARTED") {
659
+ providerStates.add("running");
660
+ }
661
+ else if (normalized === "STOPPED") {
662
+ providerStates.add("paused");
663
+ }
664
+ }
665
+ return [...providerStates];
314
666
  }
315
667
  function combineOutput(stdout, stderr) {
316
668
  if (stdout && stderr) {
317
- return stdout.endsWith("\n") || stderr.startsWith("\n") ? `${stdout}${stderr}` : `${stdout}\n${stderr}`;
669
+ return stdout.endsWith("\n") || stderr.startsWith("\n")
670
+ ? `${stdout}${stderr}`
671
+ : `${stdout}\n${stderr}`;
318
672
  }
319
673
  return stdout || stderr || "";
320
674
  }
@@ -336,15 +690,46 @@ function normalizeCommandError(error) {
336
690
  exitCode: candidate.exitCode,
337
691
  };
338
692
  }
693
+ function isOutcomeUnknownAdmissionError(error) {
694
+ const record = error && typeof error === "object"
695
+ ? error
696
+ : null;
697
+ const code = typeof record?.code === "string" ? record.code.toUpperCase() : "";
698
+ const name = typeof record?.name === "string" ? record.name : "";
699
+ const message = error instanceof Error
700
+ ? error.message
701
+ : typeof record?.message === "string"
702
+ ? record.message
703
+ : String(error);
704
+ return (code === "ECONNABORTED"
705
+ || code === "ETIMEDOUT"
706
+ || code === "ECONNRESET"
707
+ || name === "TimeoutError"
708
+ || /\btimed out\b/iu.test(message)
709
+ || /\bnetwork connection lost\b/iu.test(message)
710
+ || /\bsocket hang up\b/iu.test(message)
711
+ || /\bfetch failed\b/iu.test(message));
712
+ }
339
713
  function isSandboxNotFound(error) {
340
714
  if (typeof error !== "object" || error === null) {
341
715
  return false;
342
716
  }
343
- const name = error.name;
344
- const message = error.message;
345
- return (name === "NotFoundError" ||
346
- name === "SandboxNotFoundError" ||
347
- (typeof message === "string" && /not\s*found/i.test(message)));
717
+ const record = error;
718
+ return (record.name === "NotFoundError"
719
+ || record.name === "SandboxNotFoundError"
720
+ || record.status === 404
721
+ || record.statusCode === 404
722
+ || (typeof record.message === "string" && /not\s*found/i.test(record.message)));
723
+ }
724
+ function isFileNotFound(error) {
725
+ if (typeof error !== "object" || error === null) {
726
+ return false;
727
+ }
728
+ const record = error;
729
+ return (record.name === "FileNotFoundError"
730
+ || record.status === 404
731
+ || record.statusCode === 404
732
+ || (typeof record.message === "string" && /file.*not\s*found|no such file/iu.test(record.message)));
348
733
  }
349
734
  function toArrayBuffer(buffer) {
350
735
  return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
@@ -352,9 +737,109 @@ function toArrayBuffer(buffer) {
352
737
  function hasEntries(record) {
353
738
  return !!record && Object.keys(record).length > 0;
354
739
  }
355
- function scriptRunDir(sessionId) {
356
- const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, "_");
357
- return `/tmp/e2b-run/${safe}`;
740
+ function positiveDuration(value, fallback) {
741
+ return Number.isFinite(value) && value !== undefined && value > 0
742
+ ? Math.ceil(value)
743
+ : fallback;
744
+ }
745
+ function pageSize(options) {
746
+ const value = options.limit ?? options.pageSize ?? 10;
747
+ return Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : 10;
748
+ }
749
+ function lookupDeadline(timeoutMs) {
750
+ const normalized = positiveDuration(timeoutMs, DEFAULT_LOOKUP_TIMEOUT_MS);
751
+ return { endsAt: Date.now() + normalized, timeoutMs: normalized };
752
+ }
753
+ async function awaitLookupOperation(operation, deadline, description) {
754
+ const remainingMs = deadline.endsAt - Date.now();
755
+ if (remainingMs <= 0) {
756
+ throw new Error(`E2B sandbox lookup exceeded ${deadline.timeoutMs}ms while ${description}`);
757
+ }
758
+ let timer;
759
+ try {
760
+ return await Promise.race([
761
+ operation,
762
+ new Promise((_resolve, reject) => {
763
+ timer = setTimeout(() => {
764
+ reject(new Error(`E2B sandbox lookup exceeded ${deadline.timeoutMs}ms while ${description}`));
765
+ }, remainingMs);
766
+ }),
767
+ ]);
768
+ }
769
+ finally {
770
+ if (timer !== undefined) {
771
+ clearTimeout(timer);
772
+ }
773
+ }
774
+ }
775
+ function parseShellExitCode(value) {
776
+ const normalized = value.trim();
777
+ if (!/^\d{1,3}$/u.test(normalized)) {
778
+ return null;
779
+ }
780
+ const exitCode = Number(normalized);
781
+ return Number.isInteger(exitCode) && exitCode >= 0 && exitCode <= 255
782
+ ? exitCode
783
+ : null;
784
+ }
785
+ function parseProcessId(value) {
786
+ const normalized = value.trim();
787
+ if (!/^\d+$/u.test(normalized)) {
788
+ return null;
789
+ }
790
+ const pid = Number(normalized);
791
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
792
+ }
793
+ function parseAdmissionRecord(value) {
794
+ if (value === null) {
795
+ return null;
796
+ }
797
+ const [rawPid, fingerprint, ...extra] = value.trim().split(/\s+/u);
798
+ const pid = parseProcessId(rawPid ?? "");
799
+ if (pid === null || !fingerprint || extra.length > 0 || !/^[a-f\d]{64}$/u.test(fingerprint)) {
800
+ return null;
801
+ }
802
+ return { fingerprint, pid };
803
+ }
804
+ function asyncRequestFingerprint(options) {
805
+ const env = Object.entries(options.env ?? {}).sort(([left], [right]) => left.localeCompare(right));
806
+ return createHash("sha256").update(JSON.stringify({
807
+ command: options.command,
808
+ cwd: options.cwd ?? null,
809
+ env,
810
+ })).digest("hex");
811
+ }
812
+ function asyncSessionConflict(sessionId) {
813
+ return new Error(`E2B async session ${sessionId} is already claimed by a different request`);
814
+ }
815
+ function matchingSessionProcesses(processes, sessionToken, fingerprint) {
816
+ return processes.filter((process) => (process.envs?.[ASYNC_SESSION_ENV] === sessionToken
817
+ && process.envs?.[ASYNC_REQUEST_ENV] === fingerprint));
818
+ }
819
+ function isSessionProcess(process, sessionToken, pid) {
820
+ return process.pid === pid && process.envs?.[ASYNC_SESSION_ENV] === sessionToken;
821
+ }
822
+ function delay(timeoutMs) {
823
+ return new Promise((resolve) => setTimeout(resolve, timeoutMs));
824
+ }
825
+ function scriptRunPaths(sessionId) {
826
+ // Base64url is filesystem-safe and collision-free for the original UTF-8
827
+ // bytes, unlike replacing punctuation with underscores.
828
+ const safe = Buffer.from(sessionId, "utf8").toString("base64url") || "empty";
829
+ const root = "/tmp/e2b-run";
830
+ const dir = `${root}/${safe}`;
831
+ return {
832
+ root,
833
+ dir,
834
+ output: `${dir}/out`,
835
+ exit: `${dir}/exit`,
836
+ exitTmp: `${dir}/exit.tmp`,
837
+ admission: `${dir}/admission`,
838
+ admissionTmp: `${dir}/admission.tmp`,
839
+ request: `${dir}/request`,
840
+ requestTmp: `${dir}/request.tmp`,
841
+ sessionToken: safe,
842
+ };
358
843
  }
359
844
  function shellSingleQuote(value) {
360
845
  return `'${value.replace(/'/g, `'\\''`)}'`;