🔐 fix: Forward per-file entity_id through code-env priming (#12958)

* 🔐 fix: Forward per-file `entity_id` through code-env priming

Skill files and persisted code-env files now carry their `entity_id` on
the in-memory file refs that seed `Graph.sessions`. Without this, an
execute call that mixes a skill file (uploaded with `entity_id=skillId`)
and a user attachment (uploaded with no `entity_id`) collapses onto a
single request-level entity at the codeapi authorization step and one
side 403s. With per-file `entity_id`, codeapi resolves sessionKey per
file and both authorize.

- `primeSkillFiles` / `primeInvokedSkills`: thread `entity_id` through
  fresh-upload, cache-hit, and per-skill-batch paths in
  `packages/api/src/agents/skillFiles.ts`.
- `primeFiles` (Code/process.js): parse `entity_id` from the persisted
  `codeEnvIdentifier` query string once per iteration; forward through
  `pushFile`, including the reupload path which re-parses the fresh
  identifier returned by codeapi.
- Tests: extend `skillFiles.spec.ts` with two cases — fresh-upload
  propagation and cached-hot-path parsing.

Companion PRs in flight on `@librechat/agents` (forward `entity_id`
through `_injected_files`) and codeapi (per-file authorization). All
three are wire-back-compat: an absent `entity_id` falls back to the
existing request-level resolution.

* 🔧 chore: Update dependencies in package-lock.json and package.json

- Bump `@librechat/agents` to version `3.1.78-dev.0` across multiple package files.
- Upgrade `@langchain/langgraph-checkpoint` to version `1.0.2` and update its peer dependency for `@langchain/core` to `^1.1.44`.
- Update `axios` to version `1.16.0` and `follow-redirects` to version `1.16.0`.
- Add `@types/diff` as a new dependency at version `7.0.2` and include `diff` at version `9.0.0` in the `@librechat/agents` module.
- Introduce optional peer dependency `@anthropic-ai/sandbox-runtime` for `@librechat/agents` with metadata indicating it is optional.

* 🐛 fix: Make skill code-env cache persistence observable

Two changes to surface the skill-bundle re-upload issue without
behavioral changes to tenant scoping (root cause to be confirmed via
the new warn log):

1. `primeSkillFiles` now awaits `updateSkillFileCodeEnvIds` instead of
   firing-and-forgetting it. The prior shape could race with the next
   prime (read-before-write) even when the bulkWrite itself succeeds,
   producing a silent cache miss. Latency cost: ~10–50ms on first
   prime; in exchange every subsequent prime can rely on the
   identifier being persisted by the time it reads.

2. `updateSkillFileCodeEnvIds` now returns `{matchedCount, modifiedCount}`
   from the underlying bulkWrite. `primeSkillFiles` warn-logs when
   `modifiedCount < updates.length`, making any silent drop visible —
   whether the cause is tenant filtering, a `relativePath` mismatch,
   schema-plugin scoping, or something else. Prior shape returned
   `Promise<void>` so any zero-modification result was invisible.

Tests:
- `skill.spec.ts`: real-MongoDB happy path (counts match), no-match
  case (modifiedCount=0), and empty-input contract.
- `skillFiles.spec.ts`: deferred-promise harness proving the call
  site awaits the persist (prime stays pending until the persist
  resolves) and forwards partial-write counts.

Deliberately narrower than the original draft of this commit, which
also bypassed `tenantSafeBulkWrite` for the codeEnvIdentifier write
on the speculative diagnosis that tenant filtering was the cause.
That change was a behavior shift on tenant scoping without
confirmation; reverted pending real-world signal from the new warn
log.

* 🐛 fix: Justify await for skill code-env persistence under concurrency

The await on `updateSkillFileCodeEnvIds` isn't a defensive nicety —
it's load-bearing for cache effectiveness under concurrent priming.

Verified with an out-of-tree harness (`config/test-skill-cache.ts`,
not committed) that wires `primeSkillFiles` against a real codeapi
stack:

- With fire-and-forget (prior shape after this branch's revert):
  back-to-back primes for the same skill miss the cache. Call N+1
  reads SkillFile docs before Call N's write commits, sees no
  `codeEnvIdentifier`, re-uploads, fires its own forget that Call N+2
  also races. Steady-state stays in cache miss for the full burst.

- With await: the prime that does the upload commits its persist
  before resolving, so the next concurrent prime observes the cache
  pointer instead of racing the read. Latency cost ~10–50ms on the
  upload prime; subsequent concurrent primes save an entire batch
  upload.

In production with primes seconds apart this race is rare; at scale
with many users hitting the same skill in the same second it's the
difference between M and N×M uploads.

Updates the regression test to assert the await contract (deferred
persist promise → prime stays pending until persist resolves).
Comment in `skillFiles.ts` rewritten to document the concurrency
rationale rather than the weaker "race-with-next-prime" framing the
prior commit used.
This commit is contained in:
Danny Avila 2026-05-05 18:35:09 -04:00 • committed by GitHub
parent 187ab787da
commit 9efd61d57d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 350 additions and 40 deletions

View file

@ -430,9 +430,14 @@ const primeFiles = async (options) => {
const [path, queryString] = file.metadata.fileIdentifier.split('?');
const [session_id, id] = path.split('/');
let queryParams = {};
if (queryString) {
queryParams = Object.fromEntries(new URLSearchParams(queryString).entries());
}
/**
* `pushFile` accepts optional overrides so the reupload path can
* push the FRESH `(session_id, id)` parsed off the new
* push the FRESH `(session_id, id, entity_id)` parsed off the new
* `fileIdentifier`. Without these overrides, the closure would
* capture the stale pre-reupload refs from the outer loop and
* the in-memory `files` array (now consumed by
@ -441,8 +446,12 @@ const primeFiles = async (options) => {
* gets the new identifier via `updateFile`, but the seed would
* still inject the old one — bash_tool / read_file would 404
* trying to mount the file until the next turn re-reads metadata.
*
* `entity_id` is forwarded so codeapi can resolve sessionKey
* per-file, allowing one execute to mix files uploaded under
* different entities (e.g. a skill bundle plus a user attachment).
*/
const pushFile = (overrideSessionId, overrideId) => {
const pushFile = (overrideSessionId, overrideId, overrideEntityId) => {
if (!toolContext) {
toolContext = `- Note: The following files are available in the "${Tools.execute_code}" tool environment:`;
}
@ -455,11 +464,13 @@ const primeFiles = async (options) => {
: ' (attached by user)';
}
const entity_id = overrideEntityId ?? queryParams.entity_id;
toolContext += `\n\t- /mnt/data/${file.filename}${fileSuffix}`;
files.push({
id: overrideId ?? id,
session_id: overrideSessionId ?? session_id,
name: file.filename,
...(entity_id ? { entity_id } : {}),
});
};
@ -468,11 +479,6 @@ const primeFiles = async (options) => {
continue;
}
let queryParams = {};
if (queryString) {
queryParams = Object.fromEntries(new URLSearchParams(queryString).entries());
}
const reuploadFile = async () => {
try {
const { getDownloadStream } = getStrategyFunctions(file.source);
@ -504,11 +510,18 @@ const primeFiles = async (options) => {
* top of this iteration refer to the old, expired/missing
* sandbox object — using them here would silently re-introduce
* the bug `Graph.sessions` seeding is supposed to fix.
*
* `entity_id` survives the round-trip: the upload was tagged
* with `queryParams.entity_id` above, so the new identifier
* carries the same scope.
*/
const [newPath] = fileIdentifier.split('?');
const [newPath, newQuery] = fileIdentifier.split('?');
const [newSessionId, newId] = newPath.split('/');
const newQueryParams = newQuery
? Object.fromEntries(new URLSearchParams(newQuery).entries())
: {};
sessions.set(newSessionId, true);
pushFile(newSessionId, newId);
pushFile(newSessionId, newId, newQueryParams.entity_id);
} catch (error) {
logger.error(
`Error re-uploading file ${id} in session ${session_id}: ${error.message}`,