Compare commits

...
Author SHA1 Message Date
Hanzo AI bb4d6dbe77 fix(chat): treat missing agent_id as ephemeral so 'hi' just replies
In the agents-centric chat UI ('My Agents'), sending a message with no agent
created/selected POSTed to the agents completion endpoint without an agent_id,
which canAccessAgentFromBody rejected with 400 'agent_id is required in request
body'. A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat,
not an error — isEphemeralAgentId(undefined) is already true.

- canAccessAgentFromBody: drop the 400 branch; a missing id falls through the
  existing ephemeral path (no per-agent ACL) to plain chat.
- agents/build.buildOptions: resolve a missing agent_id to EPHEMERAL_AGENT_ID so
  loadAgent builds an ephemeral plain-model agent instead of returning null.
- Hermetic regression specs for both fix points (no Mongo/winston); update the
  stale middleware test that codified the 400.

Logged-in user types 'hi' -> ephemeral agent on the configured default model ->
reply, with no agent build required first.
2026-06-29 21:58:59 -07:00
5 changed files with 148 additions and 13 deletions
@@ -0,0 +1,81 @@
/**
* Regression: a logged-in user in "My Agents" with no agent created/selected
* could type "hi" and get `400 {message:'agent_id is required in request body'}`.
*
* A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat, not an
* error. This hermetic test locks the access-middleware fix point (no MongoDB /
* winston): canAccessAgentFromBody skips the per-agent ACL and calls next().
* The downstream build-options fix point is covered by
* ../../services/Endpoints/agents/build.spec.js.
*/
// Mock externals so neither winston (data-schemas) nor MongoDB (~/models/Agent) load.
jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn(), debug: jest.fn() } }));
jest.mock('librechat-data-provider', () => ({
Constants: { EPHEMERAL_AGENT_ID: 'ephemeral' },
ResourceType: { AGENT: 'agent' },
// Real predicate semantics (mirrors packages/data-provider/src):
isAgentsEndpoint: (endpoint) => endpoint === 'agents',
isEphemeralAgentId: (agentId) => !agentId?.startsWith?.('agent_'),
}));
const mockCanAccessResource = jest.fn(() => (req, res, next) => {
req.__aclChecked = true;
return next();
});
jest.mock('./canAccessResource', () => ({ canAccessResource: mockCanAccessResource }));
jest.mock('~/models/Agent', () => ({ getAgent: jest.fn() }));
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
const makeReqRes = (body) => {
const req = { body, params: {} };
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
const next = jest.fn();
return { req, res, next };
};
describe('agent_id missing → ephemeral fallback (no 400)', () => {
const mw = canAccessAgentFromBody({ requiredPermission: 1 });
test('agents endpoint with NO agent_id proceeds without a 400', async () => {
const { req, res, next } = makeReqRes({ endpoint: 'agents', text: 'hi' });
await mw(req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
expect(mockCanAccessResource).not.toHaveBeenCalled(); // no per-agent ACL for ephemeral
});
test('non-agents endpoint with no agent_id proceeds (ephemeral)', async () => {
const { req, res, next } = makeReqRes({ endpoint: 'openAI', text: 'hi' });
await mw(req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});
test('explicit ephemeral id proceeds', async () => {
const { req, res, next } = makeReqRes({ endpoint: 'agents', agent_id: 'ephemeral_primary' });
await mw(req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});
test('a real agent_id still runs the per-agent ACL check', async () => {
const { req, res, next } = makeReqRes({ endpoint: 'agents', agent_id: 'agent_abc123' });
await mw(req, res, next);
// The middleware builds the ACL check with the real agent id as resourceIdParam,
// then runs it (which here calls next via the mock).
expect(mockCanAccessResource).toHaveBeenCalledTimes(1);
expect(mockCanAccessResource.mock.calls[0][0]).toMatchObject({ resourceType: 'agent' });
expect(next).toHaveBeenCalledTimes(1);
});
});
@@ -60,15 +60,11 @@ const canAccessAgentFromBody = (options) => {
agentId = Constants.EPHEMERAL_AGENT_ID;
}
if (!agentId) {
return res.status(400).json({
error: 'Bad Request',
message: 'agent_id is required in request body',
});
}
// Skip permission checks for ephemeral agents
// Real agent IDs always start with "agent_", so anything else is ephemeral
// A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat,
// not an error: "type a message and get a reply" must work before the user
// has created or selected an agent. Ephemeral agents carry no per-agent ACL,
// so skip the permission check and fall through to plain chat. A missing id
// is ephemeral by the canonical predicate (isEphemeralAgentId(undefined) === true).
if (isEphemeralAgentId(agentId)) {
return next();
}
@@ -104,13 +104,15 @@ describe('canAccessAgentFromBody middleware', () => {
});
describe('primary agent checks', () => {
test('returns 400 when agent_id is missing on agents endpoint', async () => {
test('proceeds (ephemeral fallback) when agent_id is missing on agents endpoint', async () => {
// No agent created/selected yet → ad-hoc chat. "Type hi and get a reply"
// must work out of the box; a missing id is ephemeral, not a 400.
req.body.agent_id = undefined;
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('proceeds for ephemeral primary agent without addedConvo', async () => {
@@ -7,7 +7,10 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
const agentPromise = loadAgent({
req,
spec,
agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID,
// No agent selected on the agents endpoint → ad-hoc (ephemeral) chat, so the
// user gets a reply without first creating/selecting an agent. Mirrors the
// access middleware, which treats a missing id as ephemeral.
agent_id: isAgentsEndpoint(endpoint) && agent_id ? agent_id : Constants.EPHEMERAL_AGENT_ID,
endpoint,
model_parameters,
}).catch((error) => {
@@ -0,0 +1,53 @@
/**
* Regression: when no agent is created/selected, the agents endpoint must resolve
* to an ephemeral (ad-hoc, plain-model) agent so the user gets a reply — instead
* of loadAgent returning null because agent_id was missing.
*
* Hermetic: mocks data-schemas (no winston) and ~/models/Agent (no MongoDB).
*/
jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn() } }));
jest.mock('librechat-data-provider', () => ({
Constants: { EPHEMERAL_AGENT_ID: 'ephemeral' },
isAgentsEndpoint: (endpoint) => endpoint === 'agents',
removeNullishValues: (obj) =>
Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null)),
}));
const mockLoadAgent = jest.fn(() => Promise.resolve({ id: 'ephemeral', model: 'zen3-nano' }));
jest.mock('~/models/Agent', () => ({ loadAgent: mockLoadAgent }));
const { buildOptions } = require('./build');
const EPHEMERAL = 'ephemeral';
describe('agents buildOptions — missing agent_id resolves to ephemeral', () => {
beforeEach(() => mockLoadAgent.mockClear());
test('no agent_id on agents endpoint → loadAgent called with EPHEMERAL_AGENT_ID', async () => {
const req = { body: {}, user: { id: 'u1' } };
// parsedBody as built from "My Agents" with no agent selected (no agent_id).
const options = buildOptions(req, 'agents', { model: 'zen3-nano' });
await options.agent;
expect(mockLoadAgent).toHaveBeenCalledTimes(1);
expect(mockLoadAgent.mock.calls[0][0].agent_id).toBe(EPHEMERAL);
});
test('real agent_id on agents endpoint is passed through unchanged', async () => {
const req = { body: {}, user: { id: 'u1' } };
const options = buildOptions(req, 'agents', { model: 'zen3-nano', agent_id: 'agent_real_42' });
await options.agent;
expect(mockLoadAgent.mock.calls[0][0].agent_id).toBe('agent_real_42');
});
test('non-agents endpoint always resolves to ephemeral', async () => {
const req = { body: {}, user: { id: 'u1' } };
const options = buildOptions(req, 'openAI', { model: 'zen3-nano', agent_id: 'agent_real_42' });
await options.agent;
expect(mockLoadAgent.mock.calls[0][0].agent_id).toBe(EPHEMERAL);
});
});