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),