Add external AI assistant with Pipelock security proxy (#1069)

* feat(helm): add Pipelock ConfigMap, scanning config, and consolidate compose

- Add ConfigMap template rendering DLP, response scanning, MCP input/tool
  scanning, and forward proxy settings from values
- Mount ConfigMap as /etc/pipelock/pipelock.yaml volume in deployment
- Add checksum/config annotation for automatic pod restart on config change
- Gate HTTPS_PROXY/HTTP_PROXY env injection on forwardProxy.enabled (skip
  in MCP-only mode)
- Use hasKey for all boolean values to prevent Helm default swallowing false
- Single source of truth for ports (forwardProxy.port/mcpProxy.port)
- Pipelock-specific imagePullSecrets with fallback to app secrets
- Merge standalone compose.example.pipelock.yml into compose.example.ai.yml
- Add pipelock.example.yaml for Docker Compose users
- Add exclude-paths to CI workflow for locale file false positives

* Add external assistant support (OpenAI-compatible SSE proxy)

Allow self-hosted instances to delegate chat to an external AI agent
via an OpenAI-compatible streaming endpoint. Configurable per-family
through Settings UI or ASSISTANT_TYPE env override.

- Assistant::External::Client: SSE streaming HTTP client (no new gems)
- Settings UI with type selector, env lock indicator, config status
- Helm chart and Docker Compose env var support
- 45 tests covering client, config, routing, controller, integration

* Add session key routing, email allowlist, and config plumbing

Route to the actual OpenClaw session via x-openclaw-session-key header
instead of creating isolated sessions. Gate external assistant access
behind an email allowlist (EXTERNAL_ASSISTANT_ALLOWED_EMAILS env var).
Plumb session_key and allowedEmails through Helm chart, compose, and
env template.

* Add HTTPS_PROXY support to External::Client for Pipelock integration

Net::HTTP does not auto-read HTTPS_PROXY/HTTP_PROXY env vars (unlike
Faraday). Explicitly resolve proxy from environment in build_http so
outbound traffic to the external assistant routes through Pipelock's
forward proxy when enabled. Respects NO_PROXY for internal hosts.

* Add UI fields for external assistant config (Setting-backed with env fallback)

Follow the same pattern as OpenAI settings: database-backed Setting
fields with env var defaults. Self-hosters can now configure the
external assistant URL, token, and agent ID from the browser
(Settings > Self-Hosting > AI Assistant) instead of requiring env vars.
Fields disable when the corresponding env var is set.

* Improve external assistant UI labels and add help text

Change placeholder to generic OpenAI-compatible URL pattern. Add help
text under each field explaining where the values come from: URL from
agent provider, token for authentication, agent ID for multi-agent
routing.

* Add external assistant docs and fix URL help text

Add External AI Assistant section to docs/hosting/ai.md covering setup
(UI and env vars), how it works, Pipelock security scanning, access
control, and Docker Compose example. Drop "chat completions" jargon
from URL help text.

* Harden external assistant: retry logic, disconnect UI, error handling, and test coverage

- Add retry with backoff for transient network errors (no retry after streaming starts)
- Add disconnect button with confirmation modal in self-hosting settings
- Narrow rescue scope with fallback logging for unexpected errors
- Safe cleanup of partial responses on stream interruption
- Gate ai_available? on family assistant_type instead of OR-ing all providers
- Truncate conversation history to last 20 messages
- Proxy-aware HTTP client with NO_PROXY support
- Sanitize protocol to use generic headers (X-Agent-Id, X-Session-Key)
- Full test coverage for streaming, retries, proxy routing, config, and disconnect

* Exclude external assistant client from Pipelock scan-diff

False positive: `@token` instance variable flagged as "Credential in URL".
Temporary workaround until Pipelock supports inline suppression.

* Address review feedback: NO_PROXY boundary fix, SSE done flag, design tokens

- Fix NO_PROXY matching to require domain boundary (exact match or .suffix),
  case-insensitive. Prevents badexample.com matching example.com.
- Add done flag to SSE streaming so read_body stops after [DONE]
- Move MAX_CONVERSATION_MESSAGES to class level
- Use bg-success/bg-destructive design tokens for status indicators
- Add rationale comment for pipelock scan exclusion
- Update docs last-updated date

* Address second round of review feedback

- Allowlist email comparison is now case-insensitive and nil-safe
- Cap SSE buffer at 1 MB to prevent memory blowup from malformed streams
- Don't expose upstream HTTP response body in user-facing errors (log it instead)
- Fix frozen string warning on buffer initialization
- Fix "builtin" typo in docs (should be "built-in")

* Protect completed responses from cleanup, sanitize error messages

- Don't destroy a fully streamed assistant message if post-stream
  metadata update fails (only cleanup partial responses)
- Log raw connection/HTTP errors internally, show generic messages
  to users to avoid leaking network/proxy details
- Update test assertions for new error message wording

* Fix SSE content guard and NO_PROXY test correctness

Use nil check instead of present? for SSE delta content to preserve
whitespace-only chunks (newlines, spaces) that can occur in code output.

Fix NO_PROXY test to use HTTP_PROXY matching the http:// client URL so
the proxy resolution and NO_PROXY bypass logic are actually exercised.

* Forward proxy credentials to Net::HTTP

Pass proxy_uri.user and proxy_uri.password to Net::HTTP.new so
authenticated proxies (http://user:pass@host:port) work correctly.
Without this, credentials parsed from the proxy URL were silently
dropped. Nil values are safe as positional args when no creds exist.

* Update pipelock integration to v0.3.1 with full scanning config

Bump Helm image tag from 0.2.7 to 0.3.1. Add missing security
sections to both the Helm ConfigMap and compose example config:
mcp_tool_policy, mcp_session_binding, and tool_chain_detection.
These protect the /mcp endpoint against tool injection, session
hijacking, and multi-step exfiltration chains.

Add version and mode fields to config files. Enable include_defaults
for DLP and response scanning to merge user patterns with the 35
built-in patterns. Remove redundant --mode CLI flag from the Helm
deployment template since mode is now in the config file.
This commit is contained in:
LPW
2026-03-03 09:47:51 -05:00
committed by GitHub
parent ad24c3aba5
commit 84bfe5b7ab
24 changed files with 1401 additions and 24 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

175
app/models/assistant/external/client.rb vendored Normal file
View File

@@ -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

View File

@@ -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"

View File

@@ -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?

View File

@@ -0,0 +1,112 @@
<div class="space-y-4">
<div>
<h2 class="font-medium mb-1"><%= t(".title") %></h2>
<% if ENV["ASSISTANT_TYPE"].present? %>
<p class="text-sm text-secondary"><%= t(".env_notice", type: ENV["ASSISTANT_TYPE"]) %></p>
<% else %>
<p class="text-secondary text-sm mb-4"><%= t(".description") %></p>
<% end %>
</div>
<% 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" %>
<div class="flex items-center gap-2 text-sm mb-4">
<% if Assistant::External.configured? %>
<span class="inline-block w-2 h-2 rounded-full bg-success"></span>
<span class="text-secondary"><%= t(".external_configured") %></span>
<% else %>
<span class="inline-block w-2 h-2 rounded-full bg-destructive"></span>
<span class="text-secondary"><%= t(".external_not_configured") %></span>
<% end %>
</div>
<% if ENV["EXTERNAL_ASSISTANT_URL"].present? && ENV["EXTERNAL_ASSISTANT_TOKEN"].present? %>
<p class="text-sm text-secondary"><%= t(".env_configured_external") %></p>
<% end %>
<% if Assistant::External.configured? && !ENV["EXTERNAL_ASSISTANT_URL"].present? %>
<div class="flex items-center justify-between p-3 rounded-lg border border-primary">
<div>
<p class="text-sm font-medium text-primary"><%= t(".disconnect_title") %></p>
<p class="text-xs text-secondary"><%= t(".disconnect_description") %></p>
</div>
<%= 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"
}} %>
</div>
<% 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" } %>
<p class="text-xs text-secondary mt-1"><%= t(".url_help") %></p>
<%= 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" } %>
<p class="text-xs text-secondary mt-1"><%= t(".token_help") %></p>
<%= 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" } %>
<p class="text-xs text-secondary mt-1"><%= t(".agent_id_help") %></p>
<% end %>
<% end %>
</div>

View File

@@ -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 %>
<div class="space-y-6">
<%= render "settings/hostings/openai_settings" %>

View File

@@ -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`

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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"

View File

@@ -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=<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:

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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

View File

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

View File

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