Skip to main content

beam_core/
config.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Map, Value};
5
6use crate::backend_kind::BackendKind;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9pub struct DaemonConfig {
10    #[serde(default)]
11    pub quiet_restart: bool,
12    #[serde(default = "default_working_dirs")]
13    pub working_dirs: Vec<String>,
14    /// Terminal backend for new sessions. Existing deployments default to
15    /// `zellij`; upgrades must not silently change the mux. Per-bot override
16    /// lives on [`BotConfig::backend`].
17    #[serde(default)]
18    pub backend: BackendKind,
19}
20
21fn default_working_dirs() -> Vec<String> {
22    vec!["~".to_string()]
23}
24
25impl Default for DaemonConfig {
26    fn default() -> Self {
27        Self {
28            quiet_restart: false,
29            working_dirs: default_working_dirs(),
30            backend: BackendKind::Zellij,
31        }
32    }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub struct WebConfig {
37    #[serde(default = "default_web_host")]
38    pub host: String,
39    #[serde(default = "default_proxy_base_port")]
40    pub proxy_base_port: u16,
41    /// Whether the daemon starts the local zellij web server on boot.
42    /// v1 defaults to `true` (existing behavior). Set to `false` to allow a
43    /// herdr-only deployment to start without zellij web; only tested
44    /// after PR5 lands.
45    #[serde(default = "default_zellij_web")]
46    pub zellij_web: bool,
47    /// Emergency kill switch for the Herdr browser terminal. Defaults to
48    /// `true`; set to `false` to restore the pre-web behavior (Herdr sessions
49    /// get a 404 on `/s/{session_id}` and the card shows the attach hint).
50    #[serde(default = "default_herdr_terminal")]
51    pub herdr_terminal: bool,
52    /// Max concurrent `herdr terminal session observe` children per session.
53    #[serde(default = "default_herdr_max_observers_per_session")]
54    pub herdr_terminal_max_observers_per_session: usize,
55    /// Max concurrent `herdr terminal session observe` children daemon-wide.
56    #[serde(default = "default_herdr_max_observers_global")]
57    pub herdr_terminal_max_observers_global: usize,
58}
59
60fn default_web_host() -> String {
61    "0.0.0.0".to_string()
62}
63
64fn default_proxy_base_port() -> u16 {
65    8800
66}
67
68fn default_zellij_web() -> bool {
69    true
70}
71
72fn default_herdr_terminal() -> bool {
73    true
74}
75
76fn default_herdr_max_observers_per_session() -> usize {
77    8
78}
79
80fn default_herdr_max_observers_global() -> usize {
81    64
82}
83
84impl Default for WebConfig {
85    fn default() -> Self {
86        Self {
87            host: default_web_host(),
88            proxy_base_port: default_proxy_base_port(),
89            zellij_web: default_zellij_web(),
90            herdr_terminal: default_herdr_terminal(),
91            herdr_terminal_max_observers_per_session: default_herdr_max_observers_per_session(),
92            herdr_terminal_max_observers_global: default_herdr_max_observers_global(),
93        }
94    }
95}
96
97/// Herdr-specific settings. Only read when a session actually runs on the
98/// Herdr backend; a Zellij-default deployment never probes herdr.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100pub struct HerdrConfig {
101    #[serde(default = "default_herdr_min_version")]
102    pub min_version: String,
103    /// Named Herdr session escape hatch. v1 default is the shared `default`
104    /// session; a named session hides agents from the default sidebar.
105    #[serde(default = "default_herdr_session")]
106    pub session: String,
107    #[serde(default)]
108    pub socket_path: Option<String>,
109}
110
111fn default_herdr_min_version() -> String {
112    "0.8.2".to_string()
113}
114
115fn default_herdr_session() -> String {
116    "default".to_string()
117}
118
119impl Default for HerdrConfig {
120    fn default() -> Self {
121        Self {
122            min_version: default_herdr_min_version(),
123            session: default_herdr_session(),
124            socket_path: None,
125        }
126    }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
130pub struct Config {
131    #[serde(default)]
132    pub daemon: DaemonConfig,
133    #[serde(default)]
134    pub web: WebConfig,
135    #[serde(default)]
136    pub herdr: HerdrConfig,
137    #[serde(default)]
138    pub lark: LarkConfig,
139    #[serde(default, rename = "screenAnalyzer")]
140    pub screen_analyzer: ScreenAnalyzerConfig,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
144pub struct BotConfig {
145    #[serde(default)]
146    pub name: Option<String>,
147    #[serde(rename = "larkAppId")]
148    pub lark_app_id: String,
149    #[serde(rename = "larkAppSecret")]
150    pub lark_app_secret: String,
151    #[serde(rename = "cliId")]
152    pub cli_id: String,
153    #[serde(rename = "cliBin", default)]
154    pub cli_bin: Option<String>,
155    #[serde(rename = "cliArgs", default)]
156    pub cli_args: Vec<String>,
157    /// Per-bot backend override (bots.json, camelCase to match the JSON
158    /// convention). `None` follows the daemon default.
159    #[serde(default)]
160    pub backend: Option<BackendKind>,
161    /// Linux-only user systemd slice for the CLI process. Empty/omitted is unset.
162    #[serde(
163        rename = "cgroupSlice",
164        default,
165        skip_serializing_if = "Option::is_none"
166    )]
167    pub cgroup_slice: Option<String>,
168    #[serde(default)]
169    pub model: Option<String>,
170    #[serde(rename = "workingDir", default)]
171    pub working_dir: Option<String>,
172    #[serde(rename = "skipWorkingDirPrompt", default)]
173    pub skip_working_dir_prompt: bool,
174    #[serde(rename = "larkEncryptKey", default)]
175    pub lark_encrypt_key: Option<String>,
176    #[serde(rename = "larkVerificationToken", default)]
177    pub lark_verification_token: Option<String>,
178    #[serde(rename = "allowedUsers", default)]
179    pub allowed_users: Vec<String>,
180    #[serde(rename = "privateCard", default)]
181    pub private_card: bool,
182    #[serde(rename = "allowedChatGroups", default)]
183    pub allowed_chat_groups: Vec<String>,
184    #[serde(rename = "chatGrants", default)]
185    pub chat_grants: std::collections::HashMap<String, Vec<String>>,
186    #[serde(rename = "globalGrants", default)]
187    pub global_grants: Vec<String>,
188    #[serde(rename = "oncallChats", default)]
189    pub oncall_chats: Vec<OncallChatBinding>,
190    #[serde(rename = "restrictGrantCommands", default)]
191    pub restrict_grant_commands: bool,
192    #[serde(rename = "messageQuota", default)]
193    pub message_quota: Option<MessageQuotaConfig>,
194    #[serde(rename = "quotaState", default)]
195    pub quota_state: std::collections::HashMap<String, QuotaEntry>,
196    /// Group-chat keywords that activate the bot without an @mention.
197    /// A matching trigger can also supply the initial prompt for the
198    /// session it creates.
199    #[serde(rename = "customTriggers", default)]
200    pub custom_triggers: Vec<CustomTrigger>,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
204pub struct OncallChatBinding {
205    #[serde(rename = "chatId")]
206    pub chat_id: String,
207    #[serde(rename = "workingDir", default)]
208    pub working_dir: Option<String>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212pub struct CustomTrigger {
213    /// Keyword that activates the bot. Matched at the start of a group
214    /// message when it is followed by a word boundary (whitespace,
215    /// punctuation, or end of text), so a short keyword does not match
216    /// inside longer words.
217    #[serde(rename = "trigger")]
218    pub trigger: String,
219    /// Initial prompt used when this trigger creates a new session.
220    /// Trailing user text after the keyword is appended after the prompt.
221    #[serde(rename = "prompt", default)]
222    pub prompt: Option<String>,
223    /// When true, a session created by this trigger skips the directory
224    /// selection card and uses `workingDir` (or the bot's default).
225    #[serde(rename = "skipDirSelect", default)]
226    pub skip_dir_select: bool,
227    /// Working directory used when this trigger creates a session directly.
228    /// Takes precedence over the bot's `workingDir`.
229    #[serde(rename = "workingDir", default)]
230    pub working_dir: Option<String>,
231    /// Message replied immediately (as a reply to the triggering message)
232    /// when this trigger creates a session, so users know the task was
233    /// accepted before the longer-running work produces output.
234    #[serde(rename = "ackMessage", default)]
235    pub ack_message: Option<String>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
239pub struct MessageQuotaConfig {
240    #[serde(rename = "defaultLimit", default)]
241    pub default_limit: Option<u32>,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
245pub struct QuotaEntry {
246    pub limit: u32,
247    pub used: u32,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
251pub struct LarkConfig {
252    #[serde(default)]
253    pub verification_token: Option<String>,
254    #[serde(default)]
255    pub encrypt_key: Option<String>,
256}
257
258#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
259pub struct ScreenAnalyzerConfig {
260    #[serde(default)]
261    pub enabled: bool,
262    #[serde(default)]
263    pub base_url: String,
264    #[serde(default)]
265    pub api_key: String,
266    #[serde(default)]
267    pub model: String,
268    #[serde(default = "default_screen_analyzer_interval_ms")]
269    pub interval_ms: u64,
270    #[serde(default = "default_screen_analyzer_stable_count")]
271    pub stable_count: u32,
272    #[serde(default = "default_screen_analyzer_snapshot_max_chars")]
273    pub snapshot_max_chars: usize,
274    #[serde(default)]
275    pub extra_headers: HashMap<String, String>,
276    #[serde(default)]
277    pub extra_body: Map<String, Value>,
278}
279
280fn default_screen_analyzer_interval_ms() -> u64 {
281    2_000
282}
283
284fn default_screen_analyzer_stable_count() -> u32 {
285    6
286}
287
288fn default_screen_analyzer_snapshot_max_chars() -> usize {
289    8_000
290}
291
292impl Default for ScreenAnalyzerConfig {
293    fn default() -> Self {
294        Self {
295            enabled: false,
296            base_url: String::new(),
297            api_key: String::new(),
298            model: String::new(),
299            interval_ms: default_screen_analyzer_interval_ms(),
300            stable_count: default_screen_analyzer_stable_count(),
301            snapshot_max_chars: default_screen_analyzer_snapshot_max_chars(),
302            extra_headers: HashMap::new(),
303            extra_body: Map::new(),
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::BotConfig;
311
312    #[test]
313    fn bot_config_defaults_missing_cli_args_and_skip_prompt_fields() {
314        let raw = r#"{
315            "larkAppId":"app-1",
316            "larkAppSecret":"secret",
317            "cliId":"codex"
318        }"#;
319        let bot: BotConfig = serde_json::from_str(raw).expect("deserialize bot");
320        assert!(bot.cli_args.is_empty());
321        assert!(bot.cgroup_slice.is_none());
322        assert!(!bot.skip_working_dir_prompt);
323    }
324
325    #[test]
326    fn bot_config_deserializes_cgroup_slice() {
327        let raw = r#"{
328            "larkAppId":"app-1",
329            "larkAppSecret":"secret",
330            "cliId":"grok",
331            "cgroupSlice":"cgtproxy-gateway.slice"
332        }"#;
333        let bot: BotConfig = serde_json::from_str(raw).expect("deserialize bot");
334        assert_eq!(bot.cgroup_slice.as_deref(), Some("cgtproxy-gateway.slice"));
335    }
336
337    #[test]
338    fn bot_config_ignores_legacy_cli_prefix_field() {
339        let raw = r#"{
340            "larkAppId":"app-1",
341            "larkAppSecret":"secret",
342            "cliId":"grok",
343            "cliPrefix":["systemd-run","--user"]
344        }"#;
345        let bot: BotConfig = serde_json::from_str(raw).expect("deserialize bot");
346        assert!(bot.cgroup_slice.is_none());
347    }
348
349    #[test]
350    fn bot_config_deserializes_traex_cli_args() {
351        let raw = r#"{
352            "larkAppId":"app-1",
353            "larkAppSecret":"secret",
354            "cliId":"traex",
355            "cliArgs":["-y"]
356        }"#;
357        let bot: BotConfig = serde_json::from_str(raw).expect("deserialize bot");
358        assert_eq!(bot.cli_args, vec!["-y".to_string()]);
359    }
360}