abyss_agent_hook/config/
mod.rs1mod content;
4mod harness;
5
6use std::sync::Arc;
7
8use arc_swap::ArcSwap;
9use serde::{Deserialize, Serialize};
10
11pub use content::HarnessUsageContentConfig;
12pub use harness::{HarnessConfig, HarnessMatcherConfig, HarnessUsageConfig};
13
14#[derive(Debug, Clone, Default, Deserialize, Serialize)]
16#[serde(deny_unknown_fields)]
17pub struct HooksConfig {
18 #[serde(default, alias = "agent_usage")]
20 pub harness_usage: HookConfig<HarnessUsageConfig>,
21}
22
23#[derive(Debug, Clone, Deserialize, Serialize)]
25#[serde(deny_unknown_fields)]
26pub struct HookConfig<T> {
27 #[serde(default = "enabled_by_default")]
29 pub enabled: bool,
30 #[serde(default)]
32 pub config: T,
33}
34
35#[derive(Debug, Clone)]
37pub struct HooksRuntimeConfig {
38 inner: Arc<ArcSwap<HooksConfig>>,
39}
40
41#[derive(Debug, Clone)]
43#[non_exhaustive]
44pub struct DeviceIdentity {
45 pub hostname: Option<String>,
47 pub platform: Option<String>,
49 pub os_version: Option<String>,
51}
52
53#[derive(Debug, Clone)]
55#[non_exhaustive]
56pub struct HarnessUsageHookConfig {
57 pub device: DeviceIdentity,
59}
60
61impl<T> Default for HookConfig<T>
62where
63 T: Default,
64{
65 fn default() -> Self {
66 Self {
67 enabled: true,
68 config: T::default(),
69 }
70 }
71}
72
73impl HooksRuntimeConfig {
74 #[must_use]
76 pub fn new(config: HooksConfig) -> Self {
77 Self {
78 inner: Arc::new(ArcSwap::from_pointee(config)),
79 }
80 }
81
82 #[must_use]
84 pub fn default_enabled() -> Self {
85 Self::new(HooksConfig::default())
86 }
87
88 #[must_use]
90 pub fn snapshot(&self) -> Arc<HooksConfig> {
91 self.inner.load_full()
92 }
93
94 #[must_use]
96 pub fn update(&self, config: HooksConfig) -> HooksConfig {
97 self.inner.store(Arc::new(config.clone()));
98 config
99 }
100}
101
102impl DeviceIdentity {
103 #[must_use]
105 pub const fn new() -> Self {
106 Self {
107 hostname: None,
108 platform: None,
109 os_version: None,
110 }
111 }
112}
113
114impl Default for DeviceIdentity {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl HarnessUsageHookConfig {
121 #[must_use]
123 pub const fn new(device: DeviceIdentity) -> Self {
124 Self { device }
125 }
126
127 #[must_use]
129 pub fn from_platform() -> Self {
130 Self::new(platform_device_identity())
131 }
132}
133
134#[must_use]
136pub fn platform_device_identity() -> DeviceIdentity {
137 let mut device = DeviceIdentity::new();
138 device.hostname = system_hostname();
139 device.platform = Some(std::env::consts::OS.to_owned());
140 device
141}
142
143const fn enabled_by_default() -> bool {
144 true
145}
146
147fn normalize_hostname(value: &str) -> Option<String> {
148 let value = value.trim().trim_end_matches('.').to_owned();
149 if value.is_empty() { None } else { Some(value) }
150}
151
152#[cfg(unix)]
153fn system_hostname() -> Option<String> {
154 nix::unistd::gethostname()
155 .ok()
156 .and_then(|value| value.into_string().ok())
157 .and_then(|value| normalize_hostname(&value))
158}
159
160#[cfg(not(unix))]
161fn system_hostname() -> Option<String> {
162 std::env::var_os("COMPUTERNAME")
163 .map(|value| value.to_string_lossy().into_owned())
164 .and_then(|value| normalize_hostname(&value))
165}
166
167#[cfg(test)]
168mod tests {
169 use crate::harness::{BuiltInHarness, HarnessId};
170
171 use super::{
172 DeviceIdentity, HarnessConfig, HarnessMatcherConfig, HarnessUsageConfig,
173 HarnessUsageContentConfig, HarnessUsageHookConfig, HookConfig, HooksConfig,
174 HooksRuntimeConfig,
175 };
176
177 #[test]
178 fn hook_config_contains_only_producer_identity() {
179 let mut device = DeviceIdentity::new();
180 device.hostname = Some("test-host".to_owned());
181 let config = HarnessUsageHookConfig::new(device);
182
183 assert_eq!(config.device.hostname.as_deref(), Some("test-host"));
184 }
185
186 #[test]
187 fn hooks_config_defaults_to_enabled_plaintext_harness_usage() {
188 let config = serde_json::from_str::<HooksConfig>("{}")
189 .expect("empty hooks config should use defaults");
190
191 assert!(config.harness_usage.enabled);
192 let content = config
193 .harness_usage
194 .config
195 .content_for_harness(BuiltInHarness::Codex.id());
196 assert!(content.token_usage);
197 assert!(content.conversation_text);
198 assert!(content.tool_calls);
199 assert!(content.images);
200
201 let value = serde_json::to_value(config).expect("default hooks config should serialize");
202 assert_eq!(
203 value["harness_usage"]["config"]["content"],
204 serde_json::json!({
205 "token_usage": true,
206 "conversation_text": true,
207 "tool_calls": true,
208 "images": true,
209 })
210 );
211 }
212
213 #[test]
214 fn harness_usage_config_supports_custom_harnesses() {
215 let config = serde_json::from_str::<HooksConfig>(
216 r#"{
217 "harness_usage": {
218 "config": {
219 "harnesses": {
220 "acme-agent": {
221 "enabled": true,
222 "matchers": [{
223 "process_names": ["acme-agent"],
224 "application_ids": ["com.acme.agent"]
225 }]
226 }
227 }
228 }
229 }
230 }"#,
231 )
232 .expect("custom Harness configuration should parse");
233
234 let harness = config
235 .harness_usage
236 .config
237 .harnesses
238 .get("acme-agent")
239 .expect("custom Harness should be retained");
240 assert_eq!(harness.enabled, Some(true));
241 assert_eq!(harness.matchers.len(), 1);
242 }
243
244 #[test]
245 fn harness_usage_config_supports_per_harness_overrides() {
246 let mut config = HarnessUsageConfig {
247 content: HarnessUsageContentConfig {
248 token_usage: true,
249 conversation_text: false,
250 tool_calls: false,
251 images: false,
252 },
253 harnesses: std::collections::BTreeMap::new(),
254 };
255 config.harnesses.insert(
256 HarnessId::from(BuiltInHarness::Codex),
257 HarnessConfig {
258 enabled: Some(false),
259 content: Some(HarnessUsageContentConfig {
260 token_usage: false,
261 conversation_text: true,
262 tool_calls: true,
263 images: true,
264 }),
265 matchers: Vec::new(),
266 },
267 );
268
269 assert!(!config.enabled_for_harness(BuiltInHarness::Codex.id()));
270 assert!(
271 !config
272 .content_for_harness(BuiltInHarness::Codex.id())
273 .token_usage
274 );
275 let claude = config.content_for_harness(BuiltInHarness::ClaudeCode.id());
276 assert!(claude.token_usage);
277 assert!(!claude.conversation_text);
278 }
279
280 #[test]
281 fn legacy_agent_keys_migrate_during_deserialization() {
282 let config = serde_json::from_str::<HooksConfig>(
283 r#"{"agent_usage":{"config":{"agents":{"claude-cli":{"enabled":false}}}}}"#,
284 )
285 .expect("legacy Harness configuration should migrate");
286
287 assert!(
288 !config
289 .harness_usage
290 .config
291 .enabled_for_harness(BuiltInHarness::ClaudeCode.id())
292 );
293 let value = serde_json::to_value(config).expect("migrated config should serialize");
294 assert!(value.get("agent_usage").is_none());
295 assert!(
296 value["harness_usage"]["config"]["harnesses"]
297 .as_object()
298 .expect("Harness map should serialize as an object")
299 .contains_key("claude-code")
300 );
301 }
302
303 #[test]
304 fn content_defaults_omitted_independent_controls() {
305 let content =
306 serde_json::from_str::<HarnessUsageContentConfig>(r#"{"conversation_text":false}"#)
307 .expect("partial independent content policy should parse");
308
309 assert!(content.token_usage);
310 assert!(!content.conversation_text);
311 assert!(content.tool_calls);
312 assert!(content.images);
313 }
314
315 #[test]
316 fn hooks_config_rejects_unknown_fields() {
317 let unknown_hook =
318 serde_json::from_str::<HooksConfig>(r#"{"future_hook":{"enabled":true}}"#);
319 assert!(unknown_hook.is_err());
320
321 let unknown_content = serde_json::from_str::<HooksConfig>(
322 r#"{"harness_usage":{"config":{"content":{"future_content":true}}}}"#,
323 );
324 assert!(unknown_content.is_err());
325
326 let unknown_matcher = serde_json::from_str::<HooksConfig>(
327 r#"{"harness_usage":{"config":{"harnesses":{"acme":{"matchers":[{"future_matcher":["value"]}]}}}}}"#,
328 );
329 assert!(unknown_matcher.is_err());
330 }
331
332 #[test]
333 fn hooks_runtime_config_updates_snapshots_without_mutating_old_handles() {
334 let runtime = HooksRuntimeConfig::default_enabled();
335 let before = runtime.snapshot();
336
337 let updated = runtime.update(HooksConfig {
338 harness_usage: HookConfig {
339 enabled: false,
340 config: HarnessUsageConfig::default(),
341 },
342 });
343
344 assert!(!updated.harness_usage.enabled);
345 assert!(before.harness_usage.enabled);
346 assert!(!runtime.snapshot().harness_usage.enabled);
347 }
348
349 #[test]
350 fn matcher_config_defaults_to_empty_selectors() {
351 let matcher = serde_json::from_str::<HarnessMatcherConfig>("{}")
352 .expect("empty matcher DTO should deserialize");
353 assert!(matcher.process_names.is_empty());
354 assert!(matcher.application_ids.is_empty());
355 }
356
357 #[test]
358 fn custom_harness_requires_a_non_empty_matcher() {
359 for input in [
360 r#"{"harness_usage":{"config":{"harnesses":{"acme":{}}}}}"#,
361 r#"{"harness_usage":{"config":{"harnesses":{"acme":{"matchers":[{}]}}}}}"#,
362 ] {
363 assert!(serde_json::from_str::<HooksConfig>(input).is_err());
364 }
365 }
366
367 #[test]
368 fn harness_id_uses_the_documented_wire_format() {
369 for harness_id in ["", "Uppercase", "contains space"] {
370 let input = format!(
371 r#"{{"harness_usage":{{"config":{{"harnesses":{{"{harness_id}":{{"matchers":[{{"process_names":["acme"]}}]}}}}}}}}}}"#
372 );
373 assert!(serde_json::from_str::<HooksConfig>(&input).is_err());
374 }
375 }
376
377 #[test]
378 fn built_in_harness_cannot_be_redefined_with_custom_matchers() {
379 let input = r#"{
380 "harness_usage": {
381 "config": {
382 "harnesses": {
383 "codex": {"matchers": [{"process_names": ["other"]}]}
384 }
385 }
386 }
387 }"#;
388
389 assert!(serde_json::from_str::<HooksConfig>(input).is_err());
390 }
391}