Skip to main content

bridge/
common_config.rs

1//! CommonConfig — single source of truth for agent identity and transport settings.
2//!
3//! Stored as `common.toml` in the bridge config directory.
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::sync::OnceLock;
11
12/// Global custom config directory for CommonConfig (set via --config-dir).
13static COMMON_CUSTOM_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
14
15/// Set a custom config directory (call before any config operations).
16pub fn set_config_dir(path: PathBuf) {
17    COMMON_CUSTOM_CONFIG_DIR.set(path).ok();
18}
19
20/// A slash command advertised to connected clients via `available_commands_update`.
21///
22/// Define these in `common.toml` for agents that don't send `available_commands_update`
23/// themselves (e.g. Copilot CLI, Goose).
24///
25/// Example `common.toml` entry:
26/// ```toml
27/// [[slash_commands]]
28/// name        = "fix"
29/// description = "Fix the selected code"
30///
31/// [[slash_commands]]
32/// name        = "explain"
33/// description = "Explain the code, optionally with a focus"
34/// input_hint  = "what to focus on"
35/// ```
36#[derive(Debug, Serialize, Deserialize, Clone)]
37pub struct SlashCommandConfig {
38    /// Command name without the leading `/` (e.g. `"fix"`).
39    pub name: String,
40    /// Human-readable description shown in the picker.
41    pub description: String,
42    /// If set, the command accepts free-text input; this string is shown as
43    /// the placeholder hint in the text field.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub input_hint: Option<String>,
46}
47
48/// Push relay configuration for sending background notifications.
49///
50/// All four fields are required — push is silently disabled if the section is
51/// absent or any field is empty.
52#[derive(Debug, Serialize, Deserialize, Clone, Default)]
53pub struct PushRelayConfig {
54    /// Base URL of the push relay service (e.g. "https://push.aptove.com").
55    #[serde(default, skip_serializing_if = "String::is_empty")]
56    pub url: String,
57    /// Base URL of the JWT token service (e.g. "https://token.aptove.com").
58    #[serde(default, skip_serializing_if = "String::is_empty")]
59    pub token_url: String,
60    /// OAuth2 client_id issued by the token service.
61    #[serde(default, skip_serializing_if = "String::is_empty")]
62    pub client_id: String,
63    /// OAuth2 client_secret issued by the token service.
64    #[serde(default, skip_serializing_if = "String::is_empty")]
65    pub client_secret: String,
66}
67
68/// Stable agent identity and multi-transport settings.
69///
70/// Replaces the old `BridgeConfig` / `bridge.toml`. Stored as `common.toml`.
71#[derive(Debug, Serialize, Deserialize, Clone)]
72pub struct CommonConfig {
73    /// Stable UUID that identifies this agent across all transports.
74    #[serde(default, skip_serializing_if = "String::is_empty")]
75    pub agent_id: String,
76
77    /// Bearer token required for WebSocket connections.
78    #[serde(default, skip_serializing_if = "String::is_empty")]
79    pub auth_token: String,
80
81    /// Per-transport configuration, keyed by transport name
82    /// (e.g., `"local"`, `"cloudflare"`, `"tailscale-serve"`).
83    #[serde(default)]
84    pub transports: HashMap<String, TransportConfig>,
85
86    /// Slash commands to advertise to clients via `available_commands_update`.
87    /// Used for agents that don't send this notification themselves.
88    /// The bridge injects the notification after every session/new or session/load.
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub slash_commands: Vec<SlashCommandConfig>,
91
92    /// Push relay configuration. Push is disabled when this section is absent
93    /// or any required field is empty — no hardcoded defaults.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub push_relay: Option<PushRelayConfig>,
96}
97
98/// Configuration for a single transport.
99#[derive(Debug, Serialize, Deserialize, Clone, Default)]
100pub struct TransportConfig {
101    /// Whether this transport is active.
102    #[serde(default)]
103    pub enabled: bool,
104
105    /// TCP port to bind (local transport).
106    pub port: Option<u16>,
107
108    /// Enable TLS on this transport (default: true for local).
109    pub tls: Option<bool>,
110
111    // ---- Cloudflare Zero Trust fields (transport name: "cloudflare") ----
112    pub hostname: Option<String>,
113    pub tunnel_id: Option<String>,
114    pub tunnel_secret: Option<String>,
115    pub account_id: Option<String>,
116    pub client_id: Option<String>,
117    pub client_secret: Option<String>,
118    pub domain: Option<String>,
119    pub subdomain: Option<String>,
120}
121
122impl Default for CommonConfig {
123    fn default() -> Self {
124        let mut transports = HashMap::new();
125        transports.insert(
126            "local".to_string(),
127            TransportConfig {
128                enabled: true,
129                port: Some(8765),
130                tls: Some(true),
131                ..Default::default()
132            },
133        );
134        Self {
135            agent_id: String::new(),
136            auth_token: String::new(),
137            transports,
138            slash_commands: Vec::new(),
139            push_relay: None,
140        }
141    }
142}
143
144impl CommonConfig {
145    /// Path to the `common.toml` file (default config dir).
146    pub fn config_path() -> PathBuf {
147        Self::config_dir().join("common.toml")
148    }
149
150    /// Config directory (system default or custom override).
151    pub fn config_dir() -> PathBuf {
152        let dir = if let Some(custom) = COMMON_CUSTOM_CONFIG_DIR.get() {
153            custom.clone()
154        } else {
155            std::env::current_dir()
156                .unwrap_or_else(|_| PathBuf::from("."))
157                .join(".aptove-bridge")
158        };
159        fs::create_dir_all(&dir).ok();
160        dir
161    }
162
163    /// Load from `common.toml` at the default location, or return defaults.
164    pub fn load() -> Result<Self> {
165        Self::load_from_dir(&Self::config_dir())
166    }
167
168    /// Load from `common.toml` in a specific directory, or return defaults.
169    pub fn load_from_dir(dir: &Path) -> Result<Self> {
170        let path = dir.join("common.toml");
171        if !path.exists() {
172            return Ok(Self::default());
173        }
174        let text = fs::read_to_string(&path)
175            .with_context(|| format!("Failed to read {:?}", path))?;
176        let config: Self = toml::from_str(&text)
177            .with_context(|| format!("Failed to parse {:?}", path))?;
178        Ok(config)
179    }
180
181    /// Save to `common.toml` with 0600 permissions (default config dir).
182    pub fn save(&self) -> Result<()> {
183        self.save_to_dir(&Self::config_dir())
184    }
185
186    /// Save to `common.toml` in a specific directory with 0600 permissions.
187    pub fn save_to_dir(&self, dir: &Path) -> Result<()> {
188        fs::create_dir_all(dir)?;
189        let path = dir.join("common.toml");
190        let text = toml::to_string_pretty(self).context("Failed to serialize CommonConfig")?;
191        fs::write(&path, &text).with_context(|| format!("Failed to write {:?}", path))?;
192        #[cfg(unix)]
193        {
194            use std::os::unix::fs::PermissionsExt;
195            let mut perms = fs::metadata(&path)?.permissions();
196            perms.set_mode(0o600);
197            fs::set_permissions(&path, perms)?;
198        }
199        Ok(())
200    }
201
202    /// Generate a UUID v4 `agent_id` if one is not already set.
203    pub fn ensure_agent_id(&mut self) {
204        if self.agent_id.is_empty() {
205            self.agent_id = uuid::Uuid::new_v4().to_string();
206        }
207    }
208
209    /// Generate a random URL-safe authentication token (32 random bytes, base64).
210    pub fn generate_auth_token() -> String {
211        use base64::{engine::general_purpose, Engine as _};
212        let bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
213        general_purpose::URL_SAFE_NO_PAD.encode(bytes)
214    }
215
216    /// Ensure `auth_token` is populated, generating one if needed.
217    pub fn ensure_auth_token(&mut self) {
218        if self.auth_token.is_empty() {
219            self.auth_token = Self::generate_auth_token();
220        }
221    }
222
223    /// Returns all enabled transports, sorted by name for deterministic ordering.
224    pub fn enabled_transports(&self) -> Vec<(&str, &TransportConfig)> {
225        let mut result: Vec<_> = self
226            .transports
227            .iter()
228            .filter(|(_, t)| t.enabled)
229            .map(|(k, v)| (k.as_str(), v))
230            .collect();
231        result.sort_by_key(|(k, _)| *k);
232        result
233    }
234
235    /// Build a static connection JSON payload for a QR code.
236    ///
237    /// Includes `agentId`, `url`, `protocol`, `version`, `authToken`, and
238    /// Cloudflare credentials if present in the transport config.
239    pub fn to_connection_json(&self, hostname: &str, transport_name: &str, cwd: &str) -> Result<String> {
240        use serde_json::{Map, Value};
241        let transport = self.transports.get(transport_name);
242        let mut map = Map::new();
243        if !self.agent_id.is_empty() {
244            map.insert("agentId".to_string(), Value::String(self.agent_id.clone()));
245        }
246        map.insert("url".to_string(), Value::String(hostname.to_string()));
247        map.insert("protocol".to_string(), Value::String("acp".to_string()));
248        map.insert("version".to_string(), Value::String("1.0".to_string()));
249        map.insert("cwd".to_string(), Value::String(cwd.to_string()));
250        if !self.auth_token.is_empty() {
251            map.insert(
252                "authToken".to_string(),
253                Value::String(self.auth_token.clone()),
254            );
255        }
256        if let Some(t) = transport {
257            if let Some(ref id) = t.client_id {
258                if !id.is_empty() {
259                    map.insert("clientId".to_string(), Value::String(id.clone()));
260                }
261            }
262            if let Some(ref secret) = t.client_secret {
263                if !secret.is_empty() {
264                    map.insert("clientSecret".to_string(), Value::String(secret.clone()));
265                }
266            }
267        }
268        serde_json::to_string(&Value::Object(map)).context("Failed to serialize connection info")
269    }
270}