@librechat/agents 3.1.82 → 3.1.84

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 (57) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +69 -24
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/events.cjs +2 -1
  4. package/dist/cjs/events.cjs.map +1 -1
  5. package/dist/cjs/main.cjs +3 -0
  6. package/dist/cjs/main.cjs.map +1 -1
  7. package/dist/cjs/messages/cache.cjs +96 -0
  8. package/dist/cjs/messages/cache.cjs.map +1 -1
  9. package/dist/cjs/tools/BashExecutor.cjs +5 -2
  10. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  11. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +3 -3
  12. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  13. package/dist/cjs/tools/CodeExecutor.cjs +28 -2
  14. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  15. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +107 -34
  16. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  17. package/dist/cjs/tools/ToolNode.cjs +3 -4
  18. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  19. package/dist/esm/agents/AgentContext.mjs +71 -26
  20. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  21. package/dist/esm/events.mjs +2 -1
  22. package/dist/esm/events.mjs.map +1 -1
  23. package/dist/esm/main.mjs +2 -2
  24. package/dist/esm/messages/cache.mjs +96 -1
  25. package/dist/esm/messages/cache.mjs.map +1 -1
  26. package/dist/esm/tools/BashExecutor.mjs +6 -3
  27. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  28. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +3 -3
  29. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  30. package/dist/esm/tools/CodeExecutor.mjs +27 -3
  31. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  32. package/dist/esm/tools/ProgrammaticToolCalling.mjs +108 -35
  33. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  34. package/dist/esm/tools/ToolNode.mjs +3 -4
  35. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  36. package/dist/types/agents/AgentContext.d.ts +7 -3
  37. package/dist/types/agents/__tests__/promptCacheLiveHelpers.d.ts +6 -2
  38. package/dist/types/messages/cache.d.ts +1 -0
  39. package/dist/types/tools/CodeExecutor.d.ts +5 -0
  40. package/dist/types/tools/ProgrammaticToolCalling.d.ts +14 -3
  41. package/dist/types/types/tools.d.ts +6 -0
  42. package/package.json +1 -1
  43. package/src/agents/AgentContext.ts +102 -30
  44. package/src/agents/__tests__/AgentContext.anthropic.live.test.ts +0 -4
  45. package/src/agents/__tests__/AgentContext.openrouter.live.test.ts +128 -0
  46. package/src/agents/__tests__/AgentContext.test.ts +199 -27
  47. package/src/agents/__tests__/promptCacheLiveHelpers.ts +8 -2
  48. package/src/events.ts +4 -1
  49. package/src/messages/cache.ts +143 -0
  50. package/src/tools/BashExecutor.ts +14 -3
  51. package/src/tools/BashProgrammaticToolCalling.ts +6 -3
  52. package/src/tools/CodeExecutor.ts +36 -2
  53. package/src/tools/ProgrammaticToolCalling.ts +175 -30
  54. package/src/tools/ToolNode.ts +3 -4
  55. package/src/tools/__tests__/CodeApiAuthHeaders.test.ts +321 -0
  56. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +31 -1
  57. package/src/types/tools.ts +10 -0
@@ -5,7 +5,12 @@ import { HttpsProxyAgent } from 'https-proxy-agent';
5
5
  import { tool, DynamicStructuredTool } from '@langchain/core/tools';
6
6
  import type { ToolCall } from '@langchain/core/messages/tool';
7
7
  import type * as t from '@/types';
8
- import { emptyOutputMessage, getCodeBaseURL } from './CodeExecutor';
8
+ import {
9
+ buildCodeApiHttpErrorMessage,
10
+ emptyOutputMessage,
11
+ getCodeBaseURL,
12
+ resolveCodeApiAuthHeaders,
13
+ } from './CodeExecutor';
9
14
  import { Constants } from '@/common';
10
15
 
11
16
  config();
@@ -147,6 +152,113 @@ const PYTHON_KEYWORDS = new Set([
147
152
  'yield',
148
153
  ]);
149
154
 
155
+ export type FetchSessionFilesScope =
156
+ | { kind: 'skill'; id: string; version: number }
157
+ | { kind: 'agent' | 'user'; id: string; version?: never };
158
+
159
+ type CodeApiSessionFileWire = {
160
+ id?: unknown;
161
+ name?: unknown;
162
+ metadata?: unknown;
163
+ resource_id?: unknown;
164
+ storage_session_id?: unknown;
165
+ };
166
+
167
+ type CodeApiSessionFileMetadata = {
168
+ 'original-filename'?: unknown;
169
+ };
170
+
171
+ function isFetchSessionFilesScope(
172
+ value: unknown
173
+ ): value is FetchSessionFilesScope {
174
+ if (value == null || typeof value !== 'object') {
175
+ return false;
176
+ }
177
+ const scope = value as { kind?: unknown; id?: unknown; version?: unknown };
178
+ if (
179
+ (scope.kind === 'agent' || scope.kind === 'user') &&
180
+ typeof scope.id === 'string'
181
+ ) {
182
+ return true;
183
+ }
184
+ return (
185
+ scope.kind === 'skill' &&
186
+ typeof scope.id === 'string' &&
187
+ typeof scope.version === 'number'
188
+ );
189
+ }
190
+
191
+ function isCodeApiAuthHeaders(
192
+ value: string | t.CodeApiAuthHeaders | undefined
193
+ ): value is t.CodeApiAuthHeaders {
194
+ return value != null && typeof value !== 'string';
195
+ }
196
+
197
+ function isCodeApiSessionFileWire(
198
+ value: unknown
199
+ ): value is CodeApiSessionFileWire {
200
+ return value != null && typeof value === 'object';
201
+ }
202
+
203
+ function isCodeApiSessionFileMetadata(
204
+ value: unknown
205
+ ): value is CodeApiSessionFileMetadata {
206
+ return value != null && typeof value === 'object';
207
+ }
208
+
209
+ function normalizeSessionFile(
210
+ file: CodeApiSessionFileWire,
211
+ sessionId: string,
212
+ scope?: FetchSessionFilesScope
213
+ ): t.CodeEnvFile {
214
+ const metadata = isCodeApiSessionFileMetadata(file.metadata)
215
+ ? file.metadata
216
+ : undefined;
217
+ const rawName = typeof file.name === 'string' ? file.name : '';
218
+ const nameParts = rawName.split('/');
219
+ const fallbackId = nameParts.length > 1 ? nameParts[1].split('.')[0] : '';
220
+ const id =
221
+ typeof file.id === 'string' && file.id !== '' ? file.id : fallbackId;
222
+ const originalFilename = metadata?.['original-filename'];
223
+ const name =
224
+ typeof originalFilename === 'string' ? originalFilename : rawName;
225
+ const storage_session_id =
226
+ typeof file.storage_session_id === 'string'
227
+ ? file.storage_session_id
228
+ : sessionId;
229
+ const resource_id =
230
+ typeof file.resource_id === 'string' && file.resource_id !== ''
231
+ ? file.resource_id
232
+ : (scope?.id ?? id);
233
+
234
+ if (scope?.kind === 'skill') {
235
+ return {
236
+ storage_session_id,
237
+ kind: 'skill',
238
+ id,
239
+ resource_id,
240
+ name,
241
+ version: scope.version,
242
+ };
243
+ }
244
+ if (scope != null) {
245
+ return {
246
+ storage_session_id,
247
+ kind: scope.kind,
248
+ id,
249
+ resource_id,
250
+ name,
251
+ };
252
+ }
253
+ return {
254
+ storage_session_id,
255
+ kind: 'user',
256
+ id,
257
+ resource_id: id,
258
+ name,
259
+ };
260
+ }
261
+
150
262
  /**
151
263
  * Normalizes a tool name to Python identifier format.
152
264
  * Must match the Code API's `normalizePythonFunctionName` exactly:
@@ -250,20 +362,62 @@ export function filterToolsByUsage(
250
362
  * Files are returned as CodeEnvFile references to be included in the request.
251
363
  * @param baseUrl - The base URL for the Code API
252
364
  * @param sessionId - The session ID to fetch files from
365
+ * @param scope - Resource scope used by CodeAPI to authorize the session
253
366
  * @param proxy - Optional HTTP proxy URL
254
367
  * @returns Array of CodeEnvFile references, or empty array if fetch fails
255
368
  */
256
369
  export async function fetchSessionFiles(
257
370
  baseUrl: string,
258
371
  sessionId: string,
259
- proxy?: string
372
+ proxy?: string,
373
+ authHeaders?: t.CodeApiAuthHeaders
374
+ ): Promise<t.CodeEnvFile[]>;
375
+ export async function fetchSessionFiles(
376
+ baseUrl: string,
377
+ sessionId: string,
378
+ scope: FetchSessionFilesScope,
379
+ proxyOrAuthHeaders?: string | t.CodeApiAuthHeaders,
380
+ authHeaders?: t.CodeApiAuthHeaders
381
+ ): Promise<t.CodeEnvFile[]>;
382
+ export async function fetchSessionFiles(
383
+ baseUrl: string,
384
+ sessionId: string,
385
+ scopeOrProxy?: FetchSessionFilesScope | string,
386
+ proxyOrAuthHeaders?: string | t.CodeApiAuthHeaders,
387
+ scopedAuthHeaders?: t.CodeApiAuthHeaders
260
388
  ): Promise<t.CodeEnvFile[]> {
261
389
  try {
262
- const filesEndpoint = `${baseUrl}/files/${sessionId}?detail=full`;
390
+ const scope = isFetchSessionFilesScope(scopeOrProxy)
391
+ ? scopeOrProxy
392
+ : undefined;
393
+ let proxy: string | undefined;
394
+ let authHeaders: t.CodeApiAuthHeaders | undefined;
395
+ if (scope == null) {
396
+ proxy = typeof scopeOrProxy === 'string' ? scopeOrProxy : undefined;
397
+ authHeaders = isCodeApiAuthHeaders(proxyOrAuthHeaders)
398
+ ? proxyOrAuthHeaders
399
+ : undefined;
400
+ } else if (typeof proxyOrAuthHeaders === 'string') {
401
+ proxy = proxyOrAuthHeaders;
402
+ authHeaders = scopedAuthHeaders;
403
+ } else {
404
+ authHeaders = proxyOrAuthHeaders ?? scopedAuthHeaders;
405
+ }
406
+ const query = new URLSearchParams({ detail: 'full' });
407
+ if (scope != null) {
408
+ query.set('kind', scope.kind);
409
+ query.set('id', scope.id);
410
+ if (scope.kind === 'skill') {
411
+ query.set('version', String(scope.version));
412
+ }
413
+ }
414
+ const filesEndpoint = `${baseUrl}/files/${encodeURIComponent(sessionId)}?${query.toString()}`;
415
+ const resolvedAuthHeaders = await resolveCodeApiAuthHeaders(authHeaders);
263
416
  const fetchOptions: RequestInit = {
264
417
  method: 'GET',
265
418
  headers: {
266
419
  'User-Agent': 'LibreChat/1.0',
420
+ ...resolvedAuthHeaders,
267
421
  },
268
422
  };
269
423
 
@@ -273,7 +427,9 @@ export async function fetchSessionFiles(
273
427
 
274
428
  const response = await fetch(filesEndpoint, fetchOptions);
275
429
  if (!response.ok) {
276
- throw new Error(`Failed to fetch files for session: ${response.status}`);
430
+ throw new Error(
431
+ await buildCodeApiHttpErrorMessage('GET', filesEndpoint, response)
432
+ );
277
433
  }
278
434
 
279
435
  const files = await response.json();
@@ -281,25 +437,9 @@ export async function fetchSessionFiles(
281
437
  return [];
282
438
  }
283
439
 
284
- return files.map((file: Record<string, unknown>) => {
285
- // Extract the ID from the file name (part after session ID prefix and before extension)
286
- const nameParts = (file.name as string).split('/');
287
- const id = nameParts.length > 1 ? nameParts[1].split('.')[0] : '';
288
-
289
- return {
290
- storage_session_id: sessionId,
291
- /* `/files` fallback returns code-output files belonging to
292
- * the user; tag them user-private. */
293
- kind: 'user' as const,
294
- id,
295
- /* `resource_id` informational for `kind: 'user'` —
296
- * codeapi derives sessionKey from auth context. */
297
- resource_id: id,
298
- name: (file.metadata as Record<string, unknown>)[
299
- 'original-filename'
300
- ] as string,
301
- };
302
- });
440
+ return files
441
+ .filter(isCodeApiSessionFileWire)
442
+ .map((file) => normalizeSessionFile(file, sessionId, scope));
303
443
  } catch (error) {
304
444
  // eslint-disable-next-line no-console
305
445
  console.warn(
@@ -319,13 +459,16 @@ export async function fetchSessionFiles(
319
459
  export async function makeRequest(
320
460
  endpoint: string,
321
461
  body: Record<string, unknown>,
322
- proxy?: string
462
+ proxy?: string,
463
+ authHeaders?: t.CodeApiAuthHeaders
323
464
  ): Promise<t.ProgrammaticExecutionResponse> {
465
+ const resolvedAuthHeaders = await resolveCodeApiAuthHeaders(authHeaders);
324
466
  const fetchOptions: RequestInit = {
325
467
  method: 'POST',
326
468
  headers: {
327
469
  'Content-Type': 'application/json',
328
470
  'User-Agent': 'LibreChat/1.0',
471
+ ...resolvedAuthHeaders,
329
472
  },
330
473
  body: JSON.stringify(body),
331
474
  };
@@ -337,9 +480,8 @@ export async function makeRequest(
337
480
  const response = await fetch(endpoint, fetchOptions);
338
481
 
339
482
  if (!response.ok) {
340
- const errorText = await response.text();
341
483
  throw new Error(
342
- `HTTP error! status: ${response.status}, body: ${errorText}`
484
+ await buildCodeApiHttpErrorMessage('POST', endpoint, response)
343
485
  );
344
486
  }
345
487
 
@@ -486,7 +628,8 @@ export function unwrapToolResponse(
486
628
  */
487
629
  export async function executeTools(
488
630
  toolCalls: t.PTCToolCall[],
489
- toolMap: t.ToolMap
631
+ toolMap: t.ToolMap,
632
+ programmaticToolName = Constants.PROGRAMMATIC_TOOL_CALLING
490
633
  ): Promise<t.PTCToolResult[]> {
491
634
  const executions = toolCalls.map(async (call): Promise<t.PTCToolResult> => {
492
635
  const tool = toolMap.get(call.name);
@@ -502,7 +645,7 @@ export async function executeTools(
502
645
 
503
646
  try {
504
647
  const result = await tool.invoke(call.input, {
505
- metadata: { [Constants.PROGRAMMATIC_TOOL_CALLING]: true },
648
+ metadata: { [programmaticToolName]: true },
506
649
  });
507
650
 
508
651
  const isMCPTool = tool.mcp === true;
@@ -661,7 +804,8 @@ export function createProgrammaticToolCallingTool(
661
804
  timeout,
662
805
  ...(files && files.length > 0 ? { files } : {}),
663
806
  },
664
- proxy
807
+ proxy,
808
+ initParams.authHeaders
665
809
  );
666
810
 
667
811
  // ====================================================================
@@ -697,7 +841,8 @@ export function createProgrammaticToolCallingTool(
697
841
  continuation_token: response.continuation_token,
698
842
  tool_results: toolResults,
699
843
  },
700
- proxy
844
+ proxy,
845
+ initParams.authHeaders
701
846
  );
702
847
  }
703
848
 
@@ -844,10 +844,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
844
844
  // Plumb the hook context into the programmatic-tool path so
845
845
  // inner tool calls made via the in-process bridge can run
846
846
  // through `PreToolUse` (deny / updatedInput) before reaching
847
- // the underlying tool. Without this, `run_tools_with_code`
848
- // bypassed every PreToolUse hook the host registered for
849
- // the tools it dispatches — including HITL gates on
850
- // `write_file` / `edit_file` (manual review finding A).
847
+ // the underlying tool. Without this, programmatic tool calls
848
+ // bypass every PreToolUse hook the host registered for the tools
849
+ // they dispatch — including HITL gates on `write_file` / `edit_file`.
851
850
  hookContext: {
852
851
  registry: this.hookRegistry,
853
852
  runId: (config.configurable?.run_id as string | undefined) ?? '',
@@ -0,0 +1,321 @@
1
+ import fetch from 'node-fetch';
2
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals';
3
+ import type { RequestInit } from 'node-fetch';
4
+ import type * as t from '@/types';
5
+ import {
6
+ createCodeExecutionTool,
7
+ resolveCodeApiAuthHeaders,
8
+ } from '../CodeExecutor';
9
+ import { createBashExecutionTool } from '../BashExecutor';
10
+ import {
11
+ createProgrammaticToolCallingTool,
12
+ fetchSessionFiles,
13
+ makeRequest,
14
+ } from '../ProgrammaticToolCalling';
15
+ import { createBashProgrammaticToolCallingTool } from '../BashProgrammaticToolCalling';
16
+
17
+ jest.mock('node-fetch', () => ({
18
+ __esModule: true,
19
+ default: jest.fn(),
20
+ }));
21
+
22
+ type FetchMock = jest.MockedFunction<
23
+ (url: unknown, init?: unknown) => Promise<unknown>
24
+ >;
25
+
26
+ const fetchMock = fetch as unknown as FetchMock;
27
+
28
+ function jsonResponse(body: unknown): unknown {
29
+ return {
30
+ ok: true,
31
+ json: jest.fn(async () => body),
32
+ text: jest.fn(async () => JSON.stringify(body)),
33
+ };
34
+ }
35
+
36
+ function completedResponse(stdout = 'ok'): unknown {
37
+ return jsonResponse({
38
+ status: 'completed',
39
+ session_id: 'session_123',
40
+ stdout,
41
+ });
42
+ }
43
+
44
+ function errorResponse(status: number, body: string): unknown {
45
+ return {
46
+ ok: false,
47
+ status,
48
+ text: jest.fn(async () => body),
49
+ };
50
+ }
51
+
52
+ const toolDefs = [
53
+ {
54
+ name: 'lookup_user',
55
+ description: 'Lookup a user',
56
+ parameters: {
57
+ type: 'object',
58
+ properties: {},
59
+ },
60
+ },
61
+ ] as unknown as t.LCTool[];
62
+
63
+ function toolMap(): t.ToolMap {
64
+ return new Map([
65
+ [
66
+ 'lookup_user',
67
+ {
68
+ name: 'lookup_user',
69
+ invoke: jest.fn(async () => ({ id: 'user_123' })),
70
+ },
71
+ ],
72
+ ]) as unknown as t.ToolMap;
73
+ }
74
+
75
+ describe('CodeAPI auth header injection', () => {
76
+ beforeEach(() => {
77
+ fetchMock.mockReset();
78
+ fetchMock.mockResolvedValue(completedResponse());
79
+ });
80
+
81
+ it('resolves static and dynamic auth header params', async () => {
82
+ await expect(
83
+ resolveCodeApiAuthHeaders({ Authorization: 'Bearer static' })
84
+ ).resolves.toEqual({
85
+ Authorization: 'Bearer static',
86
+ });
87
+ await expect(
88
+ resolveCodeApiAuthHeaders(async () => ({
89
+ Authorization: 'Bearer dynamic',
90
+ }))
91
+ ).resolves.toEqual({
92
+ Authorization: 'Bearer dynamic',
93
+ });
94
+ await expect(resolveCodeApiAuthHeaders()).resolves.toEqual({});
95
+ });
96
+
97
+ it('keeps the no-auth request path unchanged', async () => {
98
+ await makeRequest('https://code.example.com/exec/programmatic', {
99
+ code: 'print(1)',
100
+ });
101
+
102
+ expect(fetchMock).toHaveBeenCalledWith(
103
+ 'https://code.example.com/exec/programmatic',
104
+ expect.objectContaining({
105
+ headers: expect.not.objectContaining({
106
+ Authorization: expect.any(String),
107
+ }),
108
+ })
109
+ );
110
+ });
111
+
112
+ it('forwards Authorization for direct code execution', async () => {
113
+ fetchMock.mockResolvedValueOnce(
114
+ jsonResponse({ session_id: 'session_123', stdout: '1\n' })
115
+ );
116
+ const tool = createCodeExecutionTool({
117
+ authHeaders: async () => ({ Authorization: 'Bearer code-token' }),
118
+ });
119
+
120
+ await tool.invoke({ lang: 'py', code: 'print(1)' });
121
+
122
+ expect(fetchMock).toHaveBeenCalledWith(
123
+ expect.any(String),
124
+ expect.objectContaining({
125
+ headers: expect.objectContaining({
126
+ Authorization: 'Bearer code-token',
127
+ }),
128
+ })
129
+ );
130
+ expect(
131
+ JSON.parse((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string)
132
+ ).not.toHaveProperty('authHeaders');
133
+ });
134
+
135
+ it('forwards Authorization for bash execution', async () => {
136
+ fetchMock.mockResolvedValueOnce(
137
+ jsonResponse({ session_id: 'session_123', stdout: '1\n' })
138
+ );
139
+ const tool = createBashExecutionTool({
140
+ authHeaders: { Authorization: 'Bearer bash-token' },
141
+ });
142
+
143
+ await tool.invoke({ command: 'echo 1' });
144
+
145
+ expect(fetchMock).toHaveBeenCalledWith(
146
+ expect.any(String),
147
+ expect.objectContaining({
148
+ headers: expect.objectContaining({
149
+ Authorization: 'Bearer bash-token',
150
+ }),
151
+ })
152
+ );
153
+ expect(
154
+ JSON.parse((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string)
155
+ ).not.toHaveProperty('authHeaders');
156
+ });
157
+
158
+ it('includes the CodeAPI endpoint and response body on direct execution failures', async () => {
159
+ fetchMock.mockResolvedValueOnce(errorResponse(404, 'Cannot POST /exec'));
160
+ const tool = createBashExecutionTool();
161
+
162
+ await expect(tool.invoke({ command: 'echo 1' })).rejects.toThrow(
163
+ /CodeAPI request failed: POST .*\/exec returned 404, body: Cannot POST \/exec/
164
+ );
165
+ });
166
+
167
+ it('forwards Authorization on programmatic initial and continuation requests', async () => {
168
+ fetchMock
169
+ .mockResolvedValueOnce(
170
+ jsonResponse({
171
+ status: 'tool_call_required',
172
+ continuation_token: 'continue_123',
173
+ tool_calls: [{ id: 'call_1', name: 'lookup_user', input: {} }],
174
+ })
175
+ )
176
+ .mockResolvedValueOnce(completedResponse('done'));
177
+
178
+ const tool = createProgrammaticToolCallingTool({
179
+ authHeaders: () => ({ Authorization: 'Bearer ptc-token' }),
180
+ });
181
+
182
+ await tool.invoke(
183
+ { code: 'result = await lookup_user()\nprint(result)' },
184
+ {
185
+ toolCall: {
186
+ name: 'programmatic_code_execution',
187
+ args: {},
188
+ toolMap: toolMap(),
189
+ toolDefs,
190
+ },
191
+ }
192
+ );
193
+
194
+ expect(fetchMock).toHaveBeenCalledTimes(2);
195
+ for (const call of fetchMock.mock.calls) {
196
+ expect(call[1]).toEqual(
197
+ expect.objectContaining({
198
+ headers: expect.objectContaining({
199
+ Authorization: 'Bearer ptc-token',
200
+ }),
201
+ })
202
+ );
203
+ }
204
+ });
205
+
206
+ it('forwards Authorization for bash programmatic requests', async () => {
207
+ const tool = createBashProgrammaticToolCallingTool({
208
+ authHeaders: { Authorization: 'Bearer bash-ptc-token' },
209
+ });
210
+
211
+ await tool.invoke(
212
+ { code: 'lookup_user "{}"' },
213
+ {
214
+ toolCall: {
215
+ name: 'bash_programmatic_code_execution',
216
+ args: {},
217
+ toolMap: toolMap(),
218
+ toolDefs,
219
+ },
220
+ }
221
+ );
222
+
223
+ expect(fetchMock).toHaveBeenCalledWith(
224
+ expect.any(String),
225
+ expect.objectContaining({
226
+ headers: expect.objectContaining({
227
+ Authorization: 'Bearer bash-ptc-token',
228
+ }),
229
+ })
230
+ );
231
+ });
232
+
233
+ it('fetches session files with the CodeAPI resource scope and auth headers', async () => {
234
+ fetchMock.mockResolvedValueOnce(
235
+ jsonResponse([
236
+ {
237
+ id: 'file-1',
238
+ resource_id: 'skill-1',
239
+ storage_session_id: 'session_123',
240
+ name: 'skill/file.txt',
241
+ kind: 'skill',
242
+ version: 7,
243
+ },
244
+ ])
245
+ );
246
+
247
+ const files = await fetchSessionFiles(
248
+ 'https://code.example.com',
249
+ 'session_123',
250
+ { kind: 'skill', id: 'skill-1', version: 7 },
251
+ undefined,
252
+ { Authorization: 'Bearer files-token' }
253
+ );
254
+
255
+ expect(files).toHaveLength(1);
256
+ expect(fetchMock).toHaveBeenCalledWith(
257
+ 'https://code.example.com/files/session_123?detail=full&kind=skill&id=skill-1&version=7',
258
+ expect.objectContaining({
259
+ headers: expect.objectContaining({
260
+ Authorization: 'Bearer files-token',
261
+ }),
262
+ })
263
+ );
264
+ });
265
+
266
+ it('fetches scoped session files with auth headers and no proxy placeholder', async () => {
267
+ fetchMock.mockResolvedValueOnce(jsonResponse([]));
268
+
269
+ await fetchSessionFiles(
270
+ 'https://code.example.com',
271
+ 'session_123',
272
+ { kind: 'skill', id: 'skill-1', version: 7 },
273
+ { Authorization: 'Bearer scoped-files-token' }
274
+ );
275
+
276
+ expect(fetchMock).toHaveBeenCalledWith(
277
+ 'https://code.example.com/files/session_123?detail=full&kind=skill&id=skill-1&version=7',
278
+ expect.objectContaining({
279
+ headers: expect.objectContaining({
280
+ Authorization: 'Bearer scoped-files-token',
281
+ }),
282
+ })
283
+ );
284
+ });
285
+
286
+ it('preserves the legacy fetchSessionFiles proxy/auth argument order', async () => {
287
+ fetchMock.mockResolvedValueOnce(
288
+ jsonResponse([
289
+ {
290
+ name: 'session_123/file-1.txt',
291
+ metadata: { 'original-filename': 'file.txt' },
292
+ },
293
+ ])
294
+ );
295
+
296
+ const files = await fetchSessionFiles(
297
+ 'https://code.example.com',
298
+ 'session_123',
299
+ '',
300
+ { Authorization: 'Bearer legacy-files-token' }
301
+ );
302
+
303
+ expect(files).toEqual([
304
+ {
305
+ storage_session_id: 'session_123',
306
+ kind: 'user',
307
+ id: 'file-1',
308
+ resource_id: 'file-1',
309
+ name: 'file.txt',
310
+ },
311
+ ]);
312
+ expect(fetchMock).toHaveBeenCalledWith(
313
+ 'https://code.example.com/files/session_123?detail=full',
314
+ expect.objectContaining({
315
+ headers: expect.objectContaining({
316
+ Authorization: 'Bearer legacy-files-token',
317
+ }),
318
+ })
319
+ );
320
+ });
321
+ });
@@ -3,8 +3,9 @@
3
3
  * Unit tests for Programmatic Tool Calling.
4
4
  * Tests manual invocation with mock tools and Code API responses.
5
5
  */
6
- import { describe, it, expect, beforeEach } from '@jest/globals';
6
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
7
7
  import type * as t from '@/types';
8
+ import { Constants } from '@/common';
8
9
  import {
9
10
  createProgrammaticToolCallingTool,
10
11
  formatCompletedResponse,
@@ -56,6 +57,35 @@ describe('ProgrammaticToolCalling', () => {
56
57
  });
57
58
  });
58
59
 
60
+ it('marks bash PTC inner tool invocations with bash metadata', async () => {
61
+ const invoke = jest.fn<
62
+ (_input: unknown, _config: unknown) => Promise<{ ok: boolean }>
63
+ >(async () => ({ ok: true }));
64
+ const customTool = {
65
+ name: 'custom_tool',
66
+ invoke,
67
+ } as unknown as t.GenericTool;
68
+ const customToolMap: t.ToolMap = new Map([['custom_tool', customTool]]);
69
+ const toolCalls: t.PTCToolCall[] = [
70
+ {
71
+ id: 'call_001',
72
+ name: 'custom_tool',
73
+ input: { value: 1 },
74
+ },
75
+ ];
76
+
77
+ await executeTools(
78
+ toolCalls,
79
+ customToolMap,
80
+ Constants.BASH_PROGRAMMATIC_TOOL_CALLING
81
+ );
82
+
83
+ expect(invoke).toHaveBeenCalledWith(
84
+ { value: 1 },
85
+ { metadata: { [Constants.BASH_PROGRAMMATIC_TOOL_CALLING]: true } }
86
+ );
87
+ });
88
+
59
89
  it('executes multiple tools in parallel', async () => {
60
90
  const toolCalls: t.PTCToolCall[] = [
61
91
  {
@@ -219,8 +219,16 @@ export type CodeExecutionToolParams =
219
219
  session_id?: string;
220
220
  user_id?: string;
221
221
  files?: CodeEnvFile[];
222
+ /** Optional host-supplied Code API auth headers. */
223
+ authHeaders?: CodeApiAuthHeaders;
222
224
  };
223
225
 
226
+ export type CodeApiAuthHeaderMap = Record<string, string>;
227
+
228
+ export type CodeApiAuthHeaders =
229
+ | CodeApiAuthHeaderMap
230
+ | (() => CodeApiAuthHeaderMap | Promise<CodeApiAuthHeaderMap>);
231
+
224
232
  export type FileRef = {
225
233
  /**
226
234
  * Storage file id (the per-file uuid). See `CodeEnvFile.id` for
@@ -896,6 +904,8 @@ export type ProgrammaticToolCallingParams = {
896
904
  proxy?: string;
897
905
  /** Enable debug logging (or set PTC_DEBUG=true env var) */
898
906
  debug?: boolean;
907
+ /** Optional host-supplied Code API auth headers. */
908
+ authHeaders?: CodeApiAuthHeaders;
899
909
  };
900
910
 
901
911
  // ============================================================================