- 2255
fn update_config_file<T>( - 2256
path: &Path, - 2257
edit: impl FnOnce(&mut toml::Table) -> Result<T, ConfigError>, - 2258
) -> Result<T, ConfigError> { - 2259
let _guard = lock_config_files(); - 2260
let before = match std::fs::read_to_string(path) { - 2261
Ok(text) => toml::from_str::<toml::Table>(&text).map_err(|source| ConfigError::Parse { - 2262
path: path.to_path_buf(), - 2263
source, - 2264
})?, - 2265
Err(error) if error.kind() == std::io::ErrorKind::NotFound => toml::Table::new(), - 2266
Err(source) => { - 2267
return Err(ConfigError::Read { - 2268
path: path.to_path_buf(), - 2269
source, - 2270
}); - 2271
} - 2272
}; - 2273
let mut document = before.clone(); - 2274
let outcome = edit(&mut document)?; - 2275
if document != before { - 2276
let text = toml::to_string_pretty(&document).map_err(|error| ConfigError::Write { - 2277
path: path.to_path_buf(), - 2278
source: std::io::Error::other(error.to_string()), - 2279
})?; - 2280
replace_file(path, &text)?; - 2281
} - 2282
Ok(outcome) - 2283
} - 2284
- 2285
/// Write `contents` to a new sibling of `path` and rename it over `path`, so - 2286
/// a reader sees the whole old document or the whole new one. The temporary - 2287
/// name carries the process id and a per-process sequence number, and is - 2288
/// created exclusively, so no two writes ever share one. - 2289
fn replace_file(path: &Path, contents: &str) -> Result<(), ConfigError> { - 2290
static SEQUENCE: AtomicU64 = AtomicU64::new(0); - 2291
let write_error = |source| ConfigError::Write { - 2292
path: path.to_path_buf(), - 2293
source, - 2294
}; - 2295
let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else { - 2296
return Err(write_error(std::io::Error::other( - 2297
"config path has no parent directory", - 2298
))); - 2299
}; - 2300
std::fs::create_dir_all(parent).map_err(|source| ConfigError::Write { - 2301
path: parent.to_path_buf(), - 2302
source, - 2303
})?; - 2304
let mut attempts = 0; - 2305
let (temp, mut file) = loop { - 2306
let temp = parent.join(format!( - 2307
".{}.{}.{}.tmp", - 2308
name.to_string_lossy(), - 2309
std::process::id(), - 2310
SEQUENCE.fetch_add(1, Ordering::Relaxed) - 2311
)); - 2312
match std::fs::OpenOptions::new() - 2313
.write(true) - 2314
.create_new(true) - 2315
.open(&temp) - 2316
{ - 2317
Ok(file) => break (temp, file), - 2318
// Only another process that had this pid can hold a fresh name; - 2319
// step past its file rather than write into it. - 2320
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && attempts < 8 => { - 2321
attempts += 1; - 2322
} - 2323
Err(source) => return Err(write_error(source)), - 2324
} - 2325
}; - 2326
let written = std::io::Write::write_all(&mut file, contents.as_bytes()); - 2327
drop(file); - 2328
written - 2329
.and_then(|()| std::fs::rename(&temp, path)) - 2330
.map_err(|source| { - 2331
let _ = std::fs::remove_file(&temp); - 2332
write_error(source) - 2333
}) - 2334
} - 2335
- 2336
/// The table under `key`, created empty when absent. A key that holds some - 2337
/// other kind of value is refused rather than overwritten. - 2338
fn child_table<'a>( - 2339
parent: &'a mut toml::Table, - 2340
key: &str, - 2341
path: &Path, - 2342
) -> Result<&'a mut toml::Table, ConfigError> { - 2343
parent - 2344
.entry(key) - 2345
.or_insert_with(|| toml::Value::Table(toml::Table::new())) - 2346
.as_table_mut() - 2347
.ok_or_else(|| ConfigError::Write { - 2348
path: path.to_path_buf(), - 2349
source: std::io::Error::other(format!("`{key}` config must be a TOML table")), - 2350
}) - 2351
} - 2352
- 2353
/// Persist the three permission rule lists (`allow` / `ask` / `deny`) as - 2354
/// the engine reads them, without disturbing unrelated config keys. - 2355
/// - 2356
/// Each list is `Option`: absent leaves that list alone, `Some(vec)` - 2357
/// replaces it wholesale (an empty vec clears it). - 2358
/// - 2359
/// Rule SYNTAX is not validated here on purpose: the grammar lives in - 2360
/// `vak_permission::Rule::parse`, which this crate sits below and must not - 2361
/// depend on. Callers parse every spec before calling — a second grammar - 2362
/// here would be two definitions of a rule that must agree forever. - 2363
pub fn persist_permission_rules( - 2364
path: PathBuf, - 2365
allow: Option<&[String]>, - 2366
ask: Option<&[String]>, - 2367
deny: Option<&[String]>, - 2368
) -> Result<(), ConfigError> { - 2369
update_config_file(&path, |document| { - 2370
for (key, list) in [("allow", allow), ("ask", ask), ("deny", deny)] { - 2371
let Some(list) = list else { continue }; - 2372
document.insert( - 2373
key.into(), - 2374
toml::Value::Array( - 2375
list.iter() - 2376
.map(|spec| toml::Value::String(spec.clone())) - 2377
.collect(), - 2378
), - 2379
); - 2380
} - 2381
Ok(()) - 2382
}) - 2383
} - 2384
- 2385
/// Persist the top-level `workers` toggle for the current project. - 2386
/// Mirrors [`persist_project_preferences`]'s atomic-write shape exactly. - 2387
pub fn persist_project_workers(cwd: &Path, enabled: bool) -> Result<(), ConfigError> { - 2388
persist_workers_at(project_path(cwd), enabled) - 2389
} - 2390
- 2391
/// Persist the user-level `workers` default, inherited by project - 2392
/// configs through [`load_with_trust`] until they set their own override. - 2393
pub fn persist_global_workers(enabled: bool) -> Result<(), ConfigError> { - 2394
let path = global_path().ok_or_else(|| ConfigError::Write { - 2395
path: PathBuf::from("<user-config>"), - 2396
source: std::io::Error::other("user home is unavailable"), - 2397
})?; - 2398
persist_workers_at(path, enabled) - 2399
} - 2400
- 2401
/// Persist the optional `[work]` policy fields without disturbing unrelated - 2402
/// config keys. The whole document is rewritten through the same atomic - 2403
/// rename boundary as the other authenticated preference endpoints. - 2404
pub fn persist_work_preferences( - 2405
path: PathBuf, - 2406
enabled: Option<bool>, - 2407
default_mode: Option<&str>, - 2408
max_items: Option<usize>, - 2409
max_revisions: Option<u32>, - 2410
max_parallel: Option<usize>, - 2411
confirmation: Option<&str>, - 2412
) -> Result<(), ConfigError> { - 2413
if [default_mode, confirmation] - 2414
.into_iter() - 2415
.flatten() - 2416
.any(|value| value.trim().is_empty()) - 2417
{ - 2418
return Err(ConfigError::Write { - 2419
path, - 2420
source: std::io::Error::other("work policy values cannot be empty"), - 2421
}); - 2422
} - 2423
update_config_file(&path, |document| { - 2424
let work = child_table(document, "work", &path)?; - 2425
if let Some(value) = enabled { - 2426
work.insert("enabled".into(), toml::Value::Boolean(value)); - 2427
} - 2428
if let Some(value) = default_mode { - 2429
work.insert("default_mode".into(), toml::Value::String(value.into())); - 2430
} - 2431
if let Some(value) = max_items { - 2432
work.insert("max_items".into(), toml::Value::Integer(value as i64)); - 2433
} - 2434
if let Some(value) = max_revisions { - 2435
work.insert("max_revisions".into(), toml::Value::Integer(value as i64)); - 2436
} - 2437
if let Some(value) = max_parallel { - 2438
work.insert("max_parallel".into(), toml::Value::Integer(value as i64)); - 2439
} - 2440
if let Some(value) = confirmation { - 2441
work.insert("confirmation".into(), toml::Value::String(value.into())); - 2442
} - 2443
Ok(()) - 2444
}) - 2445
} - 2446
- 2447
/// Persist `[plugins] network_allow` at the given layer path. - 2448
/// - 2449
/// `grant` is a three-state override: `Some(names)` grants egress to those - 2450
/// plugins, `Some(vec![])` removes the key entirely (so the layer reads as - 2451
/// deny-by-default and a narrower layer may inherit from a wider one), and - 2452
/// `None` leaves the file untouched. Grants are privileged and are also - 2453
/// demoted on read for untrusted project layers; callers must still refuse - 2454
/// a non-empty grant into an untrusted project rather than writing a value - 2455
/// the loader would silently discard. - 2456
pub fn persist_plugins_network_allow( - 2457
path: &Path, - 2458
grant: Option<Vec<String>>, - 2459
) -> Result<(), ConfigError> { - 2460
if let Some(names) = &grant - 2461
&& names.iter().any(|name| name.trim().is_empty()) - 2462
{ - 2463
return Err(ConfigError::Write { - 2464
path: path.to_path_buf(), - 2465
source: std::io::Error::other("plugin names cannot be empty"), - 2466
}); - 2467
} - 2468
update_config_file(path, |document| { - 2469
let plugins = child_table(document, "plugins", path)?; - 2470
match grant { - 2471
Some(names) if names.is_empty() => { - 2472
plugins.remove("network_allow"); - 2473
} - 2474
Some(names) => { - 2475
plugins.insert( - 2476
"network_allow".into(), - 2477
toml::Value::Array(names.into_iter().map(toml::Value::String).collect()), - 2478
); - 2479
} - 2480
None => {} - 2481
} - 2482
Ok(()) - 2483
}) - 2484
} - 2485
- 2486
fn persist_workers_at(path: PathBuf, enabled: bool) -> Result<(), ConfigError> { - 2487
update_config_file(&path, |document| { - 2488
document.insert("workers".into(), toml::Value::Boolean(enabled)); - 2489
// Drop the legacy alias so we don't leave two competing keys behind - 2490
// once this layer has been rewritten under the new name. - 2491
document.remove("subagents"); - 2492
Ok(()) - 2493
}) - 2494
} - 2495
- 2496
/// Persist `[finops]` budget caps for the current project. `None` leaves - 2497
/// that cap alone; `Some(None)` clears it (removes the key, so it reads - 2498
/// back as "no cap" rather than as an explicit zero); `Some(Some(v))` sets - 2499
/// it. Mirrors [`persist_project_preferences`]'s atomic-write shape. - 2500
pub fn persist_project_finops_caps( - 2501
cwd: &Path, - 2502
max_run_usd: Option<Option<f64>>, - 2503
max_day_usd: Option<Option<f64>>, - 2504
) -> Result<(), ConfigError> { - 2505
persist_finops_caps_at(project_path(cwd), max_run_usd, max_day_usd) - 2506
} - 2507
- 2508
/// A partial update to `[voice]`. `None` leaves a key untouched; for the - 2509
/// optional route keys `Some(None)` removes the key so it inherits again. - 2510
#[derive(Debug, Clone, Default)] - 2511
pub struct VoicePatch { - 2512
pub enabled: Option<bool>, - 2513
pub max_session_secs: Option<u64>, - 2514
pub max_concurrent: Option<usize>, - 2515
pub max_audio_bytes: Option<u64>, - 2516
pub provider: Option<Option<String>>, - 2517
pub transcription_model: Option<Option<String>>, - 2518
pub synthesis_model: Option<Option<String>>, - 2519
} - 2520
- 2521
impl VoicePatch { - 2522
pub fn is_empty(&self) -> bool { - 2523
self.enabled.is_none() - 2524
&& self.max_session_secs.is_none() - 2525
&& self.max_concurrent.is_none() - 2526
&& self.max_audio_bytes.is_none() - 2527
&& self.provider.is_none() - 2528
&& self.transcription_model.is_none() - 2529
&& self.synthesis_model.is_none() - 2530
} - 2531
} - 2532
- 2533
/// Atomically apply `patch` to the `[voice]` table of the config file at - 2534
/// `path`, preserving every key the patch does not name. - 2535
pub fn persist_voice_settings_at(path: PathBuf, patch: &VoicePatch) -> Result<(), ConfigError> { - 2536
update_config_file(&path, |document| { - 2537
let voice = child_table(document, "voice", &path)?; - 2538
if let Some(v) = patch.enabled { - 2539
voice.insert("enabled".into(), toml::Value::Boolean(v)); - 2540
} - 2541
for (key, value) in [ - 2542
("max_session_secs", patch.max_session_secs), - 2543
("max_concurrent", patch.max_concurrent.map(|v| v as u64)), - 2544
("max_audio_bytes", patch.max_audio_bytes), - 2545
] { - 2546
if let Some(v) = value { - 2547
voice.insert(key.into(), toml::Value::Integer(v as i64)); - 2548
} - 2549
} - 2550
for (key, value) in [ - 2551
("provider", &patch.provider), - 2552
("transcription_model", &patch.transcription_model), - 2553
("synthesis_model", &patch.synthesis_model), - 2554
] { - 2555
match value { - 2556
Some(Some(v)) => { - 2557
voice.insert(key.into(), toml::Value::String(v.clone())); - 2558
} - 2559
Some(None) => { - 2560
voice.remove(key); - 2561
} - 2562
None => {} - 2563
} - 2564
} - 2565
Ok(()) - 2566
}) - 2567
} - 2568
- 2569
/// Persist the user-level `[finops]` defaults, inherited by project - 2570
/// configs through [`load_with_trust`] until they set their own override. - 2571
pub fn persist_global_finops_caps( - 2572
max_run_usd: Option<Option<f64>>, - 2573
max_day_usd: Option<Option<f64>>, - 2574
) -> Result<(), ConfigError> { - 2575
let path = global_path().ok_or_else(|| ConfigError::Write { - 2576
path: PathBuf::from("<user-config>"), - 2577
source: std::io::Error::other("user home is unavailable"), - 2578
})?; - 2579
persist_finops_caps_at(path, max_run_usd, max_day_usd) - 2580
} - 2581
- 2582
fn persist_finops_caps_at( - 2583
path: PathBuf, - 2584
max_run_usd: Option<Option<f64>>, - 2585
max_day_usd: Option<Option<f64>>, - 2586
) -> Result<(), ConfigError> { - 2587
update_config_file(&path, |document| { - 2588
if max_run_usd.is_some() || max_day_usd.is_some() { - 2589
let finops = child_table(document, "finops", &path)?; - 2590
for (key, cap) in [("max_run_usd", max_run_usd), ("max_day_usd", max_day_usd)] { - 2591
match cap { - 2592
Some(Some(value)) => { - 2593
finops.insert(key.into(), toml::Value::Float(value)); - 2594
} - 2595
Some(None) => { - 2596
finops.remove(key); - 2597
} - 2598
None => {} - 2599
} - 2600
} - 2601
} - 2602
Ok(()) - 2603
}) - 2604
} - 2605
- 2606
pub fn load(cwd: &Path) -> Result<Config, ConfigError> { - 2607
load_with_trust(cwd, true) - 2608
} - 2609
- 2610
/// Keys a PROJECT-level config may not set when its workspace has not been - 2611
/// marked trusted: they grant execution or redirect credentials. - 2612
const PRIVILEGED_KEYS_NOTICE: &str = "permission_mode, approval_mode, allow, hooks, anthropic_base_url, mcp.servers, gateway, sandbox, server, update, capabilities, intent.autonomy, intent.escalate=cloud, intent.enabled=false, intent.posture=false, plugins.network_allow, server.bus, feeds"; - 2613
- 2614
pub fn load_with_trust(cwd: &Path, trust_project: bool) -> Result<Config, ConfigError> { - 2615
let mut warnings = Vec::new(); - 2616
let mut layers: Vec<FileConfig> = vec![FileConfig::default()]; - 2617
- 2618
if let Some(gp) = global_path().filter(|gp| gp.is_file()) { - 2619
let (fc, w) = parse_file(&gp)?; - 2620
warnings.extend(w); - 2621
layers.push(fc); - 2622
} - 2623
let pp = project_path(cwd); - 2624
let is_global_workspace = global_path().is_some_and(|global| global == pp); - 2625
if pp.is_file() && !is_global_workspace { - 2626
let (mut fc, w) = parse_file(&pp)?; - 2627
warnings.extend(w); - 2628
if !trust_project { - 2629
// A repository must not be able to configure itself into - 2630
// execution power on first run. Restrictive keys (deny/ask) - 2631
// still apply. - 2632
if fc.permission_mode.is_some() { - 2633
fc.permission_mode = None; - 2634
} - 2635
if fc.approval_mode.is_some() { - 2636
fc.approval_mode = None; - 2637
} - 2638
if fc.anthropic_base_url.is_some() { - 2639
fc.anthropic_base_url = None; - 2640
} - 2641
fc.allow.clear(); - 2642
fc.hooks.clear(); - 2643
fc.mcp.servers.clear(); - 2644
if fc.gateway.enabled.is_some() || !fc.gateway.outbound.webhooks.is_empty() { - 2645
// Outbound webhook URLs are exfil targets just like base-url - 2646
// redirection: the whole section is privileged. - 2647
fc.gateway = GatewaySettings::default(); - 2648
} - 2649
// Image choice is supply-chain power; keep it with the user. - 2650
if fc.sandbox.backend.is_some() || fc.sandbox.image.is_some() { - 2651
fc.sandbox = SandboxSettings::default(); - 2652
} - 2653
// The release feed decides which binary replaces this one, and - 2654
// its artifact hashes come from the feed itself — an attacker who - 2655
// picks the URL picks the checksum too. Same class of power as - 2656
// anthropic_base_url, and it was not stripped here. - 2657
if fc.update.url.is_some() { - 2658
fc.update.url = None; - 2659
} - 2660
// `inherit_* = false` clears the corresponding global layer in - 2661
// `merge_into`, so an untrusted project could switch off the - 2662
// user's own hooks and MCP servers — disabling a protection is - 2663
// as privileged as adding a capability. - 2664
fc.capabilities = CapabilityInheritanceSettings::default(); - 2665
// `delegated` and `autonomous` suppress approval gates, and a - 2666
// cloud classification tier spends the user's credentials before - 2667
// the run they asked for. Both are execution power; a cloned - 2668
// repository must not grant them to itself. The rest of [intent] - 2669
// only ever narrows, so it survives untrusted. - 2670
if fc.intent.autonomy.is_some() { - 2671
fc.intent.autonomy = None; - 2672
} - 2673
if fc.intent.escalate.as_deref() == Some("cloud") { - 2674
fc.intent.escalate = None; - 2675
} - 2676
// Switching the kernel or its posture off removes the approval - 2677
// floor it raises — "force push to production" asks even under - 2678
// auto-approve only while both are on — so turning either off is - 2679
// disabling a protection, which is as privileged as granting. - 2680
if fc.intent.enabled == Some(false) { - 2681
fc.intent.enabled = None; - 2682
} - 2683
if fc.intent.posture == Some(false) { - 2684
fc.intent.posture = None; - 2685
} - 2686
// Network exposure is not a project's decision to make. `bind` - 2687
// chooses which interface answers, `trusted_hosts` relaxes the - 2688
// DNS-rebinding defence, and `web.terminal` opens a shell to - 2689
// whoever can reach the port — a cloned repository that could - 2690
// set these would be handing itself the machine. - 2691
fc.server = ServerSettings::default(); - 2692
fc.plugins.network_allow = None; - 2693
fc.plugins.allow = None; - 2694
// The feed pipeline is an unattended surface: `feeds.enabled` - 2695
// makes the scheduler fetch and run the pipeline every tick, and - 2696
// the pipeline is executable code with network access. A cloned - 2697
// repository that could switch it on would be handing itself an - 2698
// unattended runner (invariant 15). The whole section is - 2699
// privileged, like [server] and [gateway]. - 2700
fc.feeds = FeedSettings::default(); - 2701
warnings.push(format!( - 2702
"project .vak/config.toml is not trusted for this workspace; \ - 2703
ignored privileged keys ({PRIVILEGED_KEYS_NOTICE}). \ - 2704
Re-run and confirm the workspace prompt, or pass --trust, to apply them." - 2705
)); - 2706
} - 2707
layers.push(fc); - 2708
} - 2709
- 2710
let mut merged = FileConfig::default(); - 2711
for layer in layers { - 2712
merge_into(&mut merged, layer); - 2713
} - 2714
- 2715
if let Ok(m) = std::env::var("VAK_MODEL") { - 2716
merged.model = Some(m); - 2717
} - 2718
if let Ok(p) = std::env::var("VAK_PROVIDER") { - 2719
merged.provider = Some(p); - 2720
} - 2721
- 2722
let mut cfg = Config { - 2723
warnings, - 2724
..Default::default() - 2725
}; - 2726
if let Some(provider) = merged.provider { - 2727
cfg.provider = provider; - 2728
} - 2729
if let Some(model) = merged.model { - 2730
cfg.model = model; - 2731
} - 2732
if let Some(mt) = merged.max_tokens { - 2733
cfg.max_tokens = mt; - 2734
} - 2735
if let Some(turns) = merged.max_turns { - 2736
cfg.max_turns = turns; - 2737
} - 2738
if let Some(mode) = merged.permission_mode { - 2739
cfg.permission_mode = mode; - 2740
} - 2741
if let Some(mode) = merged.approval_mode { - 2742
cfg.approval_mode = mode; - 2743
} - 2744
cfg.anthropic_base_url = merged.anthropic_base_url; - 2745
cfg.allow = merged.allow; - 2746
cfg.ask = merged.ask; - 2747
cfg.deny = merged.deny; - 2748
if let Some(sa) = merged.workers { - 2749
cfg.workers = sa; - 2750
} - 2751
if let Some(r) = merged.max_retries { - 2752
cfg.max_retries = r; - 2753
} - 2754
if let Some(ms) = merged.retry_base_backoff_ms { - 2755
cfg.retry_base_backoff_ms = ms; - 2756
} - 2757
if let Some(secs) = merged.request_timeout_secs { - 2758
cfg.request_timeout_secs = secs; - 2759
} - 2760
if let Some(n) = merged.run_retry_attempts { - 2761
cfg.run_retry_attempts = n; - 2762
} - 2763
if let Some(ms) = merged.run_retry_base_backoff_ms { - 2764
cfg.run_retry_base_backoff_ms = ms; - 2765
} - 2766
if let Some(t) = merged.circuit_breaker_threshold { - 2767
cfg.circuit_breaker_threshold = t; - 2768
} - 2769
if let Some(c) = merged.circuit_breaker_cooldown_secs { - 2770
cfg.circuit_breaker_cooldown_secs = c; - 2771
} - 2772
if let Some(w) = merged.context_window { - 2773
if w < 16_384 { - 2774
cfg.warnings.push(format!( - 2775
"context_window {w} too small; using default {}", - 2776
cfg.context_window - 2777
)); - 2778
} else { - 2779
cfg.context_window = w; - 2780
} - 2781
} - 2782
cfg.voice = merged.voice.unwrap_or_default(); - 2783
cfg.hooks = merged.hooks; - 2784
for (name, srv) in merged.mcp.servers { - 2785
cfg.mcp.servers.insert(name, srv); - 2786
} - 2787
cfg.capabilities = CapabilityInheritanceResolved { - 2788
inherit_mcp: merged.capabilities.inherit_mcp.unwrap_or(true), - 2789
inherit_hooks: merged.capabilities.inherit_hooks.unwrap_or(true), - 2790
inherit_skills: merged.capabilities.inherit_skills.unwrap_or(true), - 2791
inherit_commands: merged.capabilities.inherit_commands.unwrap_or(true), - 2792
inherit_plugins: merged.capabilities.inherit_plugins.unwrap_or(true), - 2793
}; - 2794
cfg.ui.theme = merged.ui.theme.clone().unwrap_or_else(|| "dark".into()); - 2795
let builtin = matches!( - 2796
cfg.ui.theme.as_str(), - 2797
"dark" - 2798
| "light" - 2799
| "neo" - 2800
| "rich" - 2801
| "teenage" - 2802
| "plain" - 2803
| "midnight" - 2804
| "synthwave" - 2805
| "forest" - 2806
); - 2807
if !builtin && !merged.ui.themes.contains_key(&cfg.ui.theme) { - 2808
cfg.warnings - 2809
.push(format!("unknown ui.theme '{}'; using 'dark'", cfg.ui.theme)); - 2810
cfg.ui.theme = "dark".into(); - 2811
} - 2812
cfg.ui.bell = merged.ui.bell.unwrap_or(true); - 2813
cfg.ui.keymap = merged.ui.keymap; - 2814
for (name, colors) in merged.ui.themes { - 2815
let entry = cfg.ui.themes.entry(name.clone()).or_default(); - 2816
for (key, value) in colors { - 2817
match value.as_str() { - 2818
Some(v) => { - 2819
entry.insert(key, v.to_string()); - 2820
} - 2821
None => cfg.warnings.push(format!( - 2822
"ui.themes.{name}.{key} must be a string color (ignored)" - 2823
)), - 2824
} - 2825
} - 2826
} - 2827
cfg.ui.composer = match merged.ui.composer.as_deref() { - 2828
Some("vim") => "vim".into(), - 2829
Some("emacs") | None => "emacs".into(), - 2830
Some(other) => { - 2831
cfg.warnings - 2832
.push(format!("unknown ui.composer '{other}'; using 'emacs'")); - 2833
"emacs".into() - 2834
} - 2835
}; - 2836
cfg.ui.osc52 = merged.ui.osc52.unwrap_or(false); - 2837
let acc = merged.ui.accessibility.unwrap_or_default(); - 2838
cfg.ui.accessibility = AccessibilityResolved { - 2839
plain: acc.plain.unwrap_or(false), - 2840
reduced_motion: acc.reduced_motion.unwrap_or(false), - 2841
screen_reader: acc.screen_reader.unwrap_or(false), - 2842
}; - 2843
- 2844
let sp = merged.stop_policy.unwrap_or_default(); - 2845
cfg.stop_policy = StopPolicyResolved { - 2846
enabled: sp.enabled.unwrap_or(true), - 2847
marker_gate: sp.marker_gate.unwrap_or(true), - 2848
verify_gate: sp.verify_gate.unwrap_or(true), - 2849
max_blocks: sp.max_blocks.unwrap_or(2), - 2850
}; - 2851
- 2852
cfg.finops = FinopsResolved { - 2853
max_run_usd: merged.finops.max_run_usd, - 2854
max_day_usd: merged.finops.max_day_usd, - 2855
price_overrides: merged.finops.price_overrides.clone(), - 2856
}; - 2857
- 2858
cfg.goal = GoalResolved { - 2859
handoff_reset: merged.goal.handoff_reset.unwrap_or(true), - 2860
max_audit_blocks: merged.goal.max_audit_blocks.unwrap_or(2), - 2861
}; - 2862
- 2863
cfg.work = WorkResolved { - 2864
enabled: merged.work.enabled.unwrap_or(true), - 2865
default_mode: match merged.work.default_mode.as_deref() { - 2866
None | Some("direct") => "direct".into(), - 2867
Some("managed") => "managed".into(), - 2868
Some("auto") => "auto".into(), - 2869
Some(other) => { - 2870
cfg.warnings.push(format!( - 2871
"unknown work.default_mode '{other}'; using 'direct'" - 2872
)); - 2873
"direct".into() - 2874
} - 2875
}, - 2876
max_items: merged.work.max_items.unwrap_or(20).clamp(1, 128), - 2877
max_revisions: merged.work.max_revisions.unwrap_or(8).clamp(1, 64), - 2878
max_parallel: merged.work.max_parallel.unwrap_or(4).clamp(1, 32), - 2879
confirmation: match merged.work.confirmation.as_deref() { - 2880
None | Some("risk-based") => "risk-based".into(), - 2881
Some("always") => "always".into(), - 2882
Some("never") => "never".into(), - 2883
Some(other) => { - 2884
cfg.warnings.push(format!( - 2885
"unknown work.confirmation '{other}'; using 'risk-based'" - 2886
)); - 2887
"risk-based".into() - 2888
} - 2889
}, - 2890
}; - 2891
- 2892
cfg.route.objective = match merged.route.objective.as_deref() { - 2893
None | Some("auto") => "auto".into(), - 2894
Some("utility") => "utility".into(), - 2895
Some("balanced") => "balanced".into(), - 2896
Some("quality-critical") | Some("quality_critical") => "quality-critical".into(), - 2897
Some(other) => { - 2898
cfg.warnings.push(format!( - 2899
"unknown route.objective '{other}'; using 'auto' \ - 2900
(valid: auto | utility | balanced | quality-critical)" - 2901
)); - 2902
"auto".into() - 2903
} - 2904
}; - 2905
cfg.route.fallback_models = merged.route.fallback_models.clone(); - 2906
cfg.route.max_fallbacks = merged.route.max_fallbacks.unwrap_or(4).clamp(1, 16); - 2907
cfg.route.quality_hints = merged - 2908
.route - 2909
.quality_hints - 2910
.iter() - 2911
.map(|h| h.to_ascii_lowercase()) - 2912
.collect(); - 2913
cfg.route.modality_hints = merged - 2914
.route - 2915
.modality_hints - 2916
.iter() - 2917
.map(|h| h.to_ascii_lowercase()) - 2918
.collect(); - 2919
- 2920
cfg.probe.hosted = match merged.probe.hosted.as_deref() { - 2921
None | Some("none") => "none".into(), - 2922
Some("full") => "full".into(), - 2923
Some(other) => { - 2924
cfg.warnings.push(format!( - 2925
"unknown probe.hosted '{other}'; using 'none' (valid: none | full)" - 2926
)); - 2927
"none".into() - 2928
} - 2929
}; - 2930
- 2931
// --- providers.ollama (docs/design/68-context-engine.md §8) --- - 2932
match merged.providers.ollama.validate() { - 2933
Ok(()) => { - 2934
if let Some(keep_alive) = merged.providers.ollama.keep_alive.clone() { - 2935
cfg.ollama.keep_alive = keep_alive; - 2936
} - 2937
cfg.ollama.num_ctx = merged.providers.ollama.num_ctx; - 2938
} - 2939
Err(msg) => cfg.warnings.push(msg), - 2940
} - 2941
- 2942
// --- providers.anthropic (docs/design/68-context-engine.md §11) --- - 2943
cfg.anthropic.fast_mode = merged.providers.anthropic.fast_mode.unwrap_or(false); - 2944
- 2945
// --- intent kernel (docs/design/47-commitment-kernel.md) --- - 2946
cfg.intent.enabled = merged.intent.enabled.unwrap_or(true); - 2947
// Clamped rather than rejected: a nonsensical threshold should not stop - 2948
// the runtime, and the clamp keeps the ordering invariant that - 2949
// `provisional <= accept` even if an operator inverts them. TOML can - 2950
// spell `nan` and `inf`, and `clamp` passes NaN straight through, where - 2951
// every comparison against it is false — so a non-finite value is - 2952
// replaced by the default before it is clamped. - 2953
let mut finite = |value: Option<f64>, default: f64, key: &str| match value { - 2954
Some(value) if !value.is_finite() => { - 2955
cfg.warnings.push(format!( - 2956
"{key} = {value} is not a finite number; using {default}" - 2957
)); - 2958
default - 2959
} - 2960
other => other.unwrap_or(default), - 2961
}; - 2962
let accept = finite( - 2963
merged.intent.accept_confidence, - 2964
0.75, - 2965
"intent.accept_confidence", - 2966
); - 2967
let provisional = finite( - 2968
merged.intent.provisional_confidence, - 2969
0.45, - 2970
"intent.provisional_confidence", - 2971
); - 2972
let max_classify = finite( - 2973
merged.intent.max_classify_usd, - 2974
0.01, - 2975
"intent.max_classify_usd", - 2976
); - 2977
let lifetime_budget = merged - 2978
.commitment - 2979
.lifetime_budget_usd - 2980
.map(|budget| finite(Some(budget), 0.0, "commitment.lifetime_budget_usd")); - 2981
cfg.intent.accept_confidence = accept.clamp(0.0, 1.0); - 2982
cfg.intent.provisional_confidence = provisional.clamp(0.0, cfg.intent.accept_confidence); - 2983
cfg.intent.slice_capabilities = merged.intent.slice_capabilities.unwrap_or(true); - 2984
cfg.intent.posture = merged.intent.posture.unwrap_or(true); - 2985
cfg.intent.escalate = match merged.intent.escalate.as_deref() { - 2986
Some("none") | None => "none".into(), - 2987
Some("local") => "local".into(), - 2988
Some("cloud") => "cloud".into(), - 2989
Some(other) => { - 2990
cfg.warnings.push(format!( - 2991
"unknown intent.escalate '{other}'; using 'none' \ - 2992
(valid: none, local, cloud)" - 2993
)); - 2994
"none".into() - 2995
} - 2996
}; - 2997
cfg.intent.classify_model = merged - 2998
.intent - 2999
.classify_model - 3000
.clone() - 3001
.filter(|model| !model.trim().is_empty()); - 3002
cfg.intent.max_classify_usd = max_classify.clamp(0.0, 1.0); - 3003
cfg.intent.classify_timeout_secs = merged - 3004
.intent - 3005
.classify_timeout_secs - 3006
.unwrap_or(10) - 3007
.clamp(1, 120); - 3008
cfg.intent.autonomy = match merged.intent.autonomy.as_deref() { - 3009
Some("manual") => "manual".into(), - 3010
Some("assisted") | None => "assisted".into(), - 3011
Some("delegated") => "delegated".into(), - 3012
Some("autonomous") => "autonomous".into(), - 3013
Some(other) => { - 3014
cfg.warnings.push(format!( - 3015
"unknown intent.autonomy '{other}'; using 'assisted' \ - 3016
(valid: manual, assisted, delegated, autonomous)" - 3017
)); - 3018
"assisted".into() - 3019
} - 3020
}; - 3021
cfg.intent.evidence_max_age_secs = merged.intent.evidence_max_age_secs.unwrap_or(86_400).max(0); - 3022
- 3023
// --- durable commitments --- - 3024
cfg.commitment.enabled = merged.commitment.enabled.unwrap_or(true); - 3025
cfg.commitment.lifetime_budget_usd = lifetime_budget.filter(|budget| *budget > 0.0); - 3026
cfg.commitment.stall_limit = merged.commitment.stall_limit.unwrap_or(3).clamp(1, 100); - 3027
cfg.commitment.review_every_hours = merged - 3028
.commitment - 3029
.review_every_hours - 3030
.filter(|hours| *hours > 0); - 3031
cfg.commitment.default_ttl_days = merged.commitment.default_ttl_days.filter(|days| *days > 0); - 3032
- 3033
cfg.gateway.enabled = merged.gateway.enabled.unwrap_or(false); - 3034
cfg.gateway.approvals = match merged.gateway.approvals.as_deref() { - 3035
Some("deny") | None => "deny".into(), - 3036
Some("forward") => "forward".into(), - 3037
Some(other) => { - 3038
cfg.warnings - 3039
.push(format!("unknown gateway.approvals '{other}'; using 'deny'")); - 3040
"deny".into() - 3041
} - 3042
}; - 3043
cfg.gateway.approver = merged.gateway.approver.clone(); - 3044
if cfg.gateway.approvals == "forward" { - 3045
let ok = cfg - 3046
.gateway - 3047
.approver - 3048
.as_deref() - 3049
.is_some_and(|t| t.contains(':') && !t.trim().is_empty()); - 3050
if !ok { - 3051
cfg.warnings.push( - 3052
"gateway.approvals = 'forward' requires gateway.approver = '<surface>:<chat>'; \ - 3053
falling back to 'deny'" - 3054
.into(), - 3055
); - 3056
cfg.gateway.approvals = "deny".into(); - 3057
cfg.gateway.approver = None; - 3058
} - 3059
} - 3060
match merged.gateway.approval_timeout_secs { - 3061
Some(t) if t < 5 => { - 3062
cfg.warnings.push(format!( - 3063
"gateway.approval_timeout_secs {t} below minimum; using 5" - 3064
)); - 3065
cfg.gateway.approval_timeout_secs = 5; - 3066
} - 3067
Some(t) => cfg.gateway.approval_timeout_secs = t, - 3068
None => {} - 3069
} - 3070
cfg.gateway.rate_limit = merged.gateway.rate_limit.clone(); - 3071
cfg.gateway.chat_allowlist = merged.gateway.chat_allowlist.clone(); - 3072
cfg.gateway.chat_allowlist_open = merged.gateway.chat_allowlist_open.unwrap_or(false); - 3073
cfg.gateway.core_pool_max = merged.gateway.core_pool_max.unwrap_or(8).max(1); - 3074
cfg.gateway.core_pool_idle_secs = merged.gateway.core_pool_idle_secs.unwrap_or(1800).max(60); - 3075
cfg.gateway.pending_expiry_days = merged.gateway.pending_expiry_days.unwrap_or(7).max(1); - 3076
if cfg.gateway.enabled - 3077
&& cfg.gateway.chat_allowlist.is_empty() - 3078
&& cfg.gateway.chat_allowlist_open - 3079
{ - 3080
cfg.warnings.push( - 3081
"gateway.chat_allowlist is empty and gateway.chat_allowlist_open = true: \ - 3082
every inbound chat is accepted. Set gateway.chat_allowlist to restrict access." - 3083
.into(), - 3084
); - 3085
} - 3086
cfg.memory.search_enabled = merged.memory.search_enabled.unwrap_or(true); - 3087
cfg.memory.write_enabled = merged.memory.write_enabled.unwrap_or(true); - 3088
cfg.memory.skill_proposals = merged.memory.skill_proposals.unwrap_or(true); - 3089
cfg.memory.reflection = merged.memory.reflection.unwrap_or(false); - 3090
cfg.sandbox.backend = match merged.sandbox.backend.as_deref() { - 3091
None => "auto".into(), - 3092
Some(b @ ("auto" | "seatbelt" | "landlock" | "docker")) => b.into(), - 3093
Some(other) => { - 3094
cfg.warnings - 3095
.push(format!("unknown sandbox.backend '{other}'; using 'auto'")); - 3096
"auto".into() - 3097
} - 3098
}; - 3099
cfg.sandbox.image = merged.sandbox.image.clone(); - 3100
cfg.automation.catch_up_missed = merged.automation.catch_up_missed.unwrap_or(true); - 3101
cfg.update.url = merged.update.url.clone(); - 3102
cfg.update.interval_hours = merged.update.interval_hours.unwrap_or(24); - 3103
cfg.tools.web_fetch = merged.tools.web_fetch.unwrap_or(true); - 3104
cfg.tools.browse = merged.tools.browse.unwrap_or(true); - 3105
let hb = &merged.heartbeat; - 3106
cfg.heartbeat.interval_secs = match hb.interval_secs { - 3107
Some(v) if v < 300 => { - 3108
cfg.warnings.push(format!( - 3109
"heartbeat.interval_secs {v} below minimum; using 300" - 3110
)); - 3111
300 - 3112
} - 3113
Some(v) => v, - 3114
None => 1800, - 3115
}; - 3116
cfg.heartbeat.enabled = hb.enabled.unwrap_or(false); - 3117
cfg.heartbeat.model = hb.model.clone(); - 3118
cfg.heartbeat.quiet_hours = match hb.quiet_hours.as_deref() { - 3119
None => None, - 3120
Some(raw) => match QuietWindow::parse(raw) { - 3121
Some(w) => Some(w), - 3122
None => { - 3123
cfg.warnings.push(format!( - 3124
"heartbeat.quiet_hours '{raw}' is not 'HH:MM-HH:MM'; ignoring" - 3125
)); - 3126
None - 3127
} - 3128
}, - 3129
}; - 3130
match hb.max_findings { - 3131
Some(0) => { - 3132
cfg.warnings - 3133
.push("heartbeat.max_findings must be >= 1; using 1".into()); - 3134
cfg.heartbeat.max_findings = 1; - 3135
} - 3136
Some(n) => cfg.heartbeat.max_findings = n, - 3137
None => cfg.heartbeat.max_findings = 3, - 3138
} - 3139
let fs = &merged.feeds; - 3140
cfg.feeds.enabled = fs.enabled.unwrap_or(false); - 3141
cfg.feeds.config_path = fs.config_path.clone(); - 3142
cfg.feeds.db_path = fs.db_path.clone(); - 3143
cfg.feeds.default_check_interval = fs - 3144
.default_check_interval - 3145
.clone() - 3146
.unwrap_or_else(|| "30m".into()); - 3147
cfg.feeds.max_items_per_feed = fs.max_items_per_feed.unwrap_or(500); - 3148
cfg.feeds.dedup_window_days = fs.dedup_window_days.unwrap_or(90); - 3149
- 3150
// ---- [server] (docs/design/48-web-client.md §4.2) -------------------- - 3151
// - 3152
// Defaults reproduce the pre-web behaviour exactly: loopback only, no - 3153
// trusted hosts, no terminal over the web. Every step away from that is - 3154
// something an operator typed on purpose. - 3155
let sv = &merged.server; - 3156
cfg.server.bind = sv - 3157
.bind - 3158
.clone() - 3159
.map(|b| b.trim().to_string()) - 3160
.filter(|b| !b.is_empty()) - 3161
.unwrap_or_else(|| "127.0.0.1".into()); - 3162
cfg.server.trusted_hosts = sv - 3163
.trusted_hosts - 3164
.clone() - 3165
.unwrap_or_default() - 3166
.into_iter() - 3167
.map(|h| h.trim().to_ascii_lowercase()) - 3168
.filter(|h| !h.is_empty()) - 3169
.collect(); - 3170
if cfg.server.trusted_hosts.iter().any(|h| h.contains('*')) { - 3171
cfg.server.trusted_hosts.retain(|h| !h.contains('*')); - 3172
cfg.warnings.push( - 3173
"server.trusted_hosts entries containing '*' were ignored: a wildcard \ - 3174
defeats the DNS-rebinding check the list exists to enforce" - 3175
.into(), - 3176
); - 3177
} - 3178
cfg.server.public_url = sv - 3179
.public_url - 3180
.clone() - 3181
.map(|u| u.trim().trim_end_matches('/').to_string()) - 3182
.filter(|u| !u.is_empty()); - 3183
cfg.server.session_ttl_hours = sv.session_ttl_hours.filter(|h| *h > 0).unwrap_or(168); - 3184
cfg.server.loopback_auto_login = sv.loopback_auto_login.unwrap_or(true); - 3185
cfg.server.workspace_roots = sv - 3186
.workspace_roots - 3187
.clone() - 3188
.unwrap_or_default() - 3189
.into_iter() - 3190
.map(std::path::PathBuf::from) - 3191
.filter(|p| p.is_absolute()) - 3192
.collect(); - 3193
cfg.server.web_terminal = sv.web.terminal.unwrap_or(false); - 3194
cfg.server.web_terminal_requires_loopback = sv.web.terminal_requires_loopback.unwrap_or(true); - 3195
// Bus config: resolve the workspace encryption secret from the named env var. - 3196
// The env var name itself is never stored in the resolved config — only the - 3197
// secret bytes read from the environment at resolution time. - 3198
cfg.server.bus = BusResolved { - 3199
nats_url: sv.bus.nats_url.clone(), - 3200
workspace_secret: sv - 3201
.bus - 3202
.workspace_secret_env - 3203
.as_deref() - 3204
.and_then(|env_name| std::env::var(env_name).ok()) - 3205
.map(|s| s.into_bytes()), - 3206
}; - 3207
for (name, hook) in merged.gateway.outbound.webhooks { - 3208
if hook.url.trim().is_empty() { - 3209
cfg.warnings.push(format!( - 3210
"gateway.outbound.webhooks.{name}.url is empty; ignored" - 3211
)); - 3212
continue; - 3213
} - 3214
if let Some(env_name) = &hook.token_env - 3215
&& env_name.trim().is_empty() - 3216
{ - 3217
cfg.warnings.push(format!( - 3218
"gateway.outbound.webhooks.{name}.token_env is empty; delivery will fail closed" - 3219
)); - 3220
} - 3221
cfg.gateway.webhooks.insert( - 3222
name, - 3223
WebhookResolved { - 3224
url: hook.url, - 3225
token_env: hook.token_env, - 3226
}, - 3227
); - 3228
} - 3229
- 3230
cfg.plugins = PluginResolved { - 3231
enabled: merged.plugins.enabled, - 3232
disabled: merged.plugins.disabled, - 3233
allow: merged.plugins.allow, - 3234
deny: merged.plugins.deny, - 3235
network_allow: merged.plugins.network_allow, - 3236
network_deny: merged.plugins.network_deny, - 3237
}; - 3238
- 3239
if let Some(name) = &merged.profile { - 3240
if let Some(profile) = merged.profiles.get(name) { - 3241
if let Some(model) = &profile.model { - 3242
cfg.model = model.clone(); - 3243
} - 3244
if let Some(provider) = &profile.provider { - 3245
cfg.provider = provider.clone(); - 3246
} - 3247
if let Some(mode) = profile.permission_mode { - 3248
cfg.permission_mode = mode; - 3249
} - 3250
if let Some(turns) = profile.max_turns { - 3251
cfg.max_turns = turns; - 3252
} - 3253
} else { - 3254
cfg.warnings.push(format!("profile '{name}' not defined"));
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.