@librechat/agents 3.2.41 → 3.2.43

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 (75) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +4 -3
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +5 -2
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  6. package/dist/cjs/llm/bedrock/index.cjs +9 -1
  7. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  8. package/dist/cjs/main.cjs +6 -0
  9. package/dist/cjs/messages/cache.cjs +43 -0
  10. package/dist/cjs/messages/cache.cjs.map +1 -1
  11. package/dist/cjs/session/JsonlSessionStore.cjs +1 -1
  12. package/dist/cjs/tools/cloudflare/CloudflareProgrammaticToolCalling.cjs +2 -2
  13. package/dist/cjs/tools/cloudflare/CloudflareProgrammaticToolCalling.cjs.map +1 -1
  14. package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs +118 -22
  15. package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs.map +1 -1
  16. package/dist/cjs/tools/local/CompileCheckTool.cjs +11 -3
  17. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
  18. package/dist/cjs/tools/local/FileCheckpointer.cjs +8 -3
  19. package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -1
  20. package/dist/cjs/tools/local/LocalCodingTools.cjs +26 -7
  21. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
  22. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +2 -2
  23. package/dist/cjs/tools/local/syntaxCheck.cjs +6 -2
  24. package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -1
  25. package/dist/cjs/tools/local/workspaceFS.cjs +20 -0
  26. package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -1
  27. package/dist/esm/agents/AgentContext.mjs +5 -4
  28. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  29. package/dist/esm/graphs/Graph.mjs +6 -3
  30. package/dist/esm/graphs/Graph.mjs.map +1 -1
  31. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  32. package/dist/esm/llm/bedrock/index.mjs +10 -2
  33. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  34. package/dist/esm/main.mjs +3 -3
  35. package/dist/esm/messages/cache.mjs +42 -1
  36. package/dist/esm/messages/cache.mjs.map +1 -1
  37. package/dist/esm/session/JsonlSessionStore.mjs +1 -1
  38. package/dist/esm/tools/cloudflare/CloudflareProgrammaticToolCalling.mjs +3 -3
  39. package/dist/esm/tools/cloudflare/CloudflareProgrammaticToolCalling.mjs.map +1 -1
  40. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs +115 -23
  41. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs.map +1 -1
  42. package/dist/esm/tools/local/CompileCheckTool.mjs +11 -3
  43. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
  44. package/dist/esm/tools/local/FileCheckpointer.mjs +9 -4
  45. package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -1
  46. package/dist/esm/tools/local/LocalCodingTools.mjs +26 -7
  47. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
  48. package/dist/esm/tools/local/LocalExecutionEngine.mjs +2 -2
  49. package/dist/esm/tools/local/syntaxCheck.mjs +6 -2
  50. package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -1
  51. package/dist/esm/tools/local/workspaceFS.mjs +19 -1
  52. package/dist/esm/tools/local/workspaceFS.mjs.map +1 -1
  53. package/dist/types/agents/AgentContext.d.ts +3 -2
  54. package/dist/types/llm/bedrock/index.d.ts +7 -0
  55. package/dist/types/messages/cache.d.ts +27 -0
  56. package/dist/types/tools/cloudflare/CloudflareSandboxExecutionEngine.d.ts +53 -0
  57. package/dist/types/tools/local/workspaceFS.d.ts +13 -0
  58. package/package.json +1 -1
  59. package/src/agents/AgentContext.ts +10 -3
  60. package/src/graphs/Graph.ts +24 -6
  61. package/src/llm/bedrock/index.ts +19 -3
  62. package/src/llm/bedrock/llm.spec.ts +97 -0
  63. package/src/messages/cache.test.ts +63 -0
  64. package/src/messages/cache.ts +51 -0
  65. package/src/specs/vllm-reasoning-toolcalls.test.ts +340 -0
  66. package/src/tools/__tests__/CloudflareSandboxExecution.test.ts +323 -0
  67. package/src/tools/__tests__/LocalExecutionTools.test.ts +54 -0
  68. package/src/tools/cloudflare/CloudflareProgrammaticToolCalling.ts +7 -2
  69. package/src/tools/cloudflare/CloudflareSandboxExecutionEngine.ts +269 -37
  70. package/src/tools/local/CompileCheckTool.ts +19 -3
  71. package/src/tools/local/FileCheckpointer.ts +20 -4
  72. package/src/tools/local/LocalCodingTools.ts +61 -8
  73. package/src/tools/local/__tests__/FileCheckpointer.test.ts +42 -0
  74. package/src/tools/local/syntaxCheck.ts +14 -2
  75. package/src/tools/local/workspaceFS.ts +27 -0
@@ -37,6 +37,8 @@ import {
37
37
  _resetSyntaxCheckProbeCacheForTests,
38
38
  } from '../local/syntaxCheck';
39
39
  import { resolveLocalToolsForBinding } from '../local/resolveLocalExecutionTools';
40
+ import { WorkspaceClientTimeoutError } from '../local/workspaceFS';
41
+ import type { WorkspaceFS } from '../local/workspaceFS';
40
42
  import { LocalFileCheckpointerImpl } from '../local/FileCheckpointer';
41
43
  import { createCompileCheckTool } from '../local/CompileCheckTool';
42
44
  import { runBashAstChecks } from '../local/bashAst';
@@ -2317,6 +2319,58 @@ describe('comprehensive review (round 14) — Codex P1 #37 + P2 #38/#40/#41', ()
2317
2319
  ).rejects.toThrow(/syntax check failed.*reverted/i);
2318
2320
  expect(await fsp.readFile(file, 'utf8')).toBe(original);
2319
2321
  });
2322
+
2323
+ it('write_file: still reverts when the strict validation READ times out', async () => {
2324
+ // A stalled validation read must not leave the just-written bytes on disk —
2325
+ // strict mode's revert contract still applies when validation is
2326
+ // inconclusive due to a timeout (here a brand-new file -> unlink).
2327
+ const cwd = await createTempDir();
2328
+ const file = join(cwd, 'config.json');
2329
+ const unlinked: string[] = [];
2330
+ let written = false;
2331
+ const fakeFs = {
2332
+ readFile: async () => {
2333
+ if (!written) {
2334
+ // write_file pre-read: the file does not exist yet.
2335
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
2336
+ }
2337
+ // jsonCheck's post-write validation read stalls.
2338
+ throw new WorkspaceClientTimeoutError(
2339
+ 'cloudflare sandbox readFile exceeded 6000ms client-side timeout'
2340
+ );
2341
+ },
2342
+ writeFile: async () => {
2343
+ written = true;
2344
+ },
2345
+ mkdir: async () => undefined,
2346
+ unlink: async (p: string) => {
2347
+ unlinked.push(p);
2348
+ },
2349
+ stat: async () => ({ isFile: () => true, size: 1 }),
2350
+ readdir: async () => [],
2351
+ realpath: async (p: string) => p,
2352
+ } as unknown as WorkspaceFS;
2353
+
2354
+ const bundle = createLocalCodingToolBundle({
2355
+ cwd,
2356
+ postEditSyntaxCheck: 'strict',
2357
+ exec: { fs: fakeFs },
2358
+ });
2359
+ const writeTool = bundle.tools.find(
2360
+ (tt) => tt.name === Constants.WRITE_FILE
2361
+ );
2362
+ await expect(
2363
+ writeTool!.invoke({
2364
+ id: 'wf-strict-read-timeout',
2365
+ name: Constants.WRITE_FILE,
2366
+ args: { path: file, content: '{"ok": true}\n' },
2367
+ type: 'tool_call',
2368
+ })
2369
+ ).rejects.toThrow(/client-side timeout/);
2370
+ // strict mode attempted the revert (brand-new file -> unlink), rather than
2371
+ // leaving the unvalidated write on disk.
2372
+ expect(unlinked.length).toBeGreaterThan(0);
2373
+ });
2320
2374
  });
2321
2375
 
2322
2376
  describe('fallbackGrep skip sentinels do not count as matches (Codex P2 [43])', () => {
@@ -19,6 +19,8 @@ import {
19
19
  } from '@/tools/BashProgrammaticToolCalling';
20
20
  import { Constants } from '@/common';
21
21
  import {
22
+ clientExecTimeoutMs,
23
+ execWithClientTimeout,
22
24
  executeCloudflareCode,
23
25
  getCloudflareWorkspaceRoot,
24
26
  resolveCloudflareSandbox,
@@ -166,13 +168,16 @@ async function executeGeneratedCloudflareBash(
166
168
  const workspaceRoot = getCloudflareWorkspaceRoot(config);
167
169
  const shell = config.shell ?? 'bash';
168
170
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT;
169
- const result = await sandbox.exec(
171
+ const result = await execWithClientTimeout(
172
+ sandbox,
170
173
  withInSandboxTimeout(`${shell} -lc ${quoteShell(command)}`, timeoutMs),
171
174
  {
172
175
  cwd: workspaceRoot,
173
176
  env: config.env,
174
177
  timeout: outerTimeoutMs(timeoutMs),
175
- }
178
+ },
179
+ clientExecTimeoutMs(timeoutMs),
180
+ 'cloudflare bash programmatic exec'
176
181
  );
177
182
  const maxOutputChars = config.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
178
183
  return {
@@ -5,6 +5,10 @@ import type { WriteFileOptions, MakeDirectoryOptions, Stats } from 'fs';
5
5
  import type { ChildProcessWithoutNullStreams } from 'child_process';
6
6
  import type { FileHandle } from 'fs/promises';
7
7
  import type { WorkspaceFS, ReaddirEntry } from '@/tools/local/workspaceFS';
8
+ import {
9
+ WorkspaceClientTimeoutError,
10
+ isWorkspaceClientTimeoutError,
11
+ } from '@/tools/local/workspaceFS';
8
12
  import type * as t from '@/types';
9
13
  import {
10
14
  LOCAL_SPAWN_TIMEOUT_MS,
@@ -131,6 +135,147 @@ function isInSandboxTimeoutExit(exitCode: number | null): boolean {
131
135
  return exitCode === 124 || exitCode === 137;
132
136
  }
133
137
 
138
+ /**
139
+ * Client-side backstop timeout for a `sandbox.exec()` await: a few seconds beyond
140
+ * the exec's own `timeout` option, so a stalled exec that never honors `timeout`
141
+ * still can't outlast this.
142
+ */
143
+ export function clientExecTimeoutMs(timeoutMs: number): number {
144
+ return outerTimeoutMs(timeoutMs) + 5000;
145
+ }
146
+
147
+ /**
148
+ * Client-side backstop timeout for a native-DO sandbox FILE-IO RPC
149
+ * (`readFile`/`writeFile`/`listFiles`/`mkdir`/`deleteFile`). Unlike `exec()`
150
+ * there is no in-sandbox `timeout(1)` layer for these to honor, so this is just a
151
+ * few seconds of headroom over the configured tool timeout — enough that a normal
152
+ * (even large, byte-capped) read completes, while a stalled/cold container can't
153
+ * outlast it. See `withClientTimeout` for why the native DO RPC needs this.
154
+ */
155
+ export function clientFsTimeoutMs(timeoutMs: number): number {
156
+ return timeoutMs + 5000;
157
+ }
158
+
159
+ /**
160
+ * Bound a `sandbox.exec()` await with a CLIENT-SIDE timeout.
161
+ *
162
+ * The native Cloudflare Sandbox Durable Object `exec()` is effectively
163
+ * uncancellable from the host: `ExecOptions` has no `signal` (so
164
+ * `supportsExecSignal` is false for the native transport), and its `timeout`
165
+ * option is not reliably enforced when the container/RPC itself stalls — while
166
+ * the in-sandbox `timeout(1)` wrapper only bounds a command that is actually
167
+ * running. So a stalled exec (an unresponsive/cold container) otherwise hangs
168
+ * until the host's run-level abort, burning the whole run budget on one tool
169
+ * call. This race guarantees the host await settles within `timeoutMs`
170
+ * regardless of the transport.
171
+ *
172
+ * On timeout the underlying `exec` promise may keep running in the DO (a
173
+ * native-DO exec cannot be truly cancelled), so its late settlement is swallowed
174
+ * to avoid an unhandled rejection.
175
+ */
176
+ export async function withClientTimeout<T>(
177
+ exec: Promise<T>,
178
+ timeoutMs: number,
179
+ label: string,
180
+ options: {
181
+ /**
182
+ * Detach the backstop timer from the event loop. Use ONLY when something else
183
+ * already settles the caller (e.g. the spawn path, where spawnLocalProcess's
184
+ * own timer resolves the child). The awaited direct-exec paths must leave it
185
+ * REF'd so the timeout is guaranteed to fire even if nothing else is pending.
186
+ */
187
+ unref?: boolean;
188
+ /** Invoked when the client timeout fires — e.g. abort a signal-aware exec. */
189
+ onTimeout?: () => void;
190
+ } = {}
191
+ ): Promise<T> {
192
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
193
+ return exec;
194
+ }
195
+ // Swallow a late rejection from the losing promise after the race settles.
196
+ exec.catch(() => undefined);
197
+ let timer: ReturnType<typeof setTimeout> | undefined;
198
+ try {
199
+ return await Promise.race([
200
+ exec,
201
+ new Promise<never>((_resolve, reject) => {
202
+ timer = setTimeout(() => {
203
+ // Reject FIRST so this client-timeout message reliably wins the race;
204
+ // only then abort a signal-aware exec, whose resulting AbortError must
205
+ // not surface to the caller instead of the timeout.
206
+ reject(
207
+ new WorkspaceClientTimeoutError(
208
+ `${label} exceeded ${timeoutMs}ms client-side timeout (sandbox RPC did not return)`
209
+ )
210
+ );
211
+ options.onTimeout?.();
212
+ }, timeoutMs);
213
+ if (options.unref === true) {
214
+ (timer as { unref?: () => void } | undefined)?.unref?.();
215
+ }
216
+ }),
217
+ ]);
218
+ } finally {
219
+ if (timer !== undefined) {
220
+ clearTimeout(timer);
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Run `sandbox.exec()` bounded by a client-side timeout, and — for signal-aware
227
+ * transports (e.g. the HTTP bridge, `supportsExecSignal === true`) — abort the
228
+ * underlying exec when the timeout fires instead of merely abandoning it. The
229
+ * native DO transport ignores `signal`, so it only gets the timeout. Leaves the
230
+ * backstop timer ref'd (this is an awaited direct-exec path).
231
+ */
232
+ export async function execWithClientTimeout(
233
+ sandbox: t.CloudflareSandboxRuntime,
234
+ command: string,
235
+ options: t.CloudflareSandboxExecOptions,
236
+ timeoutMs: number,
237
+ label: string,
238
+ runOptions: { unref?: boolean } = {}
239
+ ): Promise<t.CloudflareSandboxExecResult> {
240
+ const controller = new AbortController();
241
+ const execOptions: t.CloudflareSandboxExecOptions = { ...options };
242
+ const callerSignal = options.signal;
243
+ let onCallerAbort: (() => void) | undefined;
244
+ if (sandbox.supportsExecSignal === true) {
245
+ // Compose the caller's signal (e.g. run/user cancellation) with our timeout
246
+ // controller so EITHER source cancels the exec — don't clobber the caller's.
247
+ if (callerSignal != null) {
248
+ if (callerSignal.aborted) {
249
+ controller.abort();
250
+ } else {
251
+ onCallerAbort = (): void => controller.abort();
252
+ callerSignal.addEventListener('abort', onCallerAbort, { once: true });
253
+ }
254
+ }
255
+ execOptions.signal = controller.signal;
256
+ } else if ('signal' in execOptions) {
257
+ // Native DO RPC cannot consume an AbortSignal (and would fail to clone it).
258
+ // Strip any caller-provided one so the spread above can't reintroduce it.
259
+ delete execOptions.signal;
260
+ }
261
+ try {
262
+ return await withClientTimeout(
263
+ sandbox.exec(command, execOptions),
264
+ timeoutMs,
265
+ label,
266
+ {
267
+ unref: runOptions.unref,
268
+ onTimeout: () => controller.abort(),
269
+ }
270
+ );
271
+ } finally {
272
+ // Don't leave a listener attached to a long-lived/shared caller signal.
273
+ if (onCallerAbort != null && callerSignal != null) {
274
+ callerSignal.removeEventListener('abort', onCallerAbort);
275
+ }
276
+ }
277
+ }
278
+
134
279
  function truncateOutput(value: string, maxChars: number): string {
135
280
  if (maxChars <= 0 || value.length <= maxChars) {
136
281
  return value;
@@ -283,12 +428,17 @@ function createDirent(info: t.CloudflareSandboxFileInfo): ReaddirEntry {
283
428
 
284
429
  async function findChildInfo(
285
430
  sandbox: t.CloudflareSandboxRuntime,
286
- filePath: string
431
+ filePath: string,
432
+ timeoutMs: number
287
433
  ): Promise<t.CloudflareSandboxFileInfo | undefined> {
288
434
  const parent = path.dirname(filePath);
289
435
  const basename = path.basename(filePath);
290
436
  const entries = normalizeFileList(
291
- await sandbox.listFiles(parent, { includeHidden: true })
437
+ await withClientTimeout(
438
+ sandbox.listFiles(parent, { includeHidden: true }),
439
+ timeoutMs,
440
+ 'cloudflare sandbox listFiles'
441
+ )
292
442
  );
293
443
  return entries.find((entry) => {
294
444
  const absolute = entryAbsolutePath(entry, parent);
@@ -300,13 +450,29 @@ export function createCloudflareWorkspaceFS(
300
450
  config: t.CloudflareSandboxExecutionConfig
301
451
  ): WorkspaceFS {
302
452
  const workspaceRoot = getCloudflareWorkspaceRoot(config);
453
+ // Native-DO file-IO RPCs have the SAME stall hazard as exec() (PR #252): no
454
+ // `signal`, no reliably-enforced timeout, so a cold/unresponsive container
455
+ // hangs the host await until the run-level abort — burning the whole budget on
456
+ // one read (observed: a `read_file` that stalled ~552s before the wall-clock
457
+ // budget killed it). Bound every native FS RPC with the same client-side
458
+ // backstop the exec sites use.
459
+ const fsTimeoutMs = clientFsTimeoutMs(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
460
+ const bound = <T>(op: Promise<T>, label: string): Promise<T> =>
461
+ withClientTimeout(op, fsTimeoutMs, `cloudflare sandbox ${label}`);
303
462
 
304
463
  const fs: WorkspaceFS = {
305
464
  readFile: (async (filePath: string, encoding?: 'utf8') => {
306
465
  const sandbox = await resolveCloudflareSandbox(config);
307
466
  const resolved = toSandboxPath(filePath, workspaceRoot);
308
- const buffer = await normalizeReadFileContent(
309
- await sandbox.readFile(resolved, encoding ? { encoding } : undefined)
467
+ // Wrap the stream drain (normalizeReadFileContent) inside the backstop too:
468
+ // a sandbox.readFile that resolves to a { content: ReadableStream } can
469
+ // still stall mid-drain after the RPC promise settled.
470
+ const buffer = await bound(
471
+ (async (): Promise<Buffer> =>
472
+ normalizeReadFileContent(
473
+ await sandbox.readFile(resolved, encoding ? { encoding } : undefined)
474
+ ))(),
475
+ 'readFile'
310
476
  );
311
477
  return encoding != null ? buffer.toString(encoding) : buffer;
312
478
  }) as WorkspaceFS['readFile'],
@@ -318,29 +484,46 @@ export function createCloudflareWorkspaceFS(
318
484
  const sandbox = await resolveCloudflareSandbox(config);
319
485
  const resolved = toSandboxPath(filePath, workspaceRoot);
320
486
  const normalized = normalizeWriteFileContent(content);
321
- await sandbox.writeFile(resolved, normalized.content, normalized.options);
487
+ await bound(
488
+ sandbox.writeFile(resolved, normalized.content, normalized.options),
489
+ 'writeFile'
490
+ );
322
491
  },
323
492
  stat: async (filePath: string) => {
324
493
  const sandbox = await resolveCloudflareSandbox(config);
325
494
  const resolved = toSandboxPath(filePath, workspaceRoot);
326
495
  if (resolved === workspaceRoot) {
327
496
  const entries = normalizeFileList(
328
- await sandbox.listFiles(resolved, { includeHidden: true })
497
+ await bound(
498
+ sandbox.listFiles(resolved, { includeHidden: true }),
499
+ 'listFiles'
500
+ )
329
501
  );
330
502
  return createStats({ size: entries.length, type: 'directory' });
331
503
  }
332
- const info = await findChildInfo(sandbox, resolved);
504
+ const info = await findChildInfo(sandbox, resolved, fsTimeoutMs);
333
505
  if (info != null) {
334
506
  return createStats({ size: info.size, type: info.type });
335
507
  }
336
508
  try {
337
509
  const entries = normalizeFileList(
338
- await sandbox.listFiles(resolved, { includeHidden: true })
510
+ await bound(
511
+ sandbox.listFiles(resolved, { includeHidden: true }),
512
+ 'listFiles'
513
+ )
339
514
  );
340
515
  return createStats({ size: entries.length, type: 'directory' });
341
- } catch {
342
- const buffer = await normalizeReadFileContent(
343
- await sandbox.readFile(resolved)
516
+ } catch (error) {
517
+ // A directory-probe timeout is a stalled container, not "not a directory".
518
+ // Don't fall through to the readFile branch — that would wait through a
519
+ // SECOND full backstop (~2x the timeout) before surfacing.
520
+ if (isWorkspaceClientTimeoutError(error)) {
521
+ throw error;
522
+ }
523
+ const buffer = await bound(
524
+ (async (): Promise<Buffer> =>
525
+ normalizeReadFileContent(await sandbox.readFile(resolved)))(),
526
+ 'readFile'
344
527
  );
345
528
  return createStats({ size: buffer.length, type: 'file' });
346
529
  }
@@ -349,7 +532,10 @@ export function createCloudflareWorkspaceFS(
349
532
  const sandbox = await resolveCloudflareSandbox(config);
350
533
  const resolved = toSandboxPath(filePath, workspaceRoot);
351
534
  const entries = normalizeFileList(
352
- await sandbox.listFiles(resolved, { includeHidden: true })
535
+ await bound(
536
+ sandbox.listFiles(resolved, { includeHidden: true }),
537
+ 'listFiles'
538
+ )
353
539
  );
354
540
  if (options?.withFileTypes === true) {
355
541
  return entries.map(createDirent);
@@ -358,21 +544,29 @@ export function createCloudflareWorkspaceFS(
358
544
  }) as WorkspaceFS['readdir'],
359
545
  mkdir: async (filePath: string, options?: MakeDirectoryOptions) => {
360
546
  const sandbox = await resolveCloudflareSandbox(config);
361
- await sandbox.mkdir(toSandboxPath(filePath, workspaceRoot), {
362
- recursive: options?.recursive,
363
- });
547
+ await bound(
548
+ sandbox.mkdir(toSandboxPath(filePath, workspaceRoot), {
549
+ recursive: options?.recursive,
550
+ }),
551
+ 'mkdir'
552
+ );
364
553
  },
365
554
  realpath: async (filePath: string) =>
366
555
  toSandboxPath(filePath, workspaceRoot),
367
556
  unlink: async (filePath: string) => {
368
557
  const sandbox = await resolveCloudflareSandbox(config);
369
- await sandbox.deleteFile(toSandboxPath(filePath, workspaceRoot));
558
+ await bound(
559
+ sandbox.deleteFile(toSandboxPath(filePath, workspaceRoot)),
560
+ 'deleteFile'
561
+ );
370
562
  },
371
563
  open: async (filePath: string, _flags: 'r') => {
372
564
  const sandbox = await resolveCloudflareSandbox(config);
373
565
  const resolved = toSandboxPath(filePath, workspaceRoot);
374
- const buffer = await normalizeReadFileContent(
375
- await sandbox.readFile(resolved)
566
+ const buffer = await bound(
567
+ (async (): Promise<Buffer> =>
568
+ normalizeReadFileContent(await sandbox.readFile(resolved)))(),
569
+ 'readFile'
376
570
  );
377
571
  return {
378
572
  read: async (
@@ -466,7 +660,14 @@ function createCloudflareSpawn(
466
660
  execOptions.signal = abortController.signal;
467
661
  }
468
662
  try {
469
- const result = await ctx.sandbox.exec(timedCommand, execOptions);
663
+ const result = await withClientTimeout(
664
+ ctx.sandbox.exec(timedCommand, execOptions),
665
+ clientExecTimeoutMs(timeoutMs),
666
+ 'cloudflare sandbox exec',
667
+ // spawnLocalProcess's own timer already resolves the child, so this
668
+ // backstop may safely detach; abort the (signal-aware) exec on timeout.
669
+ { unref: true, onTimeout: () => abortController.abort() }
670
+ );
470
671
  if (isClosed()) {
471
672
  return;
472
673
  }
@@ -551,13 +752,16 @@ export async function executeCloudflareBash(
551
752
  args.length > 0
552
753
  ? `${ctx.shell} -lc ${quote(command)} -- ${args.map(quote).join(' ')}`
553
754
  : `${ctx.shell} -lc ${quote(command)}`;
554
- const result = await ctx.sandbox.exec(
755
+ const result = await execWithClientTimeout(
756
+ ctx.sandbox,
555
757
  withInSandboxTimeout(shellCommand, ctx.timeoutMs),
556
758
  {
557
759
  cwd: ctx.workspaceRoot,
558
760
  env: ctx.env,
559
761
  timeout: outerTimeoutMs(ctx.timeoutMs),
560
- }
762
+ },
763
+ clientExecTimeoutMs(ctx.timeoutMs),
764
+ 'cloudflare sandbox bash exec'
561
765
  );
562
766
  return {
563
767
  stdout: truncateOutput(result.stdout, ctx.maxOutputChars),
@@ -693,25 +897,41 @@ export async function executeCloudflareCode(
693
897
  input.args,
694
898
  ctx.shell
695
899
  );
696
- await ctx.sandbox.mkdir(tempDir, { recursive: true });
697
- if (runtime.source != null) {
698
- await ctx.sandbox.writeFile(
699
- path.join(tempDir, runtime.fileName),
700
- runtime.source,
701
- {
702
- encoding: 'utf8',
703
- }
704
- );
705
- }
900
+ let execSucceeded = false;
706
901
  try {
707
- const result = await ctx.sandbox.exec(
902
+ // Bound the temp-dir setup RPCs (they run BEFORE the bounded exec): a
903
+ // native-DO stall here would hang the host on a single mkdir/writeFile and
904
+ // burn the run budget. Keep them INSIDE the try so the finally cleanup still
905
+ // removes .lc-exec/<uuid> if setup throws — the uncancellable write can land
906
+ // late on a cold container, so an orphaned dir would otherwise accumulate.
907
+ await withClientTimeout(
908
+ ctx.sandbox.mkdir(tempDir, { recursive: true }),
909
+ clientFsTimeoutMs(ctx.timeoutMs),
910
+ 'cloudflare sandbox mkdir'
911
+ );
912
+ if (runtime.source != null) {
913
+ await withClientTimeout(
914
+ ctx.sandbox.writeFile(
915
+ path.join(tempDir, runtime.fileName),
916
+ runtime.source,
917
+ { encoding: 'utf8' }
918
+ ),
919
+ clientFsTimeoutMs(ctx.timeoutMs),
920
+ 'cloudflare sandbox writeFile'
921
+ );
922
+ }
923
+ const result = await execWithClientTimeout(
924
+ ctx.sandbox,
708
925
  withInSandboxTimeout(runtime.command, ctx.timeoutMs),
709
926
  {
710
927
  cwd: ctx.workspaceRoot,
711
928
  env: ctx.env,
712
929
  timeout: outerTimeoutMs(ctx.timeoutMs),
713
- }
930
+ },
931
+ clientExecTimeoutMs(ctx.timeoutMs),
932
+ 'cloudflare sandbox code-exec'
714
933
  );
934
+ execSucceeded = true;
715
935
  return {
716
936
  stdout: truncateOutput(result.stdout, ctx.maxOutputChars),
717
937
  stderr: truncateOutput(result.stderr, ctx.maxOutputChars),
@@ -719,13 +939,25 @@ export async function executeCloudflareCode(
719
939
  timedOut: isInSandboxTimeoutExit(result.exitCode),
720
940
  };
721
941
  } finally {
722
- await ctx.sandbox
723
- .exec(`rm -rf ${quote(tempDir)}`, {
942
+ // After a normal run, AWAIT cleanup so the temp dir is gone before returning.
943
+ // After a stalled/failed run, detach it (unref'd) so we don't pile a second
944
+ // client timeout onto the caller's latency; cleanup still runs best-effort.
945
+ const detach = !execSucceeded;
946
+ const cleanup = execWithClientTimeout(
947
+ ctx.sandbox,
948
+ `rm -rf ${quote(tempDir)}`,
949
+ {
724
950
  cwd: ctx.workspaceRoot,
725
951
  env: ctx.env,
726
952
  timeout: 10000,
727
- })
728
- .catch(() => undefined);
953
+ },
954
+ clientExecTimeoutMs(10000),
955
+ 'cloudflare sandbox cleanup',
956
+ { unref: detach }
957
+ ).catch(() => undefined);
958
+ if (!detach) {
959
+ await cleanup;
960
+ }
729
961
  }
730
962
  }
731
963
 
@@ -27,6 +27,7 @@ import { resolve } from 'path';
27
27
  import { tool } from '@langchain/core/tools';
28
28
  import type { DynamicStructuredTool } from '@langchain/core/tools';
29
29
  import type { WorkspaceFS } from './workspaceFS';
30
+ import { isWorkspaceClientTimeoutError } from './workspaceFS';
30
31
  import type * as t from '@/types';
31
32
  import {
32
33
  getLocalCwd,
@@ -74,7 +75,12 @@ async function pathExists(fs: WorkspaceFS, p: string): Promise<boolean> {
74
75
  try {
75
76
  await fs.stat(p);
76
77
  return true;
77
- } catch {
78
+ } catch (error) {
79
+ // A stalled-RPC timeout is not "absent" — don't let it pick a weaker/wrong
80
+ // toolchain after waiting through the backstop; surface it.
81
+ if (isWorkspaceClientTimeoutError(error)) {
82
+ throw error;
83
+ }
78
84
  return false;
79
85
  }
80
86
  }
@@ -94,7 +100,12 @@ async function detect(cwd: string, fs: WorkspaceFS): Promise<Detection> {
94
100
  if (await pathExists(fs, resolve(cwd, 'package.json'))) {
95
101
  const pkgRaw = await fs
96
102
  .readFile(resolve(cwd, 'package.json'), 'utf8')
97
- .catch(() => '');
103
+ .catch((error) => {
104
+ if (isWorkspaceClientTimeoutError(error)) {
105
+ throw error;
106
+ }
107
+ return '';
108
+ });
98
109
  if (pkgRaw.includes('"typescript"')) {
99
110
  return {
100
111
  kind: 'typescript',
@@ -124,7 +135,12 @@ async function detect(cwd: string, fs: WorkspaceFS): Promise<Detection> {
124
135
  ) {
125
136
  const pyToml = await fs
126
137
  .readFile(resolve(cwd, 'pyproject.toml'), 'utf8')
127
- .catch(() => '');
138
+ .catch((error) => {
139
+ if (isWorkspaceClientTimeoutError(error)) {
140
+ throw error;
141
+ }
142
+ return '';
143
+ });
128
144
  if (pyToml.includes('mypy')) {
129
145
  return {
130
146
  kind: 'python-mypy',
@@ -1,7 +1,7 @@
1
1
  import { dirname } from 'path';
2
2
  import type { WorkspaceFS } from './workspaceFS';
3
3
  import type * as t from '@/types';
4
- import { nodeWorkspaceFS } from './workspaceFS';
4
+ import { isWorkspaceClientTimeoutError, nodeWorkspaceFS } from './workspaceFS';
5
5
 
6
6
  type Snapshot = { kind: 'absent' } | { kind: 'present'; content: Buffer };
7
7
 
@@ -41,7 +41,12 @@ export class LocalFileCheckpointerImpl implements t.LocalFileCheckpointer {
41
41
  let info;
42
42
  try {
43
43
  info = await this.fs.stat(absolutePath);
44
- } catch {
44
+ } catch (error) {
45
+ // A stalled-RPC timeout is NOT "file absent" — snapshotting it as absent
46
+ // would delete an existing file on revert. Surface it instead.
47
+ if (isWorkspaceClientTimeoutError(error)) {
48
+ throw error;
49
+ }
45
50
  this.snapshots.set(absolutePath, { kind: 'absent' });
46
51
  return;
47
52
  }
@@ -60,7 +65,14 @@ export class LocalFileCheckpointerImpl implements t.LocalFileCheckpointer {
60
65
  let restored = 0;
61
66
  for (const [path, snapshot] of this.snapshots.entries()) {
62
67
  if (snapshot.kind === 'absent') {
63
- await this.fs.unlink(path).catch(() => undefined);
68
+ await this.fs.unlink(path).catch((error) => {
69
+ // A timed-out delete did NOT happen — surface it rather than counting
70
+ // the path as restored (which would falsely claim the workspace is
71
+ // back to its pre-write state).
72
+ if (isWorkspaceClientTimeoutError(error)) {
73
+ throw error;
74
+ }
75
+ });
64
76
  restored++;
65
77
  continue;
66
78
  }
@@ -68,7 +80,11 @@ export class LocalFileCheckpointerImpl implements t.LocalFileCheckpointer {
68
80
  await this.fs.mkdir(dirname(path), { recursive: true });
69
81
  await this.fs.writeFile(path, snapshot.content);
70
82
  restored++;
71
- } catch {
83
+ } catch (error) {
84
+ // A timed-out restore left the bad write in place — surface it.
85
+ if (isWorkspaceClientTimeoutError(error)) {
86
+ throw error;
87
+ }
72
88
  // Best-effort: ignore individual restore failures so the rest
73
89
  // of the rewind continues.
74
90
  }