🛤️ 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:
Danny Avila 2026-08-16 09:42:15 -04:00 committed by GitHub
parent d411512a98
commit 06bf324cf0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 2782 additions and 418 deletions

View file

@ -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);

View file

@ -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;

View file

@ -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() : [])],

View file

@ -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,

View file

@ -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,

View file

@ -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);

View file

@ -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();
});
});
});

View file

@ -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;

View file

@ -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');

View file

@ -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:

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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',
},
}),
);
});

View file

@ -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) {

View file

@ -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',
},
},
},
}),

View file

@ -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) {

View file

@ -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,