diff --git a/.env.example b/.env.example index 1a6a27ee35b..42b88f61bcf 100644 --- a/.env.example +++ b/.env.example @@ -729,6 +729,10 @@ TTS_API_KEY= #==================================================# # LIBRECHAT_CODE_API_KEY= + +# Advertise the immutable per-conversation code-environment decision protocol. +# Enable only after every LibreChat API replica runs a version that supports protocol v1. +# CODE_ENVIRONMENT_DECISION_VERSION=1 # LIBRECHAT_CODE_BASEURL= # Current self-hosted Code Interpreter deployments use per-user LibreChat JWTs outside local mode. # Configure the matching public verifier on Code Interpreter; see: diff --git a/.github/workflows/agents-integration-tests.yml b/.github/workflows/agents-integration-tests.yml index 102e7db995e..f015ee618ac 100644 --- a/.github/workflows/agents-integration-tests.yml +++ b/.github/workflows/agents-integration-tests.yml @@ -1,9 +1,12 @@ -name: Agents Integration Tests - -# Runs the packages/api `src/agents/**` integration specs (e.g. the durable HITL -# checkpointer and cross-replica subagent delivery against real MongoDB and Redis). These -# are `*.integration.spec.ts`, which `test:ci` deliberately excludes โ€” without this -# job they run nowhere and their regressions guard nothing. +name: Integration Tests + +# Runs every packages/api `*.integration.spec.ts` / `*.integration.test.ts` suite (e.g. the +# durable HITL checkpointer and cross-replica subagent delivery against real MongoDB and +# Redis, the admin config secret registry against a real Config collection, MCP flows +# against in-process servers). `test:ci` deliberately excludes them, and the Redis-backed +# `*.cache_integration` / `*.stream_integration` suites run in cache-integration-tests.yml โ€” +# without this job they run nowhere and their regressions guard nothing. Selection is by +# suffix, not folder, so a suite added anywhere under src is picked up. on: pull_request: branches: @@ -11,9 +14,9 @@ on: - dev - dev-staging - release/* - # The suite builds and consumes data-provider and data-schemas and imports + # The suites build and consume data-provider and data-schemas and import # across packages/api (the build-cache keys below hash all three src trees), - # so it must re-run on any of them โ€” not just src/agents. + # so they must re-run on any of them. paths: - 'packages/api/src/**' - 'packages/api/package.json' @@ -106,9 +109,9 @@ jobs: if: steps.cache-api.outputs.cache-hit != 'true' run: npm run build:api - - name: Run agents integration tests + - name: Run integration tests working-directory: packages/api env: NODE_ENV: test REDIS_URI: redis://127.0.0.1:6379 - run: npm run test:agents-integration + run: npm run test:integration diff --git a/CONTEXT.md b/CONTEXT.md index 1fa12027743..061c21700be 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -3,6 +3,7 @@ - **Scheduled run admission**: The claimed-occurrence phase that rehydrates the owner, validates current schedule policy and agent reachability, resolves files and MCP readiness, and only then competes for durable generation capacity. It owns cancellation and lease revalidation until a generation slot is reserved; a slow or failed readiness check never occupies generation capacity. - **Attached code environment**: A principal- or deployment-authorized stateful workspace owned by an outbound `librechat-code` worker on a user-chosen machine or VM. LibreChat selects it and enforces approval policy, Code API authenticates and dispatches to it, and the worker's local sandbox and capability flags remain the final execution ceiling. The environment interface is runtime-neutral: native SRT, WSL2, Docker/NsJail, and future adapters expose the same workspace operations without leaking host paths or runtime configuration into agent tools. +- **Conversation code-environment decision**: The immutable choice established by a conversation's first accepted submission between validated attached workspaces and continuing without an attached environment. Agent defaults and recent workspace preferences may suggest a draft choice, but only the persisted conversation decision authorizes attached workspace tool registration; later turns, retries, resumes, and alternate ingresses cannot upgrade or replace it. - **Agent run envelope**: the versioned, JSON-safe request contract created after ingress authentication and protocol validation but before agent, provider, tool, or MCP initialization. It carries only the validated protocol payload and the minimum trusted principal identifiers. The execution host rehydrates all runtime state from those identifiers. - **Agent execution context**: runtime-only, transport-free state rehydrated beside an Agent run envelope. It contains the authenticated user, application configuration, normalized request metadata, and resolved conversation facts needed by initialization, but never Express request/response objects or serialized credentials. - **Agent execution host**: the protocol-neutral module that owns run admission, disconnect cancellation, provider-start fencing, and terminal settlement. Protocol implementations execute behind its callback interface; HTTP adapters retain validation and final stream rendering. diff --git a/Dockerfile b/Dockerfile index 53c6ee7cb5a..dc28d9d771e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# v0.8.8-rc2 +# v0.8.8-rc3 # Base node image FROM node:24.16.0-alpine AS node diff --git a/Dockerfile.multi b/Dockerfile.multi index 43d203fad16..ed38c8d2eb5 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -1,5 +1,5 @@ # Dockerfile.multi -# v0.8.8-rc2 +# v0.8.8-rc3 # Set configurable max-old-space-size with default ARG NODE_MAX_OLD_SPACE_SIZE=6144 diff --git a/README.md b/README.md index 63e471edde9..4a6b43c6c60 100644 --- a/README.md +++ b/README.md @@ -51,30 +51,21 @@

-## ๐Ÿš€ What's New in v0.8.8-rc2 - -- **Agent run control:** Interrupt an Agent before visible answer text, steer runs with files and quoted excerpts, durably queue follow-ups, and recover saved partial work with **Keep going** or **Answer now**. -- **Agent activity:** Optional generated labels group reasoning and tool work, fold completed groups into live phase cards, keep generated files visible, summarize multi-step phases, and show the current reasoning direction. -- **Human-in-the-loop Agents:** Stream up to four related questions, pause for input or tool approval, and resume durably. -- **Unified Agent Builder:** Configure Skills, MCP, Code Interpreter, orchestration, Programmatic Tool Calling, model-spec controls, and per-tool background and intent settings in one Tools marketplace; Skills can be enabled for standalone runtime authoring without exposing the existing catalog. -- **Durable Agent automation:** Authenticated Agent Events support bound child actors, expected-action receipts, per-actor mailboxes, event batching, durable human pauses, and automatic detached Actions across built-in stream stores. -- **Deeper Subagent history:** Browse branch-aware child turns with bounded reasoning and stable live event views, load earlier activity, inspect event details, continue completed child chats, and automatically wake saved parent Agents when detached work settles. -- **Background tools:** Eligible Code Interpreter, MCP, Plugin, and Action tools can run while an Agent keeps working, with automatic delivery for supported completions and polling controls when needed. -- **Code Interpreter workflows:** Sandbox images return as viewable artifacts; highly experimental stateful sessions add scoped managed, attached, or personal environments, per-message file downloads, and guarded file-write and command permissions. -- **Agent extensibility:** Experimental Agent Plugins bundle deployment Skills, MCP servers, and opt-in command hooks; saved Agent teams run as isolated Subagent graphs. -- **Scheduled Chats (experimental):** Run saved Agents with presets or custom cron, selectable time zones, multi-day weekly cadence, and optional Chat Project destinations. -- **Memory and context:** Agents can use optionally isolated memory, preserve adaptive context fading across turns, and show categorized current-window usage, tokens, and optional cost. -- **Editable long pastes:** Long pasted text becomes an editable attachment that can be moved back into the composer; attachment-only turns and reliable Upload as Text downloads are also supported. -- **Projects, settings, and navigation:** Search conversation titles and message contents, manage project chats, use searchable settings and shortcuts, pin chats, choose clock/week conventions, and navigate faster on mobile. -- **Sharing and artifacts:** Stable shared links support personal copies; fullscreen previews, Mermaid export, PowerPoint templates, shell scripts, and original Office downloads expand file workflows. -- **Web search:** Keenable adds keyless search and page fetch, while SearXNG and Tavily gain richer controls and all web-tool egress uses stronger SSRF protection. -- **Security and authentication:** Default HTTP security headers, opt-in nonce CSP, authenticated local images, per-user Code Interpreter JWTs, stable SAML identity binding, live-session OpenID token refresh, and retired JWT-secret rejection harden deployments. -- **Models and reasoning:** Added GPT-5.6 with Responses reasoning controls, Claude Fable 5.1, Opus 5, and Sonnet 5, plus Gemini 3.8/3.7/3.6 Flash and Gemini 3.5 Flash-Lite. -- **Langfuse observability:** Configure encrypted in-app connections, tenant fanout, authenticated gateways, export-decision telemetry, and authorized session links in chats and shared views. -- **Administration:** Source-aware content filters can audit or block model-bound data, while tenant Insights, delegated configuration, encrypted secrets, and expiring violation scores improve operations. -- **Streaming and reliability:** Adaptive smoothing, Redis delta batching and failover recovery, automatic generation protocol v2, live MCP catalog refresh, Agent circuit breakers, and DocumentDB support improve long runs and scaled deployments. - -Read the [full v0.8.8-rc2 changelog](https://www.librechat.ai/changelog/v0.8.8-rc2). +## ๐Ÿš€ What's New in v0.8.8-rc3 + +- **Agent Management API (beta):** Create, discover, update, and delete Agents; manage Agent files and Skills; and authenticate machine clients through deployment-bound OIDC identities while preserving existing role and Agent access controls. +- **Attached workspaces (highly experimental):** Select or save a per-Agent default workspace for each managed or personal code worker, then let Agents inspect trees, read and search files, author changes, and run Bash with bounded timeouts. Personal workers support bounded self-service enrollment, readiness status, and per-Agent Git identity. +- **Background tool controls:** Optionally cancel ordinary background tools, including attached Bash, while keeping detached Subagent execution independent. +- **Code approval controls:** Choose **Ask**, **Allow**, or **Deny** for file writes and command execution where administrators permit it, including a **Full access** mode for trusted attached environments. File Search and Run Code also honor role grants. +- **Manual context compaction:** Start a summarize-only turn before the context window fills while preserving recent conversation content according to the deployment's summarization policy. +- **Context Usage:** Inspect dialogue, retained tool traffic, Agent instructions, cache, cost, and runway pressure without double-counting category subsets. +- **Unified attachments:** Upload once and let LibreChat route content to the model or extracted text, then provision File Search and Code tools only when needed. +- **Models:** Added GPT-6 Astra for the OpenAI and Agents endpoints, with Responses API routing and tool-call support. +- **Agent and chat UI:** Unified tool activity, reasoning, search, and Agent workflows; added one draggable Pinned section for chats and favorites, morphing state icons, high-contrast themes, rich-text message copying, clearer sidebar titles, and refined live phase layouts. +- **Observability:** Export correlated application logs through OpenTelemetry, configure allowlisted Langfuse trace identity and metadata, tag browser diagnostics with client build IDs, and scope Insights to authorized Agents. +- **Reliability and security:** Strengthened Agent continuation and checkpoint recovery, Redis liveness detection, DocumentDB coordination, OpenID and MCP OAuth sessions, shared-link throttling, tenant isolation, attachment bounds, and upload error handling. + +Read the [full v0.8.8-rc3 changelog](https://www.librechat.ai/changelog/v0.8.8-rc3). # โœจ Features @@ -102,6 +93,8 @@ Read the [full v0.8.8-rc2 changelog](https://www.librechat.ai/changelog/v0.8.8-r - [Skills](https://www.librechat.ai/docs/features/skills): Create reusable `SKILL.md` instruction bundles for manual, automatic, or always-on agent workflows - [Agent Plugins](https://www.librechat.ai/docs/features/agent_plugins): Experimentally bundle deployment Skills and MCP servers into startup-loaded packages - [Subagents](https://www.librechat.ai/docs/features/subagents): Delegate focused work to isolated child agent runs with their own context windows + - Agent Management API: Automate Agent, file, and Skill management with deployment-bound OIDC clients + - Attached Code Workspaces: Let Agents inspect, search, edit, and run commands in managed or personal workspaces (highly experimental) - Compatible with Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, Google, Vertex AI, Responses API, and more - [Model Context Protocol (MCP) Support](https://modelcontextprotocol.io/clients#librechat) for Tools @@ -126,10 +119,12 @@ Read the [full v0.8.8-rc2 changelog](https://www.librechat.ai/changelog/v0.8.8-r - Edit, Resubmit, and Continue Messages with Conversation branching - Create and share prompts with specific users and groups - [Fork Messages & Conversations](https://www.librechat.ai/docs/features/fork) for Advanced Context control + - Compact long conversations on demand while preserving recent context - ๐Ÿ’ฌ **Multimodal & File Interactions**: - Upload and analyze images with Claude 3, GPT-4.5, GPT-4o, o1, Llama-Vision, and Gemini ๐Ÿ“ธ - Chat with Files using Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, & Google ๐Ÿ—ƒ๏ธ + - Copy messages as formatted rich text for documents, email, and collaboration apps - ๐ŸŒŽ **Multilingual UI**: - English, ไธญๆ–‡ (็ฎ€ไฝ“), ไธญๆ–‡ (็น้ซ”), ุงู„ุนุฑุจูŠุฉ, Deutsch, Espaรฑol, Franรงais, Italiano @@ -142,6 +137,10 @@ Read the [full v0.8.8-rc2 changelog](https://www.librechat.ai/changelog/v0.8.8-r - ๐ŸŽจ **Customizable Interface**: - Customizable Dropdown & Interface that adapts to both power users and newcomers + - Light, dark, system, and high-contrast appearance modes + +- ๐Ÿ“ˆ **Observability**: + - Export traces and logs with OpenTelemetry and connect Langfuse for Agent and model insights - ๐ŸŒŠ **[Resumable Streams](https://www.librechat.ai/docs/features/resumable_streams)**: - Never lose a response: AI responses automatically reconnect and resume if your connection drops diff --git a/api/config/index.js b/api/config/index.js index 79a1c246638..2e3ed6e0181 100644 --- a/api/config/index.js +++ b/api/config/index.js @@ -44,6 +44,7 @@ function getActionFlowStateManager(flowsCache) { if (!actionFlowManager) { actionFlowManager = new FlowStateManager(flowsCache, { ttl: Time.ONE_MINUTE * 3, + redisScriptExecutor: evalKeyvRedisScript, }); } return actionFlowManager; diff --git a/api/db/indexSync.js b/api/db/indexSync.js index 85a5524a76a..ad187de1117 100644 --- a/api/db/indexSync.js +++ b/api/db/indexSync.js @@ -2,7 +2,7 @@ const mongoose = require('mongoose'); const { MeiliSearch } = require('meilisearch'); const { logger } = require('@librechat/data-schemas'); const { CacheKeys } = require('librechat-data-provider'); -const { isEnabled, FlowStateManager } = require('@librechat/api'); +const { isEnabled, FlowStateManager, evalKeyvRedisScript } = require('@librechat/api'); const { getLogStores } = require('~/cache'); const { batchResetMeiliFlags } = require('./utils'); @@ -372,6 +372,7 @@ async function indexSync() { const flowManager = new FlowStateManager(flowsCache, { ttl: 60000 * 10, // 10 minutes TTL for sync operations + redisScriptExecutor: evalKeyvRedisScript, }); // Use a unique flow ID for the sync operation diff --git a/api/package.json b/api/package.json index 1288ab39a0d..5f38267f31b 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/backend", - "version": "v0.8.8-rc2", + "version": "v0.8.8-rc3", "description": "", "scripts": { "start": "echo 'please run this from the root directory'", @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.8.5", + "@librechat/agents": "^3.8.6", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js index 1d760423812..cd9c1cd2ae8 100644 --- a/api/server/controllers/agents/__tests__/callbacks.spec.js +++ b/api/server/controllers/agents/__tests__/callbacks.spec.js @@ -22,9 +22,8 @@ jest.mock('@librechat/api', () => ({ } : null, ), - isCodeSessionToolName: jest.fn((name) => - ['execute_code', 'bash_tool', 'read_file'].includes(name), - ), + isCodeArtifactToolOutput: jest.requireActual('@librechat/api').isCodeArtifactToolOutput, + isCodeSessionToolName: jest.requireActual('@librechat/api').isCodeSessionToolName, })); jest.mock('@librechat/data-schemas', () => ({ diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index d6935069fce..e891d6cb44e 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -164,6 +164,7 @@ jest.mock('@librechat/data-schemas', () => ({ })); jest.mock('@librechat/agents', () => ({ + ...jest.requireActual('@librechat/agents'), Callback: { TOOL_ERROR: 'TOOL_ERROR' }, ToolEndHandler: jest.fn(), formatAgentMessages: jest.fn().mockReturnValue({ @@ -177,7 +178,11 @@ jest.mock('@librechat/api', () => ({ createProvisionFilesCallback: () => async () => {}, createAgentExecutionContext: (context) => context, /** Grants both by default; the capability set is what these specs vary. */ - resolveToolRoleGrants: jest.fn(async () => ({ runCode: true, fileSearch: true })), + resolveToolRoleGrants: jest.fn(async () => ({ + runCode: true, + fileSearch: true, + webSearch: true, + })), collectReachableAgents: (roots) => { const agents = []; const pending = [...roots]; @@ -204,6 +209,8 @@ jest.mock('@librechat/api', () => ({ createRun: jest.fn().mockResolvedValue({ processStream: mockProcessStream, }), + createTerminalRunErrorObserver: (...args) => + jest.requireActual('@librechat/api').createTerminalRunErrorObserver(...args), applyContextToAgent: (...args) => mockApplyContextToAgent(...args), buildAgentScopedContext: (...args) => mockBuildAgentScopedContext(...args), buildInlineMemoryContext: (...args) => mockBuildInlineMemoryContext(...args), @@ -214,14 +221,27 @@ jest.mock('@librechat/api', () => ({ buildInitialToolSessions: jest.fn().mockReturnValue(mockInitialSessions), AgentRunEnvelopeError: MockAgentRunEnvelopeError, createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args), + resolveConversationCodeEnvironmentDecision: ({ + requestedMode, + requestedSelections, + conversation, + }) => { + const codeWorkspaces = requestedSelections ?? conversation?.codeWorkspaces; + return { + mode: requestedMode ?? (codeWorkspaces?.length ? 'attached' : 'without_attached'), + ...(codeWorkspaces !== undefined && { codeWorkspaces }), + }; + }, createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId, + codeEnvironmentMode, codeWorkspaces, }) => ({ messageId, conversationId, + ...(codeEnvironmentMode !== undefined && { codeEnvironmentMode }), ...(codeWorkspaces !== undefined && { codeWorkspaces }), ...(parentMessageId !== undefined && { parentMessageId: parentMessageId ?? '00000000-0000-0000-0000-000000000000', @@ -349,7 +369,7 @@ jest.mock('@librechat/api', () => ({ return await execute(execution); } catch (error) { executionError = error; - if (handleExecutionError) return await handleExecutionError(error); + if (handleExecutionError) return await handleExecutionError(error, execution?.signal); throw error; } finally { removeCloseListener(); @@ -1037,6 +1057,28 @@ describe('OpenAIChatCompletionController', () => { }); describe('safe error logging', () => { + it('does not classify a client disconnect as an upstream model error', async () => { + const api = require('@librechat/api'); + const { logger } = require('@librechat/data-schemas'); + const abortError = Object.assign(new Error('request aborted'), { name: 'AbortError' }); + mockProcessStream.mockImplementationOnce(async () => { + const modelCallback = api.createRun.mock.calls + .at(-1)[0] + .modelCallbacks.find(({ name }) => name === 'librechat-upstream-model-error-tracker'); + modelCallback.handleLLMError(abortError); + res.once.mock.calls.find(([event]) => event === 'close')[1](); + throw abortError; + }); + + await OpenAIChatCompletionController(req, res); + + expect(mockExecution.signal.aborted).toBe(true); + expect(logger.error).not.toHaveBeenCalledWith( + '[OpenAI API] Upstream model error', + expect.anything(), + ); + }); + it('logs bounded metadata and returns a raw-free provider error', async () => { const api = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); @@ -1050,15 +1092,30 @@ describe('OpenAIChatCompletionController', () => { }, }); req.config.filters = { messages: { pii: {} } }; - mockProcessStream.mockRejectedValueOnce(providerError); + mockProcessStream.mockImplementationOnce(async () => { + const modelCallback = api.createRun.mock.calls + .at(-1)[0] + .modelCallbacks.find(({ name }) => name === 'librechat-upstream-model-error-tracker'); + modelCallback.handleLLMError(providerError); + throw new Error('graph failed', { cause: providerError }); + }); await OpenAIChatCompletionController(req, res); - expect(mockGetSafeErrorMetadata).toHaveBeenCalledWith(providerError); const errorLog = logger.error.mock.calls.find( - ([message]) => message === '[OpenAI API] Error:', + ([message]) => message === '[OpenAI API] Upstream model error', ); - expect(errorLog).toEqual(['[OpenAI API] Error:', { type: 'Error', status: 502 }]); + expect(errorLog).toEqual([ + '[OpenAI API] Upstream model error', + { + type: 'Error', + status: 502, + errorCode: 'UPSTREAM_MODEL_ERROR', + errorOrigin: 'model_provider', + errorType: '502', + traceId: 'a64360db27015f7d7eadebf78a806ac1', + }, + ]); expect(JSON.stringify(errorLog)).not.toContain(rawValue); expect(api.createErrorResponse).toHaveBeenCalledWith( 'An error occurred while processing the request', @@ -1071,7 +1128,9 @@ describe('OpenAIChatCompletionController', () => { it('streams a raw-free provider error after headers are sent', async () => { const api = require('@librechat/api'); + const { logger } = require('@librechat/data-schemas'); const rawValue = 'PRIVATE-OPENAI-STREAM-PAYLOAD'; + const providerError = new Error(`Provider echoed ${rawValue}`); api.validateRequest.mockReturnValueOnce({ request: { model: 'agent-123', @@ -1083,7 +1142,13 @@ describe('OpenAIChatCompletionController', () => { res.flushHeaders.mockImplementationOnce(() => { res.headersSent = true; }); - mockProcessStream.mockRejectedValueOnce(new Error(`Provider echoed ${rawValue}`)); + mockProcessStream.mockImplementationOnce(async () => { + api.createRun.mock.calls + .at(-1)[0] + .modelCallbacks.find(({ name }) => name === 'librechat-upstream-model-error-tracker') + .handleLLMError(providerError); + throw providerError; + }); await OpenAIChatCompletionController(req, res); @@ -1094,6 +1159,13 @@ describe('OpenAIChatCompletionController', () => { ); expect(JSON.stringify(api.createChunk.mock.calls)).not.toContain(rawValue); expect(JSON.stringify(api.writeSSE.mock.calls)).not.toContain(rawValue); + expect(logger.error).toHaveBeenCalledWith( + '[OpenAI API] Upstream model error', + expect.objectContaining({ + errorCode: 'UPSTREAM_MODEL_ERROR', + traceId: 'a64360db27015f7d7eadebf78a806ac1', + }), + ); }); it('preserves the legacy provider error when protection is inactive', async () => { @@ -1553,6 +1625,7 @@ describe('OpenAIChatCompletionController', () => { messageId: 'chatcmpl-mock-nanoid-123', conversationId: 'conversation-123', parentMessageId: 'parent-123', + codeEnvironmentMode: 'without_attached', }, }), expect.anything(), @@ -1563,6 +1636,7 @@ describe('OpenAIChatCompletionController', () => { messageId: 'chatcmpl-mock-nanoid-123', conversationId: 'conversation-123', parentMessageId: 'parent-123', + codeEnvironmentMode: 'without_attached', }, }), ); @@ -1574,6 +1648,7 @@ describe('OpenAIChatCompletionController', () => { messageId: 'chatcmpl-mock-nanoid-123', conversationId: 'conversation-123', parentMessageId: 'parent-123', + codeEnvironmentMode: 'without_attached', }, }), }), @@ -1600,6 +1675,7 @@ describe('OpenAIChatCompletionController', () => { expect(requestBody).toEqual({ messageId: 'chatcmpl-mock-nanoid-123', conversationId: 'conversation-123', + codeEnvironmentMode: 'without_attached', }); expect(requestBody).not.toHaveProperty('parentMessageId'); }); @@ -1781,4 +1857,43 @@ describe('OpenAIChatCompletionController', () => { ); }); }); + + describe('web search role gating', () => { + const setCapabilities = (capabilities) => { + req.config.endpoints.agents.capabilities = capabilities; + }; + + const passedResolver = () => { + const { initializeAgent } = require('@librechat/api'); + return initializeAgent.mock.calls[0][0].resolveWebSearchGrant; + }; + + /** Provider-native search is a model parameter with no capability of its own, + * so the resolver is handed over whatever the capabilities โ€” but it reads + * nothing until the initializer finds native search in the built config. */ + it('hands initializeAgent a grant resolver without reading the role', async () => { + const { resolveToolRoleGrants } = require('@librechat/api'); + setCapabilities([]); + + await OpenAIChatCompletionController(req, res); + + expect(passedResolver()).toEqual(expect.any(Function)); + expect(resolveToolRoleGrants).not.toHaveBeenCalled(); + }); + + it('resolves the WEB_SEARCH grant against this request when called', async () => { + const { resolveToolRoleGrants } = require('@librechat/api'); + resolveToolRoleGrants.mockResolvedValueOnce({ + runCode: true, + fileSearch: true, + webSearch: false, + }); + setCapabilities([]); + + await OpenAIChatCompletionController(req, res); + + await expect(passedResolver()()).resolves.toBe(false); + expect(resolveToolRoleGrants).toHaveBeenCalledWith(expect.objectContaining({ req })); + }); + }); }); diff --git a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js index dae2a4861a1..90b9c89a330 100644 --- a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js +++ b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js @@ -65,6 +65,10 @@ jest.mock('@librechat/api', () => ({ resolveTitleTiming: jest.fn(() => 'immediate'), resolveConversationAnchor: jest.requireActual('@librechat/api').resolveConversationAnchor, resolveRunCodeWorkspaces: jest.requireActual('@librechat/api').resolveRunCodeWorkspaces, + shouldPersistCodeWorkspaceInitializationError: + jest.requireActual('@librechat/api').shouldPersistCodeWorkspaceInitializationError, + getSafeErrorMetadata: jest.requireActual('@librechat/api').getSafeErrorMetadata, + getSafeErrorText: jest.requireActual('@librechat/api').getSafeErrorText, GenerationJobManager: mockGenerationJobManager, getReferencedQuotes: jest.fn(() => null), cleanupMCPRequestContext: jest.fn(), diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index b81a06fffac..a7594950bb7 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -55,6 +55,7 @@ function wonGenerationClaim(overrides = {}) { } const mockCheckAndIncrementPendingRequest = jest.fn(); +const mockGetFailedTurnTraceFields = jest.fn(); const mockDecrementPendingRequest = jest.fn(); const mockGetViolationInfo = jest.fn(() => ({ type: 'concurrent', @@ -274,8 +275,15 @@ jest.mock('@librechat/api', () => ({ resolveTitleTiming: jest.fn(() => 'immediate'), resolveConversationAnchor: jest.requireActual('@librechat/api').resolveConversationAnchor, resolveRunCodeWorkspaces: jest.requireActual('@librechat/api').resolveRunCodeWorkspaces, + AttachmentStorageError: jest.requireActual('@librechat/api').AttachmentStorageError, + encodeAndFormatImages: jest.requireActual('@librechat/api').encodeAndFormatImages, getCodeWorkspaceSelectionErrorDetails: jest.requireActual('@librechat/api').getCodeWorkspaceSelectionErrorDetails, + shouldPersistCodeWorkspaceInitializationError: + jest.requireActual('@librechat/api').shouldPersistCodeWorkspaceInitializationError, + getSafeErrorMetadata: jest.requireActual('@librechat/api').getSafeErrorMetadata, + getSafeErrorText: jest.requireActual('@librechat/api').getSafeErrorText, + getFailedTurnTraceFields: (...args) => mockGetFailedTurnTraceFields(...args), GenerationJobManager: mockGenerationJobManager, getReferencedQuotes: jest.fn((quotes) => { if (!Array.isArray(quotes)) { @@ -333,11 +341,13 @@ jest.mock('@librechat/api', () => ({ messageId, conversationId, parentMessageId, + codeEnvironmentMode, codeWorkspaces, }) => ({ messageId, conversationId, parentMessageId, + ...(codeEnvironmentMode !== undefined ? { codeEnvironmentMode } : {}), ...(codeWorkspaces !== undefined ? { codeWorkspaces } : {}), }), })); @@ -400,6 +410,7 @@ jest.mock('~/server/services/Agents/triggers', () => ({ })); const AgentController = require('../request'); +const { AttachmentStorageError, encodeAndFormatImages } = require('@librechat/api'); const { ErrorTypes } = require('librechat-data-provider'); const { disposeClient: mockDisposeClient } = require('~/server/cleanup'); const { getMCPRequestContext } = require('~/server/services/MCPRequestContext'); @@ -1425,6 +1436,53 @@ describe('ResumableAgentController resume metadata', () => { expect(requestBody.messageId).not.toBe(req.body.messageId); }); + it('pins a normalized code-environment decision before provider execution', async () => { + let signalProviderStarted; + const providerStarted = new Promise((resolve) => { + signalProviderStarted = resolve; + }); + const sendMessage = jest.fn(() => { + signalProviderStarted(); + return new Promise(() => {}); + }); + const initializeClient = jest.fn(async ({ req }) => { + req.body.codeEnvironmentMode = 'without_attached'; + delete req.body.codeWorkspaces; + return { client: { options: {}, sendMessage } }; + }); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Continue without my attached machine.', + messageId: 'incoming-client-message', + parentMessageId: 'previous-response', + conversationId: 'conversation-123', + endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } }, + }, + config: {}, + }; + + await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null); + await providerStarted; + + const initialRequestBody = + mockGenerationJobManager.createJob.mock.calls[0][3].initialMetadata.mcpRequestBody; + expect(initialRequestBody).not.toHaveProperty('codeEnvironmentMode'); + expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith( + 'conversation-123', + { + mcpRequestBody: { + ...initialRequestBody, + codeEnvironmentMode: 'without_attached', + }, + }, + 1000, + ); + expect(mockGenerationJobManager.updateMetadata.mock.invocationCallOrder[0]).toBeLessThan( + sendMessage.mock.invocationCallOrder[0], + ); + }); + it('uses the effective overridden conversation in the MCP request body', async () => { const initializeClient = jest.fn().mockRejectedValue(new Error('stop after MCP discovery')); const req = { @@ -2602,6 +2660,134 @@ describe('ResumableAgentController resume metadata', () => { expect(mockDecrementPendingRequest).toHaveBeenCalledWith('user-123'); }); + it('names the failure class at the initialization boundary', async () => { + const initializeClient = jest.fn().mockRejectedValue(new AttachmentStorageError()); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Describe the attached image.', + messageId: 'user-msg', + clientRequestId: 'req-abc', + conversationId: 'conversation-123', + endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } }, + }, + config: {}, + }; + const res = createResumableResponse(); + + await AgentController(req, res, jest.fn(), initializeClient, null); + + const [message, ...metadata] = mockLogger.error.mock.calls.find((call) => + String(call[0]).startsWith('[ResumableAgentController] Initialization error:'), + ); + expect(metadata).toEqual([]); + expect(message).toContain( + 'AttachmentStorageError: An attached file could not be read from storage.', + ); + expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith( + 'conversation-123', + 'An attached file could not be read from storage. Try again or upload it again.', + 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), + ); + }); + + it('redacts a signed storage URL an initialization failure carries into the log', async () => { + const signedUrl = + 'https://minio.example.com/bucket/image.png?X-Amz-Credential=secret&X-Amz-Signature=signed'; + const initializeClient = jest + .fn() + .mockRejectedValue(new Error(`AccessDenied reading ${signedUrl}`)); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Describe the attached image.', + messageId: 'user-msg', + clientRequestId: 'req-abc', + conversationId: 'conversation-123', + endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } }, + }, + config: {}, + }; + + await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null); + + const [message, ...metadata] = mockLogger.error.mock.calls.find((call) => + String(call[0]).startsWith('[ResumableAgentController] Initialization error:'), + ); + expect(metadata).toEqual([]); + expect(message).toContain('AccessDenied reading https://minio.example.com/[redacted]'); + expect(message).not.toContain('X-Amz-Signature'); + expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain(signedUrl); + }); + + it('publishes a safe image storage failure during generation and releases the request', async () => { + const signedUrl = + 'https://minio.example.com/bucket/image.png?X-Amz-Credential=secret&X-Amz-Signature=signed'; + const storageError = Object.assign(new Error(`Access denied for ${signedUrl}`), { + code: 'AccessDenied', + statusCode: 403, + }); + const getDownloadStream = jest.fn().mockRejectedValue(storageError); + const file = { + file_id: 'image-1', + source: 's3', + filepath: signedUrl, + storageKey: 'images/user/image.png', + height: 10, + width: 10, + }; + const req = { + user: { id: 'user-123' }, + body: { + text: 'Describe the attached image.', + messageId: 'user-msg', + conversationId: 'conversation-123', + endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } }, + }, + config: {}, + }; + const client = { + options: {}, + sendMessage: jest.fn(() => + encodeAndFormatImages( + req, + [file], + {}, + { getStrategyFunctions: () => ({ getDownloadStream }) }, + ), + ), + }; + const initializeClient = jest.fn().mockResolvedValue({ client }); + + await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null); + await nextTick(); + + const safeError = new AttachmentStorageError(); + expect(getDownloadStream).toHaveBeenCalledWith(req, file.storageKey); + expect(mockLogger.error).toHaveBeenCalledWith( + '[ResumableAgentController] Generation error for conversation-123:', + safeError, + ); + expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith( + 'conversation-123', + safeError.message, + 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), + ); + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + expect.objectContaining({ error: true, text: safeError.message }), + expect.any(Object), + ); + expect(mockDecrementPendingRequest).toHaveBeenCalledWith('user-123'); + expect(mockDisposeClient).toHaveBeenCalledWith(client); + expect(JSON.stringify(mockGenerationJobManager.completeJob.mock.calls)).not.toContain( + signedUrl, + ); + expect(JSON.stringify(mockSaveMessage.mock.calls)).not.toContain(signedUrl); + }); + it('returns a typed recovery conflict before acknowledging generation startup', async () => { const recoveryError = new Error('Attached resources could not be restored'); recoveryError.code = ErrorTypes.RESOURCE_RECOVERY_REQUIRED; @@ -3638,6 +3824,83 @@ describe('ResumableAgentController resume metadata', () => { expect(savedIds).not.toContain('user-message_'); }); + it('points a failed turn at the trace of the run that failed', async () => { + const traceFields = { + langfuseSampled: true, + langfuseDestinationIds: ['destination-1'], + langfuseRunId: 'server-response-uuid', + }; + mockGetFailedTurnTraceFields.mockResolvedValue(traceFields); + const serverUserMessage = { + messageId: 'server-user', + parentMessageId: 'prior-response', + conversationId, + sender: 'User', + text: 'Hello with a removed model.', + isCreatedByUser: true, + }; + const client = { + options: {}, + sendMessage: jest.fn(async (_text, options) => { + options.onStart(serverUserMessage, 'server-response-uuid'); + client.run = {}; + throw new Error('failed inside the run'); + }), + }; + + await AgentController( + createFailedRequest(), + createResumableResponse(), + jest.fn(), + jest.fn().mockResolvedValue({ client }), + null, + ); + await flushBackgroundGeneration(); + + const errorRow = mockSaveMessage.mock.calls + .map(([, message]) => message) + .find((message) => message.messageId === 'server-user_'); + expect(mockGetFailedTurnTraceFields).toHaveBeenCalledWith(expect.anything(), { + messageId: 'server-user_', + runId: 'server-response-uuid', + runCreated: true, + }); + expect(errorRow).toMatchObject({ error: true, isCreatedByUser: false, ...traceFields }); + }); + + it('tells the trace lookup when a failure came before the run was created', async () => { + mockGetFailedTurnTraceFields.mockResolvedValue({}); + const client = { + options: {}, + sendMessage: jest.fn(async (_text, options) => { + options.onStart( + { messageId: 'server-user', conversationId, isCreatedByUser: true, text: 'Hi' }, + 'server-response-uuid', + ); + throw new Error('failed before the run'); + }), + }; + + await AgentController( + createFailedRequest(), + createResumableResponse(), + jest.fn(), + jest.fn().mockResolvedValue({ client }), + null, + ); + await flushBackgroundGeneration(); + + const errorRow = mockSaveMessage.mock.calls + .map(([, message]) => message) + .find((message) => message.messageId === 'server-user_'); + expect(mockGetFailedTurnTraceFields).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ runCreated: false }), + ); + expect(errorRow).toMatchObject({ error: true }); + expect(errorRow).not.toHaveProperty('langfuseSampled'); + }); + it('does not overwrite an existing response row', async () => { mockGetMessages.mockResolvedValue([{ _id: 'already-saved' }]); @@ -3653,6 +3916,36 @@ describe('ResumableAgentController resume metadata', () => { expect(mockSaveConvo).not.toHaveBeenCalled(); }); + it('does not persist a first turn whose code environment decision was rejected', async () => { + const res = createResumableResponse(); + mockGenerationJobManager.claimGeneration.mockImplementation( + async (_userId, _clientRequestId, streamId, claimedConversationId) => + wonGenerationClaim({ streamId, conversationId: claimedConversationId }), + ); + const req = createFailedRequest({ + conversationId: undefined, + clientRequestId: 'invalid-code-decision', + codeEnvironmentMode: 'attached', + codeWorkspaces: undefined, + }); + const workspaceError = Object.assign(new Error('Choose an attached workspace'), { + code: ErrorTypes.CODE_WORKSPACE_UNAVAILABLE, + reason: 'required', + status: 409, + statusCode: 409, + }); + + await AgentController(req, res, jest.fn(), jest.fn().mockRejectedValue(workspaceError), null); + + expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining(ErrorTypes.CODE_WORKSPACE_UNAVAILABLE), + 1000, + ); + expect(mockSaveMessage).not.toHaveBeenCalled(); + expect(mockSaveConvo).not.toHaveBeenCalled(); + }); + it('creates the conversation row for a failed first turn', async () => { const res = createResumableResponse(); mockGenerationJobManager.claimGeneration.mockImplementation( @@ -3670,15 +3963,18 @@ describe('ResumableAgentController resume metadata', () => { modelOptions: { model: 'gpt-4o' }, chatProjectId: '507f1f77bcf86cd799439011', }, + codeEnvironmentMode: 'attached', + codeWorkspaces: [{ environmentId: 'personal-vm', workspaceId: 'project-a' }], + }); + const initializeClient = jest.fn().mockImplementation(async ({ req: request }) => { + request._codeEnvironmentDecision = { + mode: 'attached', + codeWorkspaces: [{ environmentId: 'personal-vm', workspaceId: 'project-a' }], + }; + throw new Error('model unavailable'); }); - await AgentController( - req, - res, - jest.fn(), - jest.fn().mockRejectedValue(new Error('model unavailable')), - null, - ); + await AgentController(req, res, jest.fn(), initializeClient, null); const mintedConversationId = res.json.mock.calls[0][0].conversationId; expect(mockSaveMessage).toHaveBeenCalledWith( @@ -3696,6 +3992,8 @@ describe('ResumableAgentController resume metadata', () => { endpoint: 'azureOpenAI', model: 'gpt-4o', chatProjectId: '507f1f77bcf86cd799439011', + codeEnvironmentMode: 'attached', + codeWorkspaces: [{ environmentId: 'personal-vm', workspaceId: 'project-a' }], }), expect.objectContaining({ initialAgentId: null }), ); diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 635e6099dfb..1cbac69ccb5 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -187,6 +187,7 @@ jest.mock('@librechat/data-schemas', () => ({ })); jest.mock('@librechat/agents', () => ({ + ...jest.requireActual('@librechat/agents'), Callback: { TOOL_ERROR: 'TOOL_ERROR' }, ToolEndHandler: jest.fn(), formatAgentMessages: jest.fn().mockReturnValue({ @@ -200,7 +201,11 @@ jest.mock('@librechat/api', () => ({ createProvisionFilesCallback: () => async () => {}, createAgentExecutionContext: (context) => context, /** Grants both by default; the capability set is what these specs vary. */ - resolveToolRoleGrants: jest.fn(async () => ({ runCode: true, fileSearch: true })), + resolveToolRoleGrants: jest.fn(async () => ({ + runCode: true, + fileSearch: true, + webSearch: true, + })), SAFE_CONVERSATION_TITLE: 'New Chat', resolveConversationTitle: (...args) => mockResolveConversationTitle(...args), /** Pass-through: the controller strips UI-only activity-label parts @@ -228,20 +233,35 @@ jest.mock('@librechat/api', () => ({ createRun: jest.fn().mockResolvedValue({ processStream: jest.fn().mockResolvedValue(undefined), }), + createTerminalRunErrorObserver: (...args) => + jest.requireActual('@librechat/api').createTerminalRunErrorObserver(...args), buildInitialToolSessions: jest.fn().mockReturnValue(mockInitialSessions), applyContextToAgent: (...args) => mockApplyContextToAgent(...args), buildRunToolSet: jest.fn().mockReturnValue(new Set()), AgentRunEnvelopeError: MockAgentRunEnvelopeError, createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args), + resolveConversationCodeEnvironmentDecision: ({ + requestedMode, + requestedSelections, + conversation, + }) => { + const codeWorkspaces = requestedSelections ?? conversation?.codeWorkspaces; + return { + mode: requestedMode ?? (codeWorkspaces?.length ? 'attached' : 'without_attached'), + ...(codeWorkspaces !== undefined && { codeWorkspaces }), + }; + }, getCodeWorkspaceSelections: jest.fn(), createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId, + codeEnvironmentMode, codeWorkspaces, }) => ({ messageId, conversationId, + ...(codeEnvironmentMode !== undefined && { codeEnvironmentMode }), ...(codeWorkspaces !== undefined && { codeWorkspaces }), ...(parentMessageId !== undefined && { parentMessageId: parentMessageId ?? '00000000-0000-0000-0000-000000000000', @@ -417,7 +437,7 @@ jest.mock('@librechat/api', () => ({ return await execute(execution); } catch (error) { executionError = error; - if (handleExecutionError) return await handleExecutionError(error); + if (handleExecutionError) return await handleExecutionError(error, execution?.signal); throw error; } finally { removeCloseListener(); @@ -608,6 +628,38 @@ describe('createResponse controller', () => { }, ); + it.each([false, true])( + 'persists the normalized no-attached decision atomically: stream=%s', + async (stream) => { + const api = require('@librechat/api'); + const db = require('~/models'); + api.getCodeWorkspaceSelections.mockReturnValueOnce([ + { environmentId: 'machine', workspaceId: 'stale-project' }, + ]); + api.validateResponseRequest.mockReturnValueOnce({ + request: { + model: 'agent-123', + input: 'Hello', + stream, + store: true, + code_environment_mode: 'without_attached', + }, + }); + + await createResponse(req, res); + + expect(db.saveConvo).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + codeEnvironmentMode: 'without_attached', + }), + expect.anything(), + ); + expect(db.saveConvo.mock.calls.at(-1)[1]).not.toHaveProperty('codeWorkspaces'); + expect(api.getCodeWorkspaceSelections).not.toHaveBeenCalled(); + }, + ); + it('enrolls, starts, and settles the remote execution lifecycle', async () => { await createResponse(req, res); @@ -902,6 +954,7 @@ describe('createResponse controller', () => { requestBody: { messageId: 'resp_mock-123', conversationId: expect.any(String), + codeEnvironmentMode: 'without_attached', }, }), expect.anything(), @@ -1638,6 +1691,29 @@ describe('createResponse controller', () => { }); describe('safe error logging', () => { + it('does not classify a client disconnect as an upstream model error', async () => { + const api = require('@librechat/api'); + const { logger } = require('@librechat/data-schemas'); + const abortError = Object.assign(new Error('request aborted'), { name: 'AbortError' }); + api.createRun.mockImplementationOnce(async (options) => ({ + processStream: jest.fn(async () => { + options.modelCallbacks + .find(({ name }) => name === 'librechat-upstream-model-error-tracker') + .handleLLMError(abortError); + res.once.mock.calls.find(([event]) => event === 'close')[1](); + throw abortError; + }), + })); + + await createResponse(req, res); + + expect(mockExecution.signal.aborted).toBe(true); + expect(logger.error).not.toHaveBeenCalledWith( + '[Responses API] Upstream model error', + expect.anything(), + ); + }); + it('logs bounded metadata and returns a raw-free provider error', async () => { const api = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); @@ -1655,7 +1731,6 @@ describe('createResponse controller', () => { await createResponse(req, res); - expect(mockGetSafeErrorMetadata).toHaveBeenCalledWith(providerError); const errorLog = logger.error.mock.calls.find( ([message]) => message === '[Responses API] Error:', ); @@ -1670,6 +1745,50 @@ describe('createResponse controller', () => { expect(JSON.stringify(api.sendResponsesErrorResponse.mock.calls)).not.toContain(rawValue); }); + it.each([false, true])( + 'classifies a terminal model callback failure and correlates its trace: stream=%s', + async (stream) => { + const api = require('@librechat/api'); + const { logger } = require('@librechat/data-schemas'); + const rawValue = 'PRIVATE-RESPONSES-UPSTREAM-PAYLOAD'; + const providerError = Object.assign(new Error(`Provider echoed ${rawValue}`), { + code: 'ERR_REMOTE', + response: { status: 503, data: { prompt: rawValue } }, + }); + req.config.filters = { messages: { pii: {} } }; + api.validateResponseRequest.mockReturnValueOnce({ + request: { model: 'agent-123', input: 'Hello', stream }, + }); + api.createRun.mockImplementationOnce(async (options) => ({ + processStream: jest.fn(async () => { + options.modelCallbacks + .find(({ name }) => name === 'librechat-upstream-model-error-tracker') + .handleLLMError(providerError); + throw new Error('graph failed', { cause: providerError }); + }), + })); + + await createResponse(req, res); + + const errorLog = logger.error.mock.calls.find( + ([message]) => message === '[Responses API] Upstream model error', + ); + expect(errorLog).toEqual([ + '[Responses API] Upstream model error', + { + type: 'Error', + status: 503, + errorCode: 'UPSTREAM_MODEL_ERROR', + errorOrigin: 'model_provider', + errorType: '503', + traceId: 'da34f2d846b1b1b770afe89e670770d5', + }, + ]); + expect(JSON.stringify(errorLog)).not.toContain(rawValue); + expect(JSON.stringify(errorLog)).not.toContain('ERR_REMOTE'); + }, + ); + it('preserves the legacy provider error when protection is inactive', async () => { const api = require('@librechat/api'); const rawValue = 'LEGACY-RESPONSES-PROVIDER-ERROR'; @@ -2327,4 +2446,43 @@ describe('createResponse controller', () => { ); }); }); + + describe('web search role gating', () => { + const setCapabilities = (capabilities) => { + req.config.endpoints.agents.capabilities = capabilities; + }; + + const passedResolver = () => { + const { initializeAgent } = require('@librechat/api'); + return initializeAgent.mock.calls[0][0].resolveWebSearchGrant; + }; + + /** Provider-native search is a model parameter with no capability of its own, + * so the resolver is handed over whatever the capabilities โ€” but it reads + * nothing until the initializer finds native search in the built config. */ + it('hands initializeAgent a grant resolver without reading the role', async () => { + const { resolveToolRoleGrants } = require('@librechat/api'); + setCapabilities([]); + + await createResponse(req, res); + + expect(passedResolver()).toEqual(expect.any(Function)); + expect(resolveToolRoleGrants).not.toHaveBeenCalled(); + }); + + it('resolves the WEB_SEARCH grant against this request when called', async () => { + const { resolveToolRoleGrants } = require('@librechat/api'); + resolveToolRoleGrants.mockResolvedValueOnce({ + runCode: true, + fileSearch: true, + webSearch: false, + }); + setCapabilities([]); + + await createResponse(req, res); + + await expect(passedResolver()()).resolves.toBe(false); + expect(resolveToolRoleGrants).toHaveBeenCalledWith(expect.objectContaining({ req })); + }); + }); }); diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js index dde2514f66b..d83ef745ed7 100644 --- a/api/server/controllers/agents/__tests__/resume.spec.js +++ b/api/server/controllers/agents/__tests__/resume.spec.js @@ -2461,6 +2461,33 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => { await flush(); }); + it('enforces the current fingerprint while retaining the rolling-deploy digest', async () => { + const { computeAgentRequestFingerprint, computeLegacyAgentRequestFingerprint } = + jest.requireActual('@librechat/api'); + const pausedBody = { + endpoint: 'agents', + agent_id: AGENT_ID, + codeEnvironmentMode: 'attached', + codeWorkspaces: [{ environmentId: 'machine-a', workspaceId: 'project-a' }], + }; + const job = makeToolApprovalJob(); + job.metadata.pendingAction.requestFingerprint = + computeLegacyAgentRequestFingerprint(pausedBody); + job.metadata.pendingAction.requestFingerprintV2 = computeAgentRequestFingerprint(pausedBody); + mockGenerationJobManager.getJob.mockResolvedValue(job); + + const res = await post( + approveBody({ + codeEnvironmentMode: 'attached', + codeWorkspaces: [{ environmentId: 'machine-a', workspaceId: 'project-b' }], + }), + ); + + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/different agent configuration/i); + expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled(); + }); + it('403 when the resume sends a different promptPrefix than the paused config', async () => { const { computeAgentRequestFingerprint } = jest.requireActual('@librechat/api'); const job = makeToolApprovalJob(); diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 770688c6018..6c295b6568a 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -26,6 +26,7 @@ const { createBackgroundCodeResultHandler: createCodeHarvestHandler, HOST_FILE_AUTHORING_ARTIFACT_KEY, isCodeSessionToolName, + isCodeArtifactToolOutput, getModelRefusalInfo, shouldSignalSandboxStart, getToolInputValidationDetails, @@ -40,10 +41,6 @@ function isHostFileAuthoringArtifact(artifact) { return artifact?.[HOST_FILE_AUTHORING_ARTIFACT_KEY] === true; } -function isCodeArtifactToolOutput(output) { - return isCodeSessionToolName(output.name) || isHostFileAuthoringArtifact(output.artifact); -} - function getAttachmentOwnership(metadata) { const agentId = metadata?.executingAgentId ?? metadata?.agentId ?? metadata?.agent_id; const stepId = metadata?.stepId; diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 58fc1824d77..e37693d986c 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -43,8 +43,8 @@ const { buildPendingAction, toClientPendingAction, captureCodeExecutionApprovalBinding, - getCodeWorkspaceSelections, computeAgentRequestFingerprint, + computeLegacyAgentRequestFingerprint, getRunDiscoveredTools, captureResumeModelParameters, pickResumeContext, @@ -169,6 +169,8 @@ const { createContextMetaPublisher, selectRunContextMetaToPublish, resolveToolRoleGrants, + createTerminalRunErrorObserver, + isAgentRunCancellation, } = require('@librechat/api'); const { Run, @@ -1997,6 +1999,7 @@ class AgentClient extends BaseClient { collectAttachedCodeEnvironmentPolicySettings(topLevelAgents), agentsEConfig?.toolApproval?.enabled !== false, ); + const codeEnvironmentDecision = this.options.req._codeEnvironmentDecision; return removeNullishValues( Object.assign( @@ -2011,9 +2014,12 @@ class AgentClient extends BaseClient { imageDetail: this.options.imageDetail, maxContextTokens: this.maxContextTokens, codeApprovalMode, - codeWorkspaces: getCodeWorkspaceSelections( - collectReachableAgents(topLevelAgents).map((agent) => agent?.codeExecutionContext), - ), + codeEnvironmentMode: + codeEnvironmentDecision?.mode ?? this.options.req.body.codeEnvironmentMode, + codeWorkspaces: + codeEnvironmentDecision != null + ? codeEnvironmentDecision.codeWorkspaces + : this.options.req.body.codeWorkspaces, }, // TODO: PARSE OPTIONS BY PROVIDER, MAY CONTAIN SENSITIVE DATA runOptions, @@ -3317,6 +3323,7 @@ class AgentClient extends BaseClient { getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, filterFilesByAgentAccess, + getRoleByName: db.getRoleByName, }, ); @@ -4308,7 +4315,11 @@ class AgentClient extends BaseClient { // Pin the graph-determining request fields so resume can't rebuild this paused // run on a different agent/tool set (esp. ephemeral agents, whose agent_id is // undefined so the id guard can't tell two configs apart). - requestFingerprint: computeAgentRequestFingerprint(this.options.req?.body ?? {}), + // Keep the legacy digest in its established field so an old replica can + // resume pauses written during a rolling deploy; current replicas also + // enforce the stricter code-environment-aware digest below. + requestFingerprint: computeLegacyAgentRequestFingerprint(this.options.req?.body ?? {}), + requestFingerprintV2: computeAgentRequestFingerprint(this.options.req?.body ?? {}), // Persist those same fields verbatim so the resume route can REPLAY them โ€” a // reload/cross-replica resume can't reconstruct the ephemeral config client-side, // so the server restores it and rebuilds the same graph (and the fingerprint matches). @@ -4375,6 +4386,12 @@ class AgentClient extends BaseClient { let run; /** @type {Promise<(TAttachment | null)[] | undefined>} */ let memoryPromise; + const terminalRunError = createTerminalRunErrorObserver({ + logger, + responseMessageId: this.responseMessageId, + source: '[api/server/controllers/agents/client.js #sendCompletion]', + genericMessage: '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error', + }); const appConfig = this.options.req.config; const balanceConfig = getBalanceConfig(appConfig); const transactionsConfig = getTransactionsConfig(appConfig); @@ -4508,6 +4525,9 @@ class AgentClient extends BaseClient { messageId: this.responseMessageId, conversationId: this.conversationId, parentMessageId: this.parentMessageId, + codeEnvironmentMode: + this.options.req.body.codeEnvironmentMode ?? + this.options.req.resolvedConversation?.codeEnvironmentMode, codeWorkspaces: this.options.req.body.codeWorkspaces ?? this.options.req.resolvedConversation?.codeWorkspaces, @@ -4854,6 +4874,7 @@ class AgentClient extends BaseClient { modelCallbacks: [ modelBoundCallback, createAgentMemoryCallback(this.attachmentMemoryContext ?? {}), + terminalRunError.modelCallback, ], // This controller implements the full HITL pause/resume lifecycle (handleRunInterrupt // persists the pending action; the /resume route rebuilds + continues the run), so it @@ -4912,6 +4933,7 @@ class AgentClient extends BaseClient { this.buildDetachedSubagentUsageRecorder(balanceConfig, transactionsConfig), ), subagentTasks: this.options.subagentTasks, + runFiles: this.options.runFiles, }).then((createdRun) => { if (!createdRun) { throw new Error('Failed to create run'); @@ -5075,7 +5097,7 @@ class AgentClient extends BaseClient { type: ContentTypes.ERROR, [ContentTypes.ERROR]: err.message, }); - } else if (abortController.signal.aborted) { + } else if (isAgentRunCancellation(err, abortController.signal)) { logger.debug( '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted by user', { conversationId: this.conversationId, ...getSafeErrorMetadata(err) }, @@ -5100,10 +5122,7 @@ class AgentClient extends BaseClient { }, ); } else { - logger.error( - '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error type', - getSafeErrorMetadata(err), - ); + terminalRunError.log(err, abortController.signal); const videoError = resolveGoogleVideoError({ error: err, provider: this.options.agent?.provider, @@ -5113,16 +5132,19 @@ class AgentClient extends BaseClient { type: ContentTypes.ERROR, [ContentTypes.ERROR]: videoError ?? - getUserFacingRequestError( - 'An error occurred while processing the request', - err, - this.options.req.config, + terminalRunError.getUserFacingError(err, () => + getUserFacingRequestError( + 'An error occurred while processing the request', + err, + this.options.req.config, + ), ), }); } } finally { /** An aborted/erroring run can still have completed compaction before * the failure; retain that model-visible state for actor reconciliation. */ + await this.options.runFiles?.close(); this.eventActorSummary = getLatestEventActorSummary(this.contentParts) ?? this.eventActorSummary; /** A run that never came to exist has no state of its own: keep the @@ -5237,6 +5259,12 @@ class AgentClient extends BaseClient { let config; /** @type {ReturnType} */ let run; + const terminalRunError = createTerminalRunErrorObserver({ + logger, + responseMessageId: this.responseMessageId, + source: '[api/server/controllers/agents/client.js #resumeCompletion]', + genericMessage: '[api/server/controllers/agents/client.js #resumeCompletion] Unhandled error', + }); const appConfig = this.options.req.config; const balanceConfig = getBalanceConfig(appConfig); const transactionsConfig = getTransactionsConfig(appConfig); @@ -5284,6 +5312,9 @@ class AgentClient extends BaseClient { messageId: this.responseMessageId, conversationId: this.conversationId, parentMessageId: this.parentMessageId, + codeEnvironmentMode: + this.options.req.body.codeEnvironmentMode ?? + this.options.req.resolvedConversation?.codeEnvironmentMode, codeWorkspaces: this.options.req.body.codeWorkspaces ?? this.options.req.resolvedConversation?.codeWorkspaces, @@ -5613,7 +5644,11 @@ class AgentClient extends BaseClient { run = await createRun({ agents, conversationId: this.conversationId, - modelCallbacks: [modelBoundCallback, attachmentMemoryCallback], + modelCallbacks: [ + modelBoundCallback, + attachmentMemoryCallback, + terminalRunError.modelCallback, + ], // State (messages, tool calls) is rehydrated from the checkpoint by // run.resume; createRun only needs the agents to rebuild the graph. messages: [], @@ -5664,6 +5699,7 @@ class AgentClient extends BaseClient { this.buildDetachedSubagentUsageRecorder(balanceConfig, transactionsConfig), ), subagentTasks: this.options.subagentTasks, + runFiles: this.options.runFiles, }); if (!run) { @@ -5755,7 +5791,7 @@ class AgentClient extends BaseClient { ); throw err; } - if (abortController.signal.aborted) { + if (isAgentRunCancellation(err, abortController.signal)) { logger.debug( '[api/server/controllers/agents/client.js #resumeCompletion] Aborted by user', { @@ -5773,20 +5809,20 @@ class AgentClient extends BaseClient { { conversationId: this.conversationId }, ); } else { - logger.error( - '[api/server/controllers/agents/client.js #resumeCompletion] Unhandled error', - getSafeErrorMetadata(err), - ); + terminalRunError.log(err, abortController.signal); this.contentParts.push({ type: ContentTypes.ERROR, - [ContentTypes.ERROR]: getUserFacingRequestError( - 'An error occurred while resuming the request', - err, - appConfig, + [ContentTypes.ERROR]: terminalRunError.getUserFacingError(err, () => + getUserFacingRequestError( + 'An error occurred while resuming the request', + err, + appConfig, + ), ), }); } } finally { + await this.options.runFiles?.close(); this.eventActorSummary = getLatestEventActorSummary(this.contentParts) ?? this.eventActorSummary; /** A run that never came to exist has no state of its own: keep the diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 3aa60dd3260..54b869ca885 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -78,18 +78,46 @@ describe('AgentClient code approval persistence', () => { }, req: { body: { codeApprovalMode: 'acceptEdits' }, + _codeEnvironmentDecision: { + mode: 'attached', + codeWorkspaces: [ + { environmentId: 'attached-vm', workspaceId: 'project-a' }, + { environmentId: 'team-vm', workspaceId: 'project-b' }, + ], + }, config: { endpoints: { [EModelEndpoint.agents]: {} } }, }, }; expect(client.getSaveOptions()).toMatchObject({ codeApprovalMode: 'acceptEdits', + codeEnvironmentMode: 'attached', codeWorkspaces: [ { environmentId: 'attached-vm', workspaceId: 'project-a' }, { environmentId: 'team-vm', workspaceId: 'project-b' }, ], }); }); + + it('does not combine a normalized no-attached mode with stale request selections', () => { + const client = Object.create(AgentClient.prototype); + client.agentConfigs = new Map(); + client.options = { + endpoint: EModelEndpoint.agents, + agent: { id: 'attached-agent' }, + req: { + body: { + codeWorkspaces: [{ environmentId: 'attached-vm', workspaceId: 'stale-project' }], + }, + _codeEnvironmentDecision: { mode: 'without_attached' }, + config: { endpoints: { [EModelEndpoint.agents]: {} } }, + }, + }; + + const saveOptions = client.getSaveOptions(); + expect(saveOptions.codeEnvironmentMode).toBe('without_attached'); + expect(saveOptions).not.toHaveProperty('codeWorkspaces'); + }); }); function deferred() { @@ -2858,6 +2886,164 @@ describe('AgentClient - startup telemetry', () => { ); }); + it('classifies a terminal chat-model failure without logging provider content', async () => { + jest.clearAllMocks(); + const { logger } = require('@librechat/data-schemas'); + const { traceIdForMessage } = require('@librechat/api'); + const privateValue = 'PRIVATE-UPSTREAM-PROVIDER-CONTENT'; + const providerError = Object.assign(new Error(), { + code: 'InternalServerException', + response: { + status: 500, + headers: { authorization: privateValue }, + data: { prompt: privateValue }, + }, + }); + Object.defineProperties(providerError, { + name: { + get() { + throw new Error(`Provider echoed ${privateValue}`); + }, + }, + message: { + get() { + throw new Error(`Provider echoed ${privateValue}`); + }, + }, + }); + const abortController = new AbortController(); + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger); + mockCreateRun.mockImplementation(async (options) => { + const tracker = options.modelCallbacks.find( + (callback) => callback.name === 'librechat-upstream-model-error-tracker', + ); + return { + Graph: null, + processStream: jest.fn(async () => { + tracker.handleLLMError(providerError, 'model-run'); + abortController.abort(); + throw providerError; + }), + getCalibrationRatio: jest.fn(() => 0), + }; + }); + mockIsHITLEnabled.mockReturnValue(false); + const client = new AgentClient({ + req: { + user: { id: 'user-123' }, + body: {}, + config: { + endpoints: { [EModelEndpoint.agents]: {} }, + filters: { messages: { pii: {} } }, + }, + _resumableStreamId: 'conversation-upstream-error', + }, + res: {}, + agent: { + id: 'agent-123', + endpoint: EModelEndpoint.openAI, + provider: EModelEndpoint.openAI, + model_parameters: { model: 'gpt-4' }, + hide_sequential_outputs: false, + }, + endpointTokenConfig: {}, + eventHandlers: {}, + contentParts: [], + collectedUsage: [], + artifactPromises: [], + }); + client.conversationId = 'conversation-upstream-error'; + client.responseMessageId = 'response-upstream-error'; + client.parentMessageId = 'parent-upstream-error'; + client.recordCollectedUsage = jest.fn().mockResolvedValue(); + + await client.chatCompletion({ payload: [], abortController }); + + expect(errorSpy).toHaveBeenCalledWith( + '[api/server/controllers/agents/client.js #sendCompletion] Upstream model error', + { + type: 'Error', + status: 500, + errorCode: 'UPSTREAM_MODEL_ERROR', + errorOrigin: 'model_provider', + errorType: '500', + traceId: traceIdForMessage('response-upstream-error'), + }, + ); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(privateValue); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('InternalServerException'); + expect(errorSpy).not.toHaveBeenCalledWith( + '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error', + expect.anything(), + ); + expect(client.contentParts).toContainEqual({ + type: ContentTypes.ERROR, + [ContentTypes.ERROR]: + 'The model provider could not complete this request.\n' + + JSON.stringify({ type: 'upstream_model_error', status: 500 }), + }); + errorSpy.mockRestore(); + }); + + it('keeps a later non-provider run failure on the generic error path', async () => { + jest.clearAllMocks(); + const { logger } = require('@librechat/data-schemas'); + const recoveredProviderError = new Error('recovered provider failure'); + const checkpointError = new Error('checkpoint failed'); + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger); + mockCreateRun.mockImplementation(async (options) => { + const tracker = options.modelCallbacks.find( + (callback) => callback.name === 'librechat-upstream-model-error-tracker', + ); + return { + Graph: null, + processStream: jest.fn(async () => { + tracker.handleLLMError(recoveredProviderError, 'recovered-model-run'); + throw checkpointError; + }), + getCalibrationRatio: jest.fn(() => 0), + }; + }); + mockIsHITLEnabled.mockReturnValue(false); + const client = new AgentClient({ + req: { + user: { id: 'user-123' }, + body: {}, + config: { endpoints: { [EModelEndpoint.agents]: {} } }, + _resumableStreamId: 'conversation-non-provider-error', + }, + res: {}, + agent: { + id: 'agent-123', + endpoint: EModelEndpoint.openAI, + provider: EModelEndpoint.openAI, + model_parameters: { model: 'gpt-4' }, + hide_sequential_outputs: false, + }, + endpointTokenConfig: {}, + eventHandlers: {}, + contentParts: [], + collectedUsage: [], + artifactPromises: [], + }); + client.conversationId = 'conversation-non-provider-error'; + client.responseMessageId = 'response-non-provider-error'; + client.parentMessageId = 'parent-non-provider-error'; + client.recordCollectedUsage = jest.fn().mockResolvedValue(); + + await client.chatCompletion({ payload: [] }); + + expect(errorSpy).toHaveBeenCalledWith( + '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error', + { type: 'Error' }, + ); + expect(errorSpy).not.toHaveBeenCalledWith( + '[api/server/controllers/agents/client.js #sendCompletion] Upstream model error', + expect.anything(), + ); + errorSpy.mockRestore(); + }); + it('cancels current attachment persistence after combined admission rejects the run', async () => { jest.clearAllMocks(); let attachmentLimitError; @@ -9469,6 +9655,70 @@ describe('AgentClient - resumeCompletion content protection', () => { errorSpy.mockRestore(); }); + it('classifies a terminal resumed model failure and preserves redaction', async () => { + const { logger } = require('@librechat/data-schemas'); + const { traceIdForMessage } = require('@librechat/api'); + const privateValue = 'PRIVATE-RESUMED-UPSTREAM-CONTENT'; + const providerError = Object.assign(new Error(), { + code: 'PROVIDER_INTERNAL', + status: 503, + }); + Object.defineProperties(providerError, { + name: { + get() { + throw new Error(`Provider echoed ${privateValue}`); + }, + }, + message: { + get() { + throw new Error(`Provider echoed ${privateValue}`); + }, + }, + }); + const abortController = new AbortController(); + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger); + mockCreateRun.mockImplementation(async (options) => { + const tracker = options.modelCallbacks.find( + (callback) => callback.name === 'librechat-upstream-model-error-tracker', + ); + return { + resume: jest.fn(async () => { + tracker.handleLLMError(providerError, 'resumed-model-run'); + abortController.abort(); + throw providerError; + }), + getCalibrationRatio: jest.fn(() => 0), + }; + }); + const context = makeContext(undefined); + + await AgentClient.prototype.resumeCompletion.call(context, { + resumeValue: {}, + abortController, + }); + + expect(errorSpy).toHaveBeenCalledWith( + '[api/server/controllers/agents/client.js #resumeCompletion] Upstream model error', + { + type: 'Error', + status: 503, + errorCode: 'UPSTREAM_MODEL_ERROR', + errorOrigin: 'model_provider', + errorType: '503', + traceId: traceIdForMessage('response-123'), + }, + ); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(privateValue); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('PROVIDER_INTERNAL'); + expect(context.contentParts).toContainEqual({ + type: ContentTypes.ERROR, + [ContentTypes.ERROR]: + 'The model provider could not complete this request.\n' + + JSON.stringify({ type: 'upstream_model_error', status: 503 }), + }); + errorSpy.mockRestore(); + }); + it('preserves provider error detail when content protection is disabled', async () => { const providerMessage = 'Legacy provider detail'; mockCreateRun.mockRejectedValue(new Error(providerMessage)); diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 1ff29ca8744..1c754b38606 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -67,6 +67,8 @@ const { executeAgentRun, waitForAgentExecutionWrites, resolveToolRoleGrants, + resolveConversationCodeEnvironmentDecision, + createTerminalRunErrorObserver, } = require('@librechat/api'); const { buildSummarizationHandlers, @@ -232,7 +234,6 @@ function sendErrorResponse(res, statusCode, message, type = 'invalid_request_err } function handleExecutionError({ error, res, context, appConfig }) { - logger.error('[OpenAI API] Error:', getSafeErrorMetadata(error)); const protectionEnabled = hasModelBoundContentProtection( appConfig?.filters, appConfig?.messageFilter?.pii, @@ -378,6 +379,11 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { } const responseId = `chatcmpl-${nanoid()}`; + const terminalRunError = createTerminalRunErrorObserver({ + logger, + responseMessageId: responseId, + source: '[OpenAI API]', + }); const created = Math.floor(Date.now() / 1000); /** @type {import('@librechat/api').OpenAIResponseContext} โ€” key must be `requestId` to match the type used by createChunk/buildNonStreamingResponse */ @@ -430,7 +436,10 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { onSettlementError: (error) => { logger.error('[OpenAI API] Failed to settle execution:', getSafeErrorMetadata(error)); }, - handleExecutionError: (error) => handleExecutionError({ error, res, context, appConfig }), + handleExecutionError: (error, signal) => { + terminalRunError.log(error, signal); + return handleExecutionError({ error, res, context, appConfig }); + }, execute: async (execution) => { if (request.conversation_id != null) { if (typeof request.conversation_id !== 'string') { @@ -448,6 +457,12 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { req.resolvedConversation = conversation; } + const codeEnvironmentDecision = resolveConversationCodeEnvironmentDecision({ + conversationId, + requestedMode: request.code_environment_mode, + requestedSelections: request.code_workspaces, + conversation: req.resolvedConversation, + }); const parentMessageId = request.parent_message_id ?? null; let mcpParentMessageId; if ( @@ -461,7 +476,8 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { const mcpRequestBody = createMCPRuntimeRequestBody({ messageId: responseId, conversationId, - codeWorkspaces: request.code_workspaces ?? req.resolvedConversation?.codeWorkspaces, + codeEnvironmentMode: codeEnvironmentDecision.mode, + codeWorkspaces: codeEnvironmentDecision.codeWorkspaces, parentMessageId: mcpParentMessageId, }); @@ -498,6 +514,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { listSkillsByAccess: skillDbMethods.listSkillsByAccess, listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills, getSkillByName: skillDbMethods.getSkillByName, + getRoleByName: db.getRoleByName, }; const enabledCapabilities = new Set(agentsEConfig?.capabilities); @@ -527,6 +544,12 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { * a tool the loader is about to drop. */ const fileSearchAvailable = fileSearchCapabilityEnabled && (await toolRoleGrants)?.fileSearch === true; + /** Called by `initializeAgent` only when an agent's built provider config + * turns native web search on. It reaches the initializer with `runtime` + * and no `req`, so this is what lets it join the grants memoized on this + * request instead of issuing its own read. */ + const resolveWebSearchGrant = async () => + (await resolveToolRoleGrants({ req, getRoleByName: db.getRoleByName })).webSearch; const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills); const ephemeralSkillsToggle = request.ephemeralAgent?.skills === true; const accessibleSkillIds = skillsCapabilityEnabled @@ -593,6 +616,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { }), codeEnvAvailable, fileSearchAvailable, + resolveWebSearchGrant, backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background), toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents), statefulSessionsAvailable: enabledCapabilities.has( @@ -673,6 +697,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { defaultActiveOnShare, codeEnvAvailable, fileSearchAvailable, + resolveWebSearchGrant, backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background), toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents), statefulSessionsAvailable: enabledCapabilities.has( @@ -1128,6 +1153,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { user: { ...createSafeUser(req.user), id: userId }, traceContext: { endpoint: EModelEndpoint.agents }, tenantId: principal.tenantId, + modelCallbacks: [terminalRunError.modelCallback], /** Bills subagent child-run model calls (reported outside the * streamEvents loop) into the same collectedUsage array. */ subagentUsageSink: createSubagentUsageSink(collectedUsage), diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 20578cffd9d..65b92bfec57 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -30,6 +30,7 @@ const { getAttachmentTitleText, createMCPRuntimeRequestBody, resolveRunCodeWorkspaces, + getSafeErrorText, isAgentEventRetentionActive, createAgentEventActorTurn, createAgentEventActorDetachedActionLifecycle, @@ -43,6 +44,8 @@ const { resolveAgentTurnExecutionPlan, logAgentMemorySnapshot, getCodeWorkspaceSelectionErrorDetails, + shouldPersistCodeWorkspaceInitializationError, + getFailedTurnTraceFields, } = require('@librechat/api'); const { disposeClient } = require('~/server/cleanup'); const { @@ -350,6 +353,7 @@ async function saveErrorTurn( errorText, liveUserMessage, liveResponseMessageId, + runCreated = false, sender, initialAgentId, }, @@ -465,9 +469,15 @@ async function saveErrorTurn( throw new Error('Failed user message could not be persisted'); } } + const langfuseTraceFields = await getFailedTurnTraceFields(req.config, { + messageId: errorMessageId, + runId: liveResponseMessageId, + runCreated, + }); const savedErrorMessage = await saveMessage( reqCtx, { + ...langfuseTraceFields, messageId: errorMessageId, conversationId, parentMessageId: errorParentMessageId, @@ -490,6 +500,7 @@ async function saveErrorTurn( const agentId = endpointOption?.agent_id ?? req.body?.agent_id; const chatProjectId = endpointOption?.chatProjectId ?? req.body?.chatProjectId; const seedConvo = isNewConvo || req.resolvedConversation === null; + const codeEnvironmentDecision = req._codeEnvironmentDecision; const convoFields = seedConvo ? { ...(endpoint != null && { endpoint }), @@ -501,6 +512,12 @@ async function saveErrorTurn( ...(endpointOption?.spec != null && { spec: endpointOption.spec }), ...(agentId != null && { agent_id: agentId }), ...(typeof chatProjectId === 'string' && chatProjectId.length > 0 && { chatProjectId }), + ...(codeEnvironmentDecision?.mode != null && { + codeEnvironmentMode: codeEnvironmentDecision.mode, + ...(codeEnvironmentDecision.codeWorkspaces != null && { + codeWorkspaces: codeEnvironmentDecision.codeWorkspaces, + }), + }), } : {}; await saveConvo( @@ -1486,6 +1503,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit const mcpRequestBody = createMCPRuntimeRequestBody({ messageId: preallocatedResponseMessageId, conversationId: effectiveConversationId, + codeEnvironmentMode: req.body.codeEnvironmentMode, codeWorkspaces: resolveRunCodeWorkspaces({ conversationId: effectiveConversationId, requestedSelections: req.body.codeWorkspaces, @@ -1919,6 +1937,22 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit }); startupTelemetry?.mark('client_initialized'); client = result.client; + const normalizedMCPRequestBody = createMCPRuntimeRequestBody({ + messageId: mcpRequestBody.messageId, + conversationId: mcpRequestBody.conversationId, + codeEnvironmentMode: req.body.codeEnvironmentMode, + codeWorkspaces: req.body.codeWorkspaces, + ...(Object.prototype.hasOwnProperty.call(mcpRequestBody, 'parentMessageId') && { + parentMessageId: mcpRequestBody.parentMessageId, + }), + }); + if (JSON.stringify(normalizedMCPRequestBody) !== JSON.stringify(mcpRequestBody)) { + await GenerationJobManager.updateMetadata( + streamId, + { mcpRequestBody: normalizedMCPRequestBody }, + jobCreatedAt, + ); + } if ( typeof client?.options?.agent?.id === 'string' && !isEphemeralAgentId(client.options.agent.id) @@ -3185,6 +3219,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit errorText: generationError, liveUserMessage: userMessage, liveResponseMessageId, + runCreated: client?.run != null, sender: client?.sender, initialAgentId: verifiedInitialAgentId, }), @@ -3288,7 +3323,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit ); }); } catch (error) { - logger.error('[ResumableAgentController] Initialization error:', error); + logger.error(`[ResumableAgentController] Initialization error: ${getSafeErrorText(error)}`); const initializationFailure = getInitializationFailure(error); const streamStarted = res.headersSent; try { @@ -3383,7 +3418,13 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit const initializationError = initializationFailure ? JSON.stringify(initializationFailure) : error.message || 'Failed to start generation'; - const completionPromise = streamStarted + const persistInitializationError = shouldPersistCodeWorkspaceInitializationError({ + streamStarted, + isNewConversation: isNewConvo, + failureCode: initializationFailure?.code, + hasValidatedDecision: req._codeEnvironmentDecision != null, + }); + const completionPromise = persistInitializationError ? GenerationJobManager.completeJob(streamId, initializationError, jobCreatedAt, { beforeErrorPublication: () => saveErrorTurn(req, { diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index bccf4d3ddec..b5da862f9cb 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -18,8 +18,6 @@ const { createAgentRunEnvelope, createAgentExecutionContext, createMCPRuntimeRequestBody, - getCodeWorkspaceSelections, - collectReachableAgents, buildAgentScopedContext, buildInlineMemoryContext, buildAgentContextAttachmentsByAgentId, @@ -84,6 +82,8 @@ const { executeAgentRun, waitForAgentExecutionWrites, resolveToolRoleGrants, + resolveConversationCodeEnvironmentDecision, + createTerminalRunErrorObserver, } = require('@librechat/api'); const { createResponsesToolEndCallback, @@ -124,7 +124,6 @@ const filterFilesByRemoteAgentAccess = (params) => filterFilesByAgentAccess({ ...params, resourceType: ResourceType.REMOTE_AGENT }); function handleExecutionError({ error, res, appConfig }) { - logger.error('[Responses API] Error:', getSafeErrorMetadata(error)); const protectionEnabled = hasModelBoundContentProtection( appConfig?.filters, appConfig?.messageFilter?.pii, @@ -449,7 +448,14 @@ async function saveResponseOutput( * @param {object} agent * @returns {Promise} */ -async function saveConversation(req, conversationId, agentId, agent, codeWorkspaces) { +async function saveConversation( + req, + conversationId, + agentId, + agent, + codeEnvironmentMode, + codeWorkspaces, +) { const title = resolveConversationTitle(req, agent?.name || 'Open Responses Conversation'); await db.saveConvo( { @@ -462,6 +468,7 @@ async function saveConversation(req, conversationId, agentId, agent, codeWorkspa conversationId, endpoint: EModelEndpoint.agents, agent_id: agentId, + codeEnvironmentMode, ...(codeWorkspaces !== undefined && { codeWorkspaces }), ...(title != null && { title }), model: agent?.model, @@ -622,6 +629,11 @@ const executeResponse = async (envelope, { req, res }) => { // Generate IDs const responseId = generateResponseId(); + const terminalRunError = createTerminalRunErrorObserver({ + logger, + responseMessageId: responseId, + source: '[Responses API]', + }); const context = createResponseContext(request, responseId); logger.debug( @@ -667,7 +679,10 @@ const executeResponse = async (envelope, { req, res }) => { onSettlementError: (error) => { logger.error('[Responses API] Failed to settle execution:', getSafeErrorMetadata(error)); }, - handleExecutionError: (error) => handleExecutionError({ error, res, appConfig }), + handleExecutionError: (error, signal) => { + terminalRunError.log(error, signal); + return handleExecutionError({ error, res, appConfig }); + }, execute: async (execution) => { if (request.previous_response_id != null) { if (typeof request.previous_response_id !== 'string') { @@ -697,11 +712,18 @@ const executeResponse = async (envelope, { req, res }) => { } } + const codeEnvironmentDecision = resolveConversationCodeEnvironmentDecision({ + conversationId, + requestedMode: request.code_environment_mode, + requestedSelections: request.code_workspaces, + conversation: req.resolvedConversation, + }); const parentMessageId = null; const mcpRequestBody = createMCPRuntimeRequestBody({ messageId: responseId, conversationId, - codeWorkspaces: request.code_workspaces ?? req.resolvedConversation?.codeWorkspaces, + codeEnvironmentMode: codeEnvironmentDecision.mode, + codeWorkspaces: codeEnvironmentDecision.codeWorkspaces, }); const agentsEConfig = appConfig?.endpoints?.[EModelEndpoint.agents]; const ordinaryToolCancellationEnabled = @@ -749,6 +771,7 @@ const executeResponse = async (envelope, { req, res }) => { listSkillsByAccess: skillDbMethods.listSkillsByAccess, listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills, getSkillByName: skillDbMethods.getSkillByName, + getRoleByName: db.getRoleByName, }; const enabledCapabilities = new Set(agentsEConfig?.capabilities); @@ -778,6 +801,12 @@ const executeResponse = async (envelope, { req, res }) => { * a tool the loader is about to drop. */ const fileSearchAvailable = fileSearchCapabilityEnabled && (await toolRoleGrants)?.fileSearch === true; + /** Called by `initializeAgent` only when an agent's built provider config + * turns native web search on. It reaches the initializer with `runtime` + * and no `req`, so this is what lets it join the grants memoized on this + * request instead of issuing its own read. */ + const resolveWebSearchGrant = async () => + (await resolveToolRoleGrants({ req, getRoleByName: db.getRoleByName })).webSearch; const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills); const ephemeralSkillsToggle = request.ephemeralAgent?.skills === true; const accessibleSkillIds = skillsCapabilityEnabled @@ -844,6 +873,7 @@ const executeResponse = async (envelope, { req, res }) => { }), codeEnvAvailable, fileSearchAvailable, + resolveWebSearchGrant, backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background), toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents), statefulSessionsAvailable: enabledCapabilities.has( @@ -924,6 +954,7 @@ const executeResponse = async (envelope, { req, res }) => { defaultActiveOnShare, codeEnvAvailable, fileSearchAvailable, + resolveWebSearchGrant, backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background), toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents), statefulSessionsAvailable: enabledCapabilities.has( @@ -1246,6 +1277,7 @@ const executeResponse = async (envelope, { req, res }) => { user: { ...createSafeUser(req.user), id: userId }, traceContext: { endpoint: EModelEndpoint.agents }, tenantId: principal.tenantId, + modelCallbacks: [terminalRunError.modelCallback], /** Bills subagent child-run model calls (reported outside the * streamEvents loop) into the same collectedUsage array. */ subagentUsageSink: createSubagentUsageSink(collectedUsage), @@ -1333,9 +1365,8 @@ const executeResponse = async (envelope, { req, res }) => { conversationId, agentId, agent, - getCodeWorkspaceSelections( - collectReachableAgents(runAgents).map((config) => config.codeExecutionContext), - ), + codeEnvironmentDecision.mode, + codeEnvironmentDecision.codeWorkspaces, ); // Save input messages @@ -1481,6 +1512,7 @@ const executeResponse = async (envelope, { req, res }) => { user: { ...createSafeUser(req.user), id: userId }, traceContext: { endpoint: EModelEndpoint.agents }, tenantId: principal.tenantId, + modelCallbacks: [terminalRunError.modelCallback], /** Bills subagent child-run model calls (reported outside the * streamEvents loop) into the same collectedUsage array. */ subagentUsageSink: createSubagentUsageSink(collectedUsage), @@ -1572,9 +1604,8 @@ const executeResponse = async (envelope, { req, res }) => { conversationId, agentId, agent, - getCodeWorkspaceSelections( - collectReachableAgents(runAgents).map((config) => config.codeExecutionContext), - ), + codeEnvironmentDecision.mode, + codeEnvironmentDecision.codeWorkspaces, ); await saveInputMessages(req, conversationId, inputMessages, agentId); diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js index 793e89568b8..284027e51d6 100644 --- a/api/server/controllers/agents/resume.js +++ b/api/server/controllers/agents/resume.js @@ -23,6 +23,7 @@ const { findDisallowedDecisions, findIncompleteDecisions, computeAgentRequestFingerprint, + computeLegacyAgentRequestFingerprint, captureAgentCheckpointGeneration, deleteAgentCheckpoint, buildAbortedResponseMetadata, @@ -917,7 +918,13 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle) // when the paused action carries a fingerprint (in-flight pauses from before this // change won't), and recomputed from the resume body's graph-determining fields. const pinnedFingerprint = pendingAction.requestFingerprint; - if (pinnedFingerprint && pinnedFingerprint !== computeAgentRequestFingerprint(req.body ?? {})) { + const pinnedFingerprintV2 = pendingAction.requestFingerprintV2; + const legacyFingerprint = computeLegacyAgentRequestFingerprint(req.body ?? {}); + const currentFingerprint = computeAgentRequestFingerprint(req.body ?? {}); + if ( + (pinnedFingerprint && pinnedFingerprint !== legacyFingerprint) || + (pinnedFingerprintV2 && pinnedFingerprintV2 !== currentFingerprint) + ) { return sendGenerationJson( res, 403, @@ -1822,6 +1829,8 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle) createMCPRuntimeRequestBody({ messageId: job.metadata.responseMessageId, conversationId: streamId, + codeEnvironmentMode: + req.body.codeEnvironmentMode ?? req.resolvedConversation?.codeEnvironmentMode, codeWorkspaces: req.body.codeWorkspaces ?? req.resolvedConversation?.codeWorkspaces, parentMessageId: job.metadata.userMessage?.messageId ?? Constants.NO_PARENT, }); diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index f728a5bd4d2..e34647cf2b7 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -38,6 +38,11 @@ const { isContentTraversalLimitError, resolveCanonicalFileReferences, reportLocatorTraversalFailure, + isActiveAgentWorkspaceConfiguration, + reconcileAgentWorkspaceDefault, + resolveAgentWorkspaceRestoreConfiguration, + shouldValidateAgentWorkspaceDefaultBinding, + validateAgentWorkspaceDefaultBinding, } = require('@librechat/api'); const { Time, @@ -480,13 +485,27 @@ const validateStatefulCodeEnvironment = ( environment, environmentId, environmentIdSelected = false, + workspaceId, + currentWorkspaceId, + currentEnvironmentId, ) => { + const configuredEnvironments = + req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments ?? []; + const workspaceValidation = validateAgentWorkspaceDefaultBinding({ + workspaceId, + environmentId, + currentWorkspaceId, + currentEnvironmentId, + environments: configuredEnvironments, + }); + if (!workspaceValidation.valid) { + res.status(400).json({ error: workspaceValidation.error }); + return false; + } if (enabled !== true && !environmentIdSelected) { return true; } if (environmentId != null) { - const configuredEnvironments = - req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments ?? []; const configuredEnvironment = configuredEnvironments.find( (configured) => configured.id === environmentId, ); @@ -780,6 +799,7 @@ const createAgentHandler = async (req, res) => { agentData.stateful_code_environment, agentData.code_environment_id, agentData.code_environment_id != null, + agentData.code_workspace_id, ) ) { return; @@ -1066,7 +1086,7 @@ const updateAgentHandler = async (req, res) => { _id, ...rest } = validatedData; - const updateData = removeNullishValues(rest); + let updateData = removeNullishValues(rest); if (codeEnvironmentIdField !== undefined) { updateData.code_environment_id = codeEnvironmentIdField; } @@ -1079,10 +1099,12 @@ const updateAgentHandler = async (req, res) => { updateData.stateful_code_sessions !== undefined || updateData.stateful_code_environment !== undefined || updateData.code_environment_id !== undefined; + const includesWorkspaceConfiguration = updateData.code_workspace_id !== undefined; const includesToolsConfiguration = Array.isArray(updateData.tools); const includesToolOptionsConfiguration = updateData.tool_options !== undefined; if ( includesStatefulConfiguration || + includesWorkspaceConfiguration || includesToolsConfiguration || includesToolOptionsConfiguration ) { @@ -1094,6 +1116,11 @@ const updateAgentHandler = async (req, res) => { const codeEnvironmentSelectionChanged = updateData.code_environment_id !== undefined && updateData.code_environment_id !== existingAgent.code_environment_id; + updateData = reconcileAgentWorkspaceDefault({ + update: updateData, + request: validatedData, + currentEnvironmentId: existingAgent.code_environment_id, + }); const statefulConfigurationChanged = (updateData.stateful_code_sessions !== undefined && (updateData.stateful_code_sessions === true) !== @@ -1106,7 +1133,20 @@ const updateAgentHandler = async (req, res) => { includesToolsConfiguration && updateData.tools.includes(Tools.execute_code) && existingAgent.tools?.includes(Tools.execute_code) !== true; - if (statefulConfigurationChanged || activatesCodeExecution) { + const effectiveCodeWorkspaceId = + updateData.code_workspace_id ?? existingAgent.code_workspace_id; + const selectsWorkspaceDefault = + includesWorkspaceConfiguration && + shouldValidateAgentWorkspaceDefaultBinding({ + workspaceId: effectiveCodeWorkspaceId, + environmentId: + updateData.code_environment_id === null + ? undefined + : (updateData.code_environment_id ?? existingAgent.code_environment_id), + currentWorkspaceId: existingAgent.code_workspace_id, + currentEnvironmentId: existingAgent.code_environment_id, + }); + if (statefulConfigurationChanged || selectsWorkspaceDefault || activatesCodeExecution) { const effectiveStatefulSessions = updateData.stateful_code_sessions ?? existingAgent.stateful_code_sessions; const effectiveStatefulEnvironment = @@ -1123,6 +1163,9 @@ const updateAgentHandler = async (req, res) => { effectiveStatefulEnvironment, effectiveCodeEnvironmentId, codeEnvironmentSelectionChanged, + effectiveCodeWorkspaceId, + existingAgent.code_workspace_id, + existingAgent.code_environment_id, ) ) { return; @@ -1422,12 +1465,15 @@ const duplicateAgentHandler = async (req, res) => { author: userId, }); if ( + isActiveAgentWorkspaceConfiguration(newAgentData) && !validateStatefulCodeEnvironment( req, res, newAgentData.stateful_code_sessions, newAgentData.stateful_code_environment, newAgentData.code_environment_id, + false, + newAgentData.code_workspace_id, ) ) { return; @@ -1829,6 +1875,7 @@ const getListAgentsHandler = async (req, res) => { limit, after: cursor, includeSkillConfig: true, + includeExecutionConfig: true, }); const agents = data?.data ?? []; @@ -2026,14 +2073,22 @@ const revertAgentVersionHandler = async (req, res) => { } const revertVersion = existingAgent.versions?.[version_index]; + const restoredWorkspaceConfiguration = revertVersion + ? resolveAgentWorkspaceRestoreConfiguration({ + version: revertVersion, + current: existingAgent, + }) + : undefined; if ( - revertVersion && + isActiveAgentWorkspaceConfiguration(restoredWorkspaceConfiguration) && !validateStatefulCodeEnvironment( req, res, - revertVersion.stateful_code_sessions, - revertVersion.stateful_code_environment, - revertVersion.code_environment_id, + restoredWorkspaceConfiguration.stateful_code_sessions, + restoredWorkspaceConfiguration.stateful_code_environment, + restoredWorkspaceConfiguration.code_environment_id, + false, + restoredWorkspaceConfiguration.code_workspace_id, ) ) { return; diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 91c351f795d..d96af401fc1 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -316,6 +316,76 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(await Agent.countDocuments()).toBe(0); }); + test('rejects a workspace default without an explicit attached environment', async () => { + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'default-vm', + name: 'Default VM', + type: 'attached', + baseURL: 'https://code.example.com/v1', + default: true, + }, + ], + }, + }, + }, + }; + mockReq.body = { + name: 'Unbound Workspace Agent', + provider: 'openai', + model: 'gpt-4', + code_workspace_id: 'project-a', + }; + + await createAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Code workspace defaults require an explicit attached code environment', + }); + expect(await Agent.countDocuments()).toBe(0); + }); + + test('rejects a workspace default for a managed environment', async () => { + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'managed-runtime', + name: 'Managed Runtime', + type: 'managed', + baseURL: 'https://code.example.com/v1', + }, + ], + }, + }, + }, + }; + mockReq.body = { + name: 'Managed Workspace Agent', + provider: 'openai', + model: 'gpt-4', + code_environment_id: 'managed-runtime', + code_workspace_id: 'project-a', + }; + + await createAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Code workspace defaults require an explicit attached code environment', + }); + expect(await Agent.countDocuments()).toBe(0); + }); + test('should block configured agent instruction content before persistence', async () => { mockReq.config = { filters: { @@ -1605,12 +1675,74 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(agentInDb.code_environment_id).toBeUndefined(); }); + test('allows a workspace-only update for an existing attached environment', async () => { + await Agent.updateOne({ id: existingAgentId }, { code_environment_id: 'attached-vm' }); + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'attached-vm', + name: 'Attached VM', + type: 'attached', + baseURL: 'https://bridge.example.com/v1', + }, + ], + }, + }, + }, + }; + mockReq.body = { code_workspace_id: 'project-a' }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).not.toHaveBeenCalledWith(400); + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(agentInDb.code_environment_id).toBe('attached-vm'); + expect(agentInDb.code_workspace_id).toBe('project-a'); + }); + + test('rejects a workspace-only update without an attached environment', async () => { + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'default-vm', + name: 'Default VM', + type: 'attached', + baseURL: 'https://bridge.example.com/v1', + default: true, + }, + ], + }, + }, + }, + }; + mockReq.body = { code_workspace_id: 'project-a' }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(agentInDb.code_workspace_id).toBeUndefined(); + }); + test('allows disabling stateful sessions after the configured environment is removed', async () => { await Agent.updateOne( { id: existingAgentId }, { stateful_code_sessions: true, code_environment_id: 'removed-vm', + code_workspace_id: 'project-a', }, ); mockReq.user.id = existingAgentAuthorId.toString(); @@ -1628,6 +1760,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => { mockReq.body = { stateful_code_sessions: false, code_environment_id: 'removed-vm', + code_workspace_id: 'project-a', }; await updateAgentHandler(mockReq, mockRes); @@ -1636,6 +1769,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => { const agentInDb = await Agent.findOne({ id: existingAgentId }); expect(agentInDb.stateful_code_sessions).toBe(false); expect(agentInDb.code_environment_id).toBe('removed-vm'); + expect(agentInDb.code_workspace_id).toBe('project-a'); }); test('restores the deployment-default code environment', async () => { @@ -1644,6 +1778,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => { { stateful_code_sessions: true, code_environment_id: 'attached-vm', + code_workspace_id: 'project-a', }, ); mockReq.user.id = existingAgentAuthorId.toString(); @@ -1673,6 +1808,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(mockRes.status).not.toHaveBeenCalledWith(400); const agentInDb = await Agent.findOne({ id: existingAgentId }); expect(agentInDb.code_environment_id).toBeUndefined(); + expect(agentInDb.code_workspace_id).toBe(''); }); test('clears a configured Git identity', async () => { @@ -2867,6 +3003,67 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(mockRes.json.mock.calls[0][0].agent.tool_options).toEqual({}); }); + test('duplicateAgentHandler preserves a disabled stale workspace binding', async () => { + const sourceAgent = await Agent.create({ + id: `agent_${uuidv4()}`, + name: 'Disabled BYOM Agent', + provider: 'openai', + model: 'gpt-4', + author: mockReq.user.id, + stateful_code_sessions: false, + code_environment_id: 'removed-vm', + code_workspace_id: 'project-a', + }); + jest.spyOn(db, 'getActions').mockResolvedValueOnce([]); + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { environments: [] }, + }, + }, + }; + mockReq.params.id = sourceAgent.id; + + await duplicateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(201); + expect(mockRes.json.mock.calls[0][0].agent).toEqual( + expect.objectContaining({ + stateful_code_sessions: false, + code_environment_id: 'removed-vm', + code_workspace_id: 'project-a', + }), + ); + }); + + test('duplicateAgentHandler rejects an active stale workspace binding', async () => { + const sourceAgent = await Agent.create({ + id: `agent_${uuidv4()}`, + name: 'Active BYOM Agent', + provider: 'openai', + model: 'gpt-4', + author: mockReq.user.id, + stateful_code_sessions: true, + code_environment_id: 'removed-vm', + code_workspace_id: 'project-a', + }); + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { environments: [] }, + }, + }, + }; + mockReq.params.id = sourceAgent.id; + + await duplicateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Code workspace defaults require an explicit attached code environment', + }); + }); + test('revertAgentVersionHandler removes restored programmatic options without Code Interpreter', async () => { const agent = await Agent.create({ id: `agent_${uuidv4()}`, @@ -2895,6 +3092,83 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(mockRes.json.mock.calls[0][0].tool_options).toEqual({}); }); + test('revertAgentVersionHandler restores a disabled stale workspace binding', async () => { + const agent = await Agent.create({ + id: `agent_${uuidv4()}`, + name: 'Current Agent', + provider: 'openai', + model: 'gpt-4', + author: mockReq.user.id, + versions: [ + { + name: 'Disabled Historical BYOM Agent', + provider: 'openai', + model: 'gpt-4', + stateful_code_sessions: false, + code_environment_id: 'removed-vm', + code_workspace_id: 'project-a', + }, + ], + }); + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { environments: [] }, + }, + }, + }; + mockReq.params.id = agent.id; + mockReq.body = { version_index: 0 }; + + await revertAgentVersionHandler(mockReq, mockRes); + + expect(mockRes.status).not.toHaveBeenCalledWith(400); + const persisted = await Agent.findOne({ id: agent.id }).lean(); + expect(persisted).toEqual( + expect.objectContaining({ + stateful_code_sessions: false, + code_environment_id: 'removed-vm', + code_workspace_id: 'project-a', + }), + ); + }); + + test('revertAgentVersionHandler rejects a stale binding that inherits active sessions', async () => { + const agent = await Agent.create({ + id: `agent_${uuidv4()}`, + name: 'Current Agent', + provider: 'openai', + model: 'gpt-4', + author: mockReq.user.id, + stateful_code_sessions: true, + versions: [ + { + name: 'Historical BYOM Agent', + provider: 'openai', + model: 'gpt-4', + code_environment_id: 'removed-vm', + code_workspace_id: 'project-a', + }, + ], + }); + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { environments: [] }, + }, + }, + }; + mockReq.params.id = agent.id; + mockReq.body = { version_index: 0 }; + + await revertAgentVersionHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Code workspace defaults require an explicit attached code environment', + }); + }); + test('revertAgentVersionHandler does not update unchanged tool options', async () => { const agent = await Agent.create({ id: `agent_${uuidv4()}`, @@ -3332,16 +3606,20 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(Object.keys(agent).sort()).toEqual( [ '_id', + 'agent_ids', 'author', 'avatar', 'category', 'conversation_starters', 'description', + 'edges', 'id', 'isEditable', 'is_promoted', 'name', + 'subagents', 'support_contact', + 'tools', 'updatedAt', ].sort(), ); @@ -3353,6 +3631,9 @@ describe('Agent Controllers - Mass Assignment Protection', () => { author: userA.toString(), category: 'general', is_promoted: true, + tools: ['execute_code'], + edges: [{ from: agentA1.id, to: agentA2.id }], + subagents: { enabled: true, agent_ids: [agentA2.id] }, }), ); }); diff --git a/api/server/controllers/assistants/chat.contentFilter.spec.js b/api/server/controllers/assistants/chat.contentFilter.spec.js index 52d569a6e8c..9b3e6301024 100644 --- a/api/server/controllers/assistants/chat.contentFilter.spec.js +++ b/api/server/controllers/assistants/chat.contentFilter.spec.js @@ -11,6 +11,7 @@ const mockRetrieveAssistant = jest.fn(); const mockListThreadMessages = jest.fn(); const mockGetConvo = jest.fn(); const mockGetFiles = jest.fn(); +const mockEncodeAndFormat = jest.fn(); const mockGetOpenAIClient = jest.fn().mockResolvedValue({ openai: { beta: { @@ -90,7 +91,7 @@ jest.mock('~/app/clients/prompts', () => ({ })); jest.mock('~/server/services/Files/images/encode', () => ({ - encodeAndFormat: jest.fn(), + encodeAndFormat: (...args) => mockEncodeAndFormat(...args), })); jest.mock('~/server/services/Runs', () => ({ @@ -135,6 +136,8 @@ jest.mock('./helpers', () => ({ const chatV1 = require('./chatV1'); const chatV2 = require('./chatV2'); +const { logger } = require('@librechat/data-schemas'); +const { ImageVisionTool } = require('librechat-data-provider'); describe.each([ ['v1', chatV1], @@ -157,6 +160,7 @@ describe.each([ }); mockGetFiles.mockReset().mockResolvedValue([]); mockGetConvo.mockReset().mockResolvedValue(null); + mockEncodeAndFormat.mockReset().mockResolvedValue({ files: [], image_urls: [] }); mockInitThread.mockReset(); closeHandler = undefined; req = { @@ -382,6 +386,41 @@ describe.each([ }); if (_version === 'v1') { + describe('V1 vision attachment failures', () => { + it('does not log a signed storage URL from an image encoding error', async () => { + const signedUrl = + 'https://minio.example.com/bucket/image.png?X-Amz-Credential=secret&X-Amz-Signature=signed'; + const failure = Object.assign(new Error(`NoSuchKey for ${signedUrl}`), { + statusCode: 404, + }); + mockRetrieveAssistant.mockResolvedValueOnce({ + id: 'asst-1', + instructions: 'Safe assistant', + tools: [{ type: 'function', function: { name: ImageVisionTool.function.name } }], + }); + req.body.endpointOption.attachments = Promise.resolve([ + { + source: 's3', + filepath: signedUrl, + storageKey: 'images/user/image.png', + }, + ]); + mockEncodeAndFormat.mockRejectedValueOnce(failure); + + await chatV1(req, res); + + expect(mockEncodeAndFormat).toHaveBeenCalled(); + const [message, ...metadata] = logger.error.mock.calls.find((call) => + String(call[0]).startsWith('[/assistants/chat/]'), + ); + expect(metadata).toEqual([]); + expect(message).toContain('NoSuchKey for https://minio.example.com/[redacted]'); + expect(message).not.toContain('X-Amz-Signature'); + expect(JSON.stringify(logger.error.mock.calls)).not.toContain(signedUrl); + expect(JSON.stringify(mockSendResponse.mock.calls)).not.toContain(signedUrl); + }); + }); + describe('V1 final conversation-file preflight', () => { beforeEach(() => { req.config.filters = { diff --git a/api/server/controllers/assistants/chatV1.js b/api/server/controllers/assistants/chatV1.js index 5b33daa7950..5db12b87007 100644 --- a/api/server/controllers/assistants/chatV1.js +++ b/api/server/controllers/assistants/chatV1.js @@ -6,6 +6,7 @@ const { countTokens, checkBalance, getBalanceConfig, + getSafeErrorText, getModelMaxTokens, getTransactionsConfig, ATTACHMENT_ONLY_TEXT, @@ -164,7 +165,7 @@ const chatV1 = async (req, res) => { } else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) { return sendResponse(req, res, messageData, error.message); } else { - logger.error('[/assistants/chat/]', error); + logger.error(`[/assistants/chat/] ${getSafeErrorText(error)}`); } if (!openai || !thread_id || !run_id) { diff --git a/api/server/index.js b/api/server/index.js index 6fc63714fee..73ce8e45d79 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -403,6 +403,7 @@ const startServer = async () => { app.use('/api/search', routes.search); app.use('/api/messages', routes.messages); app.use('/api/convos', routes.convos); + app.use('/api/traces', routes.traces); app.use('/api/presets', routes.presets); app.use('/api/projects', routes.projects); app.use('/api/prompts', routes.prompts); diff --git a/api/server/routes/__test-utils__/convos-route-mocks.js b/api/server/routes/__test-utils__/convos-route-mocks.js index b4e9270f615..0fdb419324e 100644 --- a/api/server/routes/__test-utils__/convos-route-mocks.js +++ b/api/server/routes/__test-utils__/convos-route-mocks.js @@ -90,6 +90,21 @@ module.exports = { } return Math.min(Math.max(limit, 1), max); }), + /** Mirrors the real whitelist and helpers so the route's sort normalization is exercised. */ + CONVERSATION_SORT_FIELDS: { + title: true, + createdAt: true, + updatedAt: true, + archivedAt: true, + }, + normalizeSortField: jest.fn((value, { fields, fallback }) => { + const raw = Array.isArray(value) ? value[0] : value; + return typeof raw === 'string' && fields[raw] === true ? raw : fallback; + }), + normalizeSortDirection: jest.fn((value, { fallback = 'desc' } = {}) => { + const raw = Array.isArray(value) ? value[0] : value; + return raw === 'asc' || raw === 'desc' ? raw : fallback; + }), resolveImportMaxFileSize: jest.fn(() => 262144000), createAxiosInstance: jest.fn(() => ({ get: jest.fn(), diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 529879ce9ae..0ef61228421 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -116,6 +116,7 @@ afterEach(() => { delete process.env.LANGFUSE_TRACING_ENABLED; delete process.env.LANGFUSE_SAMPLE_RATE; delete process.env.TENANT_ISOLATION_STRICT; + delete process.env.CODE_ENVIRONMENT_DECISION_VERSION; }); describe('GET /api/config', () => { @@ -350,8 +351,32 @@ describe('GET /api/config', () => { expect(response.body.modelSpecs).toEqual({ list: [{ name: 'test-spec' }] }); expect(response.body.balance).toEqual({ enabled: true, startBalance: 10000 }); expect(response.body.webSearch).toEqual({ searchProvider: 'tavily' }); + expect(response.body.codeEnvironmentDecisionVersion).toBeUndefined(); }); + it('advertises code environment decisions only after deployment-wide activation', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.CODE_ENVIRONMENT_DECISION_VERSION = '1'; + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.codeEnvironmentDecisionVersion).toBe(1); + }); + + it.each(['0', '2', '1.0', 'true'])( + 'does not advertise unsupported code environment decision version %s', + async (version) => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.CODE_ENVIRONMENT_DECISION_VERSION = version; + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.codeEnvironmentDecisionVersion).toBeUndefined(); + }, + ); + it('should strip private prompt fields from model spec presets', async () => { mockGetAppConfig.mockResolvedValue({ ...baseAppConfig, diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js index 535e5cd4407..b03940381b9 100644 --- a/api/server/routes/__tests__/convos.spec.js +++ b/api/server/routes/__tests__/convos.spec.js @@ -1872,6 +1872,40 @@ describe('Convos Routes', () => { }); }); + describe('GET / sort normalization', () => { + const { getConvosByCursor } = require('~/models'); + + beforeEach(() => { + getConvosByCursor.mockResolvedValue({ conversations: [], nextCursor: null }); + }); + + it('forwards a whitelisted sort field and direction', async () => { + const response = await request(app) + .get('/api/convos') + .query({ sortBy: 'title', sortDirection: 'asc' }); + + expect(response.status).toBe(200); + expect(getConvosByCursor).toHaveBeenCalledWith( + 'test-user-123', + expect.objectContaining({ sortBy: 'title', sortDirection: 'asc' }), + ); + }); + + /** An unknown field reaches `getConvosByCursor`, which throws on it, so forwarding + * a stale or hand-edited value would answer 500 instead of listing anything. */ + it('falls back to the default ordering for values it does not recognize', async () => { + const response = await request(app) + .get('/api/convos') + .query({ sortBy: 'DROP TABLE', sortDirection: 'sideways' }); + + expect(response.status).toBe(200); + expect(getConvosByCursor).toHaveBeenCalledWith( + 'test-user-123', + expect.objectContaining({ sortBy: 'updatedAt', sortDirection: 'desc' }), + ); + }); + }); + describe('POST /archive', () => { it('should archive a conversation successfully', async () => { const mockConversationId = 'conv-123'; diff --git a/api/server/routes/__tests__/messages-feedback.spec.js b/api/server/routes/__tests__/messages-feedback.spec.js index 2076c2a4c33..46d53a3ce06 100644 --- a/api/server/routes/__tests__/messages-feedback.spec.js +++ b/api/server/routes/__tests__/messages-feedback.spec.js @@ -135,6 +135,32 @@ describe('PUT /:conversationId/:messageId/feedback', () => { ); }); + it('scores the trace of the run a failed turn stands for', async () => { + updateMessage.mockImplementationOnce((userId, { messageId, feedback }) => + Promise.resolve({ + messageId, + conversationId: 'conversation-1', + endpoint: 'agents', + langfuseSampled: true, + langfuseDestinationIds: ['destination-1'], + langfuseRunId: 'run-1', + feedback, + }), + ); + + const response = await request(app) + .put('/api/messages/conversation-1/user-1_/feedback') + .send({ feedback: { rating: 'thumbsDown', tag: 'other' } }); + + expect(response.status).toBe(200); + expect(sendFeedbackScore).toHaveBeenCalledWith( + expect.objectContaining({ + traceId: 'trace-run-1', + metadata: expect.objectContaining({ messageId: 'user-1_' }), + }), + ); + }); + it.each([ ['an object tag', { rating: 'thumbsDown', tag: { key: 'inaccurate' } }], ['a tag for the opposite rating', { rating: 'thumbsUp', tag: 'inaccurate' }], diff --git a/api/server/routes/__tests__/messages-get.spec.js b/api/server/routes/__tests__/messages-get.spec.js index d25357bd120..b72c0973f48 100644 --- a/api/server/routes/__tests__/messages-get.spec.js +++ b/api/server/routes/__tests__/messages-get.spec.js @@ -51,6 +51,9 @@ jest.mock('@librechat/api', () => { }; return { + /** The real helper, without loading the rest of the package this suite mocks around. */ + withoutTraceRefs: jest.requireActual('../../../../packages/api/src/langfuse/trace.ts') + .withoutTraceRefs, createContentFilter: jest.fn(() => (req, res, next) => next()), inspectContent, extractChatContent, @@ -528,6 +531,9 @@ describe('message route conversation ownership filters', () => { encoding: 'claude', fading: { v: 1, budgetTokens: 1, masked: true }, }, + langfuseSampled: true, + langfuseDestinationIds: ['forged-destination'], + langfuseRunId: 'someone-elses-run', }); expect(response.status).toBe(201); @@ -546,6 +552,9 @@ describe('message route conversation ownership filters', () => { expect(saveMessage.mock.calls[0][1]).not.toHaveProperty('userSubmittedPaths'); expect(saveMessage.mock.calls[0][1]).not.toHaveProperty('userSubmittedMessageFieldPaths'); expect(saveMessage.mock.calls[0][1]).not.toHaveProperty('contextMeta'); + expect(saveMessage.mock.calls[0][1].langfuseSampled).toBe(false); + expect(saveMessage.mock.calls[0][1]).not.toHaveProperty('langfuseDestinationIds'); + expect(saveMessage.mock.calls[0][1]).not.toHaveProperty('langfuseRunId'); expect(response.body.messageId).toBe(savedMessage.messageId); expect(response.body).not.toHaveProperty('contextMeta'); expect(saveConvo).toHaveBeenCalledWith( diff --git a/api/server/routes/__tests__/messages-subagent-thread.spec.js b/api/server/routes/__tests__/messages-subagent-thread.spec.js index bb71adf098b..3c7ee69e759 100644 --- a/api/server/routes/__tests__/messages-subagent-thread.spec.js +++ b/api/server/routes/__tests__/messages-subagent-thread.spec.js @@ -6,6 +6,7 @@ const mockIsSubagentThreadWriteBlocked = jest.fn(); jest.mock('@librechat/agents', () => ({ sleep: jest.fn() })); jest.mock('@librechat/api', () => ({ + withoutTraceRefs: jest.fn((message) => message), createContentFilter: jest.fn(() => (_req, _res, next) => next()), unescapeLaTeX: jest.fn((value) => value), countTokens: jest.fn().mockResolvedValue(1), diff --git a/api/server/routes/__tests__/traces.spec.js b/api/server/routes/__tests__/traces.spec.js new file mode 100644 index 00000000000..1e202008448 --- /dev/null +++ b/api/server/routes/__tests__/traces.spec.js @@ -0,0 +1,173 @@ +const express = require('express'); +const request = require('supertest'); +const { createHash } = require('crypto'); + +const mockGetConvoOwnership = jest.fn(); +const mockGetConversationTraceRefs = jest.fn(); +const mockHasSampledTraceMessage = jest.fn(); +let mockUser; +let mockTraceViewer; + +jest.mock('~/models', () => ({ + getConvoOwnership: (...args) => mockGetConvoOwnership(...args), + getConversationTraceRefs: (...args) => mockGetConversationTraceRefs(...args), + hasSampledTraceMessage: (...args) => mockHasSampledTraceMessage(...args), +})); + +jest.mock('~/server/middleware', () => ({ + requireJwtAuth: (req, res, next) => { + if (!mockUser) { + return res.status(401).json({ message: 'Unauthorized' }); + } + req.user = mockUser; + next(); + }, +})); + +jest.mock('~/server/middleware/config/app', () => (req, _res, next) => { + req.config = { interfaceConfig: { traceViewer: mockTraceViewer } }; + next(); +}); + +const traceIdFor = (seed) => createHash('sha256').update(seed, 'utf8').digest('hex').slice(0, 32); + +const langfuseEnv = { + LANGFUSE_PUBLIC_KEY: 'pk-route', + LANGFUSE_SECRET_KEY: 'sk-route', + LANGFUSE_PROJECT_ID: 'route-project', + LANGFUSE_BASE_URL: 'https://langfuse.route.test', +}; + +describe('trace routes', () => { + let app; + let fetchSpy; + + beforeAll(() => { + Object.assign(process.env, langfuseEnv); + const tracesRouter = require('../traces'); + app = express(); + app.use('/api/traces', tracesRouter); + }); + + afterAll(() => { + for (const key of Object.keys(langfuseEnv)) { + delete process.env[key]; + } + }); + + beforeEach(() => { + mockUser = { id: 'owner', role: 'USER' }; + mockTraceViewer = { enabled: true }; + mockGetConvoOwnership.mockResolvedValue({ user: 'owner' }); + mockGetConversationTraceRefs.mockResolvedValue({ + firstMessageAt: new Date('2026-09-12T11:00:00.000Z'), + sampledMessages: [{ messageId: 'response-1' }], + }); + mockHasSampledTraceMessage.mockResolvedValue(true); + fetchSpy = jest.spyOn(global, 'fetch').mockImplementation( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'obs-root', + traceId: traceIdFor('response-1'), + startTime: '2026-09-12T11:30:00.000Z', + endTime: '2026-09-12T11:30:02.000Z', + parentObservationId: null, + type: 'AGENT', + name: 'AgentGraph', + level: 'DEFAULT', + }, + { + id: 'obs-foreign', + traceId: traceIdFor('other-response'), + startTime: '2026-09-12T11:31:00.000Z', + endTime: '2026-09-12T11:31:02.000Z', + parentObservationId: null, + type: 'AGENT', + name: 'AgentGraph', + }, + ], + meta: {}, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('requires authentication', async () => { + mockUser = undefined; + + const response = await request(app).get('/api/traces/convo-1/records'); + + expect(response.status).toBe(401); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("reads the owner's sampled traces from Langfuse without exposing other traces", async () => { + const availability = await request(app).get('/api/traces/convo-1/availability'); + const response = await request(app).get('/api/traces/convo-1/records'); + + expect(availability.body).toEqual({ available: true }); + expect(response.status).toBe(200); + expect(response.body.records.map(({ id }) => id)).toEqual(['obs-root']); + expect(response.body.records[0]).toMatchObject({ messageId: 'response-1', kind: 'agent' }); + expect(mockGetConvoOwnership).toHaveBeenCalledWith('owner', 'convo-1', null); + expect(mockHasSampledTraceMessage).toHaveBeenCalledWith({ + user: 'owner', + conversationId: 'convo-1', + tenantId: undefined, + destinationIds: [ + createHash('sha256').update('https://langfuse.route.test\nroute-project').digest('hex'), + ], + }); + expect(mockGetConversationTraceRefs).toHaveBeenCalledWith({ + user: 'owner', + conversationId: 'convo-1', + tenantId: undefined, + limit: 51, + }); + const [url, init] = fetchSpy.mock.calls[0]; + expect(new URL(url).origin).toBe('https://langfuse.route.test'); + expect(JSON.parse(new URL(url).searchParams.get('filter'))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ column: 'sessionId', operator: '=', value: 'convo-1' }), + expect.objectContaining({ column: 'traceId', operator: 'any of' }), + expect.objectContaining({ column: 'userId', operator: 'any of', value: ['owner'] }), + ]), + ); + expect(new Headers(init.headers).get('Authorization')).toBe( + `Basic ${Buffer.from('pk-route:sk-route').toString('base64')}`, + ); + expect(JSON.stringify(response.body)).not.toContain('sk-route'); + }); + + it("refuses another user's conversation before reading Langfuse", async () => { + mockGetConvoOwnership.mockResolvedValue(null); + + const availability = await request(app).get('/api/traces/convo-1/availability'); + const response = await request(app).get( + '/api/traces/convo-1/records/obs-root?message=response-1', + ); + + expect(availability.body).toEqual({ available: false }); + expect(response.status).toBe(404); + expect(mockGetConversationTraceRefs).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('stays off until the deployment enables the viewer', async () => { + mockTraceViewer = undefined; + + const response = await request(app).get('/api/traces/convo-1/records'); + + expect(response.status).toBe(404); + expect(response.body.errorCode).toBe('disabled'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 4f584b40c50..99ab46ad4ec 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -12,6 +12,7 @@ const { excludeHiddenModelSpecs, isFileSnapshotEnabled, getEndpointsDropParamsMap, + resolveCodeEnvironmentDecisionVersion, } = require('@librechat/api'); const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider'); const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas'); @@ -249,6 +250,9 @@ router.get('/', async function (req, res) { } const appConfig = await getAppConfig(getAppConfigOptionsFromUser(req.user)); + const codeEnvironmentDecisionVersion = resolveCodeEnvironmentDecisionVersion( + process.env.CODE_ENVIRONMENT_DECISION_VERSION, + ); const endpointsDropParamsMap = getEndpointsDropParamsMap(appConfig?.endpoints); @@ -309,6 +313,7 @@ router.get('/', async function (req, res) { langfuseConnectionAccess, insightsEnabled: isEnabled(process.env.ENABLE_INSIGHTS), compactionEnabled: appConfig?.summarization?.enabled !== false, + ...(codeEnvironmentDecisionVersion != null ? { codeEnvironmentDecisionVersion } : {}), ...(cloudFront ? { cloudFront } : {}), ...(rum ? { rum } : {}), fileUploadSseEnabled: isEnabled(process.env.FILE_UPLOAD_SSE_ENABLED), diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 04c9ec5d992..dc45fd6a3b9 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -5,6 +5,9 @@ const { reportLocatorTraversalFailure, isEnabled, normalizeLimit, + normalizeSortDirection, + normalizeSortField, + CONVERSATION_SORT_FIELDS, openCheckpointDeletion, waitForGenerationPersistence, createArchiveAllHandler, @@ -153,8 +156,11 @@ router.get('/', async (req, res) => { const pinned = isEnabled(req.query.pinned); const search = typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined; - const sortBy = req.query.sortBy || 'updatedAt'; - const sortDirection = req.query.sortDirection || 'desc'; + const sortBy = normalizeSortField(req.query.sortBy, { + fields: CONVERSATION_SORT_FIELDS, + fallback: 'updatedAt', + }); + const sortDirection = normalizeSortDirection(req.query.sortDirection); const projectId = Array.isArray(req.query.projectId) ? req.query.projectId[0] : req.query.projectId; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index c3b6c4bb8e7..7827559f112 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -28,6 +28,7 @@ const banner = require('./banner'); const search = require('./search'); const models = require('./models'); const convos = require('./convos'); +const traces = require('./traces'); const config = require('./config'); const agents = require('./agents'); const roles = require('./roles'); @@ -69,6 +70,7 @@ module.exports = { banner, agents, convos, + traces, search, config, models, diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index 756f8ff8cb1..6ac391c18d4 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -26,6 +26,7 @@ const { mergeUserSubmittedPaths, mergeUserSubmittedMessageFieldPaths, isContentFilterError, + withoutTraceRefs, } = require('@librechat/api'); const subagentThreadTaskStore = require('~/server/services/Endpoints/agents/subagentThreadStore'); const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update'); @@ -522,7 +523,8 @@ router.post('/:conversationId', storedMessageMutationMiddleware, async (req, res if (await rejectSubagentThreadWrite(req, res, req.params.conversationId)) { return; } - const message = { ...req.body, conversationId: req.params.conversationId }; + /** Trace sampling fields are ownership claims only the server writes. */ + const message = withoutTraceRefs({ ...req.body, conversationId: req.params.conversationId }); delete message.isUserSubmitted; delete message.userSubmittedPaths; delete message.userSubmittedMessageFieldPaths; @@ -716,7 +718,7 @@ router.put( // Best-effort: Assistants messages do not have deterministic AgentRun traces. if (!isAssistantsEndpoint(updatedMessage.endpoint)) { sendFeedbackScore({ - traceId: traceIdForMessage(messageId), + traceId: traceIdForMessage(updatedMessage.langfuseRunId ?? messageId), sampled: updatedMessage.langfuseSampled, destinationIds: updatedMessage.langfuseDestinationIds, feedback: updatedMessage.feedback, diff --git a/api/server/routes/traces.js b/api/server/routes/traces.js new file mode 100644 index 00000000000..b52869ed14f --- /dev/null +++ b/api/server/routes/traces.js @@ -0,0 +1,33 @@ +const express = require('express'); +const { + createTraceHandlers, + limiterCache, + createTraceReadLimiter, + createLangfuseTraceReader, + resolveLangfuseReadDestinations, +} = require('@librechat/api'); +const configMiddleware = require('~/server/middleware/config/app'); +const { requireJwtAuth } = require('~/server/middleware'); +const db = require('~/models'); + +const router = express.Router(); + +const handlers = createTraceHandlers({ + reader: createLangfuseTraceReader({ + getConversationTraceRefs: db.getConversationTraceRefs, + hasSampledTraceMessage: db.hasSampledTraceMessage, + resolveDestinations: resolveLangfuseReadDestinations, + fetch: (url, init) => fetch(url, init), + }), + getConvoOwnership: db.getConvoOwnership, +}); +const traceReadLimiter = createTraceReadLimiter({ + store: limiterCache('trace_viewer_user_limiter'), +}); + +router.use(requireJwtAuth, configMiddleware); +router.get('/:conversationId/availability', handlers.availability); +router.get('/:conversationId/records', traceReadLimiter, handlers.records); +router.get('/:conversationId/records/:recordId', traceReadLimiter, handlers.record); + +module.exports = router; diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 911b7ebdc4d..24d26b895da 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -1005,8 +1005,8 @@ const resendVerificationEmail = async (req) => { } catch (error) { logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`); return { - status: 500, - message: 'Something went wrong.', + status: 200, + message: genericVerificationMessage, }; } }; diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js index d6182e1334c..f9e239fef83 100644 --- a/api/server/services/AuthService.spec.js +++ b/api/server/services/AuthService.spec.js @@ -1227,6 +1227,26 @@ describe('resendVerificationEmail', () => { }), ); }); + + it('returns the generic response when delivery fails', async () => { + const user = { + _id: 'user-delivery-failure', + email: 'delivery-failure@example.com', + name: 'Delivery Failure', + }; + findUser.mockResolvedValue(user); + sendEmail.mockRejectedValue(new Error('mail transport unavailable')); + + const result = await resendVerificationEmail({ body: { email: user.email } }); + + expect(result).toEqual({ + status: 200, + message: 'Please check your email to verify your email address.', + }); + expect(logger.error).toHaveBeenCalledWith( + '[resendVerificationEmail] Error resending verification email: mail transport unavailable', + ); + }); }); describe('CloudFront cookie integration', () => { diff --git a/api/server/services/Config/__tests__/getCachedTools.spec.js b/api/server/services/Config/__tests__/getCachedTools.spec.js index dd5e231f023..efc92b41836 100644 --- a/api/server/services/Config/__tests__/getCachedTools.spec.js +++ b/api/server/services/Config/__tests__/getCachedTools.spec.js @@ -431,10 +431,25 @@ describe('MCP tool cache', () => { ); }); + it('reports the rotated generation so its caller can tell its own fence from a foreign one', async () => { + mockCache.set.mockResolvedValue(true); + mockCache.delete.mockResolvedValue(true); + + const published = await invalidateCachedTools({ userId: 'user1', serverName: 'github' }); + + expect(published).toEqual(expect.any(String)); + expect(mockCache.set).toHaveBeenNthCalledWith( + 2, + ToolCacheKeys.MCP_SERVER_GENERATION('user1', 'github'), + published, + expect.any(Number), + ); + }); + it('invalidates only the static global key for broad config changes', async () => { mockCache.delete.mockResolvedValue(true); - await invalidateCachedTools({ invalidateGlobal: true }); + await expect(invalidateCachedTools({ invalidateGlobal: true })).resolves.toBeUndefined(); expect(mockCache.delete).toHaveBeenCalledTimes(1); expect(mockCache.delete).toHaveBeenCalledWith(ToolCacheKeys.GLOBAL); diff --git a/api/server/services/Endpoints/agents/addedConvo.js b/api/server/services/Endpoints/agents/addedConvo.js index 8a9e10d92ca..1764359a219 100644 --- a/api/server/services/Endpoints/agents/addedConvo.js +++ b/api/server/services/Endpoints/agents/addedConvo.js @@ -216,6 +216,7 @@ const processAddedConvo = async ({ listSkillsByAccess: skillDbMethods.listSkillsByAccess, listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills, getSkillByName: skillDbMethods.getSkillByName, + getRoleByName: db.getRoleByName, }, ); diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index f5653ec8b8e..326a44b6b1b 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -3,6 +3,7 @@ const { createContentAggregator, GraphNodeKeys } = require('@librechat/agents'); const { resolveSender, resolveRunConversation, + resolveConversationCodeEnvironmentDecision, createConcurrencyLimiter, loadSkillStates, initializeAgent, @@ -23,11 +24,22 @@ const { getLazySubagentConfigId, resolveCodeExecutionContext, resolveCodeExecutionWorkspaceContext, + optsOutOfAttachedCodeEnvironment, + isImplicitStatefulCodeRouteAvailable, createStatefulCodeEnvironmentPolicyError, buildSubagentThreadTaskConfig, backgroundCompletionWakeupsEnabled, createLazyAgentHistoryResolver, resolveToolRoleGrants, + createChatRunFileBindings, + createAxiosInstance, + getCodeApiAuthHeaders, + getCodeExecutionBaseUrl, + getAuthorizedRunFileSnapshot, + encodeAndFormatDocuments, + encodeAndFormatAudios, + encodeAndFormatVideos, + extractFileContext, } = require('@librechat/api'); const { ResourceType, @@ -38,6 +50,7 @@ const { AgentCapabilities, normalizeServerName, Tools, + VisionModes, MAX_SUBAGENT_GRAPH_NODES, MAX_SUBAGENT_RUN_CONFIGS, isEphemeralAgentId, @@ -86,6 +99,10 @@ const { const { logViolation } = require('~/cache'); const db = require('~/models'); const { getAppConfig } = require('~/server/services/Config'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { encodeAndFormat } = require('~/server/services/Files/images/encode'); +const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process'); +const { determineFileType } = require('~/server/utils'); const SUBAGENT_GRAPH_LOAD_CONCURRENCY = 4; @@ -192,7 +209,7 @@ const initializeClient = async ({ /** The normal controller resolves this once for timestamp anchoring. Reuse * that trusted document for child-thread execution policy; resume and direct * callers fall back to the same owner-scoped lookup. */ - const runtimeRequestBody = requestBody ?? req.body; + let runtimeRequestBody = requestBody ?? req.body; const conversationId = runtimeRequestBody?.conversationId; const requestConversationPromise = resolveRunConversation({ request: req, @@ -346,6 +363,7 @@ const initializeClient = async ({ * }>} */ const agentToolContexts = new Map(); + let runFileBindings; const resolveMcpServerName = (toolName, agentId) => { if (typeof toolName !== 'string' || typeof agentId !== 'string') { return undefined; @@ -400,7 +418,7 @@ const initializeClient = async ({ if (trustedContext?.codeExecutionContext) { callbackMetadata.codeExecutionContext = trustedContext.codeExecutionContext; } - return artifactToolEndCallback(data, callbackMetadata); + return runFileBindings.deliverToolEnd(artifactToolEndCallback, data, callbackMetadata); }; /** @type {Map} */ const endpointTokenConfigByAgentId = new Map(); @@ -413,8 +431,15 @@ const initializeClient = async ({ runSignal: signal, foregroundRunId, ordinaryToolCancellation: ordinaryToolCancellationEnabled, - loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection, runSignal) => { - const ctx = agentToolContexts.get(agentId) ?? {}; + loadTools: async ( + toolNames, + agentId, + _configurable, + callerCapabilityProjection, + runSignal, + executionContext, + ) => { + const ctx = runFileBindings.getContext(agentId, executionContext) ?? {}; logger.debug(`[ON_TOOL_EXECUTE] ctx found: ${!!ctx.userMCPAuthMap}, agent: ${ctx.agent?.id}`); logger.debug(`[ON_TOOL_EXECUTE] toolRegistry size: ${ctx.toolRegistry?.size ?? 'undefined'}`); @@ -427,6 +452,10 @@ const initializeClient = async ({ requestBody: runtimeRequestBody, toolNames, agent: ctx.agent, + runFileCodeExecutionContext: runFileBindings.getCodeExecutionContext( + agentId, + executionContext, + ), toolRegistry: ctx.toolRegistry, callerCapabilityProjection, backgroundToolNames: ctx.backgroundToolNames, @@ -558,6 +587,25 @@ const initializeClient = async ({ ]); /** Preserve the owner-scoped fallback for loaders that share this request. */ req.resolvedConversation = requestConversation; + const codeEnvironmentDecision = resolveConversationCodeEnvironmentDecision({ + conversationId, + requestedMode: runtimeRequestBody?.codeEnvironmentMode, + requestedSelections: runtimeRequestBody?.codeWorkspaces, + conversation: requestConversation, + }); + /** Trusted, normalized pair used by every persistence path, including init failures. */ + req._codeEnvironmentDecision = codeEnvironmentDecision; + runtimeRequestBody = { + ...runtimeRequestBody, + codeEnvironmentMode: codeEnvironmentDecision.mode, + codeWorkspaces: codeEnvironmentDecision.codeWorkspaces, + }; + req.body.codeEnvironmentMode = codeEnvironmentDecision.mode; + if (codeEnvironmentDecision.codeWorkspaces == null) { + delete req.body.codeWorkspaces; + } else { + req.body.codeWorkspaces = codeEnvironmentDecision.codeWorkspaces; + } delete endpointOption.agent; /** The deployment switch AND the role grant. `initializeAgent` rebuilds @@ -686,6 +734,7 @@ const initializeClient = async ({ checkSessionsAlive, loadCodeApiKey, updateFile: db.updateFile, + getRoleByName: db.getRoleByName, }, ); @@ -776,6 +825,7 @@ const initializeClient = async ({ checkSessionsAlive, loadCodeApiKey, updateFile: db.updateFile, + getRoleByName: db.getRoleByName, }, // The callback fires during BFS, before the helper prunes agents // whose edges end up filtered. Don't populate `agentConfigs` here โ€” @@ -1006,8 +1056,21 @@ const initializeClient = async ({ }); const toLazySubagentMetadata = async (agent) => { + const configuredCodeEnvironments = + appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments; + const attachedEnvironmentOptOut = optsOutOfAttachedCodeEnvironment( + agent, + runtimeRequestBody, + configuredCodeEnvironments, + isImplicitStatefulCodeRouteAvailable( + process.env.CODE_ENVIRONMENT_DECISION_VERSION, + process.env.LIBRECHAT_CODE_BASEURL_STATEFUL, + ), + ); const lazyCodeEnvAvailable = - codeEnvAvailable === true && agent.tools?.includes(Tools.execute_code) === true; + codeEnvAvailable === true && + agent.tools?.includes(Tools.execute_code) === true && + !attachedEnvironmentOptOut; const statefulCodeSessions = statefulSessionsAvailable === true && lazyCodeEnvAvailable && @@ -1020,23 +1083,17 @@ const initializeClient = async ({ ) { throw createStatefulCodeEnvironmentPolicyError(statefulCodeEnvironment); } - const configuredCodeEnvironments = - appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments; - const hasConfiguredCodeEnvironment = - agent.code_environment_id != null || - configuredCodeEnvironments?.some((environment) => environment.default === true) === true; - const baseCodeExecutionContext = - lazyCodeEnvAvailable && (!statefulCodeSessions || hasConfiguredCodeEnvironment) - ? resolveCodeExecutionContext({ - statefulSessions: statefulCodeSessions, - environment: statefulCodeEnvironment, - environmentId: agent.code_environment_id, - environments: configuredCodeEnvironments, - userId, - agentId: agent.id, - conversationId, - }) - : undefined; + const baseCodeExecutionContext = lazyCodeEnvAvailable + ? resolveCodeExecutionContext({ + statefulSessions: statefulCodeSessions, + environment: statefulCodeEnvironment, + environmentId: agent.code_environment_id, + environments: configuredCodeEnvironments, + userId, + agentId: agent.id, + conversationId, + }) + : undefined; const codeExecutionContext = baseCodeExecutionContext ? await resolveCodeExecutionWorkspaceContext({ context: baseCodeExecutionContext, @@ -1193,6 +1250,11 @@ const initializeClient = async ({ agent, loadTools: createToolLoader(req, res, context.signal, streamId, true, jobCreatedAt), requestFiles, + authorizedRunFiles: getAuthorizedRunFileSnapshot({ + policy: appConfig.endpoints?.agents?.fileSharing, + agent: primaryConfig, + files: primaryConfig.currentRequestAttachments, + }), conversationId, parentMessageId, requestBody: runtimeRequestBody, @@ -1237,6 +1299,7 @@ const initializeClient = async ({ checkSessionsAlive, loadCodeApiKey, updateFile: db.updateFile, + getRoleByName: db.getRoleByName, }, ), context.signal, @@ -1648,6 +1711,54 @@ const initializeClient = async ({ } : null; + runFileBindings = createChatRunFileBindings({ + req, + contexts: agentToolContexts, + createdAt: jobCreatedAt, + requestFiles, + audit: (event) => logger.info('[agents:run-files]', event), + getInputs: () => primaryConfig.currentRequestAttachments, + loadFiles: (fileIds) => db.getRunFileCandidates(fileIds, req.user.tenantId), + filterFiles: filterFilesByAgentAccess, + listPublications: db.listRunArtifacts, + provisioning: { + provisionToCodeEnv, + provisionToVectorDB, + updateFile: db.updateFile, + updateCodeEnvRef: db.updateFileCodeEnvRef, + addEmbeddedEntity: db.addFileEmbeddedEntity, + }, + fileMethods: { + claimRunArtifactFile: db.claimRunArtifactFile, + publishRunArtifactFile: db.publishRunArtifactFile, + findRunArtifactFile: db.findRunArtifactFile, + }, + processCodeOutput, + snapshotAdapter: { + request: createAxiosInstance(), + getAuthHeaders: getCodeApiAuthHeaders, + getBaseURL: getCodeExecutionBaseUrl, + determineFileType, + }, + finalize: runPreviewFinalize, + getStrategyFunctions, + artifactPromises, + emitAttachment: createAttachmentEmitter({ res, streamId, jobCreatedAt }), + encoder: { + getAgent: (agentId) => agentToolContexts.get(agentId)?.fileEncodingAgent, + encodeImages: (request, files, params) => + encodeAndFormat(request, files, params, VisionModes.agents), + encodeDocuments: encodeAndFormatDocuments, + encodeAudios: encodeAndFormatAudios, + encodeVideos: encodeAndFormatVideos, + extractText: extractFileContext, + }, + }); + toolExecuteOptions.runFiles = runFileBindings.session; + toolExecuteOptions.provisionFiles = runFileBindings.wrapProvision( + toolExecuteOptions.provisionFiles, + ); + const eventHandlers = getDefaultHandlers({ res, contentParts, @@ -1697,6 +1808,7 @@ const initializeClient = async ({ endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents, subagentAggregatorsByToolCallId, subagentTasks, + runFiles: runFileBindings.session, /** Resolved endpoint token/pricing config so spending and cost reflect * configured rates for custom-endpoint agents instead of defaults. */ endpointTokenConfig: primaryConfig.endpointTokenConfig, diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 836268ab199..52241abda3e 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -896,12 +896,17 @@ describe('initializeClient โ€” subagent loading', () => { requestBody, }); - expect(mockInitializeAgent.mock.calls[0][0].requestBody).toBe(requestBody); - expect(agentClientArgs.mcpRequestBody).toBe(requestBody); + const normalizedRequestBody = mockInitializeAgent.mock.calls[0][0].requestBody; + expect(normalizedRequestBody).toEqual({ + ...requestBody, + codeEnvironmentMode: 'without_attached', + codeWorkspaces: undefined, + }); + expect(agentClientArgs.mcpRequestBody).toBe(normalizedRequestBody); await capturedToolExecuteOptions.loadTools([], PRIMARY_ID); expect(mockLoadToolsForExecution).toHaveBeenCalledWith( - expect.objectContaining({ requestBody }), + expect.objectContaining({ requestBody: normalizedRequestBody }), ); }); @@ -1460,20 +1465,74 @@ describe('initializeClient โ€” subagent loading', () => { const req = makeSubagentReq(); req.config.endpoints.agents.capabilities.push('execute_code', 'stateful_code_sessions'); req.config.endpoints.agents.statefulCodeSessions = { allowedEnvironments: ['user'] }; + process.env.CODE_ENVIRONMENT_DECISION_VERSION = '1'; + process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'https://stateful-code.example.com/v1/'; - await expect( - initializeClient({ + try { + await expect( + initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }), + ).rejects.toMatchObject({ + code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED, + }); + + expect(agentClientArgs).toBeUndefined(); + expect(mockInitializeAgent).toHaveBeenCalledTimes(1); + } finally { + delete process.env.CODE_ENVIRONMENT_DECISION_VERSION; + delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL; + } + }); + + it('binds a versioned implicit stateful route into lazy subagent metadata', async () => { + const subAgent = await createAgent({ + id: SUBAGENT_ID, + name: 'Implicit Stateful Subagent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['execute_code'], + stateful_code_sessions: true, + stateful_code_environment: 'user', + }); + await grantView(subAgent); + mockInitializeAgent.mockResolvedValue( + makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] }, + }), + ); + const req = makeSubagentReq(); + req.body.codeEnvironmentMode = 'without_attached'; + req.config.endpoints.agents.capabilities.push('execute_code', 'stateful_code_sessions'); + req.config.endpoints.agents.statefulCodeSessions = { allowedEnvironments: ['user'] }; + process.env.CODE_ENVIRONMENT_DECISION_VERSION = '1'; + process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'https://stateful-code.example.com/v1/'; + + try { + await initializeClient({ req, res: {}, signal: new AbortController().signal, endpointOption: makeEndpointOption(), - }), - ).rejects.toMatchObject({ - code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED, - }); + }); - expect(agentClientArgs).toBeUndefined(); - expect(mockInitializeAgent).toHaveBeenCalledTimes(1); + expect(agentClientArgs.agent.lazySubagentConfigs[0]).toEqual( + expect.objectContaining({ + codeExecutionContext: expect.objectContaining({ + baseUrl: 'https://stateful-code.example.com/v1', + executionProfile: 'stateful', + statefulSessions: true, + }), + }), + ); + } finally { + delete process.env.CODE_ENVIRONMENT_DECISION_VERSION; + delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL; + } }); it.each([ @@ -1582,7 +1641,8 @@ describe('initializeClient โ€” subagent loading', () => { signal: new AbortController().signal, endpointOption: makeEndpointOption(), }); - if (!registered) { + const defaultsWithoutAttached = source === 'resolved-null' || source === 'other-owner'; + if (!registered && !defaultsWithoutAttached) { await expect(initialization).rejects.toMatchObject({ code: ErrorTypes.CODE_WORKSPACE_UNAVAILABLE, }); @@ -1590,6 +1650,13 @@ describe('initializeClient โ€” subagent loading', () => { return; } await initialization; + if (defaultsWithoutAttached) { + expect(fetchSpy).not.toHaveBeenCalled(); + expect(agentClientArgs.mcpRequestBody).toEqual( + expect.objectContaining({ codeEnvironmentMode: 'without_attached' }), + ); + return; + } if (source === 'fallback' || source.startsWith('override')) { mockInitializeAgent.mockImplementationOnce(async (params) => { expect(params.req.resolvedConversation.codeWorkspaces).toEqual([ @@ -2019,6 +2086,19 @@ describe('initializeClient โ€” subagent loading', () => { 'tool_intents', 'stateful_code_sessions', ); + req.config.endpoints.agents.statefulCodeSessions = { + allowedEnvironments: ['user'], + environments: [ + { + id: 'managed-code', + name: 'Managed code', + type: 'managed', + baseURL: 'https://stateful-code.example.com/v1', + default: true, + }, + ], + }; + mockGetAppConfig.mockResolvedValue(req.config); const { userMCPAuthMap } = await initializeClient({ req, res: {}, diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js index 2558ba021c1..c8aa0f2b303 100644 --- a/api/server/services/Endpoints/agents/skillDeps.js +++ b/api/server/services/Endpoints/agents/skillDeps.js @@ -289,6 +289,13 @@ function buildSkillPrimedIdsByName(manualSkillPrimes, alwaysApplySkillPrimes) { function buildAgentToolContext({ agent, config }) { return { agent, + fileEncodingAgent: { + provider: config.provider, + endpoint: config.endpoint, + model_parameters: config.model_parameters, + imageDetail: config.imageDetail, + agentContextAttachments: config.agentContextAttachments, + }, /** Per-agent resolved endpoint token/pricing config. Retained here because * `agentToolContexts` is the one map that holds every agent โ€” including * pure subagents pruned from `agentConfigs` โ€” so usage can be priced with diff --git a/api/server/services/Files/Azure/crud.js b/api/server/services/Files/Azure/crud.js index 5944f40ab7d..1fea4db7d5c 100644 --- a/api/server/services/Files/Azure/crud.js +++ b/api/server/services/Files/Azure/crud.js @@ -6,6 +6,7 @@ const { logger } = require('@librechat/data-schemas'); const { deleteRagFile, assertRemoteFileURL, + getSafeErrorMetadata, getAzureContainerClient, getRemoteFileFetchMaxBytes, getRemoteFileFetchTimeoutMs, @@ -287,7 +288,7 @@ async function getAzureFileStream(_req, fileURL, { signal } = {}) { } return response.readableStreamBody; } catch (error) { - logger.error('[getAzureFileStream] Error getting blob stream:', error); + logger.error('[getAzureFileStream] Error getting blob stream:', getSafeErrorMetadata(error)); throw error; } } diff --git a/api/server/services/Files/Azure/crud.spec.js b/api/server/services/Files/Azure/crud.spec.js index c7e87b1d9b3..bafbb98430e 100644 --- a/api/server/services/Files/Azure/crud.spec.js +++ b/api/server/services/Files/Azure/crud.spec.js @@ -4,6 +4,7 @@ const mockGetAzureContainerClient = jest.fn(async () => ({ url: 'https://account.blob.core.windows.net/files', getBlockBlobClient: mockGetBlockBlobClient, })); +const mockGetSafeErrorMetadata = jest.fn(() => ({ type: 'Error', status: 403 })); jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn() }, @@ -12,12 +13,14 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('@librechat/api', () => ({ deleteRagFile: jest.fn(), assertRemoteFileURL: jest.fn((url) => url), + getSafeErrorMetadata: (...args) => mockGetSafeErrorMetadata(...args), getAzureContainerClient: (...args) => mockGetAzureContainerClient(...args), getRemoteFileFetchMaxBytes: jest.fn(() => 1024), getRemoteFileFetchTimeoutMs: jest.fn(() => 1000), assertRemoteFileContentLength: jest.fn(), })); +const { logger } = require('@librechat/data-schemas'); const { getAzureFileStream } = require('./crud'); describe('getAzureFileStream', () => { @@ -54,4 +57,22 @@ describe('getAzureFileStream', () => { expect(mockGetAzureContainerClient).toHaveBeenCalledWith(); expect(mockGetBlockBlobClient).toHaveBeenCalledWith('uploads/user/report one.pdf'); }); + + it('logs bounded metadata without the signed blob URL', async () => { + const signedUrl = + 'https://account.blob.core.windows.net/files/uploads/user/report.pdf?sig=secret'; + const failure = Object.assign(new Error(`Request failed for ${signedUrl}`), { + statusCode: 403, + }); + mockDownload.mockRejectedValue(failure); + + await expect(getAzureFileStream({}, signedUrl)).rejects.toBe(failure); + + expect(mockGetSafeErrorMetadata).toHaveBeenCalledWith(failure); + expect(logger.error).toHaveBeenCalledWith('[getAzureFileStream] Error getting blob stream:', { + type: 'Error', + status: 403, + }); + expect(JSON.stringify(logger.error.mock.calls)).not.toContain(signedUrl); + }); }); diff --git a/api/server/services/Files/Code/__tests__/process-traversal.spec.js b/api/server/services/Files/Code/__tests__/process-traversal.spec.js index ec28e907abc..99404f54046 100644 --- a/api/server/services/Files/Code/__tests__/process-traversal.spec.js +++ b/api/server/services/Files/Code/__tests__/process-traversal.spec.js @@ -5,6 +5,7 @@ jest.mock('@librechat/data-schemas', () => ({ })); jest.mock('@librechat/agents', () => ({ + ...jest.requireActual('@librechat/agents'), getCodeBaseURL: jest.fn(() => 'http://localhost:8000'), })); @@ -20,6 +21,7 @@ jest.mock('@librechat/api', () => { const http = require('http'); const https = require('https'); return { + processCodeOutput: jest.requireActual('@librechat/api').processCodeOutput, resolveDownloadPath: (file) => file.storageKey || file.filepath, logAxiosError: jest.fn(), getBasePath: jest.fn(() => ''), @@ -47,7 +49,7 @@ jest.mock('@librechat/api', () => { withTimeout: async (promise) => promise, /* These traversal cases all use non-office filenames โ€” keep the * inline (non-finalize) path so existing assertions on a single - * createFile call hold. */ + * commitCodeFile call hold. */ hasOfficeHtmlPath: jest.fn(() => false), /* Identity-helper stub mirroring `packages/api/src/files/code/identity.ts`. * `processCodeOutput` calls this for every output download URL; @@ -78,6 +80,7 @@ jest.mock('~/models', () => ({ getFiles: jest.fn().mockResolvedValue([]), updateFile: jest.fn(), claimCodeFile: jest.fn().mockResolvedValue({ file_id: 'mock-uuid', usage: 0 }), + commitCodeFile: jest.fn().mockResolvedValue(true), })); const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/user123/mock-uuid__output.csv'); @@ -105,7 +108,7 @@ jest.mock('~/server/services/Files/retention', () => ({ })); const { getRetentionExpiry } = require('~/server/services/Files/retention'); -const { createFile } = require('~/models'); +const { commitCodeFile } = require('~/models'); const { processCodeOutput } = require('../process'); const baseParams = { @@ -151,7 +154,7 @@ describe('processCodeOutput path traversal protection', () => { mockSanitizeArtifactPath.mockReturnValueOnce('safe-output.csv'); await processCodeOutput({ ...baseParams, name: 'unsafe/../../output.csv' }); - const fileArg = createFile.mock.calls[0][0]; + const fileArg = commitCodeFile.mock.calls[0][0]; expect(fileArg.filename).toBe('safe-output.csv'); expect(fileArg.tenantId).toBe('tenantA'); }); @@ -173,7 +176,7 @@ describe('processCodeOutput path traversal protection', () => { await processCodeOutput({ ...baseParams, name: '../../../chart.png' }); expect(mockSanitizeArtifactPath).toHaveBeenCalledWith('../../../chart.png'); - const fileArg = createFile.mock.calls[0][0]; + const fileArg = commitCodeFile.mock.calls[0][0]; expect(fileArg.filename).toBe('safe-chart.png'); expect(fileArg.tenantId).toBe('tenantA'); }); diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index f42da0e77ce..ed800aaf37a 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1,5 +1,4 @@ const path = require('path'); -const { v4 } = require('uuid'); const { logger } = require('@librechat/data-schemas'); const { getCodeBaseURL } = require('@librechat/agents'); const { @@ -24,6 +23,7 @@ const { extractCodeArtifactText, extractCodeArtifactRawText, extractCodeArtifactInspectionText, + prepareCodeOutputBufferForInspection, getBoundedCodeOutputByteLimit, getExtractedTextFormat, getStorageMetadata, @@ -38,16 +38,14 @@ const { CODE_OUTPUT_PREFLIGHT_MAX_BYTES, CODE_OUTPUT_PREFLIGHT_MAX_COUNT, normalizeArtifactDeliveryFailure, + processCodeOutput: processCodeOutputWithDeps, resolveDownloadPath, } = require('@librechat/api'); const { Tools, megabyte, - fileConfig, FileContext, FileSources, - imageExtRegex, - inferMimeType, EToolResources, EModelEndpoint, ErrorTypes, @@ -56,7 +54,7 @@ const { getEndpointFileConfig, } = require('librechat-data-provider'); const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); -const { createFile, getFiles, updateFile, claimCodeFile } = require('~/models'); +const { getFiles, updateFile, claimCodeFile, commitCodeFile } = require('~/models'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { convertImage } = require('~/server/services/Files/images/convert'); const { getRetentionExpiry } = require('~/server/services/Files/retention'); @@ -315,66 +313,16 @@ const prepareCodeOutputForInspection = async ({ executionProfile, executionRouteKey, }); - const safeName = sanitizeArtifactPath(name); - const fallbackType = inferMimeType(name, '') || 'application/octet-stream'; - if (!inspectContent) { - return { - buffer, - file: { - name, - filename: safeName, - type: fallbackType, - }, - }; - } - if (buffer.length > fileSizeLimit) { - return { - buffer, - extractedTextComplete: false, - file: { - name, - filename: safeName, - type: fallbackType, - }, - }; - } - - const detectedType = await determineFileType(buffer, true); - const detectedMimeType = detectedType?.mime?.toLowerCase(); - if (detectedMimeType?.startsWith('image/')) { - return { - buffer, - extractedTextComplete: false, - file: { - name, - filename: safeName, - type: detectedMimeType, - }, - }; - } - - const leafName = path.basename(safeName); - const unknownText = detectedType == null ? extractCodeArtifactRawText(buffer, 'utf8-text') : null; - const mimeType = unknownText != null ? 'text/plain' : (detectedMimeType ?? fallbackType); - const category = unknownText != null ? 'utf8-text' : classifyCodeArtifact(leafName, mimeType); - const content = unknownText ?? extractCodeArtifactRawText(buffer, category); - const extractedText = await extractCodeArtifactInspectionText( + return prepareCodeOutputBufferForInspection({ buffer, - leafName, - mimeType, - category, - ); - return { - buffer, - extractedTextComplete: extractedText.complete, - file: { - name, - filename: safeName, - type: mimeType, - content: content ?? undefined, - extractedText: extractedText.text ?? undefined, - }, - }; + name, + fileSizeLimit, + inspectContent, + determineFileType, + classify: classifyCodeArtifact, + extractRawText: extractCodeArtifactRawText, + extractInspectionText: extractCodeArtifactInspectionText, + }); }; /** @@ -636,490 +584,28 @@ const runPreviewFinalize = ({ finalize, fileId, previewRevision, onResolved }) = }); }; -/** - * Process code execution output files โ€” downloads and saves both images - * and non-image files. All files are saved to local storage with - * `codeEnvRef` metadata for code env re-upload. - * - * Returns a two-part shape so callers can ship the attachment to the - * client immediately and run preview extraction in the background: - * - `file`: persisted metadata (file is on disk, downloadable, and - * has `status: 'pending'` if a preview is still being rendered). - * - `finalize` (optional): a thunk returning the deferred preview - * result promise. Present only when an inline HTML preview is - * expected (office buckets โ€” DOCX/XLSX/XLS/ODS/CSV/PPTX). Caller - * decides whether to await or fire-and-forget. - * - * Existing fallback paths (size limit, missing storage strategy, error - * catch) return `{ file }` with no `finalize` โ€” there's nothing to - * extract. - * - * @param {ServerRequest} params.req - The Express request object. - * @param {string} params.id - The file ID from the code environment. - * @param {string} params.name - The filename. - * @param {string} params.toolCallId - The tool call ID that generated the file. - * @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. - * @param {string} [params.executionRouteKey] - Trusted deployment-local route identity. - * @param {string} [params.bridgeWorkerId] - Trusted bridge worker selected for this execution. - * @param {Buffer} [params.preparedBuffer] - Bytes downloaded during a - * no-write content inspection preflight. - * @param {boolean} [params.downloadFallback] - Return the bounded download - * fallback without downloading the generated bytes again. - * @returns {Promise<{ file: MongoFile & { messageId: string, toolCallId: string }, finalize?: () => Promise }>} - */ -const processCodeOutput = async ({ - req, - id, - name, - toolCallId, - conversationId, - messageId, - session_id, - agentId, - freshClaimAfter, - codeApiBaseUrl, - executionProfile = 'default', - executionRouteKey = executionProfile, - bridgeWorkerId, - preparedBuffer, - downloadFallback, -}) => { - const appConfig = req.config; - const currentDate = new Date(); - const fileExt = path.extname(name).toLowerCase(); - const isImage = fileExt && imageExtRegex.test(name); - - const { endpointFileConfig, fileSizeLimit } = getCodeOutputFileSettings(req); - - try { - const formattedDate = currentDate.toISOString(); - if (downloadFallback === true) { - return { - file: createDownloadFallback({ - id, - name, - agentId, - messageId, - toolCallId, - session_id, - conversationId, - executionProfile, - executionRouteKey, - expiresAt: currentDate.getTime() + 86400000, - }), - }; - } - const retentionExpiryPromise = getRetentionExpiry(req); - const buffer = - preparedBuffer ?? - (await downloadCodeOutputBuffer({ - req, - id, - session_id, - maxBytes: fileSizeLimit, - codeApiBaseUrl, - executionProfile, - bridgeWorkerId, - })); - - // Enforce file size limit - if (buffer.length > fileSizeLimit) { - logger.warn( - `[processCodeOutput] File "${name}" (${(buffer.length / megabyte).toFixed(2)} MB) exceeds size limit of ${(fileSizeLimit / megabyte).toFixed(2)} MB, falling back to download URL`, - ); - return { - file: createDownloadFallback({ - id, - name, - agentId, - messageId, - toolCallId, - session_id, - conversationId, - executionProfile, - executionRouteKey, - expiresAt: currentDate.getTime() + 86400000, - }), - }; - } - - /* Code-output files belong to the user who ran the execution. - * SessionKey on codeapi will be `:user:` for these, - * so cache and access stay user-private. */ - const codeEnvRef = { - kind: 'user', - id: req.user.id, - storage_session_id: session_id, - file_id: id, - executionProfile, - ...(executionRouteKey !== executionProfile ? { executionRouteKey } : {}), - }; - - /* `safeName` keeps the directory structure (`a/b/file.txt` -> `a/b/file.txt`) - * so the next prime() can place the file at the same nested path in the - * sandbox; flattening would re-create the bug where every nested artifact - * collapsed into the root and read_file calls 404'd. The flat-form - * storage key is composed below once `file_id` is known so we can cap - * the total length at filesystem NAME_MAX. */ - const safeName = sanitizeArtifactPath(name); - if (safeName !== name) { - logger.warn( - `[processCodeOutput] Filename sanitized: "${name}" -> "${safeName}" | conv=${conversationId}`, - ); - } - - /** - * Atomically claim a file_id for this (filename, conversationId, context) tuple. - * Uses $setOnInsert so concurrent calls for the same filename converge on - * a single record instead of creating duplicates (TOCTOU race fix). - * - * Claim by `safeName` (not raw `name`) so the claim and the eventual - * `createFile` agree on the filename column โ€” otherwise weird inputs - * (e.g. `"proj name/file@v1.txt"`) would claim under the raw name and - * then write under the sanitized one, leaving the claim row orphaned. - */ - /** - * Dispatch-order stamp persisted with every write AND every claim insert - * (foreground writes dispatch โ‰ˆ now): the out-of-order guard below - * compares WRITER dispatch order, not wall-clock write time โ€” an older - * task writing late must not make a newer task's harvest look stale, and - * a freshly claimed row must carry its claimant's stamp before the - * content write lands. - */ - const sourceDispatchedAt = freshClaimAfter ?? Date.now(); - - const newFileId = v4(); - const claimed = await claimCodeFile({ - filename: safeName, - conversationId, - file_id: newFileId, - user: req.user.id, - tenantId: req.user.tenantId, - sourceDispatchedAt, - }); - const file_id = claimed.file_id; - const isUpdate = file_id !== newFileId; - - /** - * Out-of-order guard for detached (background) harvests: when the claimed - * row's last writer was dispatched AFTER this task (`freshClaimAfter` = - * this task's dispatch time), a newer run owns this filename slot. The - * `(filename, conversationId)` unique index means the stale bytes have - * nowhere else to live, so skip this file rather than overwrite fresh - * content โ€” the harvest's stdout patch still lands, only the superseded - * attachment is omitted. Falls back to `updatedAt` for rows written - * before the stamp existed (the claim itself is timestamp-neutral). - */ - const lastWriterDispatchedAt = - claimed.metadata?.sourceDispatchedAt ?? - (claimed.updatedAt != null ? new Date(claimed.updatedAt).getTime() : null); - if (isUpdate && freshClaimAfter != null && lastWriterDispatchedAt > freshClaimAfter) { - logger.warn( - `[processCodeOutput] Skipping stale background output "${safeName}" (${file_id}): a newer run owns this filename`, - ); - return null; - } - - if (isUpdate) { - logger.debug( - `[processCodeOutput] Updating existing file "${safeName}" (${file_id}) instead of creating duplicate`, - ); - } - - /** - * Background harvests commit through a CONDITIONAL write: the ownership - * predicate (last writer's dispatch stamp not newer than ours) is part of - * the update's filter, so check and write are one atomic operation โ€” a - * stale harvest's commit simply misses and its attachment is skipped. - * The row always exists here (the claim inserted it), so the non-upsert - * `updateFile` matches `createFile(data, true)` semantics ($set + TTL - * unset). Bytes a loser may have already uploaded to the shared storage - * key are a narrow residual that per-file locking would be needed to - * close. Foreground writes keep the unconditional `createFile` path. - */ - const commitCodeFile = async (fileData) => { - if (freshClaimAfter == null) { - await createFile(fileData, true); - return true; - } - const committed = await updateFile(fileData, { - $or: [ - { 'metadata.sourceDispatchedAt': { $exists: false } }, - { 'metadata.sourceDispatchedAt': { $lte: sourceDispatchedAt } }, - ], - }); - if (!committed) { - logger.warn( - `[processCodeOutput] Skipping stale background output "${safeName}" (${file_id}): a newer run owns this filename`, - ); - return false; - } - return true; - }; - - /** - * Preserve the original `messageId` on update. Each `processCodeOutput` - * call would otherwise overwrite it with the current run's run id, which - * decouples the file from the assistant message that originally created - * it. `getCodeGeneratedFiles` filters by `messageId IN `, so a - * stale id (e.g. from a later regeneration / failed re-read attempt) - * 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; - const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`); - const filepath = usage > 1 ? `${_file.filepath}?v=${Date.now()}` : _file.filepath; - const storageMetadata = getStorageMetadata({ - filepath: _file.filepath, - source: appConfig.fileStrategy, - storageKey: _file.storageKey, - storageRegion: _file.storageRegion, - }); - const file = { - ..._file, - filepath, - ...storageMetadata, - file_id, - messageId: persistedMessageId, - usage, - filename: safeName, - conversationId, - executionProfile, - user: req.user.id, - tenantId: req.user.tenantId, - type: `image/${appConfig.imageOutputType}`, - createdAt: isUpdate ? claimed.createdAt : formattedDate, - updatedAt: formattedDate, - source: appConfig.fileStrategy, - context: FileContext.execute_code, - metadata: codeEnvMetadata, - ...(await retentionExpiryPromise), - }; - if (!(await commitCodeFile(file))) { - return null; - } - return { file: Object.assign(file, { messageId, toolCallId, agentId }) }; - } - - const { saveBuffer } = getStrategyFunctions(appConfig.fileStrategy); - if (!saveBuffer) { - logger.warn( - `[processCodeOutput] saveBuffer not available for strategy ${appConfig.fileStrategy}, falling back to download URL`, - ); - return { - file: createDownloadFallback({ - id, - name, - agentId, - messageId, - toolCallId, - session_id, - conversationId, - executionProfile, - executionRouteKey, - expiresAt: currentDate.getTime() + 86400000, - }), - }; - } - - const detectedType = await determineFileType(buffer, true); - const mimeType = detectedType?.mime || inferMimeType(name, '') || 'application/octet-stream'; - - /** Check MIME type support - for code-generated files, we're lenient but log unsupported types */ - const isSupportedMimeType = fileConfig.checkType( - mimeType, - endpointFileConfig.supportedMimeTypes, - ); - if (!isSupportedMimeType) { - logger.warn( - `[processCodeOutput] File "${name}" has unsupported MIME type "${mimeType}", proceeding with storage but may not be usable as tool resource`, - ); - } - - /* Compose the storage key here, after `file_id` is known, so the - * `flattenArtifactPath` cap budget can be calculated against the - * actual prefix length. The full key has to fit in one filesystem - * path component (NAME_MAX = 255 on most filesystems); without this - * cap, deeply-nested artifact paths whose individual segments were - * within bounds can still produce a flat form that overflows once - * `${file_id}__` is prepended, causing `ENAMETOOLONG` inside - * saveBuffer and falling back to a download URL. The 255 figure is - * the conservative cross-platform NAME_MAX (Linux ext4, NTFS, APFS). - */ - const NAME_MAX = 255; - const flatName = flattenArtifactPath(safeName, NAME_MAX - file_id.length - 2); - const fileName = `${file_id}__${flatName}`; - const filepath = await saveBuffer({ - userId: req.user.id, - buffer, - fileName, - basePath: 'uploads', - tenantId: req.user.tenantId, - }); - const storageMetadata = getStorageMetadata({ - filepath, - source: appConfig.fileStrategy, - }); - - /* `classifyCodeArtifact` and `extractCodeArtifactText` make - * extension/bare-name decisions on the input string. With the - * path-preserving sanitizer they can now receive a nested path like - * `reports.v1/Makefile`, which the classifier's `extensionOf` reads - * as `v1/Makefile` (the slice after the dot in the directory name) - * and the bare-name branch rejects because it sees a `.` anywhere in - * the string. Result: extensionless artifacts under dotted folders - * (Makefile, Dockerfile, etc.) get misclassified as `other` and - * skip text extraction. Pass the basename so classification matches - * what it would have gotten with the old flat-name flow. */ - const leafName = path.basename(safeName); - const category = classifyCodeArtifact(leafName, mimeType); - - /* Office-bucket files (DOCX/XLSX/XLS/ODS/CSV/PPTX) route through - * `bufferToOfficeHtml` which is CPU-heavy. Persist the record now - * with `status: 'pending'` and `text: null` so the agent's response - * isn't blocked, then return a `finalize` thunk the caller can run - * in the background. Non-office files have cheap or no extraction - * โ€” run it inline so the caller gets a fully-resolved record - * without juggling a finalize step. */ - const expectsPreview = hasOfficeHtmlPath(leafName, mimeType); - - const baseFile = { - file_id, - filepath, - ...storageMetadata, - messageId: persistedMessageId, - object: 'file', - filename: safeName, - type: mimeType, - conversationId, - user: req.user.id, - tenantId: req.user.tenantId, - bytes: buffer.length, - updatedAt: formattedDate, - metadata: codeEnvMetadata, - source: appConfig.fileStrategy, - context: FileContext.execute_code, - usage: isUpdate ? (claimed.usage ?? 0) + 1 : 1, - createdAt: isUpdate ? claimed.createdAt : formattedDate, - ...(await retentionExpiryPromise), - }; - - if (expectsPreview) { - /* Persist with `status: 'pending'` and explicit - * `text: null` / `textFormat: null` so an update that previously - * had cached text gets cleared. The deferred finalize transitions - * to 'ready' (with text/textFormat) or 'failed' (with - * previewError). - * - * `previewRevision` is a fresh UUID stamped on every emit. The - * deferred finalize's `updateFile` is conditional on this โ€” if - * a newer turn (cross-turn filename reuse) has rotated the - * revision before this render finishes, the stale render is - * silently discarded rather than overwriting the newer record. - * (Codex P1 review on PR #12957.) */ - const previewRevision = v4(); - const file = { - ...baseFile, - text: null, - textFormat: null, - status: 'pending', - previewError: null, - previewRevision, - }; - if (!(await commitCodeFile(file))) { - return null; - } - return { - file: Object.assign(file, { messageId, toolCallId, agentId }), - finalize: () => - finalizePreview({ buffer, leafName, mimeType, category, file_id, previewRevision }), - previewRevision, - }; - } - - /* Non-office path: extraction is cheap (utf8 decode, parseDocument - * for PDF/ODT, or null for binaries). Run inline and return a - * fully-resolved record โ€” no `finalize` needed. */ - const text = await extractCodeArtifactText(buffer, leafName, mimeType, category); - /* `textFormat` accompanies `text` so the client can gate - * office-HTML-bucket routing on a trusted signal โ€” clients MUST - * NOT inject `text` into the iframe as HTML unless `textFormat === - * 'html'`. RAG-uploaded `.docx` etc. arrive with plain text from - * mammoth.extractRawText and would otherwise be hijacked by the - * extension-based office routing into the HTML-injection path - * (Codex P1 review on PR #12934). null on extract failure โ€” the - * client treats absence as 'text' for safety. */ - const textFormat = getExtractedTextFormat(leafName, mimeType, text); - const file = { - ...baseFile, - // Always set explicitly so an update which produces a binary or - // oversized artifact clears any previously cached text โ€” createFile - // uses findOneAndUpdate with $set semantics. - text: text ?? null, - textFormat: textFormat ?? null, - // Clear deferred-preview lifecycle fields in case the prior emit - // at this (filename, conversationId) was an office file โ€” - // otherwise stale `pending`/`failed` would persist and the client - // would render the wrong state for the now non-office artifact. - status: null, - previewError: null, - previewRevision: null, - }; - - if (!(await commitCodeFile(file))) { - return null; - } - return { file: Object.assign(file, { messageId, toolCallId, agentId }) }; - } catch (error) { - if (error?.code === 'CODE_OUTPUT_DOWNLOAD_LIMIT') { - logger.warn( - `[processCodeOutput] Generated file exceeds size limit of ${(fileSizeLimit / megabyte).toFixed(2)} MB, falling back to download URL`, - ); - } - if (error?.message === 'Path traversal detected in filename') { - logger.warn( - `[processCodeOutput] Path traversal blocked for file "${name}" | conv=${conversationId}`, - ); - } - logAxiosError({ - message: 'Error downloading/processing code environment file', - error, - }); - logger.warn( - `[processCodeOutput] Falling back to Code API download URL for strategy ${appConfig.fileStrategy}`, - ); - - // Fallback for download errors - return download URL so user can still manually download - return { - file: createDownloadFallback({ - id, - name, - agentId, - messageId, - toolCallId, - session_id, - conversationId, - executionProfile, - executionRouteKey, - expiresAt: currentDate.getTime() + 86400000, - }), - }; - } -}; +const processCodeOutput = (params) => + processCodeOutputWithDeps(params, { + getCodeOutputFileSettings, + downloadCodeOutputBuffer, + createDownloadFallback, + getRetentionExpiry, + convertImage, + getStrategyFunctions, + determineFileType, + claimCodeFile, + commitCodeFile, + finalizePreview, + hasOfficeHtmlPath, + sanitizeArtifactPath, + flattenArtifactPath, + classifyCodeArtifact, + extractCodeArtifactText, + getExtractedTextFormat, + getStorageMetadata, + logAxiosError, + logger, + }); function getSessionFileInfo(ref, req, route = {}, signal) { return getCodeFileInfo({ diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index e1508e665f0..0a87df47bcc 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -64,6 +64,9 @@ const mockParseSandboxImageChunk = jest.fn((response) => response); const passthroughWithTimeout = async (promise) => promise; jest.mock('@librechat/api', () => { return { + processCodeOutput: jest.requireActual('@librechat/api').processCodeOutput, + prepareCodeOutputBufferForInspection: + jest.requireActual('@librechat/api').prepareCodeOutputBufferForInspection, resolveDownloadPath: (file) => file.storageKey || file.filepath, logAxiosError: jest.fn(), /* Behaviourally identical to the real predicate in @@ -204,12 +207,14 @@ jest.mock('@librechat/agents', () => ({ // Mock models const mockClaimCodeFile = jest.fn(); +const mockCommitCodeFile = jest.fn(); const mockUpdateFile = jest.fn(); jest.mock('~/models', () => ({ createFile: jest.fn().mockResolvedValue({}), getFiles: jest.fn(), updateFile: mockUpdateFile, claimCodeFile: (...args) => mockClaimCodeFile(...args), + commitCodeFile: mockCommitCodeFile, })); // Mock permissions (must be before process.js import) @@ -298,6 +303,8 @@ describe('Code Process', () => { }); getFiles.mockResolvedValue(null); createFile.mockResolvedValue({}); + mockCommitCodeFile.mockReset(); + mockCommitCodeFile.mockResolvedValue(true); getStrategyFunctions.mockReturnValue({ saveBuffer: jest.fn().mockResolvedValue('/uploads/mock-file-path.txt'), }); @@ -329,7 +336,7 @@ describe('Code Process', () => { }, }); expect(mockClaimCodeFile).not.toHaveBeenCalled(); - expect(createFile).not.toHaveBeenCalled(); + expect(mockCommitCodeFile).not.toHaveBeenCalled(); expect(getStrategyFunctions).not.toHaveBeenCalled(); }); @@ -340,7 +347,7 @@ describe('Code Process', () => { expect(mockAxios).not.toHaveBeenCalled(); expect(mockClaimCodeFile).toHaveBeenCalledTimes(1); - expect(createFile).toHaveBeenCalledTimes(1); + expect(mockCommitCodeFile).toHaveBeenCalledTimes(1); }); it('enforces a caller-provided aggregate inspection budget while downloading', async () => { @@ -360,7 +367,7 @@ describe('Code Process', () => { }), ); expect(mockClaimCodeFile).not.toHaveBeenCalled(); - expect(createFile).not.toHaveBeenCalled(); + expect(mockCommitCodeFile).not.toHaveBeenCalled(); }); it('extracts text bytes even when the generated filename spoofs an image extension', async () => { @@ -400,7 +407,150 @@ describe('Code Process', () => { }); expect(mockAxios).not.toHaveBeenCalled(); expect(mockClaimCodeFile).not.toHaveBeenCalled(); - expect(createFile).not.toHaveBeenCalled(); + expect(mockCommitCodeFile).not.toHaveBeenCalled(); + }); + }); + + describe('published run artifacts', () => { + const scope = { + userId: 'user-123', + conversationId: 'conv-123', + runId: 'parent-run', + executionId: 'child-run', + agentId: 'child-agent', + sourceFileId: 'file-id-123', + }; + const provenance = { + runId: scope.runId, + executionId: scope.executionId, + agentId: scope.agentId, + sourceFileId: scope.sourceFileId, + parentExecutionId: 'parent-execution', + publishedAt: '2026-09-11T16:00:00.000Z', + inputFileIds: ['input-pdf'], + }; + + it('stores the existing output pipeline result through the publication boundary', async () => { + const publish = jest.fn(async ({ file }) => ({ + ...file, + file_id: 'published-id', + user: scope.userId, + conversationId: scope.conversationId, + context: FileContext.run_artifact, + metadata: { ...file.metadata, runFile: provenance }, + })); + const discard = jest.fn(); + const result = await processCodeOutput({ + ...baseParams, + preparedBuffer: Buffer.from('report data'), + publication: { scope, provenance, publish, find: jest.fn(), discard }, + }); + expect(result.file).toMatchObject({ + file_id: 'published-id', + filename: baseParams.name, + context: FileContext.run_artifact, + metadata: { runFile: provenance }, + }); + expect(publish).toHaveBeenCalledWith(expect.objectContaining({ scope, provenance })); + expect(mockClaimCodeFile).not.toHaveBeenCalled(); + expect(mockCommitCodeFile).not.toHaveBeenCalled(); + expect(discard).not.toHaveBeenCalled(); + }); + + it('finalizes office previews against the canonical published file identity', async () => { + mockHasOfficeHtmlPath.mockReturnValueOnce(true); + const publish = jest.fn(async ({ file }) => ({ + ...file, + file_id: 'published-office-id', + user: scope.userId, + conversationId: scope.conversationId, + context: FileContext.run_artifact, + metadata: { ...file.metadata, runFile: provenance }, + })); + const result = await processCodeOutput({ + ...baseParams, + name: 'report.csv', + preparedBuffer: Buffer.from('a,b\n1,2'), + publication: { scope, provenance, publish, find: jest.fn(), discard: jest.fn() }, + }); + expect(result.file.file_id).toBe('published-office-id'); + expect(result.file.status).toBe('pending'); + await result.finalize(); + expect(require('~/models').updateFile).toHaveBeenCalledWith( + expect.objectContaining({ file_id: 'published-office-id' }), + { previewRevision: result.previewRevision }, + ); + }); + + it('cleans a stored publication attempt when classification fails before metadata commit', async () => { + const { createRunArtifactPublisher } = jest.requireActual('@librechat/api'); + const discard = jest.fn(async () => undefined); + const publishRunArtifactFile = jest.fn(); + mockClassifyCodeArtifact.mockImplementationOnce(() => { + throw new Error('Classification failed after storage'); + }); + const publish = createRunArtifactPublisher({ + claimRunArtifactFile: async () => ({ file_id: 'published-id' }), + publishRunArtifactFile, + findRunArtifactFile: async () => null, + processCodeOutput: (input) => processCodeOutput({ ...input, req: mockReq }), + prepare: async () => Buffer.from('report data'), + discard, + finalize: jest.fn(), + }); + await expect( + publish({ + scope, + provenance, + artifact: { id: baseParams.id, name: baseParams.name, sessionId: baseParams.session_id }, + }), + ).rejects.toThrow('durable storage'); + expect(publishRunArtifactFile).not.toHaveBeenCalled(); + expect(discard).toHaveBeenCalledWith( + expect.objectContaining({ + file_id: 'mock-uuid-1234', + filepath: '/uploads/mock-file-path.txt', + metadata: undefined, + }), + ); + expect(discard).toHaveBeenCalledTimes(1); + }); + + it('cleans a generated image cancelled after conversion without publishing it', async () => { + const { createRunArtifactPublisher } = jest.requireActual('@librechat/api'); + const controller = new AbortController(); + const discard = jest.fn(async () => undefined); + const publishRunArtifactFile = jest.fn(); + convertImage.mockImplementationOnce(async () => { + controller.abort(new Error('Publication generation expired')); + return { filepath: '/uploads/generated.webp', bytes: 12, width: 2, height: 2 }; + }); + const publish = createRunArtifactPublisher({ + claimRunArtifactFile: async () => ({ file_id: 'published-id' }), + publishRunArtifactFile, + findRunArtifactFile: async () => null, + processCodeOutput: (input) => processCodeOutput({ ...input, req: mockReq }), + prepare: async () => Buffer.from('image data'), + discard, + finalize: jest.fn(), + }); + await expect( + publish({ + scope, + provenance, + artifact: { id: baseParams.id, name: 'generated.png', sessionId: baseParams.session_id }, + signal: controller.signal, + }), + ).rejects.toThrow('generation expired'); + expect(publishRunArtifactFile).not.toHaveBeenCalled(); + expect(discard).toHaveBeenCalledWith( + expect.objectContaining({ + filepath: '/uploads/generated.webp', + file_id: 'mock-uuid-1234', + metadata: undefined, + }), + ); + expect(discard).toHaveBeenCalledTimes(1); }); }); @@ -465,6 +615,7 @@ describe('Code Process', () => { }); expect(result).toBeNull(); + expect(mockCommitCodeFile).not.toHaveBeenCalled(); }); it('still reuses the claim when it predates the background run', async () => { @@ -473,7 +624,7 @@ describe('Code Process', () => { filename: 'test-file.txt', updatedAt: '2024-01-01T00:00:00.000Z', }); - mockUpdateFile.mockResolvedValue({ file_id: 'existing-file-id' }); + mockCommitCodeFile.mockResolvedValue(true); mockAxios.mockResolvedValue({ data: Buffer.alloc(100) }); const { file: result } = await processCodeOutput({ @@ -482,6 +633,10 @@ describe('Code Process', () => { }); expect(result.file_id).toBe('existing-file-id'); + expect(mockCommitCodeFile).toHaveBeenCalledWith( + expect.objectContaining({ file_id: 'existing-file-id' }), + new Date('2024-01-02T00:00:00.000Z').getTime(), + ); }); it('skips when a newer task holds an unwritten claim (insert stamp, no updatedAt yet)', async () => { @@ -502,17 +657,15 @@ describe('Code Process', () => { }); expect(result).toBeNull(); + expect(mockCommitCodeFile).not.toHaveBeenCalled(); }); - it('commits background writes conditionally: an inserter overtaken mid-flight misses', async () => { - /* This task INSERTED the claim, then a newer task stamped and wrote - * while this one was still downloading โ€” the ownership predicate is in - * the write's own filter, so the commit atomically misses. */ + it('omits output when the commit boundary rejects an overtaken claim', async () => { mockClaimCodeFile.mockResolvedValue({ file_id: 'mock-uuid-1234', user: 'user-123', }); - mockUpdateFile.mockResolvedValueOnce(null); + mockCommitCodeFile.mockResolvedValueOnce(false); mockAxios.mockResolvedValue({ data: Buffer.alloc(100) }); const result = await processCodeOutput({ @@ -521,18 +674,14 @@ describe('Code Process', () => { }); expect(result).toBeNull(); - expect(mockUpdateFile).toHaveBeenCalledWith( - expect.objectContaining({ file_id: 'mock-uuid-1234' }), - { - $or: [ - { 'metadata.sourceDispatchedAt': { $exists: false } }, - { - 'metadata.sourceDispatchedAt': { - $lte: new Date('2024-01-01T00:00:00.000Z').getTime(), - }, - }, - ], - }, + expect(mockCommitCodeFile).toHaveBeenCalledWith( + expect.objectContaining({ + file_id: 'mock-uuid-1234', + metadata: expect.objectContaining({ + sourceDispatchedAt: new Date('2024-01-01T00:00:00.000Z').getTime(), + }), + }), + new Date('2024-01-01T00:00:00.000Z').getTime(), ); }); @@ -542,7 +691,7 @@ describe('Code Process', () => { filename: 'test-file.txt', updatedAt: '2024-01-01T00:00:00.000Z', }); - mockUpdateFile.mockResolvedValueOnce({ file_id: 'existing-file-id' }); + mockCommitCodeFile.mockResolvedValueOnce(true); mockAxios.mockResolvedValue({ data: Buffer.alloc(100) }); const { file: result } = await processCodeOutput({ @@ -565,7 +714,7 @@ describe('Code Process', () => { sourceDispatchedAt: new Date('2024-01-01T00:00:00.000Z').getTime(), }, }); - mockUpdateFile.mockResolvedValue({ file_id: 'existing-file-id' }); + mockCommitCodeFile.mockResolvedValue(true); mockAxios.mockResolvedValue({ data: Buffer.alloc(100) }); const { file: result } = await processCodeOutput({ @@ -641,9 +790,9 @@ describe('Code Process', () => { expect(mockClaimCodeFile).toHaveBeenCalledWith( expect.objectContaining({ tenantId: 'tenantA' }), ); - expect(createFile).toHaveBeenCalledWith( + expect(mockCommitCodeFile).toHaveBeenCalledWith( expect.objectContaining({ tenantId: 'tenantA' }), - true, + undefined, ); }); @@ -748,7 +897,7 @@ describe('Code Process', () => { storageRegion: 'us-east-2', status: 'pending', }); - expect(createFile).toHaveBeenCalledWith( + expect(mockCommitCodeFile).toHaveBeenCalledWith( expect.objectContaining({ file_id: 'mock-uuid-1234', user: 'user-123', @@ -757,7 +906,7 @@ describe('Code Process', () => { storageKey, storageRegion: 'us-east-2', }), - true, + undefined, ); expect(typeof finalize).toBe('function'); }); @@ -783,9 +932,9 @@ describe('Code Process', () => { expect(mockSaveBuffer).toHaveBeenCalledWith( expect.objectContaining({ tenantId: 'tenantA' }), ); - expect(createFile).toHaveBeenCalledWith( + expect(mockCommitCodeFile).toHaveBeenCalledWith( expect.objectContaining({ tenantId: 'tenantA' }), - true, + undefined, ); }); @@ -917,9 +1066,9 @@ describe('Code Process', () => { 'utf8-text', ); expect(result.text).toBe('hello world\n'); - expect(createFile).toHaveBeenCalledWith( + expect(mockCommitCodeFile).toHaveBeenCalledWith( expect.objectContaining({ text: 'hello world\n' }), - true, + undefined, ); }); @@ -933,7 +1082,7 @@ describe('Code Process', () => { const { file: result } = await processCodeOutput({ ...baseParams, name: 'archive.zip' }); expect(result.text).toBeNull(); - const createCall = createFile.mock.calls[0][0]; + const createCall = mockCommitCodeFile.mock.calls[0][0]; expect(createCall.text).toBeNull(); }); @@ -955,7 +1104,7 @@ describe('Code Process', () => { await processCodeOutput({ ...baseParams, name: 'output.bin' }); // null (not omitted) so $set clears any prior `text` value. - const createCall = createFile.mock.calls[0][0]; + const createCall = mockCommitCodeFile.mock.calls[0][0]; expect(createCall).toHaveProperty('text', null); }); @@ -989,7 +1138,7 @@ describe('Code Process', () => { await processCodeOutput({ ...baseParams, name: 'output.txt' }); - const createCall = createFile.mock.calls[0][0]; + const createCall = mockCommitCodeFile.mock.calls[0][0]; expect(createCall).toHaveProperty('status', null); expect(createCall).toHaveProperty('previewError', null); expect(createCall).toHaveProperty('previewRevision', null); @@ -1010,7 +1159,7 @@ describe('Code Process', () => { }), ); expect(mockClaimCodeFile).toHaveBeenCalledTimes(1); - expect(createFile).toHaveBeenCalledTimes(1); + expect(mockCommitCodeFile).toHaveBeenCalledTimes(1); }); it('should fallback to download URL when file exceeds size limit', async () => { @@ -1031,8 +1180,8 @@ describe('Code Process', () => { expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('exceeds size limit')); expect(result.filepath).toContain('/api/files/code/download/session-123/file-id-123'); expect(result.expiresAt).toBeDefined(); - // Should not call createFile for oversized files (fallback path) - expect(createFile).not.toHaveBeenCalled(); + // Oversized files use the download fallback without committing metadata. + expect(mockCommitCodeFile).not.toHaveBeenCalled(); // Reset to default for other tests fileSizeLimitConfig.value = 20 * 1024 * 1024; @@ -1049,7 +1198,7 @@ describe('Code Process', () => { expect.stringContaining('Generated file exceeds size limit'), ); expect(mockClaimCodeFile).not.toHaveBeenCalled(); - expect(createFile).not.toHaveBeenCalled(); + expect(mockCommitCodeFile).not.toHaveBeenCalled(); fileSizeLimitConfig.value = 20 * 1024 * 1024; }); @@ -1262,18 +1411,18 @@ describe('Code Process', () => { expect(result.messageId).toBe('msg-123'); }); - it('should call createFile with upsert enabled', async () => { + it('commits foreground output without a background dispatch constraint', async () => { const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); await processCodeOutput(baseParams); - expect(createFile).toHaveBeenCalledWith( + expect(mockCommitCodeFile).toHaveBeenCalledWith( expect.objectContaining({ file_id: 'mock-uuid-1234', context: FileContext.execute_code, }), - true, // upsert flag + undefined, ); }); }); @@ -1301,18 +1450,18 @@ describe('Code Process', () => { */ /** - * `processCodeOutput` mutates the file object after `createFile` returns + * `processCodeOutput` mutates the file object after `commitCodeFile` returns * (`Object.assign(file, { messageId, toolCallId })`) so the runtime * caller sees the live messageId on the response. Reading - * `createFile.mock.calls[0][0]` directly would therefore reflect the + * `mockCommitCodeFile.mock.calls[0][0]` directly would therefore reflect the * post-mutation state because JS captures by reference. To assert * what was actually PERSISTED, snapshot the args at call time. */ - function snapshotCreateFileArgs() { + function snapshotCommitCodeFileArgs() { const snapshots = []; - createFile.mockImplementation(async (file) => { + mockCommitCodeFile.mockImplementation(async (file) => { snapshots.push({ ...file }); - return {}; + return true; }); return snapshots; } @@ -1325,7 +1474,7 @@ describe('Code Process', () => { createdAt: '2024-01-01T00:00:00.000Z', messageId: 'turn-1-original-msg', }); - const persisted = snapshotCreateFileArgs(); + const persisted = snapshotCommitCodeFileArgs(); const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); @@ -1348,7 +1497,7 @@ describe('Code Process', () => { createdAt: '2024-01-01T00:00:00.000Z', // messageId intentionally absent }); - const persisted = snapshotCreateFileArgs(); + const persisted = snapshotCommitCodeFileArgs(); const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); @@ -1367,7 +1516,7 @@ describe('Code Process', () => { file_id: 'mock-uuid-1234', user: 'user-123', }); - const persisted = snapshotCreateFileArgs(); + const persisted = snapshotCommitCodeFileArgs(); const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); @@ -1391,7 +1540,7 @@ describe('Code Process', () => { createdAt: '2024-01-01T00:00:00.000Z', messageId: 'turn-1-original-msg', }); - const persisted = snapshotCreateFileArgs(); + const persisted = snapshotCommitCodeFileArgs(); const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); @@ -1418,7 +1567,7 @@ describe('Code Process', () => { createdAt: '2024-01-01T00:00:00.000Z', messageId: 'turn-1-image-msg', }); - const persisted = snapshotCreateFileArgs(); + const persisted = snapshotCommitCodeFileArgs(); const imageBuffer = Buffer.alloc(500); mockAxios.mockResolvedValue({ data: imageBuffer }); @@ -1599,9 +1748,9 @@ describe('Code Process', () => { // Extractor MUST NOT have been called yet โ€” that's deferred preview work. expect(mockExtractCodeArtifactText).not.toHaveBeenCalled(); // Persisted record with the pending status. - expect(createFile).toHaveBeenCalledWith( + expect(mockCommitCodeFile).toHaveBeenCalledWith( expect.objectContaining({ status: 'pending', text: null, textFormat: null }), - true, + undefined, ); }); diff --git a/api/server/services/Files/Firebase/crud.js b/api/server/services/Files/Firebase/crud.js index cf6d4c74e3d..174b4737516 100644 --- a/api/server/services/Files/Firebase/crud.js +++ b/api/server/services/Files/Firebase/crud.js @@ -7,6 +7,7 @@ const { deleteRagFile, getFirebaseStorage, assertRemoteFileURL, + getSafeErrorMetadata, getRemoteFileFetchMaxBytes, getRemoteFileFetchTimeoutMs, assertRemoteFileContentLength, @@ -265,7 +266,7 @@ async function getFirebaseFileStream(_req, filepath, { signal } = {}) { return response.data; } catch (error) { - logger.error('Error getting Firebase file stream:', error); + logger.error('Error getting Firebase file stream:', getSafeErrorMetadata(error)); throw error; } } diff --git a/api/server/services/Files/images/encode.js b/api/server/services/Files/images/encode.js index dc744542f12..8119c544c31 100644 --- a/api/server/services/Files/images/encode.js +++ b/api/server/services/Files/images/encode.js @@ -1,268 +1,10 @@ const axios = require('axios'); -const { logger } = require('@librechat/data-schemas'); -const { logAxiosError, validateImage, runGuardedEncode } = require('@librechat/api'); -const { - FileSources, - VisionModes, - ImageDetail, - ContentTypes, - EModelEndpoint, - mergeFileConfig, - getEndpointFileConfig, -} = require('librechat-data-provider'); +const { encodeAndFormatImages } = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); -/** - * Converts a readable stream to a base64 encoded string. - * - * @param {NodeJS.ReadableStream} stream - The readable stream to convert. - * @param {boolean} [destroyStream=true] - Whether to destroy the stream after processing. - * @returns {Promise} - Promise resolving to the base64 encoded content. - */ -async function streamToBase64(stream, destroyStream = true) { - return new Promise((resolve, reject) => { - const chunks = []; - - stream.on('data', (chunk) => { - chunks.push(chunk); - }); - - stream.on('end', () => { - try { - const buffer = Buffer.concat(chunks); - const base64Data = buffer.toString('base64'); - chunks.length = 0; // Clear the array - resolve(base64Data); - } catch (err) { - reject(err); - } - }); - - stream.on('error', (error) => { - chunks.length = 0; - reject(error); - }); - }).finally(() => { - // Clean up the stream if required - if (destroyStream && stream.destroy && typeof stream.destroy === 'function') { - stream.destroy(); - } - }); -} - -/** - * Fetches an image from a URL and returns its base64 representation. - * - * @async - * @param {string} url The URL of the image. - * @returns {Promise} The base64-encoded string of the image. - * @throws {Error} If there's an issue fetching the image or encoding it. - */ -async function fetchImageToBase64(url) { - try { - const response = await axios.get(url, { - responseType: 'arraybuffer', - }); - const base64Data = Buffer.from(response.data).toString('base64'); - response.data = null; - return base64Data; - } catch (error) { - const message = 'Error fetching image to convert to base64'; - throw new Error(logAxiosError({ message, error })); - } -} - -const base64Only = new Set([ - EModelEndpoint.google, - EModelEndpoint.anthropic, - 'Ollama', - 'ollama', - EModelEndpoint.bedrock, -]); - -const blobStorageSources = new Set([ - FileSources.azure_blob, - FileSources.s3, - FileSources.firebase, - FileSources.cloudfront, -]); - -/** - * Encodes and formats the given files. - * @param {ServerRequest} req - The request object. - * @param {Array} files - The array of files to encode and format. - * @param {object} params - Object containing provider/endpoint information - * @param {Providers | EModelEndpoint | string} [params.provider] - The provider for the image - * @param {string} [params.endpoint] - Optional: The endpoint for the image - * @param {string} [params.imageDetail] - Optional: Detail level resolved by the caller, used - * where the request body carries no conversation-level setting (the agents route). - * @param {string} [mode] - Optional: The endpoint mode for the image. - * @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details. - */ -async function encodeAndFormat(req, files, params, mode) { - const { provider, endpoint } = params; - const effectiveEndpoint = endpoint ?? provider; - const promises = []; - /** @type {Record, 'prepareImagePayload' | 'getDownloadStream'>>} */ - const encodingMethods = {}; - /** @type {{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */ - const result = { - files: [], - image_urls: [], - }; - - if (!files || !files.length) { - return result; - } - - for (let file of files) { - /** @type {FileSources} */ - const source = file.source ?? FileSources.local; - - if (!file.height) { - promises.push([file, null]); - continue; - } - - if (!encodingMethods[source]) { - const { prepareImagePayload, getDownloadStream } = getStrategyFunctions(source); - if (!prepareImagePayload) { - throw new Error(`Encoding function not implemented for ${source}`); - } - - encodingMethods[source] = { prepareImagePayload, getDownloadStream }; - } - - const preparePayload = encodingMethods[source].prepareImagePayload; - /* We need to fetch the image and convert it to base64 if we are using S3/Azure Blob/Firebase storage. */ - if (blobStorageSources.has(source)) { - try { - const downloadStream = encodingMethods[source].getDownloadStream; - let base64Data = await runGuardedEncode(file.bytes ?? 0, async () => { - let stream = await downloadStream(req, file.filepath); - const data = await streamToBase64(stream); - stream = null; - return data; - }); - promises.push([file, base64Data]); - base64Data = null; - continue; - } catch (error) { - logger.error('Error processing image from blob storage:', error); - } - } else if (source !== FileSources.local && base64Only.has(effectiveEndpoint)) { - const entry = await runGuardedEncode(file.bytes ?? 0, async () => { - const [_file, imageURL] = await preparePayload(req, file); - return [_file, await fetchImageToBase64(imageURL)]; - }); - promises.push(entry); - continue; - } - promises.push(preparePayload(req, file)); - } - - const detail = params.imageDetail ?? req.body.imageDetail ?? ImageDetail.auto; - - /** @type {Array<[MongoFile, string]>} */ - const formattedImages = await Promise.all(promises); - promises.length = 0; - - /** Extract configured file size limit from fileConfig for this endpoint */ - let configuredFileSizeLimit; - if (req.config?.fileConfig) { - const fileConfig = mergeFileConfig(req.config.fileConfig); - const endpointConfig = getEndpointFileConfig({ - fileConfig, - endpoint: effectiveEndpoint, - }); - configuredFileSizeLimit = endpointConfig?.fileSizeLimit; - } - - for (const [file, imageContent] of formattedImages) { - const fileMetadata = { - type: file.type, - file_id: file.file_id, - filepath: file.filepath, - filename: file.filename, - embedded: !!file.embedded, - metadata: file.metadata, - }; - - if (file.height && file.width) { - fileMetadata.height = file.height; - fileMetadata.width = file.width; - } - - if (!imageContent) { - result.files.push(fileMetadata); - continue; - } - - /** Validate image buffer against size limits */ - if (file.height && file.width) { - const imageBuffer = imageContent.startsWith('http') - ? null - : Buffer.from(imageContent, 'base64'); - - if (imageBuffer) { - const validation = await validateImage( - imageBuffer, - imageBuffer.length, - effectiveEndpoint, - configuredFileSizeLimit, - ); - - if (!validation.isValid) { - throw new Error(`Image validation failed for ${file.filename}: ${validation.error}`); - } - } - } - - const imagePart = { - type: ContentTypes.IMAGE_URL, - image_url: { - url: imageContent.startsWith('http') - ? imageContent - : `data:${file.type};base64,${imageContent}`, - detail, - }, - }; - - if (mode === VisionModes.agents) { - result.image_urls.push({ ...imagePart }); - result.files.push({ ...fileMetadata }); - continue; - } - - if ( - effectiveEndpoint && - effectiveEndpoint === EModelEndpoint.google && - mode === VisionModes.generative - ) { - delete imagePart.image_url; - imagePart.inlineData = { - mimeType: file.type, - data: imageContent, - }; - } else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.google) { - imagePart.image_url = imagePart.image_url.url; - } else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.anthropic) { - imagePart.type = 'image'; - imagePart.source = { - type: 'base64', - media_type: file.type, - data: imageContent, - }; - delete imagePart.image_url; - } - - result.image_urls.push({ ...imagePart }); - result.files.push({ ...fileMetadata }); - } - formattedImages.length = 0; - return { ...result }; -} +const dependencies = { getStrategyFunctions, httpClient: axios }; module.exports = { - encodeAndFormat, + encodeAndFormat: (req, files, params, mode) => + encodeAndFormatImages(req, files, params, dependencies, mode), }; diff --git a/api/server/services/Files/images/encode.spec.js b/api/server/services/Files/images/encode.spec.js index 262a55be458..525a38421fc 100644 --- a/api/server/services/Files/images/encode.spec.js +++ b/api/server/services/Files/images/encode.spec.js @@ -1,164 +1,60 @@ -const { Readable } = require('stream'); - -const mockRunGuardedEncode = jest.fn((_bytes, task) => task()); - -jest.mock('axios'); -jest.mock('@librechat/api', () => ({ - logAxiosError: jest.fn(({ message }) => message), - validateImage: jest.fn().mockResolvedValue({ isValid: true }), - runGuardedEncode: (...args) => mockRunGuardedEncode(...args), -})); -jest.mock('@librechat/data-schemas', () => ({ - logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, -})); - -const mockPrepareImagePayload = jest.fn(); -const mockGetDownloadStream = jest.fn(); -jest.mock('~/server/services/Files/strategies', () => ({ - getStrategyFunctions: jest.fn(() => ({ - prepareImagePayload: mockPrepareImagePayload, - getDownloadStream: mockGetDownloadStream, - })), -})); +jest.mock('@librechat/api', () => ({ encodeAndFormatImages: jest.fn() })); +jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: jest.fn() })); const axios = require('axios'); -const { FileSources } = require('librechat-data-provider'); +const { Readable } = require('node:stream'); +const { encodeAndFormatImages } = require('@librechat/api'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { encodeAndFormat } = require('./encode'); -const makeReq = () => ({ body: {}, config: {} }); - -beforeEach(() => { - jest.clearAllMocks(); - mockRunGuardedEncode.mockImplementation((_bytes, task) => task()); +beforeEach(() => jest.resetAllMocks()); + +it('wires the existing callers to the typed image encoder without interpreting its result', () => { + const req = { body: {}, config: {} }; + const files = []; + const params = { endpoint: 'openai', imageDetail: 'high' }; + const result = Promise.resolve({ files: [], image_urls: [] }); + encodeAndFormatImages.mockReturnValue(result); + + expect(encodeAndFormat(req, files, params, 'agents')).toBe(result); + expect(encodeAndFormatImages).toHaveBeenCalledWith( + req, + files, + params, + { getStrategyFunctions, httpClient: axios }, + 'agents', + ); }); -describe('encodeAndFormat - request memory guard', () => { - it('gates blob-storage byte pulls and returns [file, base64]', async () => { - mockGetDownloadStream.mockResolvedValue(Readable.from([Buffer.from('blob-image-bytes')])); - const file = { - source: FileSources.s3, - height: 10, - width: 10, - type: 'image/png', - file_id: 'f-blob', - filepath: 'bucket/a.png', - filename: 'a.png', - bytes: 4321, - }; - - const result = await encodeAndFormat(makeReq(), [file], { endpoint: 'openai' }); - - expect(mockRunGuardedEncode).toHaveBeenCalledTimes(1); - expect(mockRunGuardedEncode.mock.calls[0][0]).toBe(4321); - - const expectedBase64 = Buffer.from('blob-image-bytes').toString('base64'); - expect(result.image_urls).toHaveLength(1); - expect(result.image_urls[0].image_url.url).toBe(`data:image/png;base64,${expectedBase64}`); - }); - - it('gates base64Only URL fetches and returns [file, base64]', async () => { - mockPrepareImagePayload.mockResolvedValue([ - { source: FileSources.vectordb, type: 'image/png' }, - 'https://images.example/x.png', - ]); - axios.get.mockResolvedValue({ data: Buffer.from('url-image-bytes') }); - - const file = { - source: FileSources.vectordb, - height: 10, - width: 10, - type: 'image/png', - file_id: 'f-url', - filepath: 'remote/x.png', - filename: 'x.png', - bytes: 9876, - }; - - const result = await encodeAndFormat(makeReq(), [file], { endpoint: 'anthropic' }); - - expect(mockRunGuardedEncode).toHaveBeenCalledTimes(1); - expect(mockRunGuardedEncode.mock.calls[0][0]).toBe(9876); - - const expectedBase64 = Buffer.from('url-image-bytes').toString('base64'); - expect(result.image_urls).toHaveLength(1); - expect(result.image_urls[0].source.data).toBe(expectedBase64); - }); - - it('does not gate the non-buffering local prepare path', async () => { - const localBase64 = Buffer.from('local-image').toString('base64'); - mockPrepareImagePayload.mockResolvedValue([ - { source: FileSources.local, type: 'image/png' }, - localBase64, - ]); - - const file = { - source: FileSources.local, - height: 10, - width: 10, - type: 'image/png', - file_id: 'f-local', - filepath: 'local/p.png', - filename: 'p.png', - bytes: 555, - }; - - const result = await encodeAndFormat(makeReq(), [file], { endpoint: 'openai' }); - - expect(mockRunGuardedEncode).not.toHaveBeenCalled(); - expect(result.image_urls).toHaveLength(1); - expect(result.image_urls[0].image_url.url).toBe(`data:image/png;base64,${localBase64}`); - }); -}); - -describe('encodeAndFormat - image detail', () => { - const imageFile = () => ({ - source: FileSources.s3, +it('encodes canonical storage keys through the built package and the existing CJS entry point', async () => { + encodeAndFormatImages.mockImplementation( + jest.requireActual('@librechat/api').encodeAndFormatImages, + ); + const bytes = Buffer.from('stored-image-bytes'); + const getDownloadStream = jest.fn().mockResolvedValue(Readable.from(bytes)); + const prepareImagePayload = jest.fn(); + getStrategyFunctions.mockReturnValue({ getDownloadStream, prepareImagePayload }); + const req = { body: {}, config: {} }; + const file = { + source: 's3', + file_id: 'image-1', + filename: 'image.png', + filepath: 'https://storage.example/image.png?signature=secret', + storageKey: 'images/user/image.png', + type: 'image/png', + bytes: bytes.length, height: 10, width: 10, - type: 'image/png', - file_id: 'f-detail', - filepath: 'bucket/a.png', - filename: 'a.png', - bytes: 128, - }); - - beforeEach(() => { - mockGetDownloadStream.mockResolvedValue(Readable.from([Buffer.from('image-bytes')])); - }); - - it('falls back to auto when no detail is configured anywhere', async () => { - const result = await encodeAndFormat(makeReq(), [imageFile()], { endpoint: 'openai' }); - - expect(result.image_urls[0].image_url.detail).toBe('auto'); - }); - - it('uses the conversation-level detail from the request body', async () => { - const req = makeReq(); - req.body.imageDetail = 'low'; - - const result = await encodeAndFormat(req, [imageFile()], { endpoint: 'openai' }); - - expect(result.image_urls[0].image_url.detail).toBe('low'); - }); - - it('uses the detail resolved by the caller on routes with no body setting', async () => { - const result = await encodeAndFormat(makeReq(), [imageFile()], { - endpoint: 'agents', - imageDetail: 'high', - }); - - expect(result.image_urls[0].image_url.detail).toBe('high'); - }); - - it('prefers the caller-resolved detail over the request body', async () => { - const req = makeReq(); - req.body.imageDetail = 'low'; - - const result = await encodeAndFormat(req, [imageFile()], { - endpoint: 'agents', - imageDetail: 'high', - }); - - expect(result.image_urls[0].image_url.detail).toBe('high'); - }); + }; + + const result = await encodeAndFormat(req, [file], { endpoint: 'openai' }); + + expect(getDownloadStream).toHaveBeenCalledWith(req, file.storageKey); + expect(prepareImagePayload).not.toHaveBeenCalled(); + expect(result.image_urls).toEqual([ + { + type: 'image_url', + image_url: { url: `data:image/png;base64,${bytes.toString('base64')}`, detail: 'auto' }, + }, + ]); }); diff --git a/api/server/services/PermissionService.js b/api/server/services/PermissionService.js index 41533ee0f25..98a19a9c711 100644 --- a/api/server/services/PermissionService.js +++ b/api/server/services/PermissionService.js @@ -1,5 +1,5 @@ const mongoose = require('mongoose'); -const { AccessControlService, isEnabled } = require('@librechat/api'); +const { AccessControlService, isEnabled, ensureDirectoryPrincipalUser } = require('@librechat/api'); const { tenantStorage, getTenantId, @@ -337,36 +337,20 @@ const ensurePrincipalExists = async function (principal) { } if (principal.type === PrincipalType.USER && principal.source === 'entra') { - if (!principal.email || !principal.idOnTheSource) { - throw new Error('Entra ID user principals must have email and idOnTheSource'); - } - - let existingUser = await db.findUser({ idOnTheSource: principal.idOnTheSource }); - - if (!existingUser) { - existingUser = await db.findUser({ email: principal.email }); - } - - if (existingUser) { - if (!existingUser.idOnTheSource && principal.idOnTheSource) { - await db.updateUser(existingUser._id, { - idOnTheSource: principal.idOnTheSource, - provider: 'openid', - }); - } - return existingUser._id.toString(); - } - - const userData = { - name: principal.name, - email: principal.email.toLowerCase(), - emailVerified: false, - provider: 'openid', - idOnTheSource: principal.idOnTheSource, - }; - - const userId = await db.createUser(userData, true, true); - return userId.toString(); + return ensureDirectoryPrincipalUser(principal, { + findUserBySourceId: async (idOnTheSource) => { + const user = await db.findUser({ idOnTheSource }); + return user ? { id: user._id.toString() } : null; + }, + findUserByEmail: async (email) => { + const user = await db.findUser({ email }); + return user ? { id: user._id.toString() } : null; + }, + createUser: async (userData) => { + const userId = await db.createUser(userData, true, true); + return userId.toString(); + }, + }); } if (principal.type === PrincipalType.GROUP) { diff --git a/api/server/services/PermissionService.spec.js b/api/server/services/PermissionService.spec.js index 65680249d7c..a2dae303669 100644 --- a/api/server/services/PermissionService.spec.js +++ b/api/server/services/PermissionService.spec.js @@ -296,6 +296,46 @@ describe('PermissionService', () => { expect(principalId).toBe(currentUser._id.toString()); }); + test('accepts a directory user already linked to the supplied source id', async () => { + const directoryUser = await User.create({ + name: 'ACL Principal Directory User', + email: 'acl-principal-directory-user@example.com', + provider: 'openid', + idOnTheSource: 'directory-user-id', + }); + + const principalId = await ensurePrincipalExists({ + type: PrincipalType.USER, + name: directoryUser.name, + email: directoryUser.email, + source: 'entra', + idOnTheSource: directoryUser.idOnTheSource, + }); + + expect(principalId).toBe(directoryUser._id.toString()); + }); + + test('uses an existing user found only by email without linking its identity', async () => { + const existingUser = await User.create({ + name: 'ACL Principal Existing User', + email: 'acl-principal-existing-user@example.com', + provider: 'local', + }); + + const principalId = await ensurePrincipalExists({ + type: PrincipalType.USER, + name: existingUser.name, + email: existingUser.email, + source: 'entra', + idOnTheSource: 'unlinked-directory-id', + }); + + const unchangedUser = await User.findById(existingUser._id).lean(); + expect(principalId).toBe(existingUser._id.toString()); + expect(unchangedUser.provider).toBe('local'); + expect(unchangedUser.idOnTheSource).toBeUndefined(); + }); + test('rejects a local group id outside the current request context', async () => { const outsideGroup = await Group.create({ name: 'ACL Principal Outside Group', diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index bc7693cf34a..4952ee774c9 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -52,6 +52,7 @@ const { createGitIdentityProgrammaticBashTool, resolveCodeExecutionContext, resolveCodeExecutionWorkspaceContext, + resolveRunFileCodeExecutionContext, resolveCallerCapabilityProjectionSnapshot, CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME, @@ -150,6 +151,7 @@ const getActiveToolResources = (toolResources, tools) => { const toolCapabilityGates = { [Tools.file_search]: AgentCapabilities.file_search, [Tools.execute_code]: AgentCapabilities.execute_code, + [Tools.web_search]: AgentCapabilities.web_search, }; /** @@ -864,7 +866,7 @@ async function loadToolDefinitionsWrapper({ return checkCapability(AgentCapabilities.execute_code) && canUseTool(tool); } if (tool === Tools.web_search) { - return checkCapability(AgentCapabilities.web_search); + return checkCapability(AgentCapabilities.web_search) && canUseTool(tool); } if (tool === Tools.memory) { return checkCapability(AgentCapabilities.memory); @@ -1650,7 +1652,7 @@ async function loadAgentTools({ } else if (tool === Tools.execute_code) { return checkCapability(AgentCapabilities.execute_code) && canUseTool(tool); } else if (tool === Tools.web_search) { - includesWebSearch = checkCapability(AgentCapabilities.web_search); + includesWebSearch = checkCapability(AgentCapabilities.web_search) && canUseTool(tool); return includesWebSearch; } else if (tool === Tools.memory) { return checkCapability(AgentCapabilities.memory); @@ -2050,6 +2052,7 @@ async function loadToolsForExecution({ conversationId, actionsEnabled, accessibleMcpServerNames, + runFileCodeExecutionContext, }) { const appConfig = req.config; const allLoadedTools = []; @@ -2148,6 +2151,10 @@ async function loadToolsForExecution({ environments: req.config?.endpoints?.agents?.statefulCodeSessions?.environments, getAppConfig, }); + Object.assign( + codeExecutionContext, + resolveRunFileCodeExecutionContext(codeExecutionContext, runFileCodeExecutionContext), + ); configurable.codeExecutionContext = codeExecutionContext; const isPTC = diff --git a/api/server/services/Tools/mcp.js b/api/server/services/Tools/mcp.js index 0070b65c0a1..52dd1a362ba 100644 --- a/api/server/services/Tools/mcp.js +++ b/api/server/services/Tools/mcp.js @@ -4,6 +4,7 @@ const { getUserMCPAuthMap, getMissingCustomUserVars, loadMCPServerCatalogs: loadCatalogs, + resolveMCPReinitializeConfig, requiresEphemeralUserConnection, getMissingRuntimeBodyPlaceholderFields, MCPAuthenticationRejectedError, @@ -37,7 +38,6 @@ const { } = require('~/server/services/MCPAuthorizationFenceRetry'); const MCP_REINITIALIZE_FAILURE_REASONS = { - UNREACHABLE: 'unreachable', MISSING_CUSTOM_USER_VARS: 'missing_custom_user_vars', OAUTH_REQUIRED: 'oauth_required', INITIALIZATION_FAILED: 'initialization_failed', @@ -73,8 +73,8 @@ async function loadMCPServerCatalogs({ invalidateRecoveryGeneration: invalidateCachedTools, persistPublicationRetry: persistMCPAuthorizationFenceRetry, clearPublicationRetry: clearMCPAuthorizationFenceRetry, - clearLocalRecovery: (userId, serverName) => - mcpManager.clearCatalogRecoveryState?.(userId, serverName), + clearLocalRecovery: (userId, serverName, generation) => + mcpManager.clearCatalogRecoveryState?.(userId, serverName, generation), retryDelaysMs: recoveryPolicy?.authorizationFenceRetryMs, attemptTimeoutMs: recoveryPolicy?.authorizationFenceTimeoutMs, }); @@ -97,8 +97,8 @@ async function loadMCPServerCatalogs({ oboTrustChecker: createOboTrustChecker(), upstreamTokenProvider, oboIdentityContext, - onOAuthCredentialsChanging, }), + onOAuthCredentialsChanging, formatServerTools: formatMCPServerTools, recoveryTracker: mcpManager.getCatalogRecoveryTracker?.(), getRecoveryGeneration: getMCPToolsCacheGeneration, @@ -166,45 +166,17 @@ async function reinitMCPServer({ try { const registry = getMCPServersRegistry(); - serverConfig = - serverConfig ?? (await registry.getServerConfig(serverName, user?.id, configServers)); - ephemeralServer = serverConfig ? requiresEphemeralUserConnection(serverConfig) : false; - if (serverConfig?.inspectionFailed) { - if (serverConfig.source === 'config') { - logger.info( - '[MCP Reinitialize] Config-source server inspection failed; retry handled by config cache', - ); - return { - availableTools: null, - success: false, - message: `MCP server '${serverName}' is still unreachable`, - failureReason: MCP_REINITIALIZE_FAILURE_REASONS.UNREACHABLE, - oauthRequired: false, - serverName, - oauthUrl: null, - tools: null, - }; - } else { - logger.info('[MCP Reinitialize] Server inspection failed; attempting reinspection'); - try { - const storageLocation = serverConfig.source === 'user' ? 'DB' : 'CACHE'; - await registry.reinspectServer(serverName, storageLocation, user?.id); - logger.info('[MCP Reinitialize] Server reinspection succeeded'); - } catch { - logger.error('[MCP Reinitialize] Server reinspection failed'); - return { - availableTools: null, - success: false, - message: `MCP server '${serverName}' is still unreachable`, - failureReason: MCP_REINITIALIZE_FAILURE_REASONS.UNREACHABLE, - oauthRequired: false, - serverName, - oauthUrl: null, - tools: null, - }; - } - } + const resolution = await resolveMCPReinitializeConfig( + registry, + serverName, + serverConfig ?? (await registry.getServerConfig(serverName, user?.id, configServers)), + user?.id, + ); + if (resolution.result) { + return resolution.result; } + serverConfig = resolution.serverConfig; + ephemeralServer = serverConfig ? requiresEphemeralUserConnection(serverConfig) : false; const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`]; diff --git a/api/server/services/Tools/mcp.spec.js b/api/server/services/Tools/mcp.spec.js index e0f54659d77..dfd222f25f7 100644 --- a/api/server/services/Tools/mcp.spec.js +++ b/api/server/services/Tools/mcp.spec.js @@ -13,6 +13,8 @@ const mockFormatMCPServerTools = jest.fn(); const mockGetMCPServerTools = jest.fn(); const mockCacheMCPServerTools = jest.fn(); const mockGetServerToolFunctionsSnapshot = jest.fn(); +const mockClearCatalogRecoveryState = jest.fn(); +const mockInvalidateCachedTools = jest.fn(); jest.mock('@librechat/api', () => ({ ...jest.requireActual('@librechat/api'), @@ -27,6 +29,7 @@ jest.mock('~/config', () => ({ discoverServerTools: mockDiscoverServerTools, getServerToolFunctionsSnapshot: mockGetServerToolFunctionsSnapshot, getToolPublicationGeneration: mockGetToolPublicationGeneration, + clearCatalogRecoveryState: mockClearCatalogRecoveryState, })), getMCPServersRegistry: jest.fn(() => ({ getServerConfig: jest.fn() })), getFlowStateManager: jest.fn(() => ({})), @@ -43,6 +46,11 @@ jest.mock('~/server/services/Config', () => ({ getMCPToolsCacheGeneration: mockGetMCPToolsCacheGeneration, getMCPServerTools: mockGetMCPServerTools, cacheMCPServerTools: mockCacheMCPServerTools, + invalidateCachedTools: mockInvalidateCachedTools, +})); +jest.mock('~/server/services/MCPAuthorizationFenceRetry', () => ({ + persistMCPAuthorizationFenceRetry: jest.fn().mockResolvedValue('retry-v1'), + clearMCPAuthorizationFenceRetry: jest.fn().mockResolvedValue(undefined), })); jest.mock('~/server/services/GraphTokenService', () => ({ getGraphApiToken: mockGetGraphApiToken, @@ -73,7 +81,10 @@ describe('loadMCPServerCatalogs', () => { mockGetUserMCPAuthMap.mockResolvedValue({}); mockDiscoverServerTools.mockResolvedValue({ tools: [] }); mockFormatMCPServerTools.mockReturnValue({}); + const observedCredentialFence = jest.fn(); + let recoveryDeps; mockLoadCatalogs.mockImplementation(async (params, deps) => { + recoveryDeps = deps; await deps.loadUserMCPAuthMap( user.id, servers.map(({ serverName }) => serverName), @@ -82,6 +93,7 @@ describe('loadMCPServerCatalogs', () => { user, serverName: 'config-only', configServers: { 'config-only': servers[0].serverConfig }, + onOAuthCredentialsChanging: observedCredentialFence, }); deps.formatServerTools('config-only', []); await deps.getCachedServerTools(user.id, 'config-only', servers[0].serverConfig); @@ -112,6 +124,7 @@ describe('loadMCPServerCatalogs', () => { servers: ['config-only', 'user-server'], findPluginAuthsByKeys: require('~/models').findPluginAuthsByKeys, }); + expect(recoveryDeps.onOAuthCredentialsChanging).toEqual(expect.any(Function)); expect(mockDiscoverServerTools).toHaveBeenCalledWith( expect.objectContaining({ user, @@ -121,6 +134,7 @@ describe('loadMCPServerCatalogs', () => { tokenMethods: expect.any(Object), upstreamTokenProvider, oboIdentityContext, + onOAuthCredentialsChanging: observedCredentialFence, }), ); expect(mockGetConnection).not.toHaveBeenCalled(); @@ -141,6 +155,28 @@ describe('loadMCPServerCatalogs', () => { serversWithoutTools: [], }); }); + + it('clears catalog recovery with the generation its credential fence published', async () => { + let recoveryDeps; + mockInvalidateCachedTools.mockResolvedValue('generation-2'); + mockLoadCatalogs.mockImplementation(async (params, deps) => { + recoveryDeps = deps; + return { serverTools: new Map(), serversWithoutTools: [] }; + }); + + await loadMCPServerCatalogs({ user: { id: 'user-123' }, servers: [] }); + const publish = await recoveryDeps.onOAuthCredentialsChanging({ + userId: 'user-123', + serverName: 'oauth-server', + }); + + await expect(publish()).resolves.toBe('generation-2'); + expect(mockClearCatalogRecoveryState).toHaveBeenCalledWith( + 'user-123', + 'oauth-server', + 'generation-2', + ); + }); }); describe('reinitMCPServer โ€” customUserVars gating (issue #10969)', () => { @@ -445,6 +481,58 @@ describe('reinitMCPServer โ€” customUserVars gating (issue #10969)', () => { }); }); +describe('reinitMCPServer โ€” recovery of a server that failed inspection', () => { + const user = { id: 'user-123' }; + const serverName = 'Recovering'; + const stub = { + type: 'streamable-http', + url: 'https://recovering.example.com/mcp', + source: 'yaml', + inspectionFailed: true, + }; + const { getMCPServersRegistry } = require('~/config'); + + beforeEach(() => { + mockUpdateMCPServerTools.mockResolvedValue({}); + }); + + it('connects with the recovered config instead of the stub it read', async () => { + const recovered = { + type: 'streamable-http', + url: 'https://recovering.example.com/mcp', + source: 'yaml', + requiresOAuth: false, + }; + const recoverServerConfig = jest.fn().mockResolvedValue(recovered); + getMCPServersRegistry.mockReturnValueOnce({ recoverServerConfig }); + mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) }); + + const result = await reinitMCPServer({ user, serverName, serverConfig: stub }); + + expect(recoverServerConfig).toHaveBeenCalledWith(serverName, stub, user.id); + expect(mockGetConnection).toHaveBeenCalledWith( + expect.objectContaining({ serverName, serverConfig: recovered }), + ); + expect(result).toMatchObject({ success: true, serverName }); + }); + + it('reports the server unreachable without connecting while it cannot be recovered', async () => { + const recoverServerConfig = jest.fn().mockResolvedValue(undefined); + getMCPServersRegistry.mockReturnValueOnce({ recoverServerConfig }); + + const result = await reinitMCPServer({ user, serverName, serverConfig: stub }); + + expect(mockGetConnection).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + availableTools: null, + success: false, + message: `MCP server '${serverName}' is still unreachable`, + failureReason: 'unreachable', + tools: null, + }); + }); +}); + describe('reinitMCPServer โ€” direct bearer authentication outcomes', () => { it('preserves a typed rejection instead of reducing it to a generic result', async () => { const { MCPAuthenticationRejectedError } = require('@librechat/api'); diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 21d5540790e..1c15094b4e3 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -202,13 +202,14 @@ const { createOnSearchResults } = require('~/server/services/Tools/search'); const { reinitMCPServer } = require('~/server/services/Tools/mcp'); const { ContentFilterError, PENDING_STALE_MS } = require('@librechat/api'); -/** Role document shape `checkAccess` reads; both role-gated tools granted. */ +/** Role document shape `checkAccess` reads; all three role-gated tools granted. */ function buildRole(overrides = {}) { return { name: 'USER', permissions: { [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, + [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true }, ...overrides, }, }; @@ -3873,6 +3874,7 @@ describe('ToolService - Action Capability Gating', () => { AgentCapabilities.tools, AgentCapabilities.file_search, AgentCapabilities.execute_code, + AgentCapabilities.web_search, ]; const denyPermission = (deniedType) => @@ -3980,6 +3982,61 @@ describe('ToolService - Action Capability Gating', () => { expect(callArgs.codeExecutionEnabled).toBe(true); }); + it('omits web_search from definitions when WEB_SEARCH.USE is denied', async () => { + denyPermission(PermissionTypes.WEB_SEARCH); + + await loadAgentTools({ + req: createMockReq(capabilities), + res: {}, + agent: { id: 'agent_123', tools: [Tools.web_search, Tools.execute_code] }, + definitionsOnly: true, + }); + + const [callArgs] = mockLoadToolDefinitions.mock.calls[0]; + expect(callArgs.tools).not.toContain(Tools.web_search); + expect(callArgs.tools).toContain(Tools.execute_code); + }); + + it('omits web_search from the runtime loader when WEB_SEARCH.USE is denied', async () => { + denyPermission(PermissionTypes.WEB_SEARCH); + + await loadAgentTools({ + req: createMockReq(capabilities), + res: {}, + agent: { id: 'agent_123', tools: [Tools.web_search, Tools.execute_code] }, + definitionsOnly: false, + }); + + expect(mockLoadToolsUtil).toHaveBeenCalledTimes(1); + const [callArgs] = mockLoadToolsUtil.mock.calls[0]; + expect(callArgs.tools).not.toContain(Tools.web_search); + expect(callArgs.tools).toContain(Tools.execute_code); + }); + + it('keeps web_search when the role grants it', async () => { + await loadAgentTools({ + req: createMockReq(capabilities), + res: {}, + agent: { id: 'agent_123', tools: [Tools.web_search] }, + definitionsOnly: true, + }); + + const [callArgs] = mockLoadToolDefinitions.mock.calls[0]; + expect(callArgs.tools).toContain(Tools.web_search); + }); + + it('keeps web_search in the runtime loader when the role grants it', async () => { + await loadAgentTools({ + req: createMockReq(capabilities), + res: {}, + agent: { id: 'agent_123', tools: [Tools.web_search] }, + definitionsOnly: false, + }); + + const [callArgs] = mockLoadToolsUtil.mock.calls[0]; + expect(callArgs.tools).toContain(Tools.web_search); + }); + it('fails closed when the role lookup throws', async () => { mockGetRoleByName.mockRejectedValue(new Error('role lookup failed')); diff --git a/api/server/services/initializeMCPs.js b/api/server/services/initializeMCPs.js index 4796f38e952..cd18896f89b 100644 --- a/api/server/services/initializeMCPs.js +++ b/api/server/services/initializeMCPs.js @@ -116,6 +116,8 @@ async function initializeMCPs() { try { const mcpManager = await createMCPManager(mcpServers || {}, { catalogRecoveryMaxStateEntries: appConfig?.mcpSettings?.catalogRecovery?.maxStateEntries, + catalogRecoveryMaxDetachedDiscoveries: + appConfig?.mcpSettings?.catalogRecovery?.maxDetachedDiscoveries, }); startMCPAuthorizationFenceRetryWorker(invalidateCachedTools, { intervalMs: appConfig?.mcpSettings?.catalogRecovery?.authorizationFenceRetryIntervalMs, diff --git a/api/server/services/initializeMCPs.spec.js b/api/server/services/initializeMCPs.spec.js index d1b17bff2d9..0f1f9250e24 100644 --- a/api/server/services/initializeMCPs.spec.js +++ b/api/server/services/initializeMCPs.spec.js @@ -239,6 +239,7 @@ describe('initializeMCPs', () => { {}, { catalogRecoveryMaxStateEntries: undefined, + catalogRecoveryMaxDetachedDiscoveries: undefined, }, ); }); @@ -254,6 +255,7 @@ describe('initializeMCPs', () => { expect(mockCreateMCPManager).toHaveBeenCalledWith(mcpServers, { catalogRecoveryMaxStateEntries: undefined, + catalogRecoveryMaxDetachedDiscoveries: undefined, }); }); @@ -263,6 +265,7 @@ describe('initializeMCPs', () => { mcpSettings: { catalogRecovery: { maxStateEntries: 2500, + maxDetachedDiscoveries: 8, authorizationFenceRetryIntervalMs: 15_000, authorizationFenceRetryBatchSize: 250, authorizationFenceTimeoutMs: 750, @@ -276,6 +279,7 @@ describe('initializeMCPs', () => { {}, { catalogRecoveryMaxStateEntries: 2500, + catalogRecoveryMaxDetachedDiscoveries: 8, }, ); expect(mockStartMCPAuthorizationFenceRetryWorker).toHaveBeenCalledWith( @@ -465,6 +469,7 @@ describe('initializeMCPs', () => { {}, { catalogRecoveryMaxStateEntries: undefined, + catalogRecoveryMaxDetachedDiscoveries: undefined, }, ); }); diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js index cd08d51862e..467aabc0dea 100644 --- a/api/server/utils/import/fork.js +++ b/api/server/utils/import/fork.js @@ -1,4 +1,5 @@ const { v4: uuidv4 } = require('uuid'); +const { withoutTraceRefs } = require('@librechat/api'); const { logger, tenantStorage } = require('@librechat/data-schemas'); const { EModelEndpoint, Constants, ForkOptions } = require('librechat-data-provider'); const { getConvo, getMessages, getSharedMessages } = require('~/models'); @@ -64,7 +65,7 @@ function cloneMessagesWithTimestamps( } const clonedMessage = { - ...message, + ...withoutTraceRefs(message), messageId: newMessageId, parentMessageId: parentId, createdAt, diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js index 10c2bfdfbf6..d53bb8775c1 100644 --- a/api/server/utils/import/fork.spec.js +++ b/api/server/utils/import/fork.spec.js @@ -185,6 +185,44 @@ describe('forkConversation', () => { ).toBe(true); }); + test('does not carry the source messages trace sampling onto their copies', async () => { + getMessages.mockResolvedValue([ + { + messageId: 'user-1', + parentMessageId: Constants.NO_PARENT, + isCreatedByUser: true, + text: 'Hello', + }, + { + messageId: 'response-1', + parentMessageId: 'user-1', + isCreatedByUser: false, + text: 'Hi', + langfuseSampled: true, + langfuseDestinationIds: ['destination-a'], + langfuseRunId: 'run-a', + }, + ]); + + await forkConversation({ + originalConvoId: 'abc123', + targetMessageId: 'response-1', + requestUserId: 'user1', + option: ForkOptions.DIRECT_PATH, + }); + + const savedMessages = bulkSaveMessages.mock.calls[0][0]; + expect(savedMessages.map((message) => message.text)).toEqual(['Hello', 'Hi']); + expect( + savedMessages.every( + (message) => + message.langfuseSampled === false && + !('langfuseDestinationIds' in message) && + !('langfuseRunId' in message), + ), + ).toBe(true); + }); + test('drops child execution metadata while retaining its visible transcript', async () => { getConvo.mockResolvedValue({ ...mockConversation, diff --git a/api/server/utils/import/importers.js b/api/server/utils/import/importers.js index 181796c3398..d1d2edd29df 100644 --- a/api/server/utils/import/importers.js +++ b/api/server/utils/import/importers.js @@ -7,6 +7,7 @@ const { stripMessageUIResourceMarkers, } = require('@librechat/data-schemas'); const { EModelEndpoint, Constants, Tools, openAISettings } = require('librechat-data-provider'); +const { withoutTraceRefs } = require('@librechat/api'); const { getEndpointsConfig } = require('~/server/services/Config'); const { createImportBatchBuilder } = require('./importBatchBuilder'); const { resolveImportDefaultModel } = require('./defaults'); @@ -59,8 +60,8 @@ function sanitizeImportedMessage(message) { const text = castPersistedImportedText(message.text); const content = normalizeImportedArray(message.content); const attachments = normalizeImportedArray(message.attachments); - const importable = { ...message }; - /** Server-private run state never comes from an import. */ + /** Server-private run state and trace sampling records never come from an import. */ + const importable = withoutTraceRefs({ ...message }); delete importable.contextMeta; return { ...importable, diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js index fcc96162fe9..aa71772de5f 100644 --- a/api/server/utils/import/importers.spec.js +++ b/api/server/utils/import/importers.spec.js @@ -966,12 +966,15 @@ describe('importLibreChatConvo', () => { ]); }); - it('drops server-private context meta from imported messages', async () => { + it('drops server-private context meta and trace sampling from imported messages', async () => { const message = { messageId: 'message-1', parentMessageId: Constants.NO_PARENT, text: 'Imported response', isCreatedByUser: false, + langfuseSampled: true, + langfuseDestinationIds: ['forged-destination'], + langfuseRunId: 'someone-elses-run', contextMeta: { calibrationRatio: 1, encoding: 'claude', @@ -990,6 +993,9 @@ describe('importLibreChatConvo', () => { await importer(jsonData, 'user-123', () => importBatchBuilder); expect(importBatchBuilder.messages[0]).not.toHaveProperty('contextMeta'); + expect(importBatchBuilder.messages[0].langfuseSampled).toBe(false); + expect(importBatchBuilder.messages[0]).not.toHaveProperty('langfuseDestinationIds'); + expect(importBatchBuilder.messages[0]).not.toHaveProperty('langfuseRunId'); expect(importBatchBuilder.messages[0].isUserSubmitted).toBe(true); }); diff --git a/bun.lock b/bun.lock index c024ae78123..56f5d7c8a43 100644 --- a/bun.lock +++ b/bun.lock @@ -39,7 +39,7 @@ }, "api": { "name": "@librechat/backend", - "version": "0.8.8-rc2", + "version": "0.8.8-rc3", "dependencies": { "@anthropic-ai/vertex-sdk": "^0.16.0", "@aws-sdk/client-bedrock-runtime": "^3.1013.0", @@ -151,7 +151,7 @@ }, "client": { "name": "@librechat/frontend", - "version": "0.8.8-rc2", + "version": "0.8.8-rc3", "dependencies": { "@ariakit/react": "^0.4.29", "@ariakit/react-components": "^0.1.2", @@ -294,7 +294,7 @@ }, "packages/api": { "name": "@librechat/api", - "version": "1.7.47", + "version": "1.7.48", "dependencies": { "@langchain/langgraph-checkpoint": "^1.1.2", "@langchain/langgraph-checkpoint-mongodb": "^1.4.0", @@ -407,7 +407,7 @@ }, "packages/client": { "name": "@librechat/client", - "version": "0.4.77", + "version": "0.4.78", "devDependencies": { "@babel/core": "^7.28.5", "@babel/preset-env": "^7.29.5", @@ -490,7 +490,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.8.522", + "version": "0.8.523", "dependencies": { "axios": "^1.16.0", "dayjs": "^1.11.13", @@ -525,7 +525,7 @@ }, "packages/data-schemas": { "name": "@librechat/data-schemas", - "version": "0.0.69", + "version": "0.0.70", "dependencies": { "mdast-util-directive": "^3.0.0", "mdast-util-from-markdown": "^2.0.1", diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 7a32adf945e..8c01907f403 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -1,4 +1,4 @@ -/** v0.8.8-rc2 */ +/** v0.8.8-rc3 */ module.exports = { roots: ['/src'], testEnvironment: 'jsdom', diff --git a/client/package.json b/client/package.json index a8f070957c9..0072b1971cf 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/frontend", - "version": "v0.8.8-rc2", + "version": "v0.8.8-rc3", "description": "", "type": "module", "scripts": { diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index 7bad08b8196..5cbaa759410 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -52,6 +52,7 @@ export type AgentForm = { stateful_code_environment?: StatefulCodeEnvironment; /** Operator-configured managed or attached execution environment. */ code_environment_id?: string | null; + code_workspace_id?: string; /** Git authorship applied to sandboxed commands for this agent. */ git_identity?: Agent['git_identity']; provider?: AgentProvider | OptionWithIcon; diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index de800d6eb4d..24186fa708f 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -26,6 +26,7 @@ import { AskAnswerHostProvider } from './ask/state'; import MessagesView from './Messages/MessagesView'; import Presentation from './Presentation'; import ChatForm from './Input/ChatForm'; +import { TraceSurface } from './Trace'; import Landing from './Landing'; import Header from './Header'; import { cn } from '~/utils'; @@ -156,7 +157,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje -
+

{pageHeading}

{isLandingPage &&
} -
+
diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx index 02ce42ca2b9..240e83fa3fa 100644 --- a/client/src/components/Chat/Header.tsx +++ b/client/src/components/Chat/Header.tsx @@ -10,6 +10,7 @@ import { import { OpenSidebar, PresetsMenu, NewChat, HeaderMenu } from './Menus'; import { TemporaryChat, TemporaryChatIndicator } from './TemporaryChat'; import ModelSelector from './Menus/Endpoints/ModelSelector'; +import { TraceButton, useTraceControl } from './Trace'; import { useGetStartupConfig } from '~/data-provider'; import ExportAndShareMenu from './ExportAndShareMenu'; import SubagentThreadLink from './SubagentThreadLink'; @@ -36,6 +37,7 @@ function Header({ }) { const { data: startupConfig } = useGetStartupConfig(); const navVisible = useRecoilValue(store.sidebarExpanded); + const isSubmitting = useRecoilValue(store.isSubmittingFamily(0)); /** The mobile row only offers a new chat when there is one to leave. Read * from the route rather than the context conversation, which still holds the @@ -64,6 +66,14 @@ function Header({ permission: Permissions.USE, }); + /** Child threads are view-only records of their parent's run and have no trace of their own. */ + const trace = useTraceControl({ + conversationId: isNewChat ? null : routeConversationId, + traceViewer: interfaceConfig.traceViewer, + isSubmitting, + enabled: parentConversationId == null, + }); + /** The drawer covers the header on mobile; keep its controls out of the tab order. */ const hiddenBehindNav = navVisible === true && 'max-md:hidden'; @@ -101,8 +111,9 @@ function Header({
{hasAccessToTemporaryChat === true && } {!isNewChat && } - +
+ {trace.show && } {hasAccessToTemporaryChat === true && }
diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index f7e640a21af..6bc3a9715b4 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -650,6 +650,28 @@ const ChatForm = memo(function ChatForm({ onRestoreToComposer={restoreReclaimedSteer} /> )} + {(project || + (codeWorkspace.required && (!codeWorkspace.locked || !codeWorkspace.canSubmit))) && ( +
+ {project ? : null} + {codeWorkspace.required && (!codeWorkspace.locked || !codeWorkspace.canSubmit) ? ( +
+ +
+ ) : null} +
+ )}
- {project ? : null} {quotesEnabled && ( @@ -782,11 +803,6 @@ const ChatForm = memo(function ChatForm({ onFocus={handleTextareaFocus} onBlur={handleTextareaBlur} aria-label={localize('com_ui_message_input')} - aria-describedby={ - codeWorkspace.state === 'choose' || codeWorkspace.state === 'missing' - ? `code-workspace-hint-${index}` - : undefined - } onClick={handleFocusOrClick} style={{ height: 44, overflowY: 'auto' }} className={cn( @@ -805,15 +821,6 @@ const ChatForm = memo(function ChatForm({
)} - {(codeWorkspace.state === 'choose' || codeWorkspace.state === 'missing') && ( -

- {localize('com_error_code_workspace_required')} -

- )}
- {index === 0 && conversationId != null && ( )} @@ -964,6 +965,7 @@ function ChatFormWrapper({ conversation?.model, conversation?.maxContextTokens, conversation?.codeApprovalMode, + conversation?.codeEnvironmentMode, conversation?.codeWorkspaces, hasMessages, ], diff --git a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx index 4af4cd4abd3..e7f0560f270 100644 --- a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx +++ b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import * as Ariakit from '@ariakit/react'; import { Check, ChevronDown, Folder, FolderX } from 'lucide-react'; import { TooltipAnchor, composerControlClasses } from '@librechat/client'; @@ -14,15 +13,14 @@ const stateLabels: Partial missing: 'com_ui_code_workspace_missing', unavailable: 'com_ui_code_workspace_unavailable', unsupported: 'com_ui_code_workspace_unsupported', + without_attached: 'com_ui_code_workspace_without_attached', }; export default function CodeWorkspaceMenu({ - conversation, setConversation, workspace, disabled, }: { - conversation: TConversation | null; setConversation: SetterOrUpdater; workspace: CodeWorkspaceResult; disabled: boolean; @@ -31,42 +29,46 @@ export default function CodeWorkspaceMenu({ const menuStore = Ariakit.useMenuStore({ focusLoop: true, placement: 'top-start' }); const isOpen = menuStore.useState('open'); - /** A single advertised root per reachable environment is an unambiguous - * initial choice. Once a conversation owns any binding, even a partial or - * stale set, only explicit user actions may replace or complete it. */ - useEffect(() => { - if (conversation?.codeWorkspaces != null || workspace.selections == null) return; - setConversation((current) => - current == null || current.codeWorkspaces != null - ? current - : { ...current, codeWorkspaces: workspace.selections }, - ); - }, [conversation?.codeWorkspaces, setConversation, workspace.selections]); - if (!workspace.required) return null; const environmentIds = new Set(workspace.environments.map(({ environment }) => environment.id)); const selectWorkspace = (selection: CodeWorkspaceSelection) => { + workspace.rememberSelection(selection); setConversation((current) => { if (current == null) return current; - const retained = (current.codeWorkspaces ?? []).filter( + const retained = (current.codeWorkspaces ?? workspace.selections ?? []).filter( ({ environmentId }) => environmentIds.has(environmentId) && environmentId !== selection.environmentId, ); return { ...current, + codeEnvironmentMode: 'attached', codeWorkspaces: [...retained, selection].sort((a, b) => a.environmentId.localeCompare(b.environmentId), ), }; }); }; + const selectWithoutAttached = () => { + setConversation((current) => + current == null + ? current + : { + ...current, + codeEnvironmentMode: 'without_attached', + codeWorkspaces: undefined, + }, + ); + }; const onlyEnvironment = workspace.environments.length === 1 ? workspace.environments[0] : null; const onlyDescriptor = onlyEnvironment?.workspaces.find( ({ id }) => id === onlyEnvironment.selected?.workspaceId, ); const labelKey = stateLabels[workspace.state]; - let label = onlyDescriptor?.name ?? onlyDescriptor?.id; + let label = + workspace.mode === 'without_attached' + ? localize('com_ui_code_workspace_without_attached') + : (onlyDescriptor?.name ?? onlyDescriptor?.id); if (label == null && workspace.state === 'ready') { label = localize('com_ui_code_workspaces_selected', { 0: workspace.selections?.length ?? 0, @@ -74,9 +76,33 @@ export default function CodeWorkspaceMenu({ } else if (label == null) { label = labelKey ? localize(labelKey) : localize('com_ui_code_workspace_choose'); } - const canChoose = workspace.environments.some(({ workspaces }) => workspaces.length > 0); const Icon = - workspace.state === 'missing' || workspace.state === 'unavailable' ? FolderX : Folder; + workspace.mode === 'without_attached' || + workspace.state === 'missing' || + workspace.state === 'unavailable' + ? FolderX + : Folder; + + if (workspace.locked) { + if (workspace.canSubmit) return null; + const recovery = localize('com_ui_code_workspace_locked_recovery'); + return ( + + } + > + + ); + } return ( @@ -85,100 +111,131 @@ export default function CodeWorkspaceMenu({ disabled={isOpen} render={ } >