@librechat/agents 3.1.77 → 3.1.78
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/common/enum.cjs +54 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +155 -4
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +291 -0
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -0
- package/dist/cjs/main.cjs +90 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/anthropicToolCache.cjs +102 -0
- package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -0
- package/dist/cjs/messages/prune.cjs +27 -0
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/messages/recency.cjs +99 -0
- package/dist/cjs/messages/recency.cjs.map +1 -0
- package/dist/cjs/run.cjs +30 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +100 -6
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +635 -23
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/local/CompileCheckTool.cjs +227 -0
- package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -0
- package/dist/cjs/tools/local/FileCheckpointer.cjs +90 -0
- package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalCodingTools.cjs +1098 -0
- package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs +1042 -0
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalExecutionTools.cjs +122 -0
- package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +453 -0
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -0
- package/dist/cjs/tools/local/attachments.cjs +183 -0
- package/dist/cjs/tools/local/attachments.cjs.map +1 -0
- package/dist/cjs/tools/local/bashAst.cjs +129 -0
- package/dist/cjs/tools/local/bashAst.cjs.map +1 -0
- package/dist/cjs/tools/local/editStrategies.cjs +188 -0
- package/dist/cjs/tools/local/editStrategies.cjs.map +1 -0
- package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +141 -0
- package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -0
- package/dist/cjs/tools/local/syntaxCheck.cjs +182 -0
- package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -0
- package/dist/cjs/tools/local/textEncoding.cjs +30 -0
- package/dist/cjs/tools/local/textEncoding.cjs.map +1 -0
- package/dist/cjs/tools/local/workspaceFS.cjs +51 -0
- package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +31 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +53 -1
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +156 -5
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs +289 -0
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -0
- package/dist/esm/main.mjs +17 -2
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/messages/anthropicToolCache.mjs +99 -0
- package/dist/esm/messages/anthropicToolCache.mjs.map +1 -0
- package/dist/esm/messages/prune.mjs +26 -1
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/messages/recency.mjs +97 -0
- package/dist/esm/messages/recency.mjs.map +1 -0
- package/dist/esm/run.mjs +30 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +100 -6
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +635 -23
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/local/CompileCheckTool.mjs +223 -0
- package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -0
- package/dist/esm/tools/local/FileCheckpointer.mjs +87 -0
- package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -0
- package/dist/esm/tools/local/LocalCodingTools.mjs +1075 -0
- package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -0
- package/dist/esm/tools/local/LocalExecutionEngine.mjs +1022 -0
- package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -0
- package/dist/esm/tools/local/LocalExecutionTools.mjs +117 -0
- package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -0
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +448 -0
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -0
- package/dist/esm/tools/local/attachments.mjs +180 -0
- package/dist/esm/tools/local/attachments.mjs.map +1 -0
- package/dist/esm/tools/local/bashAst.mjs +126 -0
- package/dist/esm/tools/local/bashAst.mjs.map +1 -0
- package/dist/esm/tools/local/editStrategies.mjs +185 -0
- package/dist/esm/tools/local/editStrategies.mjs.map +1 -0
- package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +137 -0
- package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -0
- package/dist/esm/tools/local/syntaxCheck.mjs +179 -0
- package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -0
- package/dist/esm/tools/local/textEncoding.mjs +27 -0
- package/dist/esm/tools/local/textEncoding.mjs.map +1 -0
- package/dist/esm/tools/local/workspaceFS.mjs +49 -0
- package/dist/esm/tools/local/workspaceFS.mjs.map +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +31 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +39 -1
- package/dist/types/graphs/Graph.d.ts +34 -0
- package/dist/types/hooks/createWorkspacePolicyHook.d.ts +95 -0
- package/dist/types/hooks/index.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/messages/anthropicToolCache.d.ts +51 -0
- package/dist/types/messages/index.d.ts +2 -0
- package/dist/types/messages/prune.d.ts +11 -0
- package/dist/types/messages/recency.d.ts +64 -0
- package/dist/types/run.d.ts +21 -0
- package/dist/types/tools/ToolNode.d.ts +145 -2
- package/dist/types/tools/local/CompileCheckTool.d.ts +31 -0
- package/dist/types/tools/local/FileCheckpointer.d.ts +39 -0
- package/dist/types/tools/local/LocalCodingTools.d.ts +57 -0
- package/dist/types/tools/local/LocalExecutionEngine.d.ts +149 -0
- package/dist/types/tools/local/LocalExecutionTools.d.ts +9 -0
- package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +21 -0
- package/dist/types/tools/local/attachments.d.ts +84 -0
- package/dist/types/tools/local/bashAst.d.ts +11 -0
- package/dist/types/tools/local/editStrategies.d.ts +28 -0
- package/dist/types/tools/local/index.d.ts +12 -0
- package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +38 -0
- package/dist/types/tools/local/syntaxCheck.d.ts +42 -0
- package/dist/types/tools/local/textEncoding.d.ts +21 -0
- package/dist/types/tools/local/workspaceFS.d.ts +49 -0
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +29 -0
- package/dist/types/types/hitl.d.ts +56 -27
- package/dist/types/types/run.d.ts +8 -1
- package/dist/types/types/summarize.d.ts +30 -0
- package/dist/types/types/tools.d.ts +341 -6
- package/package.json +21 -2
- package/src/common/enum.ts +54 -0
- package/src/graphs/Graph.ts +173 -6
- package/src/hooks/__tests__/compactHooks.test.ts +38 -2
- package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +393 -0
- package/src/hooks/createWorkspacePolicyHook.ts +355 -0
- package/src/hooks/index.ts +6 -0
- package/src/index.ts +1 -0
- package/src/messages/__tests__/anthropicToolCache.test.ts +125 -0
- package/src/messages/__tests__/recency.test.ts +267 -0
- package/src/messages/anthropicToolCache.ts +116 -0
- package/src/messages/index.ts +2 -0
- package/src/messages/prune.ts +27 -1
- package/src/messages/recency.ts +155 -0
- package/src/run.ts +31 -0
- package/src/scripts/compare_pi_vs_ours.ts +840 -0
- package/src/scripts/local_engine.ts +166 -0
- package/src/scripts/local_engine_checkpointer.ts +205 -0
- package/src/scripts/local_engine_compile.ts +263 -0
- package/src/scripts/local_engine_hooks.ts +226 -0
- package/src/scripts/local_engine_image.ts +201 -0
- package/src/scripts/local_engine_ptc.ts +151 -0
- package/src/scripts/local_engine_workspace.ts +258 -0
- package/src/scripts/subagent-configurable-inheritance.ts +252 -0
- package/src/scripts/summarization-recency.ts +462 -0
- package/src/specs/prune.test.ts +39 -0
- package/src/summarization/__tests__/node.test.ts +499 -3
- package/src/summarization/node.ts +124 -7
- package/src/tools/ToolNode.ts +769 -20
- package/src/tools/__tests__/LocalExecutionTools.test.ts +2647 -0
- package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +175 -0
- package/src/tools/__tests__/SubagentExecutor.test.ts +148 -0
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +114 -0
- package/src/tools/__tests__/ToolNode.session.test.ts +84 -0
- package/src/tools/__tests__/directToolHITLResumeScope.test.ts +467 -0
- package/src/tools/__tests__/directToolHooks.test.ts +411 -0
- package/src/tools/__tests__/localToolNames.test.ts +73 -0
- package/src/tools/__tests__/workspaceSeam.test.ts +134 -0
- package/src/tools/local/CompileCheckTool.ts +278 -0
- package/src/tools/local/FileCheckpointer.ts +93 -0
- package/src/tools/local/LocalCodingTools.ts +1342 -0
- package/src/tools/local/LocalExecutionEngine.ts +1329 -0
- package/src/tools/local/LocalExecutionTools.ts +167 -0
- package/src/tools/local/LocalProgrammaticToolCalling.ts +594 -0
- package/src/tools/local/__tests__/FileCheckpointer.test.ts +120 -0
- package/src/tools/local/__tests__/editStrategies.test.ts +134 -0
- package/src/tools/local/attachments.ts +251 -0
- package/src/tools/local/bashAst.ts +151 -0
- package/src/tools/local/editStrategies.ts +188 -0
- package/src/tools/local/index.ts +12 -0
- package/src/tools/local/resolveLocalExecutionTools.ts +208 -0
- package/src/tools/local/syntaxCheck.ts +243 -0
- package/src/tools/local/textEncoding.ts +37 -0
- package/src/tools/local/workspaceFS.ts +89 -0
- package/src/tools/subagent/SubagentExecutor.ts +60 -0
- package/src/types/hitl.ts +56 -27
- package/src/types/run.ts +12 -1
- package/src/types/summarize.ts +31 -0
- package/src/types/tools.ts +359 -7
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, timingSafeEqual } from 'crypto';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { tool } from '@langchain/core/tools';
|
|
4
|
+
import type { AddressInfo } from 'net';
|
|
5
|
+
import type { IncomingMessage, ServerResponse } from 'http';
|
|
6
|
+
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
|
7
|
+
import type * as t from '@/types';
|
|
8
|
+
import { executeHooks } from '@/hooks';
|
|
9
|
+
import {
|
|
10
|
+
executeTools,
|
|
11
|
+
filterToolsByUsage,
|
|
12
|
+
formatCompletedResponse,
|
|
13
|
+
normalizeToPythonIdentifier,
|
|
14
|
+
ProgrammaticToolCallingName,
|
|
15
|
+
ProgrammaticToolCallingSchema,
|
|
16
|
+
ProgrammaticToolCallingDescription,
|
|
17
|
+
} from '@/tools/ProgrammaticToolCalling';
|
|
18
|
+
import {
|
|
19
|
+
BashProgrammaticToolCallingSchema,
|
|
20
|
+
BashProgrammaticToolCallingDescription,
|
|
21
|
+
filterBashToolsByUsage,
|
|
22
|
+
normalizeToBashIdentifier,
|
|
23
|
+
} from '@/tools/BashProgrammaticToolCalling';
|
|
24
|
+
import {
|
|
25
|
+
executeLocalBash,
|
|
26
|
+
executeLocalCode,
|
|
27
|
+
getLocalSessionId,
|
|
28
|
+
shellQuote,
|
|
29
|
+
} from './LocalExecutionEngine';
|
|
30
|
+
import { Constants } from '@/common';
|
|
31
|
+
|
|
32
|
+
const DEFAULT_TIMEOUT = 60000;
|
|
33
|
+
const LocalProgrammaticToolCallingSchema = {
|
|
34
|
+
...ProgrammaticToolCallingSchema,
|
|
35
|
+
properties: {
|
|
36
|
+
...ProgrammaticToolCallingSchema.properties,
|
|
37
|
+
lang: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
enum: ['py', 'python', 'bash', 'sh'],
|
|
40
|
+
default: 'bash',
|
|
41
|
+
description:
|
|
42
|
+
'Local engine runtime for orchestration code. Defaults to bash; use py/python for Python orchestration.',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
type ToolBridge = {
|
|
48
|
+
url: string;
|
|
49
|
+
token: string;
|
|
50
|
+
close: () => Promise<void>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type ToolRequest = {
|
|
54
|
+
id?: string;
|
|
55
|
+
name?: string;
|
|
56
|
+
input?: Record<string, unknown>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const BRIDGE_AUTH_HEADER = 'x-librechat-bridge-token';
|
|
60
|
+
|
|
61
|
+
function constantTimeEquals(a: string, b: string): boolean {
|
|
62
|
+
const aBuf = Buffer.from(a, 'utf8');
|
|
63
|
+
const bBuf = Buffer.from(b, 'utf8');
|
|
64
|
+
if (aBuf.length !== bBuf.length) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return timingSafeEqual(aBuf, bBuf);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type LocalProgrammaticRuntime = 'python' | 'bash';
|
|
71
|
+
|
|
72
|
+
type LocalProgrammaticParams = {
|
|
73
|
+
code: string;
|
|
74
|
+
timeout?: number;
|
|
75
|
+
lang?: string;
|
|
76
|
+
runtime?: string;
|
|
77
|
+
language?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type ToolFilter = (toolDefs: t.LCTool[], code: string) => t.LCTool[];
|
|
81
|
+
|
|
82
|
+
function resolveRuntime(params: LocalProgrammaticParams): LocalProgrammaticRuntime {
|
|
83
|
+
const rawRuntime = params.lang ?? params.runtime ?? params.language ?? 'bash';
|
|
84
|
+
return rawRuntime === 'py' || rawRuntime === 'python' ? 'python' : 'bash';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toSerializable(value: unknown): unknown {
|
|
88
|
+
if (value === undefined) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readRequestBody(req: IncomingMessage): Promise<ToolRequest> {
|
|
95
|
+
const chunks: Buffer[] = [];
|
|
96
|
+
for await (const chunk of req) {
|
|
97
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
98
|
+
}
|
|
99
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
100
|
+
if (raw === '') {
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
return JSON.parse(raw) as ToolRequest;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeJson(res: ServerResponse, status: number, value: unknown): void {
|
|
107
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
108
|
+
res.end(JSON.stringify(value));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run the host's `PreToolUse` hook chain for a single bridge call.
|
|
113
|
+
* Returns the (possibly rewritten) input and a `denyReason` if any
|
|
114
|
+
* matcher returned `decision: 'deny'` or `'ask'`. `'ask'` collapses
|
|
115
|
+
* to deny because the bridge can't raise a LangGraph interrupt from
|
|
116
|
+
* inside an HTTP handler — fail-closed matches the rest of the SDK
|
|
117
|
+
* when HITL is unavailable.
|
|
118
|
+
*
|
|
119
|
+
* @internal Exported for tests so the deny / allow / updatedInput /
|
|
120
|
+
* ask branches can be exercised without standing up the full HTTP
|
|
121
|
+
* bridge.
|
|
122
|
+
*/
|
|
123
|
+
export async function applyPreToolUseHooksForBridge(
|
|
124
|
+
hookContext: t.ProgrammaticHookContext,
|
|
125
|
+
toolName: string,
|
|
126
|
+
toolUseId: string,
|
|
127
|
+
toolInput: Record<string, unknown>
|
|
128
|
+
): Promise<{ input: Record<string, unknown>; denyReason?: string }> {
|
|
129
|
+
if (hookContext.registry == null) {
|
|
130
|
+
return { input: toolInput };
|
|
131
|
+
}
|
|
132
|
+
const result = await executeHooks({
|
|
133
|
+
registry: hookContext.registry,
|
|
134
|
+
input: {
|
|
135
|
+
hook_event_name: 'PreToolUse',
|
|
136
|
+
runId: hookContext.runId,
|
|
137
|
+
threadId: hookContext.threadId,
|
|
138
|
+
agentId: hookContext.agentId,
|
|
139
|
+
toolName,
|
|
140
|
+
toolInput,
|
|
141
|
+
toolUseId,
|
|
142
|
+
stepId: '',
|
|
143
|
+
turn: 0,
|
|
144
|
+
},
|
|
145
|
+
sessionId: hookContext.runId,
|
|
146
|
+
matchQuery: toolName,
|
|
147
|
+
}).catch(() => undefined);
|
|
148
|
+
if (result == null) {
|
|
149
|
+
return { input: toolInput };
|
|
150
|
+
}
|
|
151
|
+
const nextInput =
|
|
152
|
+
result.updatedInput != null
|
|
153
|
+
? (result.updatedInput as Record<string, unknown>)
|
|
154
|
+
: toolInput;
|
|
155
|
+
if (result.decision === 'deny' || result.decision === 'ask') {
|
|
156
|
+
return {
|
|
157
|
+
input: nextInput,
|
|
158
|
+
denyReason:
|
|
159
|
+
result.reason ??
|
|
160
|
+
(result.decision === 'ask'
|
|
161
|
+
? `Tool "${toolName}" requires human approval; bridge cannot raise an interrupt — denying.`
|
|
162
|
+
: `Tool "${toolName}" denied by PreToolUse hook.`),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return { input: nextInput };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function createToolBridge(
|
|
169
|
+
toolMap: t.ToolMap,
|
|
170
|
+
hookContext?: t.ProgrammaticHookContext
|
|
171
|
+
): Promise<ToolBridge> {
|
|
172
|
+
const token = randomBytes(32).toString('hex');
|
|
173
|
+
const server = createServer((req, res) => {
|
|
174
|
+
// `?mode=text` returns the already-serialized result as the body
|
|
175
|
+
// (or the error message at non-2xx). Python/Node callers stay on
|
|
176
|
+
// JSON; bash callers using curl can avoid pulling in a JSON
|
|
177
|
+
// parser dependency (Codex P2 #19 — `python3` was a hard
|
|
178
|
+
// requirement for the bash bridge, breaking minimal containers).
|
|
179
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
180
|
+
const isTextMode = url.searchParams.get('mode') === 'text';
|
|
181
|
+
if (req.method !== 'POST' || url.pathname !== '/tool') {
|
|
182
|
+
if (isTextMode) {
|
|
183
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
184
|
+
res.end('Not found');
|
|
185
|
+
} else {
|
|
186
|
+
writeJson(res, 404, { error: 'Not found' });
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const presented = req.headers[BRIDGE_AUTH_HEADER];
|
|
192
|
+
const presentedToken = Array.isArray(presented) ? presented[0] : presented;
|
|
193
|
+
if (
|
|
194
|
+
typeof presentedToken !== 'string' ||
|
|
195
|
+
!constantTimeEquals(presentedToken, token)
|
|
196
|
+
) {
|
|
197
|
+
if (isTextMode) {
|
|
198
|
+
res.writeHead(401, { 'Content-Type': 'text/plain' });
|
|
199
|
+
res.end('Unauthorized');
|
|
200
|
+
} else {
|
|
201
|
+
writeJson(res, 401, { error: 'Unauthorized' });
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
readRequestBody(req)
|
|
207
|
+
.then(async (body) => {
|
|
208
|
+
if (typeof body.name !== 'string' || body.name === '') {
|
|
209
|
+
const message = 'Tool request is missing a tool name.';
|
|
210
|
+
if (isTextMode) {
|
|
211
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
212
|
+
res.end(message);
|
|
213
|
+
} else {
|
|
214
|
+
writeJson(res, 400, {
|
|
215
|
+
call_id: body.id ?? 'invalid',
|
|
216
|
+
result: null,
|
|
217
|
+
is_error: true,
|
|
218
|
+
error_message: message,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const callId = body.id ?? `local_call_${randomUUID()}`;
|
|
225
|
+
let effectiveInput: Record<string, unknown> = body.input ?? {};
|
|
226
|
+
if (hookContext != null) {
|
|
227
|
+
const gate = await applyPreToolUseHooksForBridge(
|
|
228
|
+
hookContext,
|
|
229
|
+
body.name,
|
|
230
|
+
callId,
|
|
231
|
+
effectiveInput
|
|
232
|
+
);
|
|
233
|
+
if (gate.denyReason != null) {
|
|
234
|
+
const denyMsg = gate.denyReason;
|
|
235
|
+
if (isTextMode) {
|
|
236
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
237
|
+
res.end(denyMsg);
|
|
238
|
+
} else {
|
|
239
|
+
writeJson(res, 500, {
|
|
240
|
+
call_id: callId,
|
|
241
|
+
result: null,
|
|
242
|
+
is_error: true,
|
|
243
|
+
error_message: denyMsg,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
effectiveInput = gate.input;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const [result] = await executeTools(
|
|
252
|
+
[
|
|
253
|
+
{
|
|
254
|
+
id: callId,
|
|
255
|
+
name: body.name,
|
|
256
|
+
input: effectiveInput,
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
toolMap
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (isTextMode) {
|
|
263
|
+
if (result.is_error === true) {
|
|
264
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
265
|
+
res.end(result.error_message ?? `Tool ${body.name} failed`);
|
|
266
|
+
} else {
|
|
267
|
+
const value = toSerializable(result.result);
|
|
268
|
+
const text =
|
|
269
|
+
typeof value === 'string' ? value : JSON.stringify(value);
|
|
270
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
271
|
+
res.end(text);
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
writeJson(res, 200, {
|
|
277
|
+
...result,
|
|
278
|
+
result: toSerializable(result.result),
|
|
279
|
+
});
|
|
280
|
+
})
|
|
281
|
+
.catch((error: Error) => {
|
|
282
|
+
if (isTextMode) {
|
|
283
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
284
|
+
res.end(error.message);
|
|
285
|
+
} else {
|
|
286
|
+
writeJson(res, 500, {
|
|
287
|
+
call_id: 'error',
|
|
288
|
+
result: null,
|
|
289
|
+
is_error: true,
|
|
290
|
+
error_message: error.message,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await new Promise<void>((resolve, reject) => {
|
|
297
|
+
server.once('error', reject);
|
|
298
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const address = server.address() as AddressInfo;
|
|
302
|
+
return {
|
|
303
|
+
url: `http://127.0.0.1:${address.port}/tool`,
|
|
304
|
+
token,
|
|
305
|
+
close: () =>
|
|
306
|
+
new Promise((resolve, reject) => {
|
|
307
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
308
|
+
}),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function indent(code: string): string {
|
|
313
|
+
return code
|
|
314
|
+
.split('\n')
|
|
315
|
+
.map((line) => ` ${line}`)
|
|
316
|
+
.join('\n');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function createPythonProgram(
|
|
320
|
+
code: string,
|
|
321
|
+
toolDefs: t.LCTool[],
|
|
322
|
+
bridgeUrl: string,
|
|
323
|
+
bridgeToken: string
|
|
324
|
+
): string {
|
|
325
|
+
const functionDefs = toolDefs
|
|
326
|
+
.map((def) => {
|
|
327
|
+
const pythonName = normalizeToPythonIdentifier(def.name);
|
|
328
|
+
return [
|
|
329
|
+
`async def ${pythonName}(**kwargs):`,
|
|
330
|
+
` return await __librechat_call_tool(${JSON.stringify(def.name)}, kwargs)`,
|
|
331
|
+
].join('\n');
|
|
332
|
+
})
|
|
333
|
+
.join('\n\n');
|
|
334
|
+
|
|
335
|
+
return `
|
|
336
|
+
import asyncio
|
|
337
|
+
import json
|
|
338
|
+
import urllib.request
|
|
339
|
+
|
|
340
|
+
__LIBRECHAT_TOOL_BRIDGE = ${JSON.stringify(bridgeUrl)}
|
|
341
|
+
__LIBRECHAT_TOOL_TOKEN = ${JSON.stringify(bridgeToken)}
|
|
342
|
+
|
|
343
|
+
async def __librechat_call_tool(name, payload):
|
|
344
|
+
body = json.dumps({"name": name, "input": payload}).encode("utf-8")
|
|
345
|
+
headers = {
|
|
346
|
+
"Content-Type": "application/json",
|
|
347
|
+
${JSON.stringify(BRIDGE_AUTH_HEADER)}: __LIBRECHAT_TOOL_TOKEN,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
def request():
|
|
351
|
+
req = urllib.request.Request(__LIBRECHAT_TOOL_BRIDGE, data=body, headers=headers, method="POST")
|
|
352
|
+
with urllib.request.urlopen(req, timeout=300) as response:
|
|
353
|
+
return response.read().decode("utf-8")
|
|
354
|
+
|
|
355
|
+
raw = await asyncio.to_thread(request)
|
|
356
|
+
result = json.loads(raw)
|
|
357
|
+
if result.get("is_error"):
|
|
358
|
+
raise RuntimeError(result.get("error_message") or f"Tool {name} failed")
|
|
359
|
+
return result.get("result")
|
|
360
|
+
|
|
361
|
+
${functionDefs}
|
|
362
|
+
|
|
363
|
+
async def __librechat_main():
|
|
364
|
+
${indent(code)}
|
|
365
|
+
|
|
366
|
+
asyncio.run(__librechat_main())
|
|
367
|
+
`.trimStart();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function _createBashProgramForTests(
|
|
371
|
+
code: string,
|
|
372
|
+
toolDefs: t.LCTool[],
|
|
373
|
+
bridgeUrl: string,
|
|
374
|
+
bridgeToken: string
|
|
375
|
+
): string {
|
|
376
|
+
return createBashProgram(code, toolDefs, bridgeUrl, bridgeToken);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function createBashProgram(
|
|
380
|
+
code: string,
|
|
381
|
+
toolDefs: t.LCTool[],
|
|
382
|
+
bridgeUrl: string,
|
|
383
|
+
bridgeToken: string
|
|
384
|
+
): string {
|
|
385
|
+
const functions = toolDefs
|
|
386
|
+
.map((def) => {
|
|
387
|
+
const bashName = normalizeToBashIdentifier(def.name);
|
|
388
|
+
return [
|
|
389
|
+
`${bashName}() {`,
|
|
390
|
+
' local payload="${1:-}"',
|
|
391
|
+
' if [ -z "$payload" ]; then payload=\'{}\'; fi',
|
|
392
|
+
` __librechat_call_tool ${shellQuote(def.name)} "$payload"`,
|
|
393
|
+
'}',
|
|
394
|
+
].join('\n');
|
|
395
|
+
})
|
|
396
|
+
.join('\n\n');
|
|
397
|
+
|
|
398
|
+
return `
|
|
399
|
+
__LIBRECHAT_TOOL_BRIDGE=${shellQuote(bridgeUrl)}
|
|
400
|
+
__LIBRECHAT_TOOL_HEADER=${shellQuote(BRIDGE_AUTH_HEADER)}
|
|
401
|
+
__LIBRECHAT_TOOL_TOKEN=${shellQuote(bridgeToken)}
|
|
402
|
+
|
|
403
|
+
# Bridge call helper. Tries curl first (universally available, no
|
|
404
|
+
# JSON parser needed thanks to the bridge's ?mode=text endpoint),
|
|
405
|
+
# falls back to python3 for environments without curl. Codex P2 #19
|
|
406
|
+
# flagged that the prior python3-only path broke minimal containers
|
|
407
|
+
# (and Windows hosts without a python3 binary on PATH). Tool names
|
|
408
|
+
# come from Constants.* and are always safe identifiers, so we can
|
|
409
|
+
# splice them into JSON without an escape pass.
|
|
410
|
+
__librechat_call_tool() {
|
|
411
|
+
local tool_name="$1"
|
|
412
|
+
local payload="$2"
|
|
413
|
+
if command -v curl >/dev/null 2>&1; then
|
|
414
|
+
local body="{\\"name\\":\\"$tool_name\\",\\"input\\":$payload}"
|
|
415
|
+
local response
|
|
416
|
+
local http_code
|
|
417
|
+
response=$(curl -sS -X POST \
|
|
418
|
+
-H "Content-Type: application/json" \
|
|
419
|
+
-H "$__LIBRECHAT_TOOL_HEADER: $__LIBRECHAT_TOOL_TOKEN" \
|
|
420
|
+
--data-binary "$body" \
|
|
421
|
+
-w '\\n__LIBRECHAT_HTTP_CODE_%{http_code}__' \
|
|
422
|
+
"$__LIBRECHAT_TOOL_BRIDGE?mode=text")
|
|
423
|
+
http_code=$(printf '%s' "$response" | sed -n 's/.*__LIBRECHAT_HTTP_CODE_\\([0-9][0-9]*\\)__$/\\1/p')
|
|
424
|
+
local body_only
|
|
425
|
+
body_only=$(printf '%s' "$response" | sed 's/__LIBRECHAT_HTTP_CODE_[0-9][0-9]*__$//')
|
|
426
|
+
if [ "$http_code" = "200" ]; then
|
|
427
|
+
printf '%s' "$body_only"
|
|
428
|
+
return 0
|
|
429
|
+
fi
|
|
430
|
+
printf '%s\\n' "$body_only" >&2
|
|
431
|
+
return 1
|
|
432
|
+
elif command -v python3 >/dev/null 2>&1; then
|
|
433
|
+
python3 - "$__LIBRECHAT_TOOL_BRIDGE" "$tool_name" "$payload" "$__LIBRECHAT_TOOL_HEADER" "$__LIBRECHAT_TOOL_TOKEN" <<'PY'
|
|
434
|
+
import json
|
|
435
|
+
import sys
|
|
436
|
+
import urllib.request
|
|
437
|
+
|
|
438
|
+
url, name, payload, header, token = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
|
|
439
|
+
body = json.dumps({"name": name, "input": json.loads(payload)}).encode("utf-8")
|
|
440
|
+
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json", header: token}, method="POST")
|
|
441
|
+
with urllib.request.urlopen(req, timeout=300) as response:
|
|
442
|
+
result = json.loads(response.read().decode("utf-8"))
|
|
443
|
+
if result.get("is_error"):
|
|
444
|
+
print(result.get("error_message") or f"Tool {name} failed", file=sys.stderr)
|
|
445
|
+
sys.exit(1)
|
|
446
|
+
value = result.get("result")
|
|
447
|
+
if isinstance(value, str):
|
|
448
|
+
print(value)
|
|
449
|
+
else:
|
|
450
|
+
print(json.dumps(value))
|
|
451
|
+
PY
|
|
452
|
+
else
|
|
453
|
+
printf 'librechat: tool bridge needs either curl or python3 on PATH\\n' >&2
|
|
454
|
+
return 1
|
|
455
|
+
fi
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
${functions}
|
|
459
|
+
|
|
460
|
+
${code}
|
|
461
|
+
`.trimStart();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function getProgrammaticContext(config?: {
|
|
465
|
+
toolCall?: unknown;
|
|
466
|
+
}): Partial<t.ProgrammaticCache> {
|
|
467
|
+
return (config?.toolCall ?? {}) as Partial<t.ProgrammaticCache>;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function createEffectiveToolMap(
|
|
471
|
+
toolMap: t.ToolMap,
|
|
472
|
+
toolDefs: t.LCTool[],
|
|
473
|
+
code: string,
|
|
474
|
+
filterTools: ToolFilter
|
|
475
|
+
): { effectiveTools: t.LCTool[]; effectiveMap: t.ToolMap } {
|
|
476
|
+
const effectiveTools = filterTools(toolDefs, code);
|
|
477
|
+
const effectiveMap = new Map<string, t.GenericTool>(
|
|
478
|
+
effectiveTools
|
|
479
|
+
.map((def) => {
|
|
480
|
+
const executable = toolMap.get(def.name);
|
|
481
|
+
return executable == null
|
|
482
|
+
? undefined
|
|
483
|
+
: ([def.name, executable] as [string, t.GenericTool]);
|
|
484
|
+
})
|
|
485
|
+
.filter((entry): entry is [string, t.GenericTool] => entry != null)
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
return { effectiveTools, effectiveMap };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function runLocalProgrammaticTool(args: {
|
|
492
|
+
params: LocalProgrammaticParams;
|
|
493
|
+
config?: { toolCall?: unknown };
|
|
494
|
+
localConfig: t.LocalExecutionConfig;
|
|
495
|
+
runtime: LocalProgrammaticRuntime;
|
|
496
|
+
}): Promise<[string, t.ProgrammaticExecutionArtifact]> {
|
|
497
|
+
const { toolMap, toolDefs, hookContext } = getProgrammaticContext(args.config);
|
|
498
|
+
|
|
499
|
+
if (toolMap == null || toolMap.size === 0) {
|
|
500
|
+
throw new Error('No toolMap provided for local programmatic execution.');
|
|
501
|
+
}
|
|
502
|
+
if (toolDefs == null || toolDefs.length === 0) {
|
|
503
|
+
throw new Error('No tool definitions provided for local programmatic execution.');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const { effectiveTools, effectiveMap } = createEffectiveToolMap(
|
|
507
|
+
toolMap,
|
|
508
|
+
toolDefs,
|
|
509
|
+
args.params.code,
|
|
510
|
+
args.runtime === 'bash' ? filterBashToolsByUsage : filterToolsByUsage
|
|
511
|
+
);
|
|
512
|
+
const bridge = await createToolBridge(effectiveMap, hookContext);
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
const timeoutMs = args.params.timeout ?? args.localConfig.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
516
|
+
const result =
|
|
517
|
+
args.runtime === 'bash'
|
|
518
|
+
? await executeLocalBash(
|
|
519
|
+
createBashProgram(args.params.code, effectiveTools, bridge.url, bridge.token),
|
|
520
|
+
{ ...args.localConfig, timeoutMs }
|
|
521
|
+
)
|
|
522
|
+
: await executeLocalCode(
|
|
523
|
+
{
|
|
524
|
+
lang: 'py',
|
|
525
|
+
code: createPythonProgram(args.params.code, effectiveTools, bridge.url, bridge.token),
|
|
526
|
+
},
|
|
527
|
+
{ ...args.localConfig, timeoutMs }
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
if (result.exitCode !== 0 || result.timedOut) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
result.stderr !== ''
|
|
533
|
+
? result.stderr
|
|
534
|
+
: `Local ${args.runtime} programmatic execution exited with code ${
|
|
535
|
+
result.exitCode ?? 'unknown'
|
|
536
|
+
}`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return formatCompletedResponse({
|
|
541
|
+
status: 'completed',
|
|
542
|
+
session_id: getLocalSessionId(args.localConfig),
|
|
543
|
+
stdout: result.stdout,
|
|
544
|
+
stderr: result.stderr,
|
|
545
|
+
files: [],
|
|
546
|
+
});
|
|
547
|
+
} finally {
|
|
548
|
+
await bridge.close();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function createLocalProgrammaticToolCallingTool(
|
|
553
|
+
localConfig: t.LocalExecutionConfig = {}
|
|
554
|
+
): DynamicStructuredTool {
|
|
555
|
+
return tool(
|
|
556
|
+
async (rawParams, config) => {
|
|
557
|
+
const params = rawParams as LocalProgrammaticParams;
|
|
558
|
+
return runLocalProgrammaticTool({
|
|
559
|
+
params,
|
|
560
|
+
config,
|
|
561
|
+
localConfig,
|
|
562
|
+
runtime: resolveRuntime(params),
|
|
563
|
+
});
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
name: ProgrammaticToolCallingName,
|
|
567
|
+
description: `${ProgrammaticToolCallingDescription}\n\nLocal engine: runs bash by default, or Python when \`lang\` is \`py\` or \`python\`, on the host machine and calls tools through an in-process localhost bridge.`,
|
|
568
|
+
schema: LocalProgrammaticToolCallingSchema,
|
|
569
|
+
responseFormat: Constants.CONTENT_AND_ARTIFACT,
|
|
570
|
+
}
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export function createLocalBashProgrammaticToolCallingTool(
|
|
575
|
+
localConfig: t.LocalExecutionConfig = {}
|
|
576
|
+
): DynamicStructuredTool {
|
|
577
|
+
return tool(
|
|
578
|
+
async (rawParams, config) => {
|
|
579
|
+
const params = rawParams as LocalProgrammaticParams;
|
|
580
|
+
return runLocalProgrammaticTool({
|
|
581
|
+
params,
|
|
582
|
+
config,
|
|
583
|
+
localConfig,
|
|
584
|
+
runtime: 'bash',
|
|
585
|
+
});
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
name: Constants.BASH_PROGRAMMATIC_TOOL_CALLING,
|
|
589
|
+
description: `${BashProgrammaticToolCallingDescription}\n\nLocal engine: runs this bash orchestration code on the host machine and calls tools through an in-process localhost bridge.`,
|
|
590
|
+
schema: BashProgrammaticToolCallingSchema,
|
|
591
|
+
responseFormat: Constants.CONTENT_AND_ARTIFACT,
|
|
592
|
+
}
|
|
593
|
+
);
|
|
594
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { tmpdir } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises';
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
5
|
+
import { LocalFileCheckpointerImpl } from '../FileCheckpointer';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pins the LocalFileCheckpointer's per-Run snapshot/rewind contract.
|
|
9
|
+
* Critical because checkpoints are what `--rollback` style features
|
|
10
|
+
* (and the local engine's mid-batch undo) actually rely on — a
|
|
11
|
+
* regression here silently makes rewind a no-op or, worse, restores
|
|
12
|
+
* to the wrong byte content.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
describe('LocalFileCheckpointerImpl', () => {
|
|
16
|
+
let dir: string;
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
dir = await mkdtemp(join(tmpdir(), 'lc-fcp-'));
|
|
19
|
+
});
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await rm(dir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('captures pre-write content and rewind restores it byte-exact', async () => {
|
|
25
|
+
const file = join(dir, 'a.txt');
|
|
26
|
+
await writeFile(file, 'original\n');
|
|
27
|
+
const cp = new LocalFileCheckpointerImpl();
|
|
28
|
+
|
|
29
|
+
await cp.captureBeforeWrite(file);
|
|
30
|
+
await writeFile(file, 'overwritten\n');
|
|
31
|
+
|
|
32
|
+
const restored = await cp.rewind();
|
|
33
|
+
expect(restored).toBe(1);
|
|
34
|
+
expect(await readFile(file, 'utf8')).toBe('original\n');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('captureBeforeWrite is idempotent — second capture preserves the FIRST snapshot', async () => {
|
|
38
|
+
const file = join(dir, 'b.txt');
|
|
39
|
+
await writeFile(file, 'first');
|
|
40
|
+
const cp = new LocalFileCheckpointerImpl();
|
|
41
|
+
|
|
42
|
+
await cp.captureBeforeWrite(file);
|
|
43
|
+
// Simulate first write happening between captures, then a second
|
|
44
|
+
// tool wanting to write the same file. The second capture must be
|
|
45
|
+
// a no-op so rewind restores back to the very first content.
|
|
46
|
+
await writeFile(file, 'between');
|
|
47
|
+
await cp.captureBeforeWrite(file);
|
|
48
|
+
await writeFile(file, 'last');
|
|
49
|
+
|
|
50
|
+
await cp.rewind();
|
|
51
|
+
expect(await readFile(file, 'utf8')).toBe('first');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('captures absent files and rewind deletes any newly-created path', async () => {
|
|
55
|
+
const file = join(dir, 'newly-created.txt');
|
|
56
|
+
const cp = new LocalFileCheckpointerImpl();
|
|
57
|
+
|
|
58
|
+
await cp.captureBeforeWrite(file); // file does not exist yet
|
|
59
|
+
await writeFile(file, 'created by some tool');
|
|
60
|
+
|
|
61
|
+
const restored = await cp.rewind();
|
|
62
|
+
expect(restored).toBe(1);
|
|
63
|
+
await expect(stat(file)).rejects.toThrow();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('rewinds across multiple files in a single pass', async () => {
|
|
67
|
+
const a = join(dir, 'multi-a.txt');
|
|
68
|
+
const b = join(dir, 'multi-b.txt');
|
|
69
|
+
await writeFile(a, 'A0');
|
|
70
|
+
await writeFile(b, 'B0');
|
|
71
|
+
const cp = new LocalFileCheckpointerImpl();
|
|
72
|
+
|
|
73
|
+
await cp.captureBeforeWrite(a);
|
|
74
|
+
await cp.captureBeforeWrite(b);
|
|
75
|
+
await writeFile(a, 'A1');
|
|
76
|
+
await writeFile(b, 'B1');
|
|
77
|
+
|
|
78
|
+
const restored = await cp.rewind();
|
|
79
|
+
expect(restored).toBe(2);
|
|
80
|
+
expect(await readFile(a, 'utf8')).toBe('A0');
|
|
81
|
+
expect(await readFile(b, 'utf8')).toBe('B0');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('skips snapshotting files larger than maxBytesPerFile but still tracks them', async () => {
|
|
85
|
+
const file = join(dir, 'big.bin');
|
|
86
|
+
// Write 1024 bytes, set the cap to 100 — well under file size.
|
|
87
|
+
await writeFile(file, Buffer.alloc(1024, 0x41));
|
|
88
|
+
const cp = new LocalFileCheckpointerImpl(100);
|
|
89
|
+
|
|
90
|
+
await cp.captureBeforeWrite(file);
|
|
91
|
+
expect(cp.capturedPaths()).toContain(file);
|
|
92
|
+
|
|
93
|
+
// Mutate the file. Rewind reports 0 restored (nothing snapshotted)
|
|
94
|
+
// but does not throw — best-effort behavior documented in the
|
|
95
|
+
// class JSDoc.
|
|
96
|
+
await writeFile(file, 'mutated');
|
|
97
|
+
const restored = await cp.rewind();
|
|
98
|
+
expect(restored).toBe(0);
|
|
99
|
+
// The file is unchanged from the post-mutation state — there was
|
|
100
|
+
// nothing snapshotted to restore.
|
|
101
|
+
expect(await readFile(file, 'utf8')).toBe('mutated');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rewind of a captured file whose parent directory was removed recreates the directory', async () => {
|
|
105
|
+
const subdir = join(dir, 'nested', 'deep');
|
|
106
|
+
const file = join(subdir, 'x.txt');
|
|
107
|
+
await mkdir(subdir, { recursive: true });
|
|
108
|
+
await writeFile(file, 'kept');
|
|
109
|
+
const cp = new LocalFileCheckpointerImpl();
|
|
110
|
+
|
|
111
|
+
await cp.captureBeforeWrite(file);
|
|
112
|
+
// Blow away the subdir entirely — simulates a tool that deleted
|
|
113
|
+
// the parent directory.
|
|
114
|
+
await rm(join(dir, 'nested'), { recursive: true, force: true });
|
|
115
|
+
|
|
116
|
+
const restored = await cp.rewind();
|
|
117
|
+
expect(restored).toBe(1);
|
|
118
|
+
expect(await readFile(file, 'utf8')).toBe('kept');
|
|
119
|
+
});
|
|
120
|
+
});
|