LibreChat/api/server/controllers/agents/steer.js
Danny Avila 6adce5a12a refactor: make escalation one atomic server-side arm, in place
Codex round 4: four P2s, every one an interleaving of the same window —
escalation as reclaim-then-repost is a compound, non-atomic operation
whose continuation must revalidate the world (FIFO position lost, ref
assigned too late, no run fence, competing bubble actions). Rounds 1-3
patched that window with a lock and rechecks; round 4 shows the window
itself is the defect, so this removes it instead of guarding it again.

Escalation is now POST /chat/steer/arm: the server flips preempt on the
EXISTING queued item in one atomic store op (new IJobStore.armSteer; a
decode-patch-encode LSET Lua on Redis, an in-place mutation in memory),
fenced to the validated generation and refused once the queue closes.
The handler mirrors the steer POST's preempt contract exactly: durable
flag gated on the owner's recorded capability, volatile requestPreempt
fire-and-forget because the durable flag is the truth resume/handover
re-arm from.

By construction this resolves all four findings: FIFO survives (the
item never moves; the whole queue still drains in instruction order at
the seal), there is no continuation to hold stale controls, the store
op is fenced to the original run, and a competing Edit/Queue/Cancel
either beats the arm (armed:false, chip untouched) or operates on the
armed item, whose cancel already disarms.

The client escalation entry becomes one mutation: armed:true relabels
the chip in place (same steerId, same position), PREEMPT_UNSUPPORTED
and lost races toast honestly, and the round 1-3 machinery — the
escalating lock atom, the latest-ref, the post-reclaim rechecks and
their two toast strings — is deleted rather than extended.

Verified: 7 new handler tests on the real in-memory manager (including
FIFO preservation and the stale-generation fence), 2 Redis integration
tests against real Redis (in-place arm keeps order and every field;
missing/stale/closed all refuse), client suites 396 green.
2026-07-30 17:54:07 -04:00

136 lines
4.7 KiB
JavaScript

const {
checkAccess,
handleSteerRequest,
handleSteerCancel,
handleSteerArm,
} = require('@librechat/api');
const { logger, ResourceCapabilityMap } = require('@librechat/data-schemas');
const {
Permissions,
ResourceType,
PermissionBits,
PermissionTypes,
isAgentsEndpoint,
isEphemeralAgentId,
} = require('librechat-data-provider');
const { checkPermission } = require('~/server/services/PermissionService');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const db = require('~/models');
/**
* Steer-time agent authorization, mirroring the chat route's middlewares
* (`checkAgentAccess` + `canAccessAgentFromBody`) against the ORIGINATING
* run's identity from job metadata instead of the request body:
* - role gate: AGENTS:USE via `checkAccess`, applied exactly when chat.js
* would run it (`skipAgentCheck` skips non-agents endpoints);
* - resource gate: `canAccessResource`'s capability bypass + `checkPermission`
* VIEW on the resolved agent, skipped for ephemeral/no-agent runs.
*
* @param {import('express').Request} req
* @returns {(run: import('@librechat/api').SteerRunContext) => Promise<boolean>}
*/
const createAgentAccessCheck =
(req) =>
async ({ agentId, endpoint }) => {
const hasRealAgent = agentId != null && !isEphemeralAgentId(agentId);
const roleGateApplies = endpoint == null ? hasRealAgent : isAgentsEndpoint(endpoint);
if (roleGateApplies) {
const roleAllowed = await checkAccess({
req,
user: req.user,
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName: db.getRoleByName,
});
if (!roleAllowed) {
return false;
}
}
if (!hasRealAgent) {
return true;
}
let bypass = false;
try {
bypass = await hasCapability(req.user, ResourceCapabilityMap[ResourceType.AGENT]);
} catch {
bypass = false;
}
if (bypass) {
return true;
}
const agent = await db.getAgent({ id: agentId });
if (!agent) {
return false;
}
return checkPermission({
userId: req.user.id,
role: req.user.role,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
requiredPermission: PermissionBits.VIEW,
});
};
/**
* POST /api/agents/chat/steer
*
* Thin wrapper: the full guard ladder (validation, file sanitization,
* capability gate, ownership/tenant checks, agent access, owner-scoped file
* resolve, status-guarded enqueue) lives in `@librechat/api`
* (`handleSteerRequest`), which returns the HTTP status + JSON body to
* serialize verbatim. DB access and permission services are injected here.
*/
const SteerController = async (req, res) => {
try {
const { status, body } = await handleSteerRequest(req.user ?? {}, req.body ?? {}, {
getFiles: db.getFiles,
updateFilesUsage: db.updateFilesUsage,
checkAgentAccess: createAgentAccessCheck(req),
});
return res.status(status).json(body);
} catch (error) {
logger.error('[SteerController] Failed to queue steer', error);
return res.status(500).json({ code: 'STEER_FAILED' });
}
};
/**
* POST /api/agents/chat/steer/cancel
*
* Removes a still-queued steer before injection. `removed: false` is not an
* error — the cancel lost its race (already injected, or the run ended) and
* the client defers to the events it will receive. No agent-access check:
* a cancel injects nothing model-bound, so ownership checks suffice.
*/
const SteerCancelController = async (req, res) => {
try {
const { status, body } = await handleSteerCancel(req.user ?? {}, req.body ?? {});
return res.status(status).json(body);
} catch (error) {
logger.error('[SteerCancelController] Failed to cancel steer', error);
return res.status(500).json({ code: 'STEER_CANCEL_FAILED' });
}
};
/**
* POST /api/agents/chat/steer/arm
*
* Escalates a still-queued steer to an interrupt in place (the durable item
* keeps its FIFO position). `armed: false` is not an error — the steer
* already injected, was cancelled, or the deployment cannot seal mid-stream.
* No agent-access check: arming injects nothing model-bound, so ownership
* checks suffice, exactly like cancel.
*/
const SteerArmController = async (req, res) => {
try {
const { status, body } = await handleSteerArm(req.user ?? {}, req.body ?? {});
return res.status(status).json(body);
} catch (error) {
logger.error('[SteerArmController] Failed to arm steer', error);
return res.status(500).json({ code: 'STEER_ARM_FAILED' });
}
};
module.exports = SteerController;
module.exports.SteerCancelController = SteerCancelController;
module.exports.SteerArmController = SteerArmController;