1macro_rules! tool {
18 ($r:expr, $ctx:expr, $name:expr, $title:expr, $desc:expr, $schema:expr, $ann:expr, $f:expr) => {{
19 let ctx = $ctx.clone();
20 let f = $f;
21 $r.register(
22 Tool::new($name, $desc, $schema, move |a, c| f(&ctx, a, c))
23 .title($title)
24 .annotations($ann.clone()),
25 )?;
26 }};
27}
28
29mod accounts;
30pub mod apps;
31pub mod audit;
32mod authorize;
33pub mod builds;
34pub mod data;
35mod default_org;
36mod dns;
37mod egress;
38mod kube;
39mod monitors;
40mod notify;
41mod orgs;
42pub mod policy;
43pub mod previews;
44pub mod secrets;
45mod servers;
46mod ssh;
47pub mod superadmin;
48pub mod templates;
49mod terminal;
50mod tools;
51pub mod volumes;
52pub mod workspaces;
53
54use std::collections::BTreeMap;
55use std::path::PathBuf;
56use std::sync::Arc;
57use std::time::{Duration, Instant};
58
59use serde::Deserialize;
60use serde::de::DeserializeOwned;
61use serde_json::{Value, json};
62
63use crate::auth::AuthConfig;
64use crate::auth::AuthStore;
65use crate::auth::http::{ApiConfig, AuthApi};
66use crate::client::Client;
67use crate::error::{Error, Result};
68use crate::exec::{ExecOptions, Stdin};
69use crate::sandbox::{EnsureOptions, Sandbox, SandboxInfo};
70use crate::server::{AccessValidator, Caller, Listener, Registry, Tool, ToolPolicy};
71use crate::spec::{ComposeFile, SandboxSpec};
72use crate::stack::{Controller, StackDef, Store, now_secs};
73use authorize::{CROSS_ORG_READS, PLATFORM_TOOLS, arg_org, authorize_class, tool_listed};
74use policy::RemotePolicy;
75use tools::Ann;
76
77pub const LABEL_OWNER: &str = "isb.owner";
79
80#[derive(Debug, Clone)]
82pub struct ServeConfig {
83 pub listen: Vec<String>,
87 pub socket: PathBuf,
88 pub access: Option<(String, String)>,
90 pub allow_unauthenticated: bool,
92 pub remote_tools: ToolPolicy,
94 pub policy: RemotePolicy,
95 pub state_dir: PathBuf,
96 pub interval: Duration,
97 pub keys: crate::secrets::KeySources,
99 pub secrets_config: PathBuf,
101 pub auth: AuthConfig,
103 pub public_url: Option<String>,
106 pub oauth: crate::auth::oauth::OAuthSettings,
108 pub open_signup: bool,
110 pub ingress: Option<crate::ingress::IngressConfig>,
112 pub workspace_mcp_port: u16,
115 pub workspace_pool: Option<String>,
118 pub workspace_home_root: Option<PathBuf>,
121 pub preview_domain: Option<workspaces::PreviewBase>,
123 pub audit_retention: Duration,
125 pub audit_all: bool,
127 pub history_retention: Duration,
129 pub history_max_rows: i64,
130 pub agent: Option<AgentConfig>,
133 pub superadmin_tailnet: Option<crate::server::tailnet::AllowList>,
136 pub superadmin_access: Option<superadmin::AccessAllowList>,
139 pub heartbeat: Option<crate::monitor::heartbeat::Heartbeat>,
141 pub egress_pins: Vec<String>,
144 pub egress_ca: Vec<PathBuf>,
146}
147
148#[derive(Debug, Clone)]
150pub struct AgentConfig {
151 pub listen: String,
153 pub tls_dir: PathBuf,
155}
156
157fn auth_routes(
161 cfg: &ServeConfig,
162 store: Arc<AuthStore>,
163 secrets: &Arc<crate::secrets::Secrets>,
164 log: &Arc<crate::audit::AuditLog>,
165 gate: Arc<superadmin::Gate>,
166) -> Result<crate::server::Routes> {
167 use crate::auth::oauth::SecretFn;
168 let default_org = crate::org::OrgId::default_org();
169 let lookup = |name: &str| -> Option<SecretFn> {
170 secrets.inspect(&default_org, name).ok()?;
171 let (s, org, name) = (secrets.clone(), default_org.clone(), name.to_string());
172 Some(Arc::new(move || {
173 let (v, _) = s.get(&org, &name).map_err(|e| e.to_string())?;
174 String::from_utf8(v)
175 .map(|v| v.trim().to_string())
176 .map_err(|_| "the secret is not UTF-8".to_string())
177 }))
178 };
179 let (providers, notes) = cfg.oauth.providers(&lookup);
180 for n in notes {
181 eprintln!("isb serve: {n}");
182 }
183 let path = crate::auth::db_path(&cfg.state_dir);
184 let agent_ways = gate.agent_ways().with_public_url(cfg.public_url.clone());
185 let api = AuthApi::new(
186 store.clone(),
187 ApiConfig {
188 agent: Some(gate.agent_fn()),
189 agent_ways,
190 public_url: cfg.public_url.clone(),
191 notifier: None,
192 setup_token_file: Some(cfg.state_dir.join("setup-token")),
193 edge: Some(gate.edge_fn()),
194 providers,
195 open_signup: cfg.open_signup,
196 audit: Some(log.clone()),
197 superadmin: Some(Arc::new(move |r: &crate::server::http::Request| match gate
198 .resolve(r, None)
199 {
200 superadmin::Resolved::Superadmin(s) => Some(s),
201 _ => None,
202 })),
203 },
204 )?;
205 eprintln!("isb serve: identity store {}", path.display());
206 if !crate::web::BUILT {
207 eprintln!(
208 "isb serve: this binary was built without the web UI (a placeholder page is served)"
209 );
210 }
211 let (auth, web) = (Arc::new(api).router(), crate::web::routes());
214 let tail = audit::stream_route(log.clone(), store);
215 Ok(Arc::new(move |r| {
216 auth(r).or_else(|| tail(r)).or_else(|| web(r))
217 }))
218}
219
220struct Daemon {
221 client: Client,
222 ctl: Controller,
223 policy: RemotePolicy,
224 state_dir: PathBuf,
225 secrets: Arc<crate::secrets::Secrets>,
226 apps: crate::app::Apps,
227 ingress: Option<Arc<crate::ingress::Manager>>,
228 users: Arc<AuthStore>,
230 notifier: crate::notify::Notifier,
231 monitors: crate::monitor::Monitors,
232 history: crate::metrics_history::History,
233 data: data::Ctx,
235 volumes: crate::volume_backup::VolumeBackups,
237 audit: Arc<crate::audit::AuditLog>,
238 servers: Option<Arc<crate::servers::Servers>>,
241 gate: Arc<superadmin::Gate>,
243 host: Value,
244 catalogs: Arc<crate::template::catalog::Catalogs>,
246 workspaces: Arc<workspaces::Workspaces>,
249 public_url: Option<String>,
251 egress: Arc<isb_egress::Manager>,
253}
254
255#[expect(
257 clippy::too_many_lines,
258 reason = "predates the lint ratchet; split it when next changed"
259)]
260pub fn serve(client: Client, cfg: ServeConfig) -> Result<()> {
261 client
262 .server_info()
263 .map_err(|e| Error::invalid(format!("isb serve needs incusd: {e}")))?;
264 default_org::warn_old_incus(&client);
265 let store = Store::open(&cfg.state_dir)?;
266 dns::open_dns_path(&cfg.state_dir);
267 if cfg.agent.is_none() {
270 default_org::ensure(&client, &store);
271 }
272 let secrets_config = crate::secrets::SecretsConfig::load(&cfg.secrets_config)?;
273 let opened = crate::secrets::Secrets::open(&cfg.state_dir, &cfg.keys, &secrets_config)?;
274 for n in &opened.notes {
275 eprintln!("isb serve: {n}");
276 }
277 let secrets = Arc::new(with_external_drivers(opened.secrets, &cfg.state_dir)?);
278 for r in crate::stack::migrate::run(&store, &secrets, Some(&client)) {
281 match r {
282 Ok(m) => eprintln!("isb serve: {m}"),
283 Err(e) => eprintln!("isb serve: WARNING: {e}"),
284 }
285 }
286 let db = crate::auth::db_path(&cfg.state_dir);
289 let users = Arc::new(
290 AuthStore::open_with(&db, cfg.auth.clone())
291 .map_err(|e| Error::invalid(format!("open {}: {e}", db.display())))?,
292 );
293 let audit_db = crate::audit::db_path(&cfg.state_dir);
294 let audit_log = Arc::new(
295 crate::audit::AuditLog::open(&audit_db, cfg.audit_retention)?
296 .with_history_limits(cfg.history_retention, cfg.history_max_rows),
297 );
298 let recorder = crate::history::Recorder::start(audit_log.clone());
301 let stop_history = Arc::new(std::sync::atomic::AtomicBool::new(false));
302 history_start(&audit_log, &recorder, &client, &stop_history);
303 eprintln!(
304 "isb serve: audit log {} (kept {} days{})",
305 audit_db.display(),
306 cfg.audit_retention.as_secs() / 86400,
307 if cfg.audit_all {
308 ", reads included"
309 } else {
310 ""
311 }
312 );
313 if cfg.agent.is_some() && !cfg.listen.is_empty() {
314 return Err(Error::invalid(
315 "--agent serves its control plane only: drop --listen (users reach the control plane)",
316 ));
317 }
318 let access = match &cfg.access {
319 Some((team, aud)) => Some(Arc::new(AccessValidator::new(team, aud)?)),
320 None => None,
321 };
322 let gate = Arc::new(superadmin::gate(&cfg, users.clone(), access.clone())?);
323 let auth = if cfg.listen.is_empty() {
324 None
325 } else {
326 Some(auth_routes(
327 &cfg,
328 users.clone(),
329 &secrets,
330 &audit_log,
331 gate.clone(),
332 )?)
333 };
334 let servers = match &cfg.agent {
335 None => Some(crate::servers::Servers::open(&cfg.state_dir)?),
336 Some(_) => None,
337 };
338 match crate::registry::Registry::open(&client, Some(&cfg.state_dir)) {
341 Ok(Some(r)) => {
342 eprintln!("isb serve: local registry {}", r.info().url());
343 crate::registry::install(Arc::new(r));
344 }
345 Ok(None) => {}
346 Err(e) => eprintln!("isb serve: WARNING: local registry: {e}"),
347 }
348 let ingress = match &cfg.ingress {
351 Some(ic) => Some(crate::ingress::Manager::new(
352 ic.clone(),
353 client.clone(),
354 secrets.clone(),
355 &cfg.state_dir,
356 )?),
357 None => None,
358 };
359 let observer = ingress
360 .clone()
361 .map(|m| m as Arc<dyn crate::stack::controller::Observer>);
362 let ctl = Controller::start_with(
363 client.clone(),
364 store,
365 cfg.interval,
366 secrets.clone(),
367 observer,
368 )?;
369 if let Some(m) = &ingress {
370 m.start(ctl.clone())?;
371 }
372 let apps = crate::app::Apps::new(&cfg.state_dir, client.clone(), ctl.clone(), secrets.clone());
373 let ra = apps.clone();
375 let resolve: crate::notify::Resolve = Arc::new(move |org, stack, service| {
376 let a = ra.get(org, service).ok()?;
377 let own = a.spec.stack().ok()? == stack;
379 let preview = stack.starts_with(&format!("{}-", a.spec.project))
380 && crate::app::preview::is_pr_suffix(stack);
381 (own || preview).then_some(a.spec.project)
382 });
383 let notifier = crate::notify::Notifier::new(&cfg.state_dir, secrets.clone(), resolve)?;
384 notifier.start(ctl.clone());
385 let monitors = monitors::start(&cfg, &apps, &secrets, ¬ifier);
386 ctl.set_event_sink(recorder.controller_sink());
388 let history = crate::metrics_history::History::new(&cfg.state_dir);
390 ctl.set_metrics_sink(history.start());
391 apps.start_preview_upkeep();
393 let jobs = crate::jobs::Jobs::new(&cfg.state_dir, apps.clone());
395 let backups = crate::backup::Backups::new(&cfg.state_dir, apps.clone());
396 let volumes =
397 crate::volume_backup::VolumeBackups::new(&cfg.state_dir, apps.clone(), backups.clone());
398 let scheduler = crate::jobs::Scheduler::start(vec![
399 Arc::new(jobs.clone()) as Arc<dyn crate::jobs::Scheduled>,
400 Arc::new(backups.clone()),
401 Arc::new(volumes.clone()),
402 ]);
403 jobs.set_scheduler(scheduler.clone());
404 backups.set_scheduler(scheduler.clone());
405 volumes.set_scheduler(scheduler.clone());
406 if let Some(ic) = &cfg.ingress {
407 if ic.tunnel_port == cfg.workspace_mcp_port {
408 return Err(Error::invalid(format!(
409 "--workspace-mcp-port {} is the ingress's tunnel port; pick another",
410 cfg.workspace_mcp_port
411 )));
412 }
413 }
414 let workspaces = workspaces::Workspaces::new(
415 &cfg.state_dir,
416 client.clone(),
417 secrets.clone(),
418 recorder.clone(),
419 cfg.workspace_mcp_port,
420 cfg.workspace_pool.clone(),
421 cfg.workspace_home_root.clone(),
422 );
423 workspaces.previews.set_base(cfg.preview_domain.clone());
424 let (egress, stop_egress) = egress::start(&cfg, &client, &secrets)?;
425 let d = Arc::new(Daemon {
426 client,
427 ctl: ctl.clone(),
428 policy: cfg.policy.clone(),
429 state_dir: cfg.state_dir.clone(),
430 secrets,
431 apps: apps.clone(),
432 ingress: ingress.clone(),
433 users: users.clone(),
434 notifier: notifier.clone(),
435 monitors: monitors.clone(),
436 history,
437 data: data::Ctx {
438 apps: apps.clone(),
439 jobs,
440 backups,
441 },
442 volumes,
443 audit: audit_log.clone(),
444 servers: servers.clone(),
445 gate: gate.clone(),
446 host: superadmin::host_summary(&cfg, &gate),
447 catalogs: Arc::new(crate::template::catalog::Catalogs::new(&cfg.state_dir)),
448 workspaces: workspaces.clone(),
449 public_url: cfg.public_url.clone(),
450 egress,
451 });
452 if let Some(s) = &servers {
453 s.start(ctl.clone());
454 }
455 let registry = registry(d.clone())?;
456 let mut hooks = hooks(d.clone(), users.clone(), cfg.allow_unauthenticated);
457 superadmin::announce(&cfg, &gate, &users);
458 hooks.audit = Some(audit::hook(audit_log.clone(), cfg.audit_all));
459 if servers.is_some() {
460 hooks.route = Some(servers::route(d.clone()));
461 }
462 let webhooks = {
465 let w = apps::webhook_routes(apps.clone());
466 let w = match &servers {
467 Some(s) => servers::forward_webhooks(w, s.clone()),
468 None => w,
469 };
470 audit::audited_webhooks(w, audit_log.clone())
471 };
472 let auth = auth.map(|a| -> crate::server::Routes {
474 let logo = templates::logo::route(
475 d.catalogs.clone(),
476 Arc::new(templates::logo::Logos::new(&cfg.state_dir)),
477 templates::logo::admit(
478 hooks.authn.clone().expect("the daemon authenticates"),
479 access.clone(),
480 cfg.allow_unauthenticated,
481 ),
482 );
483 Arc::new(move |r| logo(r).or_else(|| a(r)))
484 });
485 let mut listeners = vec![Listener::unix(&cfg.socket).hooks(hooks.clone())];
486 if let Some(ac) = &cfg.agent {
487 listeners.push(servers::agent_listener(
488 d.clone(),
489 &hooks,
490 ac,
491 webhooks.clone(),
492 )?);
493 }
494 for addr in &cfg.listen {
495 let tailnet = superadmin::is_tailnet_listen(addr);
496 let mut l = Listener::tcp(addr.clone())
497 .policy(cfg.remote_tools.clone())
498 .hooks(hooks.clone())
499 .public_routes(webhooks.clone())
500 .preview(workspaces::preview_route(d.clone()))
501 .tailnet(tailnet);
502 if let Some(r) = &auth {
503 l = l.routes(r.clone());
504 }
505 listeners.push(match &access {
506 Some(v) if !tailnet => l.access_shared(v.clone()),
508 _ => l.allow_unauthenticated(true),
512 });
513 }
514 let hd = d.clone();
515 let healthz: crate::server::Healthz = Arc::new(move || {
516 let stacks: Vec<Value> = hd
517 .ctl
518 .list()
519 .into_iter()
520 .map(|s| json!({"name": s.name, "converged": s.converged}))
521 .collect();
522 (
523 true,
524 json!({"ok": true, "isb": env!("CARGO_PKG_VERSION"), "stacks": stacks}),
525 )
526 });
527 let registry = Arc::new(registry);
531 workspaces.set_serving(
532 Listener::tcp("org-bridge")
533 .policy(cfg.remote_tools.clone())
534 .hooks(hooks.clone())
535 .allow_unauthenticated(true),
536 registry.clone(),
537 healthz.clone(),
538 );
539 let local: workspaces::LocalOrg = {
540 let d = d.clone();
541 Arc::new(move |o: &crate::org::OrgId| d.remote(o).is_none())
542 };
543 workspaces.start(ctl.clone(), local);
544 workspaces::start_ports(d.clone());
545 let r = crate::server::serve_shared(listeners, registry, healthz);
546 workspaces.shutdown();
547 stop_egress.store(true, std::sync::atomic::Ordering::Relaxed);
548 stop_history.store(true, std::sync::atomic::Ordering::Relaxed);
549 recorder.record(crate::history::marker(
550 "serve.stopped",
551 "isb serve stopped: incus events from now on are not observed".into(),
552 json!({"version": env!("CARGO_PKG_VERSION")}),
553 ));
554 recorder.shutdown();
555 if let Some(s) = &servers {
556 s.shutdown();
557 }
558 notifier.shutdown();
559 monitors.shutdown();
560 scheduler.shutdown();
561 ctl.shutdown();
562 if let Some(m) = &ingress {
563 m.shutdown();
564 }
565 r
566}
567
568fn history_start(
571 log: &Arc<crate::audit::AuditLog>,
572 rec: &Arc<crate::history::Recorder>,
573 client: &Client,
574 stop: &Arc<std::sync::atomic::AtomicBool>,
575) {
576 use crate::history::{HistoryQuery, marker};
577 let now = crate::audit::now_ms();
578 let last = log
579 .history_list(
580 &HistoryQuery::default(),
581 &crate::audit::Visibility::All,
582 None,
583 None,
584 1,
585 )
586 .ok()
587 .and_then(|v| v.into_iter().next());
588 if let Some(l) = last {
589 let clean = l.kind == "serve.stopped";
590 let reason = if clean {
591 "isb serve was not running"
592 } else {
593 "isb serve was not running (it did not stop cleanly)"
594 };
595 rec.record(marker(
596 "incus.gap",
597 format!(
598 "incus events between {} and {} were not observed: {reason}",
599 crate::history::fmt_ms(l.time),
600 crate::history::fmt_ms(now),
601 ),
602 json!({"from": l.time, "to": now, "reason": reason}),
603 ));
604 }
605 rec.record(marker(
606 "serve.started",
607 format!("isb serve {} started", env!("CARGO_PKG_VERSION")),
608 json!({"version": env!("CARGO_PKG_VERSION"), "pid": std::process::id()}),
609 ));
610 let (c, r, s) = (client.clone(), rec.clone(), stop.clone());
611 let _ = std::thread::Builder::new()
612 .name("isb-incus-events".into())
613 .spawn(move || crate::history::watch_incus(c, r, s));
614}
615
616fn with_external_drivers(
618 secrets: crate::secrets::Secrets,
619 state_dir: &std::path::Path,
620) -> Result<crate::secrets::Secrets> {
621 use crate::secrets::{Driver, local::LocalDriver, onepassword};
622 let local = Arc::new(LocalDriver::new(state_dir, secrets.keyring().clone()));
623 let token: onepassword::TokenSource =
624 Arc::new(move |org| match local.get(org, onepassword::TOKEN_SECRET) {
625 Ok((v, _)) => Ok(Some(
626 String::from_utf8(v)
627 .map_err(|_| Error::invalid("the 1Password token is not text"))?
628 .trim()
629 .to_string(),
630 )),
631 Err(e) if e.is_not_found() => Ok(None),
632 Err(e) => Err(e),
633 });
634 secrets.with_driver(Arc::new(onepassword::OnePasswordDriver::new(token)))
635}
636
637fn visible_orgs(c: &Caller) -> Option<Vec<crate::org::OrgId>> {
639 match c.principal() {
640 Some(p) if !p.platform_admin => Some(p.orgs.iter().map(|(o, _)| o.clone()).collect()),
641 _ => None,
642 }
643}
644
645fn hooks(d: Arc<Daemon>, users: Arc<AuthStore>, allow_anonymous: bool) -> crate::server::Hooks {
647 use crate::server::Authenticated;
648 let term = terminal::terminal(d.clone());
649 let ssh = ssh::ssh(d.clone(), users.clone());
650 let u = users.clone();
651 let gate = d.gate.clone();
652 let wsa = d.workspaces.clone();
653 let authn: crate::server::mcp::Authn = Arc::new(move |req, id| {
654 match gate.resolve(req, id) {
655 superadmin::Resolved::Superadmin(s) => return Authenticated::Superadmin(s),
656 superadmin::Resolved::Refused => return Authenticated::Refused,
657 superadmin::Resolved::None => {}
658 }
659 if let Some(t) = bearer(req) {
661 if t.starts_with(crate::auth::secret::TokenKind::Workspace.prefix()) {
662 return match wsa.authenticate(t) {
663 Some(p) => Authenticated::User(Arc::new(p)),
664 None => Authenticated::Refused,
665 };
666 }
667 }
668 if req.header("authorization").is_some()
669 || req
670 .header("cookie")
671 .is_some_and(|c| c.contains("isb_session="))
672 {
673 return match u.principal_from_request(req) {
674 Some(p) => Authenticated::User(Arc::new(p)),
675 None => Authenticated::Refused,
676 };
677 }
678 if let Some(email) = id.and_then(|i| i.email.as_deref()) {
680 if let Ok(Some(p)) = u.principal_for_email(email) {
681 return Authenticated::User(Arc::new(p));
682 }
683 }
684 if let Some(p) = gate.agent(req, id) {
686 return Authenticated::User(Arc::new(p));
687 }
688 Authenticated::None
689 });
690 let authorize: crate::server::mcp::Authorize = Arc::new(move |c, tool, args, scope| {
691 let args = crate::server::aliases::alias_args(tool, args);
693 authorize_class(
694 c,
695 &tool.name,
696 audit::class_for(tool, &args),
697 args,
698 scope,
699 allow_anonymous,
700 )
701 });
702 let events: crate::server::mcp::Events = Arc::new(move |c, since| {
703 if let (Caller::Unauthenticated { .. }, false) = (c, allow_anonymous) {
704 return Err(Error::Forbidden("sign in to follow events".into()));
705 }
706 if let Caller::Access(id) = c {
707 return Err(Error::Forbidden(format!(
708 "{} has no isb account",
709 id.name()
710 )));
711 }
712 let orgs = visible_orgs(c);
713 let ctl = d.ctl.clone();
714 Ok(Box::new(move |w: &mut dyn std::io::Write| {
715 let mut since = since;
716 loop {
717 let (seq, evs) = ctl.wait_events(since, 200, Duration::from_secs(15));
718 let mut wrote = false;
719 for e in evs {
720 if !event_visible(&orgs, &e.stack) {
721 continue;
722 }
723 let data = serde_json::to_string(&e).unwrap_or_default();
724 write!(w, "id: {}\nevent: {}\ndata: {data}\n\n", e.seq, e.level)?;
725 wrote = true;
726 }
727 if !wrote {
728 w.write_all(b": keepalive\n\n")?;
730 }
731 w.flush()?;
732 since = seq.max(since);
733 }
734 }))
735 });
736 crate::server::Hooks {
737 authn: Some(authn),
738 authorize: Some(authorize),
739 events: Some(events),
740 terminal: Some(term),
741 ssh: Some(ssh),
742 audit: None,
743 route: None,
744 listed: Some(Arc::new(tool_listed)),
745 refuse_anonymous: !allow_anonymous,
746 }
747}
748
749fn bearer(req: &crate::server::http::Request) -> Option<&str> {
751 let (scheme, token) = req.header("authorization")?.trim().split_once(' ')?;
752 scheme.eq_ignore_ascii_case("bearer").then(|| token.trim())
753}
754
755fn event_visible(orgs: &Option<Vec<crate::org::OrgId>>, stack: &str) -> bool {
757 let Some(orgs) = orgs else { return true };
758 let org = stack
759 .split_once('/')
760 .map(|(o, _)| o)
761 .unwrap_or(crate::org::DEFAULT_ORG);
762 orgs.iter().any(|o| o.as_str() == org)
763}
764
765fn args<T: DeserializeOwned>(v: Value) -> Result<T> {
766 serde_json::from_value(v).map_err(|e| Error::invalid(format!("bad arguments: {e}")))
767}
768
769fn obj(mut props: Value, required: &[&str]) -> Value {
770 props["org"] =
772 json!({"type": "string", "description": "The org to act in (default: default)."});
773 json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
774}
775
776fn qname(org: &Option<String>, name: &str) -> Result<String> {
778 let org = match org {
779 Some(o) => crate::org::OrgId::new(o.clone())?,
780 None => crate::org::OrgId::default_org(),
781 };
782 Ok(crate::stack::qualified(&org, name))
783}
784
785fn caller_name(c: &Caller) -> String {
786 c.to_string()
787}
788
789fn registry(d: Arc<Daemon>) -> Result<Registry> {
791 let mut r = Registry::new().instructions(INSTRUCTIONS);
792 superadmin::register(&mut r, d.clone())?;
793 let ann = Ann {
794 ro: json!({"readOnlyHint": true, "openWorldHint": false}),
795 destructive: json!({"destructiveHint": true, "openWorldHint": false}),
796 write: json!({"destructiveHint": false, "openWorldHint": false}),
797 };
798
799 tools::stack_deploy_tool(&mut r, &d, &ann)?;
800 tools::overview_tool(&mut r, &d, &ann)?;
801 tools::events_tool(&mut r, &d, &ann)?;
802 tools::ingress_status_tool(&mut r, &d, &ann)?;
803 tools::stack_list_tool(&mut r, &d, &ann)?;
804 tools::stack_status_tool(&mut r, &d, &ann)?;
805 tools::stack_config_tool(&mut r, &d, &ann)?;
806 tools::stack_logs_tool(&mut r, &d, &ann)?;
807 tools::stack_scale_tool(&mut r, &d, &ann)?;
808 tools::stack_edit_tools(&mut r, &d, &ann)?;
809 tools::sandbox_create_tool(&mut r, &d, &ann)?;
810 let ctl = d.ctl.clone();
811 let bindings: secrets::Bindings = Arc::new(move |org: &crate::org::OrgId| {
812 let mut out = Vec::new();
813 for def in ctl.definitions().iter().filter(|def| def.org == *org) {
814 let used = crate::stack::secrets::used_keys(&def.file);
815 for (_, b) in def.secrets.iter().filter(|(k, _)| used.contains(*k)) {
816 out.push(secrets::Binding {
817 name: b.name.clone(),
818 driver: b.driver.clone(),
819 version: b.version,
820 stack: def.name.clone(),
821 });
822 }
823 }
824 out
825 });
826 let ctl = d.ctl.clone();
827 let in_use: secrets::InUse = Arc::new(move |org: &crate::org::OrgId, name: &str| {
828 ctl.definitions()
829 .iter()
830 .filter(|def| def.org == *org && def.store_secrets().contains(name))
831 .map(|def| def.name.clone())
832 .collect()
833 });
834 let ctl = d.ctl.clone();
835 let changed: secrets::Changed =
836 Arc::new(move |org: &crate::org::OrgId, name: &str| ctl.secret_changed(org, name));
837 let ctl = d.ctl.clone();
838 let refresh: secrets::Refresh =
839 Arc::new(move |org: &crate::org::OrgId, name: &str| ctl.refresh_secret(org, name));
840 secrets::register(
841 &mut r,
842 d.secrets.clone(),
843 secrets::Hooks {
844 in_use,
845 changed,
846 refresh,
847 bindings,
848 },
849 )?;
850 builds::register(
851 &mut r,
852 builds::Ctx {
853 client: d.client.clone(),
854 policy: d.policy.clone(),
855 ctl: d.ctl.clone(),
856 },
857 )?;
858 tools::sandbox_tools(&mut r, &d, &ann)?;
859 apps::register(&mut r, d.apps.clone(), d.ingress.is_some())?;
860 previews::register(&mut r, d.apps.clone())?;
861 let mut t = templates::Templates::new(
862 &d.state_dir,
863 d.apps.clone(),
864 d.secrets.clone(),
865 d.ingress.as_ref().and_then(|m| m.public_ip()),
866 );
867 t.catalogs = d.catalogs.clone();
868 templates::register(&mut r, t)?;
869 data::register(&mut r, d.data.clone())?;
870 volumes::register(&mut r, d.volumes.clone())?;
871 tools::server_status_tool(&mut r, &d, &ann)?;
872 orgs::register(&mut r, d.clone())?;
873 notify::register(
874 &mut r,
875 d.notifier.clone(),
876 d.history.clone(),
877 d.apps.clone(),
878 )?;
879 monitors::register(&mut r, d.monitors.clone())?;
880 audit::register(&mut r, d.audit.clone())?;
881 audit::register_history(&mut r, d.audit.clone())?;
882 servers::register(&mut r, d.clone())?;
883 ssh::register(&mut r, d.clone())?;
884 workspaces::register(&mut r, d.clone())?;
885 accounts::register(&mut r, d.clone())?;
886 Ok(r)
887}
888
889const INSTRUCTIONS: &str = "isb runs incus containers and VMs on this host. Two uses: \
890stacks (long-running services from a docker-compose-style file, with replicas, health checks, \
891rolling updates and a load balancer: stack_deploy, then stack_status) and sandboxes \
892(an isolated machine to run code in: sandbox_create, sandbox_exec, sandbox_remove). \
893Images: local incus aliases (dev-base), images:debian/12, OCI images (docker:nginx:1.27, ghcr:org/app:tag), \
894or the org's own builds in the local registry (registry:APP:TAG; build_run makes them, registry_list lists them). \
895Deploys return immediately; poll stack_status, or pass wait=true. \
896Each org also has a secret store (secret_create, secret_set, secret_list; values are base64). \
897Apps (Dokploy-style): project_create, then app_create (an image, or a repository with a builder), \
898app_env_set, app_deploy (or app_apply: a YAML definition that creates or updates, dry_run to diff first); each project environment runs as one stack <project>-<env>. \
899One-click apps: template_list, template_get, then template_deploy (dry_run first shows the plan). \
900Databases are apps too (database_create; connection details via database_get), backed up to S3-compatible \
901destinations on a cron schedule (backup_destination_create, backup_create, backup_run, backup_restore). \
902Scheduled jobs run commands against an app on a cron schedule (job_create, job_runs, job_run_log).";
903
904impl Daemon {
905 fn reachable(&self, c: &Caller, i: &SandboxInfo) -> bool {
908 c.is_trusted()
911 || c.principal().is_some()
912 || self.policy.any_instance
913 || i.config.contains_key("user.isb.stack")
914 || i.config.contains_key(&format!("user.{LABEL_OWNER}"))
915 }
916
917 fn oc(&self, org: &Option<String>) -> Result<Client> {
920 let org = crate::org::OrgId::new(org.as_deref().unwrap_or(crate::org::DEFAULT_ORG))?;
921 crate::org::check_exists(&self.client, &org)?;
922 Ok(crate::org::client(&self.client, &org))
923 }
924
925 fn reach(&self, c: &Caller, oc: &Client, name: &str) -> Result<SandboxInfo> {
926 let info = Sandbox::get(oc, name)?.info()?;
927 if !self.reachable(c, &info) {
928 return Err(Error::NotFound(format!("sandbox {name}")));
931 }
932 Ok(info)
933 }
934
935 fn workspaces_def(
938 &self,
939 org: &crate::org::OrgId,
940 name: &str,
941 ) -> Result<crate::workspace::Workspace> {
942 crate::workspace::Store::new(&self.state_dir)
943 .get(org, name)?
944 .ok_or_else(|| Error::NotFound(format!("org {org} has no workspace {name}")))
945 }
946
947 fn files_dir(&self, stack: &str) -> Result<PathBuf> {
948 let p = self.state_dir.join("files").join(stack);
949 std::fs::create_dir_all(&p)?;
950 Ok(p)
951 }
952}
953
954#[derive(Deserialize)]
955#[serde(deny_unknown_fields)]
956struct DeployArgs {
957 name: String,
958 #[serde(default)]
959 org: Option<String>,
960 #[serde(default)]
962 compose: Option<String>,
963 #[serde(default)]
965 file: Option<ComposeFile>,
966 #[serde(default)]
967 vars: BTreeMap<String, String>,
968 #[serde(default)]
969 secrets: BTreeMap<String, String>,
970 #[serde(default)]
971 base_dir: Option<PathBuf>,
972 #[serde(default)]
973 wait: bool,
974 #[serde(default)]
975 dry_run: bool,
976 #[serde(default)]
977 timeout: Option<String>,
978}
979
980#[expect(
981 clippy::too_many_lines,
982 reason = "predates the lint ratchet; split it when next changed"
983)]
984fn stack_deploy(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
985 let a: DeployArgs = args(a)?;
986 crate::stack::validate_stack_name(&a.name)?;
987 if a.name == crate::ingress::cloudflare::TUNNEL_STACK {
988 return Err(Error::invalid(format!(
989 "stack name {} is isb's (an org's cloudflared)",
990 a.name
991 )));
992 }
993 let base = match &a.base_dir {
994 Some(b) => {
995 if !b.is_absolute() {
996 return Err(Error::invalid("base_dir must be absolute"));
997 }
998 if !c.is_trusted() {
999 d.policy.check_base_dir(b)?;
1000 }
1001 b.clone()
1002 }
1003 None => d.files_dir(&a.name)?,
1004 };
1005 let file = match (a.file, a.compose) {
1006 (Some(_), _) if !c.is_trusted() => {
1007 return Err(Error::invalid("remote callers send `compose` as YAML text"));
1008 }
1009 (Some(f), None) => f,
1010 (None, Some(text)) => {
1011 let vars = a.vars.clone();
1013 let lookup = move |k: &str| vars.get(k).cloned();
1014 crate::compose::load_docs(
1015 &[(PathBuf::from("compose.yaml"), text)],
1016 &base,
1017 Some(&a.name),
1018 &lookup,
1019 )?
1020 .file
1021 }
1022 _ => return Err(Error::invalid("pass exactly one of compose or file")),
1023 };
1024 if !c.is_trusted() {
1025 d.policy.check_file(&file, &base)?;
1026 }
1027 let org = match &a.org {
1028 Some(o) => crate::org::OrgId::new(o.clone())?,
1029 None => crate::org::OrgId::default_org(),
1030 };
1031 let mut given: BTreeMap<String, Vec<u8>> = BTreeMap::new();
1034 for key in crate::stack::secrets::used_keys(&file) {
1035 let Some(def) = file.secrets.get(&key) else {
1036 continue;
1037 };
1038 if !def.is_client_side() {
1039 continue;
1040 }
1041 let v = a.secrets.get(&key).cloned().or_else(|| {
1042 def.environment
1043 .as_ref()
1044 .and_then(|e| a.vars.get(e).cloned())
1045 });
1046 if let Some(v) = v {
1047 given.insert(key, v.into_bytes());
1048 }
1049 }
1050 let mut def = StackDef {
1051 name: a.name.clone(),
1052 org: org.clone(),
1053 file,
1054 base_dir: base,
1055 secrets: BTreeMap::new(),
1056 force: BTreeMap::new(),
1057 images: BTreeMap::new(),
1058 deployed_at: now_secs(),
1059 deployed_by: caller_name(c),
1060 previous: None,
1061 };
1062 d.ctl.validate(&def)?;
1064 if let Some(m) = &d.ingress {
1065 m.check(&def)?;
1066 }
1067 def.secrets =
1068 crate::stack::secrets::bind(&d.secrets, &org, &a.name, &def.file, &given, a.dry_run)?;
1069 if a.dry_run {
1070 return Ok(json!({"changes": d.ctl.plan(&def)?, "dry_run": true}));
1071 }
1072 let who = def.deployed_by.clone();
1073 let changes = d.ctl.deploy(def)?;
1074 let summary: Vec<String> = changes
1075 .iter()
1076 .filter(|c| c.change != "unchanged")
1077 .map(|c| format!("{} {}", c.service, c.change))
1078 .collect();
1079 d.ctl.note(
1080 "info",
1081 &crate::stack::qualified(&org, &a.name),
1082 format!(
1083 "deployed by {who}: {}",
1084 if summary.is_empty() {
1085 "no changes".to_string()
1086 } else {
1087 summary.join(", ")
1088 }
1089 ),
1090 );
1091 if !a.wait {
1092 return Ok(json!({"changes": changes}));
1093 }
1094 let timeout = match &a.timeout {
1095 Some(t) => crate::flex::parse_duration(t).map_err(Error::invalid)?,
1096 None => Duration::from_secs(600),
1097 };
1098 let st = wait_settled(&d.ctl, &crate::stack::qualified(&org, &a.name), timeout)?;
1099 Ok(json!({"changes": changes, "status": st}))
1100}
1101
1102pub fn wait_settled(
1105 ctl: &Controller,
1106 name: &str,
1107 timeout: Duration,
1108) -> Result<crate::stack::controller::StackStatus> {
1109 let started = Instant::now();
1110 let def = ctl.definition(name)?;
1111 loop {
1112 let st = ctl.status(name)?;
1113 let settled = st.services.iter().all(|s| {
1116 let current = def.revision(&s.service).is_ok_and(|r| r == s.rev)
1117 && def
1118 .service(&s.service)
1119 .is_ok_and(|d| d.replicas() == s.replicas);
1120 current && matches!(s.state.as_str(), "converged" | "paused" | "failing")
1121 });
1122 if settled || started.elapsed() >= timeout {
1123 return Ok(st);
1124 }
1125 std::thread::sleep(Duration::from_secs(1));
1126 }
1127}
1128
1129#[derive(Deserialize)]
1130#[serde(untagged)]
1131enum SpecArg {
1132 Text(String),
1133 Object(Box<SandboxSpec>),
1134}
1135
1136#[expect(
1137 clippy::too_many_lines,
1138 reason = "predates the lint ratchet; split it when next changed"
1139)]
1140fn sandbox_create(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1141 #[derive(Deserialize)]
1142 struct A {
1143 spec: Value,
1144 #[serde(default)]
1145 wait_ready: Option<bool>,
1146 #[serde(default)]
1147 expires: Option<String>,
1148 #[serde(default)]
1149 idle_timeout: Option<String>,
1150 #[serde(default)]
1151 org: Option<String>,
1152 }
1153 let org = arg_org(&a)?;
1154 let a: A = args(a)?;
1155 let mut spec = match serde_json::from_value::<SpecArg>(a.spec)
1156 .map_err(|e| Error::invalid(format!("spec: {e}")))?
1157 {
1158 SpecArg::Text(t) => serde_yaml_ng::from_str::<SandboxSpec>(&t)
1159 .map_err(|e| Error::invalid(format!("spec: {e}")))?,
1160 SpecArg::Object(s) => *s,
1161 };
1162 let name = spec
1163 .name
1164 .clone()
1165 .ok_or_else(|| Error::invalid("spec needs container_name"))?;
1166 egress::check_secrets(&d.secrets, &org, &spec)?;
1167 let base = if c.is_local() {
1168 std::env::current_dir()?
1169 } else {
1170 d.files_dir("_sandboxes")?
1171 };
1172 if let Caller::Superadmin(s) = c {
1173 spec.labels.insert(LABEL_OWNER.into(), s.label());
1175 }
1176 if !c.is_trusted() {
1177 d.policy.check_spec(&spec, &base)?;
1178 if let Ok(sb) = Sandbox::get(&d.oc(&a.org)?, &name) {
1179 if !d.reachable(c, &sb.info()?) {
1181 return Err(Error::AlreadyExists(name));
1182 }
1183 }
1184 spec.labels.insert(LABEL_OWNER.into(), owner_label(c));
1185 }
1186 if let Ok(sb) = Sandbox::get(&d.oc(&a.org)?, &name) {
1187 let info = sb.info()?;
1188 if info.config.contains_key(crate::workspace::KEY_WORKSPACE) {
1190 return Err(Error::invalid(format!(
1191 "{name} is the org's workspace; pick another name"
1192 )));
1193 }
1194 }
1195 if !spec
1198 .raw_devices
1199 .get("root")
1200 .is_some_and(|r| r.contains_key("size"))
1201 && workspaces::project_has_disk_limit(&d.client, &org)
1202 {
1203 spec.raw_devices
1204 .entry("root".into())
1205 .or_default()
1206 .insert("size".into(), workspaces::SANDBOX_ROOT_SIZE.into());
1207 }
1208 let settings = d.workspaces.settings(&org)?;
1210 let (expires_at, idle) = crate::workspace::sandbox_deadlines(
1211 &settings,
1212 a.expires.as_deref(),
1213 a.idle_timeout.as_deref(),
1214 now_secs(),
1215 )?;
1216 spec.labels
1217 .insert("isb.expires_at".into(), expires_at.to_string());
1218 match idle {
1219 Some(s) => {
1220 spec.labels.insert("isb.idle_timeout".into(), s.to_string());
1221 }
1222 None => {
1223 spec.labels.insert("isb.idle_timeout".into(), "0".into());
1224 }
1225 }
1226 let opts = EnsureOptions {
1227 wait_ready: a.wait_ready.unwrap_or(true),
1228 ..Default::default()
1229 };
1230 let mut log: Vec<String> = Vec::new();
1231 let (sb, report) = Sandbox::connect_or_create_with_base(
1232 &d.oc(&a.org)?,
1233 &spec,
1234 &Default::default(),
1235 &base,
1236 opts,
1237 &mut |m| log.push(m.to_string()),
1238 )?;
1239 d.workspaces.mark_active(&org.incus_project(), &name);
1240 d.egress.kick();
1241 Ok(json!({
1242 "info": sb.info()?,
1243 "report": report,
1244 "log": log,
1245 "expires_at": expires_at,
1246 "idle_timeout": idle,
1247 "message": format!(
1248 "{name} expires {} from now{}; sandbox_extend pushes it out.",
1249 crate::workspace::human(expires_at.saturating_sub(now_secs())),
1250 match idle {
1251 Some(s) => format!(" and is deleted after {} idle", crate::workspace::human(s)),
1252 None => String::new(),
1253 }
1254 ),
1255 }))
1256}
1257
1258fn owner_label(c: &Caller) -> String {
1260 match c {
1261 Caller::Superadmin(s) => s.label(),
1262 Caller::User { principal } if principal.is_workspace() => {
1263 crate::auth::WORKSPACE_ACTOR.to_string()
1264 }
1265 _ => format!("mcp:{}", caller_name(c)),
1266 }
1267}
1268
1269fn sandbox_extend(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1270 #[derive(Deserialize)]
1271 #[serde(deny_unknown_fields)]
1272 struct A {
1273 name: String,
1274 #[serde(default)]
1275 by: Option<String>,
1276 #[serde(default)]
1277 idle_timeout: Option<String>,
1278 #[serde(default)]
1279 org: Option<String>,
1280 }
1281 let org = arg_org(&a)?;
1282 let a: A = args(a)?;
1283 let oc = d.oc(&a.org)?;
1284 let info = d.reach(c, &oc, &a.name)?;
1285 let labels: BTreeMap<String, String> = info
1286 .config
1287 .iter()
1288 .filter_map(|(k, v)| k.strip_prefix("user.").map(|k| (k.to_string(), v.clone())))
1289 .collect();
1290 if crate::workspace::kind_of(&labels) != "sandbox" {
1291 return Err(Error::invalid(format!(
1292 "{} is a {}, not a sandbox: it does not expire",
1293 a.name,
1294 crate::workspace::kind_of(&labels)
1295 )));
1296 }
1297 let mine = labels
1299 .get("isb.owner")
1300 .is_some_and(|o| *o == owner_label(c));
1301 let admin = match c {
1302 Caller::Local { .. } | Caller::Superadmin(_) => true,
1303 Caller::User { principal } => {
1304 principal.platform_admin
1305 || principal
1306 .role_in(&org)
1307 .is_some_and(|r| r >= crate::auth::Role::Admin)
1308 }
1309 _ => false,
1310 };
1311 if !mine && !admin {
1312 return Err(Error::Forbidden(format!(
1313 "{} was created by {}; its creator or the org's admins extend it",
1314 a.name,
1315 labels
1316 .get("isb.owner")
1317 .map(String::as_str)
1318 .unwrap_or("someone else")
1319 )));
1320 }
1321 let now = now_secs();
1322 let mut patch = serde_json::Map::new();
1323 let current = labels
1324 .get("isb.expires_at")
1325 .and_then(|v| v.parse::<u64>().ok());
1326 let by = match &a.by {
1327 Some(b) => crate::flex::parse_duration(b).map_err(Error::invalid)?,
1328 None if a.idle_timeout.is_some() => Duration::ZERO,
1329 None => Duration::from_secs(86400),
1330 };
1331 let mut expires_at = current;
1332 if !by.is_zero() {
1333 let e = crate::workspace::extended(current, by, now)?;
1334 patch.insert(
1335 crate::workspace::KEY_EXPIRES_AT.into(),
1336 json!(e.to_string()),
1337 );
1338 expires_at = Some(e);
1339 }
1340 let mut idle = labels
1341 .get("isb.idle_timeout")
1342 .and_then(|v| v.parse::<u64>().ok())
1343 .filter(|s| *s > 0);
1344 if let Some(t) = &a.idle_timeout {
1345 idle = crate::workspace::idle(t)?.map(|d| d.as_secs());
1346 patch.insert(
1347 crate::workspace::KEY_IDLE_TIMEOUT.into(),
1348 json!(idle.unwrap_or(0).to_string()),
1349 );
1350 }
1351 oc.mutate(
1352 "PATCH",
1353 &format!("/1.0/instances/{}", crate::client::encode_segment(&a.name)),
1354 Some(&json!({"config": patch})),
1355 &format!("extend sandbox {}", a.name),
1356 oc.timeouts.other,
1357 )?;
1358 d.workspaces.mark_active(&org.incus_project(), &a.name);
1359 Ok(json!({
1360 "name": a.name,
1361 "expires_at": expires_at,
1362 "idle_timeout": idle,
1363 "message": format!(
1364 "{} now expires {} from now.",
1365 a.name,
1366 crate::workspace::human(expires_at.unwrap_or(now).saturating_sub(now))
1367 ),
1368 }))
1369}
1370
1371const OUTPUT_CAP: usize = 256 * 1024;
1372
1373fn cap(b: &[u8]) -> (String, bool) {
1374 if b.len() <= OUTPUT_CAP {
1375 return (String::from_utf8_lossy(b).into_owned(), false);
1376 }
1377 (
1378 String::from_utf8_lossy(&b[b.len() - OUTPUT_CAP..]).into_owned(),
1379 true,
1380 )
1381}
1382
1383fn sandbox_exec(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1384 #[derive(Deserialize)]
1385 struct A {
1386 name: String,
1387 #[serde(default)]
1388 org: Option<String>,
1389 argv: Vec<String>,
1390 cwd: Option<String>,
1391 user: Option<String>,
1392 #[serde(default)]
1393 env: BTreeMap<String, String>,
1394 stdin: Option<String>,
1395 timeout: Option<String>,
1396 }
1397 let org = arg_org(&a)?;
1398 let a: A = args(a)?;
1399 let oc = d.oc(&a.org)?;
1400 d.reach(c, &oc, &a.name)?;
1401 d.workspaces.mark_active(&org.incus_project(), &a.name);
1402 let timeout = match &a.timeout {
1403 Some(t) => crate::flex::parse_duration(t).map_err(Error::invalid)?,
1404 None => Duration::from_secs(600),
1405 };
1406 let mut opts = ExecOptions::default().timeout(timeout);
1407 opts.cwd = a.cwd;
1408 opts.user = a.user;
1409 opts.env = a.env;
1410 if let Some(s) = a.stdin {
1411 opts.stdin = Stdin::Bytes(s.into_bytes());
1412 }
1413 let sb = Sandbox::get(&oc, &a.name)?;
1414 let out = match sb.exec_with(a.argv, opts) {
1415 Err(Error::ExecTimeout { .. }) => {
1416 return Err(Error::invalid(format!(
1417 "timed out after {timeout:?} and was killed"
1418 )));
1419 }
1420 r => r?,
1421 };
1422 let (stdout, t1) = cap(&out.stdout);
1423 let (stderr, t2) = cap(&out.stderr);
1424 Ok(
1425 json!({"exit_code": out.exit_code, "stdout": stdout, "stderr": stderr, "truncated": t1 || t2}),
1426 )
1427}
1428
1429pub use crate::stack::local_deploy_args;
1430
1431pub fn default_state_dir() -> PathBuf {
1433 Store::default_dir()
1434}
1435
1436#[cfg(test)]
1437#[path = "agent_tests.rs"]
1438mod agent_tests;
1439
1440#[cfg(test)]
1441mod tests;
1442
1443#[cfg(test)]
1444mod downscope_tests;