Commit graph

1568 commits

Author SHA1 Message Date
Danny Avila
a9ccac8656
🧲 feat: Enable Secure Attached Environment Pairing (#15355)
* feat: add secure code environment pairing

* fix: satisfy code environment type checks

* fix: secure code environment administration

* fix: isolate code pairing control plane

* fix: validate code pairing control responses

* fix: secure code pairing transport

* fix: validate code pairing wire format

* fix: harden pairing secret lookup
2026-08-30 17:12:20 -04:00
Danny Avila
7533d138fa
🧬 perf: Evolve Compaction Guidance on Warm Turns (#15371)
* perf: evolve compaction guidance on warm turns

* style: sort compaction adapter imports
2026-08-30 17:11:20 -04:00
Danny Avila
30124f21b2
🎻 refactor: Orchestrate Agent Runs Through a Request-Free Host (#15366)
* refactor: decouple agent initialization from HTTP

* refactor: centralize remote agent execution lifecycle

* fix: preserve pre-settlement error rendering

* fix: preserve request-backed tool loading

* style: sort agent execution imports

* fix: adapt public agent tool loaders
2026-08-30 15:38:57 -04:00
Danny Avila
c1cb591d49
🗿 feat: Add Attached Stateful Code Environments (#15352)
* feat: add attached stateful code environments

* fix: include code environment in lazy agent type

* fix: harden stateful environment routing

* fix: complete code environment route isolation

* fix: declare code environment map type

* fix: preserve configured code execution routes

* fix: harden stateful environment updates

* test: assert route-scoped sandbox readiness

* fix: harden stateful environment lifecycle

* fix: isolate migrated code sessions

* test: assert route-qualified code sessions
2026-08-30 15:38:45 -04:00
Danny Avila
cd3768ed1f
🍵 feat: Continue Late Steers in Warm Agent Runs (#15357)
* feat: continue late steers in warm agent runs

* chore: bump agents sdk to v3.7.9

* fix: guard terminal steer admission
2026-08-30 11:54:17 -04:00
Danny Avila
2ae6c8aea9
🛎️ feat: Wake Agents on Background Tool Completion (#15350)
* feat: wake agents for background tool completion

* fix: isolate background completion contracts

* fix: anchor background completion identity

* fix: close background completion delivery gaps

* fix: preserve manual background polling

* fix: preserve legacy tool group identity

* fix: close background wakeup identity gaps

* test: type background wakeup enqueue mock

* fix: preserve background completion identity

* test: type activity phase fixture

* test: mock phase media query

* fix: arbitrate background result ownership

* fix: retain tool step routing helper

* test: expand phase groups before identity checks

* fix: preserve background completion ownership

* fix: bound durable background results

* fix: type wakeup input budget export

* chore: sort background handler imports

* fix: persist timed-out background completions

* fix: reconcile background completion ownership

* fix: type background completion capabilities

* fix: harden background completion terminalization

* test: type missing completion evidence

* fix: require evidence for completion retirement

* chore: satisfy background completion static checks

* test: cover automatic background completion wakeups

* test: assert background wakeup agent identity

* fix: wake capability-fenced trigger deliveries

* test: keep capability shield fixture public

* test: assert capability worker wakeup

* fix: close background completion ownership gaps

* test: satisfy completion lease static checks

* fix: preserve artifact completion wakeups

* fix: close background delivery recovery gaps

* fix: simplify background receipt guidance

* fix: recover dead background completion batches

* fix: fence background completion recovery

* fix: recheck background recovery ownership

* fix: fence unpublished background continuations

* test: await background message hydration
2026-08-30 08:56:41 -04:00
Danny Avila
e3ccaba5af
🛄 feat: Restore Compaction Guidance Across Continuations (#15356)
* 🧭 feat: preserve compaction guidance across continuations

* 🔧 fix: narrow persisted compaction fields
2026-08-30 08:13:24 -04:00
Danny Avila
70f735336d
🛎️ fix: Enroll Remote Agent Runs in the Generation Lifecycle (#15349)
* fix: enroll remote agent runs in generation lifecycle

* fix: close remote lifecycle ownership gaps

* fix: close remote conversation drain races

* fix: fence remote runs during conversation deletion

* fix: reconcile remote deletion and settlement races

* fix: complete owner deletion recovery

* fix: preserve remote cleanup receipts

* fix: consume deletion receipts before cleanup

* fix: expose idempotent deletion option

* chore: sort remote lifecycle imports
2026-08-30 07:14:13 -04:00
Dustin Healy
8fcab7e44f
🔄 fix: Recover Missing MCP Marketplace Catalogs (#15323)
* fix: recover missing MCP marketplace catalogs

* fix: make MCP catalog recovery passive

* test: type MCP catalog recovery fixtures

* fix: bound and back off passive MCP catalog recovery

Passive recovery runs inline on `GET /api/mcp/tools` and its results are
request-local by design, so every list request re-dialed the same cold
servers with the default connection timeout. Three limits keep that cost
proportional to what recovery can actually recover:

- Cap the discovery timeout at 5s instead of inheriting the connection
  default (`initTimeout ?? 30s`); a server configured to connect faster
  keeps its own shorter limit.
- Skip a server the config tier already marked `inspectionFailed`, leaving
  it to that tier's retry window rather than re-dialing it per request.
- Skip a server whose declared `customUserVars` are unset, matching the
  gate `reinitMCPServer` applies for issue #10969 — connecting without them
  fails auth, so the attempt is spent for nothing.

Servers that still fail discovery enter a one-minute per-process cooldown,
which is what stops an unreachable server from being re-dialed by every
subsequent list request. A server that recovers clears its own entry, and
expired entries are swept at most once per window so the map stays bounded.

Skipped servers render exactly as they did before recovery existed: present
in the catalog with an empty tool list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B

* fix: bound passive MCP recovery by deadline, key cooldowns by config

Both follow-ups address the same mistake: recovery expressed its own
request-level constraints in terms borrowed from other layers.

`connectionTimeout` bounds one connection attempt, and
`MCPConnectionFactory.discoverToolsInternal` spends it twice — once on the
authenticated connection, then again in `attemptUnauthenticatedToolListing`
— so capping it bounded no total this layer could reason about. Recovery now
enforces its own wall-clock deadline per server with `withTimeout`, which
holds however many attempts the factory makes; `connectionTimeout` is left to
do only its own job, still honouring a shorter operator `initTimeout`. An
attempt abandoned by the deadline disposes its own connection when it
settles, and `Promise.race` keeps a handler on it, so a late rejection is
not unhandled.

A per-request budget now caps total recovery regardless of server count.
A server is dialed only if the remaining budget can fund a full deadline;
never dialing one is not evidence against it, so a skipped server records no
cooldown and a later request reaches it once those ahead are cached or
cooling down.

Cooldown identity now includes the publication generation — the same
effective-config identity the tool caches fence on — instead of just user and
server name. Correcting a server's URL or transport keys a new entry, so the
refetch the client issues on update is no longer skipped for up to a minute
by the previous configuration's failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B

* refactor: keep passive MCP recovery stateless and bounded by its own work

Reverts the cooldown, request budget and deadline race added in 257d5cf and
fc3e3c9, and keeps only the three stateless limits.

The tool cache refuses unfenced writes (`tools.ts`), and a discovery
connection owns no publication generation and is disposed, so a recovered
catalog cannot be retained by design. Those commits responded by building a
cache-shaped memory in front of it — per-process failure state, a scheduling
budget, an identity, an eviction sweep — and each round of review found
another way that hand-rolled cache differed from a real one: wrong identity
for configuration, wrong identity for credentials, no fairness across
requests, and a limiter slot released while its network operation was still
running. None of that machinery was asked for; all of it was compensation for
a result the architecture does not allow keeping.

Recovery is now stateless. It skips only what configuration alone proves
pointless — a server the config tier already marked `inspectionFailed`, and
one whose declared `customUserVars` are unset — and bounds the work itself
rather than racing it, so a limiter slot is held for exactly as long as its
network operation runs and the concurrency limit of three is real.

The attempt timeout is not a compromise: recovery exists for a server that is
reachable and authorized but whose catalog cache expired, and such a server
answers tools/list well inside 1.5s. Anything slower cannot be rescued here,
so failing fast costs nothing. The factory spends that value per attempt, so
a server's ceiling is it times the attempts made; the constant documents that
rather than hiding it behind a number tuned to today's attempt count.

Consequences that were bugs are now gone by construction: every cold server
is attempted on every request, so none is starved by those ahead of it, and
correcting a server's configuration or credentials takes effect on the next
refetch instead of waiting out a stale cooldown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B

* fix: correct the inspection-failure skip and bound catalog fan-out

Three fixes that belong to this layer; a fourth issue does not, and is
described below.

The `inspectionFailed` skip was too broad. `MCPServersInitializer` stores a
YAML server that was unreachable at startup via `addServerStub`, which stamps
`source: 'yaml'`, and only config-tier entries get the timed retry in
`ensureSingleConfigServer`. Skipping every failed stub therefore hid a
recoverable server from the marketplace permanently — the exact state this
recovery exists to escape. It now defers only `source === 'config'`, matching
what `reinitMCPServer` already does.

Plugin auth is read only when some cold server actually declares
`customUserVars`, and only for those servers. The common unauthenticated case
no longer pays a MongoDB round trip whose result nothing can consume.

Snapshot refreshes are now bounded by the same limiter as discovery. They are
not local reads: both connection paths reach `fetchOrderedToolsSnapshot` and
issue a real `tools/list`, so a cache reset across many servers previously
burst unbounded outbound requests while discovery was capped at three.

Not fixed here, because it cannot be: `connectionTimeout` does not bound
discovery. It covers `connection.connect()` only, and `fetchToolsSnapshot`
then applies its own `TOOLS_LIST_TIMEOUT_MS` (30s) to `tools/list`, so a
server that connects fast and stalls while listing still holds its slot for
that window. The factory also does not cancel a timed-out connect before
starting the unauthenticated fallback. Bounding this end to end needs a
deadline threaded through `MCPConnectionFactory` into both `connect()` and
`fetchToolsSnapshot()`, which is a change to shared connection machinery
rather than to this caller. The constant's comment now states what it does
and does not bound instead of implying an end-to-end guarantee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B

* fix: thread live-session OBO context into passive catalog discovery

The merge of #15334 sources OBO tokens from the live OpenID session via
request-boundary closures. Passive catalog recovery is a discovery call
site too; without these options an OBO server whose stored token went
stale fails recovery — the exact cold-catalog class this PR fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-30 06:55:30 -04:00
Danny Avila
16c2bb4149
📬 feat: Establish Background Continuation Admission (#15348)
* feat: establish background continuation admission

* fix: preserve routed subagent wakeup controls

* fix: probe configured subagent task store
2026-08-30 06:51:36 -04:00
Danny Avila
fa913148fb
🔒 fix: Refresh MCP OBO Tokens From the Live OpenID Session (#15334)
* 🧊 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 on d08c82f0d.

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 on fcdc15885 — 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 in cb26a6f7d, 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>
2026-08-29 23:55:58 -04:00
Danny Avila
10981b8637
🍱 feat: Guide Compaction With a Bounded Semantic Index (#15340)
* 🧭 feat: guide compaction with semantic context

* 🛡️ fix: fail closed on semantic intent collisions
2026-08-29 17:53:04 -04:00
Danny Avila
7a0061507e
🪪 fix: Admit Confirmed Generation Retries Before Message Limits (#15341)
* fix: admit idempotent retries before message limits

* fix: bound generation retry admission

* fix: keep retry probe store-compatible

* fix: exclude normalized resume routes

* fix: preserve trusted retry exemptions

* fix: bound retry claim admission

* style: satisfy generation retry static checks
2026-08-29 13:41:26 -04:00
Danny Avila
773127bff2
🎠 refactor: Route Every Event Actor Turn Through One Lifecycle (#15325)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* refactor: unify Event Actor turn lifecycle

* fix: retain Event Actor fence ownership

* fix: preserve mixed-version actor suspension safety
2026-08-28 17:30:23 -04:00
Danny Avila
3fa33b740b
🛫 refactor: Promote Generation Protocol V2 Automatically (#15324)
* refactor: promote generation protocol v2 automatically

* fix: remove unused protocol import
2026-08-28 17:17:09 -04:00
Danny Avila
77c2a51cf3
🪧 fix: Advertise Detached Event Actor Support from the Generation Store (#15322)
Some checks failed
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Has been cancelled
Frontend Unit Tests / Build packages (push) Has been cancelled
Frontend Unit Tests / TypeScript type checks (client) (push) Has been cancelled
Frontend Unit Tests / Tests: @librechat/client (push) Has been cancelled
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Has been cancelled
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Has been cancelled
Frontend Unit Tests / Vite build verification (push) Has been cancelled
* fix: activate detached event actions automatically

* fix: shield detached terminal generations

* perf: share capability availability index

* style: flatten capability status selection

* fix: preserve mixed-version lifecycle shells

* fix: complete detached action store adapters

* fix: close mixed-version capability races

* fix: honor legacy capability success
2026-08-28 16:33:23 -04:00
Danny Avila
487984193a
🪂 feat: Make Event Actor Detached Actions Durable (#15307)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* feat: make Event Actor detached actions durable

* fix: align detached actor checks with merged base

* fix: close detached actor lifecycle gaps

* fix: break detached action outcome cycle

* fix: deepen detached action ownership

* fix: fence detached launch handoffs

* fix: preserve detached rollout fencing

* test: type detached recovery failure

* fix: close detached ownership handoffs

* fix: stage detached action activation

* fix: persist detached terminal retries

* fix: fence detached durability ownership

* fix: fence trigger lane publication

* fix: fence detached transition generations

* fix: retire failed detached predecessors

* fix: close detached rollout consumers

* test: type detached retry turns

* fix: close detached ownership gaps

* fix: fence detached action resumes

* fix: fence prior-head event actor resumes

* fix: preserve legacy event actor resumes

* fix: align detached resume boundaries
2026-08-28 14:21:04 -04:00
Danny Avila
01f5391ee3
🕰️ fix: Guard expires_in So a Token Response Cannot Outlive Its Credential (#15321)
* 🕰️ fix: Guard `expires_in` So a Token Response Cannot Outlive Its Credential

RFC 6749 §5.1 makes `expires_in` only RECOMMENDED, so a token response may legally omit it.
Four sites derived a lifetime from the raw field, where `undefined * 1000` is `NaN`.

`NaN` is not a short TTL, it is no TTL. `@keyv/redis` writes the key without `PX` because
`NaN` is falsy, so the entry is stored in Redis with no expiration at all; the in-memory
backend embeds `expires: NaN` and every check compares with `>`, always false against `NaN`.
The namespace default does not stand in either, since Keyv applies it with `??=` and `NaN` is
neither `null` nor `undefined`. The exchanged access token was therefore cached permanently at
`openidStrategy.js` and `GraphApiService.js`, and once it genuinely expired the poisoned entry
kept being served with no path to eviction.

The same omission is sharper in `ActionService.js`, where `new Date(NaN).toISOString()` throws
`RangeError: Invalid time value`. Both call sites are inside a `try`, so the failure surfaces as
a generic "Failed to authenticate OAuth tool" that names nothing, and the refresh site falls
through to `requestLogin()` on every attempt, looping with no exit.

The rule had six hand-written homes and three of them were wrong, so it now has one. A new
`packages/api/src/oauth/expiry.ts` normalizes `expires_in` to a positive finite number of
seconds or nothing, and exposes the two shapes callers actually need: a cache TTL that falls
back rather than returning `NaN`, and an absolute expiry that is absent rather than Invalid.
The four unguarded sites adopt it, and the two ad-hoc guards in `openidStrategy.js` and
`OboTokenService.js` are consolidated onto it.

`createHandleOAuthToken` is folded in as well. Its guard already handled `null` and unparseable
strings but admitted `NaN`, since `typeof NaN === 'number'` satisfied its first branch.

The `mcp/oauth` sites are deliberately left alone: `tokens.ts` guards on truthiness and carries
richer logic that reads a JWT access token's own expiry when the response omits one, and the
file is being reworked in #13901.

Closes #15318
Closes #15319

* 🕰️ fix: Address `expires_in` Guard Review Round 1

Preserve an explicitly elapsed lifetime instead of collapsing it into "unknown". `expires_in: 0`
is the provider stating the credential is already dead, which is information; treating it as
absent handed it the one-hour fallback in `createHandleOAuthToken` and dropped the expiry
entirely in `ActionService`, so a credential declared expired could be used and retained for up
to an hour. Both sites preserved that value before this branch, so the collapse was a regression
introduced here.

`normalizeExpiresIn` now returns any finite number, positive or not, and reports `undefined` only
for a lifetime that is genuinely unusable. `getTokenExpiresAt` therefore yields a past timestamp
for an elapsed lifetime, so callers refresh rather than guess.

Cache TTLs cannot pass such a value through raw: Keyv reads a TTL of exactly `0` as "no expiry",
turning a dead credential into the immortal entry this module exists to prevent. `getTokenCacheTtlMs`
floors an elapsed lifetime at one millisecond, which expires immediately without ever writing an
entry that outlives its credential.

Parse numeric strings with `Number` rather than `parseInt`, which truncates a complete value such
as `"3.6e3"` to `3` and would expire an hour-long credential after three seconds, re-exchanging
against the identity provider on every request. An empty or blank string is rejected rather than
read as zero, since `Number('')` is `0`.

* 🕰️ fix: Bound `expires_in` to Lifetimes a Date Can Represent

Parsing the complete numeric string last round made an overflow reachable that `parseInt` had
been masking. `parseInt('1e13', 10)` was `1`; `Number('1e13')` is `1e13`, and `1e13` seconds is
1e16 ms, past the ECMAScript time value range of ±8.64e15. Every derived timestamp was therefore
an Invalid Date whose `toISOString()` throws `RangeError: Invalid time value` — the exact failure
this branch exists to remove, reintroduced by its own fix. The token model derives the same way
at `packages/data-schemas/src/methods/token.ts:19`, so storage and authentication would fail with it.

A lifetime is now reported as unusable unless it can still produce a valid `Date`. The bound is
the time value range halved, leaving room for the `Date.now()` every derived timestamp adds. At
roughly 137,000 years it rejects nothing a provider could mean: a one-year refresh token and even
a hundred-year lifetime still pass through untouched, while `1e13`, `Number.MAX_SAFE_INTEGER` and
`1e300` take the caller's fallback instead of poisoning a timestamp.

The invariant tests now carry the overflow shapes rather than a fixed list of small ones, since a
guard that only sees the inputs its author imagined is how the previous round's regression got in.
2026-08-28 10:47:09 -04:00
Danny Avila
04a7577821
🧵 fix: Retry Shared Links After Message Persistence Gaps (#15306)
Some checks failed
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* fix: Retry shared links after message persistence gaps

* chore: Sort share test imports

* fix: Address share review feedback

* fix: Normalize hydrated share target
2026-08-28 08:05:52 -04:00
Danny Avila
c06b09c945
🤖 feat: make Event Actor HITL durable (#15305)
* 🤖 feat: make event actor HITL durable

* 🤖 fix: break event actor outcome cycle

* 🤖 fix: close durable actor terminal races

* fix: harden durable event actor recovery proofs

* fix: close event actor resume publication races
2026-08-28 08:05:11 -04:00
Danny Avila
6fba51a35a
🫆 feat: Fingerprint Agent Context for Zero-Read Warm Continuation (#15299)
* feat: add context-fingerprinted warm continuation

* fix: preserve manual skills across warm turns

* perf: keep ordinary agent initialization neutral

* fix: fingerprint complete agent topology

* fix: close warm context replay gaps

* fix: preserve lazy subagent memory scope

* perf: fingerprint only model-bound memory

* fix: project lazy memory capability

* style: sort warm continuation imports

* fix: make warm continuation reconstructable

* fix: preserve lazy skill isolation

* fix: retain scoped skill semantics
2026-08-27 23:01:14 -04:00
Danny Avila
a5fd00cd7a
🎼 feat: Unify Agent Turn Execution Behind One Immutable Plan (#15296)
* feat: unify agent turn execution planning

* fix: preserve automatic subagent activity handles

* fix: gate subagent wakeups for ephemeral parents

* fix: scope subagent wakeup guidance by agent
2026-08-27 22:26:46 -04:00
Danny Avila
f4efef4b3a
♟️ fix: Settle First Bound Actor Events (#15300) 2026-08-27 19:06:41 -04:00
Ravi Kumar L
a70bcf66a4
🪢 feat: show Langfuse session link in shared chats (#15273)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* feat: show Langfuse sessions in shared chats

* fix: wait for optional auth before loading shares

* fix: require Langfuse access for shared session links

* refactor: move shared Langfuse policy into API package

* fix: wrap shared chat actions on mobile

* fix: coordinate shared Langfuse link loading

* perf: batch shared Langfuse capability checks
2026-08-27 14:29:57 -04:00
Danny Avila
03fa98eb37
🕸️ feat: Complete Keenable Web Search Provider (#15288)
* feat: wire Keenable web-search provider into config, schema, and UI

Keenable landed as a search provider in @librechat/agents (#285, shipped in
3.2.58+), but LibreChat did not yet expose it. This adds the config/schema/UI
glue so it can be selected, mirroring the existing Tavily provider.

- data-provider: add `keenable` to SearchProvider type + SearchProviders enum,
  keenableApiKey/keenableApiUrl schema fields, and a keenableSearchOptions block
  (maxResults, site, attributionTitle, timeout).
- data-schemas: register keenable in webSearchAuth.providers and default the
  key/URL placeholders in loadWebSearchConfig.
- api/web: pass keenableSearchOptions through to the provider and handle
  Keenable's keyless model. Unlike other providers it authenticates with no key
  (the public endpoint), picking up an optional key/URL when set; the URL
  override is SSRF-preflighted like other user-provided URLs.
- client: add Keenable to the provider dropdown with an optional API-key input.
- docs: document KEENABLE_API_KEY/KEENABLE_API_URL in .env.example and a
  webSearch example in librechat.example.yaml.
- tests: keyless + keyed auth resolution, config defaults, and schema parsing.

* fix: ESLint no-unused-vars and clarify Keenable yaml example

- Remove the now-unused RerankerTypes import in data-schemas web.ts (the lint
  job runs with --max-warnings 0 on changed files, so this latent warning failed
  CI once the file was touched).
- Note in the librechat.example.yaml Keenable stanza that a scraper (and
  reranker) is still required for web search to load, and include a Firecrawl
  scraper in the example.

* chore: fix import order drift (sort-imports)

* feat: add Keenable as a keyless scraper and select it without a pinned provider

The Keenable scraper landed in @librechat/agents#337, so wire the scraper
category the same way the search provider already is: `scraperProvider:
keenable` reads pages through Keenable's public fetch endpoint with no key
(a key only lifts rate limits, and the endpoint is overridden with
KEENABLE_FETCH_URL). Paired with `rerankerType: none` this makes a fully
keyless web-search stack possible for the first time.

Also closes the Codex finding on this PR: because none of Keenable's auth
fields are required, the generic auth loop skips it whenever it isn't pinned,
so a key submitted through the API-key dialog (which cannot pin a provider)
left the providers category unauthenticated. Keenable is now selected in that
case, gated on one of its values actually being present so installs that
configured nothing keep their current behavior. The scraper gets the same
fallback, additionally gated on Keenable being the resolved search provider,
so it never silently scrapes for another provider.

* fix: select the Keenable scraper from a supplied key, not only for Keenable search

The API-key dialog submits credentials and cannot pin a provider, so choosing
Keenable as the scraper while search stays on Serper/SearXNG/Tavily had no
effect: the unpinned-scraper fallback required Keenable to also be the resolved
search provider.

A supplied Keenable value now triggers it as well, which is the only signal the
dialog can send. The fallback still runs only when no keyed scraper
authenticated, and with neither trigger the category stays unauthenticated, so
a deployment that never configured Keenable is unaffected.

Note the fully keyless choice still cannot be expressed through the dialog:
Keenable's key is optional, so picking it with no key submits nothing at all.
librechat.example.yaml now documents pinning scraperProvider: keenable for that
case.

* style: Sort Keenable imports

* fix: Harden Keenable auth resolution

* fix: Preserve Keenable selection intent

* fix: Fail closed on invalid web search auth

* fix: close keenable auth gaps

* style: sort web auth imports

* fix: preserve web search selection integrity

* fix: isolate web search auth ordering

* fix: silence expected credential misses

* chore: bump agents sdk

* fix: preserve web search preference ownership

* fix: forward cleared Keenable endpoint

* style: sort web search hook imports

* fix: require intent for credential clears

---------

Co-authored-by: Ilya Bogin <ilya.bogin@keenable.ai>
2026-08-27 14:29:20 -04:00
Danny Avila
8ba76507ae
🧷 fix: Verify Scheduled HITL State Before Pausing (#15284)
Some checks failed
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `librechat-data-provider` to NPM / pack (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
* fix: require durable storage for scheduled HITL

* fix: verify scheduled HITL checkpoints before pause

* fix: narrow scheduled HITL capability checks

* fix: honor ask tool filters in schedule preflight

* fix: enforce durability across scheduled HITL resumes

* fix: scope scheduled HITL admission to active tools

* refactor: move scheduled HITL admission to TypeScript

* fix: close scheduled HITL admission gaps

* fix: bind scheduled pauses to current checkpoint

* fix: cap pending actions at binding deadline

* fix: reject expired pending actions

* fix: guard approval deadlines atomically
2026-08-27 11:16:24 -04:00
Danny Avila
de59da9636
🎟️ refactor: Require Credentials for Local Image Access by Default (#15252)
* 🔐 fix: Protect Local Image Access by Default

* 🔐 fix: Scope Image Authorization to Active Sessions

* 🧹 style: Format Image Authorization Checks

* 🛡️ fix: Harden Image Avatar Authorization

* 🧭 style: Sort Image Authorization Imports

* 🔐 fix: Close Image Authorization Review Gaps

* 🧭 fix: Normalize Stored Avatar Base Paths

* 🏢 fix: Resolve Tenant Assistant Image Policy

* 🛂 fix: Enforce Effective Image Access Policy

* 🧹 style: Flatten Assistant Config Selection

* 🧷 fix: Preserve Image Access Compatibility

* 🪪 fix: Make Image Sessions Revocable

* 🏗️ fix: Move Image Session Policy Into API
2026-08-27 09:55:27 -04:00
Danny Avila
41ba808e6f
🧅 feat: Reveal Subagent Thread History and Event Detail on Demand (#15283)
*  feat: Refine Subagent Thread Activity UX

* test: align subagent completion lifecycle

* chore: satisfy subagent panel static checks

* fix: harden subagent activity pagination

* fix: preserve paged subagent history

* fix: surface unrecoverable activity boundaries

* fix: rebase paged activity after live advances

* fix: bound child activity history state

* fix: preserve rebased child history order

* fix: retain unavailable child history boundary

* fix: fence child history cursor generations

* fix: preserve subagent history continuity

* fix: retain live turns during history rebase

* fix: close subagent history edge cases

* fix: harden subagent history phase transitions
2026-08-27 08:56:06 -04:00
Danny Avila
8b1fcc0fc2
🌐 fix: Expose Gemini Models to Vertex AI Agents (#15234)
* 🌐 fix: Expose Gemini Models to Vertex AI Agents

* ♻️ refactor: Resolve Shared Vertex Model Catalogs

* fix: Preserve Exact Vertex Model Catalogs

* style: Format Agent Model Selection

* test: Preserve Native FS in Stable Diffusion Spec
2026-08-27 06:50:23 -04:00
Danny Avila
44edcbe014
🧾 feat: Store Durable Event Actor Receipts (#15265)
* 🧾 feat: Store durable event actor receipts

* 📊 fix: Scope actor delivery metrics

* 🧱 fix: Close durable receipt recovery gaps

* fix: serialize actor action admission

* fix: retire terminal batch members

* fix: close actor terminal recovery races

* fix: reclaim lanes after terminal receipts

* fix: close actor receipt recovery gaps

* fix: preserve ambiguous legacy handling

* fix: recover legacy actor outcomes

* fix: admit leased actor actions

* fix: serialize actor admission recovery

* fix: close actor recovery races

* fix: token-fence actor admissions

* fix: preserve mixed-version actor fences

* fix: harden actor rollout and metrics

* fix: gate durable actor receipt rollout

* fix: enforce base actor receipt rollout

* fix: align experimental actor rollout
2026-08-27 06:00:43 -04:00
Danny Avila
62a55213f0
🖇️ fix: Bind Model-Spec Authorization to the Loaded Agent (#15256) 2026-08-26 10:38:20 -04:00
Danny Avila
0c959deb99
🧢 fix: Cap Batched HITL Answers Before PII and Moderation (#15257) 2026-08-26 10:10:19 -04:00
Danny Avila
68fc46a055
🪢 feat: Resume Bound Event Actors from Checkpoint Forks (#15227)
* feat: resume event actors from checkpoint forks

* fix: fence event actor checkpoint uncertainty

* fix: satisfy event actor type contracts

* fix: make actor reconciliation recoverable

* fix: preserve event actor lifecycle transitions

* fix: fence event actor lifecycle outcomes

* fix: enforce event actor lifecycle ownership

* fix: retain event actor settlement proof

* 🔒 fix: Retain Event Actor Receipts Through Repair and Bound Their Journal

Repair and compensation deleted the reconciliation row they resolved, which
was the only durable proof that the invocation had already applied an external
action. A delayed duplicate owner could then reacquire the same invocation id
and repeat that action. Both resolutions now retire their receipt to `settled`
and record how it settled, so the same-id tombstone survives; `history_repaired`
and `action_compensated` still force a cold rebuild. Compensation undoes the
effect without re-authorizing the delivery, so a legitimate retry must arrive
under a new invocation id. A retried repair converges on its own receipt.

Bound the journal so a long-lived actor cannot grow its conversation document
without limit: a new fence is admitted only when no active lifecycle row
exists, so a capped push can evict nothing but the oldest settled receipts.

Stop shipping the unbounded source payload on every bound-child continuation.
It rode the delivery body regardless of the feature flag while the sibling
`fire` body deliberately sends event identity alone, so a large webhook payload
could push a previously working delivery past the chat route's body limit. The
actor binds an invocation from identity and never builds the prompt from it.

Blank the positional token map on warm continuations. It is derived from the
full DB history, while a warm run executes on checkpoint-restored state, so its
indices address different messages and the pruner never recounts them —
misattributing cached counts to the wrong messages in both directions.

Keep the replaced-claim exit on its cleanup path when preserving reconciliation
fails: the committing CAS already left a blocking row, so the failed status
upgrade costs provenance, not safety.

* 🧪 test: Pin the Warm Continuation's Map/Summary Asymmetry

Give the warm-continuation client test a populated token map and a real
cross-run summary so its assertions bite: the positional map must arrive
blank (checkpoint-restored state no longer matches DB-derived indices, and
the pruner never recounts a populated entry) while `initialSummary` must
pass through unchanged — it rides the system tail and summarizes
pre-boundary turns that were excluded from the very history the committed
checkpoint was built from, so blanking it would silently drop context no
warm run can recover.

* ⚖️ fix: Honor Compensation in Settlement and Age-Bound the Receipt Journal

A compensated receipt still tombstones its invocation id, but its external
effect was explicitly undone — the terminal handler nonetheless replayed
every settled lifecycle's stored action as authoritative and settled the
public outcome as applied, telling an action-aware source the operation
stands and suppressing the new-invocation retry compensation requires. The
handler now settles a compensated invocation as failed with an explicit
compensation error, overriding even fresh applied run evidence from a
replayed generation; verified and repaired receipts continue to replay
applied.

Receipt eviction is now primarily age-based: a stale same-id owner is
bounded by time, not by how many newer invocations settle, so the previous
count-only slice let a high-rate actor evict a tombstone while its delayed
duplicate owner could still wake and repeat the action. Admission prunes
only settled receipts older than a retention window that dwarfs every
generation, job, and delivery-retry lifetime, and the count cap is demoted
to a raised document-size backstop.

* 🔀 fix: Serialize Compensation Against Settlement and Never Evict Live Receipts

The terminal handler read its lifecycle snapshot, verified history, and then
settled the public outcome — so a compensation resolving the same receipt
during that window lost: the handler settled applied from its stale snapshot
and no retry could ever change the replay-identity-locked outcome. The
receipt's status CAS is now the serialization point: verification resolves
the receipt BEFORE settling, whichever transition wins determines the public
outcome, and a crash between resolve and settle converges through the
retained receipt's replay. The verified-replay probe requires the receipt's
own resolution, so a compensated receipt can never satisfy a verification
retry. This inverts the settle-before-receipt ordering deliberately: that
ordering guarded proof that resolution used to delete, and the receipt now
retains its full action proof through resolution.

The document-size cap is no longer an eviction quota. A receipt inside its
retention window is never discarded: when the journal holds a full cap of
unexpired receipts, new invocations are refused fail-closed until receipts
age out, making duplicate protection and document integrity simultaneous
invariants instead of a rate-dependent trade.

* 🎓 fix: Keep Skill-Bearing Event Actors on the Legacy Path

Skill primes are spliced into the message list directly ahead of the newest
message, and a warm continuation forwards only that newest message — so a
checkpoint-restored actor would keep serving the prime bodies baked in at
its last cold start and never observe an edited or newly attached skill.
Until the actor head carries a context fingerprint that forces a cold
rebuild when the agent's skill context changes, agents with always-apply or
manual skill primes stay on the legacy path, which re-primes fresh bodies
every turn: correct on every event, just never warm.

* 📜 fix: Gate Fork Mode on the Skills Capability, Not Just Request-Time Primes

History-derived re-priming was a third path into the same staleness class:
an actor that previously invoked a skill carries no request-time prime
arrays, yet primeInvokedSkills re-resolves that skill's current body from
history each turn and the warm slice drops the reconstruction — leaving the
checkpoint's old body active after edits. The fork gate now keys on the
priming hook itself (present exactly when the skills capability is enabled)
alongside the request-time arrays, so every skill-body path routes to the
legacy rebuild until #15235's context fingerprint restores warm
continuation for skill-bearing actors.

* 🧾 fix: Capture Applied-Action Proof at Tool Execution, Not After sendMessage

The executor read applied-action evidence from the run-step collection the
instant sendMessage resolved, but that collection is populated
asynchronously — an applied invocation could classify as actionless
(runSteps still empty while the tool result already streamed), discarding
its fork and stranding the actor cold while the terminal handler later
settled the same delivery as applied from the persisted evidence.

Authoritative proof is now recorded in graph context the moment the
expected tool executes: the request-owned recorder observes the tool-end
chain (which ToolNode dispatches synchronously with both input and output)
and applies the same fences as run-step evidence — exact tool name with the
MCP-suffixed form, the declared argument subset against the execution
input, an error-free result, and the background non-execution receipt
exclusion. readAppliedAction consults the receipt first; run-step
inspection remains the fallback for paths that bypass the tool-end chain.

Regression coverage reproduces the observed ordering: the real executor
commits the head from the receipt while run steps are empty, warm-continues
the next event, and never re-executes the action; recorder fences and the
receipt-first controller wiring are covered separately.

* 🎯 fix: Supply Execution Arguments to the Tool End Callback

The live Vertex + MCP canary exposed a contract mismatch the synthetic
fixtures hid: the ON_TOOL_EXECUTE execution path invoked its tool end
callback with output only, while the action recorder must verify the
declared argument subset against the execution input. The receipt never
qualified, every turn fell back to cold history rebuilds, and the
tournament advanced with zero actor heads and zero retained checkpoints
while looking successful.

The execution handler owns both halves at the same moment, so the fix is
at the source rather than a correlation store: ToolEndCallbackData gains
the executed call's input and every callback site passes tc.args. A
handler-level regression drives the real createToolExecuteHandler and
asserts the callback receives both fields; recorder regressions pin the
production shapes — an output-only tool end must starve an
argument-fenced receipt rather than trust an unfenced match, and still
qualifies a name-only expected action.

* 🕵️ fix: Mark Background Deliveries So They Cannot Impersonate Applied Actions

The background-claim callback reports the ORIGINAL tool's name for artifact
attribution on the poll turn that harvests a completed task. A name-only
expected action could therefore be impersonated by work some earlier turn
dispatched: the recorder would attribute that delivery to the current
invocation and commit a head whose state never contained the invocation's
own action. The run-step evidence path never had this hole — it sees the
poll tool's name — so the recorder must match its provenance discipline.

Delivery callbacks now carry an explicit backgroundDelivery marker set at
the one site that rewrites the name, and the recorder ignores marked
deliveries outright. Regressions pin both halves of the contract: the
delivery callback must carry the marker with the poll call's arguments,
and a marked delivery can never qualify even a name-only expected action.

* 🧿 fix: Version Invalidations, Keep Evidence Ahead of Output Policy, Gate Detachable Actions

Three closeout-round findings, each converted into an invariant.

Every legacy-path invalidation now advances a durable epoch — including for
headless and already cold-marked actors, where the marker alone leaves no
CAS-visible trace — and the actor-head CAS requires the epoch observed at
preparation. A concurrently prepared fork whose history predates an
intervening legacy turn can no longer commit past it; the commit reports an
ordinary conflict and journals.

Execution identity is now emitted before post-execution output policy: when
a side-effecting tool succeeds but its returned content is withheld by the
output filter, the callback delivers an outputFiltered receipt with blank
content — the recorder accepts it as proof (rejecting model-detached calls
it cannot distinguish through the blank shape), the artifact path never
sees it, and an applied action is no longer reclassified actionless and
re-executed on retry.

Background-capable expected actions stay off the fork path: dispatch
returns a launch handle every evidence fence correctly rejects, and the
completion is provenance-marked as another turn's work, so a fork would
settle actionless before the external effect lands with nothing to stop a
retry from dispatching it again. The gate mirrors the MCP-suffix name
matching of the evidence path.

* 🚧 fix: Seal the Whole Legacy Turn Behind a Second Epoch Advance

The epoch fenced only the legacy turn's start: a fork preparing after the
begin invalidation but before the turn's message persistence observed the
new epoch and cold marker, rebuilt from history that did not yet contain
the turn, and committed cleanly because nothing advanced the epoch again —
making the incomplete rebuild authoritative and clearing the marker.

Every legacy event turn now seals its invalidation at terminal persistence
with a second epoch advance, on the success, replaced-claim, and error
exits alike. Sealing deliberately carries no quiescence requirement — it
must succeed while a fork fence is active, because that is exactly the
mid-turn race it defeats — and a fork that already committed against the
begin epoch is healed the same way: the seal re-marks the head cold, so
the next event rebuilds with complete history. Seal failure never diverts
the turn's own exit; the begin bump still fences everything prepared
before the turn.

* 🔗 fix: Replace the Best-Effort Epoch Bump With a Durable Legacy-Turn Fence

The second epoch advance could not make a legacy turn atomic, and three
findings shared that root cause: two conditional updates left a headless
gap a fork could create the head inside; the error exit sealed before
saveErrorTurn made the error history durable; and any crash or failure
between persistence and sealing left an incomplete fork authoritative,
because the seal was best-effort and its failure was swallowed.

A legacy turn now carries one durable fence. A token is written before
execution by a single update-pipeline write — no two-write gap, and the
cold marker is applied only where a head exists via $cond/$$REMOVE. While
the token is present no fork may prepare (the adapter refuses) or commit
(the CAS requires its absence), because the turn's messages are not yet
durable. One atomic write clears the exact token and advances the epoch
once history is persisted — after saveErrorTurn on the error exit — and
success, replacement, and error exits all route through it.

Failure is now fail-closed rather than silent: a failed seal leaves the
token set, which keeps blocking forks and is logged as such, and a fence
abandoned by a crashed turn is reclaimed only once stale, advancing the
epoch and marking any head cold so the next event rebuilds from whatever
history actually survived.

* fix: serialize legacy event actor turns

* fix: close legacy actor fence ownership gaps

* fix: preserve legacy actor persistence fences
2026-08-26 08:39:00 -04:00
Danny Avila
f34a49007d
🔣 fix: Escape SPA Language Attribute (#15248) 2026-08-26 07:37:36 -04:00
Marco Beretta
0383030817
perf: Avoid Parallel Full and Paged Prompt Loading on Startup (#15031)
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-25 21:21:17 -04:00
Marco Beretta
124e357cbf
✏️ feat: Edit Pasted Text and Clear It on New Chat (#15017)
* fix: stop an unsent paste from following every new chat

An explicit new chat now drops the unsaved-chat draft key before the
composer resets. `newConversation` empties the composer, but the key
outlives it and `useAutoSave` restores from that key on the way in, so a
long paste that was never sent came back as an attachment on every later
new chat. Per-conversation drafts are untouched.

Clicking a pasted-text chip opens the text in an editor so it can be
corrected before sending, and the chip's subtitle offers returning the
paste to the composer. The text comes from the in-memory blob, falling
back to the `text` field the file record already carries, so neither
needs a new endpoint.

`FileContainer` grows a `subtitleAction` prop for the second control.
Supplying it swaps the chip's own button wrapper for a full-bleed one
behind the content, since a button inside a button is invalid markup and
browsers drop the inner one's events.

* chore: sort imports to fix static checks

* fix: address paste edit review findings

- Keep the original paste attached until the replacement upload succeeds,
  so a rejected or failed save cannot destroy the only copy
- Guard edits and queued replacements against conversation switches and
  new-chat resets, mirroring the long-paste lifecycle guards
- Recover text for restored pastes by downloading the stored bytes, which
  Assistants and agent uploads persist without a text field
- Delete uploaded attachments when an explicit new chat discards the
  draft that referenced them, instead of orphaning the records
- Mark paste provenance explicitly (session registry plus files draft)
  instead of inferring it from a filename a deliberate upload can share
- Keep the subtitle action revealed on devices without hover

* fix: scope draft cleanup to its tab and delete restored pastes

- Stamp unsaved-chat files drafts with the writing tab's session id and
  skip deletion when another tab owns the record, so a new chat in one
  tab cannot discard the uploads attached in another
- Delete a restored paste's upload when an edit replaces it or returns
  it to the composer, which the attached flag otherwise preserved

* fix: harden paste edit lifecycle guards

- Re-check the originating composer and the file map before detaching an
  edited original on upload success, so navigation or a send during the
  request cannot remove or delete a file the old draft or sent message
  still references
- Record the replacement upload's paste provenance in the session
  registry and the files draft, keeping Edit and Move back on the new chip
- Bind move-inline to the unsaved-chat token as well, and abort the move
  when the chip is no longer attached to an unsent composer
- Restrict new-chat draft cleanup to ids the composer still owns: library
  re-attaches and ids with unknowable ownership are spared, and uploads
  still in flight are deleted once their records reach the files cache,
  unless the file came back attached in the meantime

* fix: match restored paste identities and recheck before opening the editor

- Treat a chip as attached when any of its ids (map key, file id, temp
  id) matches the composer map, since draft restoration keys entries by
  their temporary upload id while the value carries the server-assigned
  one; the previous key-only check made Move back silently do nothing
  and left both chips attached after an edit
- Recheck the originating composer and the attachment map after the
  text resolve before opening the editor, so a send during the download
  cannot stage a replacement upload into the emptied composer
- Extend the provenance predicate to temp ids for the same restored shape

* fix: spare re-attached sent pastes and discard stale editor resolves

- Force-delete a restored paste only when the composer's own draft
  claims its id, so a paste that was already sent and re-attached from
  the library keeps its shared record through Edit and Move back
- Sequence editor-open requests so a slow text resolve cannot overwrite
  the chip a later click selected

* fix: carry deferred discards across resets and clear the pending draft

- Merge newly deferred upload ids with the pending set instead of
  replacing it, so a second reset cannot orphan an earlier in-flight
  upload's eventual record
- Match deferred ids against temp_file_id as well, since the files
  cache keys records by the server id while the discard tracked the
  request uuid
- Clear the pane's pending draft key on an explicit new chat, or a
  running response's queued text and attachments come back with the
  next run

* fix: mint a fresh tab id when sessionStorage was inherited

Duplicated and opener-created tabs start with a copy of the original's
sessionStorage, so a stored tab id only proves continuity when the
document is a reload of the same tab. Every other entry into a document
now mints a fresh id, keeping an inherited one from attributing another
tab's live drafts to this composer.

* fix: gate restored-paste deletion on draft tab ownership and unclip the chip focus ring

- Stamp every files draft with the writing tab, not just unsaved-chat
  ones, and require the stamp to match before a restored paste's record
  is deleted, so another tab restoring the same draft is not destroyed
- Draw the full-chip Edit control's focus indicator as an inset ring
  with the surface's radius, since the offset ring was clipped away by
  the surface's overflow-hidden

* fix: resolve paste ownership before restoration and across draft migration

- Treat a draft's own pastedTextIds as composer-owned at discard time,
  so a reload-then-new-chat click deletes or defers those uploads
  before the composer map has been rebuilt, instead of skipping them
- Read both the pending and idle draft keys when claiming a restored
  paste for deletion, since a response finishing mid-edit migrates the
  record between them

* fix: keep failed edits recoverable and retry failed draft deletions

- Reopen the paste editor with the user's corrections when the
  replacement upload is rejected or fails, instead of leaving only the
  original's text to reopen
- Retain deferred and immediate discard ids when the delete request
  fails, and retry them on the next files-cache update, so an offline
  or transient failure cannot orphan the uploads

* fix: retry restored-paste deletions that fail

A failed delete of a detached restored paste retains its payload in a
session store, and the discard retry effect drains retained payloads
alongside its own batch on every files-cache update, so an offline or
transient failure cannot orphan the upload once its chip is gone

* fix: queue failed edits and lock chips with actions in flight

- Queue a failed edit behind whatever dialog is open instead of dropping
  its corrections, and reopen it when that dialog closes
- Track an in-flight action per source paste and hide its Edit and Move
  back affordances until the replacement uploads or the move settles,
  so the same original cannot be acted on twice

* fix: address PR review bot findings

Codex:
- Keep the tab id on back-forward restoration
- Preserve the original tab owner when rewriting drafts
- Skip clearing idle and pending drafts another tab still owns
- Delete pending-draft uploads before clearing them
- Return failedFileIds from DELETE /files and retry those records
- Spare reattached files from retained deletion retries
- Trigger retained-deletion retries when a delete is retained
- Persist deferred discards across reload
- Delete embedded owned uploads with a discarded draft
- Ignore stale paste-editor failures before toasting
- Abort a queued edit after the original is sent
- Serialize Move back with a synchronous in-flight lock
- Prune paste provenance ids that left the draft

* fix: retain paste deletions the server reports as failed

The delete route answers 200 with `failedFileIds` when a record's storage
delete fails, so the detach path's `.catch()` never fired and the orphaned
upload lost its only cleanup reference once the draft provenance was pruned.
Inspect the resolved response and retain the deletion when it names the file.

Extract the `failedFileIds` reader `useNewChat` already had into the file
utils so both deletion paths read the response the same way, and give the
paste editor coverage for the failed and accepted responses.

Also add the missing `size` on a composer file literal that was failing the
client type-check.

* fix: release draft claims when their tab is gone and keep cleanup durable

A tab stamped its id on a files draft and nothing ever took it off, so a draft
saved in a tab the user then closed became unreachable for good: no other tab
would restore it, write to it, or clean it up, and the closed tab's id can never
be presented again. Tabs now report themselves in a small liveness registry and
release the claim on pagehide, and a claim whose tab is no longer around is
treated as free. Writers restamp a dead claim rather than preserving it.

Ownership also only existed once something was attached, so a typed-but-unattached
draft on a shared composer key read as nobody's and another tab's New Chat cleared
it. Saving text to one of those keys now claims it the same way.

Two more from the same review:

- The delete route answers a partial failure as 200, so treating an id as still
  present unless the response reports it deleted kept a ghost row for a file
  another tab had already removed. Read it the other way around: only a reported
  failure keeps a record cached.
- A retained deletion whose retry failed again moved no effect dependency, so it
  was never attempted a second time, and the payload only lived in memory. It is
  now persisted for the session and asks for a backed-off retry, plus one on
  regaining connectivity.

* fix: keep bfcache claims, retain failed deletes, and move the delete contract to TS

Four findings from the latest review round:

- pagehide fires with persisted: true when a document enters the back-forward
  cache rather than closing. Releasing the tab's claim there let another tab
  take the draft and delete files the restorable document still had attached,
  so the claim is now only handed back on a real unload; a bfcached tab that is
  never restored still ages out through the liveness window.
- useFileDeletion issued its batch and never looked at the outcome, so a fresh
  paste whose delete failed was orphaned with no retry. It now retains whatever
  the server did not delete, reading failedFileIds as well as the rejection.
- A partial failure answers 200, so the unconditional success toast told the
  user a file was deleted while it was still on disk and back in their list.
- The delete response contract lived in the legacy JS route. It moves to
  packages/api as buildDeleteFilesResponse, leaving the route a thin caller.

The useFileDeletion spec's mutateAsync mock returned undefined; react-query
always hands back a promise, so it now resolves like the real one.

* fix: park bfcached tab claims and drop ownership left by an emptied draft

A document in the back-forward cache has a frozen heartbeat, so the ordinary
liveness window expired its claim after 150s even though it could still be
restored with those attachments on screen, letting another tab take the draft
and delete the files underneath it. Entering the cache now parks the tab as
suspended, which holds the claim for 30 minutes: comfortably past the point a
browser keeps a bfcache entry, and still bounded, since a claim that never
expires is what stranded drafts under owners that no longer existed. Restoring
the document beats normally again and clears the flag. The registry entry grew
a shape for this and still reads records written as a bare timestamp.

Clearing the text of a shared composer key also left the ownership-only record
behind, locking the key to a tab with nothing in it: the next tab to type there
could neither restore its own draft nor take the key back. That claim is now
released when the text goes and nothing is attached.

* fix: keep unlinks out of the delete retry and claim shared text before writing

Four findings from the latest round:

- Retaining a failed agent or assistant unlink sent it through the generic
  retry, which replays files alone. That drops the tool_resource context, so
  the route would take its ordinary delete branch and destroy a record the
  agent and other references still point at. A failed unlink orphans nothing,
  so those deletions are simply not queued.
- A retry that resolved naming files in failedFileIds left both stores
  untouched, so nothing moved the effect that would try again. It now asks for
  another attempt on a reported failure, the same as on a rejection.
- The reattachment guard read only the idle new-chat key. After a reload the
  composer map is empty until the autosave restore renders, so a file the user
  had reattached to the conversation they were viewing, or to the pending key,
  could be deleted underneath them. All three keys are checked now, including
  their paste provenance.
- Text was written to a shared composer key before ownership was resolved, so
  a tab could overwrite another's saved text and still be refused the claim,
  leaving it unable to restore what it had just typed. The claim is taken
  first, and a claim with no attachment behind it follows whoever's text is
  actually stored; one backed by an attachment stays with its open owner.

* fix: merge shared discard state and keep restored file-search pastes retrievable

Four findings from the latest round:

- Every mount of useNewChat (header, sidebar, mobile bar, shortcuts) kept its
  own snapshot of the pending-discard list and wrote it back over one shared
  session store, so an id recorded by one instance was dropped by the next
  write from another, orphaning the upload it pointed at. An update now only
  resolves the ids that instance knows about and carries the rest through.
- Refusing an attachment-backed claim still let the text write land, destroying
  the owning tab's text for a tab that could not have restored it anyway. The
  claim now reports whether it succeeded and the write is dropped with it.
- A restored paste has no tool_resource on its record, so an edit to one that
  had been uploaded for file search was re-uploaded as a plain context file and
  the vector-backed original detached, dropping it out of retrieval. embedded
  does survive on the record and is only set for a vectorized file, so it is
  what the destination falls back to.
- The reattachment guard collected map keys and server ids but not
  temp_file_id, while the retry lookup resolves that alias: reattaching a file
  whose discard was pending under its temporary id would not have protected it.

* fix: stop the draft owner refusing its own writes and guard shared pending keys

Three findings, the first a regression from the previous commit:

- The attachment-backed refusal was evaluated before the owner check, so the
  tab that owned the draft was refused its own key: once anything was attached,
  nothing typed after it was saved. Ownership is settled first now, and the
  refusal applies only to another live tab.
- A long paste wrote its provenance and pending-paste record into the shared
  composer key without checking who owned it, and setFilesDraft preserves the
  existing owner rather than rejecting the write, so the paste was recorded
  into another tab's draft, which could then restore and delete the upload
  while this tab still showed the chip. Both write sites now check first.
- Two concurrent runs share the default pending key, and the migration to the
  new conversation ran before the ownership check: the finishing run moved the
  other tab's text and attachments under its own conversation and left that tab
  nothing to carry over. Ownership of the source is verified before migrating,
  and this composer's own text is still saved either way.

* fix: protect cross-tab reattachments and orphaned pastes on every discard path

Five findings from the latest round:

- The retry guard only read this pane's own draft keys, so a file a second tab
  had reattached to a conversation this pane never opened was deleted anyway.
  Drafts live in localStorage and are readable from every tab, so the guard now
  sweeps every persisted files draft rather than three known keys.
- Clearing the composer removed the shared text record without checking who
  owned it, so an empty composer in one tab erased text another tab was still
  holding behind its attachments. The clear path takes the same guard as the
  write path, and both now share one ownership predicate.
- When another tab owned the pending key, this tab's own queued attachments
  were cleared from the map and never written anywhere, because the autosave
  that would have persisted them had been refused that key for the whole run.
  They are now written under the conversation the run just became.
- A draft write that storage refuses (private mode, quota) left a generated
  paste with no record to discard it by. New Chat now also collects the live
  marked pastes the composer is still showing, skipping re-attached ones.
- With draft saving off, the reset path deleted files without awaiting or
  reading the response, so a failure orphaned the upload. It retains what the
  server did not delete, like every other deletion path.

* fix: keep reloading tabs live and spare pastes an active run is using

Three findings:

- pagehide cannot tell a reload from a close, and the tab id survives a reload
  on purpose, so releasing the claim there handed this tab's own draft to
  another one while the document was still bootstrapping. A closing tab is left
  to the ordinary liveness window instead, which is what the window is for.
  Entering the back-forward cache is still marked, since that heartbeat freezes.
- The text-ownership guard only covered the shared composer keys, but a
  conversation key is reachable from every tab viewing that chat and is stamped
  the same way, so one tab could overwrite text another was holding behind its
  attachments. The guard now applies to any key; the ownership stub is still
  only created for the shared keys, which tabs otherwise share freely.
- Submitting empties the file map but leaves the draft's paste provenance until
  the final SSE event, so New Chat during a streaming response treated the empty
  composer as still owning what the message had just sent and deleted files the
  message, and the run reading them, still referenced. The provenance promotion
  is skipped while a run is in flight.

* fix: give each tab its own presence record and publish live attachments

Four findings:

- Tab presence lived in one shared localStorage map, so two tabs beating at the
  same time read the same snapshot and wrote back rival copies; the loser
  disappeared until its next beat, long enough for another tab to treat its live
  draft as abandoned. Each tab now writes only its own key, and expired records
  are swept while reading.
- With draft saving off nothing is written to a draft at all, so a file
  reattached in another tab was invisible to a retry running here and could be
  deleted underneath it. A tab now publishes what its composers are holding into
  its own presence record, and cleanup unions that with the drafted ids.
- The record written when another tab owns the pending key kept only attachment
  ids, so a restored chip stopped being recognised as a paste and lost editing
  and cleanup. Provenance is rebuilt from the session registry. The unsent paste
  text cannot come along: this tab was refused that key all run, so it was never
  stored anywhere to carry.
- New Chat with draft saving off skipped every embedded record, leaving an
  unsent file-search paste with its metadata, storage and vectors intact. A
  paste this composer owns is now included with its real embedded value, while
  other embedded files are still left alone.

* fix: elect one cleanup worker, scope the queue to its account, guard edit writes

Three findings:

- Every mounted useNewChat (header, sidebar, mobile bar, shortcuts) entered the
  cleanup effect against one shared store, so a single retry issued the same
  DELETE several times and toasted about each. A pass is now claimed before it
  runs; an instance that is turned away asks for a later one rather than
  dropping the work.
- The retained queue outlived a sign-out, so the next account retried the first
  one's payloads, was refused by the ownership check, and rescheduled forever.
  Logging out clears the queue and cancels the pending retry.
- The paste path checks draft ownership before recording provenance, but the
  edit path did not, so a replacement could be written into a record another
  open tab owns, which that tab could then delete while this one still showed
  the chip. It takes the same check.

* fix: match paste identities everywhere and stop migrations clobbering a foreign draft

Five findings:

- The presence record published only composer map keys, but a restored upload
  is keyed by its temporary id while the value carries the server one, and a
  retained deletion in another tab names whichever it recorded. All three
  identities are published now, matching the local guard.
- Migrating a finished run checked that the pending record was ours but not the
  destination, so a conversation draft another tab owned with attachments on
  screen was overwritten and restamped. Both ends are checked, and the
  non-owner fallback no longer writes over a foreign destination either.
- The live-paste fallback matched the registry against file_id alone, so a
  completed paste, marked under its client upload id, read as somebody else's
  file and its upload survived New Chat. It matches every identity now.
- An upload still in flight has no filepath or source, so no discard path can
  build a payload and the reset drops the chip anyway. Its id is deferred so the
  record is deleted when it arrives, with draft saving on or off.
- An edited paste that had been staged into the code sandbox was re-uploaded as
  a plain context file, since only the file-search case was reconstructed.
  metadata.codeEnvRef is durable and now routes it back to execute_code.

* fix: silence background cleanup and keep cross-tab protection past a send

Four findings:

- The reset path matched the paste registry on file_id alone, the same alias
  gap already fixed in New Chat, so a completed embedded paste read as somebody
  else's file and survived with its vectors. It matches every identity now.
- The background cleanup pass used the ordinary delete mutation, so a storage
  failure that kept failing announced itself on every retry, and success
  arrived minutes after the action behind it. The mutation takes a silent
  option and the retry pass uses it; direct user actions still report.
- Each hook instance loaded the pending-discard list once and was never told
  when another instance wrote it, so work deferred by an instance that then
  unmounted stalled. Writes now notify every mounted instance, which re-read
  and apply only a real change.
- Cross-tab protection sampled only what a composer was holding right then, and
  sending clears both the map and the draft, so a file reattached in another
  tab and then sent could be deleted between retries. A tab now remembers what
  it recently held for ten minutes, which is long enough for the other tab's
  next pass to see it and cancel that deletion for good.

* fix: honour draft ownership in every clear and track what a message consumed

Five findings:

- The ownership contract was only applied at the new call sites; the SSE final
  event, the steering handoff and the debounced text clear still erased records
  through clearAllDrafts and clearDraft. The check moved inside those helpers,
  so every path that clears a draft respects it.
- Only the explicit logout cleared the retained queue, leaving a silent refresh
  that returns nothing and a failed user query to carry it into the next
  account. It clears wherever the session is lost instead, in the one place all
  three paths pass through.
- Using isSubmitting to decide whether a paste was consumed was wrong for a
  stopped or errored turn: those clear the flag without clearing the draft, so
  New Chat afterwards deleted files the turn already referenced. Submission now
  records the ids it took, and those are excluded by name.
- The presence sweep only ran from deletion cleanup, so a profile that never had
  a failed delete accumulated a record per tab until the origin quota ran out
  and draft writes began failing silently. The heartbeat sweeps.
- When a run finished into a conversation another tab owned, this tab's own
  queued text and attachments were dropped for want of a writable destination.
  They stay on the key it does own and are restored from there.

* fix: mint a tab id when the browser has no randomUUID

crypto.randomUUID is absent on insecure origins and in older webviews, and the
throw left the tab with an empty identity: every draft was then written without
an owner and every ownership guard read another tab's record as its own,
reinstating exactly the loss this layer exists to prevent. Falls back to
getRandomValues, then to a local mint. The id only has to tell tabs apart.

* fix: address PR review bot findings

Clear the retained deletion queue on every direct authentication exit, not
just the debounced context update: an empty or rejected silent refresh, a
failed user query, and the external-IdP logout all leave the page without
passing through setUserContext, so the queue survived into whoever signed in
next and retried under credentials the ownership check rejects forever.

Settle the edit lock when a replacement upload is aborted. Removing the
replacement chip mid-upload consumes the lifecycle through onAbort, which the
paste editor never handled, so the source paste kept its Edit and Move-back
actions hidden for the rest of the session and the typed correction was lost.

Keep the temporary-file cleanup payload for whatever the server reports as
failed. The delete route answers a partial storage failure with a 200 carrying
failedFileIds, and the cleanup mutation cleared FILES_TO_DELETE wholesale on
any success, dropping the only automatic retry those orphans had.

Persist both paste registries per tab. They lived in module-level sets, so a
reload kept the files draft but forgot the paste had been consumed, and New
Chat then classified an already-sent paste as unsent and deleted a file the
persisted message still references.

Withdraw discarded ids from tab presence. A removed, moved, or discarded chip
kept its recent entry for the whole window, and the retry sweep read that as
evidence the file had been reattached: it cancelled its own cleanup and left
the failed upload orphaned on the server. Presence records whose heartbeat
cannot be read are skipped rather than rewritten, since giving one a fresh
seenAt would revive a dead tab's claims over every id it still held.

* fix: address second round of PR review bot findings

Stop a settled deletion from undoing the logout clear. A DELETE that was
already in flight when the session ended settles afterwards, and its handler
is the last reference to that payload, so it wrote the departing account's
records straight back into session storage. Clearing now latches retention
shut and only a newly established session reopens it, which also covers the
paste editor's own retention and the discard paths, not just this one writer.

Reinsert a failed paste when nothing durable holds it. A composer the user
has typed into is deliberately left alone while a recovery record exists,
because that record restores at an anchored offset later. When the shared
draft key belongs to another live tab the guard skips the record entirely, so
the upload callback held the only copy and refusing dropped the text outright.
It now goes back in at the offset its anchors resolve to, which is where a
restore from a record would have put it.

* fix: address third round of PR review bot findings

Withdraw attachment presence from this tab only. The sweep cleared the
withdrawn ids out of every tab's recent map, which is the one record a second
tab has left once it has reattached a file and sent it: its composer and its
draft are both empty by then, so erasing that entry handed the next retry a
file it read as abandoned and let it delete the upload out of the message now
referencing it. The withdrawing tab always published what it withdraws, so its
own record is all it needs to touch.

Guard the direct New Chat deletion against other tabs. The retry effect
consults every other tab's drafts and published presence before deleting, but
the discard that runs on New Chat went straight to the request, so it raced
past that guard and could delete a file another tab still had attached or had
already sent. It now consults the same two sources, excluding its own draft
keys and its own presence record, which hold exactly what the discard is
throwing away.

Fix the import order in Presentation.tsx, which CI static checks flagged.

* fix: address fourth round of PR review bot findings

Read this tab's presence before sweeping stale keys. Timers pause while the
machine sleeps, so a live tab can beat again with its own record already past
the liveness window; the sweep reaped it and the write that followed published
an empty presence, and nothing republished it because the file map had not
changed. Another tab's retry then saw no claim on chips this one still had on
screen.

Keep submitted-use evidence when a later chip is withdrawn. The same file can
be sent on one message and reattached afterwards, and once the composer and
draft have cleared, its recent entry is the only cross-tab record that a
message still references it. Withdrawing a chip no longer erases an entry for
an id a submission already consumed; it ages out on the ordinary window.

Clear composer drafts when the account changes. A files draft carries the whole
text of a paste held as a file, and the browser tab keeps its identity across
an in-app account switch, so the ordinary draft restore could hand the next
account the previous one's writing. Both draft families are now dropped on the
sign-in and sign-out paths, ahead of the skipFirst exception.

Spare a submitted paste from the edit path's explicit deletion. Editing or
moving a reattached library file that an earlier message sent deleted the
server record underneath that message, because the draft-ownership check
succeeded and nothing consulted the submitted marker.

Validate an edited paste as a replacement rather than an extra file. The
original is deliberately still attached while the replacement uploads, so the
shared validation counted both and rejected the edit at the file-count or
total-size limit; with a limit of one, a lone paste could never be edited.

Preserve failed rows after a table deletion. The table's own cache update
removed every requested file without consulting failedFileIds, undoing the
partial-aware update and hiding a file whose storage delete had failed.

Document the two deliberate dependency omissions in AuthContext, which CI now
lints at zero warnings because the file is part of this change.

* fix: address fifth round of PR review bot findings

Clear composer drafts on the way out of a session, not only on the way in.
Clearing them from the login mutation missed social sign-in entirely: OAuth,
OpenID and SAML leave through direct links and come back through the silent
refresh, so a different account could arrive in the same tab with the previous
account's drafts and tab identity intact and have its paste text restored. The
draft clearing is now paired with the retained-deletion clearing in one helper
used by every authentication exit, so neither can be wired into a path the
other was missed from.

Rebuild paste provenance when restoring a queued upload. A paste queued during
a run has its pending draft taken by takeComposerDraft, so choosing Edit
message restored the upload into an empty composer with nothing recording that
it was a generated paste. Filtering existing provenance could not recover that,
and an unmarked restored chip is treated as a shared attachment: removing it
would not delete it and New Chat skipped it, orphaning the unsent upload. The
session registry still knows, so it is consulted.

Drop paste provenance when a rejected upload is removed. Validation can reject
a paste before it reaches composer state, and the failure path removes it with
removeFile, but the id stayed in pastedTextIds. That left a record
hasDraftAttachments reads as a real attachment claim with no chip behind it,
and with the file map unchanged nothing pruned it, so it locked every other tab
out of the shared composer key.

* fix: address sixth round of PR review bot findings

Centralise the foreign-claim guard. Every path that deletes an upload has to
ask whether another tab or pane still claims the file, and the guard was being
assembled by hand at each site, which is exactly why it was missing from three
of them. collectForeignAttachmentClaims now builds that set once, and the New
Chat discard, the no-draft reset fallback in useNewConvo, and the paste
editor's explicit deletion all consult it. A record another tab claims is
skipped rather than retained, since it was never this pane's to delete.

Scope presence withdrawal to the pane that owns it. One tab holds several
composers and the presence record is flat, so the hook that won the global
deletion pass swept every pane's entry while knowing only its own file map,
erasing the evidence of a chip a sibling pane still had on screen. Withdrawal
now takes the pane index, and an id another pane still lists keeps its recent
entry too.

Guard destructive draft clearing against text-only claims. claimComposerDraftTab
stamps a key that holds nothing but text, and the write guard ignores a claim
with no attachment behind it, so a tab finishing a run that began as an unsaved
chat cleared the shared new-chat key and took another tab's half-written
message with it. Clearing now honours any live foreign stamp, while text writes
keep their deliberate last-writer-wins behaviour.

Mark queued override files as submitted. A during-run queued message drains
through overrideFiles into the reuseFiles branch and skipped the marker loop
entirely, so reattaching that paste later left isPasteSubmitted false and New
Chat or an edit could delete a file the queued message still referenced.

* fix: address seventh round of PR review bot findings

Treat publishing an attachment as proof of liveness. The publisher carried the
old seenAt over, so a tab whose timers had been paused past the liveness window
published a chip and stayed expired until its next interval tick, long enough
for another tab's cleanup to sweep the record and delete the file under the
chip that had just appeared.

Count a sibling pane as a claim. The foreign-claim helper excluded this tab
entirely, so the pane doing the discarding could not see the other composer in
the same tab and deleted a file it still had on screen. Live claims are now
gathered per pane: only the discarding pane's own entry is left out, along with
this tab's recent map, which is flat and cannot say which pane an id came from.

Keep a tab identity when session storage is unusable. It can be blocked or full
while localStorage still works, and returning an empty id left the document
unattributed, which every ownership and liveness guard reads as no owner, so
tabs could destructively clear each other's attachment-backed drafts. An id
that lives only for this document still tells the open tabs apart.

Scope attachment withdrawal in the deletion hook to the originating pane, and
thread the composer index through ChatForm, FileFormChat and FileRow to supply
it. Removing a file from one side-by-side composer withdrew the id for every
pane, and with drafts off the sibling never republished its claim.

Check foreign claims before deleting a live edit source. The guard only covered
the restored path, because detach returns early for an in-memory upload before
reaching it, so editing a live paste another tab had reattached from the library
deleted the file underneath that tab's chip.

Keep autosaving to the pending key while the destination is not writable. The
preserved queued work was written under the pending key but the destination was
still recorded as the active conversation, so later edits autosaved against a
foreign key and a reload mounted straight onto the destination, losing the work
that had just been preserved.

* fix: address eighth round of PR review bot findings

Make submitted-use evidence durable and readable across tabs. The tab that
retries a retained deletion is rarely the tab that sent the message, and this
evidence lived in the sending tab's session storage, which left published tab
presence as the only cross-tab record. That ages out on a fixed ten-minute
window, so a retry resuming after a longer freeze classified a sent file as
abandoned and deleted it out of its message. It is timestamped in localStorage
now, with a horizon wide enough to outlast any plausible freeze and a hard cap
so a long-lived profile cannot grow it without bound. Paste provenance stays in
session storage, since which chips offer the paste affordances really is
per-tab.

Tie a blocked pending draft to its intended destination. Keeping the pending
key active while a live tab owned the destination left the pending state with
no memory of where it was heading, so any later navigation looked like the
awaited transition and carried the queued text and attachments into an
unrelated conversation.

Defer an in-flight paste on direct conversation resets. Callers that reach
newConversation without going through New Chat left an upload with no filepath
yet unrecorded, so once the request landed nothing remained to delete the
server file.

* fix: restore the composer clear after send

CI e2e caught this: after sending a message with an attachment the chip stayed
in the composer, so the sent message and the composer both showed it.

Two causes, both from keying composer storage off state that lags a render.
`currentConversationId ?? conversationId` is the previous conversation during
every transition, so the file-cache restore ran against the outgoing key and
put the just-sent attachment straight back into the map that the submit had
cleared. The active key is now the conversation unless the switch effect has
deliberately parked storage on the pending key.

Separately, treating any first mount as the awaited pending transition ran the
pending migration on every direct load of a conversation. That is narrowed to a
pending record this tab owns which actually holds something, which is what a
reload with real queued work looks like.

Verified against the two failing specs locally, then the whole mock chat spec:
8 passed.

* fix: address ninth round of PR review bot findings

Stop expiring submitted-use evidence on a timer. The work it has to outlast is
a retained deletion, and those carry no expiry of their own, so any interval
chosen could be outlived by a suspended tab still holding cleanup work, which
is the same bug with a longer fuse. The ledger is bounded by count instead,
evicting oldest first only when it would otherwise grow without limit.

Consult that ledger before retrying a deletion. The retry pass built its
protection set from drafts and published presence only, both per-tab and
time-bounded, so a file sent from a tab that has since been suspended had
nothing left to speak for it. The record is resolved before judging, because
the discard is often keyed by the temporary upload id while the pane that sent
it marked only the server id.

Refresh liveness when withdrawing presence, matching the publication side. A
retained-deletion pass resuming after paused timers withdrew its own entry and
then swept the record as stale, taking sibling panes' claims with it.

Keep queued attachments when neither draft key is writable. With another tab
owning the pending key and a second owning the destination, the effect cleared
the live map and could persist it nowhere, so unsent attachments vanished the
moment the run got its conversation id.

Remove the replacement provenance when an edited paste is not accepted. The
edit path records the replacement id before routing the upload, and a rejected
upload left a provenance-only draft that reads as a live attachment claim with
no chip behind it.

Verified with the mock chat e2e spec after rebuilding the frontend: 8 passed.
2026-08-25 20:06:59 -04:00
Danny Avila
1489623fa3
🎬 test: Record-Once/Replay-Forever Model Fixtures for Mock E2E (#15210)
* 🎬 test: Record-Once/Replay-Forever Model Fixtures for Mock E2E

The mock e2e lane's only credential-free model is a hand-authored script:
`fake-model.js` decides responses from ~60 `E2E_*` prompt markers. That
covers scripted shapes well, but no scenario replays a *real* recorded
provider conversation through the assembled chain, so real streaming
shapes — provider chunk cadence, reasoning deltas, usage metadata — are
only ever approximated.

This adds a record-once/replay-forever tier alongside the marker routing.

Record (`E2E_MODEL_FIXTURES=record`, needs a provider key): the run hook
appends a LangChain callback handler to every agent context's
`clientOptions.callbacks` instead of overriding the model, so the REAL
provider streams while each invocation's `AIMessageChunk`s serialize to
`e2e/fixtures/model-replay/<name>.jsonl` — text deltas, tool_call_chunks,
reasoning kwargs, and genuine usage metadata. Only the latest human text
is recorded for binding; system prompts and tool schemas never enter the
fixture.

Replay (default, keyless): `fake-model.js` consults `tryBindReplay` ahead
of marker routing, binding a conversation whose prompt matches the next
unconsumed invocation. The replaying model is not hand-assigned — it is
registered as SDK provider `librechat-e2e-replay` via `registerProvider`
and constructed through the SDK's own `initializeModel`, so registry
lookup, constructor clientOptions, and real `bindTools` all run the way a
live provider's would. Recorded chunks therefore stream through the same
createRun → graph → SSE → persistence chain.

Consumption is enforced rather than assumed: every invocation re-checks
its prompt against the recording, an invocation past the end of the
script throws, and a per-fixture ledger lets the spec assert at teardown
that every recorded invocation and chunk was drained. Streaming
incrementality is asserted from that ledger, not by sampling transient
DOM, which is a race by construction.

The credential-free profile is unchanged when not recording: the record
provider and its selector entry are template markers that stay comments,
and no existing spec's routing is touched (a fixture only binds on an
exact prompt match; everything else falls through).

Verified: record vs the real DeepSeek API 1 passed (15.2s); keyless
replay 1 passed twice (11.4s, 12.7s) with the ledger fully drained (2/2
invocations, 10/10 chunks, no overruns or mismatches); app-load,
completion, and chat 10 passed unchanged.

* fix(e2e): rebind a replay fixture from the top for a new conversation

The replay cursor is process-global while the web server outlives a
Playwright retry, so a fully consumed fixture left the retry unable to
bind its first prompt: it fell through to marker routing and failed
deterministically, burning every configured CI retry. A partially
consumed attempt failed the same way.

Binding now restarts the fixture when the incoming prompt matches its
first recorded invocation, resetting the ledger with the cursor so the
new attempt is judged on its own consumption instead of accumulating the
previous one's counts. Continuing an in-progress binding still outranks
restarting, so a fixture whose opening prompt repeats later in the script
advances rather than rewinding.

The over-consumption guard is untouched — it fires inside the stream when
the cursor passes the end, not at bind time.

* fix(e2e): close three replay-lane gaps found in review

Restart the recorder on a retry. Its state is process-global like the
replay cursor, so a failed attempt that had already recorded invocations
left the counter advanced: the retry appended 2/3 after 0/1, or kept the
previous attempt's `error` line, and the fixture was unusable for replay.
Recording now truncates and restarts when the opening prompt reappears,
mirroring the replay side's rule and its caveat.

Retain a consumed binding for the conversation that drove it. An extra
user turn past the final recorded invocation found no next invocation and
fell through to ordinary fake-model routing, so it was answered with a
mock reply: the over-consumption guard never ran and the already-drained
ledger still passed. Such a conversation is now recognized by its human
turns opening with the fixture's recorded prompts, and stays bound so the
stream raises the overrun. Continuing an in-progress binding still
outranks restarting, which outranks retaining a consumed one, so a
retry's fresh conversation rewinds rather than being read as an extra
turn.

Validate the fixture the recording actually wrote. Record mode honors
`E2E_MODEL_FIXTURE_NAME`, but the spec always inspected the committed
`deepseek-two-turn`; another name wrote elsewhere while the assertions
read the pre-existing file, and because the prompts are fixed the stale
answers could match and green a run that verified nothing it produced.

* fix(e2e): make replay binding correct for tool and subagent fixtures

Round two's retry and consumed-binding fixes both assumed one model
invocation per user turn. A turn that calls a tool breaks that: the model
is invoked again after the tool result under the same latest human
message.

Identify a retry by the conversation boundary, not the prompt. The
recorder ran per invocation and truncated whenever the opening prompt
reappeared, so a tool round trip looked like a retry and discarded the
recorded tool-call invocation. Restart detection now sits in
`installRecorder`, which runs once per `createRun`: a turn whose history
holds no prior human message begins a conversation.

Compare consumed bindings against user turns, not invocations. Several
recorded invocations can share one prompt, so a one-to-one comparison
could not recognize the originating conversation — invocations `[A, A, B]`
against history `[A, B, C]` failed on both length and elements, and the
extra turn fell through to the fake model with the drained ledger still
passing. Fixtures now carry their collapsed turn sequence.

Override the subagent model too. `graph.overrideModel` is not inherited
by child executors, so a fixture recording a subagent call — record mode
captures child invocations already — would leave the child on its
configured provider: an underrun, and a real provider request in a lane
that must stay keyless.

Reject ambiguous prompt matches. Binding order followed filesystem
enumeration, so a second fixture sharing a prompt could silently redirect
a scenario to the wrong chunks and ledger; the spec's choice never
reaches the server-side loop, so ambiguity fails instead of picking a
winner. Fixture identity is the file name for the same reason — a
recorded `meta.name` is descriptive, and trusting it let a copied fixture
collapse onto another's registry key and ledger.

Prove the recording is fresh. The spec removes the selected fixture
before driving, so a run whose hook never installed the recorder fails
instead of greening against a stale artifact whose answers still match
these deterministic prompts.

* fix(e2e): rewind a replay fixture at the conversation boundary

Consecutive invocations can share a prompt — a tool call produces exactly
that — so an attempt stopping mid-turn left the cursor on an invocation
whose text still equalled the opening prompt. Matching the cursor first
meant a retry's fresh conversation resumed after the tool call instead of
rewinding, consuming the post-tool invocation and silently replaying a
different script than was recorded.

A conversation boundary now outranks an in-progress cursor: a fresh
conversation whose prompt opens the fixture rewinds even when the cursor
would have matched. Continuing still outranks restarting within a
conversation, so a turn that calls a tool advances to its post-tool
invocation rather than rewinding on its own repeated prompt.

* fix(e2e): refuse cross-conversation binding and prove content streaming

A fresh conversation could steal a partly consumed fixture's later turn.
Only a conversation opening with the fixture's first prompt was treated
as a boundary, so after `[A, B]` had consumed `A`, an unrelated new
conversation whose first message was `B` matched the cursor, received the
recorded second-turn response, and advanced the shared cursor without
ever having driven `A`. A conversation start may now only rewind a partly
consumed fixture, never continue it; continuation within a conversation
is unaffected.

The incrementality assertion counted empty frames. Providers emit empty
initialization and usage-metadata chunks around the content deltas, so a
total chunk count above one was satisfied by a single delta: the previous
fixture's closing turn had four chunks and one content-bearing delta
carrying the whole answer, and both modes stayed green without proving
incremental assistant-content streaming at all. Fixtures now track
content-bearing chunks separately, the closing prompt asks for prose
rather than a number, and both modes require several content deltas on
that turn. Re-recorded: the closing turn now carries 28 content deltas.

* fix(e2e): scope record mode to the fixture spec

`E2E_MODEL_FIXTURES=record` replaces the fake-model hook globally, so an
unfiltered entry point such as `npm run e2e:mock` sent every spec under
specs/mock to the paid real-provider endpoint, while each fresh
conversation truncated and rewrote the one selected fixture — leaving an
artifact from whichever scenario happened to run last.

Record mode now matches only the fixture spec: an unfiltered recording
run lists one test instead of 203. Replay mode is untouched and still
collects the full suite.

* 🪪 fix: Bind Replay Fixtures by Conversation, Not Prompt Text

Prompt text was standing in for conversation identity, and three review
rounds found the same class of defect underneath it: a tool call repeats
a prompt across invocations, a retry repeats it across attempts, and a
resumed run has neither prompt nor history because `createRun` is rebuilt
with no messages while state comes from the checkpoint. Each fix in that
space created the next gap.

Thread the identity instead. `createRun` accepts a `conversationId` and
passes it to the run hook, which the agents controller supplies at both
call sites — the same value it already uses as the checkpointer's
`thread_id`. The field is optional and the hook is env-gated, so nothing
changes when the harness is not in use.

Binding then collapses to ownership. A fixture is owned by the
conversation that claimed it, and its cursor is authoritative wherever it
stands: an extra turn reaches the over-consumption guard rather than
falling through to the scripted fake model, and a resumed run keeps
replaying with no prompt to match. A different conversation may claim the
fixture only by opening it, which rewinds — what a Playwright retry looks
like. Everything else is refused, so an unrelated conversation can no
longer continue someone else's partly consumed script by repeating a
later prompt. The prompt is still re-checked on every real turn; only a
resume, which structurally carries no human message, is exempt. The
previous text-and-history rules remain as a fallback when identity is
absent.

The recorder keys the same way: a new attempt is a new conversation, so a
resume no longer truncates the fixture mid-turn and discards its
tool-call invocation.

Record summarization too. The summary provider runs on its own model with
its own callback list, so a scenario crossing the context-pruning
threshold recorded the agent's invocations but not the summariser's,
leaving a fixture that could not reproduce the pruned context.

* 🧾 fix: Harden Record Mode and Make the Rendered-Text Assertion Honest

CI caught what local runs had not: the committed fixture was never
replayed locally, because the record run overwrote it after the replay
check rather than before. Re-recording and replaying in that order is
what surfaced the rest of this.

The DOM assertion compared raw recorded text against rendered markdown.
The previous answer opened with `52.`, which Markdown renders as an
ordered-list marker, so those characters never appear in the DOM and the
match failed on all three CI attempts while replay itself was correct.
The closing prompt now asks for prose beginning with a word, a leading
enumerator is stripped before matching, and only a prose prefix is
compared.

Derived configs discarded the record-mode restriction. `config.redis.ts`
and `config.mermaid.ts` spread this config and then replace `testMatch`,
so `e2e:mock:redis` in record mode would still send its specs to the paid
provider. A restriction expressed as an overridable value cannot hold, so
record mode now refuses any config but the mock one.

Superseded recording callbacks could write across a reset. A failed
attempt with a provider call still in flight keeps its handler on the old
graph; after the retry reset, that call would allocate an invocation from
the new counter or append an `error` entry with a cleared mapping.
Handlers now carry the recording generation they were installed for and
ignore everything from an older one, and attachment dedupes against the
current generation so a graph carried across a restart is not left with
an inert handler.

* 🚧 fix: Make Summarization an Explicit Boundary, Not a Half-Feature

Recording summarization invocations without replaying them is worse than
ignoring them. Replay routes the agent model and subagents only, so a
recorded summarization entry takes a slot in the fixture sequence that
replay never consumes, and the next primary call reads the summariser's
chunks — a prompt mismatch or, worse, silently wrong content.

The attachment was also aimed at the wrong shape: the SDK reads
`summarizationConfig.parameters`, not `.parameters` nested under
`.config`, so the previous attempt would have attached to nothing in a
real run. Its test passed only because the test built the shape the code
expected rather than the shape the SDK provides.

Rather than ship a half-routed feature, recording now fails the moment
summarization runs, naming the reason. Both shapes are guarded so the
guard cannot miss the way the recorder did. Summarization fixtures need
replay routing for that model before they can be supported.

The derived-config guard added alongside it was itself broken: workers do
not carry `--config`, and the argument lookup fell through to
`process.argv[0]`, so every recording run aborted claiming the node
binary was an unexpected config. The flag is now located explicitly and
absence is treated as "not the process that parsed the CLI".

* 🔒 fix: Close the -c Config Alias and Pin the Recorder's Fixture Name

Playwright documents `-c` as an alias for `--config`, so record mode
launched as `playwright test -c e2e/playwright.config.redis.ts` slipped
past a guard that recognised only the long spelling. Both spellings and
both `=` and space forms are now parsed.

Accepting arbitrary fixture names also worked against the ambiguity
check. This spec drives one fixed prompt pair, so recording under another
name left two fixtures sharing those prompts; replay then refused to bind
either and the keyless lane stopped working — a successful documented
recording run could disable the suite it exists to serve. The spec now
records only the fixture it owns and says so when asked for another.

* 🔧 test: Record a Real Tool-Call Turn and Replay It Through the Tool Node

The fixture format carried `tool_call_chunks` and the binding advanced
through a turn's invocations, but nothing had recorded a real
tool-calling conversation end to end — the path was covered only by
hand-written synthetic fixtures, and it is the first one a new scenario
would exercise.

This records one: the provider calls the `remember_fact` MCP tool, the
tool runs, and the model is invoked a second time with its result. That
is the shape a single prompt cannot express — one user turn spanning
several model invocations, all sharing one prompt — so it is what proves
the turn-vs-invocation distinction the binding rules were built around.

Replay drives the real tool node rather than replaying its output, so the
tool executes again and the assertion checks its live result.

Two fixtures now coexist, which the record path had to grow for: the
config keeps an allowlist so an unknown name is still refused, record
mode collects every replay spec, and each spec records only the fixture
it owns and stands down for the others.

MCP tools reach the model under a server-qualified name
(`remember_fact_mcp_e2e-memory`); that qualification has changed before,
so the assertions match the base name as a prefix rather than pinning the
suffix.

Verified: record 1 passed (15.6s, real API) then replay 1 passed (13.6s)
against that fixture, ledger drained 2/2 invocations and 35/35 chunks;
both replay specs together 2 passed; app-load, completion, chat and
mcp-ephemeral 12 passed.
2026-08-25 18:29:55 -04:00
Danny Avila
290b8664d9
🧾 feat: Track Authoritative Agent Event Outcomes (#15213)
* feat: track authoritative agent event outcomes

* fix: isolate agent event outcome types

* fix: declare agent event handler result

* fix: simplify agent event status selection

* fix: preserve authoritative event outcomes

* fix: preserve terminal event evidence

* test: use completed run-step envelope

* test: scope deferred HITL question locator

* fix: settle every agent event terminal path

* style: sort terminal host action imports

* fix: fence agent event terminal evidence

* fix: recover agent event terminal settlement

* fix: scope terminal retry hints by generation

* fix: settle terminal host actions exactly
2026-08-25 18:20:57 -04:00
Danny Avila
6d499ba3ce
fix: Anchor Resumed Elapsed Time at the Generation's Real Start (#15204)
Some checks failed
Publish `librechat-data-provider` to NPM / pack (push) Waiting to run
Publish `librechat-data-provider` to NPM / publish-npm (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `@librechat/client` to NPM / pack (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / pack (push) Has been cancelled
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
*  fix: Anchor Resumed Elapsed Time at the Generation's Real Start

A reload emptied the Recoil anchor, so the indicator fell back to its
mount time and visibly reset to 0s over a run that had been generating
for much longer. The stream status the resume path already reads carries
the server-recorded generation start; the fill now prefers it, so a
reattached run reports real elapsed time. The fill remains fill-only:
a same-session reattach keeps its original ask baseline, and the
indicator's existing clamp absorbs any client/server clock skew.

* 🕰️ fix: Rebuild the Resumed Baseline From the Server-Computed Age

Codex round 1: anchoring at the server's raw createdAt compares two
clocks — a client behind the server froze the resumed reading at 0s for
the skew, one ahead inflated it. The status route now also reports the
generation's age computed on its own clock, and the client rebuilds a
clock-local anchor as Date.now() minus that age, so each machine only
ever compares to itself. Raw createdAt stays as the fallback for an
older server mid-rolling-deploy.

* 📥 fix: Compute Elapsed Age in TypeScript, Anchor It at Status Receipt

Codex round 2: the elapsed computation moves into packages/api as
getGenerationElapsedMs — the route now just delegates, keeping the
response contract type-checked and the /api surface a thin wrapper —
and the client subtracts the age from the moment the status response
arrived (dataUpdatedAt) rather than from apply time, so a slow history
fetch between receipt and apply can no longer shrink the reading.
Declined with rationale: a shared clock source across replicas — the
residual is inter-replica NTP drift, milliseconds against the
minutes-scale client skew this PR eliminates, and the helper gives any
future shared-clock upgrade a single home.
2026-08-25 09:35:10 -04:00
Danny Avila
d9e6250d05
🛑 fix: Separate Agent Event Backpressure From User Bans (#15200)
* 🛑 fix: Separate Agent Event Backpressure From User Bans

* fix: Address Agent Event Review Findings

* fix: Mirror Case-Insensitive Agent Control Routing
2026-08-25 09:27:35 -04:00
Danny Avila
877b9b2f1a
🐚 feat: Nonce-Based Content Security Policy for the SPA Shell (#14446)
* 🛡️ feat: Configurable Baseline HTTP Security Headers

Adds helmet's CSP-independent headers (HSTS, X-Frame-Options,
X-Content-Type-Options, COOP, CORP, Referrer-Policy) on every response,
with contentSecurityPolicy explicitly disabled. Every header that can
break a deployment is configurable, so there is no allow-list to go
stale the way #7377's hardcoded CSP directives did.

HSTS includeSubDomains defaults off rather than matching helmet's
on-by-default: it would otherwise pin every sibling subdomain to HTTPS
for a year in every visitor's browser, and undoing that requires
serving max-age=0 from each affected host.

* 🛡️ feat: Nonce-Based Content Security Policy for the SPA Shell

Adds an opt-in, per-response nonce CSP on the HTML response, resolved once
at startup so each request only mints a nonce and concatenates the header.
Report-only by default, since that is the rollout step #7377 skipped.

Rebase and correctness pass over #13226:

- Styles carry no nonce. A nonce in style-src makes browsers ignore
  'unsafe-inline', which would have blocked the <style> element the theme
  script injects at runtime, plus every style third-party components inject.
- frame-ancestors 'self' is now a default rather than opt-in, so enabling
  CSP actually covers the clickjacking half of #7110.
- CSP_SCRIPT_SRC_EXTRA now drops 'strict-dynamic', which would otherwise
  make browsers ignore the very hosts the operator configured.
- Nonce stamping runs after the query-devtools bootstrap injection so that
  injected script is covered too.

* fix: replace frame-ancestors instead of merging it

Merging the configured value into the default turned a deliberate
CSP_FRAME_ANCESTORS='none' into `frame-ancestors 'self' 'none'`, which
browsers resolve back to 'self'. Also bail out if the serialized policy
somehow lacks the nonce slot rather than emitting a header the shell
cannot match.

* fix: address Codex review findings on the CSP defaults

All five were real against LibreChat's actual runtime:

- CSP_REPORT_ONLY now only enforces on an explicit false/off/0/no. A typo
  or `1` previously fell through isEnabled() to enforcing, turning a
  config slip into a blocked SPA. Shares the parse helper with
  headers.ts via a new security/env.ts.
- Module preloads are stamped. A production client/dist/index.html
  carries 32 parser-inserted `<link rel="modulepreload">` tags, which
  'strict-dynamic' does not cover and 'self' cannot rescue.
- Stale nonce attributes are replaced rather than preserved; only the
  current response's nonce is authorized.
- worker-src allows data:, which Monaco's default CDN loader needs to
  bootstrap its workers (there is no loader.config() in the client).
- script-src allows 'wasm-unsafe-eval' for the HEIC upload path, which
  compiles WebAssembly through heic-to. Narrower than 'unsafe-eval'.

Verified against the real built shell: 4 scripts and all 32 preloads
nonced, stylesheets/icons/manifest and <style> untouched.

* fix: address second Codex round on CSP rollout controls

- SECURITY_HEADERS=false now disables CSP too. It is documented as the
  global kill switch, and an operator reaching for it to recover a shell
  broken by an enforcing policy must not be left with that policy on.
- The SPA shell is forced to `no-store` while CSP is enabled, ignoring
  INDEX_CACHE_CONTROL/INDEX_PRAGMA/INDEX_EXPIRES and warning when they
  are set. A cacheable shell pins one nonce across page loads and users,
  which is the whole thing a nonce policy defends against.
- Added CSP_ALLOW_WASM and CSP_ALLOW_DATA_WORKERS. The previous commit's
  .env.example claimed CSP_ADDITIONAL_DIRECTIVES could drop
  'wasm-unsafe-eval' and data:, but merging only ever appends sources, so
  the documented hardening step was impossible. These toggles make it real.
2026-08-25 09:18:52 -04:00
James Todaro
018775de07
🧾 fix: Honor Disabled Transactions on the Assistants Usage Path (#15100)
* 🧾 fix: Honor Disabled Transactions on the Assistants Usage Path

Thread the resolved transactions config through `recordUsage` from each of
its callers, so `transactions.enabled: false` is honored on the assistants
token spend path.

* 🧾 fix: Thread the transactions config through the vision-request caller

Address review: `ToolService.processVisionRequest` also records usage without
the resolved config, and `recordUsage`'s documented return type did not match
the function.

* 🧾 fix: Set the resolved transactions config after the usage spread

- provider usage could carry a `transactions` key that overwrote the trusted value
- matches the ordering the other `recordUsage` callers already use
2026-08-25 08:30:12 -04:00
Danny Avila
2ef12b1e1d
🦺 feat: Configurable Baseline HTTP Security Headers (#14445)
Adds helmet's CSP-independent headers (HSTS, X-Frame-Options,
X-Content-Type-Options, COOP, CORP, Referrer-Policy) on every response,
with contentSecurityPolicy explicitly disabled. Every header that can
break a deployment is configurable, so there is no allow-list to go
stale the way #7377's hardcoded CSP directives did.

HSTS includeSubDomains defaults off rather than matching helmet's
on-by-default: it would otherwise pin every sibling subdomain to HTTPS
for a year in every visitor's browser, and undoing that requires
serving max-age=0 from each affected host.
2026-08-25 08:21:39 -04:00
Danny Avila
4b113697b5
🔌 feat: Background Execution Toggles for Actions & Plugin Tools (#14407)
* 🧵 feat: Background Execution Toggles for Actions & Plugin Tools

* 🩹 fix: Resolve action background opt-in across encoded-domain forms and scope it per action

* 🧹 refactor: Resolve action domain in a single pass

* 🧩 fix: Merge Normalized Action Background Options

* 🪢 fix: Reconcile Action Background Aliases

* 🧭 fix: Harden Action Background Compatibility

* 🕰️ test: Allow Settled Task TTL Expiry

* 🧬 fix: Merge Refreshed Action Tool Registrations
2026-08-25 08:13:13 -04:00
James Todaro
4d246469dd
🧾 fix: Honor Disabled Transactions on the Abort Paths (#15099)
Resolve the transactions config from the request and forward it to both
abort write paths, so `transactions.enabled: false` is honored when a
generation is stopped.
2026-08-25 08:10:26 -04:00
Danny Avila
862ebf3235
🪢 fix: Persist Failed Agent Turns Before Error Publication (#14118) 2026-08-25 06:50:46 -04:00
Danny Avila
ac2aef00f6
🫗 fix: Drain Quoted Excerpts Into Mid-Run Steering (#15175)
* 🧭 fix: Carry Quoted Excerpts Through Mid-Run Steering

"Add to chat" quote chips were dropped by every during-run steer path: the
steer POST had no quotes concept, so a composer-origin steer left the chip
staged (gluing onto the NEXT send) and a queued item steered into the live
run lost its quotes silently.

Quotes now ride the steer protocol end to end:
- POST + admission: `quotes` on the steer body, normalized like the chat
  route's (getReferencedQuotes caps), part of the idempotency fingerprint
  only when present so pre-existing receipts still replay.
- Injection: merged into the model-bound turn as Markdown blockquotes at
  both boundaries (text-only and media paths), mirroring prependQuotes.
- Persistence + replay: the STEER content part stores `quotes` separately
  from the typed text; stampSteerPartMedia re-merges them per turn (even
  with resendFiles off) via the SDK's transient media stamp, with the quote
  block folded into the token budget.
- UI: composer steers/interrupt-steers drain the chips (skill picks stay
  staged — they configure a NEW turn's run); SteerPart and the in-flight
  bubble render the same MessageQuotes reference blocks as user bubbles;
  queued/failed rows show a quote count; reconnect reseeds fall back to the
  server item's quotes when no local chip survives.
- buildMessages keeps its zero-await path to the parallel context kickoff
  via a synchronous stamp-target probe.

* 🧭 fix: Keep Quotes in the Client-Safe Steer Projection

toPendingSteer is the projection behind resume-state pendingSteers, abort
responses, and terminal leftover claims — dropping quotes there would lose
them on exactly the recovery paths the reconnect reseed's server fallback
relies on.

* 🧪 test: In-Flight Steer Bubble Renders Carried Quotes

* 🔁 fix: Re-Stage Quotes When a Pre-Quotes Replica Accepts the Steer

Codex flagged the rolling-deploy window: an old replica 202s a quoted steer
while dropping the excerpts, so the client cleared the chips for context the
model never received.

The 202 (fresh and receipt replay) now echoes quotesAccepted from the
DURABLE item; a missing echo on a quote-bearing composer-origin steer
re-stages the excerpts as composer chips — the pre-steer behavior, so they
ride the next send instead of vanishing — and strips them from the surviving
chip so a later terminal conversion cannot duplicate them. Queued-origin
steers keep quotes on the item, whose restore paths already return it
intact. The residual cross-version lost-ACK retry stays fail-closed as a
409 idempotency conflict (failed chip with retry controls).

* 🔁 fix: Close the Remaining Cross-Version Quote-Loss Windows

Codex round 2:
- Send now of a quoted queued item against a pre-quotes replica now
  re-stages the excerpts too (the row is consumed and the words inject
  bare, so the composer is their only remaining home); the strip clears the
  chip's captured origin copy so reclaims and terminal conversions cannot
  duplicate them.
- A quoted retry whose lost first ACK was accepted by a pre-quotes replica
  now REPLAYS that legacy receipt instead of 409ing: the stored fingerprint
  matching the quote-less hash of the same words proves the cross-version
  case, and the replayed 202's missing echo drives the re-stage. Different
  quotes against a quote-bearing receipt still conflict.
- TSteerAppliedEvent.part gains the quotes field (typed SSE consumers).

* 🧪 test: Drop the Stale Narrow SteerDrainOutput Alias

The spec's local intersection re-declared injectedMessages with
content: string, predating the SDK pin that declares the field natively
(content: string | MessageContentComplex[]). Under CI's clean install the
hook's BaseHookOutput is no longer assignable to that narrower alias; the
plain PostToolBatchHookOutput is the correct type for every drain/boundary
assertion. Verified against the published 3.6.16 dist and the local one.

* 🔁 fix: Honor the Generation Owner's Quote Capability End to End

Codex round 4:
- steerQuotesCapable rides job metadata (createJob + HITL resume rewrite),
  mirroring preemptCapable's owner-recorded pattern: an upgraded admission
  replica no longer stores quotes — or claims them accepted — for a
  generation whose older owning drain would silently drop them at
  injection. The missing echo drives the client re-stage, and a later
  capable handover cannot double-deliver restored context.
- Applied events reconcile dropped quotes: when a quote-less applied part
  settles a quote-bearing chip (the lost-202 ordering the ACK-echo path
  cannot see), resolveSteerChip and both reconnect settle paths re-stage
  the chip's excerpts before removing their only copy. mergeRestagedQuotes
  dedupe keeps every trigger idempotent for the same excerpts.

* 🔁 fix: Re-Read Quote Capability at the Last Moment and Cap Restaged Chips

Codex round 5:
- A HITL resume rewrites steerQuotesCapable without changing the
  generation's createdAt, so the enqueue fence cannot see a
  capable-to-legacy handover landing during admission's awaits. Re-read
  the owner's flag immediately before item construction (paid for only by
  quote-bearing requests); the residual between re-read and enqueue commit
  matches preemptCapable's documented race.
- mergeRestagedQuotes now respects the 10-quote contract with the staged
  chips winning: a restored tail that cannot ride the next send is dropped
  explicitly instead of rendering as a chip the submission would silently
  discard. MAX_QUOTE_COUNT moves to utils/steer as the single client
  source; QuoteButton imports it.

* 🔁 fix: Steer Quote Coverage for Preflights, Memory, and Single-Scan Stamping

Codex round 6:
- Stored-message policy inspection now extracts steer-part quotes as quote
  fragments (path /content/N/quotes/M), so conversation import and shared
  link preflights inspect the newly persisted field exactly like top-level
  message.quotes.
- The memory copy gets its own quote-merge stamp (text only, resendFiles
  false): formatAgentMessages ignores part.quotes, so without it a steer
  whose substance lives in its excerpt reached the chat model but never
  memory extraction.
- collectSteerStampTargets replaces the boolean probe: buildMessages
  collects once and hands the targets to stampSteerPartMedia, keeping the
  zero-await fast path without scanning the history twice.

* 🔁 fix: Redis Quote Plumbing, Conversion-Race Guard, and Quote-Bound Recovery Proof

Codex round 7:
- RedisJobStore.deserializeJob now restores steerQuotesCapable (the explicit
  mapper otherwise dropped it on every read, leaving quote steering inert in
  Redis deployments), with the round-trip spec extended.
- Both Lua parked-steer projections (terminal close + generation
  replacement) forward item.quotes, matching toPendingSteer — a lost final
  no longer strips excerpts from durable recovery in Redis mode.
- The no-echo restage reads the SURVIVING chip (reclaimRejectedChipQuotes):
  a terminal conversion that beat the delayed 202 already moved the quotes
  onto the queued follow-up, and re-staging them again double-delivered.
  Regression-tested with the conversion-before-ACK ordering.
- RecoveredSteerPayload binds normalized, order-significant quotes (builder,
  validator, TS matcher, and the Lua decode+matcher): a stale client
  presenting the same recoverySteerId with altered or missing quotes cannot
  consume the parked source. Quote-less sources keep matching quote-less
  recoveries.

* 🔁 fix: Execution-Bound Quote Capability with an Atomic Enqueue Predicate

Codex round 8:
- steerQuotesCapable becomes a transient assertion translated (at createJob
  and in ApprovalLifecycle.resolve) into steerQuotesExecutionId, valid only
  while it equals the LIVE providerExecutionId. A legacy replica winning a
  HITL resume rewrites the execution id without knowing the marker, so its
  stale assertion self-invalidates — a bare boolean could not be cleared by
  code that predates it.
- The fenced enqueue evaluates that equality atomically (all three Redis
  scripts decode-and-strip like the existing preemptCapable normalization;
  both InMemory sites mirror it) and returns the persisted item, so the
  quotesAccepted echo reflects exactly what was stored even when a handover
  lands between admission's read and the commit. The last-moment re-read is
  gone — the transaction is the authority.
- Tests: capable-resume re-binding, legacy-resume omit-not-clear
  invalidation, the admission-vs-handover race (capability read true, then
  execution rewritten before enqueue), and the Redis round-trip of the
  marker.

* 🔁 fix: Full Redis Parking Coverage and Loss-Moment Quote Restaging

Codex round 9:
- The two remaining Redis parking projections (terminal status CAS and
  stale-running cleanup) forward item.quotes — every field-picked steer
  projection now carries them (audited: 2 Lua 'projected' + 2 Lua
  'clientItem' + toPendingSteer).
- The ordinary no-echo ACK no longer re-stages: the steer has not injected
  yet, so the quotes stay carried on the pending chip. A quote-less applied
  event re-stages them at the actual loss; a terminal leftover conversion
  carries them onto the recovered row, whose normal send delivers quotes on
  any server — re-staging at the ACK let that leftover auto-send bare text
  while the excerpts glued onto an unrelated draft. Only the settled
  receipt replay (already injected, no future event) reclaims immediately.

* 🔁 fix: Legacy-Replayable Receipts with Separate Quote Identity

Codex round 10: an upgraded-first receipt stored a quote-inclusive
fingerprint no pre-quotes replica could recompute, so a lost-ACK retry
routed through one 409'd already-accepted words with duplicate-send
controls.

The durable fingerprint reverts to the quote-independent 3-field hash —
the one shape EVERY deployed version computes, replayable across a rolling
deploy in both directions — and quote identity moves beside it as
requestedQuotesFingerprint (of the REQUESTED quotes, pre any capability
strip, so an incapable-owner acceptance still replays its own retries).
Absent records (legacy-written or quote-less) accept any same-words retry,
preserving the round-5 rule; present records must match exactly, keeping
different-quotes clientSteerId reuse a 409 on quote-aware readers. Under
the keep-on-chip client contract a legacy replay's missing echo is
harmless — the excerpts stay carried on the pending chip.

* 🧪 chore: Re-Trigger CI After Dropped Workflow Events
2026-08-24 22:29:13 -04:00
Danny Avila
69e7c73614
🎛️ feat: Expose Authoritative Subagent Controls (#15169)
* feat: expose authoritative subagent controls

* fix: reconcile subagent control races

* fix: reconcile durable control conflicts

* fix: preserve authoritative subagent control outcomes

* fix: fence subagent controls to child thread

* fix: validate subagent control targets before routing

* fix: close subagent control boundary gaps

* fix: keep control reservations private

* fix: close subagent control admission gaps

* fix: preserve authoritative control history

* style: sort subagent control imports

* fix: preserve authoritative subagent control retries

* style: sort control state imports
2026-08-24 20:37:49 -04:00
Danny Avila
d641c398d5
🧳 fix: Port Subagent Control Receipt Writes to DocumentDB-Safe Operators (#15171)
* fix: harden subagent control receipt persistence

* fix: harden durable subagent control replay

* fix: await terminal control receipts on shutdown

* fix: close subagent control replay races

* test: type stale-owner transport fixture

* fix: quiesce durable subagent controls

* test: await subagent shutdown durability boundary

* fix: serialize durable subagent controls

* fix: fail shutdown on cleanup errors

* fix: report cancellable result availability accurately

* fix: fence subagent control receipt ownership

* fix: close distributed control receipt races

* test: type control receipt race fixture

* chore: require authoritative control receipts

* fix: close subagent control lifecycle races

* style: separate control reservation member

* test: harden subagent settlement wait

* fix: preserve authoritative control replay state
2026-08-24 20:05:39 -04:00
Danny Avila
a997275902
🧾 feat: Persist Authoritative Subagent Control Receipts (#15168)
* feat: persist subagent control receipts

* fix: require control receipt persistence

* fix: preserve authoritative control history
2026-08-24 11:39:56 -04:00