@librechat/agents 3.2.42 → 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 (68) 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 +1 -1
  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 +1 -1
  7. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  8. package/dist/cjs/main.cjs +2 -0
  9. package/dist/cjs/messages/cache.cjs +26 -4
  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/CloudflareSandboxExecutionEngine.cjs +32 -16
  13. package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs.map +1 -1
  14. package/dist/cjs/tools/local/CompileCheckTool.cjs +11 -3
  15. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
  16. package/dist/cjs/tools/local/FileCheckpointer.cjs +8 -3
  17. package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -1
  18. package/dist/cjs/tools/local/LocalCodingTools.cjs +26 -7
  19. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
  20. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +2 -2
  21. package/dist/cjs/tools/local/syntaxCheck.cjs +6 -2
  22. package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -1
  23. package/dist/cjs/tools/local/workspaceFS.cjs +20 -0
  24. package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -1
  25. package/dist/esm/agents/AgentContext.mjs +5 -4
  26. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  27. package/dist/esm/graphs/Graph.mjs +2 -2
  28. package/dist/esm/graphs/Graph.mjs.map +1 -1
  29. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  30. package/dist/esm/llm/bedrock/index.mjs +2 -2
  31. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  32. package/dist/esm/main.mjs +3 -3
  33. package/dist/esm/messages/cache.mjs +26 -5
  34. package/dist/esm/messages/cache.mjs.map +1 -1
  35. package/dist/esm/session/JsonlSessionStore.mjs +1 -1
  36. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs +32 -17
  37. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs.map +1 -1
  38. package/dist/esm/tools/local/CompileCheckTool.mjs +11 -3
  39. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
  40. package/dist/esm/tools/local/FileCheckpointer.mjs +9 -4
  41. package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -1
  42. package/dist/esm/tools/local/LocalCodingTools.mjs +26 -7
  43. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
  44. package/dist/esm/tools/local/LocalExecutionEngine.mjs +2 -2
  45. package/dist/esm/tools/local/syntaxCheck.mjs +6 -2
  46. package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -1
  47. package/dist/esm/tools/local/workspaceFS.mjs +19 -1
  48. package/dist/esm/tools/local/workspaceFS.mjs.map +1 -1
  49. package/dist/types/agents/AgentContext.d.ts +3 -2
  50. package/dist/types/messages/cache.d.ts +12 -2
  51. package/dist/types/tools/cloudflare/CloudflareSandboxExecutionEngine.d.ts +9 -0
  52. package/dist/types/tools/local/workspaceFS.d.ts +13 -0
  53. package/package.json +1 -1
  54. package/src/agents/AgentContext.ts +8 -3
  55. package/src/graphs/Graph.ts +7 -1
  56. package/src/llm/bedrock/index.ts +2 -2
  57. package/src/messages/cache.test.ts +32 -0
  58. package/src/messages/cache.ts +31 -5
  59. package/src/specs/vllm-reasoning-toolcalls.test.ts +340 -0
  60. package/src/tools/__tests__/CloudflareSandboxExecution.test.ts +192 -0
  61. package/src/tools/__tests__/LocalExecutionTools.test.ts +54 -0
  62. package/src/tools/cloudflare/CloudflareSandboxExecutionEngine.ts +106 -30
  63. package/src/tools/local/CompileCheckTool.ts +19 -3
  64. package/src/tools/local/FileCheckpointer.ts +20 -4
  65. package/src/tools/local/LocalCodingTools.ts +61 -8
  66. package/src/tools/local/__tests__/FileCheckpointer.test.ts +42 -0
  67. package/src/tools/local/syntaxCheck.ts +14 -2
  68. 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])', () => {
@@ -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,
@@ -140,6 +144,18 @@ export function clientExecTimeoutMs(timeoutMs: number): number {
140
144
  return outerTimeoutMs(timeoutMs) + 5000;
141
145
  }
142
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
+
143
159
  /**
144
160
  * Bound a `sandbox.exec()` await with a CLIENT-SIDE timeout.
145
161
  *
@@ -188,8 +204,8 @@ export async function withClientTimeout<T>(
188
204
  // only then abort a signal-aware exec, whose resulting AbortError must
189
205
  // not surface to the caller instead of the timeout.
190
206
  reject(
191
- new Error(
192
- `${label} exceeded ${timeoutMs}ms client-side timeout (sandbox exec did not return)`
207
+ new WorkspaceClientTimeoutError(
208
+ `${label} exceeded ${timeoutMs}ms client-side timeout (sandbox RPC did not return)`
193
209
  )
194
210
  );
195
211
  options.onTimeout?.();
@@ -412,12 +428,17 @@ function createDirent(info: t.CloudflareSandboxFileInfo): ReaddirEntry {
412
428
 
413
429
  async function findChildInfo(
414
430
  sandbox: t.CloudflareSandboxRuntime,
415
- filePath: string
431
+ filePath: string,
432
+ timeoutMs: number
416
433
  ): Promise<t.CloudflareSandboxFileInfo | undefined> {
417
434
  const parent = path.dirname(filePath);
418
435
  const basename = path.basename(filePath);
419
436
  const entries = normalizeFileList(
420
- await sandbox.listFiles(parent, { includeHidden: true })
437
+ await withClientTimeout(
438
+ sandbox.listFiles(parent, { includeHidden: true }),
439
+ timeoutMs,
440
+ 'cloudflare sandbox listFiles'
441
+ )
421
442
  );
422
443
  return entries.find((entry) => {
423
444
  const absolute = entryAbsolutePath(entry, parent);
@@ -429,13 +450,29 @@ export function createCloudflareWorkspaceFS(
429
450
  config: t.CloudflareSandboxExecutionConfig
430
451
  ): WorkspaceFS {
431
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}`);
432
462
 
433
463
  const fs: WorkspaceFS = {
434
464
  readFile: (async (filePath: string, encoding?: 'utf8') => {
435
465
  const sandbox = await resolveCloudflareSandbox(config);
436
466
  const resolved = toSandboxPath(filePath, workspaceRoot);
437
- const buffer = await normalizeReadFileContent(
438
- 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'
439
476
  );
440
477
  return encoding != null ? buffer.toString(encoding) : buffer;
441
478
  }) as WorkspaceFS['readFile'],
@@ -447,29 +484,46 @@ export function createCloudflareWorkspaceFS(
447
484
  const sandbox = await resolveCloudflareSandbox(config);
448
485
  const resolved = toSandboxPath(filePath, workspaceRoot);
449
486
  const normalized = normalizeWriteFileContent(content);
450
- await sandbox.writeFile(resolved, normalized.content, normalized.options);
487
+ await bound(
488
+ sandbox.writeFile(resolved, normalized.content, normalized.options),
489
+ 'writeFile'
490
+ );
451
491
  },
452
492
  stat: async (filePath: string) => {
453
493
  const sandbox = await resolveCloudflareSandbox(config);
454
494
  const resolved = toSandboxPath(filePath, workspaceRoot);
455
495
  if (resolved === workspaceRoot) {
456
496
  const entries = normalizeFileList(
457
- await sandbox.listFiles(resolved, { includeHidden: true })
497
+ await bound(
498
+ sandbox.listFiles(resolved, { includeHidden: true }),
499
+ 'listFiles'
500
+ )
458
501
  );
459
502
  return createStats({ size: entries.length, type: 'directory' });
460
503
  }
461
- const info = await findChildInfo(sandbox, resolved);
504
+ const info = await findChildInfo(sandbox, resolved, fsTimeoutMs);
462
505
  if (info != null) {
463
506
  return createStats({ size: info.size, type: info.type });
464
507
  }
465
508
  try {
466
509
  const entries = normalizeFileList(
467
- await sandbox.listFiles(resolved, { includeHidden: true })
510
+ await bound(
511
+ sandbox.listFiles(resolved, { includeHidden: true }),
512
+ 'listFiles'
513
+ )
468
514
  );
469
515
  return createStats({ size: entries.length, type: 'directory' });
470
- } catch {
471
- const buffer = await normalizeReadFileContent(
472
- 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'
473
527
  );
474
528
  return createStats({ size: buffer.length, type: 'file' });
475
529
  }
@@ -478,7 +532,10 @@ export function createCloudflareWorkspaceFS(
478
532
  const sandbox = await resolveCloudflareSandbox(config);
479
533
  const resolved = toSandboxPath(filePath, workspaceRoot);
480
534
  const entries = normalizeFileList(
481
- await sandbox.listFiles(resolved, { includeHidden: true })
535
+ await bound(
536
+ sandbox.listFiles(resolved, { includeHidden: true }),
537
+ 'listFiles'
538
+ )
482
539
  );
483
540
  if (options?.withFileTypes === true) {
484
541
  return entries.map(createDirent);
@@ -487,21 +544,29 @@ export function createCloudflareWorkspaceFS(
487
544
  }) as WorkspaceFS['readdir'],
488
545
  mkdir: async (filePath: string, options?: MakeDirectoryOptions) => {
489
546
  const sandbox = await resolveCloudflareSandbox(config);
490
- await sandbox.mkdir(toSandboxPath(filePath, workspaceRoot), {
491
- recursive: options?.recursive,
492
- });
547
+ await bound(
548
+ sandbox.mkdir(toSandboxPath(filePath, workspaceRoot), {
549
+ recursive: options?.recursive,
550
+ }),
551
+ 'mkdir'
552
+ );
493
553
  },
494
554
  realpath: async (filePath: string) =>
495
555
  toSandboxPath(filePath, workspaceRoot),
496
556
  unlink: async (filePath: string) => {
497
557
  const sandbox = await resolveCloudflareSandbox(config);
498
- await sandbox.deleteFile(toSandboxPath(filePath, workspaceRoot));
558
+ await bound(
559
+ sandbox.deleteFile(toSandboxPath(filePath, workspaceRoot)),
560
+ 'deleteFile'
561
+ );
499
562
  },
500
563
  open: async (filePath: string, _flags: 'r') => {
501
564
  const sandbox = await resolveCloudflareSandbox(config);
502
565
  const resolved = toSandboxPath(filePath, workspaceRoot);
503
- const buffer = await normalizeReadFileContent(
504
- await sandbox.readFile(resolved)
566
+ const buffer = await bound(
567
+ (async (): Promise<Buffer> =>
568
+ normalizeReadFileContent(await sandbox.readFile(resolved)))(),
569
+ 'readFile'
505
570
  );
506
571
  return {
507
572
  read: async (
@@ -832,18 +897,29 @@ export async function executeCloudflareCode(
832
897
  input.args,
833
898
  ctx.shell
834
899
  );
835
- await ctx.sandbox.mkdir(tempDir, { recursive: true });
836
- if (runtime.source != null) {
837
- await ctx.sandbox.writeFile(
838
- path.join(tempDir, runtime.fileName),
839
- runtime.source,
840
- {
841
- encoding: 'utf8',
842
- }
843
- );
844
- }
845
900
  let execSucceeded = false;
846
901
  try {
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
+ }
847
923
  const result = await execWithClientTimeout(
848
924
  ctx.sandbox,
849
925
  withInSandboxTimeout(runtime.command, ctx.timeoutMs),
@@ -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
  }
@@ -3,6 +3,7 @@ import { createTwoFilesPatch } from 'diff';
3
3
  import { tool } from '@langchain/core/tools';
4
4
  import type { DynamicStructuredTool } from '@langchain/core/tools';
5
5
  import type * as t from '@/types';
6
+ import { isWorkspaceClientTimeoutError } from './workspaceFS';
6
7
  import {
7
8
  createLocalBashProgrammaticToolCallingTool,
8
9
  createLocalProgrammaticToolCallingTool,
@@ -294,8 +295,14 @@ async function revertStrictWrite(
294
295
  } else {
295
296
  await fs.unlink(path);
296
297
  }
297
- } catch {
298
- /* best-effort: caller still sees the original syntax error */
298
+ } catch (error) {
299
+ // A timed-out revert leaves the rejected write on disk — the caller would
300
+ // otherwise claim "reverted to pre-write state" while the bad bytes remain.
301
+ // Surface it; for other failures stay best-effort (caller sees the original
302
+ // syntax error).
303
+ if (isWorkspaceClientTimeoutError(error)) {
304
+ throw error;
305
+ }
299
306
  }
300
307
  }
301
308
 
@@ -538,7 +545,12 @@ export function createLocalWriteFileTool(
538
545
  before = decoded.text;
539
546
  encoding = decoded;
540
547
  existed = true;
541
- } catch {
548
+ } catch (error) {
549
+ // A stalled-RPC timeout is NOT "file absent" — treating it as such would
550
+ // overwrite an existing file with fresh content. Surface it instead.
551
+ if (isWorkspaceClientTimeoutError(error)) {
552
+ throw error;
553
+ }
542
554
  existed = false;
543
555
  }
544
556
 
@@ -546,7 +558,21 @@ export function createLocalWriteFileTool(
546
558
  const finalText = encodeFile(input.content, encoding);
547
559
  await fs.writeFile(path, finalText, 'utf8');
548
560
 
549
- const syntax = await maybeRunSyntaxCheck(path, config);
561
+ let syntax;
562
+ try {
563
+ syntax = await maybeRunSyntaxCheck(path, config);
564
+ } catch (error) {
565
+ // A validation-read timeout must still honor strict mode's revert
566
+ // contract: restore the pre-write state before surfacing (best-effort —
567
+ // the revert may itself time out on the same stalled container).
568
+ if (
569
+ isWorkspaceClientTimeoutError(error) &&
570
+ config.postEditSyntaxCheck === 'strict'
571
+ ) {
572
+ await revertStrictWrite(fs, path, existed, before, encoding);
573
+ }
574
+ throw error;
575
+ }
550
576
 
551
577
  const diff = existed
552
578
  ? summariseDiff(path, before, input.content)
@@ -642,7 +668,20 @@ export function createLocalEditFileTool(
642
668
  const finalText = encodeFile(next, encoding);
643
669
  await fs.writeFile(path, finalText, 'utf8');
644
670
 
645
- const syntax = await maybeRunSyntaxCheck(path, config);
671
+ let syntax;
672
+ try {
673
+ syntax = await maybeRunSyntaxCheck(path, config);
674
+ } catch (error) {
675
+ // As in write_file: a validation-read timeout still triggers strict
676
+ // mode's revert (edit_file always operates on an existing file).
677
+ if (
678
+ isWorkspaceClientTimeoutError(error) &&
679
+ config.postEditSyntaxCheck === 'strict'
680
+ ) {
681
+ await revertStrictWrite(fs, path, true, original, encoding);
682
+ }
683
+ throw error;
684
+ }
646
685
 
647
686
  const diff = summariseDiff(path, original, next);
648
687
  const fuzzy = strategiesUsed.some((s) => s !== 'exact');
@@ -826,7 +865,12 @@ async function* walkFiles(
826
865
  let entries;
827
866
  try {
828
867
  entries = await fs.readdir(dir, { withFileTypes: true });
829
- } catch {
868
+ } catch (error) {
869
+ // A stalled-RPC timeout must surface, not be silently skipped as an
870
+ // unreadable directory — that would corrupt search results ("no matches").
871
+ if (isWorkspaceClientTimeoutError(error)) {
872
+ throw error;
873
+ }
830
874
  continue;
831
875
  }
832
876
  for (const entry of entries) {
@@ -1009,7 +1053,12 @@ async function fallbackGrep(
1009
1053
  let stat;
1010
1054
  try {
1011
1055
  stat = await fs.stat(file);
1012
- } catch {
1056
+ } catch (error) {
1057
+ // A stalled-RPC timeout must surface, not be skipped — skipping a file can
1058
+ // report "no matches" even when the (unreadable-due-to-stall) file matched.
1059
+ if (isWorkspaceClientTimeoutError(error)) {
1060
+ throw error;
1061
+ }
1013
1062
  continue;
1014
1063
  }
1015
1064
  if (stat.size > FALLBACK_GREP_MAX_FILE_BYTES) {
@@ -1021,7 +1070,11 @@ async function fallbackGrep(
1021
1070
  let content;
1022
1071
  try {
1023
1072
  content = await fs.readFile(file, 'utf8');
1024
- } catch {
1073
+ } catch (error) {
1074
+ // As above: a client-timeout means a stalled read, not an unreadable file.
1075
+ if (isWorkspaceClientTimeoutError(error)) {
1076
+ throw error;
1077
+ }
1025
1078
  continue;
1026
1079
  }
1027
1080
  if (content.includes('\0')) {
@@ -3,6 +3,10 @@ import { join } from 'path';
3
3
  import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises';
4
4
  import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
5
5
  import { LocalFileCheckpointerImpl } from '../FileCheckpointer';
6
+ import {
7
+ WorkspaceClientTimeoutError,
8
+ type WorkspaceFS,
9
+ } from '../workspaceFS';
6
10
 
7
11
  /**
8
12
  * Pins the LocalFileCheckpointer's per-Run snapshot/rewind contract.
@@ -63,6 +67,44 @@ describe('LocalFileCheckpointerImpl', () => {
63
67
  await expect(stat(file)).rejects.toThrow();
64
68
  });
65
69
 
70
+ it('rethrows a client-timeout from stat instead of snapshotting as absent', async () => {
71
+ // A stalled stat() must NOT be recorded as "absent" — that would delete an
72
+ // existing file on rewind. The timeout has to surface.
73
+ const fs = {
74
+ stat: async () => {
75
+ throw new WorkspaceClientTimeoutError(
76
+ 'cloudflare sandbox listFiles exceeded 6000ms client-side timeout'
77
+ );
78
+ },
79
+ } as unknown as WorkspaceFS;
80
+ const cp = new LocalFileCheckpointerImpl(32 * 1024 * 1024, fs);
81
+
82
+ await expect(cp.captureBeforeWrite('/workspace/a.txt')).rejects.toThrow(
83
+ /client-side timeout/
84
+ );
85
+ // Nothing was snapshotted, so rewind is a no-op (no spurious deletion).
86
+ expect(await cp.rewind()).toBe(0);
87
+ });
88
+
89
+ it('rewind rethrows a client-timeout from a restore write instead of claiming success', async () => {
90
+ // A stalled restore write leaves the bad bytes on disk — rewind must surface
91
+ // the timeout, not silently count the path as restored.
92
+ const fs = {
93
+ stat: async () => ({ isFile: () => true, size: 5 }),
94
+ readFile: async () => Buffer.from('orig\n'),
95
+ mkdir: async () => undefined,
96
+ writeFile: async () => {
97
+ throw new WorkspaceClientTimeoutError(
98
+ 'cloudflare sandbox writeFile exceeded 6000ms client-side timeout'
99
+ );
100
+ },
101
+ } as unknown as WorkspaceFS;
102
+ const cp = new LocalFileCheckpointerImpl(32 * 1024 * 1024, fs);
103
+
104
+ await cp.captureBeforeWrite('/workspace/a.txt'); // snapshots 'present'
105
+ await expect(cp.rewind()).rejects.toThrow(/client-side timeout/);
106
+ });
107
+
66
108
  it('rewinds across multiple files in a single pass', async () => {
67
109
  const a = join(dir, 'multi-a.txt');
68
110
  const b = join(dir, 'multi-b.txt');
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { extname } from 'path';
20
20
  import type * as t from '@/types';
21
+ import { isWorkspaceClientTimeoutError } from './workspaceFS';
21
22
  import {
22
23
  getSpawn,
23
24
  getWorkspaceFS,
@@ -168,7 +169,13 @@ const jsonCheck: SyntaxChecker = async (path, config) => {
168
169
  // file → catch returns undefined → ok: true) or read a different
169
170
  // file with the same absolute path. Codex P1 #24.
170
171
  const fs = getWorkspaceFS(config);
171
- const raw = await fs.readFile(path, 'utf8').catch(() => undefined);
172
+ const raw = await fs.readFile(path, 'utf8').catch((error) => {
173
+ // A stalled read must not pass the syntax gate as ok:true — surface it.
174
+ if (isWorkspaceClientTimeoutError(error)) {
175
+ throw error;
176
+ }
177
+ return undefined;
178
+ });
172
179
  if (raw == null) return { ok: true };
173
180
  try {
174
181
  JSON.parse(raw);
@@ -237,7 +244,12 @@ export async function runPostEditSyntaxCheck(
237
244
  };
238
245
  }
239
246
  return result;
240
- } catch {
247
+ } catch (error) {
248
+ // Don't swallow a stalled-RPC timeout as "no checker outcome" (which would
249
+ // let the write through without a real syntax gate) — surface it.
250
+ if (isWorkspaceClientTimeoutError(error)) {
251
+ throw error;
252
+ }
241
253
  return null;
242
254
  }
243
255
  }
@@ -39,6 +39,33 @@ export type ReaddirEntry = {
39
39
  isSymbolicLink(): boolean;
40
40
  };
41
41
 
42
+ /**
43
+ * Thrown when a {@link WorkspaceFS} operation is abandoned by a client-side
44
+ * backstop timeout (a stalled remote/sandbox RPC that never returned). This is
45
+ * DISTINCT from "file not found": callers that catch generic FS failures as
46
+ * absence — e.g. a pre-read before an overwrite, or a `stat` before snapshotting
47
+ * — MUST rethrow this so a stalled read isn't mistaken for a missing file (which
48
+ * would otherwise overwrite an existing file or snapshot it as absent).
49
+ */
50
+ export class WorkspaceClientTimeoutError extends Error {
51
+ readonly code = 'WORKSPACE_CLIENT_TIMEOUT';
52
+ constructor(message: string) {
53
+ super(message);
54
+ this.name = 'WorkspaceClientTimeoutError';
55
+ }
56
+ }
57
+
58
+ export function isWorkspaceClientTimeoutError(
59
+ error: unknown
60
+ ): error is WorkspaceClientTimeoutError {
61
+ return (
62
+ error instanceof WorkspaceClientTimeoutError ||
63
+ (typeof error === 'object' &&
64
+ error !== null &&
65
+ (error as { code?: unknown }).code === 'WORKSPACE_CLIENT_TIMEOUT')
66
+ );
67
+ }
68
+
42
69
  export interface WorkspaceFS {
43
70
  readFile(path: string, encoding: 'utf8'): Promise<string>;
44
71
  readFile(path: string): Promise<Buffer>;