diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml
index dfcb866ec24..8637598f7d0 100644
--- a/.github/workflows/pipelock.yml
+++ b/.github/workflows/pipelock.yml
@@ -20,5 +20,8 @@ jobs:
uses: luckyPipewrench/pipelock@v1
with:
scan-diff: 'true'
- fail-on-findings: 'false'
+ fail-on-findings: 'true'
test-vectors: 'false'
+ exclude-paths: |
+ config/locales/views/reports/
+ app/models/assistant/external/client.rb
diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb
index 22936cfb44a..e63a65c71be 100644
--- a/app/controllers/settings/hostings_controller.rb
+++ b/app/controllers/settings/hostings_controller.rb
@@ -3,7 +3,7 @@ class Settings::HostingsController < ApplicationController
guard_feature unless: -> { self_hosted? }
- before_action :ensure_admin, only: [ :update, :clear_cache ]
+ before_action :ensure_admin, only: [ :update, :clear_cache, :disconnect_external_assistant ]
def show
@breadcrumbs = [
@@ -118,6 +118,23 @@ def update
Setting.openai_json_mode = hosting_params[:openai_json_mode].presence
end
+ if hosting_params.key?(:external_assistant_url)
+ Setting.external_assistant_url = hosting_params[:external_assistant_url]
+ end
+
+ if hosting_params.key?(:external_assistant_token)
+ token_param = hosting_params[:external_assistant_token].to_s.strip
+ unless token_param.blank? || token_param == "********"
+ Setting.external_assistant_token = token_param
+ end
+ end
+
+ if hosting_params.key?(:external_assistant_agent_id)
+ Setting.external_assistant_agent_id = hosting_params[:external_assistant_agent_id]
+ end
+
+ update_assistant_type
+
redirect_to settings_hosting_path, notice: t(".success")
rescue Setting::ValidationError => error
flash.now[:alert] = error.message
@@ -129,9 +146,29 @@ def clear_cache
redirect_to settings_hosting_path, notice: t(".cache_cleared")
end
+ def disconnect_external_assistant
+ Setting.external_assistant_url = nil
+ Setting.external_assistant_token = nil
+ Setting.external_assistant_agent_id = nil
+ Current.family.update!(assistant_type: "builtin") unless ENV["ASSISTANT_TYPE"].present?
+ redirect_to settings_hosting_path, notice: t(".external_assistant_disconnected")
+ rescue => e
+ Rails.logger.error("[External Assistant] Disconnect failed: #{e.message}")
+ redirect_to settings_hosting_path, alert: t("settings.hostings.update.failure")
+ end
+
private
def hosting_params
- params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time)
+ return ActionController::Parameters.new unless params.key?(:setting)
+ params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id)
+ end
+
+ def update_assistant_type
+ return unless params[:family].present? && params[:family][:assistant_type].present?
+ return if ENV["ASSISTANT_TYPE"].present?
+
+ assistant_type = params[:family][:assistant_type]
+ Current.family.update!(assistant_type: assistant_type) if Family::ASSISTANT_TYPES.include?(assistant_type)
end
def ensure_admin
diff --git a/app/models/assistant.rb b/app/models/assistant.rb
index 582af9b0bf5..b0700939676 100644
--- a/app/models/assistant.rb
+++ b/app/models/assistant.rb
@@ -36,7 +36,7 @@ def function_classes
def implementation_for(chat)
raise Error, "chat is required" if chat.blank?
- type = chat.user&.family&.assistant_type.presence || "builtin"
+ type = ENV["ASSISTANT_TYPE"].presence || chat.user&.family&.assistant_type.presence || "builtin"
REGISTRY.fetch(type) { REGISTRY["builtin"] }
end
end
diff --git a/app/models/assistant/external.rb b/app/models/assistant/external.rb
index 276595dad02..9e8ddf2d06a 100644
--- a/app/models/assistant/external.rb
+++ b/app/models/assistant/external.rb
@@ -1,14 +1,106 @@
class Assistant::External < Assistant::Base
+ Config = Struct.new(:url, :token, :agent_id, :session_key, keyword_init: true)
+
class << self
def for_chat(chat)
new(chat)
end
+
+ def configured?
+ config.url.present? && config.token.present?
+ end
+
+ def available_for?(user)
+ configured? && allowed_user?(user)
+ end
+
+ def allowed_user?(user)
+ allowed = ENV["EXTERNAL_ASSISTANT_ALLOWED_EMAILS"]
+ return true if allowed.blank?
+ allowed.split(",").map(&:strip).include?(user.email)
+ end
+
+ def config
+ Config.new(
+ url: ENV["EXTERNAL_ASSISTANT_URL"].presence || Setting.external_assistant_url,
+ token: ENV["EXTERNAL_ASSISTANT_TOKEN"].presence || Setting.external_assistant_token,
+ agent_id: ENV["EXTERNAL_ASSISTANT_AGENT_ID"].presence || Setting.external_assistant_agent_id.presence || "main",
+ session_key: ENV.fetch("EXTERNAL_ASSISTANT_SESSION_KEY", "agent:main:main")
+ )
+ end
end
def respond_to(message)
- stop_thinking
- chat.add_error(
- StandardError.new("External assistant (OpenClaw/WebSocket) is not yet implemented.")
+ unless self.class.configured?
+ raise Assistant::Error,
+ "External assistant is not configured. Set the URL and token in Settings > Self-Hosting or via environment variables."
+ end
+
+ unless self.class.allowed_user?(chat.user)
+ raise Assistant::Error, "Your account is not authorized to use the external assistant."
+ end
+
+ assistant_message = AssistantMessage.new(
+ chat: chat,
+ content: "",
+ ai_model: "external-agent"
)
+
+ client = build_client
+ messages = build_conversation_messages
+
+ model = client.chat(
+ messages: messages,
+ user: "sure-family-#{chat.user.family_id}"
+ ) do |text|
+ if assistant_message.content.blank?
+ stop_thinking
+ assistant_message.content = text
+ assistant_message.save!
+ else
+ assistant_message.append_text!(text)
+ end
+ end
+
+ if assistant_message.new_record?
+ stop_thinking
+ raise Assistant::Error, "External assistant returned an empty response."
+ end
+
+ assistant_message.update!(ai_model: model) if model.present?
+ rescue Assistant::Error, ActiveRecord::ActiveRecordError => e
+ cleanup_partial_response(assistant_message)
+ stop_thinking
+ chat.add_error(e)
+ rescue => e
+ Rails.logger.error("[Assistant::External] Unexpected error: #{e.class} - #{e.message}")
+ cleanup_partial_response(assistant_message)
+ stop_thinking
+ chat.add_error(Assistant::Error.new("Something went wrong with the external assistant. Check server logs for details."))
end
+
+ private
+
+ def cleanup_partial_response(assistant_message)
+ assistant_message&.destroy! if assistant_message&.persisted?
+ rescue ActiveRecord::ActiveRecordError => e
+ Rails.logger.warn("[Assistant::External] Failed to clean up partial response: #{e.message}")
+ end
+
+ def build_client
+ Assistant::External::Client.new(
+ url: self.class.config.url,
+ token: self.class.config.token,
+ agent_id: self.class.config.agent_id,
+ session_key: self.class.config.session_key
+ )
+ end
+
+ MAX_CONVERSATION_MESSAGES = 20
+
+ def build_conversation_messages
+ chat.conversation_messages.ordered.last(MAX_CONVERSATION_MESSAGES).map do |msg|
+ { role: msg.role, content: msg.content }
+ end
+ end
end
diff --git a/app/models/assistant/external/client.rb b/app/models/assistant/external/client.rb
new file mode 100644
index 00000000000..b8372607380
--- /dev/null
+++ b/app/models/assistant/external/client.rb
@@ -0,0 +1,156 @@
+require "net/http"
+require "uri"
+require "json"
+
+class Assistant::External::Client
+ TIMEOUT_CONNECT = 10 # seconds
+ TIMEOUT_READ = 120 # seconds (agent may take time to reason + call tools)
+ MAX_RETRIES = 2
+ RETRY_DELAY = 1 # seconds (doubles each retry)
+
+ TRANSIENT_ERRORS = [
+ Net::OpenTimeout,
+ Net::ReadTimeout,
+ Errno::ECONNREFUSED,
+ Errno::ECONNRESET,
+ Errno::EHOSTUNREACH,
+ SocketError
+ ].freeze
+
+ def initialize(url:, token:, agent_id: "main", session_key: "agent:main:main")
+ @url = url
+ @token = token
+ @agent_id = agent_id
+ @session_key = session_key
+ end
+
+ # Streams text chunks from an OpenAI-compatible chat endpoint via SSE.
+ #
+ # messages - Array of {role:, content:} hashes (conversation history)
+ # user - Optional user identifier for session persistence
+ # block - Called with each text chunk as it arrives
+ #
+ # Returns the model identifier string from the response.
+ def chat(messages:, user: nil, &block)
+ uri = URI(@url)
+ request = build_request(uri, messages, user)
+ retries = 0
+ streaming_started = false
+
+ begin
+ http = build_http(uri)
+ model = stream_response(http, request) do |content|
+ streaming_started = true
+ block.call(content)
+ end
+ model
+ rescue *TRANSIENT_ERRORS => e
+ # Don't retry once streaming has started — caller already has partial data
+ raise Assistant::Error, "External assistant connection lost: #{e.message}" if streaming_started
+
+ retries += 1
+ if retries <= MAX_RETRIES
+ Rails.logger.warn("[External::Client] Transient error (attempt #{retries}/#{MAX_RETRIES}): #{e.class} - #{e.message}")
+ sleep(RETRY_DELAY * retries)
+ retry
+ end
+ raise Assistant::Error, "External assistant unreachable after #{MAX_RETRIES + 1} attempts: #{e.message}"
+ end
+ end
+
+ private
+
+ def stream_response(http, request, &block)
+ model = nil
+ buffer = ""
+
+ http.request(request) do |response|
+ unless response.is_a?(Net::HTTPSuccess)
+ raise Assistant::Error, "External assistant returned HTTP #{response.code}: #{response.body}"
+ end
+
+ response.read_body do |chunk|
+ buffer += chunk
+
+ while (line_end = buffer.index("\n"))
+ line = buffer.slice!(0..line_end).strip
+ next if line.empty?
+ next unless line.start_with?("data:")
+
+ data = line.delete_prefix("data:")
+ data = data.delete_prefix(" ") # SSE spec: strip one optional leading space
+ break if data == "[DONE]"
+
+ parsed = parse_sse_data(data)
+ next unless parsed
+
+ model ||= parsed["model"]
+ content = parsed.dig("choices", 0, "delta", "content")
+ block.call(content) if content.present?
+ end
+ end
+ end
+
+ model
+ end
+
+ def build_http(uri)
+ proxy_uri = resolve_proxy(uri)
+
+ if proxy_uri
+ http = Net::HTTP.new(uri.host, uri.port, proxy_uri.host, proxy_uri.port)
+ else
+ http = Net::HTTP.new(uri.host, uri.port)
+ end
+
+ http.use_ssl = (uri.scheme == "https")
+ http.open_timeout = TIMEOUT_CONNECT
+ http.read_timeout = TIMEOUT_READ
+ http
+ end
+
+ def resolve_proxy(uri)
+ proxy_env = (uri.scheme == "https") ? "HTTPS_PROXY" : "HTTP_PROXY"
+ proxy_url = ENV[proxy_env] || ENV[proxy_env.downcase]
+ return nil if proxy_url.blank?
+
+ no_proxy = ENV["NO_PROXY"] || ENV["no_proxy"]
+ return nil if host_bypasses_proxy?(uri.host, no_proxy)
+
+ URI(proxy_url)
+ rescue URI::InvalidURIError => e
+ Rails.logger.warn("[External::Client] Invalid proxy URL ignored: #{e.message}")
+ nil
+ end
+
+ def host_bypasses_proxy?(host, no_proxy)
+ return false if no_proxy.blank?
+ no_proxy.split(",").any? { |pattern| host.end_with?(pattern.strip) }
+ end
+
+ def build_request(uri, messages, user)
+ request = Net::HTTP::Post.new(uri.request_uri)
+ request["Content-Type"] = "application/json"
+ request["Authorization"] = "Bearer #{@token}"
+ request["Accept"] = "text/event-stream"
+ request["X-Agent-Id"] = @agent_id
+ request["X-Session-Key"] = @session_key
+
+ payload = {
+ model: @agent_id,
+ messages: messages,
+ stream: true
+ }
+ payload[:user] = user if user.present?
+
+ request.body = payload.to_json
+ request
+ end
+
+ def parse_sse_data(data)
+ JSON.parse(data)
+ rescue JSON::ParserError => e
+ Rails.logger.warn("[External::Client] Unparseable SSE data: #{e.message}")
+ nil
+ end
+end
diff --git a/app/models/setting.rb b/app/models/setting.rb
index 9a9facfb8c2..376dedc2752 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -10,6 +10,9 @@ class ValidationError < StandardError; end
field :openai_uri_base, type: :string, default: ENV["OPENAI_URI_BASE"]
field :openai_model, type: :string, default: ENV["OPENAI_MODEL"]
field :openai_json_mode, type: :string, default: ENV["LLM_JSON_MODE"]
+ field :external_assistant_url, type: :string, default: ENV["EXTERNAL_ASSISTANT_URL"]
+ field :external_assistant_token, type: :string, default: ENV["EXTERNAL_ASSISTANT_TOKEN"]
+ field :external_assistant_agent_id, type: :string, default: ENV.fetch("EXTERNAL_ASSISTANT_AGENT_ID", "main")
field :brand_fetch_client_id, type: :string, default: ENV["BRAND_FETCH_CLIENT_ID"]
field :brand_fetch_high_res_logos, type: :boolean, default: ENV.fetch("BRAND_FETCH_HIGH_RES_LOGOS", "false") == "true"
diff --git a/app/models/user.rb b/app/models/user.rb
index 5aef7afeba8..24ce74a0b5f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -136,7 +136,16 @@ def show_ai_sidebar?
end
def ai_available?
- !Rails.application.config.app_mode.self_hosted? || ENV["OPENAI_ACCESS_TOKEN"].present? || Setting.openai_access_token.present?
+ return true unless Rails.application.config.app_mode.self_hosted?
+
+ effective_type = ENV["ASSISTANT_TYPE"].presence || family&.assistant_type.presence || "builtin"
+
+ case effective_type
+ when "external"
+ Assistant::External.available_for?(self)
+ else
+ ENV["OPENAI_ACCESS_TOKEN"].present? || Setting.openai_access_token.present?
+ end
end
def ai_enabled?
diff --git a/app/views/settings/hostings/_assistant_settings.html.erb b/app/views/settings/hostings/_assistant_settings.html.erb
new file mode 100644
index 00000000000..c7451534b35
--- /dev/null
+++ b/app/views/settings/hostings/_assistant_settings.html.erb
@@ -0,0 +1,112 @@
+
+
+
<%= t(".title") %>
+ <% if ENV["ASSISTANT_TYPE"].present? %>
+
<%= t(".env_notice", type: ENV["ASSISTANT_TYPE"]) %>
+ <% else %>
+
<%= t(".description") %>
+ <% end %>
+
+
+ <% effective_type = ENV["ASSISTANT_TYPE"].presence || Current.family.assistant_type %>
+
+ <%= styled_form_with model: Current.family,
+ url: settings_hosting_path,
+ method: :patch,
+ class: "space-y-4",
+ data: {
+ controller: "auto-submit-form",
+ "auto-submit-form-trigger-event-value": "change"
+ } do |form| %>
+ <%= form.select :assistant_type,
+ options_for_select(
+ [
+ [t(".type_builtin"), "builtin"],
+ [t(".type_external"), "external"]
+ ],
+ effective_type
+ ),
+ { label: t(".type_label") },
+ { disabled: ENV["ASSISTANT_TYPE"].present?,
+ data: { "auto-submit-form-target": "auto" } } %>
+ <% end %>
+ <% if effective_type == "external" %>
+
+ <% if Assistant::External.configured? %>
+
+ <%= t(".external_configured") %>
+ <% else %>
+
+ <%= t(".external_not_configured") %>
+ <% end %>
+
+
+ <% if ENV["EXTERNAL_ASSISTANT_URL"].present? && ENV["EXTERNAL_ASSISTANT_TOKEN"].present? %>
+
<%= t(".env_configured_external") %>
+ <% end %>
+
+ <% if Assistant::External.configured? && !ENV["EXTERNAL_ASSISTANT_URL"].present? %>
+
+
+
<%= t(".disconnect_title") %>
+
<%= t(".disconnect_description") %>
+
+ <%= button_to t(".disconnect_button"),
+ disconnect_external_assistant_settings_hosting_path,
+ method: :delete,
+ class: "bg-red-600 fg-inverse text-sm font-medium rounded-lg px-4 py-2 whitespace-nowrap",
+ data: { turbo_confirm: {
+ title: t(".confirm_disconnect.title"),
+ body: t(".confirm_disconnect.body"),
+ accept: t(".disconnect_button"),
+ acceptClass: "w-full bg-red-600 fg-inverse rounded-xl text-center p-[10px] border mb-2"
+ }} %>
+
+ <% end %>
+
+ <%= styled_form_with model: Setting.new,
+ url: settings_hosting_path,
+ method: :patch,
+ class: "space-y-4",
+ data: {
+ controller: "auto-submit-form",
+ "auto-submit-form-trigger-event-value": "blur"
+ } do |form| %>
+ <%= form.text_field :external_assistant_url,
+ label: t(".url_label"),
+ placeholder: t(".url_placeholder"),
+ value: Setting.external_assistant_url,
+ autocomplete: "off",
+ autocapitalize: "none",
+ spellcheck: "false",
+ inputmode: "url",
+ disabled: ENV["EXTERNAL_ASSISTANT_URL"].present?,
+ data: { "auto-submit-form-target": "auto" } %>
+
<%= t(".url_help") %>
+
+ <%= form.password_field :external_assistant_token,
+ label: t(".token_label"),
+ placeholder: t(".token_placeholder"),
+ value: (Setting.external_assistant_token.present? ? "********" : nil),
+ autocomplete: "off",
+ autocapitalize: "none",
+ spellcheck: "false",
+ inputmode: "text",
+ disabled: ENV["EXTERNAL_ASSISTANT_TOKEN"].present?,
+ data: { "auto-submit-form-target": "auto" } %>
+
<%= t(".token_help") %>
+
+ <%= form.text_field :external_assistant_agent_id,
+ label: t(".agent_id_label"),
+ placeholder: t(".agent_id_placeholder"),
+ value: Setting.external_assistant_agent_id,
+ autocomplete: "off",
+ autocapitalize: "none",
+ spellcheck: "false",
+ inputmode: "text",
+ disabled: ENV["EXTERNAL_ASSISTANT_AGENT_ID"].present?,
+ data: { "auto-submit-form-target": "auto" } %>
+
<%= t(".agent_id_help") %>
+ <% end %>
+ <% end %>
+
diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb
index 00b60c82371..adb78ec51ad 100644
--- a/app/views/settings/hostings/show.html.erb
+++ b/app/views/settings/hostings/show.html.erb
@@ -1,4 +1,7 @@
<%= content_for :page_title, t(".title") %>
+<%= settings_section title: t(".ai_assistant") do %>
+ <%= render "settings/hostings/assistant_settings" %>
+<% end %>
<%= settings_section title: t(".general") do %>
<%= render "settings/hostings/openai_settings" %>
diff --git a/charts/sure/templates/_env.tpl b/charts/sure/templates/_env.tpl
index ccf0c1b6959..e50ab7ce85b 100644
--- a/charts/sure/templates/_env.tpl
+++ b/charts/sure/templates/_env.tpl
@@ -11,6 +11,7 @@ The helper always injects:
- optional Active Record Encryption keys (controlled by rails.encryptionEnv.enabled)
- optional DATABASE_URL + DB_PASSWORD (includeDatabase=true and helper can compute a DB URL)
- optional REDIS_URL + REDIS_PASSWORD (includeRedis=true and helper can compute a Redis URL)
+- optional HTTPS_PROXY / HTTP_PROXY / NO_PROXY (pipelock.enabled=true)
- rails.settings / rails.extraEnv / rails.extraEnvVars
- optional additional per-workload env / envFrom blocks via extraEnv / extraEnvFrom.
*/}}
@@ -77,10 +78,44 @@ The helper always injects:
{{- end }}
{{- end }}
{{- end }}
+{{- if and $ctx.Values.pipelock.enabled (ne (toString (dig "forwardProxy" "enabled" true $ctx.Values.pipelock)) "false") }}
+{{- $proxyPort := 8888 -}}
+{{- if $ctx.Values.pipelock.forwardProxy -}}
+{{- $proxyPort = int ($ctx.Values.pipelock.forwardProxy.port | default 8888) -}}
+{{- end }}
+- name: HTTPS_PROXY
+ value: {{ printf "http://%s-pipelock.%s.svc.cluster.local:%d" (include "sure.fullname" $ctx) $ctx.Release.Namespace $proxyPort | quote }}
+- name: HTTP_PROXY
+ value: {{ printf "http://%s-pipelock.%s.svc.cluster.local:%d" (include "sure.fullname" $ctx) $ctx.Release.Namespace $proxyPort | quote }}
+- name: NO_PROXY
+ value: "localhost,127.0.0.1,.svc.cluster.local,.cluster.local"
+{{- end }}
{{- range $k, $v := $ctx.Values.rails.settings }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
+{{- if $ctx.Values.rails.externalAssistant.enabled }}
+- name: EXTERNAL_ASSISTANT_URL
+ value: {{ $ctx.Values.rails.externalAssistant.url | quote }}
+{{- if $ctx.Values.rails.externalAssistant.tokenSecretRef }}
+- name: EXTERNAL_ASSISTANT_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: {{ $ctx.Values.rails.externalAssistant.tokenSecretRef.name }}
+ key: {{ $ctx.Values.rails.externalAssistant.tokenSecretRef.key }}
+{{- else }}
+- name: EXTERNAL_ASSISTANT_TOKEN
+ value: {{ $ctx.Values.rails.externalAssistant.token | quote }}
+{{- end }}
+- name: EXTERNAL_ASSISTANT_AGENT_ID
+ value: {{ $ctx.Values.rails.externalAssistant.agentId | quote }}
+- name: EXTERNAL_ASSISTANT_SESSION_KEY
+ value: {{ $ctx.Values.rails.externalAssistant.sessionKey | quote }}
+{{- if $ctx.Values.rails.externalAssistant.allowedEmails }}
+- name: EXTERNAL_ASSISTANT_ALLOWED_EMAILS
+ value: {{ $ctx.Values.rails.externalAssistant.allowedEmails | quote }}
+{{- end }}
+{{- end }}
{{- range $k, $v := $ctx.Values.rails.extraEnv }}
- name: {{ $k }}
value: {{ $v | quote }}
diff --git a/charts/sure/templates/_helpers.tpl b/charts/sure/templates/_helpers.tpl
index 43612795972..d36105db9c1 100644
--- a/charts/sure/templates/_helpers.tpl
+++ b/charts/sure/templates/_helpers.tpl
@@ -157,3 +157,27 @@ true
{{- default "redis-password" .Values.redis.passwordKey -}}
{{- end -}}
{{- end -}}
+
+{{/* Pipelock image string */}}
+{{- define "sure.pipelockImage" -}}
+{{- $repo := "ghcr.io/luckypipewrench/pipelock" -}}
+{{- $tag := "latest" -}}
+{{- if .Values.pipelock.image -}}
+{{- $repo = .Values.pipelock.image.repository | default $repo -}}
+{{- $tag = .Values.pipelock.image.tag | default $tag -}}
+{{- end -}}
+{{- printf "%s:%s" $repo $tag -}}
+{{- end -}}
+
+{{/* Pipelock MCP upstream URL (auto-compute or explicit override) */}}
+{{- define "sure.pipelockUpstream" -}}
+{{- $upstream := "" -}}
+{{- if .Values.pipelock.mcpProxy -}}
+{{- $upstream = .Values.pipelock.mcpProxy.upstream | default "" -}}
+{{- end -}}
+{{- if $upstream -}}
+{{- $upstream -}}
+{{- else -}}
+{{- printf "http://%s:%d/mcp" (include "sure.fullname" .) (int (.Values.service.port | default 80)) -}}
+{{- end -}}
+{{- end -}}
diff --git a/charts/sure/templates/pipelock-configmap.yaml b/charts/sure/templates/pipelock-configmap.yaml
new file mode 100644
index 00000000000..f840961e25c
--- /dev/null
+++ b/charts/sure/templates/pipelock-configmap.yaml
@@ -0,0 +1,76 @@
+{{- if .Values.pipelock.enabled }}
+{{- $fwdEnabled := true -}}
+{{- $fwdMaxTunnel := 300 -}}
+{{- $fwdIdleTimeout := 60 -}}
+{{- if .Values.pipelock.forwardProxy -}}
+{{- if hasKey .Values.pipelock.forwardProxy "enabled" -}}
+{{- $fwdEnabled = .Values.pipelock.forwardProxy.enabled -}}
+{{- end -}}
+{{- $fwdMaxTunnel = int (.Values.pipelock.forwardProxy.maxTunnelSeconds | default 300) -}}
+{{- $fwdIdleTimeout = int (.Values.pipelock.forwardProxy.idleTimeoutSeconds | default 60) -}}
+{{- end -}}
+{{- $wsEnabled := false -}}
+{{- $wsMaxMsg := 1048576 -}}
+{{- $wsMaxConns := 128 -}}
+{{- $wsScanText := true -}}
+{{- $wsAllowBinary := false -}}
+{{- $wsForwardCookies := false -}}
+{{- $wsMaxConnSec := 3600 -}}
+{{- $wsIdleTimeout := 300 -}}
+{{- $wsOriginPolicy := "rewrite" -}}
+{{- if .Values.pipelock.websocketProxy -}}
+{{- if hasKey .Values.pipelock.websocketProxy "enabled" -}}
+{{- $wsEnabled = .Values.pipelock.websocketProxy.enabled -}}
+{{- end -}}
+{{- $wsMaxMsg = int (.Values.pipelock.websocketProxy.maxMessageBytes | default 1048576) -}}
+{{- $wsMaxConns = int (.Values.pipelock.websocketProxy.maxConcurrentConnections | default 128) -}}
+{{- if hasKey .Values.pipelock.websocketProxy "scanTextFrames" -}}
+{{- $wsScanText = .Values.pipelock.websocketProxy.scanTextFrames -}}
+{{- end -}}
+{{- if hasKey .Values.pipelock.websocketProxy "allowBinaryFrames" -}}
+{{- $wsAllowBinary = .Values.pipelock.websocketProxy.allowBinaryFrames -}}
+{{- end -}}
+{{- if hasKey .Values.pipelock.websocketProxy "forwardCookies" -}}
+{{- $wsForwardCookies = .Values.pipelock.websocketProxy.forwardCookies -}}
+{{- end -}}
+{{- $wsMaxConnSec = int (.Values.pipelock.websocketProxy.maxConnectionSeconds | default 3600) -}}
+{{- $wsIdleTimeout = int (.Values.pipelock.websocketProxy.idleTimeoutSeconds | default 300) -}}
+{{- $wsOriginPolicy = .Values.pipelock.websocketProxy.originPolicy | default "rewrite" -}}
+{{- end }}
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ include "sure.fullname" . }}-pipelock
+ labels:
+ {{- include "sure.labels" . | nindent 4 }}
+data:
+ pipelock.yaml: |
+ forward_proxy:
+ enabled: {{ $fwdEnabled }}
+ max_tunnel_seconds: {{ $fwdMaxTunnel }}
+ idle_timeout_seconds: {{ $fwdIdleTimeout }}
+ websocket_proxy:
+ enabled: {{ $wsEnabled }}
+ max_message_bytes: {{ $wsMaxMsg }}
+ max_concurrent_connections: {{ $wsMaxConns }}
+ scan_text_frames: {{ $wsScanText }}
+ allow_binary_frames: {{ $wsAllowBinary }}
+ forward_cookies: {{ $wsForwardCookies }}
+ strip_compression: true
+ max_connection_seconds: {{ $wsMaxConnSec }}
+ idle_timeout_seconds: {{ $wsIdleTimeout }}
+ origin_policy: {{ $wsOriginPolicy }}
+ dlp:
+ scan_env: true
+ response_scanning:
+ enabled: true
+ action: warn
+ mcp_input_scanning:
+ enabled: true
+ action: block
+ on_parse_error: block
+ mcp_tool_scanning:
+ enabled: true
+ action: warn
+ detect_drift: true
+{{- end }}
diff --git a/charts/sure/templates/pipelock-deployment.yaml b/charts/sure/templates/pipelock-deployment.yaml
new file mode 100644
index 00000000000..f35db3e4936
--- /dev/null
+++ b/charts/sure/templates/pipelock-deployment.yaml
@@ -0,0 +1,101 @@
+{{- if .Values.pipelock.enabled }}
+{{- $fwdPort := 8888 -}}
+{{- $mcpPort := 8889 -}}
+{{- $pullPolicy := "IfNotPresent" -}}
+{{- if .Values.pipelock.forwardProxy -}}
+{{- $fwdPort = int (.Values.pipelock.forwardProxy.port | default 8888) -}}
+{{- end -}}
+{{- if .Values.pipelock.mcpProxy -}}
+{{- $mcpPort = int (.Values.pipelock.mcpProxy.port | default 8889) -}}
+{{- end -}}
+{{- if .Values.pipelock.image -}}
+{{- $pullPolicy = .Values.pipelock.image.pullPolicy | default "IfNotPresent" -}}
+{{- end }}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "sure.fullname" . }}-pipelock
+ labels:
+ {{- include "sure.labels" . | nindent 4 }}
+spec:
+ replicas: {{ .Values.pipelock.replicas | default 1 }}
+ selector:
+ matchLabels:
+ app.kubernetes.io/component: pipelock
+ {{- include "sure.selectorLabels" . | nindent 6 }}
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/component: pipelock
+ {{- include "sure.selectorLabels" . | nindent 8 }}
+ annotations:
+ checksum/config: {{ include (print $.Template.BasePath "/pipelock-configmap.yaml") . | sha256sum }}
+ {{- with .Values.pipelock.podAnnotations }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ spec:
+ securityContext:
+ {{- toYaml .Values.podSecurityContext | nindent 8 }}
+ {{- $plSecrets := coalesce .Values.pipelock.image.imagePullSecrets .Values.image.imagePullSecrets }}
+ {{- if $plSecrets }}
+ imagePullSecrets:
+ {{- toYaml $plSecrets | nindent 8 }}
+ {{- end }}
+ volumes:
+ - name: config
+ configMap:
+ name: {{ include "sure.fullname" . }}-pipelock
+ containers:
+ - name: pipelock
+ image: {{ include "sure.pipelockImage" . }}
+ imagePullPolicy: {{ $pullPolicy }}
+ args:
+ - "run"
+ - "--config"
+ - "/etc/pipelock/pipelock.yaml"
+ - "--listen"
+ - "0.0.0.0:{{ $fwdPort }}"
+ - "--mode"
+ - {{ .Values.pipelock.mode | default "balanced" | quote }}
+ - "--mcp-listen"
+ - "0.0.0.0:{{ $mcpPort }}"
+ - "--mcp-upstream"
+ - {{ include "sure.pipelockUpstream" . | quote }}
+ volumeMounts:
+ - name: config
+ mountPath: /etc/pipelock
+ readOnly: true
+ ports:
+ - name: proxy
+ containerPort: {{ $fwdPort }}
+ protocol: TCP
+ - name: mcp
+ containerPort: {{ $mcpPort }}
+ protocol: TCP
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: proxy
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ timeoutSeconds: 3
+ failureThreshold: 3
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: proxy
+ initialDelaySeconds: 3
+ periodSeconds: 5
+ timeoutSeconds: 2
+ failureThreshold: 3
+ securityContext:
+ {{- toYaml .Values.securityContext | nindent 12 }}
+ resources:
+ {{- toYaml (.Values.pipelock.resources | default dict) | nindent 12 }}
+ nodeSelector:
+ {{- toYaml (.Values.pipelock.nodeSelector | default dict) | nindent 8 }}
+ affinity:
+ {{- toYaml (.Values.pipelock.affinity | default dict) | nindent 8 }}
+ tolerations:
+ {{- toYaml (.Values.pipelock.tolerations | default list) | nindent 8 }}
+{{- end }}
diff --git a/charts/sure/templates/pipelock-service.yaml b/charts/sure/templates/pipelock-service.yaml
new file mode 100644
index 00000000000..01be758c763
--- /dev/null
+++ b/charts/sure/templates/pipelock-service.yaml
@@ -0,0 +1,30 @@
+{{- if .Values.pipelock.enabled }}
+{{- $fwdPort := 8888 -}}
+{{- $mcpPort := 8889 -}}
+{{- if .Values.pipelock.forwardProxy -}}
+{{- $fwdPort = int (.Values.pipelock.forwardProxy.port | default 8888) -}}
+{{- end -}}
+{{- if .Values.pipelock.mcpProxy -}}
+{{- $mcpPort = int (.Values.pipelock.mcpProxy.port | default 8889) -}}
+{{- end }}
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "sure.fullname" . }}-pipelock
+ labels:
+ {{- include "sure.labels" . | nindent 4 }}
+spec:
+ type: {{ (.Values.pipelock.service).type | default "ClusterIP" }}
+ selector:
+ app.kubernetes.io/component: pipelock
+ {{- include "sure.selectorLabels" . | nindent 4 }}
+ ports:
+ - name: proxy
+ port: {{ $fwdPort }}
+ targetPort: proxy
+ protocol: TCP
+ - name: mcp
+ port: {{ $mcpPort }}
+ targetPort: mcp
+ protocol: TCP
+{{- end }}
diff --git a/charts/sure/values.yaml b/charts/sure/values.yaml
index 3ecd95f940d..c2204d3fbcb 100644
--- a/charts/sure/values.yaml
+++ b/charts/sure/values.yaml
@@ -54,6 +54,20 @@ rails:
ONBOARDING_STATE: "open"
AI_DEBUG_MODE: "false"
+ # External AI Assistant (optional)
+ # Delegates chat to a remote AI agent that calls back via MCP.
+ externalAssistant:
+ enabled: false
+ url: "" # e.g., https://your-agent-host/v1/chat
+ token: "" # Bearer token for the external AI gateway
+ agentId: "main" # Agent routing identifier
+ sessionKey: "agent:main:main" # Session key for persistent agent sessions
+ allowedEmails: "" # Comma-separated emails allowed to use external assistant (empty = all)
+ # For production, use a Secret reference instead of plaintext:
+ # tokenSecretRef:
+ # name: external-assistant-secret
+ # key: token
+
# Database: CloudNativePG (operator chart dependency) and a Cluster CR (optional)
cloudnative-pg:
config:
@@ -465,3 +479,53 @@ hpa:
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
+
+# Pipelock: AI agent security proxy (optional)
+# Provides forward proxy (outbound HTTPS scanning) and MCP reverse proxy
+# (inbound MCP traffic scanning for prompt injection, DLP, tool poisoning).
+# More info: https://github.com/luckyPipewrench/pipelock
+pipelock:
+ enabled: false
+ image:
+ repository: ghcr.io/luckypipewrench/pipelock
+ tag: "0.2.7"
+ pullPolicy: IfNotPresent
+ imagePullSecrets: []
+ replicas: 1
+ # Pipelock run mode: strict, balanced, audit
+ mode: balanced
+ forwardProxy:
+ enabled: true
+ port: 8888
+ maxTunnelSeconds: 300
+ idleTimeoutSeconds: 60
+ mcpProxy:
+ port: 8889
+ # Auto-computed when empty: http://:/mcp
+ upstream: ""
+ # WebSocket proxy: bidirectional frame scanning for ws/wss connections.
+ # Runs on the same listener as the forward proxy at /ws?url=.
+ # Requires Pipelock >= 0.2.9 (or current dev build).
+ websocketProxy:
+ # Requires image.tag >= 0.2.9. Update pipelock.image.tag before enabling.
+ enabled: false
+ maxMessageBytes: 1048576 # 1MB per message
+ maxConcurrentConnections: 128
+ scanTextFrames: true # DLP + injection scanning on text frames
+ allowBinaryFrames: false # block binary frames by default
+ forwardCookies: false
+ maxConnectionSeconds: 3600 # 1 hour max connection lifetime
+ idleTimeoutSeconds: 300 # 5 min idle timeout
+ originPolicy: rewrite # rewrite, forward, or strip
+ service:
+ type: ClusterIP
+ resources:
+ requests:
+ cpu: 50m
+ memory: 64Mi
+ limits:
+ memory: 128Mi
+ podAnnotations: {}
+ nodeSelector: {}
+ tolerations: []
+ affinity: {}
diff --git a/compose.example.ai.yml b/compose.example.ai.yml
index e711fc8f407..ba86f831fa4 100644
--- a/compose.example.ai.yml
+++ b/compose.example.ai.yml
@@ -1,21 +1,33 @@
# ===========================================================================
-# Example Docker Compose file with additional Ollama service for LLM tools
+# Example Docker Compose file with Ollama (local LLM) and Pipelock (agent
+# security proxy)
# ===========================================================================
#
# Purpose:
# --------
#
-# This file is an example Docker Compose configuration for self hosting
-# Sure with Ollama on your local machine or on a cloud VPS.
+# This file extends the standard Sure setup with two optional capabilities:
#
-# The configuration below is a "standard" setup that works out of the box,
-# but if you're running this outside of a local network, it is recommended
-# to set the environment variables for extra security.
+# Pipelock — agent security proxy (always runs)
+# - Forward proxy (port 8888): scans outbound HTTPS from Faraday-based
+# clients (e.g. ruby-openai). NOT covered: SimpleFin, Coinbase, or
+# anything using Net::HTTP/HTTParty directly. HTTPS_PROXY is
+# cooperative; Docker Compose has no egress network policy.
+# - MCP reverse proxy (port 8889): scans inbound AI traffic (DLP,
+# prompt injection, tool poisoning, tool call policy). External AI
+# clients should connect to Pipelock on port 8889 rather than
+# directly to Sure's /mcp endpoint. Note: /mcp is still reachable
+# on web port 3000 (auth token required); Pipelock adds scanning
+# but Docker Compose cannot enforce network-level routing.
+#
+# Ollama + Open WebUI — local LLM inference (optional, --profile ai)
+# - Only starts when you run: docker compose --profile ai up
#
# Setup:
# ------
#
-# To run this, you should read the setup guide:
+# 1. Copy pipelock.example.yaml alongside this file (or customize it).
+# 2. Read the full setup guide:
#
# https://github.com/we-promise/sure/blob/main/docs/hosting/docker.md
#
@@ -41,6 +53,17 @@ x-rails-env: &rails_env
DB_HOST: db
DB_PORT: 5432
REDIS_URL: redis://redis:6379/1
+ # MCP server endpoint — enables /mcp for external AI assistants (e.g. Claude, GPT).
+ # Set both values to activate. MCP_USER_EMAIL must match an existing user's email.
+ # External AI clients should connect via Pipelock (port 8889) for scanning.
+ MCP_API_TOKEN: ${MCP_API_TOKEN:-}
+ MCP_USER_EMAIL: ${MCP_USER_EMAIL:-}
+ # Route outbound HTTPS through Pipelock for clients that respect HTTPS_PROXY.
+ # Covered: OpenAI API (ruby-openai/Faraday). NOT covered: SimpleFin, Coinbase (Net::HTTP).
+ HTTPS_PROXY: "http://pipelock:8888"
+ HTTP_PROXY: "http://pipelock:8888"
+ # Skip proxy for internal Docker network services (including ollama for local LLM calls)
+ NO_PROXY: "db,redis,pipelock,ollama,localhost,127.0.0.1"
AI_DEBUG_MODE: "true" # Useful for debugging, set to "false" in production
# Ollama using OpenAI API compatible endpoints
OPENAI_ACCESS_TOKEN: token-can-be-any-value-for-ollama
@@ -48,8 +71,51 @@ x-rails-env: &rails_env
OPENAI_URI_BASE: http://ollama:11434/v1
# NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this.
# OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN}
+ # External AI Assistant — delegates chat to a remote AI agent (e.g., OpenClaw).
+ # The agent calls back to Sure's /mcp endpoint for financial data.
+ # Set EXTERNAL_ASSISTANT_URL + TOKEN to activate, then either set ASSISTANT_TYPE=external
+ # here (forces all families) or choose "External" in Settings > Self-Hosting > AI Assistant.
+ ASSISTANT_TYPE: ${ASSISTANT_TYPE:-}
+ EXTERNAL_ASSISTANT_URL: ${EXTERNAL_ASSISTANT_URL:-}
+ EXTERNAL_ASSISTANT_TOKEN: ${EXTERNAL_ASSISTANT_TOKEN:-}
+ EXTERNAL_ASSISTANT_AGENT_ID: ${EXTERNAL_ASSISTANT_AGENT_ID:-main}
+ EXTERNAL_ASSISTANT_SESSION_KEY: ${EXTERNAL_ASSISTANT_SESSION_KEY:-agent:main:main}
+ EXTERNAL_ASSISTANT_ALLOWED_EMAILS: ${EXTERNAL_ASSISTANT_ALLOWED_EMAILS:-}
services:
+ pipelock:
+ image: ghcr.io/luckypipewrench/pipelock:latest # pin to a specific version (e.g., :0.2.7) for production
+ container_name: pipelock
+ hostname: pipelock
+ restart: unless-stopped
+ volumes:
+ - ./pipelock.example.yaml:/etc/pipelock/pipelock.yaml:ro
+ command:
+ - "run"
+ - "--config"
+ - "/etc/pipelock/pipelock.yaml"
+ - "--listen"
+ - "0.0.0.0:8888"
+ - "--mode"
+ - "balanced"
+ - "--mcp-listen"
+ - "0.0.0.0:8889"
+ - "--mcp-upstream"
+ - "http://web:3000/mcp"
+ ports:
+ # MCP reverse proxy — external AI assistants connect here
+ - "${MCP_PROXY_PORT:-8889}:8889"
+ # Uncomment to expose forward proxy endpoints (/health, /metrics, /stats):
+ # - "8888:8888"
+ healthcheck:
+ test: ["CMD", "/pipelock", "healthcheck", "--addr", "127.0.0.1:8888"]
+ interval: 10s
+ timeout: 5s
+ retries: 3
+ start_period: 30s
+ networks:
+ - sure_net
+
# Note: You still have to download models manually using the ollama CLI or via Open WebUI
ollama:
profiles:
@@ -106,6 +172,10 @@ services:
volumes:
- app-storage:/rails/storage
ports:
+ # Web UI for browser access. Note: /mcp is also reachable on this port,
+ # bypassing Pipelock's MCP scanning (auth token is still required).
+ # For hardened deployments, use `expose: [3000]` instead and front
+ # the web UI with a separate reverse proxy.
- ${PORT:-3000}:3000
restart: unless-stopped
environment:
@@ -115,6 +185,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
+ pipelock: # Remove this block and unset HTTPS_PROXY/HTTP_PROXY to run without Pipelock
+ condition: service_healthy
dns:
- 8.8.8.8
- 1.1.1.1
@@ -132,6 +204,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
+ pipelock: # Remove this block and unset HTTPS_PROXY/HTTP_PROXY to run without Pipelock
+ condition: service_healthy
dns:
- 8.8.8.8
- 1.1.1.1
diff --git a/compose.example.pipelock.yml b/compose.example.pipelock.yml
deleted file mode 100644
index b70bbb9166b..00000000000
--- a/compose.example.pipelock.yml
+++ /dev/null
@@ -1,275 +0,0 @@
-# ===========================================================================
-# Example Docker Compose file with Pipelock agent security proxy
-# ===========================================================================
-#
-# Purpose:
-# --------
-#
-# This file adds Pipelock (https://github.com/luckyPipewrench/pipelock)
-# as a security proxy for Sure, providing two layers of protection:
-#
-# 1. Forward proxy (port 8888) — routes outbound HTTPS through Pipelock
-# for clients that respect the HTTPS_PROXY environment variable.
-#
-# 2. MCP reverse proxy (port 8889) — scans inbound MCP traffic from
-# external AI assistants bidirectionally (DLP, prompt injection,
-# tool poisoning, tool call policy).
-#
-# Forward proxy coverage:
-# -----------------------
-#
-# Covered (Faraday-based clients respect HTTPS_PROXY automatically):
-# - OpenAI API calls (ruby-openai gem)
-# - Market data providers using Faraday
-#
-# NOT covered (these clients ignore HTTPS_PROXY):
-# - SimpleFin (HTTParty / Net::HTTP)
-# - Coinbase (HTTParty / Net::HTTP)
-# - Any code using Net::HTTP or HTTParty directly
-#
-# For covered traffic, Pipelock provides:
-# - Domain allowlisting (only known-good external APIs can be reached)
-# - SSRF protection (blocks connections to private/internal IPs)
-# - DLP scanning on connection targets (detects exfiltration patterns)
-# - Rate limiting per domain
-# - Structured JSON audit logging of all outbound connections
-#
-# MCP reverse proxy coverage:
-# ---------------------------
-#
-# External AI assistants connect to Pipelock on port 8889 instead of
-# directly to Sure's /mcp endpoint. Pipelock scans all traffic:
-#
-# Request scanning (client → Sure):
-# - DLP detection (blocks credential/secret leakage in tool arguments)
-# - Prompt injection detection in tool call parameters
-# - Tool call policy enforcement (blocks dangerous operations)
-#
-# Response scanning (Sure → client):
-# - Prompt injection detection in tool response content
-# - Tool poisoning / drift detection (tool definitions changing)
-#
-# The MCP endpoint on Sure (port 3000/mcp) should NOT be exposed directly
-# to the internet. Route all external MCP traffic through Pipelock.
-#
-# Limitations:
-# ------------
-#
-# HTTPS_PROXY is cooperative. Docker Compose has no egress network policy,
-# so any code path that doesn't check the env var can connect directly.
-# For hard enforcement, deploy with network-level controls that deny all
-# egress except through the proxy. Example for Kubernetes:
-#
-# # NetworkPolicy: deny all egress, allow only proxy + DNS
-# egress:
-# - to:
-# - podSelector:
-# matchLabels:
-# app: pipelock
-# ports:
-# - port: 8888
-# - ports:
-# - port: 53
-# protocol: UDP
-#
-# Monitoring:
-# -----------
-#
-# Pipelock logs every connection and MCP request as structured JSON to stdout.
-# View logs with: docker compose logs pipelock
-#
-# Forward proxy endpoints (port 8888):
-# http://localhost:8888/health - liveness check
-# http://localhost:8888/metrics - Prometheus metrics
-# http://localhost:8888/stats - JSON summary
-#
-# More info: https://github.com/luckyPipewrench/pipelock
-#
-# Setup:
-# ------
-#
-# 1. Copy this file to compose.yml (or use -f flag)
-# 2. Set your environment variables (OPENAI_ACCESS_TOKEN, MCP_API_TOKEN, etc.)
-# 3. docker compose up
-#
-# Pipelock runs both proxies in a single container:
-# - Port 8888: forward proxy for outbound HTTPS (internal only)
-# - Port 8889: MCP reverse proxy for external AI assistants
-#
-# External AI clients connect to http://:8889 as their MCP endpoint.
-# Pipelock scans the traffic and forwards clean requests to Sure's /mcp.
-#
-# Customization:
-# --------------
-#
-# Requires Pipelock with MCP HTTP listener support (--mcp-listen flag).
-# See: https://github.com/luckyPipewrench/pipelock/releases
-#
-# Edit the pipelock command to change the mode:
-# --mode strict Block unknown domains (recommended for production)
-# --mode balanced Warn on unknown domains, block known-bad (default)
-# --mode audit Log everything, block nothing (for evaluation)
-#
-# For a custom config, mount a file and use --config instead of --mode:
-# volumes:
-# - ./config/pipelock.yml:/etc/pipelock/config.yml:ro
-# command: ["run", "--config", "/etc/pipelock/config.yml",
-# "--mcp-listen", "0.0.0.0:8889", "--mcp-upstream", "http://web:3000/mcp"]
-#
-
-x-db-env: &db_env
- POSTGRES_USER: ${POSTGRES_USER:-sure_user}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sure_password}
- POSTGRES_DB: ${POSTGRES_DB:-sure_production}
-
-x-rails-env: &rails_env
- <<: *db_env
- SECRET_KEY_BASE: ${SECRET_KEY_BASE:-a7523c3d0ae56415046ad8abae168d71074a79534a7062258f8d1d51ac2f76d3c3bc86d86b6b0b307df30d9a6a90a2066a3fa9e67c5e6f374dbd7dd4e0778e13}
- SELF_HOSTED: "true"
- RAILS_FORCE_SSL: "false"
- RAILS_ASSUME_SSL: "false"
- DB_HOST: db
- DB_PORT: 5432
- REDIS_URL: redis://redis:6379/1
- # NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this.
- OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN}
- # MCP server endpoint — enables /mcp for external AI assistants (e.g. Claude, GPT).
- # Set both values to activate. MCP_USER_EMAIL must match an existing user's email.
- # External AI clients connect via Pipelock (port 8889), not directly to /mcp.
- MCP_API_TOKEN: ${MCP_API_TOKEN:-}
- MCP_USER_EMAIL: ${MCP_USER_EMAIL:-}
- # Route outbound HTTPS through Pipelock for clients that respect HTTPS_PROXY.
- # See "Forward proxy coverage" section above for which clients are covered.
- HTTPS_PROXY: "http://pipelock:8888"
- HTTP_PROXY: "http://pipelock:8888"
- # Skip proxy for internal Docker network services
- NO_PROXY: "db,redis,pipelock,localhost,127.0.0.1"
-
-services:
- pipelock:
- image: ghcr.io/luckypipewrench/pipelock:latest
- container_name: pipelock
- hostname: pipelock
- restart: unless-stopped
- command:
- - "run"
- - "--listen"
- - "0.0.0.0:8888"
- - "--mode"
- - "balanced"
- - "--mcp-listen"
- - "0.0.0.0:8889"
- - "--mcp-upstream"
- - "http://web:3000/mcp"
- ports:
- # MCP reverse proxy — external AI assistants connect here
- - "${MCP_PROXY_PORT:-8889}:8889"
- # Uncomment to expose forward proxy endpoints (/health, /metrics, /stats):
- # - "8888:8888"
- healthcheck:
- test: ["CMD", "/pipelock", "healthcheck", "--addr", "127.0.0.1:8888"]
- interval: 10s
- timeout: 5s
- retries: 3
- start_period: 30s
- networks:
- - sure_net
-
- web:
- image: ghcr.io/we-promise/sure:stable
- volumes:
- - app-storage:/rails/storage
- ports:
- # Web UI for browser access. Note: /mcp is also reachable on this port,
- # bypassing Pipelock's MCP scanning (auth token is still required).
- # For hardened deployments, use `expose: [3000]` instead and front
- # the web UI with a separate reverse proxy.
- - ${PORT:-3000}:3000
- restart: unless-stopped
- environment:
- <<: *rails_env
- depends_on:
- db:
- condition: service_healthy
- redis:
- condition: service_healthy
- pipelock:
- condition: service_healthy
- networks:
- - sure_net
-
- worker:
- image: ghcr.io/we-promise/sure:stable
- command: bundle exec sidekiq
- volumes:
- - app-storage:/rails/storage
- restart: unless-stopped
- depends_on:
- db:
- condition: service_healthy
- redis:
- condition: service_healthy
- pipelock:
- condition: service_healthy
- environment:
- <<: *rails_env
- networks:
- - sure_net
-
- db:
- image: postgres:16
- restart: unless-stopped
- volumes:
- - postgres-data:/var/lib/postgresql/data
- environment:
- <<: *db_env
- healthcheck:
- test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
- interval: 5s
- timeout: 5s
- retries: 5
- networks:
- - sure_net
-
- backup:
- profiles:
- - backup
- image: prodrigestivill/postgres-backup-local
- restart: unless-stopped
- volumes:
- - /opt/sure-data/backups:/backups # Change this path to your desired backup location on the host machine
- environment:
- - POSTGRES_HOST=db
- - POSTGRES_DB=${POSTGRES_DB:-sure_production}
- - POSTGRES_USER=${POSTGRES_USER:-sure_user}
- - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-sure_password}
- - SCHEDULE=@daily # Runs once a day at midnight
- - BACKUP_KEEP_DAYS=7 # Keeps the last 7 days of backups
- - BACKUP_KEEP_WEEKS=4 # Keeps 4 weekly backups
- - BACKUP_KEEP_MONTHS=6 # Keeps 6 monthly backups
- depends_on:
- - db
- networks:
- - sure_net
-
- redis:
- image: redis:latest
- restart: unless-stopped
- volumes:
- - redis-data:/data
- healthcheck:
- test: [ "CMD", "redis-cli", "ping" ]
- interval: 5s
- timeout: 5s
- retries: 5
- networks:
- - sure_net
-
-volumes:
- app-storage:
- postgres-data:
- redis-data:
-
-networks:
- sure_net:
- driver: bridge
diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml
index 8f3fcec3253..cfe44a8ad32 100644
--- a/config/locales/views/settings/hostings/en.yml
+++ b/config/locales/views/settings/hostings/en.yml
@@ -16,6 +16,7 @@ en:
invite_only: Invite-only
show:
general: General Settings
+ ai_assistant: AI Assistant
financial_data_providers: Financial Data Providers
sync_settings: Sync Settings
invites: Invite Codes
@@ -35,6 +36,32 @@ en:
providers:
twelve_data: Twelve Data
yahoo_finance: Yahoo Finance
+ assistant_settings:
+ title: AI Assistant
+ description: Choose how the chat assistant responds. Builtin uses your configured LLM provider directly. External delegates to a remote AI agent that can call back to Sure's financial tools via MCP.
+ type_label: Assistant type
+ type_builtin: Builtin (direct LLM)
+ type_external: External (remote agent)
+ external_status: External assistant endpoint
+ external_configured: Configured
+ external_not_configured: Not configured. Enter the URL and token below, or set EXTERNAL_ASSISTANT_URL and EXTERNAL_ASSISTANT_TOKEN environment variables.
+ env_notice: "Assistant type is locked to '%{type}' via ASSISTANT_TYPE environment variable."
+ env_configured_external: Successfully configured through environment variables.
+ url_label: Endpoint URL
+ url_placeholder: "https://your-agent-host/v1/chat"
+ url_help: The full URL to your agent's API endpoint. Your agent provider will give you this.
+ token_label: API Token
+ token_placeholder: Enter the token from your agent provider
+ token_help: The authentication token provided by your external agent. This is sent as a Bearer token with each request.
+ agent_id_label: Agent ID (Optional)
+ agent_id_placeholder: "main (default)"
+ agent_id_help: Routes to a specific agent when the provider hosts multiple. Leave blank for the default.
+ disconnect_title: External connection
+ disconnect_description: Remove the external assistant connection and switch back to the builtin assistant.
+ disconnect_button: Disconnect
+ confirm_disconnect:
+ title: Disconnect external assistant?
+ body: This will remove the saved URL, token, and agent ID, and switch to the builtin assistant. You can reconnect later by entering new credentials.
brand_fetch_settings:
description: Enter the Client ID provided by Brand Fetch
label: Client ID
@@ -83,6 +110,8 @@ en:
invalid_onboarding_state: Invalid onboarding state
invalid_sync_time: Invalid sync time format. Please use HH:MM format (e.g., 02:30).
scheduler_sync_failed: Settings saved, but failed to update the sync schedule. Please try again or check the server logs.
+ disconnect_external_assistant:
+ external_assistant_disconnected: External assistant disconnected
clear_cache:
cache_cleared: Data cache has been cleared. This may take a few moments to complete.
not_authorized: You are not authorized to perform this action
diff --git a/config/routes.rb b/config/routes.rb
index 90e2c8f9a55..1e5097fd215 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -167,6 +167,7 @@
resource :preferences, only: :show
resource :hosting, only: %i[show update] do
delete :clear_cache, on: :collection
+ delete :disconnect_external_assistant, on: :collection
end
resource :payment, only: :show
resource :security, only: :show
diff --git a/docs/hosting/ai.md b/docs/hosting/ai.md
index 0e6d56d1f7b..2ea637fec1d 100644
--- a/docs/hosting/ai.md
+++ b/docs/hosting/ai.md
@@ -290,6 +290,70 @@ For self-hosted deployments, you can configure AI settings through the web inter
**Note:** Settings in the UI override environment variables. If you change settings in the UI, those values take precedence.
+## External AI Assistant
+
+Instead of using the builtin LLM (which calls OpenAI or a local model directly), you can delegate chat to an **external AI agent**. The agent receives the conversation, can call back to Sure's financial data via MCP, and streams a response.
+
+This is useful when:
+- You have a custom AI agent with domain knowledge, memory, or personality
+- You want to use a non-OpenAI-compatible model (the agent translates)
+- You want to keep LLM credentials and logic outside Sure entirely
+
+### How It Works
+
+1. Sure sends the chat conversation to your agent's API endpoint
+2. Your agent processes it (using whatever LLM, tools, or context it needs)
+3. Your agent can call Sure's `/mcp` endpoint for financial data (accounts, transactions, balance sheet)
+4. Your agent streams the response back to Sure via Server-Sent Events (SSE)
+
+The agent's API must be **OpenAI chat completions compatible** — accept `POST` with `messages` array, return SSE with `delta.content` chunks.
+
+### Configuration
+
+Configure via the UI or environment variables:
+
+**Settings UI:**
+1. Go to **Settings** → **Self-Hosting**
+2. Set **Assistant type** to "External (remote agent)"
+3. Enter the **Endpoint URL** and **API Token** from your agent provider
+4. Optionally set an **Agent ID** if the provider hosts multiple agents
+
+**Environment variables:**
+```bash
+ASSISTANT_TYPE=external # Force all families to use external
+EXTERNAL_ASSISTANT_URL=https://your-agent/v1/chat/completions
+EXTERNAL_ASSISTANT_TOKEN=your-api-token
+EXTERNAL_ASSISTANT_AGENT_ID=main # Optional, defaults to "main"
+EXTERNAL_ASSISTANT_SESSION_KEY=agent:main:main # Optional, for session persistence
+EXTERNAL_ASSISTANT_ALLOWED_EMAILS=user@example.com # Optional, comma-separated allowlist
+```
+
+When environment variables are set, the corresponding UI fields are disabled (env takes precedence).
+
+### Security with Pipelock
+
+When [Pipelock](https://github.com/luckyPipewrench/pipelock) is enabled (`pipelock.enabled=true` in Helm, or the `pipelock` service in Docker Compose), all traffic between Sure and the external agent is scanned:
+
+- **Outbound** (Sure → agent): routed through Pipelock's forward proxy via `HTTPS_PROXY`
+- **Inbound** (agent → Sure /mcp): routed through Pipelock's MCP reverse proxy (port 8889)
+
+Pipelock scans for prompt injection, DLP violations, and tool poisoning. The external agent does not need Pipelock installed — Sure's Pipelock handles both directions.
+
+### Access Control
+
+Use `EXTERNAL_ASSISTANT_ALLOWED_EMAILS` to restrict which users can use the external assistant. When set, only users whose email matches the comma-separated list will see the AI chat. When blank, all users can access it.
+
+### Docker Compose Example
+
+```yaml
+x-rails-env: &rails_env
+ ASSISTANT_TYPE: external
+ EXTERNAL_ASSISTANT_URL: https://your-agent/v1/chat/completions
+ EXTERNAL_ASSISTANT_TOKEN: your-api-token
+```
+
+Or configure via the Settings UI after startup (no env vars needed).
+
## AI Cache Management
Sure caches AI-generated results (like auto-categorization and merchant detection) to avoid redundant API calls and costs. However, there are situations where you may want to clear this cache.
diff --git a/pipelock.example.yaml b/pipelock.example.yaml
new file mode 100644
index 00000000000..d53f11a1375
--- /dev/null
+++ b/pipelock.example.yaml
@@ -0,0 +1,36 @@
+# Pipelock configuration for Docker Compose
+# See https://github.com/luckyPipewrench/pipelock for full options.
+
+forward_proxy:
+ enabled: true
+ max_tunnel_seconds: 300
+ idle_timeout_seconds: 60
+
+websocket_proxy:
+ enabled: false
+ max_message_bytes: 1048576
+ max_concurrent_connections: 128
+ scan_text_frames: true
+ allow_binary_frames: false
+ forward_cookies: false
+ strip_compression: true
+ max_connection_seconds: 3600
+ idle_timeout_seconds: 300
+ origin_policy: rewrite
+
+dlp:
+ scan_env: true
+
+response_scanning:
+ enabled: true
+ action: warn
+
+mcp_input_scanning:
+ enabled: true
+ action: block
+ on_parse_error: block
+
+mcp_tool_scanning:
+ enabled: true
+ action: warn
+ detect_drift: true
diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb
index 5b1edb7cf0d..bd02b321afd 100644
--- a/test/controllers/settings/hostings_controller_test.rb
+++ b/test/controllers/settings/hostings_controller_test.rb
@@ -136,6 +136,98 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
assert_not Balance.exists?(account_balance.id)
end
+ test "can update assistant type to external" do
+ with_self_hosting do
+ assert_equal "builtin", users(:family_admin).family.assistant_type
+
+ patch settings_hosting_url, params: { family: { assistant_type: "external" } }
+
+ assert_redirected_to settings_hosting_url
+ assert_equal "external", users(:family_admin).family.reload.assistant_type
+ end
+ end
+
+ test "ignores invalid assistant type values" do
+ with_self_hosting do
+ patch settings_hosting_url, params: { family: { assistant_type: "hacked" } }
+
+ assert_redirected_to settings_hosting_url
+ assert_equal "builtin", users(:family_admin).family.reload.assistant_type
+ end
+ end
+
+ test "ignores assistant type update when ASSISTANT_TYPE env is set" do
+ with_self_hosting do
+ with_env_overrides("ASSISTANT_TYPE" => "external") do
+ patch settings_hosting_url, params: { family: { assistant_type: "external" } }
+
+ assert_redirected_to settings_hosting_url
+ # DB value should NOT change when env override is active
+ assert_equal "builtin", users(:family_admin).family.reload.assistant_type
+ end
+ end
+ end
+
+ test "can update external assistant settings" do
+ with_self_hosting do
+ patch settings_hosting_url, params: { setting: {
+ external_assistant_url: "https://agent.example.com/v1/chat",
+ external_assistant_token: "my-secret-token",
+ external_assistant_agent_id: "finance-bot"
+ } }
+
+ assert_redirected_to settings_hosting_url
+ assert_equal "https://agent.example.com/v1/chat", Setting.external_assistant_url
+ assert_equal "my-secret-token", Setting.external_assistant_token
+ assert_equal "finance-bot", Setting.external_assistant_agent_id
+ end
+ ensure
+ Setting.external_assistant_url = nil
+ Setting.external_assistant_token = nil
+ Setting.external_assistant_agent_id = nil
+ end
+
+ test "does not overwrite token with masked placeholder" do
+ with_self_hosting do
+ Setting.external_assistant_token = "real-secret"
+
+ patch settings_hosting_url, params: { setting: { external_assistant_token: "********" } }
+
+ assert_equal "real-secret", Setting.external_assistant_token
+ end
+ ensure
+ Setting.external_assistant_token = nil
+ end
+
+ test "disconnect external assistant clears settings and resets type" do
+ with_self_hosting do
+ Setting.external_assistant_url = "https://agent.example.com/v1/chat"
+ Setting.external_assistant_token = "token"
+ Setting.external_assistant_agent_id = "finance-bot"
+ users(:family_admin).family.update!(assistant_type: "external")
+
+ delete disconnect_external_assistant_settings_hosting_url
+
+ assert_redirected_to settings_hosting_url
+ assert_not Assistant::External.configured?
+ assert_equal "builtin", users(:family_admin).family.reload.assistant_type
+ end
+ ensure
+ Setting.external_assistant_url = nil
+ Setting.external_assistant_token = nil
+ Setting.external_assistant_agent_id = nil
+ end
+
+ test "disconnect external assistant requires admin" do
+ with_self_hosting do
+ sign_in users(:family_member)
+ delete disconnect_external_assistant_settings_hosting_url
+
+ assert_redirected_to settings_hosting_url
+ assert_equal I18n.t("settings.hostings.not_authorized"), flash[:alert]
+ end
+ end
+
test "can clear data only when admin" do
with_self_hosting do
sign_in users(:family_member)
diff --git a/test/models/assistant/external/client_test.rb b/test/models/assistant/external/client_test.rb
new file mode 100644
index 00000000000..9d2b251131c
--- /dev/null
+++ b/test/models/assistant/external/client_test.rb
@@ -0,0 +1,281 @@
+require "test_helper"
+
+class Assistant::External::ClientTest < ActiveSupport::TestCase
+ setup do
+ @client = Assistant::External::Client.new(
+ url: "http://localhost:18789/v1/chat",
+ token: "test-token",
+ agent_id: "test-agent"
+ )
+ end
+
+ test "streams text chunks from SSE response" do
+ sse_body = <<~SSE
+ data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}],"model":"test-agent"}
+
+ data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Your net worth"},"finish_reason":null}],"model":"test-agent"}
+
+ data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" is $124,200."},"finish_reason":null}],"model":"test-agent"}
+
+ data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"model":"test-agent"}
+
+ data: [DONE]
+
+ SSE
+
+ mock_http_streaming_response(sse_body)
+
+ chunks = []
+ model = @client.chat(messages: [ { role: "user", content: "test" } ]) do |text|
+ chunks << text
+ end
+
+ assert_equal [ "Your net worth", " is $124,200." ], chunks
+ assert_equal "test-agent", model
+ end
+
+ test "raises on non-200 response" do
+ mock_http_error_response(503, "Service Unavailable")
+
+ assert_raises(Assistant::Error) do
+ @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| }
+ end
+ end
+
+ test "retries transient errors then raises Assistant::Error" do
+ Net::HTTP.any_instance.stubs(:request).raises(Net::OpenTimeout, "connection timed out")
+
+ error = assert_raises(Assistant::Error) do
+ @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| }
+ end
+
+ assert_match(/unreachable after 3 attempts/, error.message)
+ end
+
+ test "does not retry after streaming has started" do
+ call_count = 0
+
+ # Custom response that yields one chunk then raises mid-stream
+ mock_response = Object.new
+ mock_response.define_singleton_method(:is_a?) { |klass| klass == Net::HTTPSuccess }
+ mock_response.define_singleton_method(:read_body) do |&blk|
+ blk.call("data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}],\"model\":\"m\"}\n\n")
+ raise Errno::ECONNRESET, "connection reset mid-stream"
+ end
+
+ mock_http = stub("http")
+ mock_http.stubs(:use_ssl=)
+ mock_http.stubs(:open_timeout=)
+ mock_http.stubs(:read_timeout=)
+ mock_http.define_singleton_method(:request) do |_req, &blk|
+ call_count += 1
+ blk.call(mock_response)
+ end
+
+ Net::HTTP.stubs(:new).returns(mock_http)
+
+ chunks = []
+ error = assert_raises(Assistant::Error) do
+ @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t }
+ end
+
+ assert_equal 1, call_count, "Should not retry after streaming started"
+ assert_equal [ "partial" ], chunks
+ assert_match(/connection lost/, error.message)
+ end
+
+ test "builds correct request payload" do
+ sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n"
+ capture = mock_http_streaming_response(sse_body)
+
+ @client.chat(
+ messages: [
+ { role: "user", content: "Hello" },
+ { role: "assistant", content: "Hi there" },
+ { role: "user", content: "What is my balance?" }
+ ],
+ user: "sure-family-42"
+ ) { |_| }
+
+ body = JSON.parse(capture[0].body)
+ assert_equal "test-agent", body["model"]
+ assert_equal true, body["stream"]
+ assert_equal 3, body["messages"].size
+ assert_equal "sure-family-42", body["user"]
+ end
+
+ test "sets authorization header and agent_id header" do
+ sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n"
+ capture = mock_http_streaming_response(sse_body)
+
+ @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| }
+
+ assert_equal "Bearer test-token", capture[0]["Authorization"]
+ assert_equal "test-agent", capture[0]["X-Agent-Id"]
+ assert_equal "agent:main:main", capture[0]["X-Session-Key"]
+ assert_equal "text/event-stream", capture[0]["Accept"]
+ assert_equal "application/json", capture[0]["Content-Type"]
+ end
+
+ test "omits user field when not provided" do
+ sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n"
+ capture = mock_http_streaming_response(sse_body)
+
+ @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| }
+
+ body = JSON.parse(capture[0].body)
+ assert_not body.key?("user")
+ end
+
+ test "handles malformed JSON in SSE data gracefully" do
+ sse_body = "data: {not valid json}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"OK\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n"
+ mock_http_streaming_response(sse_body)
+
+ chunks = []
+ @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t }
+
+ assert_equal [ "OK" ], chunks
+ end
+
+ test "handles SSE data: field without space after colon (spec-compliant)" do
+ sse_body = "data:{\"choices\":[{\"delta\":{\"content\":\"no space\"}}],\"model\":\"m\"}\n\ndata:[DONE]\n\n"
+ mock_http_streaming_response(sse_body)
+
+ chunks = []
+ @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t }
+
+ assert_equal [ "no space" ], chunks
+ end
+
+ test "handles chunked SSE data split across read_body calls" do
+ chunk1 = "data: {\"choices\":[{\"delta\":{\"content\":\"Hel"
+ chunk2 = "lo\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n"
+
+ mock_http_streaming_response_chunked([ chunk1, chunk2 ])
+
+ chunks = []
+ @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t }
+
+ assert_equal [ "Hello" ], chunks
+ end
+
+ test "routes through HTTPS_PROXY when set" do
+ sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n"
+
+ mock_response = stub("response")
+ mock_response.stubs(:code).returns("200")
+ mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
+ mock_response.stubs(:read_body).yields(sse_body)
+
+ mock_http = stub("http")
+ mock_http.stubs(:use_ssl=)
+ mock_http.stubs(:open_timeout=)
+ mock_http.stubs(:read_timeout=)
+ mock_http.stubs(:request).yields(mock_response)
+
+ captured_args = nil
+ Net::HTTP.stubs(:new).with do |*args|
+ captured_args = args
+ true
+ end.returns(mock_http)
+
+ client = Assistant::External::Client.new(
+ url: "https://example.com/v1/chat",
+ token: "test-token"
+ )
+
+ ClimateControl.modify(HTTPS_PROXY: "http://proxy:8888") do
+ client.chat(messages: [ { role: "user", content: "test" } ]) { |_| }
+ end
+
+ assert_equal "example.com", captured_args[0]
+ assert_equal 443, captured_args[1]
+ assert_equal "proxy", captured_args[2]
+ assert_equal 8888, captured_args[3]
+ end
+
+ test "skips proxy for hosts in NO_PROXY" do
+ sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n"
+
+ mock_response = stub("response")
+ mock_response.stubs(:code).returns("200")
+ mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
+ mock_response.stubs(:read_body).yields(sse_body)
+
+ mock_http = stub("http")
+ mock_http.stubs(:use_ssl=)
+ mock_http.stubs(:open_timeout=)
+ mock_http.stubs(:read_timeout=)
+ mock_http.stubs(:request).yields(mock_response)
+
+ captured_args = nil
+ Net::HTTP.stubs(:new).with do |*args|
+ captured_args = args
+ true
+ end.returns(mock_http)
+
+ client = Assistant::External::Client.new(
+ url: "http://agent.internal.example.com:18789/v1/chat",
+ token: "test-token"
+ )
+
+ ClimateControl.modify(HTTPS_PROXY: "http://proxy:8888", NO_PROXY: "localhost,.example.com") do
+ client.chat(messages: [ { role: "user", content: "test" } ]) { |_| }
+ end
+
+ # Should NOT pass proxy args — only host and port
+ assert_equal 2, captured_args.length
+ end
+
+ private
+
+ def mock_http_streaming_response(sse_body)
+ capture = []
+ mock_response = stub("response")
+ mock_response.stubs(:code).returns("200")
+ mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
+ mock_response.stubs(:read_body).yields(sse_body)
+
+ mock_http = stub("http")
+ mock_http.stubs(:use_ssl=)
+ mock_http.stubs(:open_timeout=)
+ mock_http.stubs(:read_timeout=)
+ mock_http.stubs(:request).with do |req|
+ capture[0] = req
+ true
+ end.yields(mock_response)
+
+ Net::HTTP.stubs(:new).returns(mock_http)
+ capture
+ end
+
+ def mock_http_streaming_response_chunked(chunks)
+ mock_response = stub("response")
+ mock_response.stubs(:code).returns("200")
+ mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
+ mock_response.stubs(:read_body).multiple_yields(*chunks.map { |c| [ c ] })
+
+ mock_http = stub("http")
+ mock_http.stubs(:use_ssl=)
+ mock_http.stubs(:open_timeout=)
+ mock_http.stubs(:read_timeout=)
+ mock_http.stubs(:request).yields(mock_response)
+
+ Net::HTTP.stubs(:new).returns(mock_http)
+ end
+
+ def mock_http_error_response(code, message)
+ mock_response = stub("response")
+ mock_response.stubs(:code).returns(code.to_s)
+ mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(false)
+ mock_response.stubs(:body).returns(message)
+
+ mock_http = stub("http")
+ mock_http.stubs(:use_ssl=)
+ mock_http.stubs(:open_timeout=)
+ mock_http.stubs(:read_timeout=)
+ mock_http.stubs(:request).yields(mock_response)
+
+ Net::HTTP.stubs(:new).returns(mock_http)
+ end
+end
diff --git a/test/models/assistant/external_config_test.rb b/test/models/assistant/external_config_test.rb
new file mode 100644
index 00000000000..77f2a342db7
--- /dev/null
+++ b/test/models/assistant/external_config_test.rb
@@ -0,0 +1,93 @@
+require "test_helper"
+
+class Assistant::ExternalConfigTest < ActiveSupport::TestCase
+ test "config reads URL from environment with priority over Setting" do
+ with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://from-env/v1/chat") do
+ assert_equal "http://from-env/v1/chat", Assistant::External.config.url
+ assert_equal "main", Assistant::External.config.agent_id
+ assert_equal "agent:main:main", Assistant::External.config.session_key
+ end
+ end
+
+ test "config falls back to Setting when env var is absent" do
+ Setting.external_assistant_url = "http://from-setting/v1/chat"
+ Setting.external_assistant_token = "setting-token"
+
+ with_env_overrides("EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil) do
+ assert_equal "http://from-setting/v1/chat", Assistant::External.config.url
+ assert_equal "setting-token", Assistant::External.config.token
+ end
+ ensure
+ Setting.external_assistant_url = nil
+ Setting.external_assistant_token = nil
+ end
+
+ test "config reads agent_id with custom value" do
+ with_env_overrides(
+ "EXTERNAL_ASSISTANT_URL" => "http://example.com/v1/chat",
+ "EXTERNAL_ASSISTANT_TOKEN" => "test-token",
+ "EXTERNAL_ASSISTANT_AGENT_ID" => "finance-bot"
+ ) do
+ assert_equal "finance-bot", Assistant::External.config.agent_id
+ assert_equal "test-token", Assistant::External.config.token
+ end
+ end
+
+ test "config reads session_key with custom value" do
+ with_env_overrides(
+ "EXTERNAL_ASSISTANT_URL" => "http://example.com/v1/chat",
+ "EXTERNAL_ASSISTANT_TOKEN" => "test-token",
+ "EXTERNAL_ASSISTANT_SESSION_KEY" => "agent:finance-bot:finance"
+ ) do
+ assert_equal "agent:finance-bot:finance", Assistant::External.config.session_key
+ end
+ end
+
+ test "available_for? allows any user when no allowlist is set" do
+ user = OpenStruct.new(email: "anyone@example.com")
+ with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => "t", "EXTERNAL_ASSISTANT_ALLOWED_EMAILS" => nil) do
+ assert Assistant::External.available_for?(user)
+ end
+ end
+
+ test "available_for? restricts to allowlisted emails" do
+ allowed = OpenStruct.new(email: "josh@example.com")
+ denied = OpenStruct.new(email: "other@example.com")
+ with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => "t", "EXTERNAL_ASSISTANT_ALLOWED_EMAILS" => "josh@example.com, admin@example.com") do
+ assert Assistant::External.available_for?(allowed)
+ assert_not Assistant::External.available_for?(denied)
+ end
+ end
+
+ test "build_conversation_messages truncates to last 20 messages" do
+ chat = chats(:one)
+
+ # Create enough messages to exceed the 20-message cap
+ 25.times do |i|
+ role_class = i.even? ? UserMessage : AssistantMessage
+ role_class.create!(chat: chat, content: "msg #{i}", ai_model: "test")
+ end
+
+ with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => "t") do
+ external = Assistant::External.new(chat)
+ messages = external.send(:build_conversation_messages)
+
+ assert_equal 20, messages.length
+ # Last message should be the most recent one we created
+ assert_equal "msg 24", messages.last[:content]
+ end
+ end
+
+ test "configured? returns true only when URL and token are both present" do
+ Setting.external_assistant_url = nil
+ Setting.external_assistant_token = nil
+
+ with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => nil) do
+ assert_not Assistant::External.configured?
+ end
+
+ with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => "t") do
+ assert Assistant::External.configured?
+ end
+ end
+end
diff --git a/test/models/assistant_test.rb b/test/models/assistant_test.rb
index 7ced435423a..c0785909009 100644
--- a/test/models/assistant_test.rb
+++ b/test/models/assistant_test.rb
@@ -186,15 +186,219 @@ class AssistantTest < ActiveSupport::TestCase
end
test "for_chat returns External when family assistant_type is external" do
+ @chat.user.family.update!(assistant_type: "external")
+ assert_instance_of Assistant::External, Assistant.for_chat(@chat)
+ end
+
+ test "ASSISTANT_TYPE env override forces external regardless of DB value" do
+ assert_equal "builtin", @chat.user.family.assistant_type
+
+ with_env_overrides("ASSISTANT_TYPE" => "external") do
+ assert_instance_of Assistant::External, Assistant.for_chat(@chat)
+ end
+
+ assert_instance_of Assistant::Builtin, Assistant.for_chat(@chat)
+ end
+
+ test "external assistant responds with streamed text" do
@chat.user.family.update!(assistant_type: "external")
assistant = Assistant.for_chat(@chat)
- assert_instance_of Assistant::External, assistant
- assert_no_difference "AssistantMessage.count" do
+
+ sse_body = <<~SSE
+ data: {"choices":[{"delta":{"content":"Your net worth"}}],"model":"ext-agent:main"}
+
+ data: {"choices":[{"delta":{"content":" is $124,200."}}],"model":"ext-agent:main"}
+
+ data: [DONE]
+
+ SSE
+
+ mock_external_sse_response(sse_body)
+
+ with_env_overrides(
+ "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat",
+ "EXTERNAL_ASSISTANT_TOKEN" => "test-token"
+ ) do
+ assert_difference "AssistantMessage.count", 1 do
+ assistant.respond_to(@message)
+ end
+
+ response_msg = @chat.messages.where(type: "AssistantMessage").last
+ assert_equal "Your net worth is $124,200.", response_msg.content
+ assert_equal "ext-agent:main", response_msg.ai_model
+ end
+ end
+
+ test "external assistant adds error when not configured" do
+ @chat.user.family.update!(assistant_type: "external")
+ assistant = Assistant.for_chat(@chat)
+
+ with_env_overrides(
+ "EXTERNAL_ASSISTANT_URL" => nil,
+ "EXTERNAL_ASSISTANT_TOKEN" => nil
+ ) do
+ assert_no_difference "AssistantMessage.count" do
+ assistant.respond_to(@message)
+ end
+
+ @chat.reload
+ assert @chat.error.present?
+ assert_includes @chat.error, "not configured"
+ end
+ end
+
+ test "external assistant adds error on connection failure" do
+ @chat.user.family.update!(assistant_type: "external")
+ assistant = Assistant.for_chat(@chat)
+
+ Net::HTTP.any_instance.stubs(:request).raises(Errno::ECONNREFUSED, "Connection refused")
+
+ with_env_overrides(
+ "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat",
+ "EXTERNAL_ASSISTANT_TOKEN" => "test-token"
+ ) do
+ assert_no_difference "AssistantMessage.count" do
+ assistant.respond_to(@message)
+ end
+
+ @chat.reload
+ assert @chat.error.present?
+ end
+ end
+
+ test "external assistant handles empty response gracefully" do
+ @chat.user.family.update!(assistant_type: "external")
+ assistant = Assistant.for_chat(@chat)
+
+ sse_body = <<~SSE
+ data: {"choices":[{"delta":{"role":"assistant"}}],"model":"ext-agent:main"}
+
+ data: {"choices":[{"delta":{}}],"model":"ext-agent:main"}
+
+ data: [DONE]
+
+ SSE
+
+ mock_external_sse_response(sse_body)
+
+ with_env_overrides(
+ "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat",
+ "EXTERNAL_ASSISTANT_TOKEN" => "test-token"
+ ) do
+ assert_no_difference "AssistantMessage.count" do
+ assistant.respond_to(@message)
+ end
+
+ @chat.reload
+ assert @chat.error.present?
+ assert_includes @chat.error, "empty response"
+ end
+ end
+
+ test "external assistant sends conversation history" do
+ @chat.user.family.update!(assistant_type: "external")
+ assistant = Assistant.for_chat(@chat)
+
+ AssistantMessage.create!(chat: @chat, content: "I can help with that.", ai_model: "external")
+
+ sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"Sure!\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n"
+ capture = mock_external_sse_response(sse_body)
+
+ with_env_overrides(
+ "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat",
+ "EXTERNAL_ASSISTANT_TOKEN" => "test-token"
+ ) do
assistant.respond_to(@message)
+
+ body = JSON.parse(capture[0].body)
+ messages = body["messages"]
+
+ assert messages.size >= 2
+ assert_equal "user", messages.first["role"]
+ end
+ end
+
+ test "full external assistant flow: config check, stream, save, error recovery" do
+ @chat.user.family.update!(assistant_type: "external")
+
+ # Phase 1: Without config, errors gracefully
+ with_env_overrides("EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil) do
+ assistant = Assistant::External.new(@chat)
+ assistant.respond_to(@message)
+ @chat.reload
+ assert @chat.error.present?
+ end
+
+ # Phase 2: With config, streams response
+ @chat.update!(error: nil)
+
+ sse_body = <<~SSE
+ data: {"choices":[{"delta":{"content":"Based on your accounts, "}}],"model":"ext-agent:main"}
+
+ data: {"choices":[{"delta":{"content":"your net worth is $50,000."}}],"model":"ext-agent:main"}
+
+ data: [DONE]
+
+ SSE
+
+ mock_external_sse_response(sse_body)
+
+ with_env_overrides(
+ "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat",
+ "EXTERNAL_ASSISTANT_TOKEN" => "test-token"
+ ) do
+ assistant = Assistant::External.new(@chat)
+ assistant.respond_to(@message)
+
+ @chat.reload
+ assert_nil @chat.error
+
+ response = @chat.messages.where(type: "AssistantMessage").last
+ assert_equal "Based on your accounts, your net worth is $50,000.", response.content
+ assert_equal "ext-agent:main", response.ai_model
+ end
+ end
+
+ test "ASSISTANT_TYPE env override with unknown value falls back to builtin" do
+ with_env_overrides("ASSISTANT_TYPE" => "nonexistent") do
+ assert_instance_of Assistant::Builtin, Assistant.for_chat(@chat)
+ end
+ end
+
+ test "external assistant sets user identifier with family_id" do
+ @chat.user.family.update!(assistant_type: "external")
+ assistant = Assistant.for_chat(@chat)
+
+ sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"OK\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n"
+ capture = mock_external_sse_response(sse_body)
+
+ with_env_overrides(
+ "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat",
+ "EXTERNAL_ASSISTANT_TOKEN" => "test-token"
+ ) do
+ assistant.respond_to(@message)
+
+ body = JSON.parse(capture[0].body)
+ assert_equal "sure-family-#{@chat.user.family_id}", body["user"]
+ end
+ end
+
+ test "external assistant updates ai_model from SSE response model field" do
+ @chat.user.family.update!(assistant_type: "external")
+ assistant = Assistant.for_chat(@chat)
+
+ sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}],\"model\":\"ext-agent:custom\"}\n\ndata: [DONE]\n\n"
+ mock_external_sse_response(sse_body)
+
+ with_env_overrides(
+ "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat",
+ "EXTERNAL_ASSISTANT_TOKEN" => "test-token"
+ ) do
+ assistant.respond_to(@message)
+
+ response = @chat.messages.where(type: "AssistantMessage").last
+ assert_equal "ext-agent:custom", response.ai_model
end
- @chat.reload
- assert @chat.error.present?
- assert_includes @chat.error, "not yet implemented"
end
test "for_chat raises when chat is blank" do
@@ -202,6 +406,27 @@ class AssistantTest < ActiveSupport::TestCase
end
private
+
+ def mock_external_sse_response(sse_body)
+ capture = []
+ mock_response = stub("response")
+ mock_response.stubs(:code).returns("200")
+ mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
+ mock_response.stubs(:read_body).yields(sse_body)
+
+ mock_http = stub("http")
+ mock_http.stubs(:use_ssl=)
+ mock_http.stubs(:open_timeout=)
+ mock_http.stubs(:read_timeout=)
+ mock_http.stubs(:request).with do |req|
+ capture[0] = req
+ true
+ end.yields(mock_response)
+
+ Net::HTTP.stubs(:new).returns(mock_http)
+ capture
+ end
+
def provider_function_request(id:, call_id:, function_name:, function_args:)
Provider::LlmConcept::ChatFunctionRequest.new(
id: id,
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index 85501c1d457..0d2a09d58c9 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -149,7 +149,7 @@ def teardown
test "ai_available? returns true when openai access token set in settings" do
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
previous = Setting.openai_access_token
- with_env_overrides OPENAI_ACCESS_TOKEN: nil do
+ with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
Setting.openai_access_token = nil
assert_not @user.ai_available?
@@ -160,6 +160,43 @@ def teardown
Setting.openai_access_token = previous
end
+ test "ai_available? returns true when external assistant is configured and family type is external" do
+ Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
+ previous = Setting.openai_access_token
+ @user.family.update!(assistant_type: "external")
+ with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do
+ Setting.openai_access_token = nil
+ assert @user.ai_available?
+ end
+ ensure
+ Setting.openai_access_token = previous
+ @user.family.update!(assistant_type: "builtin")
+ end
+
+ test "ai_available? returns false when external assistant is configured but family type is builtin" do
+ Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
+ previous = Setting.openai_access_token
+ with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do
+ Setting.openai_access_token = nil
+ assert_not @user.ai_available?
+ end
+ ensure
+ Setting.openai_access_token = previous
+ end
+
+ test "ai_available? returns false when external assistant is configured but user is not in allowlist" do
+ Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
+ previous = Setting.openai_access_token
+ @user.family.update!(assistant_type: "external")
+ with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token", EXTERNAL_ASSISTANT_ALLOWED_EMAILS: "other@example.com" do
+ Setting.openai_access_token = nil
+ assert_not @user.ai_available?
+ end
+ ensure
+ Setting.openai_access_token = previous
+ @user.family.update!(assistant_type: "builtin")
+ end
+
test "intro layout collapses sidebars and enables ai" do
user = User.new(
family: families(:empty),