|
5ac549c…
|
lmata
|
1 |
package api |
|
5ac549c…
|
lmata
|
2 |
|
|
5ac549c…
|
lmata
|
3 |
import ( |
|
5ac549c…
|
lmata
|
4 |
"encoding/json" |
|
5ac549c…
|
lmata
|
5 |
"fmt" |
|
5ac549c…
|
lmata
|
6 |
"net/http" |
|
5ac549c…
|
lmata
|
7 |
"os" |
|
5ac549c…
|
lmata
|
8 |
"sync" |
|
0e78954…
|
lmata
|
9 |
|
|
0e78954…
|
lmata
|
10 |
"github.com/conflicthq/scuttlebot/internal/store" |
|
5ac549c…
|
lmata
|
11 |
) |
|
5ac549c…
|
lmata
|
12 |
|
|
5ac549c…
|
lmata
|
13 |
// BehaviorConfig defines a pre-registered system bot behavior. |
|
5ac549c…
|
lmata
|
14 |
type BehaviorConfig struct { |
|
5ac549c…
|
lmata
|
15 |
ID string `json:"id"` |
|
5ac549c…
|
lmata
|
16 |
Name string `json:"name"` |
|
5ac549c…
|
lmata
|
17 |
Description string `json:"description"` |
|
5ac549c…
|
lmata
|
18 |
Nick string `json:"nick"` |
|
5ac549c…
|
lmata
|
19 |
Enabled bool `json:"enabled"` |
|
5ac549c…
|
lmata
|
20 |
JoinAllChannels bool `json:"join_all_channels"` |
|
5ac549c…
|
lmata
|
21 |
ExcludeChannels []string `json:"exclude_channels"` |
|
5ac549c…
|
lmata
|
22 |
RequiredChannels []string `json:"required_channels"` |
|
5ac549c…
|
lmata
|
23 |
// Config holds bot-specific configuration. The schema is defined per bot |
|
5ac549c…
|
lmata
|
24 |
// in the UI; the backend stores and returns it opaquely. |
|
5ac549c…
|
lmata
|
25 |
Config map[string]any `json:"config,omitempty"` |
|
5ac549c…
|
lmata
|
26 |
} |
|
5ac549c…
|
lmata
|
27 |
|
|
5ac549c…
|
lmata
|
28 |
// AgentPolicy defines requirements applied to all registering agents. |
|
5ac549c…
|
lmata
|
29 |
type AgentPolicy struct { |
|
d924aea…
|
lmata
|
30 |
RequireCheckin bool `json:"require_checkin"` |
|
d924aea…
|
lmata
|
31 |
CheckinChannel string `json:"checkin_channel"` |
|
d924aea…
|
lmata
|
32 |
RequiredChannels []string `json:"required_channels"` |
|
d924aea…
|
lmata
|
33 |
OnlineTimeoutSecs int `json:"online_timeout_secs,omitempty"` |
|
d924aea…
|
lmata
|
34 |
ReapAfterDays int `json:"reap_after_days,omitempty"` |
|
5ac549c…
|
lmata
|
35 |
} |
|
5ac549c…
|
lmata
|
36 |
|
|
5ac549c…
|
lmata
|
37 |
// LoggingPolicy configures message logging. |
|
5ac549c…
|
lmata
|
38 |
type LoggingPolicy struct { |
|
5ac549c…
|
lmata
|
39 |
Enabled bool `json:"enabled"` |
|
5ac549c…
|
lmata
|
40 |
Dir string `json:"dir"` // directory to write log files into |
|
5ac549c…
|
lmata
|
41 |
Format string `json:"format"` // "jsonl" | "csv" | "text" |
|
5ac549c…
|
lmata
|
42 |
Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size" |
|
5ac549c…
|
lmata
|
43 |
MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited |
|
5ac549c…
|
lmata
|
44 |
PerChannel bool `json:"per_channel"` // separate file per channel |
|
5ac549c…
|
lmata
|
45 |
MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all |
|
5ac549c…
|
lmata
|
46 |
} |
|
5ac549c…
|
lmata
|
47 |
|
|
a027855…
|
noreply
|
48 |
// ChannelDisplayConfig holds per-channel rendering preferences. |
|
a027855…
|
noreply
|
49 |
type ChannelDisplayConfig struct { |
|
a027855…
|
noreply
|
50 |
MirrorDetail string `json:"mirror_detail,omitempty"` // "full", "compact", "minimal" |
|
a027855…
|
noreply
|
51 |
RenderMode string `json:"render_mode,omitempty"` // "rich", "text" |
|
a027855…
|
noreply
|
52 |
} |
|
a027855…
|
noreply
|
53 |
|
|
5ac549c…
|
lmata
|
54 |
// BridgePolicy configures bridge-specific UI/relay behavior. |
|
5ac549c…
|
lmata
|
55 |
type BridgePolicy struct { |
|
5ac549c…
|
lmata
|
56 |
// WebUserTTLMinutes controls how long HTTP bridge sender nicks remain |
|
5ac549c…
|
lmata
|
57 |
// visible in the channel user list after their last post. |
|
5ac549c…
|
lmata
|
58 |
WebUserTTLMinutes int `json:"web_user_ttl_minutes"` |
|
a027855…
|
noreply
|
59 |
// ChannelDisplay holds per-channel rendering config. |
|
a027855…
|
noreply
|
60 |
ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"` |
|
5ac549c…
|
lmata
|
61 |
} |
|
5ac549c…
|
lmata
|
62 |
|
|
5ac549c…
|
lmata
|
63 |
// PolicyLLMBackend stores an LLM backend configuration in the policy store. |
|
5ac549c…
|
lmata
|
64 |
// This allows backends to be added and edited from the web UI rather than |
|
5ac549c…
|
lmata
|
65 |
// requiring a change to scuttlebot.yaml. |
|
5ac549c…
|
lmata
|
66 |
// |
|
5ac549c…
|
lmata
|
67 |
// API keys are write-only — GET responses replace them with "***" when set. |
|
5ac549c…
|
lmata
|
68 |
type PolicyLLMBackend struct { |
|
5ac549c…
|
lmata
|
69 |
Name string `json:"name"` |
|
5ac549c…
|
lmata
|
70 |
Backend string `json:"backend"` |
|
5ac549c…
|
lmata
|
71 |
APIKey string `json:"api_key,omitempty"` |
|
5ac549c…
|
lmata
|
72 |
BaseURL string `json:"base_url,omitempty"` |
|
5ac549c…
|
lmata
|
73 |
Model string `json:"model,omitempty"` |
|
5ac549c…
|
lmata
|
74 |
Region string `json:"region,omitempty"` |
|
5ac549c…
|
lmata
|
75 |
AWSKeyID string `json:"aws_key_id,omitempty"` |
|
5ac549c…
|
lmata
|
76 |
AWSSecretKey string `json:"aws_secret_key,omitempty"` |
|
5ac549c…
|
lmata
|
77 |
Allow []string `json:"allow,omitempty"` |
|
5ac549c…
|
lmata
|
78 |
Block []string `json:"block,omitempty"` |
|
5ac549c…
|
lmata
|
79 |
Default bool `json:"default,omitempty"` |
|
5ac549c…
|
lmata
|
80 |
} |
|
5ac549c…
|
lmata
|
81 |
|
|
900677e…
|
noreply
|
82 |
// ROETemplate is a rules-of-engagement template. |
|
900677e…
|
noreply
|
83 |
type ROETemplate struct { |
|
900677e…
|
noreply
|
84 |
Name string `json:"name"` |
|
900677e…
|
noreply
|
85 |
Description string `json:"description,omitempty"` |
|
900677e…
|
noreply
|
86 |
Channels []string `json:"channels,omitempty"` |
|
900677e…
|
noreply
|
87 |
Permissions []string `json:"permissions,omitempty"` |
|
900677e…
|
noreply
|
88 |
RateLimit struct { |
|
900677e…
|
noreply
|
89 |
MessagesPerSecond float64 `json:"messages_per_second,omitempty"` |
|
900677e…
|
noreply
|
90 |
Burst int `json:"burst,omitempty"` |
|
900677e…
|
noreply
|
91 |
} `json:"rate_limit,omitempty"` |
|
900677e…
|
noreply
|
92 |
} |
|
900677e…
|
noreply
|
93 |
|
|
5ac549c…
|
lmata
|
94 |
// Policies is the full mutable settings blob, persisted to policies.json. |
|
5ac549c…
|
lmata
|
95 |
type Policies struct { |
|
6d94dfd…
|
noreply
|
96 |
Behaviors []BehaviorConfig `json:"behaviors"` |
|
6d94dfd…
|
noreply
|
97 |
AgentPolicy AgentPolicy `json:"agent_policy"` |
|
6d94dfd…
|
noreply
|
98 |
Bridge BridgePolicy `json:"bridge"` |
|
6d94dfd…
|
noreply
|
99 |
Logging LoggingPolicy `json:"logging"` |
|
6d94dfd…
|
noreply
|
100 |
LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` |
|
6d94dfd…
|
noreply
|
101 |
ROETemplates []ROETemplate `json:"roe_templates,omitempty"` |
|
6d94dfd…
|
noreply
|
102 |
OnJoinMessages map[string]string `json:"on_join_messages,omitempty"` // channel → message template |
|
5ac549c…
|
lmata
|
103 |
} |
|
5ac549c…
|
lmata
|
104 |
|
|
5ac549c…
|
lmata
|
105 |
// defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
|
5ac549c…
|
lmata
|
106 |
var defaultBehaviors = []BehaviorConfig{ |
|
5ac549c…
|
lmata
|
107 |
{ |
|
5ac549c…
|
lmata
|
108 |
ID: "auditbot", |
|
5ac549c…
|
lmata
|
109 |
Name: "Auditor", |
|
5ac549c…
|
lmata
|
110 |
Description: "Immutable append-only audit trail of agent actions and credential lifecycle events.", |
|
5ac549c…
|
lmata
|
111 |
Nick: "auditbot", |
|
5ac549c…
|
lmata
|
112 |
JoinAllChannels: true, |
|
5ac549c…
|
lmata
|
113 |
}, |
|
5ac549c…
|
lmata
|
114 |
{ |
|
5ac549c…
|
lmata
|
115 |
ID: "scribe", |
|
5ac549c…
|
lmata
|
116 |
Name: "Scribe", |
|
5ac549c…
|
lmata
|
117 |
Description: "Records all channel messages to a structured log store.", |
|
5ac549c…
|
lmata
|
118 |
Nick: "scribe", |
|
5ac549c…
|
lmata
|
119 |
JoinAllChannels: true, |
|
5ac549c…
|
lmata
|
120 |
}, |
|
5ac549c…
|
lmata
|
121 |
{ |
|
3420a83…
|
lmata
|
122 |
ID: "herald", |
|
3420a83…
|
lmata
|
123 |
Name: "Herald", |
|
3420a83…
|
lmata
|
124 |
Description: "Routes event notifications from external systems to IRC channels.", |
|
3420a83…
|
lmata
|
125 |
Nick: "herald", |
|
3420a83…
|
lmata
|
126 |
JoinAllChannels: true, |
|
5ac549c…
|
lmata
|
127 |
}, |
|
5ac549c…
|
lmata
|
128 |
{ |
|
5ac549c…
|
lmata
|
129 |
ID: "oracle", |
|
5ac549c…
|
lmata
|
130 |
Name: "Oracle", |
|
5ac549c…
|
lmata
|
131 |
Description: "On-demand channel summarisation via DM using an LLM.", |
|
5ac549c…
|
lmata
|
132 |
Nick: "oracle", |
|
5ac549c…
|
lmata
|
133 |
JoinAllChannels: true, |
|
5ac549c…
|
lmata
|
134 |
}, |
|
5ac549c…
|
lmata
|
135 |
{ |
|
5ac549c…
|
lmata
|
136 |
ID: "warden", |
|
5ac549c…
|
lmata
|
137 |
Name: "Warden", |
|
5ac549c…
|
lmata
|
138 |
Description: "Enforces channel moderation — detects floods and malformed messages, escalates warn → mute → kick.", |
|
5ac549c…
|
lmata
|
139 |
Nick: "warden", |
|
5ac549c…
|
lmata
|
140 |
JoinAllChannels: true, |
|
5ac549c…
|
lmata
|
141 |
}, |
|
5ac549c…
|
lmata
|
142 |
{ |
|
5ac549c…
|
lmata
|
143 |
ID: "scroll", |
|
5ac549c…
|
lmata
|
144 |
Name: "Scroll", |
|
5ac549c…
|
lmata
|
145 |
Description: "Replays channel history to users via DM on request.", |
|
5ac549c…
|
lmata
|
146 |
Nick: "scroll", |
|
5ac549c…
|
lmata
|
147 |
JoinAllChannels: true, |
|
5ac549c…
|
lmata
|
148 |
}, |
|
5ac549c…
|
lmata
|
149 |
{ |
|
5ac549c…
|
lmata
|
150 |
ID: "systembot", |
|
5ac549c…
|
lmata
|
151 |
Name: "Systembot", |
|
5ac549c…
|
lmata
|
152 |
Description: "Logs IRC system events (joins, parts, quits, mode changes) to a store.", |
|
5ac549c…
|
lmata
|
153 |
Nick: "systembot", |
|
5ac549c…
|
lmata
|
154 |
JoinAllChannels: true, |
|
5ac549c…
|
lmata
|
155 |
}, |
|
5ac549c…
|
lmata
|
156 |
{ |
|
5ac549c…
|
lmata
|
157 |
ID: "snitch", |
|
5ac549c…
|
lmata
|
158 |
Name: "Snitch", |
|
5ac549c…
|
lmata
|
159 |
Description: "Watches for erratic behaviour and alerts operators via DM or a dedicated channel.", |
|
5ac549c…
|
lmata
|
160 |
Nick: "snitch", |
|
5ac549c…
|
lmata
|
161 |
JoinAllChannels: true, |
|
5ac549c…
|
lmata
|
162 |
}, |
|
5ac549c…
|
lmata
|
163 |
{ |
|
5ac549c…
|
lmata
|
164 |
ID: "sentinel", |
|
5ac549c…
|
lmata
|
165 |
Name: "Sentinel", |
|
5ac549c…
|
lmata
|
166 |
Description: "LLM-powered channel observer. Detects policy violations and posts structured incident reports to a mod channel. Never takes enforcement action.", |
|
5ac549c…
|
lmata
|
167 |
Nick: "sentinel", |
|
5ac549c…
|
lmata
|
168 |
JoinAllChannels: true, |
|
5ac549c…
|
lmata
|
169 |
}, |
|
5ac549c…
|
lmata
|
170 |
{ |
|
5ac549c…
|
lmata
|
171 |
ID: "steward", |
|
5ac549c…
|
lmata
|
172 |
Name: "Steward", |
|
5ac549c…
|
lmata
|
173 |
Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.", |
|
5ac549c…
|
lmata
|
174 |
Nick: "steward", |
|
5ac549c…
|
lmata
|
175 |
JoinAllChannels: true, |
|
6d94dfd…
|
noreply
|
176 |
}, |
|
039edb2…
|
noreply
|
177 |
{ |
|
039edb2…
|
noreply
|
178 |
ID: "shepherd", |
|
039edb2…
|
noreply
|
179 |
Name: "Shepherd", |
|
039edb2…
|
noreply
|
180 |
Description: "Goal-directed agent coordinator. Assigns work, tracks progress, checks in on agents, generates plans using LLM. Configurable with any LLM provider.", |
|
039edb2…
|
noreply
|
181 |
Nick: "shepherd", |
|
039edb2…
|
noreply
|
182 |
}, |
|
6d94dfd…
|
noreply
|
183 |
} |
|
6d94dfd…
|
noreply
|
184 |
|
|
6d94dfd…
|
noreply
|
185 |
// BotCommand describes a single command a bot responds to. |
|
6d94dfd…
|
noreply
|
186 |
type BotCommand struct { |
|
6d94dfd…
|
noreply
|
187 |
Command string `json:"command"` |
|
6d94dfd…
|
noreply
|
188 |
Usage string `json:"usage"` |
|
6d94dfd…
|
noreply
|
189 |
Description string `json:"description"` |
|
6d94dfd…
|
noreply
|
190 |
} |
|
6d94dfd…
|
noreply
|
191 |
|
|
6d94dfd…
|
noreply
|
192 |
// botCommands maps bot ID to its available commands. |
|
6d94dfd…
|
noreply
|
193 |
var botCommands = map[string][]BotCommand{ |
|
6d94dfd…
|
noreply
|
194 |
"oracle": { |
|
6d94dfd…
|
noreply
|
195 |
{Command: "summarize", Usage: "summarize #channel [last=N] [format=toon|json]", Description: "Summarize recent channel activity using an LLM."}, |
|
6d94dfd…
|
noreply
|
196 |
}, |
|
6d94dfd…
|
noreply
|
197 |
"scroll": { |
|
6d94dfd…
|
noreply
|
198 |
{Command: "replay", Usage: "replay #channel [last=N] [since=<unix_ms>]", Description: "Replay recent channel history via DM."}, |
|
6d94dfd…
|
noreply
|
199 |
}, |
|
6d94dfd…
|
noreply
|
200 |
"steward": { |
|
6d94dfd…
|
noreply
|
201 |
{Command: "mute", Usage: "mute <nick> [duration]", Description: "Mute a nick in the current channel."}, |
|
6d94dfd…
|
noreply
|
202 |
{Command: "unmute", Usage: "unmute <nick>", Description: "Remove mute from a nick."}, |
|
6d94dfd…
|
noreply
|
203 |
{Command: "kick", Usage: "kick <nick> [reason]", Description: "Kick a nick from the current channel."}, |
|
6d94dfd…
|
noreply
|
204 |
{Command: "warn", Usage: "warn <nick> <message>", Description: "Send a warning notice to a nick."}, |
|
6d94dfd…
|
noreply
|
205 |
}, |
|
6d94dfd…
|
noreply
|
206 |
"warden": { |
|
6d94dfd…
|
noreply
|
207 |
{Command: "status", Usage: "status", Description: "Show warden rate-limit status for all tracked nicks."}, |
|
6d94dfd…
|
noreply
|
208 |
}, |
|
6d94dfd…
|
noreply
|
209 |
"snitch": { |
|
6d94dfd…
|
noreply
|
210 |
{Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."}, |
|
6d94dfd…
|
noreply
|
211 |
}, |
|
6d94dfd…
|
noreply
|
212 |
"herald": { |
|
6d94dfd…
|
noreply
|
213 |
{Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."}, |
|
039edb2…
|
noreply
|
214 |
}, |
|
039edb2…
|
noreply
|
215 |
"shepherd": { |
|
039edb2…
|
noreply
|
216 |
{Command: "goal", Usage: "GOAL <description>", Description: "Set a goal for the current channel."}, |
|
039edb2…
|
noreply
|
217 |
{Command: "goals", Usage: "GOALS", Description: "List all active goals."}, |
|
039edb2…
|
noreply
|
218 |
{Command: "done", Usage: "DONE <goal-id>", Description: "Mark a goal as completed."}, |
|
039edb2…
|
noreply
|
219 |
{Command: "status", Usage: "STATUS", Description: "Report progress on current goals (LLM-enhanced)."}, |
|
039edb2…
|
noreply
|
220 |
{Command: "assign", Usage: "ASSIGN <nick> <task>", Description: "Manually assign a task to an agent."}, |
|
039edb2…
|
noreply
|
221 |
{Command: "checkin", Usage: "CHECKIN", Description: "Trigger a check-in round with assigned agents."}, |
|
039edb2…
|
noreply
|
222 |
{Command: "plan", Usage: "PLAN", Description: "Generate a work plan from goals using LLM."}, |
|
0e78954…
|
lmata
|
223 |
}, |
|
5ac549c…
|
lmata
|
224 |
} |
|
5ac549c…
|
lmata
|
225 |
|
|
0e78954…
|
lmata
|
226 |
// PolicyStore persists Policies to a JSON file or database. |
|
5ac549c…
|
lmata
|
227 |
type PolicyStore struct { |
|
5ac549c…
|
lmata
|
228 |
mu sync.RWMutex |
|
5ac549c…
|
lmata
|
229 |
path string |
|
5ac549c…
|
lmata
|
230 |
data Policies |
|
5ac549c…
|
lmata
|
231 |
defaultBridgeTTLMinutes int |
|
5ac549c…
|
lmata
|
232 |
onChange func(Policies) |
|
0e78954…
|
lmata
|
233 |
db *store.Store // when non-nil, supersedes path |
|
5ac549c…
|
lmata
|
234 |
} |
|
5ac549c…
|
lmata
|
235 |
|
|
5ac549c…
|
lmata
|
236 |
func NewPolicyStore(path string, defaultBridgeTTLMinutes int) (*PolicyStore, error) { |
|
5ac549c…
|
lmata
|
237 |
if defaultBridgeTTLMinutes <= 0 { |
|
5ac549c…
|
lmata
|
238 |
defaultBridgeTTLMinutes = 5 |
|
5ac549c…
|
lmata
|
239 |
} |
|
5ac549c…
|
lmata
|
240 |
ps := &PolicyStore{ |
|
5ac549c…
|
lmata
|
241 |
path: path, |
|
5ac549c…
|
lmata
|
242 |
defaultBridgeTTLMinutes: defaultBridgeTTLMinutes, |
|
5ac549c…
|
lmata
|
243 |
} |
|
5ac549c…
|
lmata
|
244 |
ps.data.Behaviors = defaultBehaviors |
|
5ac549c…
|
lmata
|
245 |
ps.data.Bridge.WebUserTTLMinutes = defaultBridgeTTLMinutes |
|
5ac549c…
|
lmata
|
246 |
if err := ps.load(); err != nil { |
|
5ac549c…
|
lmata
|
247 |
return nil, err |
|
5ac549c…
|
lmata
|
248 |
} |
|
5ac549c…
|
lmata
|
249 |
return ps, nil |
|
5ac549c…
|
lmata
|
250 |
} |
|
5ac549c…
|
lmata
|
251 |
|
|
5ac549c…
|
lmata
|
252 |
func (ps *PolicyStore) load() error { |
|
5ac549c…
|
lmata
|
253 |
raw, err := os.ReadFile(ps.path) |
|
5ac549c…
|
lmata
|
254 |
if os.IsNotExist(err) { |
|
5ac549c…
|
lmata
|
255 |
return nil |
|
5ac549c…
|
lmata
|
256 |
} |
|
5ac549c…
|
lmata
|
257 |
if err != nil { |
|
5ac549c…
|
lmata
|
258 |
return fmt.Errorf("policies: read %s: %w", ps.path, err) |
|
5ac549c…
|
lmata
|
259 |
} |
|
0e78954…
|
lmata
|
260 |
return ps.applyRaw(raw) |
|
0e78954…
|
lmata
|
261 |
} |
|
0e78954…
|
lmata
|
262 |
|
|
0e78954…
|
lmata
|
263 |
// SetStore switches the policy store to database-backed persistence. The |
|
0e78954…
|
lmata
|
264 |
// current in-memory defaults are merged with any saved policies in the store. |
|
0e78954…
|
lmata
|
265 |
func (ps *PolicyStore) SetStore(db *store.Store) error { |
|
0e78954…
|
lmata
|
266 |
raw, err := db.PolicyGet() |
|
0e78954…
|
lmata
|
267 |
if err != nil { |
|
0e78954…
|
lmata
|
268 |
return fmt.Errorf("policies: load from db: %w", err) |
|
0e78954…
|
lmata
|
269 |
} |
|
0e78954…
|
lmata
|
270 |
ps.mu.Lock() |
|
0e78954…
|
lmata
|
271 |
defer ps.mu.Unlock() |
|
0e78954…
|
lmata
|
272 |
ps.db = db |
|
0e78954…
|
lmata
|
273 |
if raw == nil { |
|
0e78954…
|
lmata
|
274 |
return nil // no saved policies yet; keep defaults |
|
0e78954…
|
lmata
|
275 |
} |
|
0e78954…
|
lmata
|
276 |
return ps.applyRaw(raw) |
|
0e78954…
|
lmata
|
277 |
} |
|
0e78954…
|
lmata
|
278 |
|
|
0e78954…
|
lmata
|
279 |
// applyRaw merges a JSON blob into the in-memory policy state. |
|
0e78954…
|
lmata
|
280 |
// Caller must hold ps.mu if called after initialisation. |
|
0e78954…
|
lmata
|
281 |
func (ps *PolicyStore) applyRaw(raw []byte) error { |
|
5ac549c…
|
lmata
|
282 |
var p Policies |
|
5ac549c…
|
lmata
|
283 |
if err := json.Unmarshal(raw, &p); err != nil { |
|
5ac549c…
|
lmata
|
284 |
return fmt.Errorf("policies: parse: %w", err) |
|
5ac549c…
|
lmata
|
285 |
} |
|
5ac549c…
|
lmata
|
286 |
ps.normalize(&p) |
|
5ac549c…
|
lmata
|
287 |
// Merge saved behaviors over defaults so new built-ins appear automatically. |
|
5ac549c…
|
lmata
|
288 |
saved := make(map[string]BehaviorConfig, len(p.Behaviors)) |
|
5ac549c…
|
lmata
|
289 |
for _, b := range p.Behaviors { |
|
5ac549c…
|
lmata
|
290 |
saved[b.ID] = b |
|
5ac549c…
|
lmata
|
291 |
} |
|
5ac549c…
|
lmata
|
292 |
for i, def := range ps.data.Behaviors { |
|
5ac549c…
|
lmata
|
293 |
if sv, ok := saved[def.ID]; ok { |
|
5ac549c…
|
lmata
|
294 |
ps.data.Behaviors[i] = sv |
|
5ac549c…
|
lmata
|
295 |
} |
|
5ac549c…
|
lmata
|
296 |
} |
|
5ac549c…
|
lmata
|
297 |
ps.data.AgentPolicy = p.AgentPolicy |
|
5ac549c…
|
lmata
|
298 |
ps.data.Bridge = p.Bridge |
|
5ac549c…
|
lmata
|
299 |
ps.data.Logging = p.Logging |
|
5ac549c…
|
lmata
|
300 |
ps.data.LLMBackends = p.LLMBackends |
|
6d94dfd…
|
noreply
|
301 |
ps.data.ROETemplates = p.ROETemplates |
|
6d94dfd…
|
noreply
|
302 |
ps.data.OnJoinMessages = p.OnJoinMessages |
|
5ac549c…
|
lmata
|
303 |
return nil |
|
5ac549c…
|
lmata
|
304 |
} |
|
5ac549c…
|
lmata
|
305 |
|
|
5ac549c…
|
lmata
|
306 |
func (ps *PolicyStore) save() error { |
|
5ac549c…
|
lmata
|
307 |
raw, err := json.MarshalIndent(ps.data, "", " ") |
|
5ac549c…
|
lmata
|
308 |
if err != nil { |
|
5ac549c…
|
lmata
|
309 |
return err |
|
0e78954…
|
lmata
|
310 |
} |
|
0e78954…
|
lmata
|
311 |
if ps.db != nil { |
|
0e78954…
|
lmata
|
312 |
return ps.db.PolicySet(raw) |
|
5ac549c…
|
lmata
|
313 |
} |
|
5ac549c…
|
lmata
|
314 |
return os.WriteFile(ps.path, raw, 0600) |
|
5ac549c…
|
lmata
|
315 |
} |
|
5ac549c…
|
lmata
|
316 |
|
|
5ac549c…
|
lmata
|
317 |
func (ps *PolicyStore) Get() Policies { |
|
5ac549c…
|
lmata
|
318 |
ps.mu.RLock() |
|
5ac549c…
|
lmata
|
319 |
defer ps.mu.RUnlock() |
|
5ac549c…
|
lmata
|
320 |
return ps.data |
|
5ac549c…
|
lmata
|
321 |
} |
|
5ac549c…
|
lmata
|
322 |
|
|
5ac549c…
|
lmata
|
323 |
// OnChange registers a callback invoked (in a new goroutine) after each |
|
5ac549c…
|
lmata
|
324 |
// successful Set(). The callback receives the new Policies snapshot. |
|
5ac549c…
|
lmata
|
325 |
func (ps *PolicyStore) OnChange(fn func(Policies)) { |
|
5ac549c…
|
lmata
|
326 |
ps.mu.Lock() |
|
5ac549c…
|
lmata
|
327 |
defer ps.mu.Unlock() |
|
5ac549c…
|
lmata
|
328 |
ps.onChange = fn |
|
5ac549c…
|
lmata
|
329 |
} |
|
5ac549c…
|
lmata
|
330 |
|
|
5ac549c…
|
lmata
|
331 |
func (ps *PolicyStore) Set(p Policies) error { |
|
5ac549c…
|
lmata
|
332 |
ps.mu.Lock() |
|
5ac549c…
|
lmata
|
333 |
defer ps.mu.Unlock() |
|
5ac549c…
|
lmata
|
334 |
ps.normalize(&p) |
|
5ac549c…
|
lmata
|
335 |
ps.data = p |
|
5ac549c…
|
lmata
|
336 |
if err := ps.save(); err != nil { |
|
5ac549c…
|
lmata
|
337 |
return err |
|
5ac549c…
|
lmata
|
338 |
} |
|
5ac549c…
|
lmata
|
339 |
if ps.onChange != nil { |
|
5ac549c…
|
lmata
|
340 |
snap := ps.data |
|
5ac549c…
|
lmata
|
341 |
fn := ps.onChange |
|
5ac549c…
|
lmata
|
342 |
go fn(snap) |
|
5ac549c…
|
lmata
|
343 |
} |
|
5ac549c…
|
lmata
|
344 |
return nil |
|
5ac549c…
|
lmata
|
345 |
} |
|
5ac549c…
|
lmata
|
346 |
|
|
c45e13f…
|
lmata
|
347 |
// Merge applies a partial Policies update over the current state. Only |
|
c45e13f…
|
lmata
|
348 |
// non-zero fields in the patch overwrite existing values. Behaviors are |
|
c45e13f…
|
lmata
|
349 |
// merged by ID — existing behaviors keep their defaults for fields not |
|
c45e13f…
|
lmata
|
350 |
// present in the patch. |
|
c45e13f…
|
lmata
|
351 |
func (ps *PolicyStore) Merge(patch Policies) error { |
|
c45e13f…
|
lmata
|
352 |
ps.mu.Lock() |
|
c45e13f…
|
lmata
|
353 |
defer ps.mu.Unlock() |
|
c45e13f…
|
lmata
|
354 |
|
|
c45e13f…
|
lmata
|
355 |
if len(patch.Behaviors) > 0 { |
|
c45e13f…
|
lmata
|
356 |
incoming := make(map[string]BehaviorConfig, len(patch.Behaviors)) |
|
c45e13f…
|
lmata
|
357 |
for _, b := range patch.Behaviors { |
|
c45e13f…
|
lmata
|
358 |
incoming[b.ID] = b |
|
c45e13f…
|
lmata
|
359 |
} |
|
c45e13f…
|
lmata
|
360 |
for i, existing := range ps.data.Behaviors { |
|
c45e13f…
|
lmata
|
361 |
if patched, ok := incoming[existing.ID]; ok { |
|
c45e13f…
|
lmata
|
362 |
// Merge: keep existing defaults, overlay patch fields. |
|
c45e13f…
|
lmata
|
363 |
if patched.Name != "" { |
|
c45e13f…
|
lmata
|
364 |
existing.Name = patched.Name |
|
c45e13f…
|
lmata
|
365 |
} |
|
c45e13f…
|
lmata
|
366 |
if patched.Description != "" { |
|
c45e13f…
|
lmata
|
367 |
existing.Description = patched.Description |
|
c45e13f…
|
lmata
|
368 |
} |
|
c45e13f…
|
lmata
|
369 |
if patched.Nick != "" { |
|
c45e13f…
|
lmata
|
370 |
existing.Nick = patched.Nick |
|
c45e13f…
|
lmata
|
371 |
} |
|
c45e13f…
|
lmata
|
372 |
existing.Enabled = patched.Enabled |
|
c45e13f…
|
lmata
|
373 |
existing.JoinAllChannels = patched.JoinAllChannels |
|
c45e13f…
|
lmata
|
374 |
if patched.ExcludeChannels != nil { |
|
c45e13f…
|
lmata
|
375 |
existing.ExcludeChannels = patched.ExcludeChannels |
|
c45e13f…
|
lmata
|
376 |
} |
|
c45e13f…
|
lmata
|
377 |
if patched.RequiredChannels != nil { |
|
c45e13f…
|
lmata
|
378 |
existing.RequiredChannels = patched.RequiredChannels |
|
c45e13f…
|
lmata
|
379 |
} |
|
c45e13f…
|
lmata
|
380 |
if patched.Config != nil { |
|
c45e13f…
|
lmata
|
381 |
existing.Config = patched.Config |
|
c45e13f…
|
lmata
|
382 |
} |
|
c45e13f…
|
lmata
|
383 |
ps.data.Behaviors[i] = existing |
|
c45e13f…
|
lmata
|
384 |
} |
|
c45e13f…
|
lmata
|
385 |
} |
|
c45e13f…
|
lmata
|
386 |
} |
|
c45e13f…
|
lmata
|
387 |
|
|
c45e13f…
|
lmata
|
388 |
// Merge agent_policy if any field is set. |
|
c45e13f…
|
lmata
|
389 |
if patch.AgentPolicy.CheckinChannel != "" || patch.AgentPolicy.RequireCheckin || patch.AgentPolicy.RequiredChannels != nil { |
|
c45e13f…
|
lmata
|
390 |
if patch.AgentPolicy.CheckinChannel != "" { |
|
c45e13f…
|
lmata
|
391 |
ps.data.AgentPolicy.CheckinChannel = patch.AgentPolicy.CheckinChannel |
|
c45e13f…
|
lmata
|
392 |
} |
|
c45e13f…
|
lmata
|
393 |
ps.data.AgentPolicy.RequireCheckin = patch.AgentPolicy.RequireCheckin |
|
c45e13f…
|
lmata
|
394 |
if patch.AgentPolicy.RequiredChannels != nil { |
|
c45e13f…
|
lmata
|
395 |
ps.data.AgentPolicy.RequiredChannels = patch.AgentPolicy.RequiredChannels |
|
c45e13f…
|
lmata
|
396 |
} |
|
c45e13f…
|
lmata
|
397 |
} |
|
c45e13f…
|
lmata
|
398 |
|
|
c45e13f…
|
lmata
|
399 |
// Merge bridge if set. |
|
c45e13f…
|
lmata
|
400 |
if patch.Bridge.WebUserTTLMinutes > 0 { |
|
c45e13f…
|
lmata
|
401 |
ps.data.Bridge.WebUserTTLMinutes = patch.Bridge.WebUserTTLMinutes |
|
c45e13f…
|
lmata
|
402 |
} |
|
c45e13f…
|
lmata
|
403 |
|
|
c45e13f…
|
lmata
|
404 |
// Merge logging if any field is set. |
|
c45e13f…
|
lmata
|
405 |
if patch.Logging.Dir != "" || patch.Logging.Enabled { |
|
c45e13f…
|
lmata
|
406 |
ps.data.Logging = patch.Logging |
|
c45e13f…
|
lmata
|
407 |
} |
|
c45e13f…
|
lmata
|
408 |
|
|
c45e13f…
|
lmata
|
409 |
// Merge LLM backends if provided. |
|
c45e13f…
|
lmata
|
410 |
if patch.LLMBackends != nil { |
|
c45e13f…
|
lmata
|
411 |
ps.data.LLMBackends = patch.LLMBackends |
|
6d94dfd…
|
noreply
|
412 |
} |
|
6d94dfd…
|
noreply
|
413 |
|
|
6d94dfd…
|
noreply
|
414 |
// Merge ROE templates if provided. |
|
6d94dfd…
|
noreply
|
415 |
if patch.ROETemplates != nil { |
|
6d94dfd…
|
noreply
|
416 |
ps.data.ROETemplates = patch.ROETemplates |
|
6d94dfd…
|
noreply
|
417 |
} |
|
6d94dfd…
|
noreply
|
418 |
|
|
6d94dfd…
|
noreply
|
419 |
// Merge on-join messages if provided. |
|
6d94dfd…
|
noreply
|
420 |
if patch.OnJoinMessages != nil { |
|
6d94dfd…
|
noreply
|
421 |
ps.data.OnJoinMessages = patch.OnJoinMessages |
|
c45e13f…
|
lmata
|
422 |
} |
|
c45e13f…
|
lmata
|
423 |
|
|
c45e13f…
|
lmata
|
424 |
ps.normalize(&ps.data) |
|
c45e13f…
|
lmata
|
425 |
if err := ps.save(); err != nil { |
|
c45e13f…
|
lmata
|
426 |
return err |
|
c45e13f…
|
lmata
|
427 |
} |
|
c45e13f…
|
lmata
|
428 |
if ps.onChange != nil { |
|
c45e13f…
|
lmata
|
429 |
snap := ps.data |
|
c45e13f…
|
lmata
|
430 |
fn := ps.onChange |
|
c45e13f…
|
lmata
|
431 |
go fn(snap) |
|
c45e13f…
|
lmata
|
432 |
} |
|
c45e13f…
|
lmata
|
433 |
return nil |
|
c45e13f…
|
lmata
|
434 |
} |
|
c45e13f…
|
lmata
|
435 |
|
|
5ac549c…
|
lmata
|
436 |
func (ps *PolicyStore) normalize(p *Policies) { |
|
5ac549c…
|
lmata
|
437 |
if p.Bridge.WebUserTTLMinutes <= 0 { |
|
5ac549c…
|
lmata
|
438 |
p.Bridge.WebUserTTLMinutes = ps.defaultBridgeTTLMinutes |
|
5ac549c…
|
lmata
|
439 |
} |
|
5ac549c…
|
lmata
|
440 |
} |
|
5ac549c…
|
lmata
|
441 |
|
|
5ac549c…
|
lmata
|
442 |
// --- HTTP handlers --- |
|
5ac549c…
|
lmata
|
443 |
|
|
5ac549c…
|
lmata
|
444 |
func (s *Server) handleGetPolicies(w http.ResponseWriter, r *http.Request) { |
|
5ac549c…
|
lmata
|
445 |
writeJSON(w, http.StatusOK, s.policies.Get()) |
|
5ac549c…
|
lmata
|
446 |
} |
|
5ac549c…
|
lmata
|
447 |
|
|
5ac549c…
|
lmata
|
448 |
func (s *Server) handlePutPolicies(w http.ResponseWriter, r *http.Request) { |
|
5ac549c…
|
lmata
|
449 |
var p Policies |
|
5ac549c…
|
lmata
|
450 |
if err := json.NewDecoder(r.Body).Decode(&p); err != nil { |
|
5ac549c…
|
lmata
|
451 |
writeError(w, http.StatusBadRequest, "invalid request body") |
|
5ac549c…
|
lmata
|
452 |
return |
|
5ac549c…
|
lmata
|
453 |
} |
|
5ac549c…
|
lmata
|
454 |
if err := s.policies.Set(p); err != nil { |
|
5ac549c…
|
lmata
|
455 |
s.log.Error("save policies", "err", err) |
|
c45e13f…
|
lmata
|
456 |
writeError(w, http.StatusInternalServerError, "save failed") |
|
c45e13f…
|
lmata
|
457 |
return |
|
c45e13f…
|
lmata
|
458 |
} |
|
c45e13f…
|
lmata
|
459 |
writeJSON(w, http.StatusOK, s.policies.Get()) |
|
c45e13f…
|
lmata
|
460 |
} |
|
c45e13f…
|
lmata
|
461 |
|
|
c45e13f…
|
lmata
|
462 |
func (s *Server) handlePatchPolicies(w http.ResponseWriter, r *http.Request) { |
|
c45e13f…
|
lmata
|
463 |
var patch Policies |
|
c45e13f…
|
lmata
|
464 |
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { |
|
c45e13f…
|
lmata
|
465 |
writeError(w, http.StatusBadRequest, "invalid request body") |
|
c45e13f…
|
lmata
|
466 |
return |
|
c45e13f…
|
lmata
|
467 |
} |
|
c45e13f…
|
lmata
|
468 |
if err := s.policies.Merge(patch); err != nil { |
|
c45e13f…
|
lmata
|
469 |
s.log.Error("merge policies", "err", err) |
|
5ac549c…
|
lmata
|
470 |
writeError(w, http.StatusInternalServerError, "save failed") |
|
5ac549c…
|
lmata
|
471 |
return |
|
5ac549c…
|
lmata
|
472 |
} |
|
5ac549c…
|
lmata
|
473 |
writeJSON(w, http.StatusOK, s.policies.Get()) |
|
5ac549c…
|
lmata
|
474 |
} |