From 65867b8b36bdeffa630d8db00e53ebd62d9af10e Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Wed, 1 Apr 2026 18:02:19 +0530 Subject: [PATCH 01/10] fix: exclude MutexApplicationJob::LockAcquisitionError from Sentry (#13965) ## Summary - Add `MutexApplicationJob::LockAcquisitionError` to Sentry's `excluded_exceptions` - This error is expected control flow (mutex lock contention during webhook processing), not a bug - Generated ~131K Sentry events in March 2026, 100% from `InstagramEventsJob` Fixes https://linear.app/chatwoot/issue/INF-58 --- config/initializers/sentry.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index ae21d7f61..eff36bfc5 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -7,7 +7,7 @@ if ENV['SENTRY_DSN'].present? # We recommend adjusting the value in production: config.traces_sample_rate = 0.1 if ENV['ENABLE_SENTRY_TRANSACTIONS'] - config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException'] + config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException', 'MutexApplicationJob::LockAcquisitionError'] # to track post data in sentry config.send_default_pii = true unless ENV['DISABLE_SENTRY_PII'] From 7b09b033ef2f82801b633dd1869fd91a3428a4b1 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:02:21 +0530 Subject: [PATCH 02/10] fix: Markdown tables don't render properly in help centre (#13971) # Pull Request Template ## Description This PR fixes an issue where markdown tables were not rendering correctly in the Help Center. The issue was caused by a backslash `(\)` being appended after table row separators `(|)`, which breaks the markdown table parsing. The issue was introduced after recent editor changes made to preserve new lines, which unintentionally affected how table markdown is parsed and displayed. ### https://github.com/chatwoot/prosemirror-schema/pull/44 Fixes https://linear.app/chatwoot/issue/CW-6714/markdown-tables-dont-render-properly-in-help-centre-preview ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? **Before** ``` | Type | What you provide |\ |--------------|-------------------------------|\ | None | No authentication |\ | Bearer Token | A token string |\ | Basic Auth | Username and password |\ | API Key | A custom header name and value| ``` **After** ``` | Type | What you provide | |--------------|-------------------------------| | None | No authentication | | Bearer Token | A token string | | Basic Auth | Username and password | | API Key | A custom header name and value| ``` ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index c8a1b7fdf..ddb6c09cc 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.8", + "@chatwoot/prosemirror-schema": "1.3.9", "@chatwoot/utils": "^0.0.52", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48edce442..cb4b2b148 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,8 +26,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.8 - version: 1.3.8 + specifier: 1.3.9 + version: 1.3.9 '@chatwoot/utils': specifier: ^0.0.52 version: 0.0.52 @@ -454,8 +454,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.8': - resolution: {integrity: sha512-Vr8eUdydmVr7iRnNky4jXKX3XD4z5HAS4bV7zJXxA4av4ig5qjTldDOg7c/C8rqYNKGR5UEOEu9CQfGcjfKVXg==} + '@chatwoot/prosemirror-schema@1.3.9': + resolution: {integrity: sha512-nbzvW4Rfe7EC+tHF/wWJK5pIxRzfQj/DDAtZI7pwM9uJfv9yQz6bAUCA7kz7Vq1NF29XOisZaT5W0005ygk1pg==} '@chatwoot/utils@0.0.52': resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==} @@ -4966,7 +4966,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.8': + '@chatwoot/prosemirror-schema@1.3.9': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.6.0 From 211fb1102dd208daee414cff1b8d71ea27ac5ebf Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 2 Apr 2026 11:26:29 +0530 Subject: [PATCH 03/10] chore: rotate oauth password if unconfirmed (#13878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user signs up with an email they don't own and sets a password, that password remains valid even after the real owner later signs in via OAuth. This means the original registrant — who never proved ownership of the email — retains working credentials on the account. This change closes that gap by rotating the password to a random value whenever an unconfirmed user completes an OAuth sign-in. The check (`oauth_user_needs_password_reset?`) is evaluated before `skip_confirmation!` runs, since confirmation would flip `confirmed_at` and mask the condition. If the user was unconfirmed, the stored password is replaced with a secure random string that satisfies the password policy. This applies to both the web and mobile OAuth callback paths, as well as the sign-up path where the password is rotated before the reset token is generated. Users who lose access to password-based login as a side effect can recover through the standard "Forgot password" flow at any time. Since they've already proven email ownership via OAuth, this is a low-friction recovery path --- .../omniauth_callbacks_controller.rb | 18 ++++++++++++++++++ .../omniauth_callbacks_controller_spec.rb | 16 ++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index af759af54..2c8387142 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa private def sign_in_user + # Capture before skip_confirmation! sets confirmed_at, which would + # make oauth_user_needs_password_reset? return false and skip the + # password reset for persisted unconfirmed users. + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def sign_in_user_on_mobile + # See comment in sign_in_user for why this is captured before skip_confirmation! + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? create_account_for_user + set_random_password_if_oauth_user token = @resource.send(:set_reset_password_token) frontend_url = ENV.fetch('FRONTEND_URL', nil) redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" @@ -81,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) end + def oauth_user_needs_password_reset? + @resource.present? && (@resource.new_record? || !@resource.confirmed?) + end + + def set_random_password_if_oauth_user + # Password must satisfy secure_password requirements (uppercase, lowercase, number, special char) + @resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted? + end + def default_devise_mapping 'user' end diff --git a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb index 603458a01..35bae8e0b 100644 --- a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb @@ -164,5 +164,21 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do expect(response).to have_http_status(:ok) end end + + it 'resets password for an unconfirmed persisted user on OAuth login' do + with_modified_env FRONTEND_URL: 'http://www.example.com' do + user = create(:user, email: 'unconfirmed-oauth@example.com', skip_confirmation: false) + original_password_digest = user.encrypted_password + set_omniauth_config('unconfirmed-oauth@example.com') + + get '/omniauth/google_oauth2/callback' + expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback') + follow_redirect! + + user.reload + expect(user).to be_confirmed + expect(user.encrypted_password).not_to eq(original_password_digest) + end + end end end From 8daf6cf6cbba1246f98a59ce474b6bd633646f46 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:40:11 +0530 Subject: [PATCH 04/10] feat: captain custom tools v1 (#13890) # Pull Request Template ## Description Adds custom tool support to v1 ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. CleanShot 2026-03-24 at 11 37 33@2x CleanShot 2026-03-24 at 11 38 18@2x CleanShot 2026-03-24 at 11 38
32@2x ## Checklist: - [x] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Shivam Mishra --- .../dashboard/api/captain/customTools.js | 6 ++ .../customTool/CustomToolCard.vue | 9 +- .../customTool/CustomToolForm.vue | 86 +++++++++++++++++-- .../emptyStates/CustomToolsPageEmptyState.vue | 12 +++ .../components-next/sidebar/Sidebar.vue | 30 +++++-- app/javascript/dashboard/featureFlags.js | 1 + .../i18n/locale/en/integrations.json | 10 ++- .../dashboard/captain/captain.routes.js | 2 +- .../routes/dashboard/captain/tools/Index.vue | 22 +++-- config/locales/en.yml | 1 + config/routes.rb | 4 +- .../captain/custom_tools_controller.rb | 24 +++++- enterprise/app/models/captain/custom_tool.rb | 27 ++++-- enterprise/app/models/concerns/toolable.rb | 31 ++++--- .../policies/captain/custom_tool_policy.rb | 4 + .../captain/llm/assistant_chat_service.rb | 22 ++++- .../captain/llm/system_prompts_service.rb | 15 +++- .../captain/tools/custom_http_tool.rb | 47 ++++++++++ .../reconcile_plan_features_service.rb | 2 +- .../models/captain/_custom_tool.json.jbuilder | 2 +- .../captain/custom_tools_controller_spec.rb | 7 +- 21 files changed, 307 insertions(+), 57 deletions(-) create mode 100644 enterprise/app/services/captain/tools/custom_http_tool.rb diff --git a/app/javascript/dashboard/api/captain/customTools.js b/app/javascript/dashboard/api/captain/customTools.js index d0818d941..471c2846b 100644 --- a/app/javascript/dashboard/api/captain/customTools.js +++ b/app/javascript/dashboard/api/captain/customTools.js @@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient { delete(id) { return axios.delete(`${this.url}/${id}`); } + + test(data = {}) { + return axios.post(`${this.url}/test`, { + custom_tool: data, + }); + } } export default new CaptainCustomTools(); diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue index d1d1dd011..d5f1e3e52 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue @@ -101,12 +101,9 @@ const authTypeLabel = computed(() => { -
-
- +
+
+ {{ description }} -import { reactive, computed, useTemplateRef, watch } from 'vue'; +import { reactive, computed, ref, useTemplateRef, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useVuelidate } from '@vuelidate/core'; -import { required } from '@vuelidate/validators'; +import { required, maxLength } from '@vuelidate/validators'; import { useMapGetter } from 'dashboard/composables/store'; +import CustomToolsAPI from 'dashboard/api/captain/customTools'; import Input from 'dashboard/components-next/input/Input.vue'; import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; @@ -72,8 +73,12 @@ const DEFAULT_PARAM = { required: false, }; +// OpenAI enforces a 64-char limit on function names. The backend slug is +// "custom_" (7 chars) + parameterized title, so cap the title conservatively. +const MAX_TOOL_NAME_LENGTH = 55; + const validationRules = { - title: { required }, + title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) }, endpoint_url: { required }, http_method: { required }, auth_type: { required }, @@ -103,9 +108,15 @@ const isLoading = computed(() => ); const getErrorMessage = (field, errorKey) => { - return v$.value[field].$error - ? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`) - : ''; + if (!v$.value[field].$error) return ''; + + const failedRule = v$.value[field].$errors[0]?.$validator; + if (failedRule === 'maxLength') { + return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.MAX_LENGTH_ERROR`, { + max: MAX_TOOL_NAME_LENGTH, + }); + } + return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`); }; const formErrors = computed(() => ({ @@ -140,6 +151,30 @@ const handleSubmit = async () => { emit('submit', state); }; + +const isTesting = ref(false); +const testResult = ref(null); +const isTestDisabled = computed( + () => state.endpoint_url.includes('{{') || !!state.request_template +); + +const handleTest = async () => { + if (!state.endpoint_url) return; + + isTesting.value = true; + testResult.value = null; + try { + const { data } = await CustomToolsAPI.test(state); + const isOk = data.status >= 200 && data.status < 300; + testResult.value = { success: isOk, status: data.status }; + } catch (e) { + const message = + e.response?.data?.error || t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR'); + testResult.value = { success: false, message }; + } finally { + isTesting.value = false; + } +};