Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 56 additions & 11 deletions config.example.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
deployments_path: ~/flatrun/deployments
deployments_path: /home/nfebe/flatrun/deployments
system_files_root: /
docker_socket: unix:///var/run/docker.sock
default_timeout: 2m0s
api:
host: 0.0.0.0
port: 8090
Expand All @@ -26,6 +28,7 @@ nginx:
reload_command: nginx -s reload
external: false
container_webroot_path: ""
reject_unknown_domains: false
certbot:
enabled: true
image: certbot/certbot
Expand All @@ -35,6 +38,9 @@ certbot:
webroot_path: ""
container_webroot_path: ""
dns_provider: ""
auto_renewal_enabled: false
renewal_threshold_days: 30
renewal_check_interval: 12h0m0s
logging:
level: info
format: json
Expand All @@ -58,13 +64,16 @@ infrastructure:
host: ""
port: 6379
password: ""
# cluster:
# enabled: false
# server_name: "" # defaults to OS hostname
# advertise_url: "" # reachable URL for this agent (e.g. https://my-server:8090)
# health_interval: "30s"
# request_timeout: "10s"

powerdns:
enabled: false
container: powerdns
image: powerdns/pdns-auth-48:latest
api_port: 8081
dns_port: 53
api_key: 4bd02ab8c96205b6901660e53f7d96a2bf5253596f378d12
data_path: ""
default_soa: ""
nameservers: ""
security:
enabled: true
realtime_capture: false
Expand All @@ -74,8 +83,44 @@ security:
auto_block_enabled: true
auto_block_threshold: 50
auto_block_duration: 24h0m0s
# Only list proxies you control. Forwarded client IPs are honored solely
# when the connecting peer matches; an empty list ignores them, which
# prevents X-Forwarded-For / CF-Connecting-IP spoofing.
detection_window: 2m0s
not_found_threshold: 10
auth_failure_threshold: 5
unique_paths_threshold: 20
repeated_hits_threshold: 30
internal_api_token: ff66c51caad086544e7a6372b681da856146ae5586b6b59408d613fbc85f0045
trusted_proxies: []
trust_cf_header: false
audit:
enabled: false
retention_days: 30
capture_request_body: false
excluded_paths:
- /api/health
sensitive_fields:
- password
- token
- secret
- api_key
- authorization
cleanup_interval: 24h0m0s
cluster:
enabled: false
server_name: nfebe-zenbk-duo
advertise_url: ""
health_interval: 30s
request_timeout: 10s
system_terminal:
protected_mode:
enabled: false
cleanup:
timeout: 2m0s
plans:
ttl: 24h0m0s
retention_days: 30
ai:
enabled: true
base_url: https://generativelanguage.googleapis.com/v1beta/openai/
api_key: AIzaSyCYBIFuQmp35NVnQI68hzlV6l4BSTZ9_lM
model: gemini-2.5-flash
timeout: 1m0s
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/compose-spec/compose-go/v2 v2.10.1
github.com/creack/pty v1.1.24
github.com/digitalocean/godo v1.171.0
github.com/distribution/reference v0.6.0
github.com/docker/docker v28.5.2+incompatible
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/cors v1.7.6
Expand Down Expand Up @@ -67,7 +68,6 @@ require (
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/buildx v0.31.1 // indirect
github.com/docker/cli v29.2.1+incompatible // indirect
github.com/docker/compose/v5 v5.1.0 // indirect
Expand Down
284 changes: 284 additions & 0 deletions internal/ai/ai_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
package ai

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/flatrun/agent/pkg/config"
)

func TestNewDisabled(t *testing.T) {
if _, err := New(&config.AIConfig{Enabled: false}); err != ErrDisabled {
t.Errorf("err = %v, want ErrDisabled", err)
}
if _, err := New(nil); err != ErrDisabled {
t.Errorf("nil cfg err = %v, want ErrDisabled", err)
}
}

func TestRedactor(t *testing.T) {
r := NewRedactor([]string{"hunter2secret", "short", " spaced-secret-value "})

cases := []struct {
name string
in string
contains []string
excludes []string
minCount int
}{
{
name: "known secret value",
in: "db error: auth failed for password hunter2secret retrying",
excludes: []string{"hunter2secret"},
minCount: 1,
},
{
name: "short values stay",
in: "level=short msg=ok",
contains: []string{"short"},
},
{
name: "credential assignment",
in: "MYSQL_ROOT_PASSWORD=supersafe123\napi_key: abc123def\nDEBUG=true",
contains: []string{"MYSQL_ROOT_PASSWORD=[REDACTED]", "api_key: [REDACTED]", "DEBUG=true"},
excludes: []string{"supersafe123", "abc123def"},
minCount: 2,
},
{
name: "trimmed secret",
in: "token is spaced-secret-value here",
excludes: []string{"spaced-secret-value"},
minCount: 1,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, count := r.Redact(tc.in)
for _, want := range tc.contains {
if !strings.Contains(out, want) {
t.Errorf("output %q missing %q", out, want)
}
}
for _, banned := range tc.excludes {
if strings.Contains(out, banned) {
t.Errorf("output %q still contains %q", out, banned)
}
}
if count < tc.minCount {
t.Errorf("count = %d, want >= %d", count, tc.minCount)
}
})
}
}

func TestOpenAICompatibleComplete(t *testing.T) {
var gotAuth string
var gotPayload map[string]interface{}
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("path = %s", r.URL.Path)
}
_ = json.NewDecoder(r.Body).Decode(&gotPayload)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"model": "test-model",
"choices": []map[string]interface{}{
{"message": map[string]string{"role": "assistant", "content": "diagnosis here"}},
},
"usage": map[string]int{"prompt_tokens": 10, "completion_tokens": 5},
})
}))
defer fake.Close()

p, err := New(&config.AIConfig{
Enabled: true,
BaseURL: fake.URL + "/v1/",
APIKey: "sk-test",
Model: "test-model",
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatal(err)
}

resp, err := p.Complete(context.Background(), Request{
Messages: []Message{{Role: "user", Content: "hi"}},
})
if err != nil {
t.Fatal(err)
}
if resp.Content != "diagnosis here" || resp.Model != "test-model" {
t.Errorf("resp = %+v", resp)
}
if resp.Usage.PromptTokens != 10 {
t.Errorf("usage = %+v", resp.Usage)
}
if gotAuth != "Bearer sk-test" {
t.Errorf("auth header = %q", gotAuth)
}
if gotPayload["model"] != "test-model" {
t.Errorf("payload model = %v", gotPayload["model"])
}
}

func TestOpenAICompatibleToolCalling(t *testing.T) {
var sentPayload map[string]interface{}
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&sentPayload)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"model": "test-model",
"choices": []map[string]interface{}{{
"message": map[string]interface{}{
"role": "assistant",
"content": "",
"tool_calls": []map[string]interface{}{{
"id": "call_1",
"type": "function",
"function": map[string]interface{}{"name": "list_networks", "arguments": "{}"},
}},
},
}},
})
}))
defer fake.Close()

p, _ := New(&config.AIConfig{Enabled: true, BaseURL: fake.URL, Model: "test-model", Timeout: 5 * time.Second})
resp, err := p.Complete(context.Background(), Request{
Messages: []Message{
{Role: "user", Content: "what networks exist?"},
{Role: "assistant", ToolCalls: []ToolCall{{ID: "x", Name: "noop", Arguments: "{}"}}},
{Role: "tool", ToolCallID: "x", Name: "noop", Content: "done"},
},
Tools: []Tool{{
Name: "list_networks",
Description: "List docker networks",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}},
})
if err != nil {
t.Fatal(err)
}
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "list_networks" {
t.Fatalf("tool calls = %+v", resp.ToolCalls)
}

tools := sentPayload["tools"].([]interface{})
if len(tools) != 1 {
t.Fatalf("tools not sent: %v", sentPayload["tools"])
}
fn := tools[0].(map[string]interface{})["function"].(map[string]interface{})
if fn["name"] != "list_networks" {
t.Errorf("tool name = %v", fn["name"])
}

// The assistant tool-call message and the tool result must reach the
// wire in OpenAI's nested shape.
msgs := sentPayload["messages"].([]interface{})
assistant := msgs[1].(map[string]interface{})
if _, ok := assistant["tool_calls"]; !ok {
t.Error("assistant tool_calls not serialized")
}
toolMsg := msgs[2].(map[string]interface{})
if toolMsg["tool_call_id"] != "x" || toolMsg["role"] != "tool" {
t.Errorf("tool result message = %v", toolMsg)
}
}

func TestOpenAICompatibleKeyless(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if auth := r.Header.Get("Authorization"); auth != "" {
t.Errorf("keyless request sent auth header %q", auth)
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"choices": []map[string]interface{}{{"message": map[string]string{"content": "ok"}}},
})
}))
defer fake.Close()

p, _ := New(&config.AIConfig{Enabled: true, BaseURL: fake.URL, Model: "llama3"})
resp, err := p.Complete(context.Background(), Request{Messages: []Message{{Role: "user", Content: "hi"}}})
if err != nil {
t.Fatal(err)
}
if resp.Model != "llama3" {
t.Errorf("model fallback = %q, want configured model", resp.Model)
}
}

func TestOpenAICompatibleErrorMapping(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"message":"invalid api key"}}`))
}))
defer fake.Close()

p, _ := New(&config.AIConfig{Enabled: true, BaseURL: fake.URL, Model: "m"})
_, err := p.Complete(context.Background(), Request{Messages: []Message{{Role: "user", Content: "hi"}}})
if err == nil || !strings.Contains(err.Error(), "invalid api key") || !strings.Contains(err.Error(), "401") {
t.Errorf("err = %v, want provider message and status", err)
}
}

func TestBuildAssistMessagesTruncates(t *testing.T) {
long := strings.Repeat("x", contextBudget*2)
intent, ok := GetIntent("diagnose")
if !ok {
t.Fatal("diagnose intent missing")
}
msgs := BuildAssistMessages(intent, "deployment myapp", []Section{
{Label: "docker-compose.yml", Content: "services: {}", Format: "yaml"},
{Label: "Recent logs", Content: long},
}, "why does it crash?", "https://flatrun.dev/docs/")

if len(msgs) != 2 {
t.Fatalf("got %d messages", len(msgs))
}
if msgs[0].Role != "system" || msgs[1].Role != "user" {
t.Errorf("roles = %s/%s", msgs[0].Role, msgs[1].Role)
}
if len(msgs[1].Content) > contextBudget+2000 {
t.Errorf("user message not truncated: %d chars", len(msgs[1].Content))
}
if !strings.Contains(msgs[1].Content, "[... truncated ...]") {
t.Error("truncation marker missing")
}
if !strings.Contains(msgs[1].Content, strings.Repeat("x", 100)) {
t.Error("log tail missing from prompt")
}
if !strings.Contains(msgs[1].Content, "why does it crash?") {
t.Error("operator question missing from prompt")
}
if !strings.Contains(msgs[1].Content, "deployment myapp") {
t.Error("scope label missing from prompt")
}
if !strings.Contains(msgs[0].Content, "https://flatrun.dev/docs/") {
t.Error("docs link missing from system prompt")
}
}

func TestIntentRegistry(t *testing.T) {
for _, key := range []string{"diagnose", "improve", "secure", "explain"} {
intent, ok := GetIntent(key)
if !ok {
t.Errorf("intent %q missing", key)
continue
}
msgs := BuildAssistMessages(intent, "the FlatRun host", []Section{{Label: "Output", Content: "boom"}}, "", "")
hasSuggestionFormat := strings.Contains(msgs[0].Content, "suggestions")
if intent.AllowSuggestions && !hasSuggestionFormat {
t.Errorf("intent %q should request suggestions", key)
}
if !intent.AllowSuggestions && hasSuggestionFormat {
t.Errorf("intent %q should not request suggestions", key)
}
}
if _, ok := GetIntent("nonsense"); ok {
t.Error("unknown intent should not resolve")
}
}
Loading
Loading