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