mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-10 08:31:37 +00:00
* 🧊 fix: Inline-refresh OpenID session tokens at MCP OBO call time Resolves the walk-away failure mode where MCP tool calls using OBO auth fail with "No valid OpenID access token is available for OBO exchange" after a user idles past their access-token lifetime. The strategy-time snapshot on `user.federatedTokens` could expire mid-stream before `resolveOboToken` ran, while `req.session.openidTokens` carried a still- valid (or refreshable) token that nothing read. - New OpenIDSessionRefresh service: per-user single-flighted closure that reads `req.session.openidTokens` at OBO time and inline-refreshes via `openid-client.refreshTokenGrant` when expired (30s skew), persisting via `req.session.save()`. No cookie writes (headers already flushed). - `resolveOboToken` gains a required UpstreamTokenProvider parameter (typed as `() => Promise<OIDCTokens | null>`, reusing the shared shape from @librechat/data-schemas). Compile-time guarantee that every call site is updated. - New `session_refresh_failed` OboTokenResolutionReason distinguishes "session expired and IdP rejected refresh" from "no upstream token ever existed." - `req` threaded through createMCPTool/createMCPTools/createToolInstance to construct the closure with captured request, plus fail-closed guards in MCPConnectionFactory.getOboTokens and MCPManager.callTool when the closure isn't plumbed. - Startup warning in MCPServersInitializer when OBO is configured but OPENID_REUSE_TOKENS is unset (the strategy populating user.federatedTokens is only registered under reuse, so OBO would fail every call without it). Tests: 16 new in OpenIDSessionRefresh.spec.js; obo.spec.ts extended for the new param + error reason; wiring smoke tests in MCPManager, MCPConnectionFactory, MCPServersInitializer, and MCP.spec.js. * 🛡️ fix: Harden OBO inline-refresh against token type and session edge cases - Token-preference asymmetry: live-token reuse and expires_at derivation now strictly gate on the access_token, not the id_token. Added a required `tokenPreference` parameter on isLiveSessionTokenStillValid, buildOIDCTokensFromSession, and createOpenIDSessionTokenProvider so every call site is explicit. Dropped the bogus id_token-exp fallback in performIdpRefresh — id_token TTL is governed by IdP session policy and would mark a short-lived access_token reusable past its real lifetime. - Missing req in /reinitialize route: the manual reconnect endpoint now forwards req into reinitMCPServer, so OBO servers can build a session-aware upstream-token closure instead of failing with missing_upstream_token. - Single-flight key collisions: composed key as tenantId:openidIssuer:openidId:sessionId via getSingleFlightKey. Concurrent calls in the same session still coalesce; separate sessions never share an in-flight refresh, preventing refresh-token rotation from breaking sibling sessions and preventing cross-tenant token crossover when distinct users share an IdP sub. - Opaque access token reuse): persist accessTokenExpiresAt (unix seconds, from tokenset.expires_in) on each refresh AND on initial login / SPA refresh in setOpenIDAuthTokens. New getAccessTokenExp helper falls back to it when the access token isn't a JWT, avoiding redundant inline refreshes for Microsoft Graph and Auth0 default audiences. - Log hygiene: the single-flight key (containing sessionId, openidId, openidIssuer, tenantId) is now SHA-256-hashed in the "Joining in-flight refresh" debug log. Preserves cross-line correlation via a 12-char prefix without leaking credential or PII material. Documented req.session.openidTokens shape contract via JSDoc typedef so the new accessTokenExpiresAt field has a discoverable home alongside the existing accessToken/idToken/refreshToken/expiresAt/lastRefreshedAt. Tests: OpenIDSessionRefresh.spec.js up to 30 passing (added coverage for opaque-token reuse, JWT-access-token-exp fallback, no-id_token-fallback regression, cross-session no-coalesce, persistence on refresh, and a guard against stale accessTokenExpiresAt carryover). AuthService.spec.js adds two cases covering accessTokenExpiresAt persistence on login. mcp.spec.js (route) gains a regression test asserting req flows into reinitMCPServer. * 🔍 fix: Detect OBO-only MCP admin config overrides Admin Config overlays for YAML-defined MCP servers compare only ADMIN_CONFIGURABLE_FIELDS to decide whether to lazy-init a config-tier override. The OBO config field was added after that fingerprint list, so an override that only added or changed `obo` was treated as unchanged YAML and skipped. Include `obo` in the admin-configurable field list and add a regression test for an OBO-only override. * 🔊 fix: Mock MCP OAuth timeout in SDK integration test MCPConnectionFactory.attemptToConnect reads mcpConfig.OAUTH_HANDLING_TIMEOUT when building the OAuth connection timeout. The SDK OAuth integration test mocked mcpConfig without that field, which made the timeout calculation produce NaN and caused the test to fail before the OAuth refresh/start path completed. Add OAUTH_HANDLING_TIMEOUT to the test mock. * ♻️ refactor: Pass OBO upstream-token closure into MCP instead of req Build the OpenID upstream-token provider at the request boundary and thread only the closure through MCP handling, so the MCP service layer no longer receives the raw Express request. The closure still reads/refreshes the live session at tool-call time, preserving the walk-away recovery. - Drop `req`/`capturedReq` from createMCPTools, createMCPTool, reconnectServer, createToolInstance, and reinitMCPServer; forward `upstreamTokenProvider` instead. Closure is constructed in loadTools, loadToolDefinitionsWrapper, and the reinitialize route, where req/res are in scope. - OBO: fall back to user.federatedTokens when the provider yields no live session, so OIDC remote-agent calls (verified bearer, no session) still work. - Inline refresh: mirror a rotated refresh token to the refreshToken cookie via a shared setRefreshTokenCookie helper, guarded by !res.headersSent (no-op on the streaming path; session copy stays authoritative). - Single-flight: hydrate a joining request's own session from the resolved tokens so a later OBO call doesn't replay a rotated-away refresh token. Addresses owner feedback and three review findings. * 🔒 fix: Recover OIDC refresh-token rotation after SSE OBO refresh When an inline OBO refresh rotates the OpenID refresh token after SSE headers have already been sent, the browser refreshToken cookie cannot be updated. Store a short-lived encrypted bridge from the stale cookie token to the rotated token so /api/auth/refresh can recover after express-session loss. Use the signed openid_user_id cookie to load user context for bridge validation, retry only on invalid_grant, and delete the bridge only after the bridged refresh succeeds. * 🔨 fix: hydrate joined OIDC refresh sessions with stable refresh tokens Update single-flight OIDC refresh joiners whenever refreshed access token state changes, even if the IdP keeps the refresh token unchanged. This prevents joined requests from retaining stale accessToken or accessTokenExpiresAt values and redundantly refreshing later in the same run. * 🌉 Persist OIDC refresh-token recovery bridges in MongoDB Store SSE OBO refresh-token recovery bridges in MongoDB instead of process-local memory so /api/auth/refresh can recover after worker restarts or cross-worker routing. Derive bridge expiry from REFRESH_TOKEN_EXPIRY so the recovery window matches the stale refreshToken cookie it repairs, and delete bridges after successful recovery. * 🤝 Coordinate OIDC inline refreshes across workers Add a short-lived Mongo-backed refresh-flight record so concurrent OBO refreshes for the same OpenID session do not redeem the same rotating refresh token on different workers. The winning worker performs the IdP refresh and stores an encrypted result; joiners wait for that result, hydrate their request session, and return without calling the IdP. * ⚓ Keep OpenID marker cookies aligned on inline refresh Refresh token_provider and openid_user_id with the same expiry as the rotated refreshToken cookie when an inline OBO refresh can still write headers. Share the marker-cookie writer with the normal OpenID auth refresh path so the fallback /api/auth/refresh branch continues to recognize valid OpenID refresh tokens after session expiry. * 🔑 fix: include refresh token in OIDC local refresh flight key Key the process-local OIDC refresh coalescing by the current session refresh token, matching the Mongo-backed flight key. This prevents a request with a newly rotated token from joining an older pending refresh and inheriting its failure/result. * 🌉 fix: store OIDC refresh bridge without cookie response Treat missing or non-cookie responses like headers-sent streaming responses during inline OIDC refresh. When the IdP rotates the refresh token and cookies cannot be written, persist a recovery bridge so a later /auth/refresh can recover after session expiry. * 🫙 fix: preserve stale OIDC cookie bridge key Track the refresh token last written to the browser cookie separately from the current session refresh token. When inline OIDC refreshes rotate tokens without a writable response, keep bridging from the browser-stale token directly to the latest session token. * 🙌 fix: keep OIDC bridge recovery success on cleanup failure Make refresh-token bridge cleanup best-effort after a bridged OIDC refresh succeeds. A transient delete failure now logs a warning but does not convert the already-refreshed session and cookies into a 403 response. * 📦 test: Exclude RefreshTokenBridge from tenant-isolation coverage Add RefreshTokenBridge to the tenant-isolation coverage allowlist because refresh bridge lookups run during unauthenticated OpenID refresh recovery. The controller first recovers user context from the signed OpenID marker cookie, then the bridge methods apply explicit user and tenant filters. Ambient tenant isolation would bind this recovery path to request-local tenant context that is not available at the point the stale cookie is being resolved * ⚡ Fix OpenID refresh flight retry and marker hydration Allow failed OpenID refresh flights to be reclaimed immediately instead of pinning transient errors. Preserve the browser refresh-token marker when joined refreshes hydrate session tokens from a shared flight result. Stabilize AuthService tests by isolating mocked module imports from prior suites. * 🛠️ fix: centralize OBO identity scoping Add shared auth identity helpers for app user ids, OpenID subjects, tenant ids, and normalized OpenID issuers. Thread a non-placeholder-visible OBO identity context from the real request user through MCP connection, tool-call, reinit, and refresh paths. Keep tenantId and openidIssuer out of createSafeUser so MCP user placeholders do not expose those fields. Scope OBO token cache and in-flight exchange keys by tenant, issuer, OpenID subject, scopes, and a SHA-256 hash of the upstream assertion. This prevents cross-tenant/cross-issuer collisions and avoids reusing tokens minted from stale rotated assertions. Use the shared identity helpers for OpenID refresh-flight keys and refresh-token bridge recovery records so related OBO refresh paths share the same identity normalization rules. The helper is intended for auth-boundary and credential-cache code, not as a blanket replacement for ordinary app user id ownership checks. * 🛠️ fix: preserve OIDC refresh-token sync on save failures Sync OpenID refresh-token cookie/bridge state before persisting the session so a transient session-store failure cannot lose an IdP-rotated refresh token. Also trigger sync when the session refresh token differs from the browser refresh-token marker, not only when the current grant rotates the token. This lets later writable refreshes repair stale browser cookies left behind by SSE refreshes. Route refresh bridge identity through the shared identity helper with the threaded OBO identity context, falling back to request/user context when needed. Add regression coverage for session-save failures, stale browser cookie repair, non-writable bridge storage, and shared-helper identity fallback. * 🛠️ fix: keep OIDC refresh bridge during recovery grace After successful bridged refresh recovery, re-store the stale-cookie bridge with a short grace TTL instead of deleting it immediately. This lets parallel /api/auth/refresh requests that already sent the stale browser cookie recover before they can observe the first response's Set-Cookie. Retarget the bridge to the refresh token returned by the bridged retry so B-to-C refresh-token rotation remains recoverable. The grace TTL is parsed with math() and defaults to 60s, which shrinks the replay window from the original REFRESH_TOKEN_EXPIRY bridge lifetime to the short recovery grace period. Remove the now-unused explicit bridge delete path from the service and data-schemas method surface. Add regression coverage for grace re-store, identity symmetry, retry failure behavior, and same-key upsert replacement. * 🛠️ fix: fail closed on OBO MCP user identity mismatch Add an OBO-specific guard before MCP tool execution that requires the effective invocation user and captured request user to both have ids and to match. This prevents OBO tool calls from falling back to a separate configurable.user_id identity after request-bound OBO context has already been captured. Keep the existing user id fallback behavior for non-OBO MCP calls. Tests cover mismatched OBO users, missing user ids, and the matching-user path ignoring a conflicting configurable.user_id. * 🛠️ fix: Guard OpenID bridge retry user identity Extract the shared OpenID refresh/user-resolution flow in AuthController so the normal refresh path and bridge-recovery retry use the same grant, claims, issuer, user lookup, and diagnostic logging code. Preserve the existing path-specific behavior: the normal path still owns migration updates and 401 login redirects, while the bridge retry still falls through to the existing 403 invalid-token response. Add a bridge-recovery guard that rejects retry results whose resolved user id differs from the signed openid_user_id cookie before issuing tokens or re-storing the grace bridge. Cover both the successful matching-user recovery and the mismatched-user rejection. * 🛠️ fix: type-safety polish on OBO data layer Replace refresh token bridge query/update Record<string, unknown> usage with typed Mongoose FilterQuery and UpdateQuery definitions. Harden OpenID marker cookie JWT expiry handling by converting refresh expiry milliseconds to integer seconds and rejecting invalid or non-positive durations. Add focused CSRF tests for fractional refresh expiry values and invalid expiry configuration. * 🛠️ fix: Bind OpenID session tokens to authenticated identity Stamp OpenID session token state with the LibreChat user id, OpenID subject, tenant id, and normalized issuer when tokens are stored. Fail closed before OBO inline token reuse/refresh when the session token identity does not match the current authenticated identity, preventing a stale or mixed Express session from supplying another user's upstream assertion. Also validate the normal /api/auth/refresh session-token reuse shortcut against the signed marker-cookie user before returning cached session tokens. Note: sessions created before this change carry no identity stamp and are treated as a mismatch. This is self-healing — the reuse path forces a full IdP refresh (which re-stamps the session) and the OBO path throws, surfacing as a one-time re-authentication for active OBO users at deploy time. The session re-stamps within one session lifetime (SESSION_EXPIRY, default 15 min). * 🛠️ fix: Recover OpenID refresh token drift Prefer the browser refresh-token cookie when it differs from the server-side OpenID session state, and force a real IdP refresh in that case instead of reusing stale session tokens. Store a short-lived refresh-token bridge when inline OBO refresh writes a rotated browser cookie but session persistence fails, so follow-up refreshes can still recover from the old token. Keep the bridge grace TTL centralized in RefreshTokenBridge so both recovery paths use the same env-backed value. Note: drift is measured against the last-synced browserRefreshToken marker, so the SSE path (intentionally stale cookie, authoritative session) does not false-positive. Sessions predating the marker have no browserRefreshToken; for those, drift falls back to comparing the cookie against the session refresh token and prefers the cookie on difference. This is the same self-healing pre-change-session window as the identity binding fix and re-syncs within one session lifetime. Tests cover cookie/session drift selection, reusable-session bypass on drift, bridge storage after session-save failure, and the shared bridge constant wiring. * 🛠️ fix: Harden OBO token caching and expiry handling Reject malformed OBO grant responses before writing them to the exchanged-token cache so a missing access_token cannot poison the cache. Store absolute expires_at values with cached OBO tokens and ignore legacy cache entries without usable expiry metadata. This keeps cached-token freshness based on the token’s real remaining lifetime instead of reusing the original relative expires_in on cache hits. Move OBO expiry normalization and skew helpers into packages/api and use them from both the JS exchange service and the TS MCP resolver. Apply a 30-second safety margin with a one- second floor for short-lived tokens, covered by direct helper tests and caller-level regression tests. Tests: - packages/api: npm run build - packages/api: npx jest src/mcp/oauth/expiry.spec.ts src/mcp/oauth/obo.spec.ts - api: npx jest server/services/OboTokenService.spec.js * 🛠️ fix: Harden OBO refresh-token bridge lookup and indexing Reuse getValidOpenIDReuseUserId for the bridge-recovery user lookup in refreshController instead of re-verifying openid_user_id inline. The shared helper enforces the JWT_REFRESH_SECRET presence check and a strict typeof payload.id === 'string' guard, rejecting tokens whose id claim is present but not a string (e.g. a numeric id) that the inline check accepted. Fail closed on issuer mismatch in getRefreshTokenBridge. Both the stored and the expected issuer are now normalized and compared for equality, so a bridge is recovered only when both sides agree (both absent, or both present and equal after normalization). Previously the check was skipped whenever the stored issuer was absent, allowing recovery across mismatched issuer context. Drop the unused {oldRefreshTokenHash, userId, tenantId, openidIssuer} index and the openidIssuer field on RefreshTokenBridgeQuery. The data-layer filter only queries the 3-field {oldRefreshTokenHash, userId, tenantId} index; the issuer is verified in application code, not the query. Hoist the repeated model accessor into getRefreshTokenBridgeModel. Note: issuer is now load-bearing for recovery. A bridge stored with an issuer recovers only when the lookup supplies a matching issuer; the recovery lookup reads user.openidIssuer via AUTH_REFRESH_USER_PROJECTION (an exclusion projection that retains the field). If a user's persisted openidIssuer is empty while the stored bridge has one, recovery fails closed (falls through to normal re-authentication) until the bridge TTLs out — no security regression. Tests cover invalid signed-cookie payloads bypassing the bridge, both asymmetric issuer-presence cases, issuer normalization before comparison, and an index-alignment assertion guarding against re-adding the dropped index. * 🛠️ fix: Degrade OBO discovery on token resolution failures Catch expected OboTokenResolutionError failures during MCP tool discovery and fall back to unauthenticated tool listing instead of aborting discovery. This keeps discovery aligned with the existing unauthenticated listing behavior while preserving unexpected errors as real failures. Also correct OBO tool-call freshness comment and tighten the OBO trust-check permissions type to the existing role permission shape. Tests: - npx jest src/mcp/__tests__/MCPConnectionFactory.test.ts --runInBand --coverage=false - npx jest src/mcp/oauth/obo.spec.ts --runInBand --coverage=false * 🛠️ fix: tighten OBO tool-call errors, bridge logging, and flight typing Move resolveToolCallUserId inside the tool-call try/catch so an OBO identity mismatch surfaces with serverName/toolName context and the standard tool-call-failed message instead of an opaque bare Error. Raise the refresh-token bridge lookup failure log from debug to warn so transient infrastructure failures on the unauthenticated /api/auth/refresh path are observable, and guard the message access against non-Error values. Replace the unknown+cast in isDuplicateKeyError with a hasErrorCode type predicate so the duplicate-key check reads error.code without an assertion. Preserve real math/isEnabled in the MCPConnectionFactory test mock (mock only processMCPEnv) so mcpConfig timeouts no longer resolve to NaN, fixing the TimeoutNaNWarning that masked slow OAuth retry behavior. * 🧪 fix: Restore the Flight Uniqueness Index and Buffer the Graph Cache TTL Two CI failures on the merge, both in suites this environment cannot run (their MongoDB binary download is blocked). `GraphApiService.spec.js` still asserted the unbuffered TTL. Graph tokens route through the same `getTokenCacheTtlMs` as the OBO and openidStrategy caches, so the entry now expires 30s before the credential does. `openidRefreshFlight.spec.ts` dropped the database between tests, which takes the indexes with it, and Mongoose builds them only once when the model is compiled. Whether the unique `key` index survived into a test was a race with that one-time build. Without it a second `create` inserts instead of raising a duplicate-key error, so every worker believes it won the flight — the mutual exclusion the file exists to prove. Indexes are now rebuilt after each drop, which also makes the reclaim and complete cases reach those paths for the right reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F * fix: address OBO review findings * 🔐 fix: Install Bridge Indexes and Carry OBO Through Assistant Recovery Two findings from the Codex pass ond08c82f0d. The refresh-token bridge relied on Mongoose auto-indexing for both of its indexes, and `MONGO_AUTO_INDEX=false` is a supported deployment setting. A bridge holds an encrypted refresh token and the TTL index is the only thing that ever deletes one, so under that setting they would accumulate for the life of the collection while concurrent upserts lost the compound uniqueness the filter assumes. Installed before the first write, matching the flight methods and the session and schedule methods before them. `recoverServerTools`, the assistant create/update path that reruns `reinitMCPServer` when a referenced server's catalog and connection snapshot are both missing, was the last reinit site not carrying the upstream-token closure. For an OBO server the factory rejects the connection outright, so the assistant write failed with unavailable MCP definitions. It now builds the provider at that request boundary like the other entry points; assistant writes have no `res`, so a rotation there falls back to the recovery bridge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F * fix: harden OBO refresh coordination * ⏳ fix: Keep Elapsed Expiries Elapsed and Revoke the Superseded Session Two of the five findings from the Codex pass onfcdc15885— the two that are defects in code this branch introduced rather than design questions about the bridge. `getSkewedTokenExpiresAtMs` floored every result at a second in the future, including an expiry the provider had already declared elapsed. An exchange answering `expires_in: 0` or a past `expires_at` was handed to the MCP connection stamped valid for another second, which only moves the failure downstream. The floor now applies to a lifetime that is still live, which is what it was for; an elapsed one stays elapsed so the caller rejects it. Same for the cache TTL, which falls back to the elapsed-credential floor. Bridge recovery left the stale token's durable Session behind. Only the token it recovered through was passed as `existingRefreshToken`, so that one's session was replaced while the token the browser actually presented kept its record until its original expiry. That record, with the marker cookie still bound to it, is what authorizes local image access for OpenID users — so a copy of the stale cookie outlived the rotation it had lost. Revoked explicitly on successful recovery. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F * fix: close OBO refresh review findings * 🎟️ fix: Carry the Bridged Token Through a Non-Rotating Recovery Bridge recovery passes the browser's stale token as `existingRefreshToken` so the durable Session naming it is the record replaced. That also makes the stale token the fallback the installed session and the refresh cookie use when a tokenset carries no `refresh_token` of its own, which holds only while the recovery grant rotates. An IdP that answers that grant without rotating sends the browser back to the very token the bridge exists to retire: `storeOpenIDSession` installs and deletes the same stale record in one call, and the cookie is rewritten to a token the IdP already rejected — a sign-out on the next refresh. The grace bridge one line above already guards this with `|| bridgedRefreshToken`; the resolved tokenset now does the same, so leader and followers alike publish the recovered token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F * 🚫 fix: Reject an OBO Exchange That Returns an Expired Credential Preserving an elapsed expiry through the skew helper only helps if something acts on it, and nothing did: `MCPManager.callTool` checks the access token and nothing else before setting the Authorization header, so a credential the IdP declared spent still went downstream to fail there. It is rejected at the exchange now, where the reason is known, and retryably — the exchange itself worked, so a fresh grant can succeed. Completes the elapsed-expiry change incb26a6f7d, which made the stamp honest without giving anyone a reason to look at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F * fix: close OpenID refresh review findings * fix: coordinate OpenID refresh entry points * chore: sort OpenID flight imports * fix: fence OpenID refreshes during logout * fix: close OpenID logout publication races * fix: narrow completed refresh flight * test: cover bridge cleanup failure after ownership loss The compensating delete in storeRefreshTokenBridgeWithLease swallows its own failure so the lease error stays the one the caller sees. Nothing asserted that, so removing the inner catch left every suite green while callers began receiving the cleanup error instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F * fix: compensate OpenID bridges only on proven ownership loss The post-write lease assertion deletes the bridge it just published when it throws, but it threw for two different reasons: a coordination record that is no longer ours, and a coordination read that simply failed. Treating the second as the first destroys the only mapping from the token the browser still holds to the one the IdP already rotated to, so a transient Mongo error on the headers-already-sent path signed the user out. Tag the ownership error where the lease raises it and compensate only for that, preserving the bridge whenever ownership is merely undetermined. A preserved bridge stays behind the logout revocation fence, so the safe default costs nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F * fix: reject spent OpenID refresh results Two ways a refresh could report success while handing back a credential nothing can use. An inline refresh carries the previous id_token forward when the IdP omits one on rotation, so tokenset.id_token is not necessarily freshly issued. setOpenIDAuthTokens applied its freshness guard only to the session copy and took tokenset.id_token unconditionally, so /refresh returned an expired bearer even though the grant produced a usable access token. Skip it only when it is provably expired: an id_token whose expiry cannot be read stays preferred, since access_token may be opaque or scoped to another audience. normalizeExpiresIn preserves a zero or negative lifetime rather than discarding it, so a grant declaring an already-spent access token still published, rotating the refresh token and returning a token every freshness check rejects. Each OBO call then repeated the grant. Reject an elapsed lifetime before publishing; an unknown lifetime still publishes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F * fix: harden OpenID refresh publication * fix: keep identity and results intact through OpenID refresh cleanup Two follow-ons from the last round's fixes. Stripping an expired carried-forward id_token from the refresh result removed the only identity material a rotation without id_token leaves behind. The result is rebuilt by buildOIDCTokensFromSession, so it carries no provider claims() either, and getTokenClaims accepts only those two — bridge recovery failed with "no usable identity claims" before setOpenIDAuthTokens could hand back the fresh access token. The stripped token now travels in a non-enumerable marker, alongside the existing browser and predecessor markers, which identity resolution reads and the authentication response never sees. The lease drained a pending renewal by awaiting it in finally, so a transient coordination failure there threw from finally and replaced the operation's result. The refresh had already settled and published, so the caller saw a failure on credentials that had rotated. Proven ownership loss is recorded on ownershipLost and checked before the return, so the drain has nothing to add but noise; it now absorbs and logs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F * refactor: fence OpenID recovery publication * fix: close OpenID publication transaction * refactor: make OpenID publication transactional * fix: satisfy OpenID publication type checks * fix: fence OpenID session publication * fix: authorize OpenID refresh publication * fix: bind OpenID replay generations * fix: fence OpenID response generations * fix: authorize OpenID token delivery * fix: linearize OpenID publication delivery * chore: sort OpenID refresh flight imports --------- Co-authored-by: J.C. Bartle <jcbartle@users.noreply.github.com> Co-authored-by: jbartle <jbartle@rand.org> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: jcbartle <7274202+jcbartle@users.noreply.github.com>
3526 lines
126 KiB
JavaScript
3526 lines
126 KiB
JavaScript
const { createHash } = require('node:crypto');
|
|
const { Constants: AgentConstants } = require('@librechat/agents');
|
|
const {
|
|
Tools,
|
|
Constants,
|
|
ResourceType,
|
|
ErrorTypes,
|
|
EToolResources,
|
|
EModelEndpoint,
|
|
isActionTool,
|
|
actionDelimiter,
|
|
AgentCapabilities,
|
|
defaultAgentCapabilities,
|
|
} = require('librechat-data-provider');
|
|
|
|
const mockGetEndpointsConfig = jest.fn();
|
|
const mockGetMCPServerTools = jest.fn();
|
|
const mockGetCachedTools = jest.fn();
|
|
const mockSendEvent = jest.fn();
|
|
const mockEmitChunk = jest.fn();
|
|
const mockResolveCodeExecutionContext = jest.fn(
|
|
({ statefulSessions, environment, userId, agentId, conversationId }) => {
|
|
if (!statefulSessions) {
|
|
return {
|
|
baseUrl: (process.env.LIBRECHAT_CODE_BASEURL ?? 'https://api.librechat.ai').replace(
|
|
/\/$/,
|
|
'',
|
|
),
|
|
codeSessionKey: 'execute_code',
|
|
executionProfile: 'default',
|
|
statefulSessions: false,
|
|
};
|
|
}
|
|
const baseUrl = process.env.LIBRECHAT_CODE_BASEURL_STATEFUL?.replace(/\/$/, '');
|
|
if (!baseUrl) {
|
|
throw new Error('LIBRECHAT_CODE_BASEURL_STATEFUL is not configured');
|
|
}
|
|
const fingerprint = (...parts) =>
|
|
createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 32);
|
|
let runtimeSessionHint = `v2:user:${fingerprint(userId)}`;
|
|
if (environment === 'agent-user') {
|
|
runtimeSessionHint = `v2:agent-user:${fingerprint(userId, agentId)}`;
|
|
} else if (environment === 'conversation') {
|
|
runtimeSessionHint = `v2:conversation:${fingerprint(userId, conversationId)}`;
|
|
}
|
|
return {
|
|
baseUrl,
|
|
codeSessionKey: `execute_code:stateful:${runtimeSessionHint}`,
|
|
executionProfile: 'stateful',
|
|
runtimeSessionHint,
|
|
statefulSessions: true,
|
|
};
|
|
},
|
|
);
|
|
const mockPrimeSearchFiles = jest.fn().mockResolvedValue({});
|
|
const mockPrimeCodeFiles = jest.fn().mockResolvedValue({});
|
|
jest.mock('~/server/services/Config', () => ({
|
|
getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args),
|
|
getMCPServerTools: (...args) => mockGetMCPServerTools(...args),
|
|
getCachedTools: (...args) => mockGetCachedTools(...args),
|
|
}));
|
|
|
|
const mockLoadToolDefinitions = jest.fn();
|
|
const mockGetUserMCPAuthMap = jest.fn();
|
|
jest.mock('@librechat/api', () => ({
|
|
...jest.requireActual('@librechat/api'),
|
|
AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
|
isFatalAgentInitializationError: (error) =>
|
|
['AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE', 'resource_recovery_required'].includes(error?.code),
|
|
loadToolDefinitions: (...args) => mockLoadToolDefinitions(...args),
|
|
getUserMCPAuthMap: (...args) => mockGetUserMCPAuthMap(...args),
|
|
createAuthIdentityContext: ({ user, tenantId }) => ({
|
|
appUserId: user?._id?.toString?.() ?? user?.id,
|
|
openidSubject: user?.openidId,
|
|
tenantId: tenantId ?? user?.tenantId,
|
|
openidIssuer: user?.openidIssuer,
|
|
}),
|
|
sendEvent: (...args) => mockSendEvent(...args),
|
|
GenerationJobManager: {
|
|
emitChunk: (...args) => mockEmitChunk(...args),
|
|
},
|
|
resolveCodeExecutionContext: (...args) => mockResolveCodeExecutionContext(...args),
|
|
}));
|
|
|
|
const mockLoadToolsUtil = jest.fn();
|
|
jest.mock('~/app/clients/tools/util', () => ({
|
|
loadTools: (...args) => mockLoadToolsUtil(...args),
|
|
}));
|
|
|
|
const mockLoadActionSets = jest.fn();
|
|
const mockDomainParser = jest.fn();
|
|
const mockLegacyDomainEncode = jest.fn();
|
|
const mockDecryptMetadata = jest.fn();
|
|
const mockCreateActionTool = jest.fn();
|
|
const mockGetServerConfig = jest.fn();
|
|
const mockFlowManager = { getFlowState: jest.fn() };
|
|
const mockResolveConfigServers = jest.fn();
|
|
const mockResolveMcpServerNames = jest.fn();
|
|
const mockUserCanUseMCPServers = jest.fn().mockResolvedValue(true);
|
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
|
loadAuthValues: jest.fn().mockResolvedValue({}),
|
|
}));
|
|
jest.mock('~/server/services/Tools/search', () => ({
|
|
createOnSearchResults: jest.fn(),
|
|
}));
|
|
jest.mock('~/server/services/Tools/mcp', () => ({
|
|
reinitMCPServer: jest.fn(),
|
|
}));
|
|
jest.mock('~/server/services/Files/process', () => ({
|
|
processFileURL: jest.fn(),
|
|
uploadImageBuffer: jest.fn(),
|
|
}));
|
|
jest.mock('~/app/clients/tools/util/fileSearch', () => ({
|
|
primeFiles: (...args) => mockPrimeSearchFiles(...args),
|
|
}));
|
|
jest.mock('~/server/services/Files/Code/process', () => ({
|
|
primeFiles: (...args) => mockPrimeCodeFiles(...args),
|
|
}));
|
|
jest.mock('../ActionService', () => ({
|
|
loadActionSets: (...args) => mockLoadActionSets(...args),
|
|
decryptMetadata: (...args) => mockDecryptMetadata(...args),
|
|
createActionTool: (...args) => mockCreateActionTool(...args),
|
|
domainParser: (...args) => mockDomainParser(...args),
|
|
legacyDomainEncode: (...args) => mockLegacyDomainEncode(...args),
|
|
}));
|
|
jest.mock('~/server/services/Threads', () => ({
|
|
recordUsage: jest.fn(),
|
|
}));
|
|
jest.mock('~/models', () => ({
|
|
findPluginAuthsByKeys: jest.fn(),
|
|
}));
|
|
jest.mock('~/config', () => ({
|
|
getFlowStateManager: jest.fn(() => mockFlowManager),
|
|
getMCPServersRegistry: jest.fn(() => ({
|
|
getServerConfig: (...args) => mockGetServerConfig(...args),
|
|
})),
|
|
}));
|
|
jest.mock('~/server/services/MCP', () => ({
|
|
resolveConfigServers: (...args) => mockResolveConfigServers(...args),
|
|
resolveMcpServerNames: (...args) => mockResolveMcpServerNames(...args),
|
|
resolveMcpServerContext: async (...args) => {
|
|
const configServers = (await mockResolveConfigServers(...args)) ?? {};
|
|
const serverNames = Object.keys(configServers);
|
|
return { configServers, serverNames, rawServerNames: serverNames };
|
|
},
|
|
/** Mirrors the real resolver's shape; these fixtures use safe names, so the
|
|
* raw set is always the complete audit. */
|
|
resolveCollisionAuditNames: jest.fn(async ({ rawServerNames, accessibleServerNames }) => ({
|
|
names: accessibleServerNames?.length ? accessibleServerNames : rawServerNames,
|
|
complete: true,
|
|
})),
|
|
createMCPPermissionContext: jest.fn((req) => ({
|
|
canUseServers: (user) => mockUserCanUseMCPServers(user, req),
|
|
})),
|
|
userCanUseMCPServers: mockUserCanUseMCPServers,
|
|
}));
|
|
jest.mock('~/cache', () => ({
|
|
getLogStores: jest.fn(() => ({})),
|
|
}));
|
|
|
|
const {
|
|
loadAgentTools,
|
|
loadToolsForExecution,
|
|
processRequiredActions,
|
|
resolveAgentCapabilities,
|
|
} = require('../ToolService');
|
|
const { createOnSearchResults } = require('~/server/services/Tools/search');
|
|
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
|
|
const { ContentFilterError, PENDING_STALE_MS } = require('@librechat/api');
|
|
|
|
function createMockReq(capabilities) {
|
|
return {
|
|
user: { id: 'user_123' },
|
|
config: {
|
|
endpoints: {
|
|
[EModelEndpoint.agents]: {
|
|
capabilities,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createEndpointsConfig(capabilities) {
|
|
return {
|
|
[EModelEndpoint.agents]: { capabilities },
|
|
};
|
|
}
|
|
|
|
describe('ToolService - Action Capability Gating', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockLoadToolDefinitions.mockResolvedValue({
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
});
|
|
mockLoadToolsUtil.mockResolvedValue({ loadedTools: [], toolContextMap: {} });
|
|
mockLoadActionSets.mockResolvedValue([]);
|
|
mockDecryptMetadata.mockImplementation(async (metadata) => metadata);
|
|
mockGetMCPServerTools.mockResolvedValue(null);
|
|
mockGetCachedTools.mockResolvedValue(null);
|
|
mockGetUserMCPAuthMap.mockResolvedValue({});
|
|
mockGetServerConfig.mockResolvedValue(undefined);
|
|
mockFlowManager.getFlowState.mockResolvedValue(undefined);
|
|
mockResolveConfigServers.mockResolvedValue({});
|
|
mockResolveMcpServerNames.mockResolvedValue([]);
|
|
mockPrimeSearchFiles.mockResolvedValue({});
|
|
mockPrimeCodeFiles.mockResolvedValue({});
|
|
});
|
|
|
|
describe('processRequiredActions content protection', () => {
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
const buildFilters = (field, sentinel) => ({
|
|
toolArguments: {
|
|
pii: {
|
|
fields: [field],
|
|
starterPatterns: [],
|
|
customPatterns: [
|
|
{
|
|
id: 'private',
|
|
label: 'private value',
|
|
regex: sentinel,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const buildClient = (filters) => ({
|
|
req: {
|
|
user: { id: 'user_123' },
|
|
body: {
|
|
assistant_id: 'assistant_content_filter',
|
|
model: 'gpt-4o-mini',
|
|
endpoint: 'openAI',
|
|
},
|
|
config: { filters },
|
|
},
|
|
res: {},
|
|
apiKey: 'test-key',
|
|
mappedOrder: new Map([['call_1', 0]]),
|
|
seenToolCalls: new Map(),
|
|
addContentData: jest.fn(),
|
|
});
|
|
|
|
const buildAction = (overrides = {}) => ({
|
|
tool: 'safe_tool',
|
|
toolInput: { value: 'safe' },
|
|
toolCallId: 'call_1',
|
|
thread_id: 'thread_1',
|
|
run_id: 'run_1',
|
|
...overrides,
|
|
});
|
|
|
|
it('blocks a filtered tool name before lookup, logging, or execution', async () => {
|
|
const privateName = 'PRIVATE-NAME';
|
|
const client = buildClient(buildFilters('name', privateName));
|
|
const debugSpy = jest.spyOn(require('@librechat/data-schemas').logger, 'debug');
|
|
|
|
await expect(
|
|
processRequiredActions(client, [buildAction({ tool: privateName })]),
|
|
).rejects.toMatchObject({
|
|
code: 'content_filter_block',
|
|
message: expect.not.stringContaining(privateName),
|
|
});
|
|
|
|
expect(mockGetCachedTools).not.toHaveBeenCalled();
|
|
expect(mockLoadToolsUtil).not.toHaveBeenCalled();
|
|
expect(debugSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('blocks filtered arguments before tool execution', async () => {
|
|
const privateArgument = 'PRIVATE-ARGUMENT';
|
|
const client = buildClient(buildFilters('arguments', privateArgument));
|
|
|
|
await expect(
|
|
processRequiredActions(client, [
|
|
buildAction({ toolInput: { nested: { value: privateArgument } } }),
|
|
]),
|
|
).rejects.toMatchObject({
|
|
code: 'content_filter_block',
|
|
message: expect.not.stringContaining(privateArgument),
|
|
});
|
|
|
|
expect(mockGetCachedTools).not.toHaveBeenCalled();
|
|
expect(mockLoadToolsUtil).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('blocks a visible argument match before enforcing its traversal overflow', async () => {
|
|
const privateArgument = 'PRIVATE-ARGUMENT';
|
|
const deeplyNestedArguments = { visible: privateArgument };
|
|
let current = deeplyNestedArguments;
|
|
for (let depth = 0; depth < 30; depth++) {
|
|
current.nested = {};
|
|
current = current.nested;
|
|
}
|
|
const client = buildClient(buildFilters('arguments', privateArgument));
|
|
|
|
await expect(
|
|
processRequiredActions(client, [buildAction({ toolInput: deeplyNestedArguments })]),
|
|
).rejects.toMatchObject({
|
|
code: 'content_filter_block',
|
|
message: expect.not.stringContaining(privateArgument),
|
|
});
|
|
|
|
expect(mockGetCachedTools).not.toHaveBeenCalled();
|
|
expect(mockLoadToolsUtil).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fails closed before execution when selected arguments exhaust traversal', async () => {
|
|
const deeplyNestedArguments = { visible: 'safe' };
|
|
let current = deeplyNestedArguments;
|
|
for (let depth = 0; depth < 30; depth++) {
|
|
current.nested = {};
|
|
current = current.nested;
|
|
}
|
|
const client = buildClient(buildFilters('arguments', 'PRIVATE-ARGUMENT'));
|
|
|
|
await expect(
|
|
processRequiredActions(client, [buildAction({ toolInput: deeplyNestedArguments })]),
|
|
).rejects.toMatchObject({
|
|
code: 'content_filter_uninspectable',
|
|
body: {
|
|
error: 'content_filter_uninspectable',
|
|
source: 'tool_argument',
|
|
field: 'arguments',
|
|
},
|
|
});
|
|
|
|
expect(mockGetCachedTools).not.toHaveBeenCalled();
|
|
expect(mockLoadToolsUtil).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not inspect required-action values when the source has no active patterns', async () => {
|
|
const opaqueArguments = new Proxy(
|
|
{},
|
|
{
|
|
ownKeys: () => {
|
|
throw new Error('inactive arguments were traversed');
|
|
},
|
|
},
|
|
);
|
|
const client = buildClient({
|
|
toolArguments: {
|
|
pii: {
|
|
fields: ['arguments'],
|
|
starterPatterns: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
processRequiredActions(client, [buildAction({ toolInput: opaqueArguments })]),
|
|
).resolves.toEqual({ tool_outputs: [] });
|
|
|
|
expect(mockGetCachedTools).toHaveBeenCalledTimes(1);
|
|
expect(mockLoadToolsUtil).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not traverse unselected required-action arguments', async () => {
|
|
const opaqueArguments = new Proxy(
|
|
{},
|
|
{
|
|
ownKeys: () => {
|
|
throw new Error('unselected arguments were traversed');
|
|
},
|
|
},
|
|
);
|
|
const client = buildClient(buildFilters('name', 'PRIVATE-NAME'));
|
|
|
|
await expect(
|
|
processRequiredActions(client, [buildAction({ toolInput: opaqueArguments })]),
|
|
).resolves.toEqual({ tool_outputs: [] });
|
|
|
|
expect(mockGetCachedTools).toHaveBeenCalledTimes(1);
|
|
expect(mockLoadToolsUtil).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not traverse unselected required-action output', async () => {
|
|
const opaqueOutput = new Proxy(
|
|
{},
|
|
{
|
|
ownKeys: () => {
|
|
throw new Error('unselected output was traversed');
|
|
},
|
|
},
|
|
);
|
|
const toolCall = jest.fn().mockResolvedValue(opaqueOutput);
|
|
mockLoadToolsUtil.mockResolvedValue({
|
|
loadedTools: [{ name: 'safe_tool', _call: toolCall }],
|
|
toolContextMap: {},
|
|
});
|
|
const client = buildClient(buildFilters('arguments', 'PRIVATE-ARGUMENT'));
|
|
|
|
const result = await processRequiredActions(client, [buildAction()]);
|
|
|
|
expect(result.tool_outputs[0].tool_call_id).toBe('call_1');
|
|
expect(result.tool_outputs[0].output).toBe(opaqueOutput);
|
|
});
|
|
|
|
it('does not traverse unselected required-action arguments for output-only policies', async () => {
|
|
const deeplyNestedArguments = { value: 'safe' };
|
|
let current = deeplyNestedArguments;
|
|
for (let depth = 0; depth < 30; depth++) {
|
|
current.nested = {};
|
|
current = current.nested;
|
|
}
|
|
const toolCall = jest.fn().mockResolvedValue('safe output');
|
|
mockLoadToolsUtil.mockResolvedValue({
|
|
loadedTools: [{ name: 'safe_tool', _call: toolCall }],
|
|
toolContextMap: {},
|
|
});
|
|
const client = buildClient(buildFilters('output', 'PRIVATE-OUTPUT'));
|
|
|
|
await expect(
|
|
processRequiredActions(client, [buildAction({ toolInput: deeplyNestedArguments })]),
|
|
).resolves.toEqual({
|
|
tool_outputs: [{ tool_call_id: 'call_1', output: 'safe output' }],
|
|
});
|
|
|
|
expect(toolCall).toHaveBeenCalledWith(deeplyNestedArguments);
|
|
});
|
|
|
|
it('replaces a filtered tool output before UI or model submission', async () => {
|
|
const privateOutput = 'PRIVATE-OUTPUT';
|
|
const toolCall = jest.fn().mockResolvedValue(privateOutput);
|
|
mockLoadToolsUtil.mockResolvedValue({
|
|
loadedTools: [{ name: 'safe_tool', _call: toolCall }],
|
|
toolContextMap: {},
|
|
});
|
|
const client = buildClient(buildFilters('output', privateOutput));
|
|
const action = buildAction();
|
|
|
|
const result = await processRequiredActions(client, [action]);
|
|
|
|
expect(toolCall).toHaveBeenCalledWith(action.toolInput);
|
|
expect(result.tool_outputs).toEqual([
|
|
{
|
|
tool_call_id: 'call_1',
|
|
output: JSON.stringify({
|
|
error: 'content_filter_block',
|
|
message: 'Submitted content was blocked by content policy.',
|
|
source: 'tool_argument',
|
|
field: 'output',
|
|
}),
|
|
},
|
|
]);
|
|
expect(action.output).not.toContain(privateOutput);
|
|
expect(client.addContentData).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tool_call: expect.objectContaining({
|
|
function: expect.objectContaining({
|
|
output: expect.not.stringContaining(privateOutput),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('replaces an uninspectable tool output before UI or model submission', async () => {
|
|
const deeplyNestedOutput = { visible: 'safe' };
|
|
let current = deeplyNestedOutput;
|
|
for (let depth = 0; depth < 30; depth++) {
|
|
current.nested = {};
|
|
current = current.nested;
|
|
}
|
|
const toolCall = jest.fn().mockResolvedValue(deeplyNestedOutput);
|
|
mockLoadToolsUtil.mockResolvedValue({
|
|
loadedTools: [{ name: 'safe_tool', _call: toolCall }],
|
|
toolContextMap: {},
|
|
});
|
|
const client = buildClient(buildFilters('output', 'PRIVATE-OUTPUT'));
|
|
const action = buildAction();
|
|
|
|
const result = await processRequiredActions(client, [action]);
|
|
const output = result.tool_outputs[0].output;
|
|
|
|
expect(JSON.parse(output)).toEqual({
|
|
error: 'content_filter_uninspectable',
|
|
message: 'Submitted content could not be completely inspected before processing.',
|
|
source: 'tool_argument',
|
|
field: 'output',
|
|
});
|
|
expect(action.output).toBe(output);
|
|
expect(output).not.toContain('PRIVATE-OUTPUT');
|
|
expect(client.addContentData).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tool_call: expect.objectContaining({
|
|
function: expect.objectContaining({ output }),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
['bearer_header', 'Authorization: Bearer required-action-token', 'Bearer token'],
|
|
['api_key_header', 'api-key: required-action-token', 'api-key header'],
|
|
])(
|
|
'returns a stable required-action %s block output',
|
|
async (starterPattern, privateOutput, detectorLabel) => {
|
|
mockLoadToolsUtil.mockResolvedValue({
|
|
loadedTools: [{ name: 'safe_tool', _call: jest.fn().mockResolvedValue(privateOutput) }],
|
|
toolContextMap: {},
|
|
});
|
|
const client = buildClient({
|
|
toolArguments: {
|
|
pii: {
|
|
fields: ['output'],
|
|
starterPatterns: [starterPattern],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await processRequiredActions(client, [buildAction()]);
|
|
const output = result.tool_outputs[0].output;
|
|
|
|
expect(JSON.parse(output)).toEqual({
|
|
error: 'content_filter_block',
|
|
message: 'Submitted content was blocked by content policy.',
|
|
source: 'tool_argument',
|
|
field: 'output',
|
|
});
|
|
expect(output).not.toContain(privateOutput);
|
|
expect(output).not.toContain(detectorLabel);
|
|
},
|
|
);
|
|
|
|
it('normalizes a required-action policy error without tool-output filtering', async () => {
|
|
const privateOutput = 'Authorization: Bearer generated-file-token';
|
|
const policyError = new ContentFilterError({
|
|
detectorId: 'pii-pattern',
|
|
ruleId: 'bearer_header',
|
|
label: 'Bearer token',
|
|
source: 'file',
|
|
field: 'content',
|
|
provenance: 'tool',
|
|
fragmentId: 'generated-file',
|
|
fragmentPath: '/content',
|
|
});
|
|
mockLoadToolsUtil.mockResolvedValue({
|
|
loadedTools: [
|
|
{
|
|
name: 'safe_tool',
|
|
_call: jest.fn().mockRejectedValue(policyError),
|
|
},
|
|
],
|
|
toolContextMap: {},
|
|
});
|
|
const client = buildClient({
|
|
files: {
|
|
pii: {
|
|
fields: ['content'],
|
|
starterPatterns: ['bearer_header'],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await processRequiredActions(client, [buildAction()]);
|
|
const output = result.tool_outputs[0].output;
|
|
|
|
expect(JSON.parse(output)).toEqual({
|
|
error: 'content_filter_block',
|
|
message: 'Submitted content was blocked by content policy.',
|
|
source: 'file',
|
|
field: 'content',
|
|
});
|
|
expect(output).not.toContain(privateOutput);
|
|
expect(output).not.toContain('Bearer token');
|
|
expect(output).not.toContain('bearer_header');
|
|
});
|
|
|
|
it('blocks persisted Assistant action metadata before required-action execution', async () => {
|
|
const actionToolName = `get_weather${actionDelimiter}api_example_com`;
|
|
const filters = {
|
|
actionMetadata: {
|
|
pii: {
|
|
fields: ['api_key'],
|
|
starterPatterns: [],
|
|
customPatterns: [
|
|
{
|
|
id: 'private',
|
|
label: 'private value',
|
|
regex: 'PRIVATE-[A-Z]+',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
const metadata = {
|
|
domain: 'https://api.example.com',
|
|
raw_spec: '{}',
|
|
api_key: 'encrypted-value',
|
|
};
|
|
const client = buildClient(filters);
|
|
mockLoadActionSets.mockResolvedValue([
|
|
{
|
|
action_id: 'assistant_action_private_auth',
|
|
metadata,
|
|
},
|
|
]);
|
|
mockDecryptMetadata.mockResolvedValue({
|
|
...metadata,
|
|
api_key: 'PRIVATE-AUTH',
|
|
});
|
|
|
|
await expect(
|
|
processRequiredActions(client, [buildAction({ tool: actionToolName })]),
|
|
).rejects.toMatchObject({
|
|
code: 'content_filter_block',
|
|
body: {
|
|
source: 'action_metadata',
|
|
field: 'api_key',
|
|
},
|
|
});
|
|
|
|
expect(mockDomainParser).not.toHaveBeenCalled();
|
|
expect(mockCreateActionTool).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('resolveAgentCapabilities', () => {
|
|
it('should return capabilities from endpoints config', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
const result = await resolveAgentCapabilities(req, req.config, 'agent_123');
|
|
|
|
expect(result).toBeInstanceOf(Set);
|
|
expect(result.has(AgentCapabilities.tools)).toBe(true);
|
|
expect(result.has(AgentCapabilities.actions)).toBe(true);
|
|
expect(result.has(AgentCapabilities.web_search)).toBe(false);
|
|
});
|
|
|
|
it('should fall back to default capabilities for ephemeral agents with empty config', async () => {
|
|
const req = createMockReq(defaultAgentCapabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue({});
|
|
|
|
const result = await resolveAgentCapabilities(req, req.config, Constants.EPHEMERAL_AGENT_ID);
|
|
|
|
for (const cap of defaultAgentCapabilities) {
|
|
expect(result.has(cap)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should return empty set when no capabilities and not ephemeral', async () => {
|
|
const req = createMockReq([]);
|
|
mockGetEndpointsConfig.mockResolvedValue({});
|
|
|
|
const result = await resolveAgentCapabilities(req, req.config, 'agent_123');
|
|
|
|
expect(result.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('isActionTool — cross-delimiter collision guard', () => {
|
|
it('should identify real action tools', () => {
|
|
expect(isActionTool(`get_weather${actionDelimiter}api_example_com`)).toBe(true);
|
|
expect(isActionTool(`fetch_data${actionDelimiter}my---domain---com`)).toBe(true);
|
|
});
|
|
|
|
it('should identify action tools whose operationId contains _mcp_', () => {
|
|
expect(isActionTool(`sync_mcp_state${actionDelimiter}api---example---com`)).toBe(true);
|
|
expect(isActionTool(`get_mcp_config${actionDelimiter}internal---api---com`)).toBe(true);
|
|
});
|
|
|
|
it('should reject MCP tools whose name ends with _action', () => {
|
|
expect(isActionTool(`get_action${Constants.mcp_delimiter}myserver`)).toBe(false);
|
|
expect(isActionTool(`fetch_action${Constants.mcp_delimiter}server_name`)).toBe(false);
|
|
expect(isActionTool(`retrieve_action${Constants.mcp_delimiter}srv`)).toBe(false);
|
|
});
|
|
|
|
it('should reject MCP tools with _action_ in the middle of their name', () => {
|
|
expect(isActionTool(`get_action_data${Constants.mcp_delimiter}myserver`)).toBe(false);
|
|
expect(isActionTool(`create_action_item${Constants.mcp_delimiter}server`)).toBe(false);
|
|
});
|
|
|
|
it('should reject tools without the action delimiter', () => {
|
|
expect(isActionTool('calculator')).toBe(false);
|
|
expect(isActionTool(`web_search${Constants.mcp_delimiter}myserver`)).toBe(false);
|
|
});
|
|
|
|
it('known limitation: non-RFC domain with _mcp_ substring yields false negative', () => {
|
|
// RFC 952/1123 prohibit underscores in hostnames, so this is not expected in practice.
|
|
// Encoded domain `api_mcp_internal_com` places `_mcp_` after `_action_`, which
|
|
// the guard interprets as the MCP suffix.
|
|
const edgeCaseTool = `getData${actionDelimiter}api_mcp_internal_com`;
|
|
expect(isActionTool(edgeCaseTool)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('loadAgentTools tool-resource content protection', () => {
|
|
const opaqueFileFilters = {
|
|
files: {
|
|
pii: {
|
|
fields: ['content'],
|
|
starterPatterns: ['sk_prefix'],
|
|
uninspectable: 'block',
|
|
},
|
|
},
|
|
};
|
|
|
|
const patternFilters = (source, fields, regex) => ({
|
|
[source]: {
|
|
pii: {
|
|
fields,
|
|
starterPatterns: [],
|
|
customPatterns: [{ id: 'private', label: 'private value', regex }],
|
|
},
|
|
},
|
|
});
|
|
|
|
it.each([true, false])(
|
|
'blocks historical execute-code resources before code-file priming (definitionsOnly=%s)',
|
|
async (definitionsOnly) => {
|
|
const privateFileId = 'file-historical-code';
|
|
const req = createMockReq([AgentCapabilities.execute_code]);
|
|
req.config.filters = opaqueFileFilters;
|
|
mockGetEndpointsConfig.mockResolvedValue(
|
|
createEndpointsConfig([AgentCapabilities.execute_code]),
|
|
);
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_code', tools: [Tools.execute_code] },
|
|
tool_resources: {
|
|
[EToolResources.execute_code]: { file_ids: [privateFileId] },
|
|
},
|
|
definitionsOnly,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'content_filter_uninspectable',
|
|
message: expect.not.stringContaining(privateFileId),
|
|
});
|
|
|
|
expect(mockLoadToolDefinitions).not.toHaveBeenCalled();
|
|
expect(mockPrimeCodeFiles).not.toHaveBeenCalled();
|
|
expect(mockPrimeSearchFiles).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each([true, false])(
|
|
'blocks historical file-search resources before search-file priming (definitionsOnly=%s)',
|
|
async (definitionsOnly) => {
|
|
const privateFileId = 'file-historical-search';
|
|
const req = createMockReq([AgentCapabilities.file_search]);
|
|
req.config.filters = opaqueFileFilters;
|
|
mockGetEndpointsConfig.mockResolvedValue(
|
|
createEndpointsConfig([AgentCapabilities.file_search]),
|
|
);
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_search', tools: [Tools.file_search] },
|
|
tool_resources: {
|
|
[EToolResources.file_search]: { file_ids: [privateFileId] },
|
|
},
|
|
definitionsOnly,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'content_filter_uninspectable',
|
|
message: expect.not.stringContaining(privateFileId),
|
|
});
|
|
|
|
expect(mockLoadToolDefinitions).not.toHaveBeenCalled();
|
|
expect(mockPrimeCodeFiles).not.toHaveBeenCalled();
|
|
expect(mockPrimeSearchFiles).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('does not apply prompt policy to canonical file-resource content', async () => {
|
|
const privateFilename = 'PRIVATE-FILE.txt';
|
|
const req = createMockReq([AgentCapabilities.file_search]);
|
|
req.config.filters = patternFilters('prompts', ['name'], privateFilename);
|
|
mockGetEndpointsConfig.mockResolvedValue(
|
|
createEndpointsConfig([AgentCapabilities.file_search]),
|
|
);
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_search', tools: [Tools.file_search] },
|
|
tool_resources: {
|
|
[EToolResources.file_search]: {
|
|
files: [{ file_id: 'file-safe', filename: privateFilename }],
|
|
},
|
|
},
|
|
definitionsOnly: true,
|
|
}),
|
|
).resolves.toBeDefined();
|
|
|
|
expect(mockPrimeSearchFiles).toHaveBeenCalledTimes(1);
|
|
expect(mockPrimeCodeFiles).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not apply an unselected file field to canonical resource content', async () => {
|
|
const privateContent = 'PRIVATE-CONTENT';
|
|
const req = createMockReq([AgentCapabilities.execute_code]);
|
|
req.config.filters = patternFilters('files', ['name'], privateContent);
|
|
mockGetEndpointsConfig.mockResolvedValue(
|
|
createEndpointsConfig([AgentCapabilities.execute_code]),
|
|
);
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_code', tools: [Tools.execute_code] },
|
|
tool_resources: {
|
|
[EToolResources.execute_code]: {
|
|
files: [
|
|
{
|
|
file_id: 'file-safe',
|
|
filename: 'safe.txt',
|
|
text: privateContent,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
definitionsOnly: true,
|
|
}),
|
|
).resolves.toBeDefined();
|
|
|
|
expect(mockPrimeCodeFiles).toHaveBeenCalledTimes(1);
|
|
expect(mockPrimeSearchFiles).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not log raw code-file priming errors', async () => {
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const rawValue = 'PRIVATE-CODE-FILE-PROVIDER-ECHO';
|
|
const errorSpy = jest.spyOn(logger, 'error');
|
|
const providerError = Object.assign(new Error(`Provider echoed ${rawValue}`), {
|
|
response: { status: 502, data: { file: rawValue } },
|
|
});
|
|
mockPrimeCodeFiles.mockRejectedValueOnce(providerError);
|
|
const req = createMockReq([AgentCapabilities.execute_code]);
|
|
mockGetEndpointsConfig.mockResolvedValue(
|
|
createEndpointsConfig([AgentCapabilities.execute_code]),
|
|
);
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_code', tools: [Tools.execute_code] },
|
|
tool_resources: {
|
|
[EToolResources.execute_code]: { file_ids: ['file-safe'] },
|
|
},
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
'[loadToolDefinitionsWrapper] Error priming code files:',
|
|
{ type: 'Error', status: 502 },
|
|
);
|
|
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(rawValue);
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it('does not log raw search-file priming errors', async () => {
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const rawValue = 'PRIVATE-SEARCH-FILE-PROVIDER-ECHO';
|
|
const errorSpy = jest.spyOn(logger, 'error');
|
|
const providerError = Object.assign(new Error(`Provider echoed ${rawValue}`), {
|
|
response: { status: 503, data: { file: rawValue } },
|
|
});
|
|
mockPrimeSearchFiles.mockRejectedValueOnce(providerError);
|
|
const req = createMockReq([AgentCapabilities.file_search]);
|
|
mockGetEndpointsConfig.mockResolvedValue(
|
|
createEndpointsConfig([AgentCapabilities.file_search]),
|
|
);
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_search', tools: [Tools.file_search] },
|
|
tool_resources: {
|
|
[EToolResources.file_search]: { file_ids: ['file-safe'] },
|
|
},
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
'[loadToolDefinitionsWrapper] Error priming search files:',
|
|
{ type: 'Error', status: 503 },
|
|
);
|
|
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(rawValue);
|
|
errorSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('loadAgentTools (definitionsOnly=true) — action tool filtering', () => {
|
|
const actionToolName = `get_weather${actionDelimiter}api_example_com`;
|
|
const regularTool = 'calculator';
|
|
|
|
it('should preserve the remote-agent permission boundary while priming files', async () => {
|
|
const capabilities = [
|
|
AgentCapabilities.tools,
|
|
AgentCapabilities.file_search,
|
|
AgentCapabilities.execute_code,
|
|
];
|
|
const req = createMockReq(capabilities);
|
|
const tool_resources = {
|
|
file_search: { file_ids: ['search-file'] },
|
|
execute_code: { file_ids: ['code-file'] },
|
|
};
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [Tools.file_search, Tools.execute_code] },
|
|
tool_resources,
|
|
agentResourceType: ResourceType.REMOTE_AGENT,
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
const expectedParams = {
|
|
req,
|
|
tool_resources,
|
|
agentId: 'agent_123',
|
|
agentResourceType: ResourceType.REMOTE_AGENT,
|
|
};
|
|
expect(mockPrimeSearchFiles).toHaveBeenCalledWith(expectedParams);
|
|
expect(mockPrimeCodeFiles).toHaveBeenCalledWith({
|
|
...expectedParams,
|
|
codeApiBaseUrl: 'https://api.librechat.ai',
|
|
executionProfile: 'default',
|
|
});
|
|
});
|
|
|
|
it('primes code files through the initializer-selected stateful route', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.execute_code];
|
|
const req = createMockReq(capabilities);
|
|
const tool_resources = { execute_code: { file_ids: ['stateful-file'] } };
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'stateful-agent', tools: [Tools.execute_code] },
|
|
tool_resources,
|
|
definitionsOnly: true,
|
|
codeExecutionContext: {
|
|
baseUrl: 'https://stateful-code.example.com',
|
|
codeSessionKey: 'execute_code:stateful:v2:user:abc',
|
|
executionProfile: 'stateful',
|
|
runtimeSessionHint: 'v2:user:abc',
|
|
statefulSessions: true,
|
|
},
|
|
});
|
|
|
|
expect(mockPrimeCodeFiles).toHaveBeenCalledWith({
|
|
req,
|
|
tool_resources,
|
|
agentId: 'stateful-agent',
|
|
agentResourceType: undefined,
|
|
codeApiBaseUrl: 'https://stateful-code.example.com',
|
|
executionProfile: 'stateful',
|
|
});
|
|
});
|
|
|
|
it('propagates a typed CodeAPI resource recovery failure before model invocation', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.execute_code];
|
|
const req = createMockReq(capabilities);
|
|
const resourceRecoveryError = Object.assign(new Error('resource recovery required'), {
|
|
code: ErrorTypes.RESOURCE_RECOVERY_REQUIRED,
|
|
});
|
|
mockPrimeCodeFiles.mockRejectedValueOnce(resourceRecoveryError);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: {
|
|
id: 'agent_123',
|
|
tools: [Tools.execute_code, 'run_query_mcp_warehouse'],
|
|
},
|
|
tool_resources: { execute_code: { file_ids: ['stale-file'] } },
|
|
definitionsOnly: true,
|
|
}),
|
|
).rejects.toBe(resourceRecoveryError);
|
|
});
|
|
|
|
it('should exclude action tools from definitions when actions capability is disabled', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.web_search];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool, actionToolName] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
|
|
const [callArgs] = mockLoadToolDefinitions.mock.calls[0];
|
|
expect(callArgs.tools).toContain(regularTool);
|
|
expect(callArgs.tools).not.toContain(actionToolName);
|
|
});
|
|
|
|
it('should include action tools in definitions when actions capability is enabled', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool, actionToolName] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
|
|
const [callArgs] = mockLoadToolDefinitions.mock.calls[0];
|
|
expect(callArgs.tools).toContain(regularTool);
|
|
expect(callArgs.tools).toContain(actionToolName);
|
|
});
|
|
|
|
it('blocks a persisted action schema before domain parsing during definition loading', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
req.config.filters = {
|
|
toolArguments: {
|
|
pii: {
|
|
fields: ['arguments'],
|
|
starterPatterns: [],
|
|
customPatterns: [
|
|
{
|
|
id: 'private',
|
|
label: 'private value',
|
|
regex: 'PRIVATE-[A-Z]+',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockLoadActionSets.mockResolvedValue([
|
|
{
|
|
action_id: 'action_private_schema',
|
|
metadata: {
|
|
domain: 'https://api.example.com',
|
|
raw_spec: '{"description":"PRIVATE-SCHEMA"}',
|
|
},
|
|
},
|
|
]);
|
|
mockLoadToolDefinitions.mockImplementationOnce(async (_options, dependencies) => {
|
|
await dependencies.getActionToolDefinitions('agent_123', [actionToolName]);
|
|
return {
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
});
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [actionToolName] },
|
|
definitionsOnly: true,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'content_filter_block',
|
|
body: {
|
|
source: 'tool_argument',
|
|
field: 'arguments',
|
|
},
|
|
});
|
|
|
|
expect(mockDecryptMetadata).not.toHaveBeenCalled();
|
|
expect(mockDomainParser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('blocks decrypted persisted action secrets before definition-time domain work', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
req.config.filters = {
|
|
actionMetadata: {
|
|
pii: {
|
|
fields: ['api_key'],
|
|
starterPatterns: [],
|
|
customPatterns: [
|
|
{
|
|
id: 'private',
|
|
label: 'private value',
|
|
regex: 'PRIVATE-[A-Z]+',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
const metadata = {
|
|
domain: 'https://api.example.com',
|
|
raw_spec: '{}',
|
|
api_key: 'encrypted-value',
|
|
};
|
|
mockLoadActionSets.mockResolvedValue([
|
|
{
|
|
action_id: 'action_private_auth',
|
|
metadata,
|
|
},
|
|
]);
|
|
mockDecryptMetadata.mockResolvedValue({
|
|
...metadata,
|
|
api_key: 'PRIVATE-AUTH',
|
|
});
|
|
mockLoadToolDefinitions.mockImplementationOnce(async (_options, dependencies) => {
|
|
await dependencies.getActionToolDefinitions('agent_123', [actionToolName]);
|
|
return {
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
});
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [actionToolName] },
|
|
definitionsOnly: true,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'content_filter_block',
|
|
body: {
|
|
source: 'action_metadata',
|
|
field: 'api_key',
|
|
},
|
|
});
|
|
|
|
expect(mockDecryptMetadata).toHaveBeenCalledWith(metadata);
|
|
expect(mockDomainParser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'inactive secret-field policy',
|
|
{
|
|
actionMetadata: {
|
|
pii: {
|
|
fields: ['api_key'],
|
|
starterPatterns: [],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
'active plaintext-field policy',
|
|
{
|
|
actionMetadata: {
|
|
pii: {
|
|
fields: ['raw_spec'],
|
|
starterPatterns: [],
|
|
customPatterns: [
|
|
{
|
|
id: 'private',
|
|
label: 'private value',
|
|
regex: 'PRIVATE-[A-Z]+',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
])('does not decrypt action metadata for an %s', async (_label, filters) => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
req.config.filters = filters;
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockLoadToolDefinitions.mockReset().mockResolvedValue({
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
});
|
|
mockLoadActionSets.mockResolvedValue([
|
|
{
|
|
action_id: 'action_safe_metadata',
|
|
metadata: {
|
|
domain: 'https://api.example.com',
|
|
raw_spec: '{}',
|
|
api_key: 'encrypted-value',
|
|
},
|
|
},
|
|
]);
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [actionToolName] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(mockDecryptMetadata).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('blocks persisted action metadata before MCP definition initialization', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
const mcpTool = `search${Constants.mcp_delimiter}private-server`;
|
|
req.config.filters = {
|
|
actionMetadata: {
|
|
pii: {
|
|
fields: ['raw_spec'],
|
|
starterPatterns: [],
|
|
customPatterns: [
|
|
{
|
|
id: 'private',
|
|
label: 'private value',
|
|
regex: 'PRIVATE-[A-Z]+',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockLoadActionSets.mockResolvedValue([
|
|
{
|
|
action_id: 'action_private_before_mcp',
|
|
metadata: {
|
|
domain: 'https://api.example.com',
|
|
raw_spec: '{"description":"PRIVATE-ACTION"}',
|
|
},
|
|
},
|
|
]);
|
|
mockLoadToolDefinitions.mockImplementationOnce(async (options, dependencies) => {
|
|
await dependencies.getOrFetchMCPServerTools(options.userId, 'private-server');
|
|
await dependencies.getActionToolDefinitions('agent_123', [actionToolName]);
|
|
return {
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
});
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [mcpTool, actionToolName] },
|
|
definitionsOnly: true,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'content_filter_block',
|
|
body: {
|
|
source: 'action_metadata',
|
|
field: 'raw_spec',
|
|
},
|
|
});
|
|
|
|
expect(mockLoadToolDefinitions).not.toHaveBeenCalled();
|
|
expect(mockGetUserMCPAuthMap).not.toHaveBeenCalled();
|
|
expect(mockResolveConfigServers).not.toHaveBeenCalled();
|
|
expect(reinitMCPServer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should exclude ask_user_question when its capability is disabled (even if tools is enabled)', async () => {
|
|
// ask_user_question is gated by its OWN capability, like execute_code —
|
|
// NOT the generic `tools` capability. Here `tools` is on but the ask
|
|
// capability is not, so the tool must be filtered out.
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool, 'ask_user_question'] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
|
|
const [callArgs] = mockLoadToolDefinitions.mock.calls[0];
|
|
expect(callArgs.tools).toContain(regularTool);
|
|
expect(callArgs.tools).not.toContain('ask_user_question');
|
|
});
|
|
|
|
it('should include ask_user_question when its capability is enabled', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.ask_user_question];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool, 'ask_user_question'] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
|
|
const [callArgs] = mockLoadToolDefinitions.mock.calls[0];
|
|
expect(callArgs.tools).toContain('ask_user_question');
|
|
});
|
|
|
|
it('should not filter MCP tools whose name contains _action (cross-delimiter collision)', async () => {
|
|
const mcpToolWithAction = `get_action${Constants.mcp_delimiter}myserver`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool, mcpToolWithAction] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
|
|
const [callArgs] = mockLoadToolDefinitions.mock.calls[0];
|
|
expect(callArgs.tools).toContain(mcpToolWithAction);
|
|
expect(callArgs.tools).toContain(regularTool);
|
|
});
|
|
|
|
it('fails initialization when an explicitly selected MCP tool cannot be resolved', async () => {
|
|
const mcpTool = `search${Constants.mcp_delimiter}warehouse`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockResolveConfigServers.mockResolvedValue({
|
|
warehouse: {
|
|
type: 'streamable-http',
|
|
url: 'https://mcp.example.com/warehouse',
|
|
},
|
|
});
|
|
mockLoadToolDefinitions.mockResolvedValueOnce({
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
mcpResolution: { expectedToolCount: 1, resolvedToolCount: 0 },
|
|
});
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', name: 'Target Agent', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
|
statusCode: 503,
|
|
message: expect.stringContaining('can access its selected tools'),
|
|
});
|
|
});
|
|
|
|
it('fails closed when MCP definition loading throws before resolution completes', async () => {
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockLoadToolDefinitions.mockRejectedValueOnce(new Error('MCP registry unavailable'));
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: {
|
|
id: 'agent_123',
|
|
name: 'Target Agent',
|
|
tools: [`run_query${Constants.mcp_delimiter}warehouse`],
|
|
},
|
|
definitionsOnly: true,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
|
statusCode: 503,
|
|
cause: expect.objectContaining({ message: 'MCP registry unavailable' }),
|
|
});
|
|
});
|
|
|
|
it('allows a server pin with no explicitly selected MCP tools', async () => {
|
|
const serverPin = `${Constants.mcp_server}${Constants.mcp_delimiter}warehouse`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockLoadToolDefinitions.mockResolvedValueOnce({
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
mcpResolution: { expectedToolCount: 0, resolvedToolCount: 0 },
|
|
});
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [serverPin] },
|
|
definitionsOnly: true,
|
|
}),
|
|
).resolves.toMatchObject({ toolDefinitions: [] });
|
|
});
|
|
|
|
it('allows partial MCP resolution when at least one expected tool is available', async () => {
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockLoadToolDefinitions.mockResolvedValueOnce({
|
|
toolDefinitions: [{ name: 'list_sources_mcp_warehouse', toolType: 'mcp' }],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
mcpResolution: { expectedToolCount: 2, resolvedToolCount: 1 },
|
|
});
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: {
|
|
id: 'agent_123',
|
|
tools: [
|
|
`list_sources${Constants.mcp_delimiter}warehouse`,
|
|
`run_query${Constants.mcp_delimiter}warehouse`,
|
|
],
|
|
},
|
|
definitionsOnly: true,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
toolDefinitions: [expect.objectContaining({ name: 'list_sources_mcp_warehouse' })],
|
|
});
|
|
});
|
|
|
|
it('should filter MCP tool definitions when user lacks MCP server use permission', async () => {
|
|
const { userCanUseMCPServers } = require('~/server/services/MCP');
|
|
userCanUseMCPServers.mockResolvedValueOnce(false);
|
|
|
|
const mcpTool = `search${Constants.mcp_delimiter}myserver`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool, mcpTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
|
|
const [callArgs] = mockLoadToolDefinitions.mock.calls[0];
|
|
expect(callArgs.tools).toContain(regularTool);
|
|
expect(callArgs.tools).not.toContain(mcpTool);
|
|
});
|
|
|
|
it('fails explicitly when MCP permission filtering removes every expected tool', async () => {
|
|
const { userCanUseMCPServers } = require('~/server/services/MCP');
|
|
userCanUseMCPServers.mockResolvedValueOnce(false);
|
|
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: {
|
|
id: 'agent_123',
|
|
tools: [`run_query${Constants.mcp_delimiter}warehouse`],
|
|
},
|
|
definitionsOnly: true,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
|
statusCode: 503,
|
|
});
|
|
expect(mockLoadToolDefinitions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return actionsEnabled in the result', async () => {
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
const result = await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(result.actionsEnabled).toBe(false);
|
|
});
|
|
|
|
it('emits separate MCP OAuth login steps and completion events for multiple pending servers', async () => {
|
|
const req = createMockReq([AgentCapabilities.tools]);
|
|
const res = { writableEnded: false };
|
|
const servers = ['ELI', 'Vespa'];
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig([AgentCapabilities.tools]));
|
|
mockResolveConfigServers.mockResolvedValue(
|
|
Object.fromEntries(
|
|
servers.map((serverName) => [
|
|
serverName,
|
|
{
|
|
type: 'streamable-http',
|
|
url: `https://mcp.example.com/${serverName}`,
|
|
requiresOAuth: true,
|
|
},
|
|
]),
|
|
),
|
|
);
|
|
|
|
mockLoadToolDefinitions
|
|
.mockImplementationOnce(async (_args, deps) => {
|
|
await deps.getOrFetchMCPServerTools(req.user.id, servers[0]);
|
|
await deps.getOrFetchMCPServerTools(req.user.id, servers[1]);
|
|
return {
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
})
|
|
.mockResolvedValue({
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
});
|
|
|
|
reinitMCPServer.mockImplementation(
|
|
async ({ serverName, returnOnOAuth, oauthStart, oauthEnd }) => {
|
|
if (returnOnOAuth === false) {
|
|
await oauthStart(`https://auth.example.com/${serverName}`);
|
|
await oauthEnd();
|
|
return { availableTools: { [`tool_${serverName}`]: {} } };
|
|
}
|
|
|
|
await oauthStart(`https://auth.example.com/${serverName}`);
|
|
return { availableTools: null };
|
|
},
|
|
);
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res,
|
|
agent: {
|
|
id: 'agent_123',
|
|
tools: servers.map((server) => `search${Constants.mcp_delimiter}${server}`),
|
|
},
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
const runStepEvents = mockSendEvent.mock.calls
|
|
.map(([, event]) => event)
|
|
.filter((event) => event.data?.stepDetails?.type === 'tool_calls');
|
|
const deltaEvents = mockSendEvent.mock.calls
|
|
.map(([, event]) => event)
|
|
.filter((event) => event.data?.delta?.type === 'tool_calls');
|
|
const authDeltaEvents = deltaEvents.filter((event) => event.data.delta.auth);
|
|
const completionEvents = mockSendEvent.mock.calls
|
|
.map(([, event]) => event)
|
|
.filter((event) => event.data?.result?.tool_call?.name?.startsWith('oauth'));
|
|
|
|
expect(runStepEvents.map((event) => event.data.index)).toEqual([0, 1]);
|
|
expect(authDeltaEvents.map((event) => event.data.id)).toEqual([
|
|
'step_oauth_login_ELI',
|
|
'step_oauth_login_Vespa',
|
|
]);
|
|
expect(completionEvents.map((event) => event.data.result.id)).toEqual([
|
|
'step_oauth_login_ELI',
|
|
'step_oauth_login_Vespa',
|
|
]);
|
|
});
|
|
|
|
it('does not count an empty post-OAuth catalog as tools available or reload definitions', async () => {
|
|
const req = createMockReq([AgentCapabilities.tools]);
|
|
const res = { writableEnded: false };
|
|
const serverName = 'Empty-Catalog';
|
|
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig([AgentCapabilities.tools]));
|
|
mockResolveConfigServers.mockResolvedValue({
|
|
[serverName]: {
|
|
type: 'streamable-http',
|
|
url: 'https://mcp.example.com/empty',
|
|
requiresOAuth: true,
|
|
},
|
|
});
|
|
mockGetMCPServerTools.mockResolvedValue(null);
|
|
mockFlowManager.getFlowState.mockResolvedValue(null);
|
|
mockLoadToolDefinitions.mockImplementationOnce(async (params, deps) => {
|
|
await deps.getOrFetchMCPServerTools(params.userId, serverName);
|
|
return {
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
mcpResolution: { resolvedToolCount: 1 },
|
|
};
|
|
});
|
|
reinitMCPServer
|
|
.mockImplementationOnce(async ({ oauthStart }) => {
|
|
await oauthStart(`https://auth.example.com/${serverName}`);
|
|
return { availableTools: null };
|
|
})
|
|
.mockResolvedValueOnce({ availableTools: {} });
|
|
|
|
const result = await loadAgentTools({
|
|
req,
|
|
res,
|
|
agent: { id: 'agent_123', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(result.toolDefinitions).toEqual([]);
|
|
expect(result.mcpAvailableTools).toEqual({});
|
|
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('fences resumable MCP OAuth definition events to the owning job epoch', async () => {
|
|
const req = createMockReq([AgentCapabilities.tools]);
|
|
const res = { writableEnded: false };
|
|
const serverName = 'Epoch-Server';
|
|
const streamId = 'stream-epoch';
|
|
const jobCreatedAt = 1234;
|
|
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig([AgentCapabilities.tools]));
|
|
mockResolveConfigServers.mockResolvedValue({
|
|
[serverName]: {
|
|
type: 'streamable-http',
|
|
url: `https://mcp.example.com/${serverName}`,
|
|
requiresOAuth: true,
|
|
},
|
|
});
|
|
mockLoadToolDefinitions
|
|
.mockImplementationOnce(async (_args, deps) => {
|
|
await deps.getOrFetchMCPServerTools(req.user.id, serverName);
|
|
return {
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
})
|
|
.mockResolvedValue({
|
|
toolDefinitions: [mcpTool],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
});
|
|
reinitMCPServer.mockImplementation(async ({ returnOnOAuth, oauthStart, oauthEnd }) => {
|
|
await oauthStart(`https://auth.example.com/${serverName}`);
|
|
if (returnOnOAuth === false) {
|
|
await oauthEnd();
|
|
return { availableTools: { [mcpTool]: {} } };
|
|
}
|
|
return { availableTools: null };
|
|
});
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res,
|
|
agent: { id: 'agent_123', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
streamId,
|
|
jobCreatedAt,
|
|
});
|
|
|
|
expect(mockSendEvent).not.toHaveBeenCalled();
|
|
expect(mockEmitChunk).toHaveBeenCalledTimes(3);
|
|
expect(mockEmitChunk.mock.calls.map(([, event]) => event.event)).toEqual([
|
|
'on_run_step',
|
|
'on_run_step_delta',
|
|
'on_run_step_completed',
|
|
]);
|
|
for (const [emittedStreamId, , options] of mockEmitChunk.mock.calls) {
|
|
expect(emittedStreamId).toBe(streamId);
|
|
expect(options).toEqual({ expectedCreatedAt: jobCreatedAt });
|
|
}
|
|
});
|
|
|
|
it('should not expose cached MCP tool definitions when the registry lookup fails', async () => {
|
|
const serverName = 'PRIVATE-MCP-SERVER-NAME';
|
|
const privateRegistryError = 'PRIVATE-MCP-REGISTRY-ERROR';
|
|
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
const warnSpy = jest
|
|
.spyOn(require('@librechat/data-schemas').logger, 'warn')
|
|
.mockImplementation(() => {});
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockGetServerConfig.mockImplementation(() => {
|
|
throw new Error(privateRegistryError);
|
|
});
|
|
mockGetMCPServerTools.mockResolvedValue({
|
|
[mcpTool]: {
|
|
function: {
|
|
name: mcpTool,
|
|
description: 'Cached private search',
|
|
parameters: {},
|
|
},
|
|
},
|
|
});
|
|
mockLoadToolDefinitions.mockImplementation(async (params, deps) => {
|
|
const serverTools = await deps.getOrFetchMCPServerTools(params.userId, serverName);
|
|
return {
|
|
toolDefinitions: serverTools ? Object.keys(serverTools) : [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
});
|
|
|
|
const result = await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
const loggedText = warnSpy.mock.calls.flat().map(String).join('\n');
|
|
expect(result.toolDefinitions).toEqual([]);
|
|
expect(mockGetMCPServerTools).not.toHaveBeenCalled();
|
|
expect(loggedText).not.toContain(serverName);
|
|
expect(loggedText).not.toContain(privateRegistryError);
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'[Tool Definitions] MCP registry unavailable; skipping tool exposure for one server',
|
|
);
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('should re-emit pending MCP OAuth prompts when cached tool definitions exist', async () => {
|
|
const serverName = 'Google-Workspace';
|
|
const authorizationUrl = 'https://auth.example.com/Google-Workspace';
|
|
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
const res = { writableEnded: false };
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockGetServerConfig.mockResolvedValue({
|
|
type: 'streamable-http',
|
|
url: 'https://demo.librechat.ai/mcp',
|
|
requiresOAuth: true,
|
|
});
|
|
mockGetMCPServerTools.mockResolvedValue({
|
|
[mcpTool]: {
|
|
function: {
|
|
name: mcpTool,
|
|
description: 'Cached search',
|
|
parameters: {},
|
|
},
|
|
},
|
|
});
|
|
mockFlowManager.getFlowState.mockResolvedValue({
|
|
status: 'PENDING',
|
|
createdAt: Date.now(),
|
|
metadata: { authorizationUrl },
|
|
});
|
|
mockLoadToolDefinitions.mockImplementation(async (params, deps) => {
|
|
const serverTools = await deps.getOrFetchMCPServerTools(params.userId, serverName);
|
|
return {
|
|
toolDefinitions: serverTools ? Object.keys(serverTools) : [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
});
|
|
reinitMCPServer.mockImplementation(async ({ oauthStart }) => {
|
|
await oauthStart(authorizationUrl);
|
|
return { availableTools: { [mcpTool]: {} } };
|
|
});
|
|
|
|
const result = await loadAgentTools({
|
|
req,
|
|
res,
|
|
agent: { id: 'agent_123', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(result.toolDefinitions).toEqual([mcpTool]);
|
|
expect(mockGetMCPServerTools).toHaveBeenCalledWith(
|
|
req.user.id,
|
|
serverName,
|
|
expect.objectContaining({ requiresOAuth: true }),
|
|
);
|
|
expect(reinitMCPServer).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
serverName,
|
|
returnOnOAuth: false,
|
|
oauthStart: expect.any(Function),
|
|
}),
|
|
);
|
|
expect(mockSendEvent).toHaveBeenCalledWith(
|
|
res,
|
|
expect.objectContaining({
|
|
event: 'on_run_step',
|
|
data: expect.objectContaining({
|
|
id: `step_oauth_login_${serverName}`,
|
|
}),
|
|
}),
|
|
);
|
|
expect(mockSendEvent).toHaveBeenCalledWith(
|
|
res,
|
|
expect.objectContaining({
|
|
event: 'on_run_step_delta',
|
|
data: expect.objectContaining({
|
|
id: `step_oauth_login_${serverName}`,
|
|
delta: expect.objectContaining({
|
|
auth: authorizationUrl,
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should not join in-flight MCP initialization before replaying pending OAuth prompts', async () => {
|
|
const serverName = 'Google-Workspace';
|
|
const authorizationUrl = 'https://auth.example.com/Google-Workspace';
|
|
const mcpTool = `${Constants.mcp_all}${Constants.mcp_delimiter}${serverName}`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
const res = { writableEnded: false };
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockGetServerConfig.mockResolvedValue({
|
|
type: 'streamable-http',
|
|
url: 'https://demo.librechat.ai/mcp',
|
|
requiresOAuth: true,
|
|
});
|
|
mockGetMCPServerTools.mockResolvedValue(null);
|
|
mockFlowManager.getFlowState.mockResolvedValue({
|
|
status: 'PENDING',
|
|
createdAt: Date.now(),
|
|
metadata: { authorizationUrl },
|
|
});
|
|
mockLoadToolDefinitions.mockImplementation(async (params, deps) => {
|
|
await deps.getOrFetchMCPServerTools(params.userId, serverName);
|
|
return {
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
});
|
|
reinitMCPServer.mockImplementation(async ({ oauthStart }) => {
|
|
await oauthStart(authorizationUrl);
|
|
return { availableTools: null };
|
|
});
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res,
|
|
agent: { id: 'agent_123', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(mockGetMCPServerTools).toHaveBeenCalledWith(
|
|
req.user.id,
|
|
serverName,
|
|
expect.objectContaining({ requiresOAuth: true }),
|
|
);
|
|
expect(reinitMCPServer).toHaveBeenCalledTimes(1);
|
|
expect(reinitMCPServer).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
serverName,
|
|
returnOnOAuth: false,
|
|
oauthStart: expect.any(Function),
|
|
}),
|
|
);
|
|
expect(mockSendEvent).toHaveBeenCalledWith(
|
|
res,
|
|
expect.objectContaining({
|
|
event: 'on_run_step_delta',
|
|
data: expect.objectContaining({
|
|
id: `step_oauth_login_${serverName}`,
|
|
delta: expect.objectContaining({
|
|
auth: authorizationUrl,
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should re-emit pending MCP OAuth prompts when selected MCP tools are already concrete', async () => {
|
|
const serverName = `Google${Constants.mcp_delimiter}Workspace`;
|
|
const authorizationUrl = 'https://auth.example.com/Google-Workspace';
|
|
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
/** A server whose own name contains the delimiter is only resolvable
|
|
* against the configured set, so the key boundary is unambiguous. */
|
|
mockResolveConfigServers.mockResolvedValue({ [serverName]: {} });
|
|
const res = { writableEnded: false };
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockFlowManager.getFlowState.mockResolvedValue({
|
|
status: 'PENDING',
|
|
createdAt: Date.now(),
|
|
metadata: { authorizationUrl },
|
|
});
|
|
mockLoadToolDefinitions.mockResolvedValue({
|
|
toolDefinitions: [mcpTool],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
});
|
|
reinitMCPServer.mockImplementation(async ({ oauthStart }) => {
|
|
await oauthStart(authorizationUrl);
|
|
return { availableTools: { [mcpTool]: {} } };
|
|
});
|
|
|
|
const result = await loadAgentTools({
|
|
req,
|
|
res,
|
|
agent: { id: 'agent_123', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(result.toolDefinitions).toEqual([mcpTool]);
|
|
expect(mockGetMCPServerTools).not.toHaveBeenCalled();
|
|
expect(reinitMCPServer).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
serverName,
|
|
returnOnOAuth: false,
|
|
oauthStart: expect.any(Function),
|
|
}),
|
|
);
|
|
expect(mockSendEvent).toHaveBeenCalledWith(
|
|
res,
|
|
expect.objectContaining({
|
|
event: 'on_run_step_delta',
|
|
data: expect.objectContaining({
|
|
id: `step_oauth_login_${serverName}`,
|
|
delta: expect.objectContaining({
|
|
auth: authorizationUrl,
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should emit stored pending MCP OAuth prompts before waiting on a silent in-flight join', async () => {
|
|
const serverName = 'Google-Workspace';
|
|
const authorizationUrl = 'https://auth.example.com/Google-Workspace';
|
|
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
const res = { writableEnded: false };
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockFlowManager.getFlowState.mockResolvedValue({
|
|
status: 'PENDING',
|
|
createdAt: Date.now(),
|
|
metadata: { authorizationUrl },
|
|
});
|
|
mockLoadToolDefinitions.mockResolvedValue({
|
|
toolDefinitions: [mcpTool],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
});
|
|
reinitMCPServer.mockResolvedValue({ availableTools: null });
|
|
|
|
const result = await loadAgentTools({
|
|
req,
|
|
res,
|
|
agent: { id: 'agent_123', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(result.toolDefinitions).toEqual([mcpTool]);
|
|
expect(reinitMCPServer).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
serverName,
|
|
returnOnOAuth: false,
|
|
oauthStart: expect.any(Function),
|
|
}),
|
|
);
|
|
expect(mockSendEvent).toHaveBeenCalledWith(
|
|
res,
|
|
expect.objectContaining({
|
|
event: 'on_run_step_delta',
|
|
data: expect.objectContaining({
|
|
id: `step_oauth_login_${serverName}`,
|
|
delta: expect.objectContaining({
|
|
auth: authorizationUrl,
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should preserve OAuth URLs emitted while discovering MCP tools before a silent wait join', async () => {
|
|
const serverName = 'Google-Workspace';
|
|
const authorizationUrl = 'https://auth.example.com/Google-Workspace';
|
|
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
const res = { writableEnded: false };
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockGetServerConfig.mockResolvedValue({
|
|
type: 'streamable-http',
|
|
url: 'https://demo.librechat.ai/mcp',
|
|
requiresOAuth: true,
|
|
});
|
|
mockGetMCPServerTools.mockResolvedValue(null);
|
|
mockFlowManager.getFlowState.mockResolvedValue(null);
|
|
mockLoadToolDefinitions.mockImplementation(async (params, deps) => {
|
|
await deps.getOrFetchMCPServerTools(params.userId, serverName);
|
|
return {
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
});
|
|
reinitMCPServer
|
|
.mockImplementationOnce(async ({ oauthStart }) => {
|
|
await oauthStart(authorizationUrl, { expiresAt: Date.now() + 60_000 });
|
|
return { availableTools: null };
|
|
})
|
|
.mockResolvedValue({ availableTools: null });
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res,
|
|
agent: { id: 'agent_123', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(reinitMCPServer).toHaveBeenCalledTimes(2);
|
|
expect(mockSendEvent).toHaveBeenCalledWith(
|
|
res,
|
|
expect.objectContaining({
|
|
event: 'on_run_step_delta',
|
|
data: expect.objectContaining({
|
|
id: `step_oauth_login_${serverName}`,
|
|
delta: expect.objectContaining({
|
|
auth: authorizationUrl,
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should pass request body context into MCP tool definition reinitialization', async () => {
|
|
const serverName = 'Body-Scoped';
|
|
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
req.body = { conversationId: 'conv-123', messageId: 'msg-123' };
|
|
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockGetServerConfig.mockResolvedValue({
|
|
type: 'streamable-http',
|
|
url: 'https://demo.librechat.ai/messages/{{LIBRECHAT_BODY_MESSAGEID}}/mcp',
|
|
source: 'yaml',
|
|
});
|
|
mockGetMCPServerTools.mockResolvedValue(null);
|
|
mockFlowManager.getFlowState.mockResolvedValue(null);
|
|
mockLoadToolDefinitions.mockImplementation(async (params, deps) => {
|
|
await deps.getOrFetchMCPServerTools(params.userId, serverName);
|
|
return {
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
});
|
|
reinitMCPServer.mockResolvedValue({ availableTools: null });
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
agent: { id: 'agent_123', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(reinitMCPServer).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
serverName,
|
|
requestBody: req.body,
|
|
}),
|
|
);
|
|
expect(mockGetMCPServerTools).toHaveBeenCalledWith(
|
|
req.user.id,
|
|
serverName,
|
|
expect.objectContaining({
|
|
url: expect.stringContaining('LIBRECHAT_BODY_MESSAGEID'),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('forwards OBO context through forced MCP catalog refreshes', async () => {
|
|
const serverName = 'OBO-Refresh';
|
|
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
req.user = {
|
|
id: 'user_123',
|
|
provider: 'openid',
|
|
openidId: 'oidc-sub-123',
|
|
tenantId: 'tenant-1',
|
|
openidIssuer: 'https://issuer.example.com',
|
|
};
|
|
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockGetServerConfig.mockResolvedValue({
|
|
type: 'streamable-http',
|
|
url: 'https://mcp.example.com/obo',
|
|
obo: { scopes: 'api://obo/Mcp.Tools.ReadWrite' },
|
|
});
|
|
mockLoadToolDefinitions.mockImplementation(async (params, dependencies) => {
|
|
await dependencies.refreshMCPServerTools(params.userId, serverName);
|
|
return {
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
});
|
|
reinitMCPServer.mockResolvedValue({ availableTools: {} });
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
agent: { id: 'agent_123', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(reinitMCPServer).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
serverName,
|
|
forceNew: true,
|
|
upstreamTokenProvider: expect.any(Function),
|
|
oboIdentityContext: expect.objectContaining({
|
|
appUserId: 'user_123',
|
|
openidSubject: 'oidc-sub-123',
|
|
tenantId: 'tenant-1',
|
|
openidIssuer: 'https://issuer.example.com',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('returns run-scoped MCP tool definitions for request-scoped servers', async () => {
|
|
const serverName = 'ClickHouse';
|
|
const mcpTool = `list_tables${Constants.mcp_delimiter}${serverName}`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
req.body = { conversationId: 'conv-123', messageId: 'msg-123' };
|
|
const availableTools = {
|
|
[mcpTool]: {
|
|
function: {
|
|
name: mcpTool,
|
|
description: 'List tables',
|
|
parameters: { type: 'object', properties: {} },
|
|
},
|
|
},
|
|
};
|
|
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockGetServerConfig.mockResolvedValue({
|
|
type: 'streamable-http',
|
|
url: 'https://mcp.example.com/{{LIBRECHAT_BODY_MESSAGEID}}/mcp',
|
|
source: 'yaml',
|
|
});
|
|
mockGetMCPServerTools.mockResolvedValue(null);
|
|
mockFlowManager.getFlowState.mockResolvedValue(null);
|
|
mockLoadToolDefinitions.mockImplementation(async (params, deps) => {
|
|
const serverTools = await deps.getOrFetchMCPServerTools(params.userId, serverName);
|
|
return {
|
|
toolDefinitions: serverTools ? Object.keys(serverTools) : [],
|
|
toolRegistry: new Map([[mcpTool, { name: mcpTool }]]),
|
|
hasDeferredTools: false,
|
|
};
|
|
});
|
|
reinitMCPServer.mockResolvedValue({ availableTools });
|
|
|
|
const result = await loadAgentTools({
|
|
req,
|
|
agent: { id: 'agent_123', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(result.toolDefinitions).toEqual([mcpTool]);
|
|
expect(result.mcpAvailableTools).toEqual({ [serverName]: availableTools });
|
|
expect(mockGetMCPServerTools).toHaveBeenCalledWith(
|
|
req.user.id,
|
|
serverName,
|
|
expect.objectContaining({
|
|
url: expect.stringContaining('LIBRECHAT_BODY_MESSAGEID'),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should preserve pending-flow expiry for OAuth URLs captured during discovery', async () => {
|
|
const serverName = 'Google-Workspace';
|
|
const authorizationUrl = 'https://auth.example.com/Google-Workspace';
|
|
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
const res = { writableEnded: false };
|
|
const createdAt = Date.now() - 45_000;
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockGetServerConfig.mockResolvedValue({
|
|
type: 'streamable-http',
|
|
url: 'https://demo.librechat.ai/mcp',
|
|
requiresOAuth: true,
|
|
});
|
|
mockGetMCPServerTools.mockResolvedValue(null);
|
|
mockFlowManager.getFlowState.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
|
status: 'PENDING',
|
|
createdAt,
|
|
metadata: { authorizationUrl },
|
|
});
|
|
mockLoadToolDefinitions.mockImplementation(async (params, deps) => {
|
|
await deps.getOrFetchMCPServerTools(params.userId, serverName);
|
|
return {
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
});
|
|
reinitMCPServer
|
|
.mockImplementationOnce(async ({ oauthStart }) => {
|
|
await oauthStart(authorizationUrl);
|
|
return { availableTools: null };
|
|
})
|
|
.mockResolvedValue({ availableTools: null });
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res,
|
|
agent: { id: 'agent_123', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
const authDeltaEvent = mockSendEvent.mock.calls
|
|
.map(([, event]) => event)
|
|
.find((event) => event.data?.delta?.auth === authorizationUrl);
|
|
expect(authDeltaEvent?.data.delta.expires_at).toBe(createdAt + PENDING_STALE_MS);
|
|
});
|
|
|
|
it('should use request-scoped MCP config before falling back to the registry', async () => {
|
|
const serverName = 'config-server';
|
|
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockResolveConfigServers.mockResolvedValue({
|
|
[serverName]: {
|
|
type: 'streamable-http',
|
|
url: 'https://config.example.com/mcp',
|
|
customUserVars: {
|
|
TOKEN: { title: 'Token', description: 'Token' },
|
|
},
|
|
},
|
|
});
|
|
mockGetUserMCPAuthMap.mockResolvedValue({
|
|
[`${Constants.mcp_prefix}${serverName}`]: { TOKEN: 'secret' },
|
|
});
|
|
mockGetMCPServerTools.mockResolvedValue({
|
|
[mcpTool]: {
|
|
function: {
|
|
name: mcpTool,
|
|
description: 'Config search',
|
|
parameters: {},
|
|
},
|
|
},
|
|
});
|
|
mockLoadToolDefinitions.mockImplementation(async (params, deps) => {
|
|
const serverTools = await deps.getOrFetchMCPServerTools(params.userId, serverName);
|
|
return {
|
|
toolDefinitions: serverTools ? Object.keys(serverTools) : [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
});
|
|
|
|
const result = await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [mcpTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(result.toolDefinitions).toEqual([mcpTool]);
|
|
expect(mockGetServerConfig).not.toHaveBeenCalled();
|
|
expect(mockGetMCPServerTools).toHaveBeenCalledWith(
|
|
req.user.id,
|
|
serverName,
|
|
expect.objectContaining({ url: 'https://config.example.com/mcp' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('loadAgentTools (definitionsOnly=false) — action tool filtering', () => {
|
|
const actionToolName = `get_weather${actionDelimiter}api_example_com`;
|
|
const regularTool = 'calculator';
|
|
|
|
it('threads the owning job epoch into web-search attachment callbacks', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.web_search];
|
|
const req = createMockReq(capabilities);
|
|
const res = {};
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res,
|
|
streamId: 'conversation-1',
|
|
jobCreatedAt: 1234,
|
|
agent: { id: 'agent_123', tools: [Tools.web_search] },
|
|
definitionsOnly: false,
|
|
});
|
|
|
|
expect(createOnSearchResults).toHaveBeenCalledWith(res, 'conversation-1', 1234);
|
|
});
|
|
|
|
it('should not load action sets when actions capability is disabled', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.web_search];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool, actionToolName] },
|
|
definitionsOnly: false,
|
|
});
|
|
|
|
expect(mockLoadActionSets).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should load action sets when actions capability is enabled and action tools present', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool, actionToolName] },
|
|
definitionsOnly: false,
|
|
});
|
|
|
|
expect(mockLoadActionSets).toHaveBeenCalledWith({ agent_id: 'agent_123' });
|
|
});
|
|
|
|
it('blocks persisted action metadata before generic tool initialization', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
req.config.filters = {
|
|
actionMetadata: {
|
|
pii: {
|
|
fields: ['raw_spec'],
|
|
starterPatterns: [],
|
|
customPatterns: [
|
|
{
|
|
id: 'private',
|
|
label: 'private value',
|
|
regex: 'PRIVATE-[A-Z]+',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockLoadActionSets.mockResolvedValue([
|
|
{
|
|
action_id: 'action_private_before_generic',
|
|
metadata: {
|
|
domain: 'https://api.example.com',
|
|
raw_spec: '{"description":"PRIVATE-ACTION"}',
|
|
},
|
|
},
|
|
]);
|
|
|
|
await expect(
|
|
loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool, actionToolName] },
|
|
definitionsOnly: false,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'content_filter_block',
|
|
body: {
|
|
source: 'action_metadata',
|
|
field: 'raw_spec',
|
|
},
|
|
});
|
|
|
|
expect(mockLoadToolsUtil).not.toHaveBeenCalled();
|
|
expect(mockDomainParser).not.toHaveBeenCalled();
|
|
expect(mockCreateActionTool).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('loadToolsForExecution — action tool gating', () => {
|
|
it('should preserve the remote-agent permission boundary for deferred tool loading', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.file_search];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [Tools.file_search] },
|
|
toolNames: [Tools.file_search],
|
|
agentResourceType: ResourceType.REMOTE_AGENT,
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
options: expect.objectContaining({
|
|
agentResourceType: ResourceType.REMOTE_AGENT,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('threads the normalized MCP body through deferred tool loading', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.web_search];
|
|
const req = createMockReq(capabilities);
|
|
const requestBody = {
|
|
messageId: 'response-1',
|
|
conversationId: 'conversation-1',
|
|
parentMessageId: 'parent-1',
|
|
};
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
requestBody,
|
|
agent: { id: 'agent_123', tools: [Tools.web_search] },
|
|
toolNames: [Tools.web_search],
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
options: expect.objectContaining({ requestBody }),
|
|
}),
|
|
);
|
|
expect(result.configurable.requestBody).toBe(requestBody);
|
|
});
|
|
|
|
const actionToolName = `get_weather${actionDelimiter}api_example_com`;
|
|
const regularTool = Tools.web_search;
|
|
|
|
it('threads the owning job epoch into web-search attachment callbacks', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.web_search];
|
|
const req = createMockReq(capabilities);
|
|
const res = {};
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadToolsForExecution({
|
|
req,
|
|
res,
|
|
streamId: 'conversation-1',
|
|
jobCreatedAt: 1234,
|
|
agent: { id: 'agent_123', tools: [Tools.web_search] },
|
|
toolNames: [Tools.web_search],
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(createOnSearchResults).toHaveBeenCalledWith(res, 'conversation-1', 1234);
|
|
});
|
|
|
|
it('does not load code execution tools that were not registered for the agent', async () => {
|
|
const capabilities = [
|
|
AgentCapabilities.tools,
|
|
AgentCapabilities.web_search,
|
|
AgentCapabilities.execute_code,
|
|
];
|
|
const req = createMockReq(capabilities);
|
|
const toolRegistry = new Map([[Tools.web_search, { name: Tools.web_search }]]);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_without_code', tools: [Tools.web_search] },
|
|
toolNames: [AgentConstants.BASH_TOOL, Tools.execute_code],
|
|
toolRegistry,
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(result.loadedTools.map((tool) => tool.name)).toEqual([]);
|
|
expect(mockLoadToolsUtil).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps stateless and stateful agents on isolated execution profiles in one run', async () => {
|
|
const capabilities = [
|
|
AgentCapabilities.tools,
|
|
AgentCapabilities.execute_code,
|
|
AgentCapabilities.stateful_code_sessions,
|
|
];
|
|
const req = createMockReq(capabilities);
|
|
req.body = { conversationId: 'conversation-1' };
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
process.env.LIBRECHAT_CODE_BASEURL = 'http://code-default.test/v1';
|
|
process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'http://code-stateful.test/v1';
|
|
|
|
try {
|
|
const stateless = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'stateless-agent', tools: [Tools.execute_code] },
|
|
toolNames: [],
|
|
});
|
|
const stateful = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: {
|
|
id: 'stateful-agent',
|
|
tools: [Tools.execute_code],
|
|
stateful_code_sessions: true,
|
|
stateful_code_environment: 'agent-user',
|
|
},
|
|
toolNames: [],
|
|
});
|
|
|
|
expect(stateless.configurable.codeExecutionContext).toEqual({
|
|
baseUrl: 'http://code-default.test/v1',
|
|
codeSessionKey: 'execute_code',
|
|
executionProfile: 'default',
|
|
statefulSessions: false,
|
|
});
|
|
expect(stateful.configurable.codeExecutionContext).toEqual({
|
|
baseUrl: 'http://code-stateful.test/v1',
|
|
codeSessionKey: 'execute_code:stateful:v2:agent-user:7c684f0773d9642c122f67aa30e9e0f4',
|
|
executionProfile: 'stateful',
|
|
runtimeSessionHint: 'v2:agent-user:7c684f0773d9642c122f67aa30e9e0f4',
|
|
statefulSessions: true,
|
|
});
|
|
} finally {
|
|
delete process.env.LIBRECHAT_CODE_BASEURL;
|
|
delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL;
|
|
}
|
|
});
|
|
|
|
it('resolves stateful routing for host file tools with the controller conversation ID', async () => {
|
|
const capabilities = [
|
|
AgentCapabilities.tools,
|
|
AgentCapabilities.execute_code,
|
|
AgentCapabilities.stateful_code_sessions,
|
|
];
|
|
const req = createMockReq(capabilities);
|
|
req.body = {};
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'http://code-stateful.test/v1';
|
|
|
|
try {
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
conversationId: 'resolved-api-conversation',
|
|
agent: {
|
|
id: 'stateful-agent',
|
|
tools: [Tools.execute_code],
|
|
stateful_code_sessions: true,
|
|
stateful_code_environment: 'conversation',
|
|
},
|
|
toolNames: [AgentConstants.READ_FILE],
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(result.configurable.codeExecutionContext.executionProfile).toBe('stateful');
|
|
expect(mockResolveCodeExecutionContext).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
statefulSessions: true,
|
|
conversationId: 'resolved-api-conversation',
|
|
}),
|
|
);
|
|
} finally {
|
|
delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL;
|
|
}
|
|
});
|
|
|
|
it('resolves stateful routing when handle_skill is the only requested tool', async () => {
|
|
const capabilities = [
|
|
AgentCapabilities.tools,
|
|
AgentCapabilities.execute_code,
|
|
AgentCapabilities.stateful_code_sessions,
|
|
];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'http://code-stateful.test/v1';
|
|
|
|
try {
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: {
|
|
id: 'stateful-agent',
|
|
tools: [Tools.execute_code],
|
|
stateful_code_sessions: true,
|
|
stateful_code_environment: 'agent-user',
|
|
},
|
|
toolNames: [AgentConstants.SKILL_TOOL],
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(result.configurable.codeExecutionContext.executionProfile).toBe('stateful');
|
|
expect(mockResolveCodeExecutionContext).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ statefulSessions: true, environment: 'agent-user' }),
|
|
);
|
|
} finally {
|
|
delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL;
|
|
}
|
|
});
|
|
|
|
it('loads bash PTC under the legacy programmatic tool name when code capabilities are enabled', async () => {
|
|
const capabilities = [
|
|
AgentCapabilities.tools,
|
|
AgentCapabilities.programmatic_tools,
|
|
AgentCapabilities.execute_code,
|
|
];
|
|
const req = createMockReq(capabilities);
|
|
const toolRegistry = new Map([['custom_tool', { name: 'custom_tool' }]]);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
|
|
toolNames: [Constants.PROGRAMMATIC_TOOL_CALLING],
|
|
toolRegistry,
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(result.loadedTools.map((tool) => tool.name)).toEqual([
|
|
Constants.PROGRAMMATIC_TOOL_CALLING,
|
|
]);
|
|
expect(result.configurable.toolRegistry).toBe(toolRegistry);
|
|
expect(result.configurable.ptcToolMap.size).toBe(0);
|
|
});
|
|
|
|
it('passes run-scoped MCP tool definitions into PTC execution loading', async () => {
|
|
const capabilities = [
|
|
AgentCapabilities.tools,
|
|
AgentCapabilities.programmatic_tools,
|
|
AgentCapabilities.execute_code,
|
|
];
|
|
const req = createMockReq(capabilities);
|
|
const serverName = 'ClickHouse';
|
|
const mcpTool = `list_tables${Constants.mcp_delimiter}${serverName}`;
|
|
const mcpAvailableTools = {
|
|
[serverName]: {
|
|
[mcpTool]: {
|
|
function: {
|
|
name: mcpTool,
|
|
description: 'List tables',
|
|
parameters: { type: 'object', properties: {} },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const toolRegistry = new Map([
|
|
[mcpTool, { name: mcpTool, allowed_callers: ['code_execution'] }],
|
|
]);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
|
|
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
|
|
toolRegistry,
|
|
mcpAvailableTools,
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tools: [mcpTool],
|
|
options: expect.objectContaining({
|
|
mcpAvailableTools,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('loads only code_execution tools into the PTC execution map', async () => {
|
|
const capabilities = [
|
|
AgentCapabilities.tools,
|
|
AgentCapabilities.programmatic_tools,
|
|
AgentCapabilities.execute_code,
|
|
];
|
|
const req = createMockReq(capabilities);
|
|
const programmaticTool = {
|
|
name: 'programmatic_tool',
|
|
invoke: jest.fn(),
|
|
};
|
|
const toolRegistry = new Map([
|
|
[
|
|
programmaticTool.name,
|
|
{ name: programmaticTool.name, allowed_callers: ['code_execution'] },
|
|
],
|
|
['direct_tool', { name: 'direct_tool', allowed_callers: ['direct'] }],
|
|
]);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockLoadToolsUtil.mockResolvedValue({
|
|
loadedTools: [programmaticTool],
|
|
toolContextMap: {},
|
|
});
|
|
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
|
|
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
|
|
toolRegistry,
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
|
|
expect.objectContaining({ tools: ['programmatic_tool'] }),
|
|
);
|
|
expect([...result.configurable.ptcToolMap.keys()]).toEqual(['programmatic_tool']);
|
|
});
|
|
|
|
it('intersects PTC loading with the SDK live caller projection', async () => {
|
|
const capabilities = [
|
|
AgentCapabilities.tools,
|
|
AgentCapabilities.programmatic_tools,
|
|
AgentCapabilities.execute_code,
|
|
];
|
|
const req = createMockReq(capabilities);
|
|
const activeTool = { name: 'active_programmatic_tool', invoke: jest.fn() };
|
|
const deferredTool = { name: 'deferred_programmatic_tool', invoke: jest.fn() };
|
|
const toolRegistry = new Map([
|
|
[activeTool.name, { name: activeTool.name, allowed_callers: ['code_execution'] }],
|
|
[
|
|
deferredTool.name,
|
|
{
|
|
name: deferredTool.name,
|
|
allowed_callers: ['code_execution'],
|
|
defer_loading: true,
|
|
},
|
|
],
|
|
]);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockLoadToolsUtil.mockResolvedValue({
|
|
loadedTools: [activeTool],
|
|
toolContextMap: {},
|
|
});
|
|
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
|
|
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
|
|
toolRegistry,
|
|
callerCapabilityProjection: {
|
|
version: 1,
|
|
directToolNames: [],
|
|
codeExecutionToolNames: [activeTool.name],
|
|
directOnlyToolNames: [],
|
|
codeExecutionOnlyToolNames: [activeTool.name],
|
|
},
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
|
|
expect.objectContaining({ tools: [activeTool.name] }),
|
|
);
|
|
expect([...result.configurable.ptcToolMap.keys()]).toEqual([activeTool.name]);
|
|
});
|
|
|
|
it('treats an empty versioned caller projection as authoritative', async () => {
|
|
const capabilities = [
|
|
AgentCapabilities.tools,
|
|
AgentCapabilities.programmatic_tools,
|
|
AgentCapabilities.execute_code,
|
|
];
|
|
const req = createMockReq(capabilities);
|
|
const toolRegistry = new Map([
|
|
[
|
|
'deferred_programmatic_tool',
|
|
{ name: 'deferred_programmatic_tool', allowed_callers: ['code_execution'] },
|
|
],
|
|
]);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
|
|
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
|
|
toolRegistry,
|
|
callerCapabilityProjection: {
|
|
version: 1,
|
|
directToolNames: [],
|
|
codeExecutionToolNames: [],
|
|
directOnlyToolNames: [],
|
|
codeExecutionOnlyToolNames: [],
|
|
},
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(mockLoadToolsUtil).not.toHaveBeenCalled();
|
|
expect(result.configurable.ptcToolMap).toEqual(new Map());
|
|
});
|
|
|
|
it('falls back to registry projection for unknown snapshot versions', async () => {
|
|
const capabilities = [
|
|
AgentCapabilities.tools,
|
|
AgentCapabilities.programmatic_tools,
|
|
AgentCapabilities.execute_code,
|
|
];
|
|
const req = createMockReq(capabilities);
|
|
const programmaticTool = { name: 'programmatic_tool', invoke: jest.fn() };
|
|
const toolRegistry = new Map([
|
|
[
|
|
programmaticTool.name,
|
|
{ name: programmaticTool.name, allowed_callers: ['code_execution'] },
|
|
],
|
|
]);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
mockLoadToolsUtil.mockResolvedValue({
|
|
loadedTools: [programmaticTool],
|
|
toolContextMap: {},
|
|
});
|
|
|
|
await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
|
|
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
|
|
toolRegistry,
|
|
callerCapabilityProjection: {
|
|
version: 2,
|
|
directToolNames: [],
|
|
codeExecutionToolNames: [],
|
|
directOnlyToolNames: [],
|
|
codeExecutionOnlyToolNames: [],
|
|
},
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
|
|
expect.objectContaining({ tools: [programmaticTool.name] }),
|
|
);
|
|
});
|
|
|
|
it('does not load PTC when programmatic tools capability is disabled', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.execute_code];
|
|
const req = createMockReq(capabilities);
|
|
const toolRegistry = new Map([['custom_tool', { name: 'custom_tool' }]]);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
|
|
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
|
|
toolRegistry,
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(result.loadedTools.map((tool) => tool.name)).toEqual([]);
|
|
expect(result.configurable.toolRegistry).toBeUndefined();
|
|
expect(result.configurable.ptcToolMap).toBeUndefined();
|
|
});
|
|
|
|
it('does not load PTC when agent did not request execute_code', async () => {
|
|
const capabilities = [
|
|
AgentCapabilities.tools,
|
|
AgentCapabilities.programmatic_tools,
|
|
AgentCapabilities.execute_code,
|
|
];
|
|
const req = createMockReq(capabilities);
|
|
const toolRegistry = new Map([['custom_tool', { name: 'custom_tool' }]]);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_ptc', tools: [] },
|
|
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
|
|
toolRegistry,
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(result.loadedTools.map((tool) => tool.name)).toEqual([]);
|
|
expect(result.configurable.toolRegistry).toBeUndefined();
|
|
expect(result.configurable.ptcToolMap).toBeUndefined();
|
|
});
|
|
|
|
it('should skip action tool loading when actionsEnabled=false', async () => {
|
|
const req = createMockReq([]);
|
|
req.config = {};
|
|
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123' },
|
|
toolNames: [regularTool, actionToolName],
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(mockLoadActionSets).not.toHaveBeenCalled();
|
|
expect(result.loadedTools).toBeDefined();
|
|
});
|
|
|
|
it('should load action tools when actionsEnabled=true', async () => {
|
|
const req = createMockReq([AgentCapabilities.actions]);
|
|
req.config = {};
|
|
|
|
await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123' },
|
|
toolNames: [actionToolName],
|
|
actionsEnabled: true,
|
|
});
|
|
|
|
expect(mockLoadActionSets).toHaveBeenCalledWith({ agent_id: 'agent_123' });
|
|
});
|
|
|
|
it('blocks decrypted persisted action secrets before execution-time domain work', async () => {
|
|
const req = createMockReq([AgentCapabilities.actions]);
|
|
req.config.filters = {
|
|
actionMetadata: {
|
|
pii: {
|
|
fields: ['oauth_client_secret'],
|
|
starterPatterns: [],
|
|
customPatterns: [
|
|
{
|
|
id: 'private',
|
|
label: 'private value',
|
|
regex: 'PRIVATE-[A-Z]+',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
const metadata = {
|
|
domain: 'https://api.example.com',
|
|
raw_spec: '{}',
|
|
oauth_client_secret: 'encrypted-value',
|
|
};
|
|
mockLoadActionSets.mockResolvedValue([
|
|
{
|
|
action_id: 'action_private_auth',
|
|
metadata,
|
|
},
|
|
]);
|
|
mockDecryptMetadata.mockResolvedValue({
|
|
...metadata,
|
|
oauth_client_secret: 'PRIVATE-OAUTH',
|
|
});
|
|
|
|
await expect(
|
|
loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123' },
|
|
toolNames: [actionToolName],
|
|
actionsEnabled: true,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: 'content_filter_block',
|
|
body: {
|
|
source: 'action_metadata',
|
|
field: 'oauth_client_secret',
|
|
},
|
|
});
|
|
|
|
expect(mockDecryptMetadata).toHaveBeenCalledWith(metadata);
|
|
expect(mockDomainParser).not.toHaveBeenCalled();
|
|
expect(mockCreateActionTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should resolve actionsEnabled from capabilities when not explicitly provided', async () => {
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123' },
|
|
toolNames: [actionToolName],
|
|
});
|
|
|
|
expect(mockGetEndpointsConfig).toHaveBeenCalled();
|
|
expect(mockLoadActionSets).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not call loadActionSets when there are no action tools', async () => {
|
|
const req = createMockReq([AgentCapabilities.actions]);
|
|
req.config = {};
|
|
|
|
await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123' },
|
|
toolNames: [regularTool],
|
|
actionsEnabled: true,
|
|
});
|
|
|
|
expect(mockLoadActionSets).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('checkCapability logic', () => {
|
|
const createCheckCapability = (enabledCapabilities, logger = { warn: jest.fn() }) => {
|
|
return (capability) => {
|
|
const enabled = enabledCapabilities.has(capability);
|
|
if (!enabled) {
|
|
const isToolCapability = [
|
|
AgentCapabilities.file_search,
|
|
AgentCapabilities.execute_code,
|
|
AgentCapabilities.web_search,
|
|
].includes(capability);
|
|
const suffix = isToolCapability ? ' despite configured tool.' : '.';
|
|
logger.warn(`Capability "${capability}" disabled${suffix}`);
|
|
}
|
|
return enabled;
|
|
};
|
|
};
|
|
|
|
it('should return true when capability is enabled', () => {
|
|
const enabledCapabilities = new Set([AgentCapabilities.deferred_tools]);
|
|
const checkCapability = createCheckCapability(enabledCapabilities);
|
|
|
|
expect(checkCapability(AgentCapabilities.deferred_tools)).toBe(true);
|
|
});
|
|
|
|
it('should return false when capability is not enabled', () => {
|
|
const enabledCapabilities = new Set([]);
|
|
const checkCapability = createCheckCapability(enabledCapabilities);
|
|
|
|
expect(checkCapability(AgentCapabilities.deferred_tools)).toBe(false);
|
|
});
|
|
|
|
it('should log warning with "despite configured tool" for tool capabilities', () => {
|
|
const logger = { warn: jest.fn() };
|
|
const enabledCapabilities = new Set([]);
|
|
const checkCapability = createCheckCapability(enabledCapabilities, logger);
|
|
|
|
checkCapability(AgentCapabilities.file_search);
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('despite configured tool'));
|
|
|
|
logger.warn.mockClear();
|
|
checkCapability(AgentCapabilities.execute_code);
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('despite configured tool'));
|
|
|
|
logger.warn.mockClear();
|
|
checkCapability(AgentCapabilities.web_search);
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('despite configured tool'));
|
|
});
|
|
|
|
it('should log warning without "despite configured tool" for non-tool capabilities', () => {
|
|
const logger = { warn: jest.fn() };
|
|
const enabledCapabilities = new Set([]);
|
|
const checkCapability = createCheckCapability(enabledCapabilities, logger);
|
|
|
|
checkCapability(AgentCapabilities.deferred_tools);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('Capability "deferred_tools" disabled.'),
|
|
);
|
|
expect(logger.warn).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('despite configured tool'),
|
|
);
|
|
|
|
logger.warn.mockClear();
|
|
checkCapability(AgentCapabilities.tools);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('Capability "tools" disabled.'),
|
|
);
|
|
expect(logger.warn).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('despite configured tool'),
|
|
);
|
|
|
|
logger.warn.mockClear();
|
|
checkCapability(AgentCapabilities.actions);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('Capability "actions" disabled.'),
|
|
);
|
|
});
|
|
|
|
it('should not log warning when capability is enabled', () => {
|
|
const logger = { warn: jest.fn() };
|
|
const enabledCapabilities = new Set([
|
|
AgentCapabilities.deferred_tools,
|
|
AgentCapabilities.file_search,
|
|
]);
|
|
const checkCapability = createCheckCapability(enabledCapabilities, logger);
|
|
|
|
checkCapability(AgentCapabilities.deferred_tools);
|
|
checkCapability(AgentCapabilities.file_search);
|
|
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('defaultAgentCapabilities', () => {
|
|
it('should include deferred_tools capability by default', () => {
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.deferred_tools);
|
|
});
|
|
|
|
it('should include all expected default capabilities', () => {
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.execute_code);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.file_search);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.web_search);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.artifacts);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.actions);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.context);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.ask_user_question);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.tools);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.chain);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.ocr);
|
|
});
|
|
});
|
|
|
|
describe('userMCPAuthMap gating', () => {
|
|
const shouldFetchMCPAuth = (tools) =>
|
|
tools?.some((t) => t.includes(Constants.mcp_delimiter)) ?? false;
|
|
|
|
it('should return true when agent has MCP tools', () => {
|
|
const tools = ['web_search', `search${Constants.mcp_delimiter}my-mcp-server`, 'calculator'];
|
|
expect(shouldFetchMCPAuth(tools)).toBe(true);
|
|
});
|
|
|
|
it('should return false when agent has no MCP tools', () => {
|
|
const tools = ['web_search', 'calculator', 'code_interpreter'];
|
|
expect(shouldFetchMCPAuth(tools)).toBe(false);
|
|
});
|
|
|
|
it('should return false when tools is empty', () => {
|
|
expect(shouldFetchMCPAuth([])).toBe(false);
|
|
});
|
|
|
|
it('should return false when tools is undefined', () => {
|
|
expect(shouldFetchMCPAuth(undefined)).toBe(false);
|
|
});
|
|
|
|
it('should return false when tools is null', () => {
|
|
expect(shouldFetchMCPAuth(null)).toBe(false);
|
|
});
|
|
|
|
it('should detect MCP tools with different server names', () => {
|
|
const tools = [
|
|
`listFiles${Constants.mcp_delimiter}file-server`,
|
|
`query${Constants.mcp_delimiter}db-server`,
|
|
];
|
|
expect(shouldFetchMCPAuth(tools)).toBe(true);
|
|
});
|
|
|
|
it('should return true even when only one tool is MCP', () => {
|
|
const tools = [
|
|
'web_search',
|
|
'calculator',
|
|
'code_interpreter',
|
|
`echo${Constants.mcp_delimiter}test-server`,
|
|
];
|
|
expect(shouldFetchMCPAuth(tools)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('deferredToolsEnabled integration', () => {
|
|
it('should correctly determine deferredToolsEnabled from capabilities set', () => {
|
|
const createCheckCapability = (enabledCapabilities) => {
|
|
return (capability) => enabledCapabilities.has(capability);
|
|
};
|
|
|
|
const withDeferred = new Set([AgentCapabilities.deferred_tools, AgentCapabilities.tools]);
|
|
const checkWithDeferred = createCheckCapability(withDeferred);
|
|
expect(checkWithDeferred(AgentCapabilities.deferred_tools)).toBe(true);
|
|
|
|
const withoutDeferred = new Set([AgentCapabilities.tools, AgentCapabilities.actions]);
|
|
const checkWithoutDeferred = createCheckCapability(withoutDeferred);
|
|
expect(checkWithoutDeferred(AgentCapabilities.deferred_tools)).toBe(false);
|
|
});
|
|
|
|
it('should use defaultAgentCapabilities when no capabilities configured', () => {
|
|
const endpointsConfig = {};
|
|
const enabledCapabilities = new Set(
|
|
endpointsConfig?.capabilities ?? defaultAgentCapabilities,
|
|
);
|
|
|
|
expect(enabledCapabilities.has(AgentCapabilities.deferred_tools)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('multi-action domain collision regression', () => {
|
|
// Two distinct OpenAPI Actions whose `servers[0].url` resolves to the
|
|
// same hostname must both contribute their tools to the agent. The
|
|
// previous implementation indexed processed action sets by encoded
|
|
// domain, so the second action overwrote the first in the map and one
|
|
// action's tools silently disappeared from the LLM payload.
|
|
//
|
|
// The encoded domain we use as the lookup key for the action sets is
|
|
// mocked to a fixed string for both actions to make the collision
|
|
// condition deterministic without depending on the real base64
|
|
// truncation rules.
|
|
const SHARED_DOMAIN = 'https://api.example.com';
|
|
const ENCODED_DOMAIN = 'shared_dom';
|
|
const LEGACY_ENCODED_DOMAIN = 'legacy_dom';
|
|
|
|
const buildSpec = (operationId, path) =>
|
|
JSON.stringify({
|
|
openapi: '3.0.3',
|
|
info: { title: `Mock ${operationId}`, version: '1.0.0' },
|
|
servers: [{ url: SHARED_DOMAIN }],
|
|
paths: {
|
|
[path]: {
|
|
get: {
|
|
operationId,
|
|
summary: `Mock ${operationId}`,
|
|
responses: {
|
|
200: {
|
|
description: 'OK',
|
|
content: { 'application/json': { schema: { type: 'object' } } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const actionA = {
|
|
action_id: 'action_a',
|
|
metadata: {
|
|
domain: SHARED_DOMAIN,
|
|
raw_spec: buildSpec('echoMessage', '/echo'),
|
|
},
|
|
};
|
|
const actionB = {
|
|
action_id: 'action_b',
|
|
metadata: {
|
|
domain: SHARED_DOMAIN,
|
|
raw_spec: buildSpec('listItems', '/items'),
|
|
},
|
|
};
|
|
|
|
const toolNameA = `echoMessage${actionDelimiter}${ENCODED_DOMAIN}`;
|
|
const toolNameB = `listItems${actionDelimiter}${ENCODED_DOMAIN}`;
|
|
|
|
beforeEach(() => {
|
|
// Both actions share a hostname → both call sites get the same encoded
|
|
// value back. This is precisely the collision shape that triggered
|
|
// the bug in production.
|
|
mockDomainParser.mockResolvedValue(ENCODED_DOMAIN);
|
|
mockLegacyDomainEncode.mockReturnValue(LEGACY_ENCODED_DOMAIN);
|
|
mockDecryptMetadata.mockImplementation(async (metadata) => metadata);
|
|
mockCreateActionTool.mockImplementation(async ({ name, requestBuilder }) => ({
|
|
name,
|
|
// Surface the request builder identity on the returned tool so
|
|
// assertions can verify each tool was wired to the correct action's
|
|
// builder, not its sibling's.
|
|
_builder: requestBuilder,
|
|
// Resolve instead of returning undefined — processRequiredActions
|
|
// chains `.then(handleToolOutput)` directly onto this call, which
|
|
// would throw synchronously on an undefined return and mask the
|
|
// test as a simulated runtime crash.
|
|
_call: jest.fn().mockResolvedValue('{"status":"ok"}'),
|
|
schema: {},
|
|
description: '',
|
|
}));
|
|
});
|
|
|
|
const expectBothActionsResolved = (calls) => {
|
|
const callsByName = new Map(calls.map((c) => [c[0].name, c[0]]));
|
|
expect(callsByName.has(toolNameA)).toBe(true);
|
|
expect(callsByName.has(toolNameB)).toBe(true);
|
|
// Each tool's request builder must come from the matching action's
|
|
// own parsed spec — not the sibling's. The previous bug would either
|
|
// route both to the same action's builders (and drop one as
|
|
// undefined) or silently skip one entirely.
|
|
const builderA = callsByName.get(toolNameA).requestBuilder;
|
|
const builderB = callsByName.get(toolNameB).requestBuilder;
|
|
expect(builderA).toBeDefined();
|
|
expect(builderB).toBeDefined();
|
|
expect(builderA).not.toBe(builderB);
|
|
// Each builder targets its own operation path — confirms the
|
|
// request builder lookup didn't cross-contaminate between actions.
|
|
expect(builderA.path).toBe('/echo');
|
|
expect(builderB.path).toBe('/items');
|
|
};
|
|
|
|
it('loadAgentTools resolves both actions when they share a hostname', async () => {
|
|
mockLoadActionSets.mockResolvedValue([actionA, actionB]);
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_collision', tools: [toolNameA, toolNameB] },
|
|
definitionsOnly: false,
|
|
});
|
|
|
|
expect(mockCreateActionTool).toHaveBeenCalledTimes(2);
|
|
expectBothActionsResolved(mockCreateActionTool.mock.calls);
|
|
});
|
|
|
|
it('loadAgentTools is order-invariant for two actions sharing a hostname', async () => {
|
|
// Reverse the actionSets order — what used to flip the "winner" of
|
|
// the encoded-domain Map overwrite must now make zero observable
|
|
// difference.
|
|
mockLoadActionSets.mockResolvedValue([actionB, actionA]);
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_collision', tools: [toolNameA, toolNameB] },
|
|
definitionsOnly: false,
|
|
});
|
|
|
|
expect(mockCreateActionTool).toHaveBeenCalledTimes(2);
|
|
expectBothActionsResolved(mockCreateActionTool.mock.calls);
|
|
});
|
|
|
|
it('loadToolsForExecution resolves both actions when they share a hostname', async () => {
|
|
mockLoadActionSets.mockResolvedValue([actionA, actionB]);
|
|
const req = createMockReq([AgentCapabilities.actions]);
|
|
req.config = {};
|
|
|
|
await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_collision' },
|
|
toolNames: [toolNameA, toolNameB],
|
|
actionsEnabled: true,
|
|
});
|
|
|
|
expect(mockCreateActionTool).toHaveBeenCalledTimes(2);
|
|
expectBothActionsResolved(mockCreateActionTool.mock.calls);
|
|
});
|
|
|
|
it('processRequiredActions resolves both actions when they share a hostname', async () => {
|
|
// The assistants/threads path received the same structural rewrite
|
|
// as the agent paths. Cover it directly so future regressions in the
|
|
// `toolToAction` map shape or the lookup normalization don't slip
|
|
// through just because the agent-path tests still pass.
|
|
mockLoadActionSets.mockResolvedValue([actionA, actionB]);
|
|
const client = {
|
|
req: {
|
|
user: { id: 'user_123' },
|
|
body: {
|
|
assistant_id: 'assistant_collision',
|
|
model: 'gpt-4o-mini',
|
|
endpoint: 'openAI',
|
|
},
|
|
config: {},
|
|
},
|
|
res: {},
|
|
apiKey: 'sk-test',
|
|
mappedOrder: new Map(),
|
|
seenToolCalls: new Map(),
|
|
addContentData: jest.fn(),
|
|
};
|
|
|
|
await processRequiredActions(client, [
|
|
{
|
|
tool: toolNameA,
|
|
toolInput: {},
|
|
toolCallId: 'call_a',
|
|
thread_id: 'thread_1',
|
|
run_id: 'run_1',
|
|
},
|
|
{
|
|
tool: toolNameB,
|
|
toolInput: {},
|
|
toolCallId: 'call_b',
|
|
thread_id: 'thread_1',
|
|
run_id: 'run_1',
|
|
},
|
|
]);
|
|
|
|
// The assistants path intentionally doesn't forward `name` to
|
|
// createActionTool (see ToolService.js — "intentionally not passing
|
|
// zodSchema, name, and description for assistants API"), so key
|
|
// resolution assertions off the request builder path instead.
|
|
expect(mockCreateActionTool).toHaveBeenCalledTimes(2);
|
|
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
options: expect.objectContaining({ res: client.res }),
|
|
}),
|
|
);
|
|
const builderPaths = mockCreateActionTool.mock.calls.map((c) => c[0].requestBuilder?.path);
|
|
expect(builderPaths).toEqual(expect.arrayContaining(['/echo', '/items']));
|
|
// Each call must carry a distinct builder — guards against the bug
|
|
// where the surviving action's builders got routed to every tool.
|
|
expect(builderPaths[0]).not.toBe(builderPaths[1]);
|
|
});
|
|
|
|
it('loadAgentTools resolves legacy-format tool names via the legacy encoding branch', async () => {
|
|
// Agents whose tool names predate the current domain encoding store
|
|
// them under `legacyDomainEncode`'s output. The map registers both
|
|
// encodings per function so these keep resolving after the fix;
|
|
// this test exercises the `if (legacyNormalized !== normalizedDomain)`
|
|
// branch, which was previously never hit by any test.
|
|
mockLoadActionSets.mockResolvedValue([actionA]);
|
|
const legacyToolName = `echoMessage${actionDelimiter}${LEGACY_ENCODED_DOMAIN}`;
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_legacy', tools: [legacyToolName] },
|
|
definitionsOnly: false,
|
|
});
|
|
|
|
expect(mockCreateActionTool).toHaveBeenCalledTimes(1);
|
|
const [callArgs] = mockCreateActionTool.mock.calls[0];
|
|
expect(callArgs.name).toBe(legacyToolName);
|
|
expect(callArgs.requestBuilder.path).toBe('/echo');
|
|
});
|
|
|
|
it('definitions-only loading emits the selected legacy action name', async () => {
|
|
mockLoadActionSets.mockResolvedValue([actionA]);
|
|
const legacyToolName = `echoMessage${actionDelimiter}${LEGACY_ENCODED_DOMAIN}`;
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
mockLoadToolDefinitions.mockImplementationOnce(async (_options, dependencies) => {
|
|
const definitions = await dependencies.getActionToolDefinitions('agent_legacy', [
|
|
legacyToolName,
|
|
]);
|
|
expect(definitions).toEqual([
|
|
expect.objectContaining({ name: legacyToolName, description: 'Mock echoMessage' }),
|
|
]);
|
|
return {
|
|
toolDefinitions: definitions,
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
};
|
|
});
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: {
|
|
id: 'agent_legacy',
|
|
tools: [legacyToolName],
|
|
tool_options: { [legacyToolName]: { run_in_background: true } },
|
|
},
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('loadAgentTools distinguishes operationIds that differ only by `---` vs `_`', async () => {
|
|
// `openapiToFunction` uses the user-supplied operationId verbatim
|
|
// and only sanitizes the synthetic `<method>_<path>` fallback, and
|
|
// `sanitizeOperationId` preserves `-`. So two operations whose
|
|
// operationIds differ only by `---` vs `_` (e.g. `get_foo---bar`
|
|
// and `get_foo_bar`) are legitimately distinct on the same spec —
|
|
// or, here, on two actions sharing a hostname.
|
|
//
|
|
// Normalization must only touch the encoded-domain suffix after
|
|
// `actionDelimiter`; if it also collapsed the operationId, both
|
|
// tools would write to the same map slot and resolve to the
|
|
// surviving entry's request builder.
|
|
const hyphenSpec = {
|
|
action_id: 'action_hyphen',
|
|
metadata: {
|
|
domain: SHARED_DOMAIN,
|
|
raw_spec: buildSpec('get_foo---bar', '/foo-bar'),
|
|
},
|
|
};
|
|
const underscoreSpec = {
|
|
action_id: 'action_underscore',
|
|
metadata: {
|
|
domain: SHARED_DOMAIN,
|
|
raw_spec: buildSpec('get_foo_bar', '/foo_bar'),
|
|
},
|
|
};
|
|
mockLoadActionSets.mockResolvedValue([hyphenSpec, underscoreSpec]);
|
|
|
|
const hyphenTool = `get_foo---bar${actionDelimiter}${ENCODED_DOMAIN}`;
|
|
const underscoreTool = `get_foo_bar${actionDelimiter}${ENCODED_DOMAIN}`;
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_hyphen', tools: [hyphenTool, underscoreTool] },
|
|
definitionsOnly: false,
|
|
});
|
|
|
|
expect(mockCreateActionTool).toHaveBeenCalledTimes(2);
|
|
const callsByName = new Map(mockCreateActionTool.mock.calls.map((c) => [c[0].name, c[0]]));
|
|
expect(callsByName.has(hyphenTool)).toBe(true);
|
|
expect(callsByName.has(underscoreTool)).toBe(true);
|
|
expect(callsByName.get(hyphenTool).requestBuilder.path).toBe('/foo-bar');
|
|
expect(callsByName.get(underscoreTool).requestBuilder.path).toBe('/foo_bar');
|
|
// Critical: the two must resolve to distinct builders. If the
|
|
// operationId half of the key is normalized, both collapse to
|
|
// the same map slot and one silently overwrites the other.
|
|
expect(callsByName.get(hyphenTool).requestBuilder).not.toBe(
|
|
callsByName.get(underscoreTool).requestBuilder,
|
|
);
|
|
});
|
|
|
|
it('loadAgentTools resolves raw `---`-separated tool names from agent.tools', async () => {
|
|
// Hostnames at or below ENCODED_DOMAIN_LENGTH round-trip through
|
|
// `domainParser(..., true)` as a `---`-separated string, and agents
|
|
// persist that raw form in `agent.tools`. The map is always keyed
|
|
// with the `_`-collapsed form, so the lookup must normalize the
|
|
// incoming name or short-hostname tools silently drop out.
|
|
mockDomainParser.mockResolvedValue('shared---dom');
|
|
mockLoadActionSets.mockResolvedValue([actionA, actionB]);
|
|
const rawNameA = `echoMessage${actionDelimiter}shared---dom`;
|
|
const rawNameB = `listItems${actionDelimiter}shared---dom`;
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_short', tools: [rawNameA, rawNameB] },
|
|
definitionsOnly: false,
|
|
});
|
|
|
|
expect(mockCreateActionTool).toHaveBeenCalledTimes(2);
|
|
const callsByName = new Map(mockCreateActionTool.mock.calls.map((c) => [c[0].name, c[0]]));
|
|
expect(callsByName.has(rawNameA)).toBe(true);
|
|
expect(callsByName.has(rawNameB)).toBe(true);
|
|
expect(callsByName.get(rawNameA).requestBuilder.path).toBe('/echo');
|
|
expect(callsByName.get(rawNameB).requestBuilder.path).toBe('/items');
|
|
});
|
|
});
|
|
});
|