diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml
index 3668c0a49..ad538b51d 100644
--- a/.github/workflows/pipelock.yml
+++ b/.github/workflows/pipelock.yml
@@ -24,3 +24,5 @@ jobs:
test-vectors: 'false'
exclude-paths: |
config/locales/views/reports/
+ # False positive: client.rb stores Bearer token and sends Authorization header by design
+ app/models/assistant/external/client.rb
diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb
index 22936cfb4..e63a65c71 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 @@ class Settings::HostingsController < ApplicationController
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 @@ class Settings::HostingsController < ApplicationController
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 582af9b0b..b07009396 100644
--- a/app/models/assistant.rb
+++ b/app/models/assistant.rb
@@ -36,7 +36,7 @@ module Assistant
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 276595dad..530d25503 100644
--- a/app/models/assistant/external.rb
+++ b/app/models/assistant/external.rb
@@ -1,14 +1,110 @@
class Assistant::External < Assistant::Base
+ Config = Struct.new(:url, :token, :agent_id, :session_key, keyword_init: true)
+ MAX_CONVERSATION_MESSAGES = 20
+
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?
+ return false if user&.email.blank?
+
+ allowed.split(",").map { |e| e.strip.downcase }.include?(user.email.downcase)
+ 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.")
+ response_completed = false
+
+ 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
+
+ response_completed = true
+ assistant_message.update!(ai_model: model) if model.present?
+ rescue Assistant::Error, ActiveRecord::ActiveRecordError => e
+ cleanup_partial_response(assistant_message) unless response_completed
+ stop_thinking
+ chat.add_error(e)
+ rescue => e
+ Rails.logger.error("[Assistant::External] Unexpected error: #{e.class} - #{e.message}")
+ cleanup_partial_response(assistant_message) unless response_completed
+ 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
+
+ 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 000000000..c6d680e8e
--- /dev/null
+++ b/app/models/assistant/external/client.rb
@@ -0,0 +1,175 @@
+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)
+ MAX_SSE_BUFFER = 1_048_576 # 1 MB safety cap on SSE buffer
+
+ 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
+ if streaming_started
+ Rails.logger.warn("[External::Client] Stream interrupted: #{e.class} - #{e.message}")
+ raise Assistant::Error, "External assistant connection was interrupted."
+ end
+
+ 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
+ Rails.logger.error("[External::Client] Unreachable after #{MAX_RETRIES + 1} attempts: #{e.class} - #{e.message}")
+ raise Assistant::Error, "External assistant is temporarily unavailable."
+ end
+ end
+
+ private
+
+ def stream_response(http, request, &block)
+ model = nil
+ buffer = +""
+ done = false
+
+ http.request(request) do |response|
+ unless response.is_a?(Net::HTTPSuccess)
+ Rails.logger.warn("[External::Client] Upstream HTTP #{response.code}: #{response.body.to_s.truncate(500)}")
+ raise Assistant::Error, "External assistant returned HTTP #{response.code}."
+ end
+
+ response.read_body do |chunk|
+ break if done
+ buffer << chunk
+
+ if buffer.bytesize > MAX_SSE_BUFFER
+ raise Assistant::Error, "External assistant stream exceeded maximum buffer size."
+ end
+
+ 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
+
+ if data == "[DONE]"
+ done = true
+ break
+ end
+
+ parsed = parse_sse_data(data)
+ next unless parsed
+
+ model ||= parsed["model"]
+ content = parsed.dig("choices", 0, "delta", "content")
+ block.call(content) unless content.nil?
+ 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, proxy_uri.user, proxy_uri.password)
+ 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?
+ host_down = host.downcase
+ no_proxy.split(",").any? do |pattern|
+ pattern = pattern.strip.downcase.delete_prefix(".")
+ host_down == pattern || host_down.end_with?(".#{pattern}")
+ end
+ 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 9a9facfb8..376dedc27 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -10,6 +10,9 @@ class Setting < RailsSettings::Base
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 5aef7afeb..24ce74a0b 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -136,7 +136,16 @@ class User < ApplicationRecord
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 000000000..5522706ad
--- /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 00b60c823..adb78ec51 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/CHANGELOG.md b/charts/sure/CHANGELOG.md
index b2d44fe72..7aa613010 100644
--- a/charts/sure/CHANGELOG.md
+++ b/charts/sure/CHANGELOG.md
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Pipelock security proxy** (`pipelock.enabled=true`): Separate Deployment + Service that provides two scanning layers
- **Forward proxy** (port 8888): Scans outbound HTTPS from Faraday-based clients (e.g. ruby-openai). Auto-injects `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` env vars into app pods
- **MCP reverse proxy** (port 8889): Scans inbound MCP traffic for DLP, prompt injection, and tool poisoning. Auto-computes upstream URL via `sure.pipelockUpstream` helper
- - **WebSocket proxy** configuration support (disabled by default, requires Pipelock >= 0.2.9)
+ - **WebSocket proxy** configuration support (disabled by default)
- ConfigMap with scanning config (DLP, prompt injection detection, MCP input/tool scanning, response scanning)
- ConfigMap checksum annotation for automatic pod restart on config changes
- Helm helpers: `sure.pipelockImage`, `sure.pipelockUpstream`
diff --git a/charts/sure/templates/_env.tpl b/charts/sure/templates/_env.tpl
index a0b230ac1..e50ab7ce8 100644
--- a/charts/sure/templates/_env.tpl
+++ b/charts/sure/templates/_env.tpl
@@ -94,6 +94,28 @@ The helper always injects:
- 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/pipelock-configmap.yaml b/charts/sure/templates/pipelock-configmap.yaml
index f840961e2..7b39a6726 100644
--- a/charts/sure/templates/pipelock-configmap.yaml
+++ b/charts/sure/templates/pipelock-configmap.yaml
@@ -36,6 +36,34 @@
{{- $wsMaxConnSec = int (.Values.pipelock.websocketProxy.maxConnectionSeconds | default 3600) -}}
{{- $wsIdleTimeout = int (.Values.pipelock.websocketProxy.idleTimeoutSeconds | default 300) -}}
{{- $wsOriginPolicy = .Values.pipelock.websocketProxy.originPolicy | default "rewrite" -}}
+{{- end -}}
+{{- $mcpPolicyEnabled := true -}}
+{{- $mcpPolicyAction := "warn" -}}
+{{- if .Values.pipelock.mcpToolPolicy -}}
+{{- if hasKey .Values.pipelock.mcpToolPolicy "enabled" -}}
+{{- $mcpPolicyEnabled = .Values.pipelock.mcpToolPolicy.enabled -}}
+{{- end -}}
+{{- $mcpPolicyAction = .Values.pipelock.mcpToolPolicy.action | default "warn" -}}
+{{- end -}}
+{{- $mcpBindingEnabled := true -}}
+{{- $mcpBindingAction := "warn" -}}
+{{- if .Values.pipelock.mcpSessionBinding -}}
+{{- if hasKey .Values.pipelock.mcpSessionBinding "enabled" -}}
+{{- $mcpBindingEnabled = .Values.pipelock.mcpSessionBinding.enabled -}}
+{{- end -}}
+{{- $mcpBindingAction = .Values.pipelock.mcpSessionBinding.unknownToolAction | default "warn" -}}
+{{- end -}}
+{{- $chainEnabled := true -}}
+{{- $chainAction := "warn" -}}
+{{- $chainWindow := 20 -}}
+{{- $chainGap := 3 -}}
+{{- if .Values.pipelock.toolChainDetection -}}
+{{- if hasKey .Values.pipelock.toolChainDetection "enabled" -}}
+{{- $chainEnabled = .Values.pipelock.toolChainDetection.enabled -}}
+{{- end -}}
+{{- $chainAction = .Values.pipelock.toolChainDetection.action | default "warn" -}}
+{{- $chainWindow = int (.Values.pipelock.toolChainDetection.windowSize | default 20) -}}
+{{- $chainGap = int (.Values.pipelock.toolChainDetection.maxGap | default 3) -}}
{{- end }}
apiVersion: v1
kind: ConfigMap
@@ -45,6 +73,8 @@ metadata:
{{- include "sure.labels" . | nindent 4 }}
data:
pipelock.yaml: |
+ version: 1
+ mode: {{ .Values.pipelock.mode | default "balanced" }}
forward_proxy:
enabled: {{ $fwdEnabled }}
max_tunnel_seconds: {{ $fwdMaxTunnel }}
@@ -62,9 +92,11 @@ data:
origin_policy: {{ $wsOriginPolicy }}
dlp:
scan_env: true
+ include_defaults: true
response_scanning:
enabled: true
action: warn
+ include_defaults: true
mcp_input_scanning:
enabled: true
action: block
@@ -73,4 +105,15 @@ data:
enabled: true
action: warn
detect_drift: true
+ mcp_tool_policy:
+ enabled: {{ $mcpPolicyEnabled }}
+ action: {{ $mcpPolicyAction }}
+ mcp_session_binding:
+ enabled: {{ $mcpBindingEnabled }}
+ unknown_tool_action: {{ $mcpBindingAction }}
+ tool_chain_detection:
+ enabled: {{ $chainEnabled }}
+ action: {{ $chainAction }}
+ window_size: {{ $chainWindow }}
+ max_gap: {{ $chainGap }}
{{- end }}
diff --git a/charts/sure/templates/pipelock-deployment.yaml b/charts/sure/templates/pipelock-deployment.yaml
index f35db3e49..99732fb0c 100644
--- a/charts/sure/templates/pipelock-deployment.yaml
+++ b/charts/sure/templates/pipelock-deployment.yaml
@@ -55,8 +55,6 @@ spec:
- "/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"
diff --git a/charts/sure/values.yaml b/charts/sure/values.yaml
index 349e88a23..ae3b34b3d 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:
@@ -474,7 +488,7 @@ pipelock:
enabled: false
image:
repository: ghcr.io/luckypipewrench/pipelock
- tag: "0.2.7"
+ tag: "0.3.1"
pullPolicy: IfNotPresent
imagePullSecrets: []
replicas: 1
@@ -491,9 +505,7 @@ pipelock:
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
@@ -503,6 +515,20 @@ pipelock:
maxConnectionSeconds: 3600 # 1 hour max connection lifetime
idleTimeoutSeconds: 300 # 5 min idle timeout
originPolicy: rewrite # rewrite, forward, or strip
+ # MCP tool policy: pre-execution rules for tool calls (shell obfuscation, etc.)
+ mcpToolPolicy:
+ enabled: true
+ action: warn
+ # MCP session binding: pins tool inventory on first tools/list, detects injection
+ mcpSessionBinding:
+ enabled: true
+ unknownToolAction: warn
+ # Tool call chain detection: detects multi-step attack patterns (recon, exfil, etc.)
+ toolChainDetection:
+ enabled: true
+ action: warn
+ windowSize: 20
+ maxGap: 3
service:
type: ClusterIP
resources:
diff --git a/compose.example.ai.yml b/compose.example.ai.yml
index fb85a354a..7532e27a8 100644
--- a/compose.example.ai.yml
+++ b/compose.example.ai.yml
@@ -71,6 +71,16 @@ 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:
@@ -86,8 +96,6 @@ services:
- "/etc/pipelock/pipelock.yaml"
- "--listen"
- "0.0.0.0:8888"
- - "--mode"
- - "balanced"
- "--mcp-listen"
- "0.0.0.0:8889"
- "--mcp-upstream"
diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml
index 8f3fcec32..cfe44a8ad 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 90e2c8f9a..1e5097fd2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -167,6 +167,7 @@ Rails.application.routes.draw do
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 0e6d56d1f..a1f8829c6 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 built-in 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.
@@ -777,4 +841,4 @@ For issues with AI features:
---
-**Last Updated:** October 2025
+**Last Updated:** March 2026
diff --git a/pipelock.example.yaml b/pipelock.example.yaml
index d53f11a13..2a8f4acdb 100644
--- a/pipelock.example.yaml
+++ b/pipelock.example.yaml
@@ -1,6 +1,9 @@
# Pipelock configuration for Docker Compose
# See https://github.com/luckyPipewrench/pipelock for full options.
+version: 1
+mode: balanced
+
forward_proxy:
enabled: true
max_tunnel_seconds: 300
@@ -20,10 +23,12 @@ websocket_proxy:
dlp:
scan_env: true
+ include_defaults: true
response_scanning:
enabled: true
action: warn
+ include_defaults: true
mcp_input_scanning:
enabled: true
@@ -34,3 +39,17 @@ mcp_tool_scanning:
enabled: true
action: warn
detect_drift: true
+
+mcp_tool_policy:
+ enabled: true
+ action: warn
+
+mcp_session_binding:
+ enabled: true
+ unknown_tool_action: warn
+
+tool_chain_detection:
+ enabled: true
+ action: warn
+ window_size: 20
+ max_gap: 3
diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb
index 5b1edb7cf..bd02b321a 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 000000000..74f2258ea
--- /dev/null
+++ b/test/models/assistant/external/client_test.rb
@@ -0,0 +1,283 @@
+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(/temporarily unavailable/, 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 was interrupted/, 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://proxyuser:proxypass@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]
+ assert_equal "proxyuser", captured_args[4]
+ assert_equal "proxypass", captured_args[5]
+ 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(HTTP_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 000000000..77f2a342d
--- /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 7ced43542..c07859090 100644
--- a/test/models/assistant_test.rb
+++ b/test/models/assistant_test.rb
@@ -187,14 +187,218 @@ class AssistantTest < ActiveSupport::TestCase
test "for_chat returns External when family assistant_type is external" 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
- assistant.respond_to(@message)
+ 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)
+
+ 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 85501c1d4..0d2a09d58 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -149,7 +149,7 @@ class UserTest < ActiveSupport::TestCase
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 @@ class UserTest < ActiveSupport::TestCase
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),