mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🛤️ feat: Per-Agent Code Execution Routing With Stateful Session Scopes (#14848)
* feat: route code execution per agent profile * chore: sort execution profile imports * test: preserve stateful environment literal types * fix: isolate stateful code environments by user * fix: preserve per-agent code routing end to end * fix: route code priming by execution profile * fix: isolate code profile lifecycle state * fix: preserve mixed-profile code resources * fix: complete stateful skill routing
This commit is contained in:
parent
d411512a98
commit
06bf324cf0
70 changed files with 2782 additions and 418 deletions
|
|
@ -444,6 +444,7 @@ describe('createToolEndCallback', () => {
|
|||
name,
|
||||
toolName = 'execute_code',
|
||||
hostFileAuthoring = false,
|
||||
codeExecutionContext,
|
||||
}) {
|
||||
return {
|
||||
output: {
|
||||
|
|
@ -455,7 +456,7 @@ describe('createToolEndCallback', () => {
|
|||
files: [{ id: fileId, name, session_id: 'sess-1' }],
|
||||
},
|
||||
},
|
||||
metadata: { run_id: runId, thread_id: threadId },
|
||||
metadata: { run_id: runId, thread_id: threadId, codeExecutionContext },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -679,6 +680,10 @@ describe('createToolEndCallback', () => {
|
|||
name: 'created.txt',
|
||||
toolName: 'create_file',
|
||||
hostFileAuthoring: true,
|
||||
codeExecutionContext: {
|
||||
baseUrl: 'https://code-stateful.example.com',
|
||||
executionProfile: 'stateful',
|
||||
},
|
||||
});
|
||||
await toolEndCallback({ output: event.output }, event.metadata);
|
||||
await Promise.all(artifactPromises);
|
||||
|
|
@ -690,6 +695,8 @@ describe('createToolEndCallback', () => {
|
|||
messageId: 'run-create',
|
||||
toolCallId: 'tool-create',
|
||||
conversationId: 'thread789',
|
||||
codeApiBaseUrl: 'https://code-stateful.example.com',
|
||||
executionProfile: 'stateful',
|
||||
}),
|
||||
);
|
||||
expect(res.write).toHaveBeenCalledTimes(1);
|
||||
|
|
|
|||
|
|
@ -964,6 +964,8 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo
|
|||
* ids.
|
||||
*/
|
||||
session_id: file.storage_session_id ?? output.artifact.session_id,
|
||||
codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl,
|
||||
executionProfile: metadata.codeExecutionContext?.executionProfile,
|
||||
});
|
||||
const fileMetadata = result?.file ?? null;
|
||||
const finalize = result?.finalize;
|
||||
|
|
@ -1286,6 +1288,8 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises })
|
|||
* ids.
|
||||
*/
|
||||
session_id: file.storage_session_id ?? output.artifact.session_id,
|
||||
codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl,
|
||||
executionProfile: metadata.codeExecutionContext?.executionProfile,
|
||||
});
|
||||
const fileMetadata = result?.file ?? null;
|
||||
const finalize = result?.finalize;
|
||||
|
|
|
|||
|
|
@ -1415,7 +1415,7 @@ class AgentClient extends BaseClient {
|
|||
this.contextHandlers?.processFile(file);
|
||||
continue;
|
||||
}
|
||||
if (file.metadata?.codeEnvRef) {
|
||||
if (file.metadata?.codeEnvRef || file.metadata?.codeEnvRefs) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
@ -2585,7 +2585,7 @@ class AgentClient extends BaseClient {
|
|||
abortController = new AbortController();
|
||||
}
|
||||
|
||||
/** Fire-and-forget: boot the per-conversation stateful sandbox in
|
||||
/** Fire-and-forget: boot each selected stateful environment in
|
||||
* parallel with generation so the first execute_code/bash call lands
|
||||
* on a warm VM. No-op unless a reachable agent resolved
|
||||
* `statefulCodeSessions`. */
|
||||
|
|
@ -2631,13 +2631,7 @@ class AgentClient extends BaseClient {
|
|||
? await this.options.primeInvokedSkills(payload)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Seed `Graph.sessions` with code-env files primed across every
|
||||
* reachable agent (primary, handoff/addedConvo, and nested
|
||||
* subagents) plus skill-priming output. The merge logic and its
|
||||
* run-wide semantics live in `buildInitialToolSessions`; see that
|
||||
* helper's doc for why this is intentionally NOT per-agent.
|
||||
*/
|
||||
/** Seed each reachable agent's trusted code-session partition. */
|
||||
const initialSessions = buildInitialToolSessions({
|
||||
skillSessions: skillPrimeResult?.initialSessions,
|
||||
agents: [this.options.agent, ...(this.agentConfigs ? this.agentConfigs.values() : [])],
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ function createToolLoader(signal, definitionsOnly = true) {
|
|||
provider,
|
||||
tool_options,
|
||||
tool_resources,
|
||||
codeExecutionContext,
|
||||
accessibleMcpServerNames,
|
||||
}) {
|
||||
const agent = { id: agentId, tools, provider, model, tool_options };
|
||||
|
|
@ -97,6 +98,7 @@ function createToolLoader(signal, definitionsOnly = true) {
|
|||
agent,
|
||||
signal,
|
||||
tool_resources,
|
||||
codeExecutionContext,
|
||||
agentResourceType: ResourceType.REMOTE_AGENT,
|
||||
definitionsOnly,
|
||||
accessibleMcpServerNames,
|
||||
|
|
@ -508,6 +510,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
|
|||
req,
|
||||
res,
|
||||
agentResourceType: ResourceType.REMOTE_AGENT,
|
||||
conversationId,
|
||||
toolNames,
|
||||
agent: ctx.agent ?? agent,
|
||||
signal: abortController.signal,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ function createToolLoader(signal, definitionsOnly = true) {
|
|||
provider,
|
||||
tool_options,
|
||||
tool_resources,
|
||||
codeExecutionContext,
|
||||
accessibleMcpServerNames,
|
||||
}) {
|
||||
const agent = { id: agentId, tools, provider, model, tool_options };
|
||||
|
|
@ -111,6 +112,7 @@ function createToolLoader(signal, definitionsOnly = true) {
|
|||
agent,
|
||||
signal,
|
||||
tool_resources,
|
||||
codeExecutionContext,
|
||||
agentResourceType: ResourceType.REMOTE_AGENT,
|
||||
definitionsOnly,
|
||||
accessibleMcpServerNames,
|
||||
|
|
@ -735,6 +737,7 @@ const executeResponse = async (envelope, { req, res }) => {
|
|||
req,
|
||||
res,
|
||||
agentResourceType: ResourceType.REMOTE_AGENT,
|
||||
conversationId,
|
||||
toolNames,
|
||||
agent: ctx.agent ?? agent,
|
||||
signal: abortController.signal,
|
||||
|
|
@ -919,6 +922,7 @@ const executeResponse = async (envelope, { req, res }) => {
|
|||
req,
|
||||
res,
|
||||
agentResourceType: ResourceType.REMOTE_AGENT,
|
||||
conversationId,
|
||||
toolNames,
|
||||
agent: ctx.agent ?? agent,
|
||||
signal: abortController.signal,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const {
|
|||
startUploadSseStream,
|
||||
resolveUploadErrorMessage,
|
||||
verifyAgentUploadPermission,
|
||||
getCodeExecutionBaseUrl,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
|
|
@ -329,6 +330,18 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => {
|
|||
return res.status(400).send('Bad request');
|
||||
}
|
||||
|
||||
const requestedProfile = req.query.execution_profile;
|
||||
if (
|
||||
requestedProfile != null &&
|
||||
requestedProfile !== 'default' &&
|
||||
requestedProfile !== 'stateful'
|
||||
) {
|
||||
logger.debug(`${logPrefix} invalid execution_profile`);
|
||||
return res.status(400).send('Bad request');
|
||||
}
|
||||
const executionProfile = requestedProfile ?? 'default';
|
||||
const baseUrl = getCodeExecutionBaseUrl(executionProfile);
|
||||
|
||||
const { getDownloadStream } = getStrategyFunctions(FileSources.execute_code);
|
||||
if (!getDownloadStream) {
|
||||
logger.warn(
|
||||
|
|
@ -352,6 +365,7 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => {
|
|||
id: req.user.id,
|
||||
},
|
||||
req,
|
||||
{ baseUrl, executionProfile },
|
||||
);
|
||||
res.set(response.headers);
|
||||
response.data.pipe(res);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ jest.mock('sharp', () =>
|
|||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
refreshS3FileUrls: jest.fn(),
|
||||
getCodeExecutionBaseUrl: jest.fn((profile) =>
|
||||
profile === 'stateful'
|
||||
? process.env.LIBRECHAT_CODE_BASEURL_STATEFUL
|
||||
: 'https://code-default.example.com/v1',
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
|
|
@ -1088,4 +1093,43 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /files/code/download/:session_id/:fileId', () => {
|
||||
it('routes a persisted stateful fallback through the stateful Code API', async () => {
|
||||
const getDownloadStream = jest.fn().mockResolvedValue({
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
data: Readable.from(['stateful output']),
|
||||
});
|
||||
getStrategyFunctions.mockReturnValue({ getDownloadStream });
|
||||
process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'https://code-stateful.example.com/v1';
|
||||
|
||||
try {
|
||||
const sessionId = 's'.repeat(21);
|
||||
const codeFileId = 'f'.repeat(21);
|
||||
const response = await request(app).get(
|
||||
`/files/code/download/${sessionId}/${codeFileId}?execution_profile=stateful`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('stateful output');
|
||||
expect(getDownloadStream).toHaveBeenCalledWith(
|
||||
`${sessionId}/${codeFileId}`,
|
||||
{ kind: 'user', id: otherUserId.toString() },
|
||||
expect.any(Object),
|
||||
{ baseUrl: 'https://code-stateful.example.com/v1', executionProfile: 'stateful' },
|
||||
);
|
||||
} finally {
|
||||
delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an unknown execution profile', async () => {
|
||||
const response = await request(app).get(
|
||||
`/files/code/download/${'s'.repeat(21)}/${'f'.repeat(21)}?execution_profile=attacker`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(getStrategyFunctions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
const { logger } = require('@librechat/data-schemas');
|
||||
const { createContentAggregator } = require('@librechat/agents');
|
||||
const { createContentAggregator, GraphNodeKeys } = require('@librechat/agents');
|
||||
const {
|
||||
checkAccess,
|
||||
loadSkillStates,
|
||||
initializeAgent,
|
||||
isMemoryEnabled,
|
||||
primeInvokedSkills,
|
||||
primeInvokedSkillsForProfiles,
|
||||
validateAgentModel,
|
||||
extractManualSkills,
|
||||
GenerationJobManager,
|
||||
|
|
@ -17,6 +17,7 @@ const {
|
|||
resolveModelSpecSkillIds,
|
||||
getAgentStartupTelemetry,
|
||||
buildAgentContextAttachmentsByAgentId,
|
||||
collectCodeExecutionProfileRoutes,
|
||||
getLazySubagentConfigId,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
|
|
@ -97,6 +98,7 @@ function createToolLoader(signal, streamId = null, definitionsOnly = false, jobC
|
|||
provider,
|
||||
tool_options,
|
||||
tool_resources,
|
||||
codeExecutionContext,
|
||||
accessibleMcpServerNames,
|
||||
}) {
|
||||
const agent = { id: agentId, tools, provider, model, tool_options };
|
||||
|
|
@ -109,6 +111,7 @@ function createToolLoader(signal, streamId = null, definitionsOnly = false, jobC
|
|||
streamId,
|
||||
jobCreatedAt,
|
||||
tool_resources,
|
||||
codeExecutionContext,
|
||||
definitionsOnly,
|
||||
accessibleMcpServerNames,
|
||||
});
|
||||
|
|
@ -167,7 +170,7 @@ const initializeClient = async ({
|
|||
/** @type {Map<string, import('@librechat/api').ToolInputValidationError>} */
|
||||
const toolInputValidationErrors = new Map();
|
||||
const { contentParts, aggregateContent, stepMap } = createContentAggregator();
|
||||
const toolEndCallback = createToolEndCallback({
|
||||
const artifactToolEndCallback = createToolEndCallback({
|
||||
req,
|
||||
res,
|
||||
artifactPromises,
|
||||
|
|
@ -279,6 +282,30 @@ const initializeClient = async ({
|
|||
* }>}
|
||||
*/
|
||||
const agentToolContexts = new Map();
|
||||
/** Attach only the host-resolved route for the actually executing agent.
|
||||
* Runnable metadata is transport data and may contain caller-controlled
|
||||
* keys, so discard any incoming route context before resolving from the
|
||||
* server-owned per-agent map. This covers both traditional TOOL_END events
|
||||
* and event-driven ON_TOOL_EXECUTE callbacks. */
|
||||
const toolEndCallback = async (data, metadata = {}) => {
|
||||
const node = typeof metadata.langgraph_node === 'string' ? metadata.langgraph_node : '';
|
||||
const nodeAgentId = node.startsWith(GraphNodeKeys.TOOLS)
|
||||
? node.slice(GraphNodeKeys.TOOLS.length)
|
||||
: undefined;
|
||||
const executingAgentId =
|
||||
metadata.executingAgentId ?? metadata.agentId ?? metadata.agent_id ?? nodeAgentId;
|
||||
const soleContext =
|
||||
agentToolContexts.size === 1 ? agentToolContexts.values().next().value : null;
|
||||
const trustedContext =
|
||||
(typeof executingAgentId === 'string' ? agentToolContexts.get(executingAgentId) : null) ??
|
||||
soleContext;
|
||||
const callbackMetadata = { ...metadata };
|
||||
delete callbackMetadata.codeExecutionContext;
|
||||
if (trustedContext?.codeExecutionContext) {
|
||||
callbackMetadata.codeExecutionContext = trustedContext.codeExecutionContext;
|
||||
}
|
||||
return artifactToolEndCallback(data, callbackMetadata);
|
||||
};
|
||||
/** @type {Map<string, import('@librechat/api').EndpointTokenConfig | undefined>} */
|
||||
const endpointTokenConfigByAgentId = new Map();
|
||||
|
||||
|
|
@ -293,6 +320,7 @@ const initializeClient = async ({
|
|||
res,
|
||||
signal,
|
||||
streamId,
|
||||
conversationId,
|
||||
toolNames,
|
||||
agent: ctx.agent,
|
||||
toolRegistry: ctx.toolRegistry,
|
||||
|
|
@ -796,6 +824,7 @@ const initializeClient = async ({
|
|||
codeEnvAvailable === true &&
|
||||
agent.stateful_code_sessions === true &&
|
||||
agent.tools?.includes(Tools.execute_code) === true,
|
||||
statefulCodeEnvironment: agent.stateful_code_environment,
|
||||
includeReasoningHistory: getIncludeReasoningHistory(agent),
|
||||
});
|
||||
|
||||
|
|
@ -977,6 +1006,7 @@ const initializeClient = async ({
|
|||
configId: metadata.configId,
|
||||
codeEnvAvailable: metadata.codeEnvAvailable,
|
||||
statefulCodeSessions: metadata.statefulCodeSessions,
|
||||
statefulCodeEnvironment: metadata.statefulCodeEnvironment,
|
||||
includeReasoningHistory: metadata.includeReasoningHistory,
|
||||
lazySubagentConfigs: lazyChildren,
|
||||
subagentAgentConfigs: eagerChildren,
|
||||
|
|
@ -1057,16 +1087,23 @@ const initializeClient = async ({
|
|||
|
||||
/** History priming uses the user's full ACL-accessible skill set (not
|
||||
* per-agent scoped) because prior turns may reference skills no longer
|
||||
* in any active agent's scope; the ACL check is the security gate.
|
||||
* `codeEnvAvailable` comes from `primaryConfig` — @see
|
||||
* `InitializedAgent.codeEnvAvailable` for the per-agent narrowing. */
|
||||
* in any active agent's scope; the ACL check is the security gate. Each
|
||||
* selected Code API deployment receives its own upload, and only session
|
||||
* partitions routed to that deployment receive those storage pointers. */
|
||||
const codeExecutionProfiles = collectCodeExecutionProfileRoutes(
|
||||
[primaryConfig, ...agentConfigs.values()],
|
||||
{
|
||||
userId: req.user.id,
|
||||
conversationId,
|
||||
},
|
||||
);
|
||||
const handlePrimeInvokedSkills = skillsCapabilityEnabled
|
||||
? (payload) =>
|
||||
primeInvokedSkills({
|
||||
primeInvokedSkillsForProfiles({
|
||||
req,
|
||||
payload,
|
||||
accessibleSkillIds,
|
||||
codeEnvAvailable: primaryConfig.codeEnvAvailable === true,
|
||||
executionProfiles: codeExecutionProfiles,
|
||||
...getSkillToolDeps(),
|
||||
})
|
||||
: undefined;
|
||||
|
|
|
|||
|
|
@ -45,8 +45,9 @@ jest.mock('@librechat/api', () => ({
|
|||
* the tool context (agent, tool_resources, skill ACLs) was preserved. */
|
||||
let capturedToolExecuteOptions;
|
||||
let capturedDefaultHandlerOptions;
|
||||
const mockArtifactToolEndCallback = jest.fn();
|
||||
jest.mock('~/server/controllers/agents/callbacks', () => ({
|
||||
createToolEndCallback: jest.fn(() => jest.fn()),
|
||||
createToolEndCallback: jest.fn(() => mockArtifactToolEndCallback),
|
||||
createAttachmentEmitter: jest.fn(() => jest.fn()),
|
||||
createBackgroundCodeResultHandler: jest.fn(() => jest.fn()),
|
||||
getDefaultHandlers: jest.fn((opts) => {
|
||||
|
|
@ -158,6 +159,42 @@ describe('initializeClient — processAgent ACL gate', () => {
|
|||
tool_resources: {},
|
||||
resendFiles: true,
|
||||
maxContextTokens: 4096,
|
||||
codeExecutionContext: {
|
||||
baseUrl: 'https://code-default.example.com',
|
||||
codeSessionKey: 'execute_code',
|
||||
executionProfile: 'default',
|
||||
statefulSessions: false,
|
||||
},
|
||||
});
|
||||
|
||||
it('replaces untrusted artifact route metadata with the executing agent context', async () => {
|
||||
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
|
||||
|
||||
await initializeClient({
|
||||
req: makeReq(),
|
||||
res: {},
|
||||
signal: new AbortController().signal,
|
||||
endpointOption: makeEndpointOption(),
|
||||
});
|
||||
|
||||
const data = { output: { name: 'execute_code', artifact: { files: [] } } };
|
||||
await capturedDefaultHandlerOptions.toolEndCallback(data, {
|
||||
langgraph_node: `tools=${PRIMARY_ID}`,
|
||||
codeExecutionContext: {
|
||||
baseUrl: 'https://attacker.invalid',
|
||||
executionProfile: 'stateful',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockArtifactToolEndCallback).toHaveBeenLastCalledWith(
|
||||
data,
|
||||
expect.objectContaining({
|
||||
codeExecutionContext: expect.objectContaining({
|
||||
baseUrl: 'https://code-default.example.com',
|
||||
executionProfile: 'default',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('threads the owning job epoch into resumable event handlers', async () => {
|
||||
|
|
@ -766,6 +803,7 @@ describe('initializeClient — subagent loading', () => {
|
|||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(),
|
||||
tools: ['web'],
|
||||
stateful_code_environment: 'agent-user',
|
||||
});
|
||||
await grantView(subAgent);
|
||||
|
||||
|
|
@ -795,7 +833,11 @@ describe('initializeClient — subagent loading', () => {
|
|||
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
|
||||
expect(agentClientArgs.agent.lazySubagentConfigs).toHaveLength(1);
|
||||
expect(agentClientArgs.agent.lazySubagentConfigs[0]).toEqual(
|
||||
expect.objectContaining({ id: SUBAGENT_ID, configId: expect.any(String) }),
|
||||
expect.objectContaining({
|
||||
id: SUBAGENT_ID,
|
||||
configId: expect.any(String),
|
||||
statefulCodeEnvironment: 'agent-user',
|
||||
}),
|
||||
);
|
||||
expect(agentClientArgs.agent.lazySubagentConfigs[0]).not.toHaveProperty('tools');
|
||||
expect(agentClientArgs.agent.lazySubagentConfigs[0]).not.toHaveProperty('tool_resources');
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ function buildAgentToolContext({ agent, config }) {
|
|||
accessibleSkillIds: config.accessibleSkillIds,
|
||||
activeSkillNames: config.activeSkillNames,
|
||||
codeEnvAvailable: config.codeEnvAvailable,
|
||||
codeExecutionContext: config.codeExecutionContext,
|
||||
skillAuthoringAvailable: config.skillAuthoringAvailable,
|
||||
fileAuthoringToolNames: config.fileAuthoringToolNames,
|
||||
skillPrimedIdsByName:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ jest.mock('@librechat/api', () => {
|
|||
flattenArtifactPath: mockFlattenArtifactPath,
|
||||
createAxiosInstance: jest.fn(() => mockAxios),
|
||||
getCodeApiAuthHeaders: jest.fn(async () => ({})),
|
||||
getCodeExecutionBaseUrl: jest.fn(() => 'http://localhost:8000'),
|
||||
CODE_API_EXPECTED_PROFILE_HEADER: 'X-CodeAPI-Expected-Profile',
|
||||
classifyCodeArtifact: jest.fn(() => 'other'),
|
||||
extractCodeArtifactText: jest.fn(async () => null),
|
||||
/* `processCodeOutput` calls this to derive the trust flag persisted
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const FormData = require('form-data');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getCodeBaseURL } = require('@librechat/agents');
|
||||
const { getCodeEnvRefs } = require('librechat-data-provider');
|
||||
const {
|
||||
logAxiosError,
|
||||
appendCodeEnvFile,
|
||||
|
|
@ -10,6 +11,8 @@ const {
|
|||
appendCodeEnvFileIdentity,
|
||||
buildCodeEnvDownloadQuery,
|
||||
getCodeApiAuthHeaders,
|
||||
getCodeExecutionBaseUrl,
|
||||
CODE_API_EXPECTED_PROFILE_HEADER,
|
||||
} = require('@librechat/api');
|
||||
|
||||
const axios = createAxiosInstance();
|
||||
|
|
@ -24,12 +27,15 @@ const MAX_FILE_SIZE = 150 * 1024 * 1024;
|
|||
* matching sessionKey. For code-output downloads this is always
|
||||
* `kind: 'user', id: <userId>`; for skill/agent re-downloads pass
|
||||
* the kind+id (+version for skill) from the file's `metadata.codeEnvRef`.
|
||||
* @param {ServerRequest} req - Current authenticated request.
|
||||
* @param {{baseUrl?: string, executionProfile?: 'default'|'stateful'}} [route]
|
||||
* Trusted host-selected Code API route.
|
||||
* @returns {Promise<AxiosResponse>} A promise that resolves to a readable stream of the file content.
|
||||
* @throws {Error} If there's an error during the download process.
|
||||
*/
|
||||
async function getCodeOutputDownloadStream(fileIdentifier, identity, req) {
|
||||
async function getCodeOutputDownloadStream(fileIdentifier, identity, req, route = {}) {
|
||||
try {
|
||||
const baseURL = getCodeBaseURL();
|
||||
const baseURL = route.baseUrl ?? getCodeBaseURL();
|
||||
const query = buildCodeEnvDownloadQuery(identity);
|
||||
const authHeaders = await getCodeApiAuthHeaders(req);
|
||||
/** @type {import('axios').AxiosRequestConfig} */
|
||||
|
|
@ -40,6 +46,9 @@ async function getCodeOutputDownloadStream(fileIdentifier, identity, req) {
|
|||
headers: {
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
...authHeaders,
|
||||
...(route.executionProfile
|
||||
? { [CODE_API_EXPECTED_PROFILE_HEADER]: route.executionProfile }
|
||||
: {}),
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -66,26 +75,26 @@ async function getCodeOutputDownloadStream(fileIdentifier, identity, req) {
|
|||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function deleteCodeEnvFile(req, file) {
|
||||
const ref = file?.metadata?.codeEnvRef;
|
||||
if (!ref) {
|
||||
const refs = getCodeEnvRefs(file?.metadata);
|
||||
if (refs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastError;
|
||||
const missingOrUnsupportedStatuses = new Set([404, 405]);
|
||||
try {
|
||||
const baseURL = getCodeBaseURL();
|
||||
const authHeaders = await getCodeApiAuthHeaders(req);
|
||||
for (const [executionProfile, ref] of refs) {
|
||||
const baseURL = getCodeExecutionBaseUrl(executionProfile);
|
||||
const query = buildCodeEnvDownloadQuery({
|
||||
kind: ref.kind,
|
||||
id: ref.id,
|
||||
...(ref.kind === 'skill' ? { version: ref.version } : {}),
|
||||
});
|
||||
const authHeaders = await getCodeApiAuthHeaders(req);
|
||||
const baseRequest = {
|
||||
method: 'delete',
|
||||
headers: {
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
...authHeaders,
|
||||
[CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -96,10 +105,13 @@ async function deleteCodeEnvFile(req, file) {
|
|||
`${baseURL}/files/${ref.storage_session_id}/${ref.file_id}${query}`,
|
||||
];
|
||||
|
||||
let lastError;
|
||||
let deleted = false;
|
||||
for (const url of urls) {
|
||||
try {
|
||||
await axios({ ...baseRequest, url });
|
||||
return;
|
||||
deleted = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!missingOrUnsupportedStatuses.has(error.response?.status)) {
|
||||
|
|
@ -107,19 +119,16 @@ async function deleteCodeEnvFile(req, file) {
|
|||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
logAxiosError({
|
||||
error: lastError,
|
||||
message: `Error deleting code environment file: ${lastError.message}`,
|
||||
});
|
||||
if (lastError.response?.status === 404) {
|
||||
return;
|
||||
if (!deleted && lastError) {
|
||||
logAxiosError({
|
||||
error: lastError,
|
||||
message: `Error deleting code environment file: ${lastError.message}`,
|
||||
});
|
||||
if (lastError.response?.status === 404) {
|
||||
continue;
|
||||
}
|
||||
throw new Error(lastError.message || 'An error occurred during file deletion.');
|
||||
}
|
||||
throw new Error(lastError.message || 'An error occurred during file deletion.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,17 +151,28 @@ async function deleteCodeEnvFile(req, file) {
|
|||
* ignores this for `kind: 'user'` (auth context provides userId), but it's
|
||||
* sent uniformly for shape symmetry with the discriminated union.
|
||||
* @param {number} [params.version] - Required when `kind === 'skill'`; absent otherwise.
|
||||
* @param {string} [params.codeApiBaseUrl] - Trusted per-agent Code API endpoint.
|
||||
* @param {'default'|'stateful'} [params.executionProfile] - Trusted execution profile.
|
||||
* @returns {Promise<{ storage_session_id: string; file_id: string }>}
|
||||
* The codeapi storage location of the uploaded file.
|
||||
* @throws {Error} If there's an error during the upload process.
|
||||
*/
|
||||
async function uploadCodeEnvFile({ req, stream, filename, kind, id, version }) {
|
||||
async function uploadCodeEnvFile({
|
||||
req,
|
||||
stream,
|
||||
filename,
|
||||
kind,
|
||||
id,
|
||||
version,
|
||||
codeApiBaseUrl,
|
||||
executionProfile,
|
||||
}) {
|
||||
try {
|
||||
const form = new FormData();
|
||||
appendCodeEnvFileIdentity(form, { kind, id, version });
|
||||
appendCodeEnvFile(form, stream, filename);
|
||||
|
||||
const baseURL = getCodeBaseURL();
|
||||
const baseURL = codeApiBaseUrl ?? getCodeBaseURL();
|
||||
const authHeaders = await getCodeApiAuthHeaders(req);
|
||||
/** @type {import('axios').AxiosRequestConfig} */
|
||||
const options = {
|
||||
|
|
@ -162,6 +182,7 @@ async function uploadCodeEnvFile({ req, stream, filename, kind, id, version }) {
|
|||
'User-Agent': 'LibreChat/1.0',
|
||||
'User-Id': req.user.id,
|
||||
...authHeaders,
|
||||
...(executionProfile ? { [CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile } : {}),
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -211,10 +232,21 @@ async function uploadCodeEnvFile({ req, stream, filename, kind, id, version }) {
|
|||
* through subsequent download/walk passes — sandboxed-code modifications
|
||||
* are dropped on the floor and the original ref is echoed back as
|
||||
* `inherited: true`, never as a generated artifact.
|
||||
* @param {string} [params.codeApiBaseUrl] - Trusted per-agent Code API endpoint.
|
||||
* @param {'default'|'stateful'} [params.executionProfile] - Trusted execution profile.
|
||||
* @returns {Promise<{ storage_session_id: string; files: Array<{ fileId: string; filename: string }> }>}
|
||||
* @throws {Error} If the batch upload fails entirely.
|
||||
*/
|
||||
async function batchUploadCodeEnvFiles({ req, files, kind, id, version, read_only = false }) {
|
||||
async function batchUploadCodeEnvFiles({
|
||||
req,
|
||||
files,
|
||||
kind,
|
||||
id,
|
||||
version,
|
||||
read_only = false,
|
||||
codeApiBaseUrl,
|
||||
executionProfile,
|
||||
}) {
|
||||
const form = new FormData();
|
||||
appendCodeEnvFileIdentity(form, { kind, id, version });
|
||||
if (read_only) {
|
||||
|
|
@ -224,7 +256,7 @@ async function batchUploadCodeEnvFiles({ req, files, kind, id, version, read_onl
|
|||
appendCodeEnvFile(form, file.stream, file.filename);
|
||||
}
|
||||
|
||||
const baseURL = getCodeBaseURL();
|
||||
const baseURL = codeApiBaseUrl ?? getCodeBaseURL();
|
||||
const authHeaders = await getCodeApiAuthHeaders(req);
|
||||
/** @type {import('axios').AxiosRequestConfig} */
|
||||
const options = {
|
||||
|
|
@ -234,6 +266,7 @@ async function batchUploadCodeEnvFiles({ req, files, kind, id, version, read_onl
|
|||
'User-Agent': 'LibreChat/1.0',
|
||||
'User-Id': req.user.id,
|
||||
...authHeaders,
|
||||
...(executionProfile ? { [CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile } : {}),
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ jest.mock('@librechat/api', () => {
|
|||
}),
|
||||
logAxiosError: jest.fn(({ message }) => message),
|
||||
getCodeApiAuthHeaders: jest.fn(async () => ({})),
|
||||
getCodeExecutionBaseUrl: jest.fn((profile) =>
|
||||
profile === 'stateful' ? 'https://code-stateful.example.com' : 'https://code-api.example.com',
|
||||
),
|
||||
CODE_API_EXPECTED_PROFILE_HEADER: 'X-CodeAPI-Expected-Profile',
|
||||
createAxiosInstance: jest.fn(() => mockAxios),
|
||||
codeServerHttpAgent: new http.Agent({ keepAlive: false }),
|
||||
codeServerHttpsAgent: new https.Agent({ keepAlive: false }),
|
||||
|
|
@ -107,6 +111,22 @@ describe('Code CRUD', () => {
|
|||
expect(callConfig.timeout).toBe(15000);
|
||||
});
|
||||
|
||||
it('uses the trusted stateful route and fail-closed profile header', async () => {
|
||||
mockAxios.mockResolvedValue({ data: Readable.from(['chunk']) });
|
||||
|
||||
await getCodeOutputDownloadStream('session-1/file-1', userIdentity, undefined, {
|
||||
baseUrl: 'https://code-stateful.example.com',
|
||||
executionProfile: 'stateful',
|
||||
});
|
||||
|
||||
expect(mockAxios).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://code-stateful.example.com/download/session-1/file-1?kind=user&id=user-123',
|
||||
headers: expect.objectContaining({ 'X-CodeAPI-Expected-Profile': 'stateful' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards Code API auth headers when a request is provided', async () => {
|
||||
const req = { user: { id: 'user-123' } };
|
||||
getCodeApiAuthHeaders.mockResolvedValue({ Authorization: 'Bearer codeapi-token' });
|
||||
|
|
@ -190,6 +210,65 @@ describe('Code CRUD', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('deletes a stateful artifact from its originating profile', async () => {
|
||||
mockAxios.mockResolvedValue({ status: 204 });
|
||||
const statefulFile = {
|
||||
metadata: {
|
||||
codeEnvRef: {
|
||||
...file.metadata.codeEnvRef,
|
||||
executionProfile: 'stateful',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await deleteCodeEnvFile(req, statefulFile);
|
||||
|
||||
expect(mockAxios).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://code-stateful.example.com/sessions/session-1/objects/file-1?kind=agent&id=agent-abc',
|
||||
headers: expect.objectContaining({
|
||||
'X-CodeAPI-Expected-Profile': 'stateful',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes every profile-local object retained for a shared file record', async () => {
|
||||
mockAxios.mockResolvedValue({ status: 204 });
|
||||
const dualProfileFile = {
|
||||
metadata: {
|
||||
codeEnvRef: file.metadata.codeEnvRef,
|
||||
codeEnvRefs: {
|
||||
default: file.metadata.codeEnvRef,
|
||||
stateful: {
|
||||
...file.metadata.codeEnvRef,
|
||||
storage_session_id: 'stateful-session',
|
||||
file_id: 'stateful-file',
|
||||
executionProfile: 'stateful',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await deleteCodeEnvFile(req, dualProfileFile);
|
||||
|
||||
expect(mockAxios).toHaveBeenCalledTimes(2);
|
||||
expect(mockAxios).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining('/sessions/session-1/objects/file-1'),
|
||||
headers: expect.objectContaining({ 'X-CodeAPI-Expected-Profile': 'default' }),
|
||||
}),
|
||||
);
|
||||
expect(mockAxios).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining('/sessions/stateful-session/objects/stateful-file'),
|
||||
headers: expect.objectContaining({ 'X-CodeAPI-Expected-Profile': 'stateful' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([404, 405])(
|
||||
'falls back to the legacy code environment delete route after a %s',
|
||||
async (status) => {
|
||||
|
|
@ -317,6 +396,26 @@ describe('Code CRUD', () => {
|
|||
expect(callConfig.headers.Authorization).toBe('Bearer codeapi-token');
|
||||
});
|
||||
|
||||
it('routes uploads through the trusted stateful endpoint and profile header', async () => {
|
||||
mockAxios.post.mockResolvedValue({
|
||||
data: {
|
||||
message: 'success',
|
||||
storage_session_id: 'sess-1',
|
||||
files: [{ fileId: 'fid-1', filename: 'data.csv' }],
|
||||
},
|
||||
});
|
||||
|
||||
await uploadCodeEnvFile({
|
||||
...baseUploadParams,
|
||||
codeApiBaseUrl: 'https://stateful-code.example.com',
|
||||
executionProfile: 'stateful',
|
||||
});
|
||||
|
||||
const [url, , callConfig] = mockAxios.post.mock.calls[0];
|
||||
expect(url).toBe('https://stateful-code.example.com/upload');
|
||||
expect(callConfig.headers['X-CodeAPI-Expected-Profile']).toBe('stateful');
|
||||
});
|
||||
|
||||
/* Phase C / option α (codeapi #1455): the upload wire carries the
|
||||
* resource identity codeapi uses for sessionKey derivation. Without
|
||||
* these on the form, codeapi falls back to user bucketing for every
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ const {
|
|||
extractCodeArtifactText,
|
||||
getExtractedTextFormat,
|
||||
getStorageMetadata,
|
||||
getCodeExecutionBaseUrl,
|
||||
buildCodeEnvDownloadQuery,
|
||||
CODE_API_EXPECTED_PROFILE_HEADER,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Tools,
|
||||
|
|
@ -31,6 +33,9 @@ const {
|
|||
EModelEndpoint,
|
||||
ErrorTypes,
|
||||
mergeFileConfig,
|
||||
getCodeEnvRefs,
|
||||
mergeCodeEnvRef,
|
||||
getCodeEnvRefForProfile,
|
||||
getEndpointFileConfig,
|
||||
} = require('librechat-data-provider');
|
||||
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
|
||||
|
|
@ -53,6 +58,7 @@ const axios = createAxiosInstance();
|
|||
* @param {string} params.toolCallId - The tool call ID that generated the file.
|
||||
* @param {string} params.messageId - The current message ID.
|
||||
* @param {number} params.expiresAt - Expiration timestamp (24 hours from creation).
|
||||
* @param {'default'|'stateful'} [params.executionProfile] - Code API route for later fallback download.
|
||||
* @returns {Object} Fallback response with download URL.
|
||||
*/
|
||||
const createDownloadFallback = ({
|
||||
|
|
@ -64,11 +70,13 @@ const createDownloadFallback = ({
|
|||
session_id,
|
||||
toolCallId,
|
||||
conversationId,
|
||||
executionProfile,
|
||||
}) => {
|
||||
const basePath = getBasePath();
|
||||
const profileQuery = executionProfile === 'stateful' ? '?execution_profile=stateful' : '';
|
||||
return {
|
||||
filename: name,
|
||||
filepath: `${basePath}/api/files/code/download/${session_id}/${id}`,
|
||||
filepath: `${basePath}/api/files/code/download/${session_id}/${id}${profileQuery}`,
|
||||
expiresAt,
|
||||
conversationId,
|
||||
toolCallId,
|
||||
|
|
@ -314,6 +322,8 @@ const runPreviewFinalize = ({ finalize, fileId, previewRevision, onResolved }) =
|
|||
* @param {string} params.session_id - The code execution session ID.
|
||||
* @param {string} params.conversationId - The current conversation ID.
|
||||
* @param {string} params.messageId - The current message ID.
|
||||
* @param {string} [params.codeApiBaseUrl] - Trusted per-agent Code API endpoint.
|
||||
* @param {'default'|'stateful'} [params.executionProfile] - Trusted execution profile.
|
||||
* @returns {Promise<{ file: MongoFile & { messageId: string, toolCallId: string }, finalize?: () => Promise<MongoFile | null> }>}
|
||||
*/
|
||||
const processCodeOutput = async ({
|
||||
|
|
@ -326,10 +336,12 @@ const processCodeOutput = async ({
|
|||
session_id,
|
||||
agentId,
|
||||
freshClaimAfter,
|
||||
codeApiBaseUrl,
|
||||
executionProfile = 'default',
|
||||
}) => {
|
||||
const appConfig = req.config;
|
||||
const currentDate = new Date();
|
||||
const baseURL = getCodeBaseURL();
|
||||
const baseURL = codeApiBaseUrl ?? getCodeExecutionBaseUrl(executionProfile);
|
||||
const fileExt = path.extname(name).toLowerCase();
|
||||
const isImage = fileExt && imageExtRegex.test(name);
|
||||
|
||||
|
|
@ -356,6 +368,7 @@ const processCodeOutput = async ({
|
|||
headers: {
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
...authHeaders,
|
||||
[CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -378,6 +391,7 @@ const processCodeOutput = async ({
|
|||
toolCallId,
|
||||
session_id,
|
||||
conversationId,
|
||||
executionProfile,
|
||||
expiresAt: currentDate.getTime() + 86400000,
|
||||
}),
|
||||
};
|
||||
|
|
@ -391,6 +405,7 @@ const processCodeOutput = async ({
|
|||
id: req.user.id,
|
||||
storage_session_id: session_id,
|
||||
file_id: id,
|
||||
executionProfile,
|
||||
};
|
||||
|
||||
/* `safeName` keeps the directory structure (`a/b/file.txt` -> `a/b/file.txt`)
|
||||
|
|
@ -504,6 +519,14 @@ const processCodeOutput = async ({
|
|||
* silently excludes the file from priming on subsequent turns.
|
||||
*/
|
||||
const persistedMessageId = isUpdate ? (claimed.messageId ?? messageId) : messageId;
|
||||
/* A generated-output write replaces the file's bytes, so pointers to
|
||||
* earlier content in another profile must not survive as reusable refs. */
|
||||
const codeEnvReferenceSet = mergeCodeEnvRef(undefined, codeEnvRef);
|
||||
const codeEnvMetadata = {
|
||||
...claimed.metadata,
|
||||
...codeEnvReferenceSet,
|
||||
sourceDispatchedAt,
|
||||
};
|
||||
|
||||
if (isImage) {
|
||||
const usage = isUpdate ? (claimed.usage ?? 0) + 1 : 1;
|
||||
|
|
@ -524,6 +547,7 @@ const processCodeOutput = async ({
|
|||
usage,
|
||||
filename: safeName,
|
||||
conversationId,
|
||||
executionProfile,
|
||||
user: req.user.id,
|
||||
tenantId: req.user.tenantId,
|
||||
type: `image/${appConfig.imageOutputType}`,
|
||||
|
|
@ -531,7 +555,7 @@ const processCodeOutput = async ({
|
|||
updatedAt: formattedDate,
|
||||
source: appConfig.fileStrategy,
|
||||
context: FileContext.execute_code,
|
||||
metadata: { codeEnvRef, sourceDispatchedAt },
|
||||
metadata: codeEnvMetadata,
|
||||
...(await getRetentionExpiry(req)),
|
||||
};
|
||||
if (!(await commitCodeFile(file))) {
|
||||
|
|
@ -554,6 +578,7 @@ const processCodeOutput = async ({
|
|||
toolCallId,
|
||||
session_id,
|
||||
conversationId,
|
||||
executionProfile,
|
||||
expiresAt: currentDate.getTime() + 86400000,
|
||||
}),
|
||||
};
|
||||
|
|
@ -633,7 +658,7 @@ const processCodeOutput = async ({
|
|||
tenantId: req.user.tenantId,
|
||||
bytes: buffer.length,
|
||||
updatedAt: formattedDate,
|
||||
metadata: { codeEnvRef, sourceDispatchedAt },
|
||||
metadata: codeEnvMetadata,
|
||||
source: appConfig.fileStrategy,
|
||||
context: FileContext.execute_code,
|
||||
usage: isUpdate ? (claimed.usage ?? 0) + 1 : 1,
|
||||
|
|
@ -731,6 +756,7 @@ const processCodeOutput = async ({
|
|||
toolCallId,
|
||||
session_id,
|
||||
conversationId,
|
||||
executionProfile,
|
||||
expiresAt: currentDate.getTime() + 86400000,
|
||||
}),
|
||||
};
|
||||
|
|
@ -752,14 +778,16 @@ function checkIfActive(dateString) {
|
|||
* into codeapi storage. Carries kind/id/storage_session_id/file_id;
|
||||
* codeapi resolves the sessionKey from the request's auth context.
|
||||
* @param {ServerRequest} [req] - Current authenticated request, used to mint Code API auth.
|
||||
* @param {{baseUrl?: string, executionProfile?: 'default'|'stateful'}} [route]
|
||||
* Trusted host-selected Code API route.
|
||||
*
|
||||
* @returns {Promise<string|null>}
|
||||
* A promise that resolves to the `lastModified` time string of the file if successful, or null if there is an
|
||||
* error in initialization or fetching the info.
|
||||
*/
|
||||
async function getSessionInfo(ref, req) {
|
||||
async function getSessionInfo(ref, req, route = {}) {
|
||||
try {
|
||||
const baseURL = getCodeBaseURL();
|
||||
const baseURL = route.baseUrl ?? getCodeBaseURL();
|
||||
const authHeaders = await getCodeApiAuthHeaders(req);
|
||||
/* `/sessions/.../objects/...` is gated by codeapi's `sessionAuth`
|
||||
* middleware (post-Phase C). The middleware reconstructs the
|
||||
|
|
@ -778,6 +806,9 @@ async function getSessionInfo(ref, req) {
|
|||
headers: {
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
...authHeaders,
|
||||
...(route.executionProfile
|
||||
? { [CODE_API_EXPECTED_PROFILE_HEADER]: route.executionProfile }
|
||||
: {}),
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -886,7 +917,15 @@ const getReuploadFailureCategory = (error) => {
|
|||
* }>}
|
||||
*/
|
||||
const primeFiles = async (options) => {
|
||||
const { tool_resources, req, agentId, agentResourceType } = options;
|
||||
const {
|
||||
tool_resources,
|
||||
req,
|
||||
agentId,
|
||||
agentResourceType,
|
||||
codeApiBaseUrl,
|
||||
executionProfile = 'default',
|
||||
} = options;
|
||||
const codeApiRoute = { baseUrl: codeApiBaseUrl, executionProfile };
|
||||
const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? [];
|
||||
const agentResourceIds = new Set(file_ids);
|
||||
const resourceFiles = tool_resources?.[EToolResources.execute_code]?.files ?? [];
|
||||
|
|
@ -943,15 +982,16 @@ const primeFiles = async (options) => {
|
|||
continue;
|
||||
}
|
||||
|
||||
const ref = file.metadata?.codeEnvRef;
|
||||
if (!ref) {
|
||||
const ref = getCodeEnvRefForProfile(file.metadata, executionProfile);
|
||||
const sourceRef = ref ?? getCodeEnvRefs(file.metadata)[0]?.[1];
|
||||
if (!sourceRef) {
|
||||
skippedNoRef += 1;
|
||||
logger.debug(`[primeCodeFiles] file=${file.file_id} path=skip reason=no-codeenvref`);
|
||||
continue;
|
||||
}
|
||||
requiredCodeFiles += 1;
|
||||
const session_id = ref.storage_session_id;
|
||||
const id = ref.file_id;
|
||||
const session_id = sourceRef.storage_session_id;
|
||||
const id = sourceRef.file_id;
|
||||
|
||||
/**
|
||||
* `pushFile` accepts optional overrides so the reupload path can
|
||||
|
|
@ -982,22 +1022,14 @@ const primeFiles = async (options) => {
|
|||
* we still send it for shape uniformity with shared kinds. */
|
||||
files.push({
|
||||
id: overrideId ?? id,
|
||||
resource_id: ref.id,
|
||||
resource_id: sourceRef.id,
|
||||
storage_session_id: overrideSessionId ?? session_id,
|
||||
name: file.filename,
|
||||
kind: ref.kind,
|
||||
...(ref.kind === 'skill' ? { version: ref.version } : {}),
|
||||
kind: sourceRef.kind,
|
||||
...(sourceRef.kind === 'skill' ? { version: sourceRef.version } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
if (sessions.has(session_id)) {
|
||||
logger.debug(
|
||||
`[primeCodeFiles] file=${file.file_id} path=cache-hit-by-session storage_session_id=${session_id}`,
|
||||
);
|
||||
pushFile();
|
||||
continue;
|
||||
}
|
||||
|
||||
const reuploadFile = async () => {
|
||||
try {
|
||||
const { getDownloadStream } = getStrategyFunctions(file.source);
|
||||
|
|
@ -1014,9 +1046,11 @@ const primeFiles = async (options) => {
|
|||
req: options.req,
|
||||
stream,
|
||||
filename: file.filename,
|
||||
kind: ref.kind,
|
||||
id: ref.id,
|
||||
...(ref.kind === 'skill' ? { version: ref.version } : {}),
|
||||
kind: sourceRef.kind,
|
||||
id: sourceRef.id,
|
||||
...(sourceRef.kind === 'skill' ? { version: sourceRef.version } : {}),
|
||||
codeApiBaseUrl,
|
||||
executionProfile,
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -1033,21 +1067,20 @@ const primeFiles = async (options) => {
|
|||
* pointer changes.
|
||||
*/
|
||||
const newRef = {
|
||||
kind: ref.kind,
|
||||
id: ref.id,
|
||||
kind: sourceRef.kind,
|
||||
id: sourceRef.id,
|
||||
storage_session_id: uploaded.storage_session_id,
|
||||
file_id: uploaded.file_id,
|
||||
...(ref.kind === 'skill' ? { version: ref.version } : {}),
|
||||
executionProfile,
|
||||
...(sourceRef.kind === 'skill' ? { version: sourceRef.version } : {}),
|
||||
};
|
||||
|
||||
const updatedMetadata = {
|
||||
...file.metadata,
|
||||
codeEnvRef: newRef,
|
||||
};
|
||||
const updatedRefs = mergeCodeEnvRef(file.metadata, newRef);
|
||||
|
||||
await updateFile({
|
||||
file_id: file.file_id,
|
||||
metadata: updatedMetadata,
|
||||
'metadata.codeEnvRef': updatedRefs.codeEnvRef,
|
||||
[`metadata.codeEnvRefs.${executionProfile}`]: newRef,
|
||||
});
|
||||
sessions.set(newRef.storage_session_id, true);
|
||||
pushFile(newRef.storage_session_id, newRef.file_id);
|
||||
|
|
@ -1066,7 +1099,22 @@ const primeFiles = async (options) => {
|
|||
);
|
||||
}
|
||||
};
|
||||
const uploadTime = await getSessionInfo(ref, req);
|
||||
if (!ref) {
|
||||
logger.debug(
|
||||
`[primeCodeFiles] file=${file.file_id} path=reupload reason=profile-missing ` +
|
||||
`requestedProfile=${executionProfile}`,
|
||||
);
|
||||
await reuploadFile();
|
||||
continue;
|
||||
}
|
||||
if (sessions.has(session_id)) {
|
||||
logger.debug(
|
||||
`[primeCodeFiles] file=${file.file_id} path=cache-hit-by-session storage_session_id=${session_id}`,
|
||||
);
|
||||
pushFile();
|
||||
continue;
|
||||
}
|
||||
const uploadTime = await getSessionInfo(ref, req, codeApiRoute);
|
||||
if (!uploadTime) {
|
||||
logger.debug(
|
||||
`[primeCodeFiles] file=${file.file_id} path=reupload reason=no-uploadtime ` +
|
||||
|
|
@ -1144,8 +1192,16 @@ const primeFiles = async (options) => {
|
|||
* @param {ServerRequest} [params.req] - Current authenticated request, used to mint Code API auth.
|
||||
* @returns {Promise<{content: string} | null>}
|
||||
*/
|
||||
async function readSandboxFile({ file_path, session_id, files, runtime_session_hint, req }) {
|
||||
const baseURL = getCodeBaseURL();
|
||||
async function readSandboxFile({
|
||||
file_path,
|
||||
session_id,
|
||||
files,
|
||||
runtime_session_hint,
|
||||
codeApiBaseUrl,
|
||||
executionProfile,
|
||||
req,
|
||||
}) {
|
||||
const baseURL = codeApiBaseUrl ?? getCodeBaseURL();
|
||||
if (!baseURL) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1177,6 +1233,7 @@ async function readSandboxFile({ file_path, session_id, files, runtime_session_h
|
|||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
...authHeaders,
|
||||
...(executionProfile ? { [CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile } : {}),
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -1225,10 +1282,12 @@ async function readSandboxImage({
|
|||
session_id,
|
||||
files,
|
||||
runtime_session_hint,
|
||||
codeApiBaseUrl,
|
||||
executionProfile,
|
||||
maxBytes,
|
||||
req,
|
||||
}) {
|
||||
const baseURL = getCodeBaseURL();
|
||||
const baseURL = codeApiBaseUrl ?? getCodeBaseURL();
|
||||
if (!baseURL) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1287,6 +1346,7 @@ async function readSandboxImage({
|
|||
file_path,
|
||||
session_id,
|
||||
runtime_session_hint,
|
||||
executionProfile,
|
||||
files,
|
||||
req,
|
||||
chunkBytes,
|
||||
|
|
@ -1357,6 +1417,7 @@ async function execSandboxImageChunk({
|
|||
file_path,
|
||||
session_id,
|
||||
runtime_session_hint,
|
||||
executionProfile,
|
||||
files,
|
||||
req,
|
||||
chunkBytes,
|
||||
|
|
@ -1383,6 +1444,7 @@ async function execSandboxImageChunk({
|
|||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
...authHeaders,
|
||||
...(executionProfile ? { [CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile } : {}),
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -1448,9 +1510,11 @@ async function writeSandboxFile({
|
|||
session_id,
|
||||
files,
|
||||
runtime_session_hint,
|
||||
codeApiBaseUrl,
|
||||
executionProfile,
|
||||
req,
|
||||
}) {
|
||||
const baseURL = getCodeBaseURL();
|
||||
const baseURL = codeApiBaseUrl ?? getCodeBaseURL();
|
||||
if (!baseURL) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1500,6 +1564,7 @@ async function writeSandboxFile({
|
|||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
...authHeaders,
|
||||
...(executionProfile ? { [CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile } : {}),
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ jest.mock('@librechat/api', () => {
|
|||
flattenArtifactPath: jest.fn((name) => name.replace(/\//g, '__')),
|
||||
createAxiosInstance: jest.fn(() => mockAxios),
|
||||
getCodeApiAuthHeaders: jest.fn(async () => ({})),
|
||||
getCodeExecutionBaseUrl: jest.fn((profile) =>
|
||||
profile === 'stateful' ? 'https://code-stateful.example.com' : 'https://code-api.example.com',
|
||||
),
|
||||
CODE_API_EXPECTED_PROFILE_HEADER: 'X-CodeAPI-Expected-Profile',
|
||||
withTimeout: (...args) => passthroughWithTimeout(...args),
|
||||
hasOfficeHtmlPath: (...args) => mockHasOfficeHtmlPath(...args),
|
||||
/**
|
||||
|
|
@ -821,6 +825,24 @@ describe('Code Process', () => {
|
|||
});
|
||||
|
||||
describe('fallback behavior', () => {
|
||||
it('preserves the stateful route in generated downloads and fallbacks', async () => {
|
||||
mockAxios.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { file: result } = await processCodeOutput({
|
||||
...baseParams,
|
||||
codeApiBaseUrl: 'https://code-stateful.example.com',
|
||||
executionProfile: 'stateful',
|
||||
});
|
||||
|
||||
expect(mockAxios).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining('https://code-stateful.example.com/download/'),
|
||||
headers: expect.objectContaining({ 'X-CodeAPI-Expected-Profile': 'stateful' }),
|
||||
}),
|
||||
);
|
||||
expect(result.filepath).toContain('execution_profile=stateful');
|
||||
});
|
||||
|
||||
it('should fallback to download URL when saveBuffer is not available', async () => {
|
||||
const smallBuffer = Buffer.alloc(100);
|
||||
mockAxios.mockResolvedValue({ data: smallBuffer });
|
||||
|
|
@ -901,11 +923,33 @@ describe('Code Process', () => {
|
|||
id: 'user-123',
|
||||
storage_session_id: 'session-123',
|
||||
file_id: 'file-id-123',
|
||||
executionProfile: 'default',
|
||||
},
|
||||
codeEnvRefs: {
|
||||
default: {
|
||||
kind: 'user',
|
||||
id: 'user-123',
|
||||
storage_session_id: 'session-123',
|
||||
file_id: 'file-id-123',
|
||||
executionProfile: 'default',
|
||||
},
|
||||
},
|
||||
sourceDispatchedAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the originating profile on a stateful artifact ref', async () => {
|
||||
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
|
||||
|
||||
const { file: result } = await processCodeOutput({
|
||||
...baseParams,
|
||||
codeApiBaseUrl: 'https://code-stateful.example.com',
|
||||
executionProfile: 'stateful',
|
||||
});
|
||||
|
||||
expect(result.metadata.codeEnvRef.executionProfile).toBe('stateful');
|
||||
});
|
||||
|
||||
/* Phase C lock-in: outputs are ALWAYS user-scoped, never skill-scoped.
|
||||
* Even when an execution turn invoked a skill (so input files were
|
||||
* `kind: 'skill'` shared cross-user), the resulting output bucket
|
||||
|
|
@ -935,12 +979,14 @@ describe('Code Process', () => {
|
|||
id: 'user-A',
|
||||
storage_session_id: 'session-123',
|
||||
file_id: 'file-id-123',
|
||||
executionProfile: 'default',
|
||||
});
|
||||
expect(outputB.metadata.codeEnvRef).toEqual({
|
||||
kind: 'user',
|
||||
id: 'user-B',
|
||||
storage_session_id: 'session-123',
|
||||
file_id: 'file-id-123',
|
||||
executionProfile: 'default',
|
||||
});
|
||||
|
||||
// No skill identity leaks into the output ref under any property.
|
||||
|
|
@ -1208,6 +1254,35 @@ describe('Code Process', () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('checks freshness against the trusted stateful endpoint and profile', async () => {
|
||||
mockAxios.mockResolvedValue({
|
||||
data: { lastModified: '2026-08-15T00:00:00Z' },
|
||||
});
|
||||
|
||||
await getSessionInfo(
|
||||
{
|
||||
kind: 'user',
|
||||
id: 'user-123',
|
||||
storage_session_id: 'session-123',
|
||||
file_id: 'file-123',
|
||||
},
|
||||
mockReq,
|
||||
{
|
||||
baseUrl: 'https://stateful-code.example.com',
|
||||
executionProfile: 'stateful',
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockAxios).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringMatching(/^https:\/\/stateful-code\.example\.com\/sessions\//),
|
||||
headers: expect.objectContaining({
|
||||
'X-CodeAPI-Expected-Profile': 'stateful',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deferred-preview flow (office-bucket files)', () => {
|
||||
|
|
@ -1610,6 +1685,22 @@ describe('Code Process', () => {
|
|||
expect(call.data.lang).toBe('bash');
|
||||
});
|
||||
|
||||
it('routes to the selected profile endpoint and asserts the expected profile', async () => {
|
||||
mockAxios.mockResolvedValueOnce({ data: { stdout: 'ok', stderr: '' } });
|
||||
|
||||
await readSandboxFile({
|
||||
file_path: '/mnt/data/x.txt',
|
||||
codeApiBaseUrl: 'https://stateful-code.example.com',
|
||||
executionProfile: 'stateful',
|
||||
runtime_session_hint: 'v1:user',
|
||||
});
|
||||
|
||||
const call = mockAxios.mock.calls[0][0];
|
||||
expect(call.url).toBe('https://stateful-code.example.com/exec');
|
||||
expect(call.headers['X-CodeAPI-Expected-Profile']).toBe('stateful');
|
||||
expect(call.data.runtime_session_hint).toBe('v1:user');
|
||||
});
|
||||
|
||||
it('omits session_id and files when not provided', async () => {
|
||||
mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } });
|
||||
|
||||
|
|
@ -2118,6 +2209,83 @@ describe('Code Process', () => {
|
|||
expect(uploadArgs.version).toBe(4);
|
||||
});
|
||||
|
||||
it('reuploads instead of reusing a ref from the other execution profile', async () => {
|
||||
const dbFile = {
|
||||
file_id: 'librechat-file-id',
|
||||
filename: 'sentinel.txt',
|
||||
filepath: '/uploads/sentinel.txt',
|
||||
source: 'local',
|
||||
context: 'execute_code',
|
||||
metadata: {
|
||||
codeEnvRef: {
|
||||
kind: 'user',
|
||||
id: 'user-123',
|
||||
storage_session_id: 'DEFAULT_SESSION',
|
||||
file_id: 'DEFAULT_ID',
|
||||
executionProfile: 'default',
|
||||
},
|
||||
},
|
||||
};
|
||||
getFiles.mockResolvedValue([dbFile]);
|
||||
const { handleFileUpload } = setupReuploadMocks({
|
||||
storage_session_id: 'STATEFUL_SESSION',
|
||||
file_id: 'STATEFUL_ID',
|
||||
});
|
||||
|
||||
await primeFiles({
|
||||
req: { user: { id: 'user-123', role: 'USER' } },
|
||||
tool_resources: {
|
||||
execute_code: { file_ids: ['librechat-file-id'], files: [] },
|
||||
},
|
||||
agentId: 'agent-id',
|
||||
codeApiBaseUrl: 'https://stateful-code.example.com',
|
||||
executionProfile: 'stateful',
|
||||
});
|
||||
|
||||
expect(mockAxios).not.toHaveBeenCalled();
|
||||
expect(handleFileUpload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
codeApiBaseUrl: 'https://stateful-code.example.com',
|
||||
executionProfile: 'stateful',
|
||||
}),
|
||||
);
|
||||
expect(updateFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'metadata.codeEnvRef': expect.objectContaining({ executionProfile: 'default' }),
|
||||
'metadata.codeEnvRefs.stateful': expect.objectContaining({
|
||||
executionProfile: 'stateful',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const persistedMetadata = {
|
||||
...dbFile.metadata,
|
||||
codeEnvRef: updateFile.mock.calls[0][0]['metadata.codeEnvRef'],
|
||||
codeEnvRefs: {
|
||||
default: dbFile.metadata.codeEnvRef,
|
||||
stateful: updateFile.mock.calls[0][0]['metadata.codeEnvRefs.stateful'],
|
||||
},
|
||||
};
|
||||
getFiles.mockResolvedValue([{ ...dbFile, metadata: persistedMetadata }]);
|
||||
mockAxios.mockResolvedValue({ data: { lastModified: new Date().toISOString() } });
|
||||
|
||||
await primeFiles({
|
||||
req: { user: { id: 'user-123', role: 'USER' } },
|
||||
tool_resources: {
|
||||
execute_code: { file_ids: ['librechat-file-id'], files: [] },
|
||||
},
|
||||
agentId: 'agent-id',
|
||||
executionProfile: 'default',
|
||||
});
|
||||
|
||||
expect(handleFileUpload).toHaveBeenCalledTimes(1);
|
||||
expect(mockAxios).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining('/sessions/DEFAULT_SESSION/objects/DEFAULT_ID'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('persists fresh codeEnvRef (kind/id preserved) on the DB record after reupload', async () => {
|
||||
const dbFile = {
|
||||
file_id: 'librechat-file-id',
|
||||
|
|
@ -2149,14 +2317,20 @@ describe('Code Process', () => {
|
|||
expect(updateFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
file_id: 'librechat-file-id',
|
||||
metadata: expect.objectContaining({
|
||||
codeEnvRef: {
|
||||
kind: 'user',
|
||||
id: 'user-123',
|
||||
storage_session_id: 'NEW_SESSION',
|
||||
file_id: 'NEW_ID',
|
||||
},
|
||||
}),
|
||||
'metadata.codeEnvRef': {
|
||||
kind: 'user',
|
||||
id: 'user-123',
|
||||
storage_session_id: 'NEW_SESSION',
|
||||
file_id: 'NEW_ID',
|
||||
executionProfile: 'default',
|
||||
},
|
||||
'metadata.codeEnvRefs.default': {
|
||||
kind: 'user',
|
||||
id: 'user-123',
|
||||
storage_session_id: 'NEW_SESSION',
|
||||
file_id: 'NEW_ID',
|
||||
executionProfile: 'default',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const {
|
|||
imageExtRegex,
|
||||
EModelEndpoint,
|
||||
EToolResources,
|
||||
mergeCodeEnvRef,
|
||||
mergeFileConfig,
|
||||
AgentCapabilities,
|
||||
checkOpenAIStorage,
|
||||
|
|
@ -69,7 +70,8 @@ const createSanitizedUploadWrapper = (uploadFunction) => {
|
|||
};
|
||||
};
|
||||
|
||||
const hasCodeEnvRef = (file) => file?.metadata?.codeEnvRef != null;
|
||||
const hasCodeEnvRef = (file) =>
|
||||
file?.metadata?.codeEnvRef != null || file?.metadata?.codeEnvRefs != null;
|
||||
|
||||
const isMissingStorageError = (err) => {
|
||||
const code = err?.code ?? err?.status ?? err?.statusCode ?? err?.response?.status;
|
||||
|
|
@ -727,14 +729,13 @@ const processAgentFileUpload = async ({ req, res, metadata, sseStream }) => {
|
|||
* `fileIdentifier` key would be silently dropped by mongoose strict
|
||||
* mode and the file would lose its sandbox reference on subsequent
|
||||
* priming turns. */
|
||||
fileInfoMetadata = {
|
||||
codeEnvRef: {
|
||||
kind: codeKind,
|
||||
id: codeId,
|
||||
storage_session_id: uploaded.storage_session_id,
|
||||
file_id: uploaded.file_id,
|
||||
},
|
||||
};
|
||||
fileInfoMetadata = mergeCodeEnvRef(undefined, {
|
||||
kind: codeKind,
|
||||
id: codeId,
|
||||
storage_session_id: uploaded.storage_session_id,
|
||||
file_id: uploaded.file_id,
|
||||
executionProfile: 'default',
|
||||
});
|
||||
} else if (tool_resource === EToolResources.file_search) {
|
||||
const isFileSearchEnabled = await checkCapability(req, AgentCapabilities.file_search);
|
||||
if (!isFileSearchEnabled) {
|
||||
|
|
|
|||
|
|
@ -791,6 +791,16 @@ describe('processAgentFileUpload', () => {
|
|||
id: 'user-123',
|
||||
storage_session_id: 'sess-1',
|
||||
file_id: 'fid-1',
|
||||
executionProfile: 'default',
|
||||
},
|
||||
codeEnvRefs: {
|
||||
default: {
|
||||
kind: 'user',
|
||||
id: 'user-123',
|
||||
storage_session_id: 'sess-1',
|
||||
file_id: 'fid-1',
|
||||
executionProfile: 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -819,6 +829,16 @@ describe('processAgentFileUpload', () => {
|
|||
id: 'agent-abc',
|
||||
storage_session_id: 'sess-2',
|
||||
file_id: 'fid-2',
|
||||
executionProfile: 'default',
|
||||
},
|
||||
codeEnvRefs: {
|
||||
default: {
|
||||
kind: 'agent',
|
||||
id: 'agent-abc',
|
||||
storage_session_id: 'sess-2',
|
||||
file_id: 'fid-2',
|
||||
executionProfile: 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -879,6 +899,16 @@ describe('processAgentFileUpload', () => {
|
|||
id: 'agent-abc',
|
||||
storage_session_id: 'sess-5',
|
||||
file_id: 'fid-5',
|
||||
executionProfile: 'default',
|
||||
},
|
||||
codeEnvRefs: {
|
||||
default: {
|
||||
kind: 'agent',
|
||||
id: 'agent-abc',
|
||||
storage_session_id: 'sess-5',
|
||||
file_id: 'fid-5',
|
||||
executionProfile: 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const {
|
|||
isNormalizationSensitiveName,
|
||||
AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE,
|
||||
isFatalAgentInitializationError,
|
||||
resolveCodeExecutionContext,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
|
|
@ -583,6 +584,7 @@ async function loadToolDefinitionsWrapper({
|
|||
streamId = null,
|
||||
jobCreatedAt,
|
||||
tool_resources,
|
||||
codeExecutionContext,
|
||||
accessibleMcpServerNames,
|
||||
}) {
|
||||
if (!agent.tools || agent.tools.length === 0) {
|
||||
|
|
@ -608,6 +610,18 @@ async function loadToolDefinitionsWrapper({
|
|||
const codeExecutionEnabled =
|
||||
agent.tools?.includes(Tools.execute_code) === true &&
|
||||
enabledCapabilities.has(AgentCapabilities.execute_code);
|
||||
const resolvedCodeExecutionContext =
|
||||
codeExecutionContext ??
|
||||
resolveCodeExecutionContext({
|
||||
statefulSessions:
|
||||
codeExecutionEnabled &&
|
||||
enabledCapabilities.has(AgentCapabilities.stateful_code_sessions) &&
|
||||
agent.stateful_code_sessions === true,
|
||||
environment: agent.stateful_code_environment,
|
||||
userId: req.user.id,
|
||||
agentId: agent.id,
|
||||
conversationId: req.body?.conversationId,
|
||||
});
|
||||
const hasMCPTools = agent.tools?.some((tool) => tool?.includes(Constants.mcp_delimiter));
|
||||
const mcpPermissionContext = createMCPPermissionContext(req);
|
||||
const canUseMCP = hasMCPTools ? await mcpPermissionContext.canUseServers(req.user) : true;
|
||||
|
|
@ -1158,6 +1172,8 @@ async function loadToolDefinitionsWrapper({
|
|||
tool_resources,
|
||||
agentId: agent.id,
|
||||
agentResourceType,
|
||||
codeApiBaseUrl: resolvedCodeExecutionContext.baseUrl,
|
||||
executionProfile: resolvedCodeExecutionContext.executionProfile,
|
||||
});
|
||||
if (toolContext) {
|
||||
dynamicToolContextMap[Tools.execute_code] = toolContext;
|
||||
|
|
@ -1258,6 +1274,7 @@ async function loadAgentTools({
|
|||
streamId = null,
|
||||
jobCreatedAt,
|
||||
definitionsOnly = true,
|
||||
codeExecutionContext: providedCodeExecutionContext,
|
||||
accessibleMcpServerNames,
|
||||
}) {
|
||||
if (definitionsOnly) {
|
||||
|
|
@ -1270,6 +1287,7 @@ async function loadAgentTools({
|
|||
streamId,
|
||||
jobCreatedAt,
|
||||
tool_resources,
|
||||
codeExecutionContext: providedCodeExecutionContext,
|
||||
accessibleMcpServerNames,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -1368,6 +1386,23 @@ async function loadAgentTools({
|
|||
});
|
||||
}
|
||||
|
||||
const codeExecutionEnabled =
|
||||
agent.tools?.includes(Tools.execute_code) === true &&
|
||||
enabledCapabilities.has(AgentCapabilities.execute_code);
|
||||
const statefulCodeSessions =
|
||||
codeExecutionEnabled &&
|
||||
enabledCapabilities.has(AgentCapabilities.stateful_code_sessions) &&
|
||||
agent.stateful_code_sessions === true;
|
||||
const codeExecutionContext =
|
||||
providedCodeExecutionContext ??
|
||||
resolveCodeExecutionContext({
|
||||
statefulSessions: statefulCodeSessions,
|
||||
environment: agent.stateful_code_environment,
|
||||
userId: req.user.id,
|
||||
agentId: agent.id,
|
||||
conversationId: req.body?.conversationId,
|
||||
});
|
||||
|
||||
const { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles } = await loadTools({
|
||||
agent,
|
||||
signal,
|
||||
|
|
@ -1388,6 +1423,7 @@ async function loadAgentTools({
|
|||
returnMetadata: true,
|
||||
mcpPermissionContext,
|
||||
requestScopedConnections: getMCPRequestContext(req, res),
|
||||
codeExecutionContext,
|
||||
[Tools.web_search]: webSearchCallbacks,
|
||||
},
|
||||
webSearch: appConfig.webSearch,
|
||||
|
|
@ -1398,9 +1434,6 @@ async function loadAgentTools({
|
|||
/** Build tool registry from MCP tools and create PTC/tool search tools if configured */
|
||||
const deferredToolsEnabled = checkCapability(AgentCapabilities.deferred_tools);
|
||||
const programmaticToolsEnabled = enabledCapabilities.has(AgentCapabilities.programmatic_tools);
|
||||
const codeExecutionEnabled =
|
||||
agent.tools?.includes(Tools.execute_code) === true &&
|
||||
enabledCapabilities.has(AgentCapabilities.execute_code);
|
||||
const { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools } =
|
||||
await buildToolClassification({
|
||||
loadedTools,
|
||||
|
|
@ -1412,6 +1445,7 @@ async function loadAgentTools({
|
|||
programmaticToolsEnabled,
|
||||
codeExecutionEnabled,
|
||||
authHeaders: () => getCodeApiAuthHeaders(req),
|
||||
codeExecutionContext,
|
||||
});
|
||||
|
||||
const agentTools = [];
|
||||
|
|
@ -1646,6 +1680,7 @@ async function loadAgentTools({
|
|||
* @param {Object} [params.tool_resources] - Tool resources
|
||||
* @param {string|null} [params.streamId] - Stream ID for web search callbacks
|
||||
* @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted tool events
|
||||
* @param {string} [params.conversationId] - Resolved conversation identity for this request
|
||||
* @param {boolean} [params.actionsEnabled] - Whether the actions capability is enabled
|
||||
* @param {readonly string[]} [params.accessibleMcpServerNames] - COMPLETE accessible-server audit resolved at initialization
|
||||
* @returns {Promise<{ loadedTools: Array, configurable: Object }>}
|
||||
|
|
@ -1666,6 +1701,7 @@ async function loadToolsForExecution({
|
|||
tool_resources,
|
||||
streamId = null,
|
||||
jobCreatedAt,
|
||||
conversationId,
|
||||
actionsEnabled,
|
||||
accessibleMcpServerNames,
|
||||
}) {
|
||||
|
|
@ -1695,9 +1731,19 @@ async function loadToolsForExecution({
|
|||
const isBashToolRequested = toolNames.includes(AgentConstants.BASH_TOOL);
|
||||
const isLegacyExecuteCodeRequested = toolNames.includes(Tools.execute_code);
|
||||
const isCodeExecutionToolRequested = isBashToolRequested || isLegacyExecuteCodeRequested;
|
||||
const isSkillToolRequested = toolNames.includes(AgentConstants.SKILL_TOOL);
|
||||
const isSandboxFileToolRequested = toolNames.some((name) =>
|
||||
[AgentConstants.READ_FILE, AgentConstants.CREATE_FILE, AgentConstants.EDIT_FILE].includes(name),
|
||||
);
|
||||
|
||||
let enabledCapabilities;
|
||||
if (actionsEnabled === undefined || isPTCRequested || isCodeExecutionToolRequested) {
|
||||
if (
|
||||
actionsEnabled === undefined ||
|
||||
isPTCRequested ||
|
||||
isCodeExecutionToolRequested ||
|
||||
isSkillToolRequested ||
|
||||
isSandboxFileToolRequested
|
||||
) {
|
||||
enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent?.id);
|
||||
}
|
||||
if (actionsEnabled === undefined) {
|
||||
|
|
@ -1707,17 +1753,21 @@ async function loadToolsForExecution({
|
|||
enabledCapabilities?.has(AgentCapabilities.execute_code) === true &&
|
||||
agent?.tools?.includes(Tools.execute_code) === true;
|
||||
|
||||
/**
|
||||
* Opt bash_tool into the hedged stateful-session description. Gated on code
|
||||
* execution being enabled AND the admin `stateful_code_sessions` capability
|
||||
* AND the agent's own builder opt-in; off by default. Sets prompt text only
|
||||
* (the wire hint is set at run config). PTC keeps its stateless prompt in
|
||||
* v1. Older @librechat/agents ignore the param.
|
||||
*/
|
||||
/** Resolve the trusted endpoint/profile from the actually executing agent.
|
||||
* This stays per-agent across handoffs and subagents; no graph-global stateful
|
||||
* flag or model-supplied value is consulted. */
|
||||
const statefulCodeSessions =
|
||||
codeExecutionEnabled &&
|
||||
enabledCapabilities?.has(AgentCapabilities.stateful_code_sessions) === true &&
|
||||
agent?.stateful_code_sessions === true;
|
||||
const codeExecutionContext = resolveCodeExecutionContext({
|
||||
statefulSessions: statefulCodeSessions,
|
||||
environment: agent?.stateful_code_environment,
|
||||
userId: req.user.id,
|
||||
agentId: agent?.id,
|
||||
conversationId: conversationId ?? req.body?.conversationId,
|
||||
});
|
||||
configurable.codeExecutionContext = codeExecutionContext;
|
||||
|
||||
const isPTC =
|
||||
isPTCRequested &&
|
||||
|
|
@ -1747,6 +1797,9 @@ async function loadToolsForExecution({
|
|||
for (const name of ptcToolNames) {
|
||||
const ptcTool = createBashProgrammaticToolCallingTool({
|
||||
authHeaders: () => getCodeApiAuthHeaders(req),
|
||||
baseUrl: codeExecutionContext.baseUrl,
|
||||
executionProfile: codeExecutionContext.executionProfile,
|
||||
runtimeSessionHint: codeExecutionContext.runtimeSessionHint,
|
||||
});
|
||||
ptcTool.name = name;
|
||||
allLoadedTools.push(ptcTool);
|
||||
|
|
@ -1770,7 +1823,7 @@ async function loadToolsForExecution({
|
|||
try {
|
||||
const bashTool = createBashExecutionTool({
|
||||
authHeaders: () => getCodeApiAuthHeaders(req),
|
||||
statefulSessions: statefulCodeSessions,
|
||||
...codeExecutionContext,
|
||||
});
|
||||
allLoadedTools.push(bashTool);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
const { createHash } = require('node:crypto');
|
||||
const { Constants: AgentConstants } = require('@librechat/agents');
|
||||
const {
|
||||
Tools,
|
||||
|
|
@ -16,6 +17,40 @@ const mockGetMCPServerTools = jest.fn();
|
|||
const mockGetCachedTools = jest.fn();
|
||||
const mockSendEvent = jest.fn();
|
||||
const mockEmitChunk = jest.fn();
|
||||
const mockResolveCodeExecutionContext = jest.fn(
|
||||
({ statefulSessions, environment, userId, agentId, conversationId }) => {
|
||||
if (!statefulSessions) {
|
||||
return {
|
||||
baseUrl: (process.env.LIBRECHAT_CODE_BASEURL ?? 'https://api.librechat.ai').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
),
|
||||
codeSessionKey: 'execute_code',
|
||||
executionProfile: 'default',
|
||||
statefulSessions: false,
|
||||
};
|
||||
}
|
||||
const baseUrl = process.env.LIBRECHAT_CODE_BASEURL_STATEFUL?.replace(/\/$/, '');
|
||||
if (!baseUrl) {
|
||||
throw new Error('LIBRECHAT_CODE_BASEURL_STATEFUL is not configured');
|
||||
}
|
||||
const fingerprint = (...parts) =>
|
||||
createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 32);
|
||||
let runtimeSessionHint = `v2:user:${fingerprint(userId)}`;
|
||||
if (environment === 'agent-user') {
|
||||
runtimeSessionHint = `v2:agent-user:${fingerprint(userId, agentId)}`;
|
||||
} else if (environment === 'conversation') {
|
||||
runtimeSessionHint = `v2:conversation:${fingerprint(userId, conversationId)}`;
|
||||
}
|
||||
return {
|
||||
baseUrl,
|
||||
codeSessionKey: `execute_code:stateful:${runtimeSessionHint}`,
|
||||
executionProfile: 'stateful',
|
||||
runtimeSessionHint,
|
||||
statefulSessions: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args),
|
||||
getMCPServerTools: (...args) => mockGetMCPServerTools(...args),
|
||||
|
|
@ -35,6 +70,7 @@ jest.mock('@librechat/api', () => ({
|
|||
GenerationJobManager: {
|
||||
emitChunk: (...args) => mockEmitChunk(...args),
|
||||
},
|
||||
resolveCodeExecutionContext: (...args) => mockResolveCodeExecutionContext(...args),
|
||||
}));
|
||||
|
||||
const mockLoadToolsUtil = jest.fn();
|
||||
|
|
@ -267,7 +303,43 @@ describe('ToolService - Action Capability Gating', () => {
|
|||
agentResourceType: ResourceType.REMOTE_AGENT,
|
||||
};
|
||||
expect(primeSearchFiles).toHaveBeenCalledWith(expectedParams);
|
||||
expect(primeCodeFiles).toHaveBeenCalledWith(expectedParams);
|
||||
expect(primeCodeFiles).toHaveBeenCalledWith({
|
||||
...expectedParams,
|
||||
codeApiBaseUrl: 'https://api.librechat.ai',
|
||||
executionProfile: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('primes code files through the initializer-selected stateful route', async () => {
|
||||
const capabilities = [AgentCapabilities.tools, AgentCapabilities.execute_code];
|
||||
const req = createMockReq(capabilities);
|
||||
const tool_resources = { execute_code: { file_ids: ['stateful-file'] } };
|
||||
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
|
||||
await loadAgentTools({
|
||||
req,
|
||||
res: {},
|
||||
agent: { id: 'stateful-agent', tools: [Tools.execute_code] },
|
||||
tool_resources,
|
||||
definitionsOnly: true,
|
||||
codeExecutionContext: {
|
||||
baseUrl: 'https://stateful-code.example.com',
|
||||
codeSessionKey: 'execute_code:stateful:v2:user:abc',
|
||||
executionProfile: 'stateful',
|
||||
runtimeSessionHint: 'v2:user:abc',
|
||||
statefulSessions: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(primeCodeFiles).toHaveBeenCalledWith({
|
||||
req,
|
||||
tool_resources,
|
||||
agentId: 'stateful-agent',
|
||||
agentResourceType: undefined,
|
||||
codeApiBaseUrl: 'https://stateful-code.example.com',
|
||||
executionProfile: 'stateful',
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates a typed CodeAPI resource recovery failure before model invocation', async () => {
|
||||
|
|
@ -1402,6 +1474,127 @@ describe('ToolService - Action Capability Gating', () => {
|
|||
expect(mockLoadToolsUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps stateless and stateful agents on isolated execution profiles in one run', async () => {
|
||||
const capabilities = [
|
||||
AgentCapabilities.tools,
|
||||
AgentCapabilities.execute_code,
|
||||
AgentCapabilities.stateful_code_sessions,
|
||||
];
|
||||
const req = createMockReq(capabilities);
|
||||
req.body = { conversationId: 'conversation-1' };
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
process.env.LIBRECHAT_CODE_BASEURL = 'http://code-default.test/v1';
|
||||
process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'http://code-stateful.test/v1';
|
||||
|
||||
try {
|
||||
const stateless = await loadToolsForExecution({
|
||||
req,
|
||||
res: {},
|
||||
agent: { id: 'stateless-agent', tools: [Tools.execute_code] },
|
||||
toolNames: [],
|
||||
});
|
||||
const stateful = await loadToolsForExecution({
|
||||
req,
|
||||
res: {},
|
||||
agent: {
|
||||
id: 'stateful-agent',
|
||||
tools: [Tools.execute_code],
|
||||
stateful_code_sessions: true,
|
||||
stateful_code_environment: 'agent-user',
|
||||
},
|
||||
toolNames: [],
|
||||
});
|
||||
|
||||
expect(stateless.configurable.codeExecutionContext).toEqual({
|
||||
baseUrl: 'http://code-default.test/v1',
|
||||
codeSessionKey: 'execute_code',
|
||||
executionProfile: 'default',
|
||||
statefulSessions: false,
|
||||
});
|
||||
expect(stateful.configurable.codeExecutionContext).toEqual({
|
||||
baseUrl: 'http://code-stateful.test/v1',
|
||||
codeSessionKey: 'execute_code:stateful:v2:agent-user:7c684f0773d9642c122f67aa30e9e0f4',
|
||||
executionProfile: 'stateful',
|
||||
runtimeSessionHint: 'v2:agent-user:7c684f0773d9642c122f67aa30e9e0f4',
|
||||
statefulSessions: true,
|
||||
});
|
||||
} finally {
|
||||
delete process.env.LIBRECHAT_CODE_BASEURL;
|
||||
delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves stateful routing for host file tools with the controller conversation ID', async () => {
|
||||
const capabilities = [
|
||||
AgentCapabilities.tools,
|
||||
AgentCapabilities.execute_code,
|
||||
AgentCapabilities.stateful_code_sessions,
|
||||
];
|
||||
const req = createMockReq(capabilities);
|
||||
req.body = {};
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'http://code-stateful.test/v1';
|
||||
|
||||
try {
|
||||
const result = await loadToolsForExecution({
|
||||
req,
|
||||
res: {},
|
||||
conversationId: 'resolved-api-conversation',
|
||||
agent: {
|
||||
id: 'stateful-agent',
|
||||
tools: [Tools.execute_code],
|
||||
stateful_code_sessions: true,
|
||||
stateful_code_environment: 'conversation',
|
||||
},
|
||||
toolNames: [AgentConstants.READ_FILE],
|
||||
actionsEnabled: false,
|
||||
});
|
||||
|
||||
expect(result.configurable.codeExecutionContext.executionProfile).toBe('stateful');
|
||||
expect(mockResolveCodeExecutionContext).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
statefulSessions: true,
|
||||
conversationId: 'resolved-api-conversation',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves stateful routing when handle_skill is the only requested tool', async () => {
|
||||
const capabilities = [
|
||||
AgentCapabilities.tools,
|
||||
AgentCapabilities.execute_code,
|
||||
AgentCapabilities.stateful_code_sessions,
|
||||
];
|
||||
const req = createMockReq(capabilities);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'http://code-stateful.test/v1';
|
||||
|
||||
try {
|
||||
const result = await loadToolsForExecution({
|
||||
req,
|
||||
res: {},
|
||||
agent: {
|
||||
id: 'stateful-agent',
|
||||
tools: [Tools.execute_code],
|
||||
stateful_code_sessions: true,
|
||||
stateful_code_environment: 'agent-user',
|
||||
},
|
||||
toolNames: [AgentConstants.SKILL_TOOL],
|
||||
actionsEnabled: false,
|
||||
});
|
||||
|
||||
expect(result.configurable.codeExecutionContext.executionProfile).toBe('stateful');
|
||||
expect(mockResolveCodeExecutionContext).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ statefulSessions: true, environment: 'agent-user' }),
|
||||
);
|
||||
} finally {
|
||||
delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL;
|
||||
}
|
||||
});
|
||||
|
||||
it('loads bash PTC under the legacy programmatic tool name when code capabilities are enabled', async () => {
|
||||
const capabilities = [
|
||||
AgentCapabilities.tools,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue