From 0086ecb321826af0a24e958692f00dedee540323 Mon Sep 17 00:00:00 2001 From: abhishekp-bruno Date: Wed, 24 Jun 2026 15:03:19 +0530 Subject: [PATCH 01/23] fix(oauth2): prevent code injection in OAuth2 callback handling --- .../authorize-user-in-system-browser.js | 4 +-- .../ipc/network/authorize-user-in-window.js | 18 ++++++++++++- .../src/utils/oauth2-protocol-handler.js | 20 +++++++++++++- packages/bruno-electron/src/utils/oauth2.js | 27 ++++++++++++++++--- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/packages/bruno-electron/src/ipc/network/authorize-user-in-system-browser.js b/packages/bruno-electron/src/ipc/network/authorize-user-in-system-browser.js index 23705b827d1..d9923e19c79 100644 --- a/packages/bruno-electron/src/ipc/network/authorize-user-in-system-browser.js +++ b/packages/bruno-electron/src/ipc/network/authorize-user-in-system-browser.js @@ -1,7 +1,7 @@ const { shell } = require('electron'); const { registerOauth2AuthorizationRequest, rejectOauth2AuthorizationRequest } = require('../../utils/oauth2-protocol-handler'); -const authorizeUserInSystemBrowser = ({ authorizeUrl, callbackUrl, grantType = 'authorization_code' }) => { +const authorizeUserInSystemBrowser = ({ authorizeUrl, callbackUrl, grantType = 'authorization_code', expectedState = null }) => { return new Promise((resolve, reject) => { // Replace callback URL in authorization URL const authorizationUrlObj = new URL(authorizeUrl); @@ -51,7 +51,7 @@ const authorizeUserInSystemBrowser = ({ authorizeUrl, callbackUrl, grantType = ' reject(error); }; - registerOauth2AuthorizationRequest(wrappedResolve, wrappedReject, debugInfo); + registerOauth2AuthorizationRequest(wrappedResolve, wrappedReject, debugInfo, expectedState); // Open system browser shell.openExternal(modifiedAuthorizeUrl).catch((error) => { diff --git a/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js b/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js index e8ecf994b7d..172d36627a1 100644 --- a/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js +++ b/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js @@ -11,7 +11,7 @@ const matchesCallbackUrl = (url, callbackUrl) => { && (url.searchParams.has('code') || url.hash.length > 1); }; -const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session, additionalHeaders = {}, grantType = 'authorization_code' }) => { +const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session, additionalHeaders = {}, grantType = 'authorization_code', expectedState = null }) => { return new Promise(async (resolve, reject) => { let finalUrl = null; let debugInfo = { @@ -202,6 +202,22 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session, additionalH if (finalUrl) { try { + // Validate the state parameter to protect against CSRF / authorization + // code injection. The returned state must match the cryptographically + // random state issued when the flow was initiated. + if (expectedState) { + const finalUrlObj = new URL(finalUrl); + const returnedState + = finalUrlObj.searchParams.get('state') + || (finalUrlObj.hash ? new URLSearchParams(finalUrlObj.hash.substring(1)).get('state') : null); + + if (returnedState !== expectedState) { + return reject( + new Error('OAuth2 state mismatch: the returned state does not match the issued state. Aborting to prevent authorization code injection.') + ); + } + } + // Handle different grant types differently if (grantType === 'implicit') { // For implicit flow, tokens are in the URL hash fragment diff --git a/packages/bruno-electron/src/utils/oauth2-protocol-handler.js b/packages/bruno-electron/src/utils/oauth2-protocol-handler.js index 3d998e1bfb9..9557fbd3165 100644 --- a/packages/bruno-electron/src/utils/oauth2-protocol-handler.js +++ b/packages/bruno-electron/src/utils/oauth2-protocol-handler.js @@ -1,6 +1,6 @@ let oauth2AuthorizationRequest = null; -const registerOauth2AuthorizationRequest = (resolve, reject, debugInfo = null) => { +const registerOauth2AuthorizationRequest = (resolve, reject, debugInfo = null, expectedState = null) => { // Cancel any existing pending request if (oauth2AuthorizationRequest) { oauth2AuthorizationRequest.reject(new Error('Authorization cancelled: new request started')); @@ -10,6 +10,7 @@ const registerOauth2AuthorizationRequest = (resolve, reject, debugInfo = null) = resolve, reject, debugInfo, + expectedState, timestamp: Date.now() }; }; @@ -80,6 +81,23 @@ const handleOauth2ProtocolUrl = (url) => { return; } + // Validate the state parameter to protect against CSRF / authorization code + // injection. The returned state must match the cryptographically random state + // issued when the flow was initiated. + const expectedState = oauth2AuthorizationRequest?.expectedState; + if (expectedState) { + const returnedState + = urlObj.searchParams.get('state') + || (urlObj.hash ? new URLSearchParams(urlObj.hash.substring(1)).get('state') : null); + + if (returnedState !== expectedState) { + rejectOauth2AuthorizationRequest( + new Error('OAuth2 state mismatch: the returned state does not match the issued state. Aborting to prevent authorization code injection.') + ); + return; + } + } + // Check if this is an implicit grant (tokens in hash fragment) if (urlObj.hash) { const hash = urlObj.hash.substring(1); // Remove the leading # diff --git a/packages/bruno-electron/src/utils/oauth2.js b/packages/bruno-electron/src/utils/oauth2.js index 6af08a41b67..f44b74b668a 100644 --- a/packages/bruno-electron/src/utils/oauth2.js +++ b/packages/bruno-electron/src/utils/oauth2.js @@ -308,6 +308,10 @@ const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => { const { callbackUrl, clientId, authorizationUrl, scope, state, pkce, accessTokenUrl, additionalParameters } = oauth2; const useSystemBrowser = preferencesUtil.shouldUseSystemBrowser(); const effectiveCallbackUrl = callbackUrl && callbackUrl.length ? callbackUrl : BRUNO_OAUTH2_CALLBACK_URL; + // Always append a cryptographically random nonce to the user-configured state + // (or generate a fully random one when none is set). The state is validated when + // the callback is received to prevent authorization code injection / CSRF. + const effectiveState = generateState({ userState: state }); const authorizationUrlWithQueryParams = new URL(authorizationUrl); authorizationUrlWithQueryParams.searchParams.append('response_type', 'code'); @@ -324,8 +328,8 @@ const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => { authorizationUrlWithQueryParams.searchParams.append('code_challenge', codeChallenge); authorizationUrlWithQueryParams.searchParams.append('code_challenge_method', 'S256'); } - if (state) { - authorizationUrlWithQueryParams.searchParams.append('state', state); + if (effectiveState) { + authorizationUrlWithQueryParams.searchParams.append('state', effectiveState); } if (additionalParameters?.authorization?.length) { additionalParameters.authorization.forEach((param) => { @@ -344,6 +348,7 @@ const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => { authorizeUrl, callbackUrl: effectiveCallbackUrl, session: oauth2Store.getSessionIdOfCollection({ collectionUid, url: accessTokenUrl }), + expectedState: effectiveState, additionalHeaders: getAdditionalHeaders(additionalParameters?.authorization) }); resolve({ authorizationCode, debugInfo }); @@ -707,6 +712,16 @@ const generateCodeVerifier = () => { return crypto.randomBytes(22).toString('hex'); }; +// Generate a cryptographically random state value used to protect OAuth2 +// authorization flows against CSRF / authorization code injection. +const generateState = ({ userState }) => { + let cryptographicallyRandomString = crypto.randomBytes(16).toString('hex'); + if (userState && userState.length) { + return userState + cryptographicallyRandomString; + } + return cryptographicallyRandomString; +}; + const generateCodeChallenge = (codeVerifier) => { const hash = crypto.createHash('sha256'); hash.update(codeVerifier); @@ -760,6 +775,9 @@ const getOAuth2TokenUsingImplicitGrant = async ({ request, collectionUid, forceF } = oauth2; const useSystemBrowser = preferencesUtil.shouldUseSystemBrowser(); const effectiveCallbackUrl = callbackUrl && callbackUrl.length ? callbackUrl : BRUNO_OAUTH2_CALLBACK_URL; + // Use the user-configured state if present, otherwise generate a cryptographically + // random one. The state is validated when the callback is received to prevent CSRF. + const effectiveState = generateState({ userState: state }); // Validate required fields if (!authorizationUrl) { @@ -844,8 +862,8 @@ const getOAuth2TokenUsingImplicitGrant = async ({ request, collectionUid, forceF if (scope) { authorizationUrlWithQueryParams.searchParams.append('scope', scope); } - if (state) { - authorizationUrlWithQueryParams.searchParams.append('state', state); + if (effectiveState) { + authorizationUrlWithQueryParams.searchParams.append('state', effectiveState); } if (additionalParameters?.authorization?.length) { additionalParameters.authorization.forEach((param) => { @@ -866,6 +884,7 @@ const getOAuth2TokenUsingImplicitGrant = async ({ request, collectionUid, forceF callbackUrl: effectiveCallbackUrl, session: oauth2Store.getSessionIdOfCollection({ collectionUid, url: authorizationUrl }), grantType: 'implicit', + expectedState: effectiveState, additionalHeaders: getAdditionalHeaders(additionalParameters?.authorization) }); From 52d82ae4c61f794486230f7f4d7b786e6d7899c4 Mon Sep 17 00:00:00 2001 From: abhishekp-bruno Date: Wed, 24 Jun 2026 16:42:16 +0530 Subject: [PATCH 02/23] ADD(test-case):oauth2-test-cases-for-state-vulnerability --- packages/bruno-electron/src/utils/oauth2.js | 1 + .../utils/oauth2-protocol-handler.spec.js | 109 ++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js diff --git a/packages/bruno-electron/src/utils/oauth2.js b/packages/bruno-electron/src/utils/oauth2.js index f44b74b668a..d54a691379e 100644 --- a/packages/bruno-electron/src/utils/oauth2.js +++ b/packages/bruno-electron/src/utils/oauth2.js @@ -973,5 +973,6 @@ module.exports = { refreshOauth2Token, generateCodeVerifier, generateCodeChallenge, + generateState, updateCollectionOauth2Credentials }; diff --git a/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js b/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js new file mode 100644 index 00000000000..aa3f9ec8f3f --- /dev/null +++ b/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js @@ -0,0 +1,109 @@ +const { + registerOauth2AuthorizationRequest, + handleOauth2ProtocolUrl, + cancelOAuth2AuthorizationRequest, + isOauth2AuthorizationRequestInProgress +} = require('../../src/utils/oauth2-protocol-handler'); + +describe('handleOauth2ProtocolUrl - state validation', () => { + let resolve; + let reject; + + beforeEach(() => { + resolve = jest.fn(); + reject = jest.fn(); + }); + + afterEach(() => { + // Clear any pending request between tests + if (isOauth2AuthorizationRequestInProgress()) { + cancelOAuth2AuthorizationRequest(); + } + jest.clearAllMocks(); + }); + + describe('authorization code flow (state in query params)', () => { + it('should resolve with the code when the returned state matches', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl('bruno://oauth2/callback?code=auth-code-123&state=expected-state'); + + expect(resolve).toHaveBeenCalledWith('auth-code-123'); + expect(reject).not.toHaveBeenCalled(); + }); + + it('should reject when the returned state does not match', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl('bruno://oauth2/callback?code=auth-code-123&state=attacker-state'); + + expect(resolve).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('state mismatch') }) + ); + }); + + it('should reject when no state is returned but one was expected', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl('bruno://oauth2/callback?code=auth-code-123'); + + expect(resolve).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('state mismatch') }) + ); + }); + }); + + describe('implicit flow (state in hash fragment)', () => { + it('should resolve with tokens when the returned state matches', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl( + 'bruno://oauth2/callback#access_token=token-abc&token_type=bearer&state=expected-state' + ); + + expect(resolve).toHaveBeenCalledWith( + expect.objectContaining({ access_token: 'token-abc' }) + ); + expect(reject).not.toHaveBeenCalled(); + }); + + it('should reject when the hash state does not match', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl( + 'bruno://oauth2/callback#access_token=token-abc&token_type=bearer&state=attacker-state' + ); + + expect(resolve).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('state mismatch') }) + ); + }); + }); + + describe('when no expected state was registered (backward compatibility)', () => { + it('should resolve without validating state', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, null); + + handleOauth2ProtocolUrl('bruno://oauth2/callback?code=auth-code-123'); + + expect(resolve).toHaveBeenCalledWith('auth-code-123'); + expect(reject).not.toHaveBeenCalled(); + }); + }); + + describe('error responses are handled before state validation', () => { + it('should reject with the provider error even if state is absent', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl('bruno://oauth2/callback?error=access_denied'); + + expect(resolve).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Authorization Failed') }) + ); + }); + }); +}); From 3ec9505f01505d6888182e68dcdd73f024a19b76 Mon Sep 17 00:00:00 2001 From: abhishekp-bruno Date: Mon, 29 Jun 2026 08:49:02 +0530 Subject: [PATCH 03/23] ADD OAuth test cases --- .../Auth/OAuth2/Oauth2ActionButtons/index.js | 31 ++- .../collection/Authorization Code.bru | 30 ++ .../oauth2/fixtures/collection/Implicit.bru | 24 ++ .../oauth2/fixtures/collection/bruno.json | 5 + .../collection/environments/Local.bru | 3 + .../oauth2/init-user-data/preferences.json | 15 + .../oauth2/oauth2-state-validation.spec.ts | 260 ++++++++++++++++++ 7 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 tests/auth/oauth2/fixtures/collection/Authorization Code.bru create mode 100644 tests/auth/oauth2/fixtures/collection/Implicit.bru create mode 100644 tests/auth/oauth2/fixtures/collection/bruno.json create mode 100644 tests/auth/oauth2/fixtures/collection/environments/Local.bru create mode 100644 tests/auth/oauth2/init-user-data/preferences.json create mode 100644 tests/auth/oauth2/oauth2-state-validation.spec.ts diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js index 1591a5be6a8..4cd209160d9 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js @@ -5,6 +5,8 @@ import { cloneDeep, find, get } from 'lodash'; import { IconLoader2, IconX } from '@tabler/icons'; import { interpolate } from '@usebruno/common'; import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials, cancelOauth2AuthorizationRequest, isOauth2AuthorizationRequestInProgress } from 'providers/ReduxStore/slices/collections/actions'; +import { responseReceived } from 'providers/ReduxStore/slices/collections'; +import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs'; import { getAllVariables } from 'utils/collections/index'; import Button from 'ui/Button'; @@ -41,6 +43,25 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c const credentialsData = find(collection?.oauth2Credentials, (creds) => creds?.url == interpolatedAccessTokenUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId); const creds = credentialsData?.credentials || {}; + const showOauth2Error = (errorMessage) => { + dispatch( + responseReceived({ + itemUid: item.uid, + collectionUid, + response: { + error: errorMessage, + status: null, + headers: {}, + data: null, + dataBuffer: null, + size: 0, + duration: 0 + } + }) + ); + dispatch(updateResponsePaneTab({ uid: item.uid, responsePaneTab: 'response' })); + }; + const handleFetchOauth2Credentials = async () => { let requestCopy = cloneDeep(request); requestCopy.oauth2 = requestCopy?.auth.oauth2; @@ -59,6 +80,7 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c const errorMessage = result?.error || 'No access token received from authorization server'; console.error(errorMessage); toast.error(errorMessage); + showOauth2Error(errorMessage); return; } @@ -70,7 +92,9 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c if (error?.message && error.message.includes('cancelled by user')) { return; } - toast.error(error?.message || 'An error occurred while fetching token!'); + const errorMessage = error?.message || 'An error occurred while fetching token!'; + toast.error(errorMessage); + showOauth2Error(errorMessage); } finally { toggleFetchingToken(false); toggleFetchingAuthorizationCode(false); @@ -97,6 +121,7 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c const errorMessage = result?.error || 'No access token received from authorization server'; console.error(errorMessage); toast.error(errorMessage); + showOauth2Error(errorMessage); return; } @@ -104,7 +129,9 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c } catch (error) { console.error(error); toggleRefreshingToken(false); - toast.error(error?.message || 'An error occurred while refreshing token!'); + const errorMessage = error?.message || 'An error occurred while refreshing token!'; + toast.error(errorMessage); + showOauth2Error(errorMessage); } }; diff --git a/tests/auth/oauth2/fixtures/collection/Authorization Code.bru b/tests/auth/oauth2/fixtures/collection/Authorization Code.bru new file mode 100644 index 00000000000..6f23e605101 --- /dev/null +++ b/tests/auth/oauth2/fixtures/collection/Authorization Code.bru @@ -0,0 +1,30 @@ +meta { + name: Authorization Code + type: http + seq: 1 +} + +get { + url: {{localhost}}/ping + body: none + auth: oauth2 +} + +auth:oauth2 { + grant_type: authorization_code + callback_url: bruno://app/oauth2/callback + authorization_url: {{localhost}}/api/auth/oauth2/authorization_code/authorize + access_token_url: {{localhost}}/api/auth/oauth2/authorization_code/token + refresh_token_url: + client_id: client_id_1 + client_secret: client_secret_1 + scope: read + state: + pkce: false + credentials_placement: body + credentials_id: credentials + token_placement: header + token_header_prefix: Bearer + auto_fetch_token: true + auto_refresh_token: false +} diff --git a/tests/auth/oauth2/fixtures/collection/Implicit.bru b/tests/auth/oauth2/fixtures/collection/Implicit.bru new file mode 100644 index 00000000000..e8cdb4aa8eb --- /dev/null +++ b/tests/auth/oauth2/fixtures/collection/Implicit.bru @@ -0,0 +1,24 @@ +meta { + name: Implicit + type: http + seq: 2 +} + +get { + url: {{localhost}}/ping + body: none + auth: oauth2 +} + +auth:oauth2 { + grant_type: implicit + callback_url: bruno://app/oauth2/callback + authorization_url: {{localhost}}/api/auth/oauth2/authorization_code/authorize + client_id: client_id_1 + scope: read + state: + credentials_id: credentials + token_placement: header + token_header_prefix: Bearer + auto_fetch_token: true +} diff --git a/tests/auth/oauth2/fixtures/collection/bruno.json b/tests/auth/oauth2/fixtures/collection/bruno.json new file mode 100644 index 00000000000..c2925567f67 --- /dev/null +++ b/tests/auth/oauth2/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "oauth2-state", + "type": "collection" +} diff --git a/tests/auth/oauth2/fixtures/collection/environments/Local.bru b/tests/auth/oauth2/fixtures/collection/environments/Local.bru new file mode 100644 index 00000000000..5d116f2effa --- /dev/null +++ b/tests/auth/oauth2/fixtures/collection/environments/Local.bru @@ -0,0 +1,3 @@ +vars { + localhost: http://localhost:8081 +} diff --git a/tests/auth/oauth2/init-user-data/preferences.json b/tests/auth/oauth2/init-user-data/preferences.json new file mode 100644 index 00000000000..9c8a07b2067 --- /dev/null +++ b/tests/auth/oauth2/init-user-data/preferences.json @@ -0,0 +1,15 @@ +{ + "maximized": false, + "lastOpenedCollections": ["{{collectionPath}}"], + "preferences": { + "request": { + "oauth2": { + "useSystemBrowser": true + } + }, + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} diff --git a/tests/auth/oauth2/oauth2-state-validation.spec.ts b/tests/auth/oauth2/oauth2-state-validation.spec.ts new file mode 100644 index 00000000000..635970c43ce --- /dev/null +++ b/tests/auth/oauth2/oauth2-state-validation.spec.ts @@ -0,0 +1,260 @@ +/** + * E2E tests for OAuth2 callback `state` validation (authorization code + implicit grant). + * + * Bruno issues `state` on the authorization URL when the flow starts. The callback must + * return that same `state`. These tests compare issued vs returned `state` — the + * authorization code / access token values are arbitrary provider payloads. + * + * `shell.openExternal` is stubbed so no real browser opens; the authorization URL is + * captured to obtain the issued state. Callbacks are delivered via the real + * `second-instance` protocol handler — the same path the OS uses for + * `bruno://app/oauth2/callback` deep links. A wrapped `second-instance` listener + * records the URL Bruno actually receives. + * + * Oracles: + * - authorization code: callback URI + error/success toast + bruno-tests token endpoint + * - implicit grant: callback URI + error/success toast (no token exchange POST) + * + * Requires the bruno-tests server (`packages/bruno-tests`, default port 8081). + * + * Fixtures (auto-loaded by Playwright): + * - fixtures/collection → oauth2-state collection (Authorization Code + Implicit requests) + * - init-user-data → preloads collection, enables system-browser OAuth2 mode + */ + +import type { ElectronApplication } from 'playwright'; +import { test, expect, waitForReadyPage } from '../../../playwright'; +import { openRequest, selectRequestPaneTab, selectEnvironment } from '../../utils/page'; + +const COLLECTION = 'oauth2-state'; +const TESTBENCH = 'http://localhost:8081'; +const CALLBACK = 'bruno://app/oauth2/callback'; +const PROVIDER_AUTH_CODE = 'provider-auth-code'; +const PROVIDER_ACCESS_TOKEN = 'provider-access-token'; +const WRONG_STATE = 'not-the-issued-state'; +const STATE_MISMATCH_ERROR + = 'Error invoking remote method \'renderer:fetch-oauth2-credentials\': Error: OAuth2 state mismatch: the returned state does not match the issued state. Aborting to prevent authorization code injection.'; + +test.beforeAll(async () => { + const response = await fetch(`${TESTBENCH}/ping`); + expect(response.ok, `bruno-tests server should be running at ${TESTBENCH}`).toBeTruthy(); + expect(await response.text()).toBe('pong'); +}); + +/** Register an auth code with the testbench by hitting the captured authorization URL. */ +const registerAuthCodeWithTestbench = async (authorizationUrl: string): Promise => { + const response = await fetch(authorizationUrl); + expect(response.ok, 'testbench authorize should respond').toBeTruthy(); + const html = await response.text(); + const match = html.match(/bruno:\/\/app\/oauth2\/callback\?code=([a-f0-9]+)/); + expect(match, 'authorize response should embed a callback code').toBeTruthy(); + return match![1]; +}; + +/** Fire a deep-link callback through the real protocol handler (cross-platform path). */ +const fireCallback = (app: ElectronApplication, url: string) => + app.evaluate(({ app: electronApp }, callbackUrl) => { + electronApp.emit('second-instance', {}, [callbackUrl]); + }, url); + +/** + * Wrap the real `second-instance` listener so callback URLs are recorded when Bruno + * receives them — same argv path as `getAppProtocolUrlFromArgv` in index.js. + */ +const installCallbackCapture = (app: ElectronApplication) => + app.evaluate(({ app: electronApp }) => { + (globalThis as any).__brunoCapturedCallbackUrl = null; + + const listeners = electronApp.listeners('second-instance') as Array< + (event: unknown, commandLine: string[]) => void + >; + + electronApp.removeAllListeners('second-instance'); + + for (const listener of listeners) { + electronApp.on('second-instance', (event, commandLine) => { + const url = commandLine?.find((arg) => arg?.startsWith('bruno://')); + if (url) { + (globalThis as any).__brunoCapturedCallbackUrl = url; + } + listener.call(electronApp, event, commandLine); + }); + } + }); + +const getCapturedCallbackUrl = (app: ElectronApplication): Promise => + app.evaluate(() => (globalThis as any).__brunoCapturedCallbackUrl ?? null); + +// This block stubs Electron's shell.openExternal inside the Bruno app +// so the OAuth2 test can intercept the authorization URL +// instead of opening a real browser. +const stubOpenExternal = (app: ElectronApplication) => + app.evaluate(({ shell }) => { + (globalThis as any).__brunoCapturedAuthUrl = null; + shell.openExternal = async (url: string) => { + (globalThis as any).__brunoCapturedAuthUrl = url; + }; + }); + +const getCapturedAuthUrl = (app: ElectronApplication): Promise => + app.evaluate(() => (globalThis as any).__brunoCapturedAuthUrl ?? null); + +const waitForAuthorizationStarted = async (app: ElectronApplication) => { + await expect.poll(() => getCapturedAuthUrl(app), { timeout: 15_000 }).toBeTruthy(); +}; + +type CallbackStyle = 'query' | 'hash'; + +const stateFromAuthorizationUrl = (url: string) => new URL(url).searchParams.get('state'); + +/** `state` Bruno sent on the authorization URL (stored internally as expectedState). */ +const getIssuedState = async (app: ElectronApplication): Promise => { + const authUrl = await getCapturedAuthUrl(app); + expect(authUrl, 'authorization URL should have been opened').toBeTruthy(); + const state = stateFromAuthorizationUrl(authUrl as string); + expect(state, 'issued state should be present on the authorization URL').toBeTruthy(); + expect(state).toMatch(/^[0-9a-f]{32}$/); + return state as string; +}; + +const clickGetAccessToken = async (page: Parameters[0], requestName: string) => { + await openRequest(page, COLLECTION, requestName); + await selectEnvironment(page, 'Local', 'collection'); + await selectRequestPaneTab(page, 'Auth'); + await page.getByRole('button', { name: 'Get Access Token' }).click(); +}; + +const getCallbackParams = (callbackUrl: string, style: CallbackStyle = 'query') => { + const url = new URL(callbackUrl); + if (style === 'hash') { + const hashParams = new URLSearchParams(url.hash.slice(1)); + return { + state: hashParams.get('state'), + code: null, + access_token: hashParams.get('access_token') + }; + } + return { + state: url.searchParams.get('state'), + code: url.searchParams.get('code'), + access_token: null + }; +}; + +test.describe.serial('OAuth2 callback state validation', () => { + test('authorization code: rejects callback when returned state does not match issued state', async ({ restartApp }) => { + const app = await restartApp(); + const page = await waitForReadyPage(app); + await stubOpenExternal(app); + await installCallbackCapture(app); + await clickGetAccessToken(page, 'Authorization Code'); + await waitForAuthorizationStarted(app); + // await page.pause(); + + const issuedState = await getIssuedState(app); + const callbackUrl = `${CALLBACK}?code=${PROVIDER_AUTH_CODE}&state=${WRONG_STATE}`; + + await fireCallback(app, callbackUrl); + + const receivedCallbackUrl = await getCapturedCallbackUrl(app); + expect(receivedCallbackUrl).toBe(callbackUrl); + + const { state: returnedState, code } = getCallbackParams(receivedCallbackUrl as string); + expect(returnedState).toBe(WRONG_STATE); + expect(returnedState).not.toBe(issuedState); + expect(code).toBe(PROVIDER_AUTH_CODE); + + await expect( + page.getByTestId('response-pane').getByText(STATE_MISMATCH_ERROR) + ).toBeVisible({ timeout: 15_000 }); + await expect( + page.getByRole('status').filter({ hasText: STATE_MISMATCH_ERROR }) + ).toBeVisible({ timeout: 15_000 }); + // await page.pause(); + }); + + test('implicit grant: rejects callback when returned state does not match issued state', async ({ restartApp }) => { + const app = await restartApp(); + const page = await waitForReadyPage(app); + await stubOpenExternal(app); + await installCallbackCapture(app); + + await clickGetAccessToken(page, 'Implicit'); + await waitForAuthorizationStarted(app); + + const issuedState = await getIssuedState(app); + const callbackUrl = `${CALLBACK}#access_token=${PROVIDER_ACCESS_TOKEN}&token_type=Bearer&state=${WRONG_STATE}`; + + await fireCallback(app, callbackUrl); + + const receivedCallbackUrl = await getCapturedCallbackUrl(app); + expect(receivedCallbackUrl).toBe(callbackUrl); + + const { state: returnedState, access_token } = getCallbackParams(receivedCallbackUrl as string, 'hash'); + expect(returnedState).toBe(WRONG_STATE); + expect(returnedState).not.toBe(issuedState); + expect(access_token).toBe(PROVIDER_ACCESS_TOKEN); + + await expect( + page.getByTestId('response-pane').getByText(STATE_MISMATCH_ERROR) + ).toBeVisible({ timeout: 15_000 }); + await expect( + page.getByRole('status').filter({ hasText: STATE_MISMATCH_ERROR }) + ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('Token fetched successfully!')).not.toBeVisible(); + }); + + test('authorization code: accepts callback when returned state matches issued state', async ({ restartApp }) => { + test.setTimeout(60_000); + const app = await restartApp(); + const page = await waitForReadyPage(app); + await stubOpenExternal(app); + await installCallbackCapture(app); + + await clickGetAccessToken(page, 'Authorization Code'); + await waitForAuthorizationStarted(app); + + const authUrl = await getCapturedAuthUrl(app); + expect(authUrl).toBeTruthy(); + const issuedState = await getIssuedState(app); + const authCode = await registerAuthCodeWithTestbench(authUrl as string); + const callbackUrl = `${CALLBACK}?code=${authCode}&state=${encodeURIComponent(issuedState)}`; + + await fireCallback(app, callbackUrl); + + const receivedCallbackUrl = await getCapturedCallbackUrl(app); + expect(receivedCallbackUrl).toBe(callbackUrl); + + const { state: returnedState, code } = getCallbackParams(receivedCallbackUrl as string); + expect(returnedState).toBe(issuedState); + expect(code).toBe(authCode); + + await expect(page.getByText('Token fetched successfully!')).toBeVisible({ timeout: 15_000 }); + }); + + test('implicit grant: accepts callback when returned state matches issued state', async ({ restartApp }) => { + test.setTimeout(60_000); + const app = await restartApp(); + const page = await waitForReadyPage(app); + await stubOpenExternal(app); + await installCallbackCapture(app); + + await clickGetAccessToken(page, 'Implicit'); + await waitForAuthorizationStarted(app); + + const issuedState = await getIssuedState(app); + const callbackUrl + = `${CALLBACK}#access_token=${PROVIDER_ACCESS_TOKEN}&token_type=Bearer&expires_in=3600&state=${encodeURIComponent(issuedState)}`; + + await fireCallback(app, callbackUrl); + + const receivedCallbackUrl = await getCapturedCallbackUrl(app); + expect(receivedCallbackUrl).toBe(callbackUrl); + + const { state: returnedState, access_token } = getCallbackParams(receivedCallbackUrl as string, 'hash'); + expect(returnedState).toBe(issuedState); + expect(access_token).toBe(PROVIDER_ACCESS_TOKEN); + + await expect(page.getByText('Token fetched successfully!')).toBeVisible({ timeout: 15_000 }); + }); +}); From b76ac9aea6da91393a0992d1dcff4846840e9a65 Mon Sep 17 00:00:00 2001 From: abhishekp-bruno Date: Mon, 29 Jun 2026 11:30:54 +0530 Subject: [PATCH 04/23] ADD IpcError formating for the error message --- .../Auth/OAuth2/Oauth2ActionButtons/index.js | 5 ++-- .../oauth2/oauth2-state-validation.spec.ts | 23 ++++++++----------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js index 4cd209160d9..6c1679d9f0f 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js @@ -8,6 +8,7 @@ import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials, can import { responseReceived } from 'providers/ReduxStore/slices/collections'; import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs'; import { getAllVariables } from 'utils/collections/index'; +import { formatIpcError } from 'utils/common/error'; import Button from 'ui/Button'; const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, credentialsId }) => { @@ -92,7 +93,7 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c if (error?.message && error.message.includes('cancelled by user')) { return; } - const errorMessage = error?.message || 'An error occurred while fetching token!'; + const errorMessage = formatIpcError(error) || 'An error occurred while fetching token!'; toast.error(errorMessage); showOauth2Error(errorMessage); } finally { @@ -129,7 +130,7 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c } catch (error) { console.error(error); toggleRefreshingToken(false); - const errorMessage = error?.message || 'An error occurred while refreshing token!'; + const errorMessage = formatIpcError(error) || 'An error occurred while refreshing token!'; toast.error(errorMessage); showOauth2Error(errorMessage); } diff --git a/tests/auth/oauth2/oauth2-state-validation.spec.ts b/tests/auth/oauth2/oauth2-state-validation.spec.ts index 635970c43ce..f475a2581a8 100644 --- a/tests/auth/oauth2/oauth2-state-validation.spec.ts +++ b/tests/auth/oauth2/oauth2-state-validation.spec.ts @@ -33,7 +33,7 @@ const PROVIDER_AUTH_CODE = 'provider-auth-code'; const PROVIDER_ACCESS_TOKEN = 'provider-access-token'; const WRONG_STATE = 'not-the-issued-state'; const STATE_MISMATCH_ERROR - = 'Error invoking remote method \'renderer:fetch-oauth2-credentials\': Error: OAuth2 state mismatch: the returned state does not match the issued state. Aborting to prevent authorization code injection.'; + = 'OAuth2 state mismatch: the returned state does not match the issued state. Aborting to prevent authorization code injection.'; test.beforeAll(async () => { const response = await fetch(`${TESTBENCH}/ping`); @@ -41,8 +41,8 @@ test.beforeAll(async () => { expect(await response.text()).toBe('pong'); }); -/** Register an auth code with the testbench by hitting the captured authorization URL. */ -const registerAuthCodeWithTestbench = async (authorizationUrl: string): Promise => { +/** Obtain a valid auth code issued by the testbench by hitting the captured authorization URL. */ +const fetchAuthCodeFromTestbench = async (authorizationUrl: string): Promise => { const response = await fetch(authorizationUrl); expect(response.ok, 'testbench authorize should respond').toBeTruthy(); const html = await response.text(); @@ -141,7 +141,7 @@ const getCallbackParams = (callbackUrl: string, style: CallbackStyle = 'query') }; }; -test.describe.serial('OAuth2 callback state validation', () => { +test.describe('OAuth2 callback state validation', () => { test('authorization code: rejects callback when returned state does not match issued state', async ({ restartApp }) => { const app = await restartApp(); const page = await waitForReadyPage(app); @@ -149,7 +149,6 @@ test.describe.serial('OAuth2 callback state validation', () => { await installCallbackCapture(app); await clickGetAccessToken(page, 'Authorization Code'); await waitForAuthorizationStarted(app); - // await page.pause(); const issuedState = await getIssuedState(app); const callbackUrl = `${CALLBACK}?code=${PROVIDER_AUTH_CODE}&state=${WRONG_STATE}`; @@ -165,12 +164,11 @@ test.describe.serial('OAuth2 callback state validation', () => { expect(code).toBe(PROVIDER_AUTH_CODE); await expect( - page.getByTestId('response-pane').getByText(STATE_MISMATCH_ERROR) + page.getByRole('status').filter({ hasText: STATE_MISMATCH_ERROR }) ).toBeVisible({ timeout: 15_000 }); await expect( - page.getByRole('status').filter({ hasText: STATE_MISMATCH_ERROR }) + page.getByTestId('response-pane').getByText(STATE_MISMATCH_ERROR) ).toBeVisible({ timeout: 15_000 }); - // await page.pause(); }); test('implicit grant: rejects callback when returned state does not match issued state', async ({ restartApp }) => { @@ -194,14 +192,12 @@ test.describe.serial('OAuth2 callback state validation', () => { expect(returnedState).toBe(WRONG_STATE); expect(returnedState).not.toBe(issuedState); expect(access_token).toBe(PROVIDER_ACCESS_TOKEN); - await expect( - page.getByTestId('response-pane').getByText(STATE_MISMATCH_ERROR) + page.getByRole('status').filter({ hasText: STATE_MISMATCH_ERROR }) ).toBeVisible({ timeout: 15_000 }); await expect( - page.getByRole('status').filter({ hasText: STATE_MISMATCH_ERROR }) + page.getByTestId('response-pane').getByText(STATE_MISMATCH_ERROR) ).toBeVisible({ timeout: 15_000 }); - await expect(page.getByText('Token fetched successfully!')).not.toBeVisible(); }); test('authorization code: accepts callback when returned state matches issued state', async ({ restartApp }) => { @@ -213,11 +209,10 @@ test.describe.serial('OAuth2 callback state validation', () => { await clickGetAccessToken(page, 'Authorization Code'); await waitForAuthorizationStarted(app); - const authUrl = await getCapturedAuthUrl(app); expect(authUrl).toBeTruthy(); const issuedState = await getIssuedState(app); - const authCode = await registerAuthCodeWithTestbench(authUrl as string); + const authCode = await fetchAuthCodeFromTestbench(authUrl as string); const callbackUrl = `${CALLBACK}?code=${authCode}&state=${encodeURIComponent(issuedState)}`; await fireCallback(app, callbackUrl); From 9fd37c8fbf520c0b05de5ae11d2a65356fcf686e Mon Sep 17 00:00:00 2001 From: abhishekp-bruno Date: Mon, 29 Jun 2026 11:47:30 +0530 Subject: [PATCH 05/23] ADD one more unit test case --- .../tests/utils/oauth2-protocol-handler.spec.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js b/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js index aa3f9ec8f3f..0cd5092631d 100644 --- a/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js +++ b/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js @@ -81,6 +81,19 @@ describe('handleOauth2ProtocolUrl - state validation', () => { expect.objectContaining({ message: expect.stringContaining('state mismatch') }) ); }); + + it('should reject when no hash state is returned but one was expected', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl( + 'bruno://oauth2/callback#access_token=token-abc&token_type=bearer' + ); + + expect(resolve).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('state mismatch') }) + ); + }); }); describe('when no expected state was registered (backward compatibility)', () => { From 87f646936b93e0848b1173fa30a425402d63f24c Mon Sep 17 00:00:00 2001 From: abhishekp-bruno Date: Mon, 29 Jun 2026 12:07:35 +0530 Subject: [PATCH 06/23] ADDED one more test for user supplied state --- .../collection/User Supplied State.bru | 30 ++++++++++++++ .../oauth2/oauth2-state-validation.spec.ts | 41 ++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/auth/oauth2/fixtures/collection/User Supplied State.bru diff --git a/tests/auth/oauth2/fixtures/collection/User Supplied State.bru b/tests/auth/oauth2/fixtures/collection/User Supplied State.bru new file mode 100644 index 00000000000..0048b3ab91f --- /dev/null +++ b/tests/auth/oauth2/fixtures/collection/User Supplied State.bru @@ -0,0 +1,30 @@ +meta { + name: User Supplied State + type: http + seq: 3 +} + +get { + url: {{localhost}}/ping + body: none + auth: oauth2 +} + +auth:oauth2 { + grant_type: authorization_code + callback_url: bruno://app/oauth2/callback + authorization_url: {{localhost}}/api/auth/oauth2/authorization_code/authorize + access_token_url: {{localhost}}/api/auth/oauth2/authorization_code/token + refresh_token_url: + client_id: client_id_1 + client_secret: client_secret_1 + scope: read + state: brunoUserState + pkce: false + credentials_placement: body + credentials_id: credentials + token_placement: header + token_header_prefix: Bearer + auto_fetch_token: true + auto_refresh_token: false +} diff --git a/tests/auth/oauth2/oauth2-state-validation.spec.ts b/tests/auth/oauth2/oauth2-state-validation.spec.ts index f475a2581a8..0a4f35acef9 100644 --- a/tests/auth/oauth2/oauth2-state-validation.spec.ts +++ b/tests/auth/oauth2/oauth2-state-validation.spec.ts @@ -107,13 +107,18 @@ type CallbackStyle = 'query' | 'hash'; const stateFromAuthorizationUrl = (url: string) => new URL(url).searchParams.get('state'); +// Bruno always appends a cryptographically random nonce (crypto.randomBytes(16) → 32 hex +// chars) to the state — whether the user configured one or not (see generateState in +// oauth2.js). So the issued state is at least that nonce length. +const STATE_NONCE_HEX_LENGTH = 32; + /** `state` Bruno sent on the authorization URL (stored internally as expectedState). */ const getIssuedState = async (app: ElectronApplication): Promise => { const authUrl = await getCapturedAuthUrl(app); expect(authUrl, 'authorization URL should have been opened').toBeTruthy(); const state = stateFromAuthorizationUrl(authUrl as string); expect(state, 'issued state should be present on the authorization URL').toBeTruthy(); - expect(state).toMatch(/^[0-9a-f]{32}$/); + expect((state as string).length).toBeGreaterThanOrEqual(STATE_NONCE_HEX_LENGTH); return state as string; }; @@ -252,4 +257,38 @@ test.describe('OAuth2 callback state validation', () => { await expect(page.getByText('Token fetched successfully!')).toBeVisible({ timeout: 15_000 }); }); + + test('authorization code (user-supplied state): issues userState + nonce and accepts matching callback', async ({ restartApp }) => { + test.setTimeout(60_000); + const USER_STATE = 'brunoUserState'; + const app = await restartApp(); + const page = await waitForReadyPage(app); + await stubOpenExternal(app); + await installCallbackCapture(app); + + await clickGetAccessToken(page, 'User Supplied State'); + await waitForAuthorizationStarted(app); + const authUrl = await getCapturedAuthUrl(app); + expect(authUrl).toBeTruthy(); + + const issuedState = await getIssuedState(app); + // Bruno appends a nonce — the issued state must be `userState + <32 hex>`, not the raw input. + expect(issuedState.startsWith(USER_STATE)).toBeTruthy(); + expect(issuedState).not.toBe(USER_STATE); + expect(issuedState.slice(USER_STATE.length)).toMatch(/^[0-9a-f]{32}$/); + + const authCode = await fetchAuthCodeFromTestbench(authUrl as string); + const callbackUrl = `${CALLBACK}?code=${authCode}&state=${encodeURIComponent(issuedState)}`; + + await fireCallback(app, callbackUrl); + + const receivedCallbackUrl = await getCapturedCallbackUrl(app); + expect(receivedCallbackUrl).toBe(callbackUrl); + + const { state: returnedState, code } = getCallbackParams(receivedCallbackUrl as string); + expect(returnedState).toBe(issuedState); + expect(code).toBe(authCode); + + await expect(page.getByText('Token fetched successfully!')).toBeVisible({ timeout: 15_000 }); + }); }); From 515d061bac1f29c404b74cf4f1736a1d4842414c Mon Sep 17 00:00:00 2001 From: abhishekp-bruno Date: Mon, 29 Jun 2026 12:28:35 +0530 Subject: [PATCH 07/23] Merge branch 'main' of https://github.com/abhishekp-bruno/bruno into fix/code-injection-vulnerability From d783e82eae2a49de6840c88385be985ab53694ef Mon Sep 17 00:00:00 2001 From: abhishekp-bruno Date: Wed, 24 Jun 2026 15:03:19 +0530 Subject: [PATCH 08/23] fix(oauth2): prevent code injection in OAuth2 callback handling --- .../authorize-user-in-system-browser.js | 4 +-- .../ipc/network/authorize-user-in-window.js | 18 ++++++++++++- .../src/utils/oauth2-protocol-handler.js | 20 +++++++++++++- packages/bruno-electron/src/utils/oauth2.js | 27 ++++++++++++++++--- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/packages/bruno-electron/src/ipc/network/authorize-user-in-system-browser.js b/packages/bruno-electron/src/ipc/network/authorize-user-in-system-browser.js index 23705b827d1..d9923e19c79 100644 --- a/packages/bruno-electron/src/ipc/network/authorize-user-in-system-browser.js +++ b/packages/bruno-electron/src/ipc/network/authorize-user-in-system-browser.js @@ -1,7 +1,7 @@ const { shell } = require('electron'); const { registerOauth2AuthorizationRequest, rejectOauth2AuthorizationRequest } = require('../../utils/oauth2-protocol-handler'); -const authorizeUserInSystemBrowser = ({ authorizeUrl, callbackUrl, grantType = 'authorization_code' }) => { +const authorizeUserInSystemBrowser = ({ authorizeUrl, callbackUrl, grantType = 'authorization_code', expectedState = null }) => { return new Promise((resolve, reject) => { // Replace callback URL in authorization URL const authorizationUrlObj = new URL(authorizeUrl); @@ -51,7 +51,7 @@ const authorizeUserInSystemBrowser = ({ authorizeUrl, callbackUrl, grantType = ' reject(error); }; - registerOauth2AuthorizationRequest(wrappedResolve, wrappedReject, debugInfo); + registerOauth2AuthorizationRequest(wrappedResolve, wrappedReject, debugInfo, expectedState); // Open system browser shell.openExternal(modifiedAuthorizeUrl).catch((error) => { diff --git a/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js b/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js index e8ecf994b7d..172d36627a1 100644 --- a/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js +++ b/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js @@ -11,7 +11,7 @@ const matchesCallbackUrl = (url, callbackUrl) => { && (url.searchParams.has('code') || url.hash.length > 1); }; -const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session, additionalHeaders = {}, grantType = 'authorization_code' }) => { +const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session, additionalHeaders = {}, grantType = 'authorization_code', expectedState = null }) => { return new Promise(async (resolve, reject) => { let finalUrl = null; let debugInfo = { @@ -202,6 +202,22 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session, additionalH if (finalUrl) { try { + // Validate the state parameter to protect against CSRF / authorization + // code injection. The returned state must match the cryptographically + // random state issued when the flow was initiated. + if (expectedState) { + const finalUrlObj = new URL(finalUrl); + const returnedState + = finalUrlObj.searchParams.get('state') + || (finalUrlObj.hash ? new URLSearchParams(finalUrlObj.hash.substring(1)).get('state') : null); + + if (returnedState !== expectedState) { + return reject( + new Error('OAuth2 state mismatch: the returned state does not match the issued state. Aborting to prevent authorization code injection.') + ); + } + } + // Handle different grant types differently if (grantType === 'implicit') { // For implicit flow, tokens are in the URL hash fragment diff --git a/packages/bruno-electron/src/utils/oauth2-protocol-handler.js b/packages/bruno-electron/src/utils/oauth2-protocol-handler.js index 3d998e1bfb9..9557fbd3165 100644 --- a/packages/bruno-electron/src/utils/oauth2-protocol-handler.js +++ b/packages/bruno-electron/src/utils/oauth2-protocol-handler.js @@ -1,6 +1,6 @@ let oauth2AuthorizationRequest = null; -const registerOauth2AuthorizationRequest = (resolve, reject, debugInfo = null) => { +const registerOauth2AuthorizationRequest = (resolve, reject, debugInfo = null, expectedState = null) => { // Cancel any existing pending request if (oauth2AuthorizationRequest) { oauth2AuthorizationRequest.reject(new Error('Authorization cancelled: new request started')); @@ -10,6 +10,7 @@ const registerOauth2AuthorizationRequest = (resolve, reject, debugInfo = null) = resolve, reject, debugInfo, + expectedState, timestamp: Date.now() }; }; @@ -80,6 +81,23 @@ const handleOauth2ProtocolUrl = (url) => { return; } + // Validate the state parameter to protect against CSRF / authorization code + // injection. The returned state must match the cryptographically random state + // issued when the flow was initiated. + const expectedState = oauth2AuthorizationRequest?.expectedState; + if (expectedState) { + const returnedState + = urlObj.searchParams.get('state') + || (urlObj.hash ? new URLSearchParams(urlObj.hash.substring(1)).get('state') : null); + + if (returnedState !== expectedState) { + rejectOauth2AuthorizationRequest( + new Error('OAuth2 state mismatch: the returned state does not match the issued state. Aborting to prevent authorization code injection.') + ); + return; + } + } + // Check if this is an implicit grant (tokens in hash fragment) if (urlObj.hash) { const hash = urlObj.hash.substring(1); // Remove the leading # diff --git a/packages/bruno-electron/src/utils/oauth2.js b/packages/bruno-electron/src/utils/oauth2.js index 6af08a41b67..f44b74b668a 100644 --- a/packages/bruno-electron/src/utils/oauth2.js +++ b/packages/bruno-electron/src/utils/oauth2.js @@ -308,6 +308,10 @@ const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => { const { callbackUrl, clientId, authorizationUrl, scope, state, pkce, accessTokenUrl, additionalParameters } = oauth2; const useSystemBrowser = preferencesUtil.shouldUseSystemBrowser(); const effectiveCallbackUrl = callbackUrl && callbackUrl.length ? callbackUrl : BRUNO_OAUTH2_CALLBACK_URL; + // Always append a cryptographically random nonce to the user-configured state + // (or generate a fully random one when none is set). The state is validated when + // the callback is received to prevent authorization code injection / CSRF. + const effectiveState = generateState({ userState: state }); const authorizationUrlWithQueryParams = new URL(authorizationUrl); authorizationUrlWithQueryParams.searchParams.append('response_type', 'code'); @@ -324,8 +328,8 @@ const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => { authorizationUrlWithQueryParams.searchParams.append('code_challenge', codeChallenge); authorizationUrlWithQueryParams.searchParams.append('code_challenge_method', 'S256'); } - if (state) { - authorizationUrlWithQueryParams.searchParams.append('state', state); + if (effectiveState) { + authorizationUrlWithQueryParams.searchParams.append('state', effectiveState); } if (additionalParameters?.authorization?.length) { additionalParameters.authorization.forEach((param) => { @@ -344,6 +348,7 @@ const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => { authorizeUrl, callbackUrl: effectiveCallbackUrl, session: oauth2Store.getSessionIdOfCollection({ collectionUid, url: accessTokenUrl }), + expectedState: effectiveState, additionalHeaders: getAdditionalHeaders(additionalParameters?.authorization) }); resolve({ authorizationCode, debugInfo }); @@ -707,6 +712,16 @@ const generateCodeVerifier = () => { return crypto.randomBytes(22).toString('hex'); }; +// Generate a cryptographically random state value used to protect OAuth2 +// authorization flows against CSRF / authorization code injection. +const generateState = ({ userState }) => { + let cryptographicallyRandomString = crypto.randomBytes(16).toString('hex'); + if (userState && userState.length) { + return userState + cryptographicallyRandomString; + } + return cryptographicallyRandomString; +}; + const generateCodeChallenge = (codeVerifier) => { const hash = crypto.createHash('sha256'); hash.update(codeVerifier); @@ -760,6 +775,9 @@ const getOAuth2TokenUsingImplicitGrant = async ({ request, collectionUid, forceF } = oauth2; const useSystemBrowser = preferencesUtil.shouldUseSystemBrowser(); const effectiveCallbackUrl = callbackUrl && callbackUrl.length ? callbackUrl : BRUNO_OAUTH2_CALLBACK_URL; + // Use the user-configured state if present, otherwise generate a cryptographically + // random one. The state is validated when the callback is received to prevent CSRF. + const effectiveState = generateState({ userState: state }); // Validate required fields if (!authorizationUrl) { @@ -844,8 +862,8 @@ const getOAuth2TokenUsingImplicitGrant = async ({ request, collectionUid, forceF if (scope) { authorizationUrlWithQueryParams.searchParams.append('scope', scope); } - if (state) { - authorizationUrlWithQueryParams.searchParams.append('state', state); + if (effectiveState) { + authorizationUrlWithQueryParams.searchParams.append('state', effectiveState); } if (additionalParameters?.authorization?.length) { additionalParameters.authorization.forEach((param) => { @@ -866,6 +884,7 @@ const getOAuth2TokenUsingImplicitGrant = async ({ request, collectionUid, forceF callbackUrl: effectiveCallbackUrl, session: oauth2Store.getSessionIdOfCollection({ collectionUid, url: authorizationUrl }), grantType: 'implicit', + expectedState: effectiveState, additionalHeaders: getAdditionalHeaders(additionalParameters?.authorization) }); From 30fef00bae7ad055f5d1b02152296b088d4638cd Mon Sep 17 00:00:00 2001 From: abhishekp-bruno Date: Wed, 24 Jun 2026 16:42:16 +0530 Subject: [PATCH 09/23] ADD(test-case):oauth2-test-cases-for-state-vulnerability --- packages/bruno-electron/src/utils/oauth2.js | 1 + .../utils/oauth2-protocol-handler.spec.js | 109 ++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js diff --git a/packages/bruno-electron/src/utils/oauth2.js b/packages/bruno-electron/src/utils/oauth2.js index f44b74b668a..d54a691379e 100644 --- a/packages/bruno-electron/src/utils/oauth2.js +++ b/packages/bruno-electron/src/utils/oauth2.js @@ -973,5 +973,6 @@ module.exports = { refreshOauth2Token, generateCodeVerifier, generateCodeChallenge, + generateState, updateCollectionOauth2Credentials }; diff --git a/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js b/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js new file mode 100644 index 00000000000..aa3f9ec8f3f --- /dev/null +++ b/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js @@ -0,0 +1,109 @@ +const { + registerOauth2AuthorizationRequest, + handleOauth2ProtocolUrl, + cancelOAuth2AuthorizationRequest, + isOauth2AuthorizationRequestInProgress +} = require('../../src/utils/oauth2-protocol-handler'); + +describe('handleOauth2ProtocolUrl - state validation', () => { + let resolve; + let reject; + + beforeEach(() => { + resolve = jest.fn(); + reject = jest.fn(); + }); + + afterEach(() => { + // Clear any pending request between tests + if (isOauth2AuthorizationRequestInProgress()) { + cancelOAuth2AuthorizationRequest(); + } + jest.clearAllMocks(); + }); + + describe('authorization code flow (state in query params)', () => { + it('should resolve with the code when the returned state matches', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl('bruno://oauth2/callback?code=auth-code-123&state=expected-state'); + + expect(resolve).toHaveBeenCalledWith('auth-code-123'); + expect(reject).not.toHaveBeenCalled(); + }); + + it('should reject when the returned state does not match', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl('bruno://oauth2/callback?code=auth-code-123&state=attacker-state'); + + expect(resolve).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('state mismatch') }) + ); + }); + + it('should reject when no state is returned but one was expected', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl('bruno://oauth2/callback?code=auth-code-123'); + + expect(resolve).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('state mismatch') }) + ); + }); + }); + + describe('implicit flow (state in hash fragment)', () => { + it('should resolve with tokens when the returned state matches', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl( + 'bruno://oauth2/callback#access_token=token-abc&token_type=bearer&state=expected-state' + ); + + expect(resolve).toHaveBeenCalledWith( + expect.objectContaining({ access_token: 'token-abc' }) + ); + expect(reject).not.toHaveBeenCalled(); + }); + + it('should reject when the hash state does not match', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl( + 'bruno://oauth2/callback#access_token=token-abc&token_type=bearer&state=attacker-state' + ); + + expect(resolve).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('state mismatch') }) + ); + }); + }); + + describe('when no expected state was registered (backward compatibility)', () => { + it('should resolve without validating state', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, null); + + handleOauth2ProtocolUrl('bruno://oauth2/callback?code=auth-code-123'); + + expect(resolve).toHaveBeenCalledWith('auth-code-123'); + expect(reject).not.toHaveBeenCalled(); + }); + }); + + describe('error responses are handled before state validation', () => { + it('should reject with the provider error even if state is absent', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl('bruno://oauth2/callback?error=access_denied'); + + expect(resolve).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Authorization Failed') }) + ); + }); + }); +}); From 4e9c21989bd32c411984b35d2ae49aaa23d538b8 Mon Sep 17 00:00:00 2001 From: abhishekp-bruno Date: Mon, 29 Jun 2026 08:49:02 +0530 Subject: [PATCH 10/23] ADD OAuth test cases --- .../Auth/OAuth2/Oauth2ActionButtons/index.js | 31 ++- .../collection/Authorization Code.bru | 30 ++ .../oauth2/fixtures/collection/Implicit.bru | 24 ++ .../oauth2/fixtures/collection/bruno.json | 5 + .../collection/environments/Local.bru | 3 + .../oauth2/init-user-data/preferences.json | 15 + .../oauth2/oauth2-state-validation.spec.ts | 260 ++++++++++++++++++ 7 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 tests/auth/oauth2/fixtures/collection/Authorization Code.bru create mode 100644 tests/auth/oauth2/fixtures/collection/Implicit.bru create mode 100644 tests/auth/oauth2/fixtures/collection/bruno.json create mode 100644 tests/auth/oauth2/fixtures/collection/environments/Local.bru create mode 100644 tests/auth/oauth2/init-user-data/preferences.json create mode 100644 tests/auth/oauth2/oauth2-state-validation.spec.ts diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js index 1591a5be6a8..4cd209160d9 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js @@ -5,6 +5,8 @@ import { cloneDeep, find, get } from 'lodash'; import { IconLoader2, IconX } from '@tabler/icons'; import { interpolate } from '@usebruno/common'; import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials, cancelOauth2AuthorizationRequest, isOauth2AuthorizationRequestInProgress } from 'providers/ReduxStore/slices/collections/actions'; +import { responseReceived } from 'providers/ReduxStore/slices/collections'; +import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs'; import { getAllVariables } from 'utils/collections/index'; import Button from 'ui/Button'; @@ -41,6 +43,25 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c const credentialsData = find(collection?.oauth2Credentials, (creds) => creds?.url == interpolatedAccessTokenUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId); const creds = credentialsData?.credentials || {}; + const showOauth2Error = (errorMessage) => { + dispatch( + responseReceived({ + itemUid: item.uid, + collectionUid, + response: { + error: errorMessage, + status: null, + headers: {}, + data: null, + dataBuffer: null, + size: 0, + duration: 0 + } + }) + ); + dispatch(updateResponsePaneTab({ uid: item.uid, responsePaneTab: 'response' })); + }; + const handleFetchOauth2Credentials = async () => { let requestCopy = cloneDeep(request); requestCopy.oauth2 = requestCopy?.auth.oauth2; @@ -59,6 +80,7 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c const errorMessage = result?.error || 'No access token received from authorization server'; console.error(errorMessage); toast.error(errorMessage); + showOauth2Error(errorMessage); return; } @@ -70,7 +92,9 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c if (error?.message && error.message.includes('cancelled by user')) { return; } - toast.error(error?.message || 'An error occurred while fetching token!'); + const errorMessage = error?.message || 'An error occurred while fetching token!'; + toast.error(errorMessage); + showOauth2Error(errorMessage); } finally { toggleFetchingToken(false); toggleFetchingAuthorizationCode(false); @@ -97,6 +121,7 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c const errorMessage = result?.error || 'No access token received from authorization server'; console.error(errorMessage); toast.error(errorMessage); + showOauth2Error(errorMessage); return; } @@ -104,7 +129,9 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c } catch (error) { console.error(error); toggleRefreshingToken(false); - toast.error(error?.message || 'An error occurred while refreshing token!'); + const errorMessage = error?.message || 'An error occurred while refreshing token!'; + toast.error(errorMessage); + showOauth2Error(errorMessage); } }; diff --git a/tests/auth/oauth2/fixtures/collection/Authorization Code.bru b/tests/auth/oauth2/fixtures/collection/Authorization Code.bru new file mode 100644 index 00000000000..6f23e605101 --- /dev/null +++ b/tests/auth/oauth2/fixtures/collection/Authorization Code.bru @@ -0,0 +1,30 @@ +meta { + name: Authorization Code + type: http + seq: 1 +} + +get { + url: {{localhost}}/ping + body: none + auth: oauth2 +} + +auth:oauth2 { + grant_type: authorization_code + callback_url: bruno://app/oauth2/callback + authorization_url: {{localhost}}/api/auth/oauth2/authorization_code/authorize + access_token_url: {{localhost}}/api/auth/oauth2/authorization_code/token + refresh_token_url: + client_id: client_id_1 + client_secret: client_secret_1 + scope: read + state: + pkce: false + credentials_placement: body + credentials_id: credentials + token_placement: header + token_header_prefix: Bearer + auto_fetch_token: true + auto_refresh_token: false +} diff --git a/tests/auth/oauth2/fixtures/collection/Implicit.bru b/tests/auth/oauth2/fixtures/collection/Implicit.bru new file mode 100644 index 00000000000..e8cdb4aa8eb --- /dev/null +++ b/tests/auth/oauth2/fixtures/collection/Implicit.bru @@ -0,0 +1,24 @@ +meta { + name: Implicit + type: http + seq: 2 +} + +get { + url: {{localhost}}/ping + body: none + auth: oauth2 +} + +auth:oauth2 { + grant_type: implicit + callback_url: bruno://app/oauth2/callback + authorization_url: {{localhost}}/api/auth/oauth2/authorization_code/authorize + client_id: client_id_1 + scope: read + state: + credentials_id: credentials + token_placement: header + token_header_prefix: Bearer + auto_fetch_token: true +} diff --git a/tests/auth/oauth2/fixtures/collection/bruno.json b/tests/auth/oauth2/fixtures/collection/bruno.json new file mode 100644 index 00000000000..c2925567f67 --- /dev/null +++ b/tests/auth/oauth2/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "oauth2-state", + "type": "collection" +} diff --git a/tests/auth/oauth2/fixtures/collection/environments/Local.bru b/tests/auth/oauth2/fixtures/collection/environments/Local.bru new file mode 100644 index 00000000000..5d116f2effa --- /dev/null +++ b/tests/auth/oauth2/fixtures/collection/environments/Local.bru @@ -0,0 +1,3 @@ +vars { + localhost: http://localhost:8081 +} diff --git a/tests/auth/oauth2/init-user-data/preferences.json b/tests/auth/oauth2/init-user-data/preferences.json new file mode 100644 index 00000000000..9c8a07b2067 --- /dev/null +++ b/tests/auth/oauth2/init-user-data/preferences.json @@ -0,0 +1,15 @@ +{ + "maximized": false, + "lastOpenedCollections": ["{{collectionPath}}"], + "preferences": { + "request": { + "oauth2": { + "useSystemBrowser": true + } + }, + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} diff --git a/tests/auth/oauth2/oauth2-state-validation.spec.ts b/tests/auth/oauth2/oauth2-state-validation.spec.ts new file mode 100644 index 00000000000..635970c43ce --- /dev/null +++ b/tests/auth/oauth2/oauth2-state-validation.spec.ts @@ -0,0 +1,260 @@ +/** + * E2E tests for OAuth2 callback `state` validation (authorization code + implicit grant). + * + * Bruno issues `state` on the authorization URL when the flow starts. The callback must + * return that same `state`. These tests compare issued vs returned `state` — the + * authorization code / access token values are arbitrary provider payloads. + * + * `shell.openExternal` is stubbed so no real browser opens; the authorization URL is + * captured to obtain the issued state. Callbacks are delivered via the real + * `second-instance` protocol handler — the same path the OS uses for + * `bruno://app/oauth2/callback` deep links. A wrapped `second-instance` listener + * records the URL Bruno actually receives. + * + * Oracles: + * - authorization code: callback URI + error/success toast + bruno-tests token endpoint + * - implicit grant: callback URI + error/success toast (no token exchange POST) + * + * Requires the bruno-tests server (`packages/bruno-tests`, default port 8081). + * + * Fixtures (auto-loaded by Playwright): + * - fixtures/collection → oauth2-state collection (Authorization Code + Implicit requests) + * - init-user-data → preloads collection, enables system-browser OAuth2 mode + */ + +import type { ElectronApplication } from 'playwright'; +import { test, expect, waitForReadyPage } from '../../../playwright'; +import { openRequest, selectRequestPaneTab, selectEnvironment } from '../../utils/page'; + +const COLLECTION = 'oauth2-state'; +const TESTBENCH = 'http://localhost:8081'; +const CALLBACK = 'bruno://app/oauth2/callback'; +const PROVIDER_AUTH_CODE = 'provider-auth-code'; +const PROVIDER_ACCESS_TOKEN = 'provider-access-token'; +const WRONG_STATE = 'not-the-issued-state'; +const STATE_MISMATCH_ERROR + = 'Error invoking remote method \'renderer:fetch-oauth2-credentials\': Error: OAuth2 state mismatch: the returned state does not match the issued state. Aborting to prevent authorization code injection.'; + +test.beforeAll(async () => { + const response = await fetch(`${TESTBENCH}/ping`); + expect(response.ok, `bruno-tests server should be running at ${TESTBENCH}`).toBeTruthy(); + expect(await response.text()).toBe('pong'); +}); + +/** Register an auth code with the testbench by hitting the captured authorization URL. */ +const registerAuthCodeWithTestbench = async (authorizationUrl: string): Promise => { + const response = await fetch(authorizationUrl); + expect(response.ok, 'testbench authorize should respond').toBeTruthy(); + const html = await response.text(); + const match = html.match(/bruno:\/\/app\/oauth2\/callback\?code=([a-f0-9]+)/); + expect(match, 'authorize response should embed a callback code').toBeTruthy(); + return match![1]; +}; + +/** Fire a deep-link callback through the real protocol handler (cross-platform path). */ +const fireCallback = (app: ElectronApplication, url: string) => + app.evaluate(({ app: electronApp }, callbackUrl) => { + electronApp.emit('second-instance', {}, [callbackUrl]); + }, url); + +/** + * Wrap the real `second-instance` listener so callback URLs are recorded when Bruno + * receives them — same argv path as `getAppProtocolUrlFromArgv` in index.js. + */ +const installCallbackCapture = (app: ElectronApplication) => + app.evaluate(({ app: electronApp }) => { + (globalThis as any).__brunoCapturedCallbackUrl = null; + + const listeners = electronApp.listeners('second-instance') as Array< + (event: unknown, commandLine: string[]) => void + >; + + electronApp.removeAllListeners('second-instance'); + + for (const listener of listeners) { + electronApp.on('second-instance', (event, commandLine) => { + const url = commandLine?.find((arg) => arg?.startsWith('bruno://')); + if (url) { + (globalThis as any).__brunoCapturedCallbackUrl = url; + } + listener.call(electronApp, event, commandLine); + }); + } + }); + +const getCapturedCallbackUrl = (app: ElectronApplication): Promise => + app.evaluate(() => (globalThis as any).__brunoCapturedCallbackUrl ?? null); + +// This block stubs Electron's shell.openExternal inside the Bruno app +// so the OAuth2 test can intercept the authorization URL +// instead of opening a real browser. +const stubOpenExternal = (app: ElectronApplication) => + app.evaluate(({ shell }) => { + (globalThis as any).__brunoCapturedAuthUrl = null; + shell.openExternal = async (url: string) => { + (globalThis as any).__brunoCapturedAuthUrl = url; + }; + }); + +const getCapturedAuthUrl = (app: ElectronApplication): Promise => + app.evaluate(() => (globalThis as any).__brunoCapturedAuthUrl ?? null); + +const waitForAuthorizationStarted = async (app: ElectronApplication) => { + await expect.poll(() => getCapturedAuthUrl(app), { timeout: 15_000 }).toBeTruthy(); +}; + +type CallbackStyle = 'query' | 'hash'; + +const stateFromAuthorizationUrl = (url: string) => new URL(url).searchParams.get('state'); + +/** `state` Bruno sent on the authorization URL (stored internally as expectedState). */ +const getIssuedState = async (app: ElectronApplication): Promise => { + const authUrl = await getCapturedAuthUrl(app); + expect(authUrl, 'authorization URL should have been opened').toBeTruthy(); + const state = stateFromAuthorizationUrl(authUrl as string); + expect(state, 'issued state should be present on the authorization URL').toBeTruthy(); + expect(state).toMatch(/^[0-9a-f]{32}$/); + return state as string; +}; + +const clickGetAccessToken = async (page: Parameters[0], requestName: string) => { + await openRequest(page, COLLECTION, requestName); + await selectEnvironment(page, 'Local', 'collection'); + await selectRequestPaneTab(page, 'Auth'); + await page.getByRole('button', { name: 'Get Access Token' }).click(); +}; + +const getCallbackParams = (callbackUrl: string, style: CallbackStyle = 'query') => { + const url = new URL(callbackUrl); + if (style === 'hash') { + const hashParams = new URLSearchParams(url.hash.slice(1)); + return { + state: hashParams.get('state'), + code: null, + access_token: hashParams.get('access_token') + }; + } + return { + state: url.searchParams.get('state'), + code: url.searchParams.get('code'), + access_token: null + }; +}; + +test.describe.serial('OAuth2 callback state validation', () => { + test('authorization code: rejects callback when returned state does not match issued state', async ({ restartApp }) => { + const app = await restartApp(); + const page = await waitForReadyPage(app); + await stubOpenExternal(app); + await installCallbackCapture(app); + await clickGetAccessToken(page, 'Authorization Code'); + await waitForAuthorizationStarted(app); + // await page.pause(); + + const issuedState = await getIssuedState(app); + const callbackUrl = `${CALLBACK}?code=${PROVIDER_AUTH_CODE}&state=${WRONG_STATE}`; + + await fireCallback(app, callbackUrl); + + const receivedCallbackUrl = await getCapturedCallbackUrl(app); + expect(receivedCallbackUrl).toBe(callbackUrl); + + const { state: returnedState, code } = getCallbackParams(receivedCallbackUrl as string); + expect(returnedState).toBe(WRONG_STATE); + expect(returnedState).not.toBe(issuedState); + expect(code).toBe(PROVIDER_AUTH_CODE); + + await expect( + page.getByTestId('response-pane').getByText(STATE_MISMATCH_ERROR) + ).toBeVisible({ timeout: 15_000 }); + await expect( + page.getByRole('status').filter({ hasText: STATE_MISMATCH_ERROR }) + ).toBeVisible({ timeout: 15_000 }); + // await page.pause(); + }); + + test('implicit grant: rejects callback when returned state does not match issued state', async ({ restartApp }) => { + const app = await restartApp(); + const page = await waitForReadyPage(app); + await stubOpenExternal(app); + await installCallbackCapture(app); + + await clickGetAccessToken(page, 'Implicit'); + await waitForAuthorizationStarted(app); + + const issuedState = await getIssuedState(app); + const callbackUrl = `${CALLBACK}#access_token=${PROVIDER_ACCESS_TOKEN}&token_type=Bearer&state=${WRONG_STATE}`; + + await fireCallback(app, callbackUrl); + + const receivedCallbackUrl = await getCapturedCallbackUrl(app); + expect(receivedCallbackUrl).toBe(callbackUrl); + + const { state: returnedState, access_token } = getCallbackParams(receivedCallbackUrl as string, 'hash'); + expect(returnedState).toBe(WRONG_STATE); + expect(returnedState).not.toBe(issuedState); + expect(access_token).toBe(PROVIDER_ACCESS_TOKEN); + + await expect( + page.getByTestId('response-pane').getByText(STATE_MISMATCH_ERROR) + ).toBeVisible({ timeout: 15_000 }); + await expect( + page.getByRole('status').filter({ hasText: STATE_MISMATCH_ERROR }) + ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('Token fetched successfully!')).not.toBeVisible(); + }); + + test('authorization code: accepts callback when returned state matches issued state', async ({ restartApp }) => { + test.setTimeout(60_000); + const app = await restartApp(); + const page = await waitForReadyPage(app); + await stubOpenExternal(app); + await installCallbackCapture(app); + + await clickGetAccessToken(page, 'Authorization Code'); + await waitForAuthorizationStarted(app); + + const authUrl = await getCapturedAuthUrl(app); + expect(authUrl).toBeTruthy(); + const issuedState = await getIssuedState(app); + const authCode = await registerAuthCodeWithTestbench(authUrl as string); + const callbackUrl = `${CALLBACK}?code=${authCode}&state=${encodeURIComponent(issuedState)}`; + + await fireCallback(app, callbackUrl); + + const receivedCallbackUrl = await getCapturedCallbackUrl(app); + expect(receivedCallbackUrl).toBe(callbackUrl); + + const { state: returnedState, code } = getCallbackParams(receivedCallbackUrl as string); + expect(returnedState).toBe(issuedState); + expect(code).toBe(authCode); + + await expect(page.getByText('Token fetched successfully!')).toBeVisible({ timeout: 15_000 }); + }); + + test('implicit grant: accepts callback when returned state matches issued state', async ({ restartApp }) => { + test.setTimeout(60_000); + const app = await restartApp(); + const page = await waitForReadyPage(app); + await stubOpenExternal(app); + await installCallbackCapture(app); + + await clickGetAccessToken(page, 'Implicit'); + await waitForAuthorizationStarted(app); + + const issuedState = await getIssuedState(app); + const callbackUrl + = `${CALLBACK}#access_token=${PROVIDER_ACCESS_TOKEN}&token_type=Bearer&expires_in=3600&state=${encodeURIComponent(issuedState)}`; + + await fireCallback(app, callbackUrl); + + const receivedCallbackUrl = await getCapturedCallbackUrl(app); + expect(receivedCallbackUrl).toBe(callbackUrl); + + const { state: returnedState, access_token } = getCallbackParams(receivedCallbackUrl as string, 'hash'); + expect(returnedState).toBe(issuedState); + expect(access_token).toBe(PROVIDER_ACCESS_TOKEN); + + await expect(page.getByText('Token fetched successfully!')).toBeVisible({ timeout: 15_000 }); + }); +}); From f880abc044962e78a85cd7db15578fb473e4dc1a Mon Sep 17 00:00:00 2001 From: abhishekp-bruno Date: Mon, 29 Jun 2026 11:30:54 +0530 Subject: [PATCH 11/23] ADD IpcError formating for the error message --- .../Auth/OAuth2/Oauth2ActionButtons/index.js | 5 ++-- .../oauth2/oauth2-state-validation.spec.ts | 23 ++++++++----------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js index 4cd209160d9..6c1679d9f0f 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js @@ -8,6 +8,7 @@ import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials, can import { responseReceived } from 'providers/ReduxStore/slices/collections'; import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs'; import { getAllVariables } from 'utils/collections/index'; +import { formatIpcError } from 'utils/common/error'; import Button from 'ui/Button'; const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, credentialsId }) => { @@ -92,7 +93,7 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c if (error?.message && error.message.includes('cancelled by user')) { return; } - const errorMessage = error?.message || 'An error occurred while fetching token!'; + const errorMessage = formatIpcError(error) || 'An error occurred while fetching token!'; toast.error(errorMessage); showOauth2Error(errorMessage); } finally { @@ -129,7 +130,7 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c } catch (error) { console.error(error); toggleRefreshingToken(false); - const errorMessage = error?.message || 'An error occurred while refreshing token!'; + const errorMessage = formatIpcError(error) || 'An error occurred while refreshing token!'; toast.error(errorMessage); showOauth2Error(errorMessage); } diff --git a/tests/auth/oauth2/oauth2-state-validation.spec.ts b/tests/auth/oauth2/oauth2-state-validation.spec.ts index 635970c43ce..f475a2581a8 100644 --- a/tests/auth/oauth2/oauth2-state-validation.spec.ts +++ b/tests/auth/oauth2/oauth2-state-validation.spec.ts @@ -33,7 +33,7 @@ const PROVIDER_AUTH_CODE = 'provider-auth-code'; const PROVIDER_ACCESS_TOKEN = 'provider-access-token'; const WRONG_STATE = 'not-the-issued-state'; const STATE_MISMATCH_ERROR - = 'Error invoking remote method \'renderer:fetch-oauth2-credentials\': Error: OAuth2 state mismatch: the returned state does not match the issued state. Aborting to prevent authorization code injection.'; + = 'OAuth2 state mismatch: the returned state does not match the issued state. Aborting to prevent authorization code injection.'; test.beforeAll(async () => { const response = await fetch(`${TESTBENCH}/ping`); @@ -41,8 +41,8 @@ test.beforeAll(async () => { expect(await response.text()).toBe('pong'); }); -/** Register an auth code with the testbench by hitting the captured authorization URL. */ -const registerAuthCodeWithTestbench = async (authorizationUrl: string): Promise => { +/** Obtain a valid auth code issued by the testbench by hitting the captured authorization URL. */ +const fetchAuthCodeFromTestbench = async (authorizationUrl: string): Promise => { const response = await fetch(authorizationUrl); expect(response.ok, 'testbench authorize should respond').toBeTruthy(); const html = await response.text(); @@ -141,7 +141,7 @@ const getCallbackParams = (callbackUrl: string, style: CallbackStyle = 'query') }; }; -test.describe.serial('OAuth2 callback state validation', () => { +test.describe('OAuth2 callback state validation', () => { test('authorization code: rejects callback when returned state does not match issued state', async ({ restartApp }) => { const app = await restartApp(); const page = await waitForReadyPage(app); @@ -149,7 +149,6 @@ test.describe.serial('OAuth2 callback state validation', () => { await installCallbackCapture(app); await clickGetAccessToken(page, 'Authorization Code'); await waitForAuthorizationStarted(app); - // await page.pause(); const issuedState = await getIssuedState(app); const callbackUrl = `${CALLBACK}?code=${PROVIDER_AUTH_CODE}&state=${WRONG_STATE}`; @@ -165,12 +164,11 @@ test.describe.serial('OAuth2 callback state validation', () => { expect(code).toBe(PROVIDER_AUTH_CODE); await expect( - page.getByTestId('response-pane').getByText(STATE_MISMATCH_ERROR) + page.getByRole('status').filter({ hasText: STATE_MISMATCH_ERROR }) ).toBeVisible({ timeout: 15_000 }); await expect( - page.getByRole('status').filter({ hasText: STATE_MISMATCH_ERROR }) + page.getByTestId('response-pane').getByText(STATE_MISMATCH_ERROR) ).toBeVisible({ timeout: 15_000 }); - // await page.pause(); }); test('implicit grant: rejects callback when returned state does not match issued state', async ({ restartApp }) => { @@ -194,14 +192,12 @@ test.describe.serial('OAuth2 callback state validation', () => { expect(returnedState).toBe(WRONG_STATE); expect(returnedState).not.toBe(issuedState); expect(access_token).toBe(PROVIDER_ACCESS_TOKEN); - await expect( - page.getByTestId('response-pane').getByText(STATE_MISMATCH_ERROR) + page.getByRole('status').filter({ hasText: STATE_MISMATCH_ERROR }) ).toBeVisible({ timeout: 15_000 }); await expect( - page.getByRole('status').filter({ hasText: STATE_MISMATCH_ERROR }) + page.getByTestId('response-pane').getByText(STATE_MISMATCH_ERROR) ).toBeVisible({ timeout: 15_000 }); - await expect(page.getByText('Token fetched successfully!')).not.toBeVisible(); }); test('authorization code: accepts callback when returned state matches issued state', async ({ restartApp }) => { @@ -213,11 +209,10 @@ test.describe.serial('OAuth2 callback state validation', () => { await clickGetAccessToken(page, 'Authorization Code'); await waitForAuthorizationStarted(app); - const authUrl = await getCapturedAuthUrl(app); expect(authUrl).toBeTruthy(); const issuedState = await getIssuedState(app); - const authCode = await registerAuthCodeWithTestbench(authUrl as string); + const authCode = await fetchAuthCodeFromTestbench(authUrl as string); const callbackUrl = `${CALLBACK}?code=${authCode}&state=${encodeURIComponent(issuedState)}`; await fireCallback(app, callbackUrl); From 388742326e870ca1d3883d6cf7fe09f68151e0fe Mon Sep 17 00:00:00 2001 From: abhishekp-bruno Date: Mon, 29 Jun 2026 11:47:30 +0530 Subject: [PATCH 12/23] ADD one more unit test case --- .../tests/utils/oauth2-protocol-handler.spec.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js b/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js index aa3f9ec8f3f..0cd5092631d 100644 --- a/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js +++ b/packages/bruno-electron/tests/utils/oauth2-protocol-handler.spec.js @@ -81,6 +81,19 @@ describe('handleOauth2ProtocolUrl - state validation', () => { expect.objectContaining({ message: expect.stringContaining('state mismatch') }) ); }); + + it('should reject when no hash state is returned but one was expected', () => { + registerOauth2AuthorizationRequest(resolve, reject, null, 'expected-state'); + + handleOauth2ProtocolUrl( + 'bruno://oauth2/callback#access_token=token-abc&token_type=bearer' + ); + + expect(resolve).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('state mismatch') }) + ); + }); }); describe('when no expected state was registered (backward compatibility)', () => { From 900616a4962cc336e01e58606d0d0c1d5c1cf37e Mon Sep 17 00:00:00 2001 From: abhishekp-bruno Date: Mon, 29 Jun 2026 12:07:35 +0530 Subject: [PATCH 13/23] ADDED one more test for user supplied state --- .../collection/User Supplied State.bru | 30 ++++++++++++++ .../oauth2/oauth2-state-validation.spec.ts | 41 ++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/auth/oauth2/fixtures/collection/User Supplied State.bru diff --git a/tests/auth/oauth2/fixtures/collection/User Supplied State.bru b/tests/auth/oauth2/fixtures/collection/User Supplied State.bru new file mode 100644 index 00000000000..0048b3ab91f --- /dev/null +++ b/tests/auth/oauth2/fixtures/collection/User Supplied State.bru @@ -0,0 +1,30 @@ +meta { + name: User Supplied State + type: http + seq: 3 +} + +get { + url: {{localhost}}/ping + body: none + auth: oauth2 +} + +auth:oauth2 { + grant_type: authorization_code + callback_url: bruno://app/oauth2/callback + authorization_url: {{localhost}}/api/auth/oauth2/authorization_code/authorize + access_token_url: {{localhost}}/api/auth/oauth2/authorization_code/token + refresh_token_url: + client_id: client_id_1 + client_secret: client_secret_1 + scope: read + state: brunoUserState + pkce: false + credentials_placement: body + credentials_id: credentials + token_placement: header + token_header_prefix: Bearer + auto_fetch_token: true + auto_refresh_token: false +} diff --git a/tests/auth/oauth2/oauth2-state-validation.spec.ts b/tests/auth/oauth2/oauth2-state-validation.spec.ts index f475a2581a8..0a4f35acef9 100644 --- a/tests/auth/oauth2/oauth2-state-validation.spec.ts +++ b/tests/auth/oauth2/oauth2-state-validation.spec.ts @@ -107,13 +107,18 @@ type CallbackStyle = 'query' | 'hash'; const stateFromAuthorizationUrl = (url: string) => new URL(url).searchParams.get('state'); +// Bruno always appends a cryptographically random nonce (crypto.randomBytes(16) → 32 hex +// chars) to the state — whether the user configured one or not (see generateState in +// oauth2.js). So the issued state is at least that nonce length. +const STATE_NONCE_HEX_LENGTH = 32; + /** `state` Bruno sent on the authorization URL (stored internally as expectedState). */ const getIssuedState = async (app: ElectronApplication): Promise => { const authUrl = await getCapturedAuthUrl(app); expect(authUrl, 'authorization URL should have been opened').toBeTruthy(); const state = stateFromAuthorizationUrl(authUrl as string); expect(state, 'issued state should be present on the authorization URL').toBeTruthy(); - expect(state).toMatch(/^[0-9a-f]{32}$/); + expect((state as string).length).toBeGreaterThanOrEqual(STATE_NONCE_HEX_LENGTH); return state as string; }; @@ -252,4 +257,38 @@ test.describe('OAuth2 callback state validation', () => { await expect(page.getByText('Token fetched successfully!')).toBeVisible({ timeout: 15_000 }); }); + + test('authorization code (user-supplied state): issues userState + nonce and accepts matching callback', async ({ restartApp }) => { + test.setTimeout(60_000); + const USER_STATE = 'brunoUserState'; + const app = await restartApp(); + const page = await waitForReadyPage(app); + await stubOpenExternal(app); + await installCallbackCapture(app); + + await clickGetAccessToken(page, 'User Supplied State'); + await waitForAuthorizationStarted(app); + const authUrl = await getCapturedAuthUrl(app); + expect(authUrl).toBeTruthy(); + + const issuedState = await getIssuedState(app); + // Bruno appends a nonce — the issued state must be `userState + <32 hex>`, not the raw input. + expect(issuedState.startsWith(USER_STATE)).toBeTruthy(); + expect(issuedState).not.toBe(USER_STATE); + expect(issuedState.slice(USER_STATE.length)).toMatch(/^[0-9a-f]{32}$/); + + const authCode = await fetchAuthCodeFromTestbench(authUrl as string); + const callbackUrl = `${CALLBACK}?code=${authCode}&state=${encodeURIComponent(issuedState)}`; + + await fireCallback(app, callbackUrl); + + const receivedCallbackUrl = await getCapturedCallbackUrl(app); + expect(receivedCallbackUrl).toBe(callbackUrl); + + const { state: returnedState, code } = getCallbackParams(receivedCallbackUrl as string); + expect(returnedState).toBe(issuedState); + expect(code).toBe(authCode); + + await expect(page.getByText('Token fetched successfully!')).toBeVisible({ timeout: 15_000 }); + }); }); From 01fd1e87182d424e8b5a9b61cb9d059a85409c5d Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Thu, 25 Jun 2026 16:15:22 +0530 Subject: [PATCH 14/23] feat(ai): OpenAI-compatible endpoints support (#8365) --- .../Preferences/AI/CompatEndpointCard.js | 467 ++++++++++++++++++ .../Preferences/AI/StyledWrapper.js | 87 ++++ .../src/components/Preferences/AI/index.js | 252 ++++++++-- packages/bruno-electron/src/ipc/ai/index.js | 37 +- .../bruno-electron/src/ipc/ai/providers.js | 213 ++++++-- .../bruno-electron/src/store/preferences.js | 15 + 6 files changed, 986 insertions(+), 85 deletions(-) create mode 100644 packages/bruno-app/src/components/Preferences/AI/CompatEndpointCard.js diff --git a/packages/bruno-app/src/components/Preferences/AI/CompatEndpointCard.js b/packages/bruno-app/src/components/Preferences/AI/CompatEndpointCard.js new file mode 100644 index 00000000000..cae1befd06d --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/AI/CompatEndpointCard.js @@ -0,0 +1,467 @@ +import { useEffect, useRef, useState } from 'react'; +import { v4 as uuid } from 'uuid'; +import { + IconAlertCircle, + IconBolt, + IconCheck, + IconChevronDown, + IconEye, + IconEyeOff, + IconLoader2, + IconPencil, + IconPlus, + IconServer, + IconTrash, + IconX +} from '@tabler/icons'; +import toast from 'react-hot-toast'; +import { clearAiApiKey, getAiApiKey, setAiApiKey, testAiProvider } from 'utils/ai'; + +const stopBubble = (e) => e.stopPropagation(); + +const CompatEndpointCard = ({ + endpoint, + provider, + providerEnabled, + providerToggle, + pending, + isModelEnabled, + onToggleModel, + onChangeName, + onChangeBaseURL, + onAddModel, + onRemoveModel, + onUpdateModel, + onRemoveEndpoint, + onStatusChange +}) => { + const [expanded, setExpanded] = useState(!endpoint.baseURL); + const [keyDraft, setKeyDraft] = useState(''); + const [editing, setEditing] = useState(false); + const [showKey, setShowKey] = useState(false); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [feedback, setFeedback] = useState(null); + + const [newModelId, setNewModelId] = useState(''); + const [newModelLabel, setNewModelLabel] = useState(''); + + const prev = useRef({ enabled: providerEnabled }); + useEffect(() => { + const was = prev.current; + if (!was.enabled && providerEnabled) setExpanded(true); + else if (was.enabled && !providerEnabled) setExpanded(false); + prev.current = { enabled: providerEnabled }; + }, [providerEnabled]); + + const isEditingKey = editing || !provider.configured; + + const handleSaveKey = async () => { + const trimmed = keyDraft.trim(); + if (!trimmed) return; + setSaving(true); + setFeedback(null); + try { + const status = await setAiApiKey({ providerId: provider.id, apiKey: trimmed }); + onStatusChange?.(status); + setKeyDraft(''); + setShowKey(false); + setEditing(false); + setFeedback({ type: 'success', message: 'API key saved' }); + } catch (err) { + setFeedback({ type: 'error', message: err.message || 'Failed to save API key' }); + } finally { + setSaving(false); + } + }; + + const handleClearKey = async () => { + setFeedback(null); + try { + const status = await clearAiApiKey({ providerId: provider.id }); + onStatusChange?.(status); + setEditing(false); + setKeyDraft(''); + toast.success(`${endpoint.name || 'Endpoint'} API key removed`); + } catch (err) { + toast.error(err.message || 'Failed to clear API key'); + } + }; + + const handleTest = async () => { + setTesting(true); + setFeedback(null); + try { + const result = await testAiProvider({ providerId: provider.id }); + if (result.ok) { + setFeedback({ type: 'success', message: 'Connection successful' }); + } else { + setFeedback({ type: 'error', message: result.error || 'Connection failed' }); + } + } catch (err) { + setFeedback({ type: 'error', message: err.message || 'Connection failed' }); + } finally { + setTesting(false); + } + }; + + const handleStartEditKey = async () => { + setEditing(true); + setFeedback(null); + try { + const current = await getAiApiKey({ providerId: provider.id }); + setKeyDraft(current || ''); + } catch (err) { + setKeyDraft(''); + } + }; + + const handleCancelEditKey = () => { + setEditing(false); + setKeyDraft(''); + setShowKey(false); + setFeedback(null); + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (keyDraft.trim() && !saving) handleSaveKey(); + } else if (e.key === 'Escape' && provider.configured) { + e.preventDefault(); + handleCancelEditKey(); + } + }; + + const handleAddModel = () => { + const id = newModelId.trim(); + if (!id) return; + onAddModel({ + id: uuid(), + modelId: id, + label: newModelLabel.trim() || id + }); + setNewModelId(''); + setNewModelLabel(''); + }; + + const handleAddModelKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddModel(); + } + }; + + const models = endpoint.models || []; + const enabledModelsCount = models.filter((m) => isModelEnabled(m.id)).length; + + return ( +
+
setExpanded(!expanded)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setExpanded(!expanded); + } + }} + > +
+ +
+ {endpoint.name || 'Unnamed endpoint'} + {endpoint.baseURL && ( + {endpoint.baseURL} + )} +
+
+
+ + + {provider.configured + ? `${enabledModelsCount}/${models.length} model${models.length === 1 ? '' : 's'}` + : 'Not configured'} + + + {providerToggle} + + + + +
+
+ +
+
+
+ {/* Endpoint details */} +
+
+ + onChangeName(e.target.value)} + onClick={stopBubble} + /> +
+
+ + onChangeBaseURL(e.target.value)} + autoComplete="off" + autoCorrect="off" + spellCheck="false" + onClick={stopBubble} + /> +
+
+ + {/* API key */} +
+
+ API Key +
+ + {!isEditingKey ? ( +
+ •••••••••••••••• +
+ + + +
+
+ ) : ( +
+
+ setKeyDraft(e.target.value)} + onKeyDown={handleKeyDown} + onClick={stopBubble} + autoFocus + data-testid={`ai-endpoint-${endpoint.id}-key-input`} + /> + +
+ + {provider.configured && ( + + )} +
+ )} + + {pending && ( +
+ + Saving endpoint… +
+ )} + + {feedback && ( +
+ {feedback.type === 'success' ? : } + {feedback.message} +
+ )} +
+ + {/* Models */} +
+
+ Models + {!provider.configured && ( + + + Add an API key to enable + + )} +
+ + {models.length === 0 && ( +
+ No models yet. Add the model id your provider expects (e.g. gpt-4o or llama3.1:8b). +
+ )} + + {models.length > 0 && ( +
+ {models.map((model) => { + const enabled = isModelEnabled(model.id); + const disabled = !provider.configured || !providerEnabled; + return ( +
+ onToggleModel(model.id, !enabled)} + /> + onUpdateModel(model.id, { label: e.target.value })} + /> + onUpdateModel(model.id, { modelId: e.target.value })} + /> + +
+ ); + })} +
+ )} + +
+ setNewModelId(e.target.value)} + onKeyDown={handleAddModelKeyDown} + data-testid={`ai-endpoint-${endpoint.id}-new-model-id`} + /> + setNewModelLabel(e.target.value)} + onKeyDown={handleAddModelKeyDown} + data-testid={`ai-endpoint-${endpoint.id}-new-model-label`} + /> + +
+
+ +
+ +
+
+
+
+
+ ); +}; + +export default CompatEndpointCard; diff --git a/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js index f1382d4f3bd..b6e5d531281 100644 --- a/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js @@ -379,6 +379,93 @@ const StyledWrapper = styled.div` } } + .compat-add-btn { + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.sm}; + padding: 3px 8px; + transition: color 0.15s ease, border-color 0.15s ease; + + &:hover { + color: ${(props) => props.theme.text}; + border-color: ${(props) => props.theme.colors.accent}80; + } + } + + .compat-models-empty { + color: ${(props) => props.theme.colors.text.muted}; + border: 1px dashed ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.sm}; + + code { + font-family: ${(props) => props.theme.font.monospace || 'monospace'}; + color: ${(props) => props.theme.text}; + } + } + + .compat-model-row { + border-radius: ${(props) => props.theme.border.radius.sm}; + border: 1px solid ${(props) => props.theme.input.border}; + background: ${(props) => props.theme.input.bg}; + transition: background-color 0.15s ease, border-color 0.15s ease; + + &.selected { + background: ${(props) => props.theme.colors.accent}06; + } + + &.disabled { + opacity: 0.45; + + input { + cursor: not-allowed; + } + } + } + + .compat-inline-input { + background: transparent; + border: none; + outline: none; + color: ${(props) => props.theme.text}; + padding: 2px 4px; + border-radius: ${(props) => props.theme.border.radius.sm}; + min-width: 0; + font-family: inherit; + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.6; + } + + &:focus { + background: ${(props) => props.theme.bg}; + box-shadow: inset 0 0 0 1px ${(props) => props.theme.input.focusBorder}; + } + } + + .compat-inline-id { + font-family: ${(props) => props.theme.font.monospace || 'monospace'}; + } + + .compat-add-model { + padding-top: 4px; + } + + .compat-remove-endpoint { + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: none; + padding: 4px 6px; + border-radius: ${(props) => props.theme.border.radius.sm}; + transition: color 0.15s ease, background-color 0.15s ease; + + &:hover { + color: ${(props) => props.theme.colors.text.danger}; + background: ${(props) => props.theme.colors.bg.danger}15; + } + } + @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } diff --git a/packages/bruno-app/src/components/Preferences/AI/index.js b/packages/bruno-app/src/components/Preferences/AI/index.js index 9604b6c2b32..8294d8a8f71 100644 --- a/packages/bruno-app/src/components/Preferences/AI/index.js +++ b/packages/bruno-app/src/components/Preferences/AI/index.js @@ -1,23 +1,42 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import get from 'lodash/get'; import debounce from 'lodash/debounce'; +import { v4 as uuid } from 'uuid'; import { useFormik } from 'formik'; import { useDispatch, useSelector } from 'react-redux'; import * as Yup from 'yup'; import toast from 'react-hot-toast'; -import { IconSettings, IconTerminal2 } from '@tabler/icons'; +import { IconPlus, IconSettings, IconTerminal2 } from '@tabler/icons'; import { savePreferences } from 'providers/ReduxStore/slices/app'; import ToggleSwitch from 'components/ToggleSwitch'; -import { getAiStatus } from 'utils/ai'; +import { clearAiApiKey, getAiStatus } from 'utils/ai'; import ProviderCard from './ProviderCard'; +import CompatEndpointCard from './CompatEndpointCard'; import AutocompletePane from './AutocompletePane'; import StyledWrapper from './StyledWrapper'; +const OPENAI_COMPATIBLE_PREFIX = 'openai-compatible:'; +const isCompatProviderId = (id) => typeof id === 'string' && id.startsWith(OPENAI_COMPATIBLE_PREFIX); + const aiPreferencesSchema = Yup.object().shape({ enabled: Yup.boolean(), providers: Yup.object(), models: Yup.object(), defaultModel: Yup.string().max(200).nullable(), + openaiCompatibleEndpoints: Yup.array().of( + Yup.object().shape({ + id: Yup.string().required(), + name: Yup.string().max(120).nullable(), + baseURL: Yup.string().max(2048).nullable(), + models: Yup.array().of( + Yup.object().shape({ + id: Yup.string().required(), + label: Yup.string().max(120).nullable(), + modelId: Yup.string().max(200).nullable() + }) + ) + }) + ), autocomplete: Yup.object().shape({ enabled: Yup.boolean(), model: Yup.string().max(200).nullable(), @@ -58,6 +77,7 @@ const AI = () => { }, {}), models: get(preferences, 'ai.models', {}), defaultModel: get(preferences, 'ai.defaultModel', ''), + openaiCompatibleEndpoints: get(preferences, 'ai.openaiCompatibleEndpoints', []), autocomplete: { enabled: get(preferences, 'ai.autocomplete.enabled', true), model: get(preferences, 'ai.autocomplete.model', ''), @@ -69,7 +89,7 @@ const AI = () => { }); const handleSave = useCallback( - (values) => { + (values) => dispatch( savePreferences({ ...preferences, @@ -78,6 +98,7 @@ const AI = () => { providers: values.providers, models: values.models, defaultModel: values.defaultModel || '', + openaiCompatibleEndpoints: values.openaiCompatibleEndpoints || [], autocomplete: { enabled: values.autocomplete?.enabled !== false, model: values.autocomplete?.model || '', @@ -85,12 +106,14 @@ const AI = () => { } } }) - ).catch((err) => { - console.error('Failed to save AI preferences:', err); - toast.error('Failed to save AI preferences'); - }); - }, - [dispatch, preferences] + ) + .then(() => refreshStatus()) + .catch((err) => { + console.error('Failed to save AI preferences:', err); + toast.error('Failed to save AI preferences'); + throw err; + }), + [dispatch, preferences, refreshStatus] ); const handleSaveRef = useRef(handleSave); @@ -129,14 +152,96 @@ const AI = () => { formik.setFieldValue(`models.${modelId}.enabled`, next); }; + const endpoints = formik.values.openaiCompatibleEndpoints || []; + + const handleAddEndpoint = async () => { + const newEndpoint = { + id: uuid(), + name: `Endpoint ${endpoints.length + 1}`, + baseURL: '', + models: [] + }; + const next = [...endpoints, newEndpoint]; + formik.setFieldValue('openaiCompatibleEndpoints', next); + formik.setFieldValue(`providers.${OPENAI_COMPATIBLE_PREFIX}${newEndpoint.id}.enabled`, true); + // Persist immediately so the backend recognises the new virtual provider id + // by the time the user enters an API key. The card derives a `pending` flag + // from `status.providers` so its key/test actions stay disabled until this + // resolves, which also closes the race with debouncedSave. + try { + await handleSaveRef.current({ + ...formik.values, + openaiCompatibleEndpoints: next, + providers: { + ...formik.values.providers, + [`${OPENAI_COMPATIBLE_PREFIX}${newEndpoint.id}`]: { enabled: true } + } + }); + } catch (_) { + // toast already raised by handleSave + } + }; + + const updateEndpoint = (endpointId, patch) => { + const next = endpoints.map((e) => (e.id === endpointId ? { ...e, ...patch } : e)); + formik.setFieldValue('openaiCompatibleEndpoints', next); + }; + + const updateEndpointModels = (endpointId, mapFn) => { + const next = endpoints.map((e) => (e.id === endpointId ? { ...e, models: mapFn(e.models || []) } : e)); + formik.setFieldValue('openaiCompatibleEndpoints', next); + }; + + const handleRemoveEndpoint = async (endpointId) => { + const providerId = `${OPENAI_COMPATIBLE_PREFIX}${endpointId}`; + const removed = endpoints.find((e) => e.id === endpointId); + const removedModelIds = new Set((removed?.models || []).map((m) => m.id)); + + const next = endpoints.filter((e) => e.id !== endpointId); + formik.setFieldValue('openaiCompatibleEndpoints', next); + + const providersCopy = { ...formik.values.providers }; + delete providersCopy[providerId]; + formik.setFieldValue('providers', providersCopy); + + // Drop per-model toggles and clear any selector still pointing at a removed + // model so the picker doesn't resolve to an unknown id later. + if (removedModelIds.size > 0) { + const modelsCopy = { ...(formik.values.models || {}) }; + for (const id of removedModelIds) delete modelsCopy[id]; + formik.setFieldValue('models', modelsCopy); + + if (removedModelIds.has(formik.values.defaultModel)) { + formik.setFieldValue('defaultModel', ''); + } + if (removedModelIds.has(formik.values.autocomplete?.model)) { + formik.setFieldValue('autocomplete.model', ''); + } + } + + // Best-effort key cleanup so we don't leave orphan encrypted blobs on disk. + try { + await clearAiApiKey({ providerId }); + } catch (_) { + // ignore, key may not have been set + } + }; + const usableModels = useMemo(() => { if (!status) return []; + const endpointsById = new Map((formik.values.openaiCompatibleEndpoints || []).map((e) => [e.id, e])); return (status.models || []).filter((m) => { if (!formik.values.providers?.[m.provider]?.enabled) return false; if (!status.providers?.[m.provider]?.configured) return false; - return isModelEnabled(m.id); + if (!isModelEnabled(m.id)) return false; + if (isCompatProviderId(m.provider)) { + const endpointId = m.provider.slice(OPENAI_COMPATIBLE_PREFIX.length); + const endpoint = endpointsById.get(endpointId); + if (!endpoint?.baseURL) return false; + } + return true; }); - }, [status, formik.values.providers, formik.values.models]); + }, [status, formik.values.providers, formik.values.models, formik.values.openaiCompatibleEndpoints]); return ( @@ -201,33 +306,106 @@ const AI = () => { Providers
- {providerIds.map((id) => { - const provider = status.providers[id]; - const providerEnabled = get(formik.values, `providers.${id}.enabled`, false); - - const providerToggle = ( - - formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)} - /> - ); - - return ( - setStatus(next)} - /> - ); - })} + {providerIds + .filter((id) => !isCompatProviderId(id)) + .map((id) => { + const provider = status.providers[id]; + const providerEnabled = get(formik.values, `providers.${id}.enabled`, false); + + const providerToggle = ( + + formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)} + /> + ); + + return ( + setStatus(next)} + /> + ); + })} +
+ +
+ OpenAI-Compatible Endpoints +
+ + {endpoints.length === 0 && ( +
+ Point Bruno at any OpenAI-compatible API — Ollama, LM Studio, Together, Groq, OpenRouter, vLLM, and more. +
+ )} + + {endpoints.length > 0 && ( +
+ {endpoints.map((endpoint) => { + const providerId = `${OPENAI_COMPATIBLE_PREFIX}${endpoint.id}`; + const pending = !status.providers[providerId]; + const provider = status.providers[providerId] || { + id: providerId, + label: endpoint.name, + configured: false, + isCustom: true + }; + const providerEnabled = get(formik.values, `providers.${providerId}.enabled`, false); + + const providerToggle = ( + + formik.setFieldValue(`providers.${providerId}.enabled`, !providerEnabled)} + /> + ); + + return ( + updateEndpoint(endpoint.id, { name })} + onChangeBaseURL={(baseURL) => updateEndpoint(endpoint.id, { baseURL })} + onAddModel={(model) => + updateEndpointModels(endpoint.id, (models) => [...models, model])} + onRemoveModel={(modelId) => + updateEndpointModels(endpoint.id, (models) => + models.filter((m) => m.id !== modelId) + )} + onUpdateModel={(modelId, patch) => + updateEndpointModels(endpoint.id, (models) => + models.map((m) => (m.id === modelId ? { ...m, ...patch } : m)) + )} + onRemoveEndpoint={handleRemoveEndpoint} + onStatusChange={(next) => setStatus(next)} + /> + ); + })} +
+ )} )} diff --git a/packages/bruno-electron/src/ipc/ai/index.js b/packages/bruno-electron/src/ipc/ai/index.js index 0cdc7ae37b7..75c6a5c19aa 100644 --- a/packages/bruno-electron/src/ipc/ai/index.js +++ b/packages/bruno-electron/src/ipc/ai/index.js @@ -8,7 +8,10 @@ const { listModels, getModel, getAvailableModels, - clearSdkCache + clearSdkCache, + isKnownProviderId, + validateApiKeyForProvider, + providerLabel } = require('./providers'); const { SCRIPT_PROMPTS, SCRIPT_TYPES, buildScriptUserPrompt, stripCodeFences } = require('./script-prompts'); @@ -23,7 +26,7 @@ const buildStatus = () => { const hasApiKey = (providerId) => aiKeyStore.hasKey(providerId); const providers = {}; - for (const provider of listProviders()) { + for (const provider of listProviders(aiPreferences)) { providers[provider.id] = { ...provider, enabled: Boolean(aiPreferences?.providers?.[provider.id]?.enabled), @@ -34,7 +37,7 @@ const buildStatus = () => { return { enabled: Boolean(aiPreferences.enabled), providers, - models: listModels(), + models: listModels(aiPreferences), availableModels: getAvailableModels({ aiPreferences, hasApiKey }) }; }; @@ -59,13 +62,17 @@ const pickDefaultModelId = () => { return available[0].id; }; +const assertKnownProvider = (providerId) => { + if (!isKnownProviderId(providerId, getAiPrefs())) { + throw new Error(`Unknown AI provider: ${providerId}`); + } +}; + const registerAiIpc = (mainWindow) => { ipcMain.handle('renderer:get-ai-status', async () => buildStatus()); ipcMain.handle('renderer:set-ai-api-key', async (_event, { providerId, apiKey }) => { - if (!PROVIDERS[providerId]) { - throw new Error(`Unknown AI provider: ${providerId}`); - } + assertKnownProvider(providerId); const trimmed = typeof apiKey === 'string' ? apiKey.trim() : ''; if (!trimmed) { throw new Error('API key cannot be empty'); @@ -76,23 +83,20 @@ const registerAiIpc = (mainWindow) => { }); ipcMain.handle('renderer:clear-ai-api-key', async (_event, { providerId }) => { - if (!PROVIDERS[providerId]) { - throw new Error(`Unknown AI provider: ${providerId}`); - } + assertKnownProvider(providerId); aiKeyStore.clearKey(providerId); clearSdkCache(); return buildStatus(); }); ipcMain.handle('renderer:get-ai-api-key', async (_event, { providerId }) => { - if (!PROVIDERS[providerId]) { - throw new Error(`Unknown AI provider: ${providerId}`); - } + assertKnownProvider(providerId); return aiKeyStore.getKey(providerId) || ''; }); ipcMain.handle('renderer:ai-test-provider', async (_event, { providerId }) => { - if (!PROVIDERS[providerId]) { + const aiPrefs = getAiPrefs(); + if (!isKnownProviderId(providerId, aiPrefs)) { return { ok: false, error: `Unknown provider: ${providerId}` }; } const apiKey = aiKeyStore.getKey(providerId); @@ -100,14 +104,13 @@ const registerAiIpc = (mainWindow) => { return { ok: false, error: 'No API key configured' }; } - const aiPrefs = getAiPrefs(); const providerEnabled = aiPrefs?.providers?.[providerId]?.enabled; if (!providerEnabled) { - return { ok: false, error: `${PROVIDERS[providerId].label} is disabled` }; + return { ok: false, error: `${providerLabel(providerId, aiPrefs)} is disabled` }; } try { - const res = await PROVIDERS[providerId].validateApiKey({ apiKey }); + const res = await validateApiKeyForProvider({ providerId, apiKey, aiPreferences: aiPrefs }); if (res.ok) { return { ok: true }; } @@ -119,7 +122,7 @@ const registerAiIpc = (mainWindow) => { } return { ok: false, error: `Could not verify key (HTTP ${res.status})` }; } catch (err) { - return { ok: false, error: 'Could not reach provider. Check your network connection.' }; + return { ok: false, error: err.message || 'Could not reach provider. Check your network connection.' }; } }); diff --git a/packages/bruno-electron/src/ipc/ai/providers.js b/packages/bruno-electron/src/ipc/ai/providers.js index 5fe945ec80b..dc88db88b4d 100644 --- a/packages/bruno-electron/src/ipc/ai/providers.js +++ b/packages/bruno-electron/src/ipc/ai/providers.js @@ -1,6 +1,16 @@ const { createOpenAI } = require('@ai-sdk/openai'); const { createAnthropic } = require('@ai-sdk/anthropic'); +const OPENAI_COMPATIBLE_PREFIX = 'openai-compatible:'; + +const isOpenAiCompatibleProviderId = (id) => + typeof id === 'string' && id.startsWith(OPENAI_COMPATIBLE_PREFIX); + +const endpointIdFromProviderId = (providerId) => + isOpenAiCompatibleProviderId(providerId) ? providerId.slice(OPENAI_COMPATIBLE_PREFIX.length) : null; + +const providerIdFromEndpointId = (endpointId) => `${OPENAI_COMPATIBLE_PREFIX}${endpointId}`; + const PROVIDERS = { openai: { id: 'openai', @@ -27,8 +37,8 @@ const PROVIDERS = { }; /** - * Model catalog. Each entry is keyed by a stable id used in preferences and IPC. - * `modelId` is the value passed to the provider SDK; `id` is the Bruno-internal id. + * Static model catalog for built-in providers. User-defined custom models for + * OpenAI-compatible endpoints are layered on top at lookup time. */ const MODEL_DEFINITIONS = { // OpenAI @@ -42,18 +52,46 @@ const MODEL_DEFINITIONS = { 'claude-haiku-4-5': { provider: 'anthropic', modelId: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' } }; -// Cache SDK instances. Keyed by `${providerId}:${apiKey}` so changing keys rebuilds the SDK. +// Cache SDK instances. Built-in keyed by `${providerId}:${apiKey}`; compat +// also folds baseURL in so editing the URL rebuilds the SDK. const sdkCache = new Map(); -const getSdk = (providerId, apiKey) => { - const cacheKey = `${providerId}:${apiKey}`; - let sdk = sdkCache.get(cacheKey); - if (!sdk) { +// JSON-stringified tuple so values containing ":" (provider ids, URLs) can't +// collide and reuse an SDK configured for a different endpoint/key. +const sdkCacheKey = ({ providerId, apiKey, baseURL }) => + JSON.stringify([providerId, baseURL || '', apiKey]); + +const getCompatEndpoint = (aiPreferences, endpointId) => { + const list = Array.isArray(aiPreferences?.openaiCompatibleEndpoints) + ? aiPreferences.openaiCompatibleEndpoints + : []; + return list.find((e) => e?.id === endpointId) || null; +}; + +const compatProviderEntry = (endpoint) => ({ + id: providerIdFromEndpointId(endpoint.id), + label: endpoint.name || 'OpenAI-compatible', + apiKeyPlaceholder: 'sk-...', + apiKeyHelpUrl: null, + isCustom: true, + endpointId: endpoint.id, + baseURL: endpoint.baseURL || '' +}); + +const getSdk = ({ providerId, apiKey, baseURL }) => { + const key = sdkCacheKey({ providerId, apiKey, baseURL }); + let sdk = sdkCache.get(key); + if (sdk) return sdk; + + if (isOpenAiCompatibleProviderId(providerId)) { + sdk = createOpenAI({ apiKey, baseURL }); + } else { const provider = PROVIDERS[providerId]; if (!provider) throw new Error(`Unknown AI provider: ${providerId}`); sdk = provider.createSdk({ apiKey }); - sdkCache.set(cacheKey, sdk); } + + sdkCache.set(key, sdk); return sdk; }; @@ -61,38 +99,114 @@ const clearSdkCache = () => { sdkCache.clear(); }; -const listProviders = () => Object.values(PROVIDERS).map((p) => ({ - id: p.id, - label: p.label, - apiKeyPlaceholder: p.apiKeyPlaceholder, - apiKeyHelpUrl: p.apiKeyHelpUrl -})); +const listProviders = (aiPreferences) => { + const builtIn = Object.values(PROVIDERS).map((p) => ({ + id: p.id, + label: p.label, + apiKeyPlaceholder: p.apiKeyPlaceholder, + apiKeyHelpUrl: p.apiKeyHelpUrl, + isCustom: false + })); + + const endpoints = Array.isArray(aiPreferences?.openaiCompatibleEndpoints) + ? aiPreferences.openaiCompatibleEndpoints + : []; + + return [...builtIn, ...endpoints.map(compatProviderEntry)]; +}; + +const listModels = (aiPreferences) => { + const builtIn = Object.entries(MODEL_DEFINITIONS).map(([id, def]) => ({ + id, + label: def.label, + provider: def.provider, + isCustom: false + })); -const listModels = () => Object.entries(MODEL_DEFINITIONS).map(([id, def]) => ({ - id, - label: def.label, - provider: def.provider -})); + const endpoints = Array.isArray(aiPreferences?.openaiCompatibleEndpoints) + ? aiPreferences.openaiCompatibleEndpoints + : []; + + const custom = []; + for (const endpoint of endpoints) { + if (!endpoint?.id || !Array.isArray(endpoint.models)) continue; + for (const model of endpoint.models) { + if (!model?.id || !model?.modelId) continue; + custom.push({ + id: model.id, + label: model.label || model.modelId, + provider: providerIdFromEndpointId(endpoint.id), + isCustom: true + }); + } + } + + return [...builtIn, ...custom]; +}; + +/** Resolve a Bruno model id (built-in or custom) into its provider config. */ +const resolveModelDefinition = (modelId, aiPreferences) => { + if (MODEL_DEFINITIONS[modelId]) { + const def = MODEL_DEFINITIONS[modelId]; + return { + providerId: def.provider, + sdkModelId: def.modelId, + label: def.label, + baseURL: null + }; + } + + const endpoints = Array.isArray(aiPreferences?.openaiCompatibleEndpoints) + ? aiPreferences.openaiCompatibleEndpoints + : []; + for (const endpoint of endpoints) { + if (!endpoint?.id || !Array.isArray(endpoint.models)) continue; + const match = endpoint.models.find((m) => m?.id === modelId); + if (match) { + return { + providerId: providerIdFromEndpointId(endpoint.id), + sdkModelId: match.modelId, + label: match.label || match.modelId, + baseURL: endpoint.baseURL || '' + }; + } + } + return null; +}; + +const providerLabel = (providerId, aiPreferences) => { + if (PROVIDERS[providerId]) return PROVIDERS[providerId].label; + const endpointId = endpointIdFromProviderId(providerId); + if (endpointId) { + const endpoint = getCompatEndpoint(aiPreferences, endpointId); + if (endpoint) return endpoint.name || 'OpenAI-compatible'; + } + return providerId; +}; /** * Resolve a Bruno model id to a vercel-ai SDK model instance. * Throws if the provider isn't configured (no key) or the model is unknown. */ const getModel = (modelId, { aiPreferences, getApiKey }) => { - const def = MODEL_DEFINITIONS[modelId]; + const def = resolveModelDefinition(modelId, aiPreferences); if (!def) throw new Error(`Unknown model: ${modelId}`); - const providerConfig = aiPreferences?.providers?.[def.provider]; + const providerConfig = aiPreferences?.providers?.[def.providerId]; if (!providerConfig?.enabled) { - throw new Error(`${PROVIDERS[def.provider].label} is not enabled. Enable it in Preferences > AI.`); + throw new Error(`${providerLabel(def.providerId, aiPreferences)} is not enabled. Enable it in Preferences > AI.`); } - const apiKey = getApiKey(def.provider); + const apiKey = getApiKey(def.providerId); if (!apiKey) { - throw new Error(`${PROVIDERS[def.provider].label} API key is not configured. Add it in Preferences > AI.`); + throw new Error(`${providerLabel(def.providerId, aiPreferences)} API key is not configured. Add it in Preferences > AI.`); } - return getSdk(def.provider, apiKey)(def.modelId); + if (isOpenAiCompatibleProviderId(def.providerId) && !def.baseURL) { + throw new Error(`${providerLabel(def.providerId, aiPreferences)} is missing a Base URL. Set one in Preferences > AI.`); + } + + return getSdk({ providerId: def.providerId, apiKey, baseURL: def.baseURL })(def.sdkModelId); }; /** @@ -100,25 +214,62 @@ const getModel = (modelId, { aiPreferences, getApiKey }) => { */ const getAvailableModels = ({ aiPreferences, hasApiKey }) => { const out = []; - for (const [id, def] of Object.entries(MODEL_DEFINITIONS)) { - const providerConfig = aiPreferences?.providers?.[def.provider]; + for (const model of listModels(aiPreferences)) { + const providerConfig = aiPreferences?.providers?.[model.provider]; if (!providerConfig?.enabled) continue; - if (!hasApiKey(def.provider)) continue; + if (!hasApiKey(model.provider)) continue; - const modelConfig = aiPreferences?.models?.[id]; + const modelConfig = aiPreferences?.models?.[model.id]; if (modelConfig?.enabled === false) continue; - out.push({ id, label: def.label, provider: def.provider }); + if (isOpenAiCompatibleProviderId(model.provider)) { + const endpointId = endpointIdFromProviderId(model.provider); + const endpoint = getCompatEndpoint(aiPreferences, endpointId); + if (!endpoint?.baseURL) continue; + } + + out.push({ id: model.id, label: model.label, provider: model.provider }); } return out; }; +const isKnownProviderId = (providerId, aiPreferences) => { + if (PROVIDERS[providerId]) return true; + const endpointId = endpointIdFromProviderId(providerId); + if (!endpointId) return false; + return Boolean(getCompatEndpoint(aiPreferences, endpointId)); +}; + +const validateApiKeyForProvider = async ({ providerId, apiKey, aiPreferences }) => { + if (PROVIDERS[providerId]) { + return PROVIDERS[providerId].validateApiKey({ apiKey }); + } + const endpointId = endpointIdFromProviderId(providerId); + const endpoint = endpointId ? getCompatEndpoint(aiPreferences, endpointId) : null; + if (!endpoint?.baseURL) { + throw new Error('Endpoint Base URL is not configured'); + } + const url = `${endpoint.baseURL.replace(/\/$/, '')}/models`; + return fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(10000) + }); +}; + module.exports = { PROVIDERS, MODEL_DEFINITIONS, + OPENAI_COMPATIBLE_PREFIX, listProviders, listModels, getModel, getAvailableModels, - clearSdkCache + clearSdkCache, + isOpenAiCompatibleProviderId, + endpointIdFromProviderId, + providerIdFromEndpointId, + getCompatEndpoint, + isKnownProviderId, + validateApiKeyForProvider, + providerLabel }; diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index de0bb3a5f6b..bb3b59c9b4e 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -81,6 +81,7 @@ const defaultPreferences = { }, models: {}, defaultModel: '', + openaiCompatibleEndpoints: [], autocomplete: { enabled: true, model: '', @@ -163,6 +164,20 @@ const preferencesSchema = Yup.object().shape({ providers: Yup.object().optional(), models: Yup.object().optional(), defaultModel: Yup.string().max(200).nullable(), + openaiCompatibleEndpoints: Yup.array().of( + Yup.object({ + id: Yup.string().required(), + name: Yup.string().max(120).nullable(), + baseURL: Yup.string().max(2048).nullable(), + models: Yup.array().of( + Yup.object({ + id: Yup.string().required(), + label: Yup.string().max(120).nullable(), + modelId: Yup.string().max(200).nullable() + }) + ) + }) + ).optional(), autocomplete: Yup.object({ enabled: Yup.boolean(), model: Yup.string().max(200).nullable(), From 224d38d6719a38ce8b5576d77a40cc9534cc7d6d Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Thu, 25 Jun 2026 16:53:31 +0530 Subject: [PATCH 15/23] refactor(migration): extract migration modal (#8359) --- .../MigrateToYmlModal/StyledWrapper.js | 40 ++++++++ .../Migration/MigrateToYmlModal/index.js | 92 +++++++++++++++++++ .../Overview/Migration/StyledWrapper.js | 35 ------- .../Overview/Migration/index.js | 81 +--------------- .../RequestTabs/CollectionHeader/index.js | 23 ++--- .../migrate-to-yml-pill.spec.ts | 11 ++- 6 files changed, 154 insertions(+), 128 deletions(-) create mode 100644 packages/bruno-app/src/components/CollectionSettings/Overview/Migration/MigrateToYmlModal/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/CollectionSettings/Overview/Migration/MigrateToYmlModal/index.js diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/MigrateToYmlModal/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/MigrateToYmlModal/StyledWrapper.js new file mode 100644 index 00000000000..7cdd36491f7 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/MigrateToYmlModal/StyledWrapper.js @@ -0,0 +1,40 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .backup-section { + border: 1px solid ${(props) => props.theme.border.border2}; + border-radius: ${(props) => props.theme.border.radius.base}; + background-color: ${(props) => props.theme.background.mantle}; + padding: 12px 14px; + } + + .backup-section-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + color: ${(props) => props.theme.text}; + } + + .backup-section-title { + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 500; + color: ${(props) => props.theme.colors.text.muted}; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .backup-section-help { + font-size: ${(props) => props.theme.font.size.base}; + color: ${(props) => props.theme.colors.text.muted}; + line-height: 1.45; + margin: 0 0 10px 0; + } + + .backup-section-action { + display: flex; + justify-content: flex-start; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/MigrateToYmlModal/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/MigrateToYmlModal/index.js new file mode 100644 index 00000000000..35fab54662a --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/MigrateToYmlModal/index.js @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import toast from 'react-hot-toast'; +import { migrateCollectionToYml } from 'providers/ReduxStore/slices/collections/actions'; +import Modal from 'components/Modal'; +import Portal from 'components/Portal'; +import Button from 'ui/Button'; +import StyledWrapper from './StyledWrapper'; + +const MigrateToYmlModal = ({ collection, onClose }) => { + const dispatch = useDispatch(); + const [isMigrating, setIsMigrating] = useState(false); + const [isExporting, setIsExporting] = useState(false); + + const handleMigrate = () => { + setIsMigrating(true); + dispatch(migrateCollectionToYml(collection.uid)) + .catch(() => {}) + .finally(() => { + setIsMigrating(false); + onClose(); + }); + }; + + const handleExportBackup = async () => { + if (isExporting) return; + setIsExporting(true); + try { + const { ipcRenderer } = window; + const result = await ipcRenderer.invoke('renderer:export-collection-zip', collection.pathname, collection.name); + if (result?.success) { + toast.success('Collection backup exported'); + } + } catch (error) { + toast.error('Failed to export backup: ' + error.message); + } finally { + setIsExporting(false); + } + }; + + return ( + + + +
+

+ This will convert all files in {collection.name} from .bru format to .yml format. +

+
+

What will happen:

+
    +
  • All .bru request files will be converted to .yml
  • +
  • Environment files will be converted to YML format
  • +
  • bruno.json will be replaced with opencollection.yml
  • +
  • The collection will be reloaded after migration
  • +
+
+
+
+ Backup +
+

+ Export this collection as a ZIP archive before migrating, in case you want to restore it later. +

+
+ +
+
+
+
+
+
+ ); +}; + +export default MigrateToYmlModal; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/StyledWrapper.js index e6151a30929..8f8330d0310 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/StyledWrapper.js @@ -15,41 +15,6 @@ const StyledWrapper = styled.div` color: ${(props) => props.theme.colors.text.yellow}; } } - - .backup-section { - border: 1px solid ${(props) => props.theme.border.border2}; - border-radius: ${(props) => props.theme.border.radius.base}; - background-color: ${(props) => props.theme.background.mantle}; - padding: 12px 14px; - } - - .backup-section-head { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 6px; - color: ${(props) => props.theme.text}; - } - - .backup-section-title { - font-size: ${(props) => props.theme.font.size.sm}; - font-weight: 500; - color: ${(props) => props.theme.colors.text.muted}; - text-transform: uppercase; - letter-spacing: 0.04em; - } - - .backup-section-help { - font-size: ${(props) => props.theme.font.size.base}; - color: ${(props) => props.theme.colors.text.muted}; - line-height: 1.45; - margin: 0 0 10px 0; - } - - .backup-section-action { - display: flex; - justify-content: flex-start; - } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/index.js index 0665edfb663..fd0a615c5f1 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/index.js @@ -1,47 +1,17 @@ import React, { useState } from 'react'; -import { useDispatch } from 'react-redux'; import { IconFileCode, IconTransform } from '@tabler/icons'; -import toast from 'react-hot-toast'; -import { migrateCollectionToYml } from 'providers/ReduxStore/slices/collections/actions'; -import Modal from 'components/Modal'; import Button from 'ui/Button'; +import MigrateToYmlModal from './MigrateToYmlModal'; import StyledWrapper from './StyledWrapper'; const Migration = ({ collection }) => { - const dispatch = useDispatch(); const [showConfirmModal, setShowConfirmModal] = useState(false); - const [isMigrating, setIsMigrating] = useState(false); - const [isExporting, setIsExporting] = useState(false); // Only show for bru format collections if (collection.format !== 'bru') { return null; } - const handleMigrate = () => { - setIsMigrating(true); - setShowConfirmModal(false); - dispatch(migrateCollectionToYml(collection.uid)) - .catch(() => { }) - .finally(() => setIsMigrating(false)); - }; - - const handleExportBackup = async () => { - if (isExporting) return; - setIsExporting(true); - try { - const { ipcRenderer } = window; - const result = await ipcRenderer.invoke('renderer:export-collection-zip', collection.pathname, collection.name); - if (result?.success) { - toast.success('Collection backup exported'); - } - } catch (error) { - toast.error('Failed to export backup: ' + error.message); - } finally { - setIsExporting(false); - } - }; - return (
@@ -74,8 +44,6 @@ const Migration = ({ collection }) => { color="primary" className="mt-2" onClick={() => setShowConfirmModal(true)} - disabled={isMigrating} - loading={isMigrating} > Convert to YML @@ -84,49 +52,10 @@ const Migration = ({ collection }) => {
{showConfirmModal && ( - setShowConfirmModal(false)} - > -
-

- This will convert all files in {collection.name} from .bru format to .yml format. -

-
-

What will happen:

-
    -
  • All .bru request files will be converted to .yml
  • -
  • Environment files will be converted to YML format
  • -
  • bruno.json will be replaced with opencollection.yml
  • -
  • The collection will be reloaded after migration
  • -
-
-
-
- Backup -
-

- Export this collection as a ZIP archive before migrating, in case you want to restore it later. -

-
- -
-
-
-
+ setShowConfirmModal(false)} + /> )}
); diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js index 0b28f5586ef..6746e77ee28 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -23,7 +23,8 @@ import OpenAPISyncIcon from 'components/Icons/OpenAPISync'; import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions'; import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces'; import { showInFolder } from 'providers/ReduxStore/slices/collections/actions'; -import { toggleCollectionFileMode, toggleAppMode, updateSettingsSelectedTab } from 'providers/ReduxStore/slices/collections'; +import { toggleCollectionFileMode, toggleAppMode } from 'providers/ReduxStore/slices/collections'; +import MigrateToYmlModal from 'components/CollectionSettings/Overview/Migration/MigrateToYmlModal'; import { findItemInCollection, findItemInCollectionByPathname } from 'utils/collections'; import find from 'lodash/find'; import get from 'lodash/get'; @@ -92,6 +93,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { const [workspaceNameError, setWorkspaceNameError] = useState(''); const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false); const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false); + const [showMigrateModal, setShowMigrateModal] = useState(false); // Migrate-to-YML pill dismissal state (persisted by collection pathname) const [migratePillDismissed, setMigratePillDismissed] = useState(true); @@ -270,17 +272,6 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { ); }; - const viewMigrationSettings = () => { - dispatch( - addTab({ - uid: collection.uid, - collectionUid: collection.uid, - type: 'collection-settings' - }) - ); - dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: 'overview' })); - }; - const viewOpenApiSync = () => { dispatch(addTab({ uid: uuid(), @@ -708,7 +699,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { + +
+        
+          {content}
+          {isStreaming && isLast && |}
+        
+      
+ + ); +}; + +export default AssistantCodeBlock; diff --git a/packages/bruno-app/src/components/AiChatSidebar/DiffView/StyledWrapper.js b/packages/bruno-app/src/components/AiChatSidebar/DiffView/StyledWrapper.js new file mode 100644 index 00000000000..53520d87959 --- /dev/null +++ b/packages/bruno-app/src/components/AiChatSidebar/DiffView/StyledWrapper.js @@ -0,0 +1,298 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + margin-top: 8px; + border-radius: ${(props) => props.theme.border.radius.base}; + overflow: hidden; + border: 1px solid ${(props) => props.theme.border.border1}; + background: ${(props) => props.theme.codemirror.bg}; + + &.accepted { + border-color: ${(props) => props.theme.colors.text.green}; + } + + &.rejected { + opacity: 0.5; + } + + .diff-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: ${(props) => props.theme.background.mantle}; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + gap: 8px; + flex-wrap: nowrap; + } + + .diff-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + color: ${(props) => props.theme.colors.text.muted}; + flex-shrink: 0; + + .diff-icon { + color: ${(props) => props.theme.brand}; + display: flex; + align-items: center; + } + } + + .diff-content-type { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 6px; + border-radius: 3px; + background: ${(props) => props.theme.background.surface0}; + color: ${(props) => props.theme.colors.text.muted}; + } + + .diff-stats { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 500; + + .stat { + padding: 1px 5px; + border-radius: 4px; + } + .additions { + background: ${(props) => props.theme.status.success.background}; + color: ${(props) => props.theme.colors.text.green}; + } + .deletions { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.colors.text.danger}; + } + } + + .diff-actions { + display: flex; + gap: 6px; + flex-shrink: 0; + margin-left: auto; + } + + .diff-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 4px 8px; + font-size: 11px; + font-weight: 500; + border: 1px solid transparent; + border-radius: ${(props) => props.theme.border.radius.base}; + cursor: pointer; + white-space: nowrap; + + &.accept { + background: ${(props) => props.theme.brand}; + color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')}; + + &:hover:not(:disabled) { + opacity: 0.9; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + + &.reject { + background: transparent; + color: ${(props) => props.theme.colors.text.muted}; + border-color: ${(props) => props.theme.border.border1}; + + &:hover { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.colors.text.danger}; + border-color: ${(props) => props.theme.status.danger.background}; + } + } + } + + .status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + font-size: 11px; + border-radius: ${(props) => props.theme.border.radius.base}; + font-weight: 500; + + &.accepted { + background: ${(props) => props.theme.status.success.background}; + color: ${(props) => props.theme.colors.text.green}; + } + + &.rejected { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.colors.text.danger}; + } + } + + .diff-warning { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + font-size: 11px; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + + &.warn { + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + } + + &.error { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.colors.text.danger}; + } + } + + .diff-toggle { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 4px 8px; + font-size: 11px; + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: none; + border-top: 1px solid ${(props) => props.theme.border.border1}; + cursor: pointer; + width: 100%; + + &:hover { + background: ${(props) => props.theme.background.surface0}; + color: ${(props) => props.theme.text}; + } + } + + .diff-content { + max-height: 300px; + overflow: auto; + font-family: 'Menlo', 'Monaco', 'Courier New', monospace; + font-size: 11px; + line-height: 1.5; + + &::-webkit-scrollbar { + width: 4px; + height: 4px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: ${(props) => props.theme.border.border1}; + border-radius: 2px; + } + } + + .diff-line { + padding: 0 8px 0 4px; + white-space: pre; + display: flex; + min-height: 18px; + line-height: 18px; + + .line-number { + width: 24px; + text-align: right; + padding-right: 8px; + color: ${(props) => props.theme.colors.text.muted}; + user-select: none; + flex-shrink: 0; + opacity: 0.5; + } + + .line-prefix { + width: 12px; + flex-shrink: 0; + } + + .line-content { + flex: 1; + overflow-x: auto; + } + + &.added { + background: ${(props) => props.theme.status.success.background}; + .line-content { color: ${(props) => props.theme.colors.text.green}; } + .line-prefix { color: ${(props) => props.theme.colors.text.green}; font-weight: 600; } + } + + &.removed { + background: ${(props) => props.theme.status.danger.background}; + .line-content { color: ${(props) => props.theme.colors.text.danger}; } + .line-prefix { color: ${(props) => props.theme.colors.text.danger}; font-weight: 600; } + } + + &.unchanged { + .line-content { color: ${(props) => props.theme.colors.text.muted}; } + .line-prefix { opacity: 0; } + } + } + + .expand-marker { + display: flex; + align-items: center; + padding: 0 8px 0 4px; + min-height: 22px; + background: ${(props) => props.theme.background.mantle}; + + .expand-gutter { + width: 24px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: 4px; + } + + .expand-buttons { + display: flex; + flex-direction: column; + gap: 0; + } + + .expand-btn { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 11px; + padding: 0; + background: transparent; + border: none; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + opacity: 0.6; + + &:hover { + color: ${(props) => props.theme.text}; + opacity: 1; + } + } + + .expand-line { + flex: 1; + height: 1px; + background: ${(props) => props.theme.border.border1}; + margin-left: 8px; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/AiChatSidebar/DiffView/index.js b/packages/bruno-app/src/components/AiChatSidebar/DiffView/index.js new file mode 100644 index 00000000000..e10100064fd --- /dev/null +++ b/packages/bruno-app/src/components/AiChatSidebar/DiffView/index.js @@ -0,0 +1,210 @@ +import React, { useMemo, useState } from 'react'; +import { diffLines } from 'diff'; +import { IconCheck, IconX, IconCode, IconChevronDown, IconChevronUp } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; + +const CONTEXT_LINES = 2; +const EXPAND_CHUNK_SIZE = 20; + +const DiffView = ({ originalCode, newCode, onAccept, onReject, status, contentTypeLabel, warning, disableAccept }) => { + const [isExpanded, setIsExpanded] = useState(true); + const [expandedFromTop, setExpandedFromTop] = useState({}); + const [expandedFromBottom, setExpandedFromBottom] = useState({}); + + const diffResult = useMemo(() => { + const changes = diffLines(originalCode || '', newCode || ''); + let additions = 0; + let deletions = 0; + let lineNumber = 1; + + const lines = changes.flatMap((part) => { + const partLines = part.value.split('\n'); + if (partLines[partLines.length - 1] === '') partLines.pop(); + + return partLines.map((line) => { + const entry = { content: line, lineNumber: null }; + if (part.added) { + additions += 1; + entry.type = 'added'; + entry.lineNumber = lineNumber++; + } else if (part.removed) { + deletions += 1; + entry.type = 'removed'; + } else { + entry.type = 'unchanged'; + entry.lineNumber = lineNumber++; + } + return entry; + }); + }); + + return { lines, additions, deletions }; + }, [originalCode, newCode]); + + const hunks = useMemo(() => { + const { lines } = diffResult; + if (lines.length === 0) return []; + + const changedIndices = new Set(); + lines.forEach((line, idx) => { + if (line.type === 'added' || line.type === 'removed') changedIndices.add(idx); + }); + + const visibleIndices = new Set(); + changedIndices.forEach((idx) => { + for (let i = Math.max(0, idx - CONTEXT_LINES); i <= Math.min(lines.length - 1, idx + CONTEXT_LINES); i++) { + visibleIndices.add(i); + } + }); + + const result = []; + let i = 0; + while (i < lines.length) { + if (visibleIndices.has(i)) { + result.push({ type: 'line', data: lines[i], index: i }); + i += 1; + } else { + const start = i; + while (i < lines.length && !visibleIndices.has(i)) i += 1; + result.push({ + type: 'collapsed', + startIndex: start, + count: i - start, + lines: lines.slice(start, i) + }); + } + } + return result; + }, [diffResult]); + + const expandUp = (startIndex, totalLines) => { + setExpandedFromTop((prev) => { + const current = prev[startIndex] || 0; + const bottomExpanded = expandedFromBottom[startIndex] || 0; + const remaining = totalLines - current - bottomExpanded; + return { ...prev, [startIndex]: Math.min(current + EXPAND_CHUNK_SIZE, current + remaining) }; + }); + }; + + const expandDown = (startIndex, totalLines) => { + setExpandedFromBottom((prev) => { + const current = prev[startIndex] || 0; + const topExpanded = expandedFromTop[startIndex] || 0; + const remaining = totalLines - topExpanded - current; + return { ...prev, [startIndex]: Math.min(current + EXPAND_CHUNK_SIZE, current + remaining) }; + }); + }; + + if (diffResult.additions === 0 && diffResult.deletions === 0) return null; + + const renderActions = () => { + if (status === 'accepted') { + return ( + + Applied + + ); + } + if (status === 'rejected') { + return ( + + Dismissed + + ); + } + return ( +
+ + +
+ ); + }; + + const renderLine = (line, key) => ( +
+ {line.type !== 'removed' ? line.lineNumber : ''} + {line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '} + {line.content || ' '} +
+ ); + + const renderHunks = () => + hunks.map((hunk, idx) => { + if (hunk.type === 'line') return renderLine(hunk.data, `line-${hunk.index}`); + + const topCount = expandedFromTop[hunk.startIndex] || 0; + const bottomCount = expandedFromBottom[hunk.startIndex] || 0; + const remainingCount = hunk.count - topCount - bottomCount; + + const topLines = hunk.lines.slice(0, topCount); + const bottomLines = hunk.lines.slice(hunk.count - bottomCount); + const isAtTop = idx === 0; + const isAtBottom = idx === hunks.length - 1; + + return ( + + {topLines.map((line, lineIdx) => renderLine(line, `top-${hunk.startIndex}-${lineIdx}`))} + + {remainingCount > 0 && ( +
+
+
+ {!isAtTop && ( + + )} + {!isAtBottom && ( + + )} +
+
+
+
+ )} + + {bottomLines.map((line, lineIdx) => renderLine(line, `bottom-${hunk.startIndex}-${lineIdx}`))} + + ); + }); + + return ( + +
+
+ + {contentTypeLabel && {contentTypeLabel}} +
+ +{diffResult.additions} + -{diffResult.deletions} +
+
+ {renderActions()} +
+ + {warning && ( +
+ {warning} +
+ )} + + {isExpanded &&
{renderHunks()}
} + + +
+ ); +}; + +export default DiffView; diff --git a/packages/bruno-app/src/components/AiChatSidebar/StyledWrapper.js b/packages/bruno-app/src/components/AiChatSidebar/StyledWrapper.js new file mode 100644 index 00000000000..58a87bc7aca --- /dev/null +++ b/packages/bruno-app/src/components/AiChatSidebar/StyledWrapper.js @@ -0,0 +1,827 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + flex-shrink: 0; + height: 100%; + + .ai-sidebar { + width: 420px; + height: 100%; + background: ${(props) => props.theme.bg}; + border-left: 1px solid ${(props) => props.theme.border.border1}; + display: flex; + flex-direction: column; + } + + .ai-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + + .header-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + + .header-icon { + color: ${(props) => props.theme.brand}; + flex-shrink: 0; + display: flex; + align-items: center; + } + + .header-method { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 4px; + flex-shrink: 0; + background: ${(props) => props.theme.background.surface0}; + display: flex; + align-items: center; + + &.method-get { color: ${(props) => props.theme.request.methods.get}; } + &.method-post { color: ${(props) => props.theme.request.methods.post}; } + &.method-put { color: ${(props) => props.theme.request.methods.put}; } + &.method-delete { color: ${(props) => props.theme.request.methods.delete}; } + &.method-patch { color: ${(props) => props.theme.request.methods.patch}; } + &.method-options { color: ${(props) => props.theme.request.methods.options}; } + &.method-head { color: ${(props) => props.theme.request.methods.head}; } + } + + .header-title { + font-size: 13px; + color: ${(props) => props.theme.text}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + } + + .chat-switcher-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 2px; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + color: ${(props) => props.theme.colors.text.muted}; + flex-shrink: 0; + + &:hover { + background: ${(props) => props.theme.background.surface0}; + color: ${(props) => props.theme.text}; + } + } + + .header-actions { + display: flex; + align-items: center; + gap: 2px; + } + + .history-wrap { + position: relative; + } + + .icon-btn { + position: relative; + padding: 6px; + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + color: ${(props) => props.theme.colors.text.muted}; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: ${(props) => props.theme.background.surface0}; + color: ${(props) => props.theme.text}; + } + + &.is-active { + background: ${(props) => props.theme.background.surface0}; + color: ${(props) => props.theme.text}; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + &.close-btn:hover { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.colors.text.danger}; + } + + } + } + + .history-popover { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 20; + width: 300px; + max-height: 320px; + overflow-y: auto; + background: ${(props) => props.theme.bg}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 8px; + box-shadow: ${(props) => props.theme.shadow.md}; + padding: 4px; + + &::-webkit-scrollbar { + width: 4px; + } + &::-webkit-scrollbar-thumb { + background: ${(props) => props.theme.scrollbar.color}; + border-radius: 2px; + } + + &__empty { + padding: 16px; + text-align: center; + font-size: 11px; + color: ${(props) => props.theme.colors.text.muted}; + } + + &__item { + display: flex; + align-items: stretch; + gap: 2px; + border-radius: 4px; + + &:hover { + background: ${(props) => props.theme.background.surface0}; + } + + &.is-active { + background: ${(props) => props.theme.background.surface0}; + } + } + + &__title { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 6px 8px; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + color: ${(props) => props.theme.text}; + } + + &__title-text { + display: block; + width: 100%; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__meta { + font-size: 10px; + color: ${(props) => props.theme.colors.text.muted}; + } + + &__delete { + display: flex; + align-items: center; + justify-content: center; + padding: 0 8px; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + color: ${(props) => props.theme.colors.text.muted}; + + &:hover { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.colors.text.danger}; + } + } + } + + .ai-sidebar-messages { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; + + &::-webkit-scrollbar { + width: 4px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: ${(props) => props.theme.scrollbar.color}; + border-radius: 2px; + } + } + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 24px 16px; + animation: fadeIn 0.3s ease; + + .empty-icon { + width: 40px; + height: 40px; + border-radius: 10px; + background: ${(props) => props.theme.brand}; + display: flex; + align-items: center; + justify-content: center; + color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')}; + margin-bottom: 12px; + } + + h3 { + font-size: 14px; + font-weight: 600; + margin: 0 0 4px 0; + color: ${(props) => props.theme.text}; + } + + > p { + color: ${(props) => props.theme.colors.text.muted}; + font-size: 12px; + margin: 0 0 16px 0; + line-height: 1.4; + } + + .suggestions-title { + font-size: 11px; + color: ${(props) => props.theme.colors.text.muted}; + margin: 0 0 8px 0; + font-weight: 500; + } + + .suggestion-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: center; + } + + .suggestion-chip { + padding: 5px 10px; + background: ${(props) => props.theme.background.surface0}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 12px; + font-size: 11px; + color: ${(props) => props.theme.text}; + cursor: pointer; + + &:hover { + border-color: ${(props) => props.theme.brand}; + color: ${(props) => props.theme.brand}; + } + } + } + + .message { + animation: slideIn 0.25s ease; + + &.user .message-content { + background: ${(props) => props.theme.background.mantle}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 8px; + padding: 8px 12px; + font-size: 13px; + line-height: 1.4; + color: ${(props) => props.theme.text}; + } + + &.assistant .message-content { + color: ${(props) => props.theme.text}; + } + } + + .message-status { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + margin-bottom: 6px; + color: ${(props) => props.theme.colors.text.muted}; + + &__spinner { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid ${(props) => props.theme.brand}; + border-top-color: transparent; + animation: spin 0.9s linear infinite; + flex-shrink: 0; + } + } + + .tool-activity-log { + display: flex; + flex-direction: column; + gap: 1px; + margin: 6px 0; + padding: 4px 0; + + &.completed { + opacity: 0.7; + } + } + + .tool-activity-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: ${(props) => props.theme.colors.text.muted}; + line-height: 1.6; + padding: 1px 0; + + .tool-activity-indicator { + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + flex-shrink: 0; + } + + &.done .tool-activity-indicator { + color: ${(props) => props.theme.colors.text.green}; + } + + &.active { + color: ${(props) => props.theme.text}; + + .tool-activity-indicator { + color: ${(props) => props.theme.brand}; + } + } + + .tool-activity-spinner { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid ${(props) => props.theme.brand}; + border-top-color: transparent; + animation: spin 0.9s linear infinite; + display: block; + } + } + + .message-cancelled { + margin-top: 8px; + font-size: 11px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .assistant-code-block { + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 8px; + background: ${(props) => props.theme.codemirror.bg}; + overflow: hidden; + margin: 8px 0; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + } + + &__meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + color: ${(props) => props.theme.colors.text.muted}; + } + + &__lang { + text-transform: lowercase; + } + + &__spinner { + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid ${(props) => props.theme.brand}; + border-top-color: transparent; + animation: spin 0.9s linear infinite; + flex-shrink: 0; + } + + &__btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 6px; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 4px; + background: ${(props) => props.theme.background.mantle}; + font-size: 10px; + font-weight: 500; + color: ${(props) => props.theme.text}; + cursor: pointer; + + &:hover { + border-color: ${(props) => props.theme.brand}; + color: ${(props) => props.theme.brand}; + } + } + + &__body { + margin: 0; + padding: 10px 12px; + overflow: auto; + max-height: 240px; + font-family: 'Menlo', 'Monaco', 'Courier New', monospace; + font-size: 11px; + line-height: 1.5; + white-space: pre; + } + + .cursor { + display: inline-block; + animation: blink 1s infinite; + color: ${(props) => props.theme.brand}; + margin-left: 1px; + } + } + + .prose.markdown-body { + font-size: 13px; + line-height: 1.5; + + .cursor { + display: inline-block; + animation: blink 1s infinite; + color: ${(props) => props.theme.brand}; + margin-left: 1px; + } + + p { + margin: 0 0 8px 0; + font-size: 13px; + &:last-child { margin-bottom: 0; } + } + + h1, h2, h3, h4, h5, h6 { + margin: 10px 0 6px 0; + font-weight: 600; + line-height: 1.3; + &:first-child { margin-top: 0; } + } + + h1 { font-size: 1.3em; } + h2 { font-size: 1.2em; } + h3 { font-size: 1.1em; } + + ul, ol { + margin: 6px 0; + padding-left: 16px; + } + + li { + margin: 4px 0; + font-size: 13px; + } + + code { + background: ${(props) => props.theme.codemirror.bg}; + padding: 2px 5px; + border-radius: 4px; + font-family: 'Menlo', 'Monaco', 'Courier New', monospace; + font-size: 0.9em; + } + + pre, .code-block { + background: ${(props) => props.theme.codemirror.bg}; + padding: 10px 12px; + border-radius: 6px; + overflow-x: auto; + margin: 8px 0; + border: 1px solid ${(props) => props.theme.border.border1}; + + code { + background: none; + padding: 0; + font-size: 11px; + line-height: 1.5; + } + } + + blockquote { + border-left: 2px solid ${(props) => props.theme.brand}; + margin: 8px 0; + padding: 4px 0 4px 10px; + color: ${(props) => props.theme.colors.text.muted}; + background: ${(props) => props.theme.background.surface0}; + border-radius: 0 4px 4px 0; + } + + a { + color: ${(props) => props.theme.textLink}; + text-decoration: none; + &:hover { text-decoration: underline; } + } + + strong { font-weight: 600; } + em { font-style: italic; } + + hr { + border: none; + border-top: 1px solid ${(props) => props.theme.border.border1}; + margin: 10px 0; + } + + table { + border-collapse: collapse; + width: 100%; + margin: 8px 0; + font-size: 12px; + } + + th, td { + border: 1px solid ${(props) => props.theme.border.border1}; + padding: 6px 8px; + text-align: left; + } + + th { + background: ${(props) => props.theme.codemirror.bg}; + font-weight: 600; + } + } + + .processing-indicator { + padding: 8px 10px; + background: ${(props) => props.theme.background.surface0}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 8px; + animation: slideIn 0.2s ease; + + .processing-content { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + } + + .processing-icon { + width: 20px; + height: 20px; + border-radius: 4px; + background: ${(props) => props.theme.background.surface1}; + display: flex; + align-items: center; + justify-content: center; + color: ${(props) => props.theme.brand}; + } + + .processing-label { + font-size: 12px; + font-weight: 500; + color: ${(props) => props.theme.text}; + } + + .processing-dots { + display: flex; + gap: 3px; + margin-left: 2px; + + span { + width: 3px; + height: 3px; + background: ${(props) => props.theme.brand}; + border-radius: 50%; + animation: dotBounce 1.4s infinite ease-in-out both; + + &:nth-child(1) { animation-delay: -0.32s; } + &:nth-child(2) { animation-delay: -0.16s; } + } + } + + .processing-bar { + height: 2px; + background: ${(props) => props.theme.border.border1}; + border-radius: 1px; + overflow: hidden; + + .processing-bar-fill { + height: 100%; + width: 30%; + background: ${(props) => props.theme.brand}; + border-radius: 1px; + animation: progressSlide 1.5s infinite ease-in-out; + } + } + } + + .error-message { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + background: ${(props) => props.theme.status.danger.background}; + border: 1px solid ${(props) => props.theme.status.danger.border}; + border-radius: 6px; + + .error-icon { + width: 18px; + height: 18px; + border-radius: 50%; + background: ${(props) => props.theme.colors.text.danger}; + color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')}; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 11px; + flex-shrink: 0; + } + + .error-text { + color: ${(props) => props.theme.colors.text.danger}; + font-size: 12px; + line-height: 1.4; + } + } + + .ai-sidebar-input { + padding: 12px; + border-top: 1px solid ${(props) => props.theme.border.border1}; + + .no-models-warning { + padding: 10px 12px; + font-size: 12px; + color: ${(props) => props.theme.colors.text.muted}; + background: ${(props) => props.theme.input.bg}; + border: 1px dashed ${(props) => props.theme.border.border1}; + border-radius: 6px; + text-align: center; + line-height: 1.4; + } + + .input-container { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + background: ${(props) => props.theme.bg}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 8px; + + &:focus-within { + border-color: ${(props) => props.theme.brand}; + } + } + + textarea { + width: 100%; + padding: 0; + margin: 4px 0; + border: none; + background: transparent; + color: ${(props) => props.theme.text}; + font-size: 13px; + font-family: inherit; + line-height: 1.4; + resize: none; + outline: none; + max-height: 100px; + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } + + &:disabled { + opacity: 0.6; + } + } + + .input-actions { + display: flex; + align-items: center; + justify-content: space-between; + } + + .model-selector { + position: relative; + } + + .model-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 6px 4px 8px; + background: transparent; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.base}; + font-size: 11px; + font-weight: 500; + color: ${(props) => props.theme.text}; + cursor: pointer; + + svg:first-child { + color: ${(props) => props.theme.brand}; + } + + &:hover { + border-color: ${(props) => props.theme.border.border2}; + } + } + + .send-btn, .stop-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border: none; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + cursor: pointer; + } + + .send-btn { + background: ${(props) => props.theme.brand}; + color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')}; + + &:hover { + opacity: 0.9; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + + .stop-btn { + background: ${(props) => props.theme.colors.text.danger}; + color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')}; + + &:hover { + opacity: 0.9; + } + } + } + + @keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } + } + + @keyframes slideIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } + } + + @keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } + } + + @keyframes dotBounce { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } + } + + @keyframes progressSlide { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(400%); } + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/AiChatSidebar/constants.js b/packages/bruno-app/src/components/AiChatSidebar/constants.js new file mode 100644 index 00000000000..2da906bef5f --- /dev/null +++ b/packages/bruno-app/src/components/AiChatSidebar/constants.js @@ -0,0 +1,54 @@ +export const PROCESSING_STAGES = [ + { id: 'sending', label: 'Sending request', icon: 'send' }, + { id: 'thinking', label: 'AI is thinking', icon: 'sparkles' }, + { id: 'generating', label: 'Generating response', icon: 'wand' }, + { id: 'applying', label: 'Preparing changes', icon: 'code' } +]; + +export const CONTENT_TYPE_LABELS = { + 'app': 'App', + 'tests': 'Tests', + 'pre-request': 'Script', + 'post-response': 'Script', + 'docs': 'Docs' +}; + +export const SUGGESTIONS_BY_TYPE = { + 'app': [ + { label: 'Create a form for this request', prompt: 'Create a simple form to send this request' }, + { label: 'Add a loading spinner', prompt: 'Add a loading spinner while the request is pending' }, + { label: 'Show response in a table', prompt: 'Display the response data in a table' }, + { label: 'Add error handling', prompt: 'Add error handling with user-friendly messages' } + ], + 'tests': [ + { label: 'Generate basic tests', prompt: 'Generate tests for status code, response body, and headers' }, + { label: 'Test response structure', prompt: 'Write tests to validate the response body structure and data types' }, + { label: 'Test error cases', prompt: 'Write tests for common error scenarios' }, + { label: 'Test response time', prompt: 'Add a test to verify response time is acceptable' } + ], + 'pre-request': [ + { label: 'Add authentication', prompt: 'Add authorization header from environment variable' }, + { label: 'Set dynamic variables', prompt: 'Set dynamic request variables like timestamp or unique ID' }, + { label: 'Conditional logic', prompt: 'Add conditional logic to modify the request based on environment' } + ], + 'post-response': [ + { label: 'Extract to variables', prompt: 'Extract data from response and save to environment variables' }, + { label: 'Store auth token', prompt: 'Extract auth token from response and save for future requests' }, + { label: 'Log response', prompt: 'Log response status and body for debugging' }, + { label: 'Transform response', prompt: 'Transform and process the response data' } + ], + 'docs': [ + { label: 'Generate full docs', prompt: 'Generate comprehensive API documentation for this endpoint' }, + { label: 'Document parameters', prompt: 'Document all request parameters, headers, and body' }, + { label: 'Add examples', prompt: 'Add request and response examples' }, + { label: 'Document errors', prompt: 'Document common error responses and status codes' } + ] +}; + +export const PLACEHOLDER_BY_TYPE = { + 'tests': { empty: 'Describe the tests you want...', filled: 'Ask to modify or add tests...' }, + 'pre-request': { empty: 'Describe the script you want...', filled: 'Ask to modify the script...' }, + 'post-response': { empty: 'Describe the script you want...', filled: 'Ask to modify the script...' }, + 'docs': { empty: 'Describe the documentation...', filled: 'Ask to update the docs...' }, + 'app': { empty: 'Describe the app you want to create...', filled: 'Ask to modify your app...' } +}; diff --git a/packages/bruno-app/src/components/AiChatSidebar/index.js b/packages/bruno-app/src/components/AiChatSidebar/index.js new file mode 100644 index 00000000000..bfeed187ac3 --- /dev/null +++ b/packages/bruno-app/src/components/AiChatSidebar/index.js @@ -0,0 +1,758 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + IconX, + IconPlayerStop, + IconCheck, + IconCode, + IconWand, + IconStars, + IconCornerDownLeft, + IconChevronDown, + IconHistory, + IconPlus, + IconTrash +} from '@tabler/icons'; +import get from 'lodash/get'; +import find from 'lodash/find'; +import MenuDropdown from 'ui/MenuDropdown'; +import { focusTab } from 'providers/ReduxStore/slices/tabs'; +import { + closeAiSidebar, + sendAiMessage, + stopAiStream, + setChatBinding, + startNewConversation, + refreshChatHistory, + openConversation, + removeConversation, + setMessageCodeStatus +} from 'providers/ReduxStore/slices/chat'; +import { + updateAppCode, + updateRequestTests, + updateRequestScript, + updateResponseScript, + updateRequestDocs +} from 'providers/ReduxStore/slices/collections'; +import { findItemInCollection } from 'utils/collections'; +import { getAiStatus } from 'utils/ai'; + +import StyledWrapper from './StyledWrapper'; +import DiffView from './DiffView'; +import AssistantCodeBlock from './AssistantCodeBlock'; +import { PROCESSING_STAGES, CONTENT_TYPE_LABELS, SUGGESTIONS_BY_TYPE, PLACEHOLDER_BY_TYPE } from './constants'; +import { renderMarkdown, parseMessageSegments } from './utils'; + +const SELECTED_MODEL_LS_KEY = 'bruno.ai.chat.selectedModel'; +const AUTO_MODEL_ID = ''; + +const ToolActivityGroup = ({ activities }) => { + if (!activities?.length) return null; + const allDone = activities.every((a) => a.done); + return ( +
+ {activities.map((activity, i) => ( +
+ + {activity.done ? : } + + {activity.label}{!activity.done ? '…' : ''} +
+ ))} +
+ ); +}; + +const buildMessageTimeline = (cleanedContent, activities) => { + if (!activities?.length) { + return cleanedContent ? [{ type: 'text', content: cleanedContent }] : []; + } + if (!cleanedContent) return [{ type: 'tools', activities }]; + + const groups = []; + for (const activity of activities) { + const offset = Math.min(activity.textOffset || 0, cleanedContent.length); + const last = groups[groups.length - 1]; + if (last && last.offset === offset) last.activities.push(activity); + else groups.push({ offset, activities: [activity] }); + } + + const parts = []; + let cursor = 0; + for (const group of groups) { + if (group.offset > cursor) { + parts.push({ type: 'text', content: cleanedContent.substring(cursor, group.offset) }); + } + parts.push({ type: 'tools', activities: group.activities }); + cursor = Math.max(cursor, group.offset); + } + if (cursor < cleanedContent.length) { + parts.push({ type: 'text', content: cleanedContent.substring(cursor) }); + } + return parts; +}; + +const formatRelativeTime = (timestamp) => { + if (!timestamp) return ''; + const diff = Date.now() - timestamp; + const minute = 60 * 1000; + const hour = 60 * minute; + const day = 24 * hour; + if (diff < minute) return 'just now'; + if (diff < hour) return `${Math.floor(diff / minute)}m ago`; + if (diff < day) return `${Math.floor(diff / hour)}h ago`; + if (diff < 7 * day) return `${Math.floor(diff / day)}d ago`; + return new Date(timestamp).toLocaleDateString(); +}; + +const HistoryPopover = ({ items, activeId, onPick, onDelete, onClose }) => { + const popoverRef = useRef(null); + + useEffect(() => { + const handleClick = (e) => { + if (popoverRef.current && !popoverRef.current.contains(e.target)) { + onClose(); + } + }; + const handleKey = (e) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('mousedown', handleClick); + document.addEventListener('keydown', handleKey); + return () => { + document.removeEventListener('mousedown', handleClick); + document.removeEventListener('keydown', handleKey); + }; + }, [onClose]); + + return ( +
+ {items.length === 0 ? ( +
No past conversations
+ ) : ( + items.map((item) => ( +
+ + +
+ )) + )} +
+ ); +}; + +const AiChatSidebar = ({ collection }) => { + const dispatch = useDispatch(); + const [input, setInput] = useState(''); + const [processingStage, setProcessingStage] = useState(null); + const [availableModels, setAvailableModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(() => { + try { return localStorage.getItem(SELECTED_MODEL_LS_KEY) ?? AUTO_MODEL_ID; } catch { return AUTO_MODEL_ID; } + }); + const [historyOpen, setHistoryOpen] = useState(false); + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + const isNearBottomRef = useRef(true); + const textareaRef = useRef(null); + + const isOpen = useSelector((state) => state.chat.isOpen); + const allChats = useSelector((state) => state.chat.chats); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const preferences = useSelector((state) => state.app.preferences); + const aiEnabled = get(preferences, 'ai.enabled', false); + + const focusedTab = find(tabs, (t) => t.uid === activeTabUid); + const activeItem = focusedTab && collection ? findItemInCollection(collection, activeTabUid) : null; + + const currentChat = allChats[activeTabUid] || { messages: [], isLoading: false, error: null, historyList: [] }; + const { messages, isLoading, error, historyList, conversationId } = currentChat; + + useEffect(() => { + if (!isOpen || !aiEnabled) return; + let cancelled = false; + getAiStatus() + .then((status) => { + if (cancelled) return; + setAvailableModels(status?.availableModels || []); + }) + .catch(() => { + if (!cancelled) setAvailableModels([]); + }); + return () => { cancelled = true; }; + }, [isOpen, aiEnabled, preferences?.ai]); + + // Auto = empty string. We don't auto-correct to the first model — let the + // backend pick, so users get smart defaults that adapt as providers change. + useEffect(() => { + if (selectedModel === AUTO_MODEL_ID) return; + if (availableModels.length === 0) return; + if (availableModels.some((m) => m.id === selectedModel)) return; + setSelectedModel(AUTO_MODEL_ID); + try { localStorage.setItem(SELECTED_MODEL_LS_KEY, AUTO_MODEL_ID); } catch {} + }, [availableModels, selectedModel]); + + const requestName = activeItem?.name || 'Untitled'; + const requestMethod = activeItem?.draft + ? get(activeItem, 'draft.request.method', 'GET') + : get(activeItem, 'request.method', 'GET'); + + const requestPaneTab = focusedTab?.requestPaneTab; + const contentType = useMemo(() => { + switch (requestPaneTab) { + case 'tests': return 'tests'; + case 'script': return 'pre-request'; + case 'docs': return 'docs'; + default: return 'app'; + } + }, [requestPaneTab]); + + // Bind the chat to the active item's pathname so the history list reflects + // this specific request and persistence keys stay stable across sessions. + // Restoring the most recent conversation happens once per tab — if the + // user explicitly starts a new chat, we don't auto-replace it. + const restoredOnceRef = useRef({}); + useEffect(() => { + if (!isOpen || !activeItem || !collection) return; + const pathname = activeItem.pathname || ''; + dispatch(setChatBinding({ + tabUid: activeTabUid, + pathname, + collectionUid: collection.uid, + contentType + })); + dispatch(refreshChatHistory(activeTabUid)); + }, [isOpen, activeItem?.pathname, collection?.uid, activeTabUid, contentType, dispatch]); + + // First-open restore: if this tab has no conversation yet and there's a + // saved one for the same file, load the most recent. + useEffect(() => { + if (!isOpen || !activeTabUid) return; + if (restoredOnceRef.current[activeTabUid]) return; + if (currentChat.conversationId) return; + if (currentChat.messages?.length > 0) return; + if (!historyList || historyList.length === 0) return; + restoredOnceRef.current[activeTabUid] = true; + dispatch(openConversation(activeTabUid, historyList[0].id)); + }, [isOpen, activeTabUid, currentChat.conversationId, currentChat.messages?.length, historyList, dispatch]); + + const allContent = useMemo(() => { + if (!activeItem) return {}; + const draft = activeItem.draft; + const draftAppCode = get(activeItem, 'draft.app.code'); + return { + 'app': draftAppCode != null ? draftAppCode : get(activeItem, 'app.code', ''), + 'tests': draft ? get(draft, 'request.tests', '') : get(activeItem, 'request.tests', ''), + 'pre-request': draft ? get(draft, 'request.script.req', '') : get(activeItem, 'request.script.req', ''), + 'post-response': draft ? get(draft, 'request.script.res', '') : get(activeItem, 'request.script.res', ''), + 'docs': draft ? get(draft, 'request.docs', '') : get(activeItem, 'request.docs', '') + }; + }, [activeItem]); + + const currentContent = allContent[contentType] || ''; + + const requestContext = useMemo(() => { + if (!activeItem) return null; + const draft = activeItem.draft; + return { + url: draft ? get(activeItem, 'draft.request.url', '') : get(activeItem, 'request.url', ''), + method: draft ? get(activeItem, 'draft.request.method', '') : get(activeItem, 'request.method', ''), + headers: draft ? get(activeItem, 'draft.request.headers', []) : get(activeItem, 'request.headers', []), + params: draft ? get(activeItem, 'draft.request.params', []) : get(activeItem, 'request.params', []), + body: draft ? get(activeItem, 'draft.request.body', null) : get(activeItem, 'request.body', null), + docs: draft ? get(activeItem, 'draft.request.docs', null) : get(activeItem, 'request.docs', null), + responseStatus: get(activeItem, 'response.status', null), + responseData: get(activeItem, 'response.data', null) + }; + }, [activeItem]); + + const chatsWithMessages = useMemo(() => { + if (!collection) return []; + return Object.entries(allChats) + .filter(([, chat]) => chat.messages?.length > 0) + .map(([tabUid, chat]) => { + const item = findItemInCollection(collection, tabUid); + if (!item) return null; + const method = item.draft + ? get(item, 'draft.request.method', 'GET') + : get(item, 'request.method', 'GET'); + return { + id: tabUid, + name: item.name || 'Untitled', + method, + messageCount: chat.messages.length + }; + }) + .filter(Boolean); + }, [allChats, collection]); + + const scrollToBottom = useCallback((behavior = 'smooth') => { + messagesEndRef.current?.scrollIntoView({ behavior }); + }, []); + + const handleMessagesScroll = useCallback(() => { + const el = messagesContainerRef.current; + if (!el) return; + isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80; + }, []); + + useEffect(() => { + if (!isNearBottomRef.current) return; + const behavior = messages.some((m) => m.isStreaming) ? 'auto' : 'smooth'; + scrollToBottom(behavior); + }, [messages, scrollToBottom]); + + useEffect(() => { + if (isOpen) textareaRef.current?.focus(); + }, [isOpen]); + + useEffect(() => { + if (!isLoading) { + setProcessingStage(null); + return; + } + const last = messages[messages.length - 1]; + if (last?.isStreaming && last.content) setProcessingStage('generating'); + else if (last?.isStreaming) setProcessingStage('thinking'); + else setProcessingStage('sending'); + }, [isLoading, messages]); + + const handleTextareaChange = (e) => { + setInput(e.target.value); + const el = textareaRef.current; + if (el) { + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 150) + 'px'; + } + }; + + const handleSubmit = async (e) => { + e?.preventDefault(); + if (!input.trim() || isLoading || availableModels.length === 0) return; + + const text = input.trim(); + setInput(''); + setProcessingStage('sending'); + if (textareaRef.current) textareaRef.current.style.height = 'auto'; + + try { + await dispatch(sendAiMessage(activeTabUid, text, allContent, requestContext, selectedModel, contentType)); + setProcessingStage('applying'); + setTimeout(() => setProcessingStage(null), 500); + } catch (err) { + console.error('Failed to send AI message:', err); + setProcessingStage(null); + } + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + const handleStop = () => { + dispatch(stopAiStream(activeTabUid)); + setProcessingStage(null); + }; + + const handleApplyCode = (code, originalCode, messageIndex, msgContentType, writeIndex) => { + if (!activeItem || code == null) return; + const targetType = msgContentType || contentType; + + // Bail if the live buffer has drifted from what the AI based the diff on. + // The DiffView already disables the button in this case, but guarding here + // too means the keyboard / programmatic path can't blow away local edits. + const liveContent = allContent[targetType] || ''; + if (originalCode != null && liveContent !== originalCode) { + return; + } + + const payload = { itemUid: activeItem.uid, collectionUid: collection.uid }; + + switch (targetType) { + case 'tests': + dispatch(updateRequestTests({ ...payload, tests: code })); + break; + case 'pre-request': + dispatch(updateRequestScript({ ...payload, script: code })); + break; + case 'post-response': + dispatch(updateResponseScript({ ...payload, script: code })); + break; + case 'docs': + dispatch(updateRequestDocs({ ...payload, docs: code })); + break; + default: + dispatch(updateAppCode({ ...payload, code })); + break; + } + + dispatch(setMessageCodeStatus({ + tabUid: activeTabUid, + messageIndex, + status: 'accepted', + writeIndex + })); + }; + + const handleRejectCode = (messageIndex, writeIndex) => { + dispatch(setMessageCodeStatus({ + tabUid: activeTabUid, + messageIndex, + status: 'rejected', + writeIndex + })); + }; + + const handleNewChat = () => { + setHistoryOpen(false); + restoredOnceRef.current[activeTabUid] = true; // suppress restore + dispatch(startNewConversation({ tabUid: activeTabUid, contentType })); + textareaRef.current?.focus(); + }; + + const handlePickConversation = (id) => { + setHistoryOpen(false); + restoredOnceRef.current[activeTabUid] = true; + dispatch(openConversation(activeTabUid, id)); + }; + + const handleDeleteConversation = (id) => { + dispatch(removeConversation(activeTabUid, id)); + }; + + const handleClose = () => dispatch(closeAiSidebar()); + const handleSwitchChat = (tabUid) => dispatch(focusTab({ uid: tabUid })); + + const handleSuggestionClick = (suggestion) => { + setInput(suggestion); + textareaRef.current?.focus(); + }; + + const handleModelSelect = (modelId) => { + setSelectedModel(modelId); + try { localStorage.setItem(SELECTED_MODEL_LS_KEY, modelId); } catch {} + }; + + const selectedModelLabel = useMemo(() => { + if (selectedModel === AUTO_MODEL_ID) return 'Auto'; + return availableModels.find((m) => m.id === selectedModel)?.label || 'Auto'; + }, [availableModels, selectedModel]); + + const ModelSelectorTrigger = forwardRef((props, ref) => ( +
+ + {selectedModelLabel} + +
+ )); + ModelSelectorTrigger.displayName = 'ModelSelectorTrigger'; + + const modelMenuItems = useMemo( + () => [ + { id: AUTO_MODEL_ID, label: 'Auto', onClick: () => handleModelSelect(AUTO_MODEL_ID) }, + ...availableModels.map((model) => ({ + id: model.id, + label: model.label, + onClick: () => handleModelSelect(model.id) + })) + ], + [availableModels] + ); + + const hasActiveStream = messages.some((m) => m.isStreaming); + + const renderProcessingIndicator = () => { + if (!processingStage || processingStage === 'thinking' || hasActiveStream) return null; + const stage = PROCESSING_STAGES.find((s) => s.id === processingStage) || PROCESSING_STAGES[0]; + return ( +
+
+
+ {stage.icon === 'sparkles' && } + {stage.icon === 'wand' && } + {stage.icon === 'code' && } + {stage.icon === 'send' && } +
+ {stage.label} +
+
+
+
+ ); + }; + + const renderMessage = (msg, index) => { + const isUser = msg.role === 'user'; + const isStreaming = msg.isStreaming; + const activities = msg.toolActivity || []; + const hasPendingTool = activities.some((a) => !a.done); + const content = msg.content || ''; + + const showThinking = isStreaming && !content && activities.length === 0; + const showWorking = isStreaming && activities.length > 0 && !hasPendingTool; + const timeline = buildMessageTimeline(content, activities); + + return ( +
+
+ {isUser ? content : ( + <> + {showThinking && ( +
+ + Thinking… +
+ )} + + {timeline.map((part, partIndex) => { + if (part.type === 'tools') { + return ; + } + const segments = parseMessageSegments(part.content); + const isLastTextPart = !timeline.slice(partIndex + 1).some((p) => p.type === 'text'); + return ( + + {segments.map((segment, segIndex) => { + const isLastSegment = isLastTextPart && segIndex === segments.length - 1; + if (segment.type === 'code') { + return ( + + ); + } + return ( +
+
+ {isStreaming && isLastSegment && |} +
+ ); + })} + + ); + })} + + {showWorking && ( +
+ + Working… +
+ )} + + {!isStreaming && msg.writes?.length > 0 && msg.writes.map((write, writeIdx) => { + if (write.content === write.originalContent) return null; + const liveContent = allContent[write.type] || ''; + const isStale = liveContent !== write.originalContent; + const notRead = !write.wasRead; + return ( + handleApplyCode(write.content, write.originalContent, index, write.type, writeIdx)} + onReject={() => handleRejectCode(index, writeIdx)} + status={write.status} + /> + ); + })} + + {!isStreaming && !msg.writes && msg.code && msg.originalCode && msg.code !== msg.originalCode && ( + handleApplyCode(msg.code, msg.originalCode, index, msg.contentType)} + onReject={() => handleRejectCode(index)} + status={msg.codeStatus} + /> + )} + + {!isStreaming && msg.cancelled && ( +
Cancelled
+ )} + + )} +
+
+ ); + }; + + const renderEmptyState = () => { + const suggestions = SUGGESTIONS_BY_TYPE[contentType] || SUGGESTIONS_BY_TYPE.app; + return ( +
+
+

AI Assistant

+

Ask me to generate or modify code, tests, scripts, and docs.

+
+

Try asking:

+
+ {suggestions.map((s, i) => ( + + ))} +
+
+
+ ); + }; + + if (!isOpen) return null; + if (!activeItem) return null; + + const placeholders = PLACEHOLDER_BY_TYPE[contentType] || PLACEHOLDER_BY_TYPE.app; + const placeholder = currentContent ? placeholders.filled : placeholders.empty; + const historyCount = historyList?.length || 0; + + return ( + +
+
+
+ + {requestMethod} + {requestName} + {chatsWithMessages.length > 1 && ( + ({ + id: chat.id, + label: `${chat.method} · ${chat.name}`, + onClick: () => handleSwitchChat(chat.id) + }))} + placement="bottom-start" + selectedItemId={activeTabUid} + > + + + )} +
+
+ +
+ + {historyOpen && ( + setHistoryOpen(false)} + /> + )} +
+ +
+
+ +
+ {messages.length === 0 ? renderEmptyState() : ( + <> + {messages.map(renderMessage)} + {renderProcessingIndicator()} + + )} + {error && ( +
+
!
+
{error}
+
+ )} +
+
+ +
+ {availableModels.length === 0 ? ( +
+ No AI models available. Configure a provider and enable models in Preferences > AI. +
+ ) : ( +
+