@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.
- package/dist/cjs/agents/AgentContext.cjs +4 -3
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +5 -2
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +9 -1
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +6 -0
- package/dist/cjs/messages/cache.cjs +43 -0
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/session/JsonlSessionStore.cjs +1 -1
- package/dist/cjs/tools/cloudflare/CloudflareProgrammaticToolCalling.cjs +2 -2
- package/dist/cjs/tools/cloudflare/CloudflareProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs +118 -22
- package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs.map +1 -1
- package/dist/cjs/tools/local/CompileCheckTool.cjs +11 -3
- package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
- package/dist/cjs/tools/local/FileCheckpointer.cjs +8 -3
- package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -1
- package/dist/cjs/tools/local/LocalCodingTools.cjs +26 -7
- package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs +2 -2
- package/dist/cjs/tools/local/syntaxCheck.cjs +6 -2
- package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -1
- package/dist/cjs/tools/local/workspaceFS.cjs +20 -0
- package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +5 -4
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +6 -3
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
- package/dist/esm/llm/bedrock/index.mjs +10 -2
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -3
- package/dist/esm/messages/cache.mjs +42 -1
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/session/JsonlSessionStore.mjs +1 -1
- package/dist/esm/tools/cloudflare/CloudflareProgrammaticToolCalling.mjs +3 -3
- package/dist/esm/tools/cloudflare/CloudflareProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs +115 -23
- package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs.map +1 -1
- package/dist/esm/tools/local/CompileCheckTool.mjs +11 -3
- package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
- package/dist/esm/tools/local/FileCheckpointer.mjs +9 -4
- package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -1
- package/dist/esm/tools/local/LocalCodingTools.mjs +26 -7
- package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
- package/dist/esm/tools/local/LocalExecutionEngine.mjs +2 -2
- package/dist/esm/tools/local/syntaxCheck.mjs +6 -2
- package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -1
- package/dist/esm/tools/local/workspaceFS.mjs +19 -1
- package/dist/esm/tools/local/workspaceFS.mjs.map +1 -1
- package/dist/types/agents/AgentContext.d.ts +3 -2
- package/dist/types/llm/bedrock/index.d.ts +7 -0
- package/dist/types/messages/cache.d.ts +27 -0
- package/dist/types/tools/cloudflare/CloudflareSandboxExecutionEngine.d.ts +53 -0
- package/dist/types/tools/local/workspaceFS.d.ts +13 -0
- package/package.json +1 -1
- package/src/agents/AgentContext.ts +10 -3
- package/src/graphs/Graph.ts +24 -6
- package/src/llm/bedrock/index.ts +19 -3
- package/src/llm/bedrock/llm.spec.ts +97 -0
- package/src/messages/cache.test.ts +63 -0
- package/src/messages/cache.ts +51 -0
- package/src/specs/vllm-reasoning-toolcalls.test.ts +340 -0
- package/src/tools/__tests__/CloudflareSandboxExecution.test.ts +323 -0
- package/src/tools/__tests__/LocalExecutionTools.test.ts +54 -0
- package/src/tools/cloudflare/CloudflareProgrammaticToolCalling.ts +7 -2
- package/src/tools/cloudflare/CloudflareSandboxExecutionEngine.ts +269 -37
- package/src/tools/local/CompileCheckTool.ts +19 -3
- package/src/tools/local/FileCheckpointer.ts +20 -4
- package/src/tools/local/LocalCodingTools.ts +61 -8
- package/src/tools/local/__tests__/FileCheckpointer.test.ts +42 -0
- package/src/tools/local/syntaxCheck.ts +14 -2
- 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
|
|
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
|
|
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
|
-
|
|
309
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
343
|
-
|
|
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
|
|
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
|
|
362
|
-
|
|
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
|
|
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
|
|
375
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
723
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
}
|