Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8e3c81b
feat: Let Users Connect Their Own Account for Pull Request Lookups
berry-13 Oct 11, 2026
0bd4b1e
fix: Refuse Cross-Tenant Pull Request Callbacks and Surface Key Failures
berry-13 Oct 11, 2026
5448b3a
fix: Keep Pull Request Grants Through Outages and Remove Them on Disc…
berry-13 Oct 11, 2026
cedace6
fix: Keep the Pull Request OAuth App Out of Overrides
berry-13 Oct 11, 2026
9824568
fix: Read a User's Pull Request Token Only With the Feature On
berry-13 Oct 11, 2026
57fedc6
fix: Report a Malformed Stored Pull Request Grant as a Failure
berry-13 Oct 11, 2026
d65a790
fix: Remove a User's Stored Tokens When an Administrator Deletes the …
berry-13 Oct 11, 2026
c8434bb
fix: Answer No Pull Request for a Conversation Without a Lane When th…
berry-13 Oct 11, 2026
30b076d
fix: Share One Grant Read Between Concurrent Callers and Report an Ex…
berry-13 Oct 11, 2026
aa72c17
fix: Remove a Pull Request Grant Only on a Definite Refusal and Past …
berry-13 Oct 11, 2026
979eaa9
fix: Version Pull Request Grants and Coordinate Refresh, Refusal and …
berry-13 Oct 11, 2026
9bde387
fix: Invalidate a Refused User Token and Read It Only for a Lane That…
berry-13 Oct 11, 2026
b7bc5f5
fix: Finish a Pull Request Connection Under the User's Effective Conf…
berry-13 Oct 11, 2026
6c666e2
fix: Type the Pull Request User Token for Isolated Declarations and S…
berry-13 Oct 11, 2026
d4892f7
fix: Bind a Pull Request Connection Flow to the Generation a Disconne…
berry-13 Oct 12, 2026
7bea76c
fix: Start a Batch's User Token Read Only Within Its Deadline
berry-13 Oct 12, 2026
d1cf97e
fix: Fence Pull Request Token Reads Against a Disconnect and Report a…
berry-13 Oct 12, 2026
84fc44e
fix: Fence Connection Status Like Token Reads and Keep a Newer Read J…
berry-13 Oct 12, 2026
ce170b6
fix: Report a Pull Request Grant Without Its App and Name the Sign-In…
berry-13 Oct 12, 2026
33d7560
fix: Read a User's Unexpired Pull Request Grants Newest First
berry-13 Oct 12, 2026
960b34b
fix: Mark the Connection Callback as an OAuth Navigation and Report a…
berry-13 Oct 12, 2026
7008510
fix: Name the Pull Request Sign-In Flow on Its Landing Page
berry-13 Oct 12, 2026
ea402c6
fix: Remove a Pull Request Grant of a Replaced App When It Is Read
berry-13 Oct 12, 2026
6b6dd7f
fix: Keep a Replaced App's Grant Untouched and Extend Retention From …
berry-13 Oct 12, 2026
0d09dba
fix: Accept a Pull Request Grant Only Under the User's Current Connec…
berry-13 Oct 12, 2026
4cc1643
fix: Renew the Connection Generation With a Grant Still in Use
berry-13 Oct 12, 2026
d62defd
fix: Restore the Previous Connection When a Sign-In Cannot Store Its …
berry-13 Oct 12, 2026
38cac85
fix: Decide a Sign-In by What Was Stored When a Write Does Not Answer
berry-13 Oct 12, 2026
ecfbf66
fix: Roll Back a Sign-In's Generation Along With a Grant It Must Remove
berry-13 Oct 12, 2026
247b8d4
fix: Refresh an Access Token a Lookup Was Refused With Before Ending …
berry-13 Oct 12, 2026
d289d8d
fix: Undo a Sign-In Only for a Confirmed Fence, Not a Failed Check
berry-13 Oct 12, 2026
09a85c3
fix: Read a User Token Again When the Connection Changes Under the Read
berry-13 Oct 12, 2026
230eaf1
fix: Apply the Current Retention Again When Another Replica Changed t…
berry-13 Oct 12, 2026
1ed8ace
test: Answer Each Concurrent Sign-In by Its Own Code
berry-13 Oct 12, 2026
bdebfd5
fix: Skip Retention Rewrites for Grants That Expire Without a Refresh…
berry-13 Oct 12, 2026
ce13b85
fix: Retry a Token Rejection That Loses to a Concurrent Grant Write
berry-13 Oct 12, 2026
b270a66
fix: Skip User Token Reads for Lanes Without a Commit and Reread Stat…
berry-13 Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/server/experimental.js
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ if (cluster.isMaster) {
app.use('/api/search', routes.search);
app.use('/api/messages', routes.messages);
app.use('/api/convos', routes.convos);
app.use('/api/pull-requests', preAuthTenantMiddleware, routes.pullRequests);
app.use('/api/presets', routes.presets);
app.use('/api/projects', routes.projects);
app.use('/api/prompts', routes.prompts);
Expand Down
1 change: 1 addition & 0 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ const startServer = async () => {
app.use('/api/search', routes.search);
app.use('/api/messages', routes.messages);
app.use('/api/convos', routes.convos);
app.use('/api/pull-requests', preAuthTenantMiddleware, routes.pullRequests);
app.use('/api/traces', routes.traces);
app.use('/api/presets', routes.presets);
app.use('/api/projects', routes.projects);
Expand Down
1 change: 1 addition & 0 deletions api/server/routes/__test-utils__/convos-route-mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ module.exports = {
createProxyAwareFetch: jest.fn(() => jest.fn()),
createPullRequestLookup: jest.fn(() => jest.fn()),
createPullRequestLookupStore: jest.fn(() => undefined),
createPullRequestUserTokenFromEnv: jest.fn(() => undefined),
createConversationPullRequestsHandler: jest.fn(() => jest.fn()),
createConversationPullRequestHandler: jest.fn(
() => (_req, res) => res.status(200).json({ pullRequest: null }),
Expand Down
1 change: 1 addition & 0 deletions api/server/routes/admin/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const handlers = createAdminUsersHandlers({
deleteConfig: db.deleteConfig,
deleteAclEntries: db.deleteAclEntries,
deletePasskeysByUser: db.deletePasskeysByUser,
deleteTokens: db.deleteTokens,
});

router.use(requireJwtAuth, requireAdminAccess);
Expand Down
8 changes: 8 additions & 0 deletions api/server/routes/convos.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const {
createPullRequestLookupStore,
keyvRedisClient,
cacheConfig,
createPullRequestUserTokenFromEnv,
createConversationPullRequestsHandler,
backgroundTaskRegistry,
createSubagentThreadViewHandler,
Expand Down Expand Up @@ -180,17 +181,24 @@ const pullRequestLookup = createPullRequestLookup({
source: createPullRequestSource({ fetchFn: createProxyAwareFetch() }),
store: createPullRequestLookupStore({ client: keyvRedisClient, config: cacheConfig }),
});
const pullRequestUserToken = createPullRequestUserTokenFromEnv({
store: db,
fetchFn: createProxyAwareFetch(),
env: process.env,
});
const conversationPullRequestHandler = createConversationPullRequestHandler({
getConvoLaneGit: db.getConvoLaneGit,
getAppConfig,
lookup: pullRequestLookup,
env: process.env,
userToken: pullRequestUserToken,
});
const conversationPullRequestsHandler = createConversationPullRequestsHandler({
getConvosLaneGit: db.getConvosLaneGit,
getAppConfig,
lookup: pullRequestLookup,
env: process.env,
userToken: pullRequestUserToken,
});
router.use(requireJwtAuth);

Expand Down
2 changes: 2 additions & 0 deletions api/server/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const openapi = require('./openapi');
const search = require('./search');
const models = require('./models');
const convos = require('./convos');
const pullRequests = require('./pullRequests');
const traces = require('./traces');
const config = require('./config');
const agents = require('./agents');
Expand All @@ -45,6 +46,7 @@ const rum = require('./rum');
const insights = require('./insights');

module.exports = {
pullRequests,
insights,
rum,
mcp,
Expand Down
17 changes: 17 additions & 0 deletions api/server/routes/pullRequests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const { createProxyAwareFetch, createPullRequestConnectionRouter } = require('@librechat/api');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { markOAuthNavigation } = require('~/server/middleware/oauthNavigation');
const { loginLimiter } = require('~/server/middleware/limiters');
const { getAppConfig } = require('~/server/services/Config');
const db = require('~/models');

module.exports = createPullRequestConnectionRouter({
store: db,
fetchFn: createProxyAwareFetch(),
env: process.env,
getAppConfig,
getUserById: db.getUserById,
requireJwtAuth,
markNavigation: markOAuthNavigation,
callbackLimiter: loginLimiter,
});
13 changes: 13 additions & 0 deletions librechat.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,19 @@ endpoints:
# # host: "github.example.com"
# # Environment variable reference only, never the token itself. Optional, see above.
# token: "${GITHUB_PULL_REQUEST_TOKEN}"
# # Let each user connect their own account, so lookups use their token, rate limit and
# # repository access instead of the server's (read from this file only). Register
# # <DOMAIN_SERVER>/api/pull-requests/oauth/callback as the app's callback URL. On GitHub a
# # GitHub App with read-only Pull requests, Checks and Contents permissions is recommended.
# # Changing clientId, provider or host asks users to connect again; a changed scope only
# # applies to new connections. Disconnecting removes every grant of the user.
# # oauth:
# # clientId: "${GITHUB_PR_OAUTH_CLIENT_ID}"
# # clientSecret: "${GITHUB_PR_OAUTH_CLIENT_SECRET}"
# # scope: "repo" # only for a classic OAuth app; GitLab defaults to read_api
# # flowTimeoutMinutes: 10 # time to approve at the provider
# # requestTimeoutSeconds: 10 # per code exchange or token refresh
# # retentionDays: 180 # an unused connection is removed after this; each use extends it
# # Repositories the token may be used for, as owner/name or owner/*. A worker reports its
# # own repository, and only these are ever looked up with the server's token.
# allowedRepositories: ["LibreChat-AI/LibreChat"]
Expand Down
29 changes: 29 additions & 0 deletions packages/api/src/admin/users.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ function createDeps(overrides: Partial<AdminUsersDeps> = {}): AdminUsersDeps {
deleteConfig: jest.fn().mockResolvedValue(null),
deleteAclEntries: jest.fn().mockResolvedValue(undefined),
deletePasskeysByUser: jest.fn().mockResolvedValue(0),
deleteTokens: jest.fn().mockResolvedValue({ deletedCount: 0 }),
...overrides,
};
}
Expand Down Expand Up @@ -461,6 +462,34 @@ describe('createAdminUsersHandlers', () => {
expect(deps.deletePasskeysByUser).toHaveBeenCalledWith(validUserId);
});

it("removes the user's stored credentials before the user", async () => {
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res, status } = createReqRes({ params: { id: validUserId } });

await handlers.deleteUser(req, res);

expect(status).toHaveBeenCalledWith(200);
expect(deps.deleteTokens).toHaveBeenCalledWith({ userId: validUserId });
expect(jest.mocked(deps.deleteTokens).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(deps.deleteUserById).mock.invocationCallOrder[0],
);
});

it('keeps the user, and releases the fence, when its credentials cannot be removed', async () => {
const deps = createDeps({
deleteTokens: jest.fn().mockRejectedValue(new Error('mongo down')),
});
const handlers = createAdminUsersHandlers(deps);
const { req, res, status } = createReqRes({ params: { id: validUserId } });

await handlers.deleteUser(req, res);

expect(status).toHaveBeenCalledWith(500);
expect(deps.deleteUserById).not.toHaveBeenCalled();
expect(deps.cancelAgentTriggerUserDeletion).toHaveBeenCalled();
});

it('returns success even when cascade cleanup partially fails', async () => {
const result: UserDeleteResult = {
deletedCount: 1,
Expand Down
8 changes: 8 additions & 0 deletions packages/api/src/admin/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ export interface AdminUsersDeps {
* keep authentication material and can collide with a later registration.
*/
deletePasskeysByUser: (userId: string) => Promise<number>;
/**
* Stored credentials the user holds, such as a connected account's OAuth grant. Removed before
* the user is, as the self-delete flow does, so a failure leaves the account to retry rather
* than its credentials behind.
*/
deleteTokens: (query: { userId: string }) => Promise<unknown>;
}

export function createAdminUsersHandlers(deps: AdminUsersDeps): {
Expand All @@ -85,6 +91,7 @@ export function createAdminUsersHandlers(deps: AdminUsersDeps): {
deleteConfig,
deleteAclEntries,
deletePasskeysByUser,
deleteTokens,
} = deps;

async function listUsersHandler(req: ServerRequest, res: Response) {
Expand Down Expand Up @@ -202,6 +209,7 @@ export function createAdminUsersHandlers(deps: AdminUsersDeps): {
await prepareAgentTriggerUserPurge(id, triggerDeletionFence, targetUser?.tenantId);
await drainAgentTriggerDeliveriesForUser(id);

await deleteTokens({ userId: id });
const result = await deleteUserById(id);

if (result.deletedCount === 0) {
Expand Down
12 changes: 9 additions & 3 deletions packages/api/src/oauth/csrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,19 @@ function getOAuthCookiePath(cookiePath: string): string {
}
}

/** Sets a SameSite=Lax CSRF cookie bound to a specific OAuth flow */
export function setOAuthCsrfCookie(res: Response, flowId: string, cookiePath: string): void {
/** Sets a SameSite=Lax CSRF cookie bound to a specific OAuth flow. `maxAge` follows the flow's own
* approval window when it has one, so the cookie lives as long as the state it binds. */
export function setOAuthCsrfCookie(
res: Response,
flowId: string,
cookiePath: string,
maxAge: number = OAUTH_CSRF_MAX_AGE,
): void {
res.cookie(OAUTH_CSRF_COOKIE, generateOAuthCsrfToken(flowId), {
httpOnly: true,
secure: shouldUseSecureCookie(),
sameSite: 'lax',
maxAge: OAUTH_CSRF_MAX_AGE,
maxAge,
path: getOAuthCookiePath(cookiePath),
});
}
Expand Down
5 changes: 5 additions & 0 deletions packages/api/src/pulls/bitbucket.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,11 @@ describe('createBitbucketPullRequestSource', () => {
expect(urlsOf(fetchFn)[0]).not.toContain('"');
});

it('maps a 401 to CREDENTIAL_REJECTED, a refusal of the token itself', async () => {
const { source } = sourceFor({ 'state=OPEN': () => new Response('', { status: 401 }) });
await expect(find(source)).rejects.toMatchObject({ code: 'CREDENTIAL_REJECTED' });
});

it.each([
['another host', 'https://evil.example/w/r/pull-requests/7'],
['plain http', 'http://bitbucket.org/w/r/pull-requests/7'],
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/pulls/bitbucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ export function createBitbucketPullRequestSource({
}
/** A repository the token cannot see is indistinguishable from one without pull requests. */
if (response.status === 404 || absent.includes(response.status)) return null;
/** The provider no longer accepts the token: revoked, or expired without a refresh. */
if (response.status === 401) throw new PullRequestSourceError('CREDENTIAL_REJECTED');
throw new PullRequestSourceError('UPSTREAM_ERROR');
}

Expand Down
13 changes: 13 additions & 0 deletions packages/api/src/pulls/capabilities.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ describe('resolvePullRequestCapabilities', () => {
});
});

it('advertises the provider users can connect to, only when an app is configured', () => {
const oauth = { clientId: '${ID}', clientSecret: '${SECRET}' };
expect(
resolvePullRequestCapabilities(configWith({ provider: 'gitlab', oauth }), {
ID: 'i',
SECRET: 's',
}),
).toMatchObject({ pullRequestsEnabled: true, pullRequestsConnect: 'gitlab' });
expect(resolvePullRequestCapabilities(configWith(scoped), env)).not.toHaveProperty(
'pullRequestsConnect',
);
});

it('advertises the configured lookup limit', () => {
expect(
resolvePullRequestCapabilities(configWith({ ...scoped, maxConcurrentLookups: 2 }), env),
Expand Down
10 changes: 8 additions & 2 deletions packages/api/src/pulls/capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { EModelEndpoint, PULL_REQUEST_BATCH_VERSION } from 'librechat-data-provider';
import type { TAgentsEndpoint } from 'librechat-data-provider';
import type { PullRequestProvider, TAgentsEndpoint } from 'librechat-data-provider';
import type { AppConfig } from '@librechat/data-schemas';
import { isPullRequestFeatureActive } from './settings';
import { isPullRequestFeatureActive, resolvePullRequestOAuth } from './settings';

export type PullRequestCapabilities = {
pullRequestsEnabled: boolean;
Expand All @@ -10,6 +10,8 @@ export type PullRequestCapabilities = {
/** The lookups one batch runs at once, so a client that falls back to the single route can
* keep to the same limit while a replica without the batch route is still serving it. */
pullRequestsMaxConcurrentLookups?: number;
/** The provider users can connect their own account to, when the operator configured one. */
pullRequestsConnect?: PullRequestProvider;
};

/**
Expand All @@ -23,12 +25,16 @@ export function resolvePullRequestCapabilities(
): PullRequestCapabilities {
const agents = appConfig?.endpoints?.[EModelEndpoint.agents] as TAgentsEndpoint | undefined;
const enabled = isPullRequestFeatureActive(agents?.pullRequests, env);
const connect = enabled
? resolvePullRequestOAuth(agents?.pullRequests, env)?.provider
Comment thread
berry-13 marked this conversation as resolved.
: undefined;
return {
pullRequestsEnabled: enabled,
Comment thread
berry-13 marked this conversation as resolved.
...(enabled
? {
pullRequestsBatchVersion: PULL_REQUEST_BATCH_VERSION,
pullRequestsMaxConcurrentLookups: agents?.pullRequests?.maxConcurrentLookups ?? 4,
...(connect != null ? { pullRequestsConnect: connect } : {}),
Comment thread
berry-13 marked this conversation as resolved.
Comment thread
berry-13 marked this conversation as resolved.
Comment thread
berry-13 marked this conversation as resolved.
}
: {}),
};
Expand Down
Loading
Loading