Skip to main content

isb_daemon/daemon/
mod.rs

1//! `isb serve`: the stack controller behind an MCP server.
2//!
3//! Two doors, one set of tools:
4//! - the unix socket, for the local CLI (`isb stack ...`), trusted as the
5//!   daemon's own user;
6//! - loopback HTTP at `/mcp`, for remote agents through a cloudflared tunnel
7//!   and Cloudflare Access, held to [`policy::RemotePolicy`].
8//!
9//! The tools manage stacks (long-running, replicated, load-balanced
10//! services), apps over them ([`crate::app`]), plain sandboxes (an isolated
11//! machine for an agent), and each org's secrets ([`crate::secrets`]).
12
13/// Register one tool: `tool!(registry, ctx, name, title, description,
14/// input_schema, annotations, handler)`. The handler is called with its own
15/// clone of `ctx`, the arguments and the caller. Defined before the
16/// submodules so their tool tables use it too.
17macro_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;
44mod secret_hooks;
45pub mod secrets;
46mod servers;
47mod ssh;
48mod stack_deploy;
49pub mod superadmin;
50pub mod templates;
51mod terminal;
52mod tools;
53pub mod volumes;
54pub mod workspaces;
55
56use std::collections::BTreeMap;
57use std::path::PathBuf;
58use std::sync::Arc;
59use std::time::{Duration, Instant};
60
61use serde::Deserialize;
62use serde::de::DeserializeOwned;
63use serde_json::{Value, json};
64
65use crate::auth::AuthConfig;
66use crate::auth::AuthStore;
67use crate::auth::http::{ApiConfig, AuthApi};
68use crate::client::Client;
69use crate::error::{Error, Result};
70use crate::exec::{ExecOptions, Stdin};
71use crate::sandbox::{EnsureOptions, Sandbox, SandboxInfo};
72use crate::server::{AccessValidator, Caller, Listener, Registry, Tool, ToolPolicy};
73use crate::spec::SandboxSpec;
74use crate::stack::{Controller, Store, now_secs};
75use authorize::{
76    CROSS_ORG_READS, PLATFORM_TOOLS, arg_org, authorize_class, event_visible, read_orgs,
77    tool_listed, visible_orgs,
78};
79use policy::RemotePolicy;
80use stack_deploy::{DeployArgs, How, deploy, stack_deploy};
81#[cfg(test)]
82use stack_deploy::{reused_note, stack_owner};
83use tools::Ann;
84
85/// Marks an instance a remote caller created with `sandbox_create`.
86pub const LABEL_OWNER: &str = "isb.owner";
87
88/// How `isb serve` runs.
89#[derive(Debug, Clone)]
90pub struct ServeConfig {
91    /// `host:port` addresses for remote MCP and the web UI: loopback, or a
92    /// tailnet address with `superadmin_tailnet`. Empty serves the socket
93    /// only.
94    pub listen: Vec<String>,
95    pub socket: PathBuf,
96    /// Cloudflare Access team domain and application audience.
97    pub access: Option<(String, String)>,
98    /// Serve the TCP listener with no Access (local testing only).
99    pub allow_unauthenticated: bool,
100    /// Which tools remote callers see.
101    pub remote_tools: ToolPolicy,
102    pub policy: RemotePolicy,
103    pub state_dir: PathBuf,
104    pub interval: Duration,
105    /// Where the secrets key is looked for (and generated).
106    pub keys: crate::secrets::KeySources,
107    /// `~/.config/isb/secrets.toml`: break-glass recipients.
108    pub secrets_config: PathBuf,
109    /// Session lifetimes and the rest of the identity store's settings.
110    pub auth: AuthConfig,
111    /// Where users reach isb, for invitation and reset links, provider
112    /// callbacks and the passkey relying party.
113    pub public_url: Option<String>,
114    /// External sign-in providers (GitHub, Google, generic OIDC).
115    pub oauth: crate::auth::oauth::OAuthSettings,
116    /// Accounts without an invitation, for verified provider emails.
117    pub open_signup: bool,
118    /// The HTTP(S) edge for stack domains; `None` leaves domains unserved.
119    pub ingress: Option<crate::ingress::IngressConfig>,
120    /// The port each org's workspace reaches the org-bound MCP on, on the
121    /// org bridge's address.
122    pub workspace_mcp_port: u16,
123    /// `--workspace-pool`: the storage pool new workspace homes go in,
124    /// unless the org sets its own; none: the org's default pool.
125    pub workspace_pool: Option<String>,
126    /// `--workspace-home-root`: workspace homes are host folders
127    /// `<root>/<org>/home` instead of managed volumes.
128    pub workspace_home_root: Option<PathBuf>,
129    /// `--preview-domain`: where workspace ports' previews get their origins.
130    pub preview_domain: Option<workspaces::PreviewBase>,
131    /// How long audit rows are kept.
132    pub audit_retention: Duration,
133    /// Record read-only tool calls too (secret reads always are).
134    pub audit_all: bool,
135    /// How long, and how many, history rows are kept.
136    pub history_retention: Duration,
137    pub history_max_rows: i64,
138    /// Run as a server's agent for a control plane (docs/guides/servers.md): an
139    /// mTLS listener instead of the identity store, web UI and `--listen`.
140    pub agent: Option<AgentConfig>,
141    /// `--superadmin-tailnet`: tailnet logins and tags with the unix
142    /// socket's reach.
143    pub superadmin_tailnet: Option<crate::server::tailnet::AllowList>,
144    /// `--superadmin-access`: Access emails and service token client ids
145    /// with the unix socket's reach.
146    pub superadmin_access: Option<superadmin::AccessAllowList>,
147    /// `ISB_DEV_SUPERADMIN` ([`crate::auth::dev`]; debug builds only):
148    /// every loopback request with no credential is this superadmin.
149    pub dev_superadmin: Option<String>,
150    /// `--heartbeat-url`: a dead man's switch pinged every interval.
151    pub heartbeat: Option<crate::monitor::heartbeat::Heartbeat>,
152    /// `--egress-pin NAME=IP[:PORT]`: names the egress proxy connects to
153    /// at a fixed address instead of resolving.
154    pub egress_pins: Vec<String>,
155    /// `--egress-ca FILE`: roots the egress proxy trusts besides the system's.
156    pub egress_ca: Vec<PathBuf>,
157}
158
159/// `isb serve --agent`.
160#[derive(Debug, Clone)]
161pub struct AgentConfig {
162    /// `host:port` on any address; only the control plane's client certificate gets through.
163    pub listen: String,
164    /// `ca.crt`, `tls.crt`, `tls.key` from the control plane.
165    pub tls_dir: PathBuf,
166}
167
168/// The identity endpoints over `<state>/isb.db`, and the web UI. Provider
169/// client secrets not in the environment are read from the default org's
170/// secrets.
171fn auth_routes(
172    cfg: &ServeConfig,
173    store: Arc<AuthStore>,
174    secrets: &Arc<crate::secrets::Secrets>,
175    log: &Arc<crate::audit::AuditLog>,
176    gate: Arc<superadmin::Gate>,
177) -> Result<crate::server::Routes> {
178    use crate::auth::oauth::SecretFn;
179    let default_org = crate::org::OrgId::default_org();
180    let lookup = |name: &str| -> Option<SecretFn> {
181        secrets.inspect(&default_org, name).ok()?;
182        let (s, org, name) = (secrets.clone(), default_org.clone(), name.to_string());
183        Some(Arc::new(move || {
184            let (v, _) = s.get(&org, &name).map_err(|e| e.to_string())?;
185            String::from_utf8(v)
186                .map(|v| v.trim().to_string())
187                .map_err(|_| "the secret is not UTF-8".to_string())
188        }))
189    };
190    let (providers, notes) = cfg.oauth.providers(&lookup);
191    for n in notes {
192        eprintln!("isb serve: {n}");
193    }
194    let path = crate::auth::db_path(&cfg.state_dir);
195    let agent_ways = gate.agent_ways().with_public_url(cfg.public_url.clone());
196    let api = AuthApi::new(
197        store.clone(),
198        ApiConfig {
199            agent: Some(gate.agent_fn()),
200            agent_ways,
201            public_url: cfg.public_url.clone(),
202            notifier: None,
203            setup_token_file: Some(cfg.state_dir.join("setup-token")),
204            edge: Some(gate.edge_fn()),
205            providers,
206            open_signup: cfg.open_signup,
207            audit: Some(log.clone()),
208            superadmin: Some(Arc::new(move |r: &crate::server::http::Request| match gate
209                .resolve(r, None)
210            {
211                superadmin::Resolved::Superadmin(s) => Some(s),
212                _ => None,
213            })),
214        },
215    )?;
216    eprintln!("isb serve: identity store {}", path.display());
217    if !crate::web::BUILT {
218        eprintln!(
219            "isb serve: this binary was built without the web UI (a placeholder page is served)"
220        );
221    }
222    // The identity endpoints first, the audit tail, then the web UI, which
223    // answers every other non-API GET.
224    let (auth, web) = (Arc::new(api).router(), crate::web::routes());
225    let tail = audit::stream_route(log.clone(), store);
226    Ok(Arc::new(move |r| {
227        auth(r).or_else(|| tail(r)).or_else(|| web(r))
228    }))
229}
230
231struct Daemon {
232    client: Client,
233    ctl: Controller,
234    policy: RemotePolicy,
235    state_dir: PathBuf,
236    secrets: Arc<crate::secrets::Secrets>,
237    apps: crate::app::Apps,
238    ingress: Option<Arc<crate::ingress::Manager>>,
239    /// The identity store: the org list memberships hang off.
240    users: Arc<AuthStore>,
241    notifier: crate::notify::Notifier,
242    monitors: crate::monitor::Monitors,
243    history: crate::metrics_history::History,
244    /// Databases' backups and scheduled jobs.
245    data: data::Ctx,
246    /// Named volumes' snapshots and staged restores.
247    volumes: crate::volume_backup::VolumeBackups,
248    audit: Arc<crate::audit::AuditLog>,
249    /// The servers orgs can be placed on (a control plane; `None` on an
250    /// agent).
251    servers: Option<Arc<crate::servers::Servers>>,
252    /// Who is a superadmin, and what `host_policy` reports.
253    gate: Arc<superadmin::Gate>,
254    host: Value,
255    /// The template catalogs, shared by the tools and the logo route.
256    catalogs: Arc<crate::template::catalog::Catalogs>,
257    /// Each org's workspace, its token and its bridge listener; the
258    /// sandbox reaper.
259    workspaces: Arc<workspaces::Workspaces>,
260    /// Where users reach isb, for invitation links.
261    public_url: Option<String>,
262    /// One proxy per egress network (docs/guides/egress.md).
263    egress: Arc<isb_egress::Manager>,
264    /// Compose stacks' environments, managed domains and deployments.
265    meta: crate::stack::deployments::StackMeta,
266}
267
268/// Run the daemon until SIGINT/SIGTERM. Apps keep running when it stops.
269#[expect(
270    clippy::too_many_lines,
271    reason = "predates the lint ratchet; split it when next changed"
272)]
273pub fn serve(client: Client, cfg: ServeConfig) -> Result<()> {
274    client
275        .server_info()
276        .map_err(|e| Error::invalid(format!("isb serve needs incusd: {e}")))?;
277    default_org::warn_old_incus(&client);
278    let store = Store::open(&cfg.state_dir)?;
279    dns::open_dns_path(&cfg.state_dir);
280    // The default org is the incus project `isb-default`, made here when
281    // it is missing. A server's agent has no default org of its own.
282    if cfg.agent.is_none() {
283        default_org::ensure(&client, &store);
284    }
285    let secrets_config = crate::secrets::SecretsConfig::load(&cfg.secrets_config)?;
286    let opened = crate::secrets::Secrets::open(&cfg.state_dir, &cfg.keys, &secrets_config)?;
287    for n in &opened.notes {
288        eprintln!("isb serve: {n}");
289    }
290    let secrets = Arc::new(with_external_drivers(opened.secrets, &cfg.state_dir)?);
291    // Definitions from before secrets were references carry values: move
292    // them into the store before anything reads the definitions.
293    for r in crate::stack::migrate::run(&store, &secrets, Some(&client)) {
294        match r {
295            Ok(m) => eprintln!("isb serve: {m}"),
296            Err(e) => eprintln!("isb serve: WARNING: {e}"),
297        }
298    }
299    // Open the identity store before anything starts, so a bad one fails
300    // startup cleanly. Its endpoints ride on the TCP listener.
301    let db = crate::auth::db_path(&cfg.state_dir);
302    let users = Arc::new(
303        AuthStore::open_with(&db, cfg.auth.clone())
304            .map_err(|e| Error::invalid(format!("open {}: {e}", db.display())))?,
305    );
306    let audit_db = crate::audit::db_path(&cfg.state_dir);
307    let audit_log = Arc::new(
308        crate::audit::AuditLog::open(&audit_db, cfg.audit_retention)?
309            .with_history_limits(cfg.history_retention, cfg.history_max_rows),
310    );
311    // The history: markers for the time nobody was watching and for this
312    // start, then incus' lifecycle events from now on.
313    let recorder = crate::history::Recorder::start(audit_log.clone());
314    let stop_history = Arc::new(std::sync::atomic::AtomicBool::new(false));
315    history_start(&audit_log, &recorder, &client, &stop_history);
316    eprintln!(
317        "isb serve: audit log {} (kept {} days{})",
318        audit_db.display(),
319        cfg.audit_retention.as_secs() / 86400,
320        if cfg.audit_all {
321            ", reads included"
322        } else {
323            ""
324        }
325    );
326    if cfg.agent.is_some() && !cfg.listen.is_empty() {
327        return Err(Error::invalid(
328            "--agent serves its control plane only: drop --listen (users reach the control plane)",
329        ));
330    }
331    let access = match &cfg.access {
332        Some((team, aud)) => Some(Arc::new(AccessValidator::new(team, aud)?)),
333        None => None,
334    };
335    let gate = Arc::new(superadmin::gate(&cfg, users.clone(), access.clone())?);
336    let auth = if cfg.listen.is_empty() {
337        None
338    } else {
339        Some(auth_routes(
340            &cfg,
341            users.clone(),
342            &secrets,
343            &audit_log,
344            gate.clone(),
345        )?)
346    };
347    let servers = match &cfg.agent {
348        None => Some(crate::servers::Servers::open(&cfg.state_dir)?),
349        Some(_) => None,
350    };
351    // The local registry, when set up: this daemon pushes to it and keeps
352    // its push index under the state directory.
353    match crate::registry::Registry::open(&client, Some(&cfg.state_dir)) {
354        Ok(Some(r)) => {
355            eprintln!("isb serve: local registry {}", r.info().url());
356            crate::registry::install(Arc::new(r));
357        }
358        Ok(None) => {}
359        Err(e) => eprintln!("isb serve: WARNING: local registry: {e}"),
360    }
361    // The ingress follows rotation from the first replica the controller
362    // resumes, so it exists before the controller does.
363    let ingress = match &cfg.ingress {
364        Some(ic) => Some(crate::ingress::Manager::new(
365            ic.clone(),
366            client.clone(),
367            secrets.clone(),
368            &cfg.state_dir,
369        )?),
370        None => None,
371    };
372    let observer = ingress
373        .clone()
374        .map(|m| m as Arc<dyn crate::stack::controller::Observer>);
375    let ctl = Controller::start_with(
376        client.clone(),
377        store,
378        cfg.interval,
379        secrets.clone(),
380        observer,
381    )?;
382    if let Some(m) = &ingress {
383        m.start(ctl.clone())?;
384    }
385    let apps = crate::app::Apps::new(&cfg.state_dir, client.clone(), ctl.clone(), secrets.clone());
386    // Every compose stack belongs to a project environment: stacks from
387    // before that (or whose adoption failed last time) get one now. Only
388    // records change; nothing is redeployed.
389    let stacks: Vec<(crate::org::OrgId, String)> = ctl
390        .definitions()
391        .iter()
392        .map(|d| (d.org.clone(), d.name.clone()))
393        .collect();
394    for r in apps.adopt_compose_stacks(&stacks) {
395        match r {
396            Ok(m) => eprintln!("isb serve: {m}"),
397            Err(e) => eprintln!("isb serve: WARNING: {e}"),
398        }
399    }
400    // Services of those stacks are named in their environment too.
401    ctl.set_dns_scope(apps.scope_fn());
402    // Notifications follow the event feed from the start of this run.
403    let ra = apps.clone();
404    let resolve: crate::notify::Resolve = Arc::new(move |org, stack, service| {
405        let a = ra.get(org, service).ok()?;
406        // The app's own stack, or one of its previews' (`<project>-...-pr-<n>`).
407        let own = a.spec.stack().ok()? == stack;
408        let preview = stack.starts_with(&format!("{}-", a.spec.project))
409            && crate::app::preview::is_pr_suffix(stack);
410        (own || preview).then_some(a.spec.project)
411    });
412    let notifier = crate::notify::Notifier::new(&cfg.state_dir, secrets.clone(), resolve)?;
413    notifier.start(ctl.clone());
414    let monitors = monitors::start(&cfg, &apps, &secrets, &notifier);
415    // Every controller event goes to the history (the ones already emitted at startup first).
416    ctl.set_event_sink(recorder.controller_sink());
417    // Every metrics sample also goes to the history.
418    let history = crate::metrics_history::History::new(&cfg.state_dir);
419    ctl.set_metrics_sink(history.start());
420    // Previews past their TTL, and removals that did not finish.
421    apps.start_preview_upkeep();
422    // Jobs and backups share one scheduler thread.
423    let jobs = crate::jobs::Jobs::new(&cfg.state_dir, apps.clone());
424    let backups = crate::backup::Backups::new(&cfg.state_dir, apps.clone());
425    let volumes =
426        crate::volume_backup::VolumeBackups::new(&cfg.state_dir, apps.clone(), backups.clone());
427    let scheduler = crate::jobs::Scheduler::start(vec![
428        Arc::new(jobs.clone()) as Arc<dyn crate::jobs::Scheduled>,
429        Arc::new(backups.clone()),
430        Arc::new(volumes.clone()),
431    ]);
432    jobs.set_scheduler(scheduler.clone());
433    backups.set_scheduler(scheduler.clone());
434    volumes.set_scheduler(scheduler.clone());
435    if let Some(ic) = &cfg.ingress {
436        if ic.tunnel_port == cfg.workspace_mcp_port {
437            return Err(Error::invalid(format!(
438                "--workspace-mcp-port {} is the ingress's tunnel port; pick another",
439                cfg.workspace_mcp_port
440            )));
441        }
442    }
443    let workspaces = workspaces::Workspaces::new(
444        &cfg.state_dir,
445        client.clone(),
446        secrets.clone(),
447        recorder.clone(),
448        cfg.workspace_mcp_port,
449        cfg.workspace_pool.clone(),
450        cfg.workspace_home_root.clone(),
451    );
452    workspaces.previews.set_base(cfg.preview_domain.clone());
453    let (egress, stop_egress) = egress::start(&cfg, &client, &secrets)?;
454    let meta = crate::stack::deployments::StackMeta::new(&cfg.state_dir);
455    meta.recover();
456    let d = Arc::new(Daemon {
457        client,
458        ctl: ctl.clone(),
459        policy: cfg.policy.clone(),
460        state_dir: cfg.state_dir.clone(),
461        secrets,
462        apps: apps.clone(),
463        ingress: ingress.clone(),
464        users: users.clone(),
465        notifier: notifier.clone(),
466        monitors: monitors.clone(),
467        history,
468        data: data::Ctx {
469            apps: apps.clone(),
470            jobs,
471            backups,
472        },
473        volumes,
474        audit: audit_log.clone(),
475        servers: servers.clone(),
476        gate: gate.clone(),
477        host: superadmin::host_summary(&cfg, &gate),
478        catalogs: Arc::new(crate::template::catalog::Catalogs::new(&cfg.state_dir)),
479        workspaces: workspaces.clone(),
480        public_url: cfg.public_url.clone(),
481        egress,
482        meta,
483    });
484    if let Some(s) = &servers {
485        s.start(ctl.clone());
486    }
487    let registry = registry(d.clone())?;
488    let mut hooks = hooks(d.clone(), users.clone(), cfg.allow_unauthenticated);
489    superadmin::announce(&cfg, &gate, &users);
490    hooks.audit = Some(audit::hook(audit_log.clone(), cfg.audit_all));
491    if servers.is_some() {
492        hooks.route = Some(servers::route(d.clone()));
493    }
494    // Webhooks carry their own credential (a signature), and come from
495    // senders that hold no session; a control plane hands those for orgs on servers to the server.
496    let webhooks = {
497        let w = apps::webhook_routes(apps.clone());
498        let w = match &servers {
499            Some(s) => servers::forward_webhooks(w, s.clone()),
500            None => w,
501        };
502        audit::audited_webhooks(w, audit_log.clone())
503    };
504    // Template logos from isb's own cache, ahead of the web UI.
505    let auth = auth.map(|a| -> crate::server::Routes {
506        let logo = templates::logo::route(
507            d.catalogs.clone(),
508            Arc::new(templates::logo::Logos::new(&cfg.state_dir)),
509            templates::logo::admit(
510                hooks.authn.clone().expect("the daemon authenticates"),
511                access.clone(),
512                cfg.allow_unauthenticated,
513            ),
514        );
515        Arc::new(move |r| logo(r).or_else(|| a(r)))
516    });
517    let mut listeners = vec![Listener::unix(&cfg.socket).hooks(hooks.clone())];
518    if let Some(ac) = &cfg.agent {
519        listeners.push(servers::agent_listener(
520            d.clone(),
521            &hooks,
522            ac,
523            webhooks.clone(),
524        )?);
525    }
526    for addr in &cfg.listen {
527        let tailnet = superadmin::is_tailnet_listen(addr);
528        let mut l = Listener::tcp(addr.clone())
529            .policy(cfg.remote_tools.clone())
530            .hooks(hooks.clone())
531            .public_routes(webhooks.clone())
532            .preview(workspaces::preview_route(d.clone()))
533            .tailnet(tailnet);
534        if let Some(r) = &auth {
535            l = l.routes(r.clone());
536        }
537        listeners.push(match &access {
538            // Access guards the loopback listeners (the tunnel's end).
539            Some(v) if !tailnet => l.access_shared(v.clone()),
540            // Callers sign in with an API token or a session (or are tailnet
541            // superadmins); the authorizer refuses anonymous ones unless
542            // --allow-unauthenticated.
543            _ => l.allow_unauthenticated(true),
544        });
545    }
546    let hd = d.clone();
547    let healthz: crate::server::Healthz = Arc::new(move || {
548        let stacks: Vec<Value> = hd
549            .ctl
550            .list()
551            .into_iter()
552            .map(|s| json!({"name": s.name, "converged": s.converged}))
553            .collect();
554        (
555            true,
556            json!({"ok": true, "isb": env!("CARGO_PKG_VERSION"), "stacks": stacks}),
557        )
558    });
559    // Each org's workspace reaches the org-bound surface on its bridge:
560    // the hooks and tool policy of the TCP listeners, no Access (only the
561    // org's own subnet, with bearer tokens, gets in).
562    let registry = Arc::new(registry);
563    workspaces.set_serving(
564        Listener::tcp("org-bridge")
565            .policy(cfg.remote_tools.clone())
566            .hooks(hooks.clone())
567            .allow_unauthenticated(true),
568        registry.clone(),
569        healthz.clone(),
570    );
571    let local: workspaces::LocalOrg = {
572        let d = d.clone();
573        Arc::new(move |o: &crate::org::OrgId| d.remote(o).is_none())
574    };
575    workspaces.start(ctl.clone(), local);
576    workspaces::start_ports(d.clone());
577    let r = crate::server::serve_shared(listeners, registry, healthz);
578    workspaces.shutdown();
579    stop_egress.store(true, std::sync::atomic::Ordering::Relaxed);
580    stop_history.store(true, std::sync::atomic::Ordering::Relaxed);
581    recorder.record(crate::history::marker(
582        "serve.stopped",
583        "isb serve stopped: incus events from now on are not observed".into(),
584        json!({"version": env!("CARGO_PKG_VERSION")}),
585    ));
586    recorder.shutdown();
587    if let Some(s) = &servers {
588        s.shutdown();
589    }
590    notifier.shutdown();
591    monitors.shutdown();
592    scheduler.shutdown();
593    ctl.shutdown();
594    if let Some(m) = &ingress {
595        m.shutdown();
596    }
597    r
598}
599
600/// Record the gap since the history last heard anything and this start,
601/// then follow incus' lifecycle events until `stop`.
602fn history_start(
603    log: &Arc<crate::audit::AuditLog>,
604    rec: &Arc<crate::history::Recorder>,
605    client: &Client,
606    stop: &Arc<std::sync::atomic::AtomicBool>,
607) {
608    use crate::history::{HistoryQuery, marker};
609    let now = crate::audit::now_ms();
610    let last = log
611        .history_list(
612            &HistoryQuery::default(),
613            &crate::audit::Visibility::All,
614            None,
615            None,
616            1,
617        )
618        .ok()
619        .and_then(|v| v.into_iter().next());
620    if let Some(l) = last {
621        let clean = l.kind == "serve.stopped";
622        let reason = if clean {
623            "isb serve was not running"
624        } else {
625            "isb serve was not running (it did not stop cleanly)"
626        };
627        rec.record(marker(
628            "incus.gap",
629            format!(
630                "incus events between {} and {} were not observed: {reason}",
631                crate::history::fmt_ms(l.time),
632                crate::history::fmt_ms(now),
633            ),
634            json!({"from": l.time, "to": now, "reason": reason}),
635        ));
636    }
637    rec.record(marker(
638        "serve.started",
639        format!("isb serve {} started", env!("CARGO_PKG_VERSION")),
640        json!({"version": env!("CARGO_PKG_VERSION"), "pid": std::process::id()}),
641    ));
642    let (c, r, s) = (client.clone(), rec.clone(), stop.clone());
643    let _ = std::thread::Builder::new()
644        .name("isb-incus-events".into())
645        .spawn(move || crate::history::watch_incus(c, r, s));
646}
647
648/// The external secret drivers, each reading its credentials from the org's own `local` secrets.
649fn with_external_drivers(
650    secrets: crate::secrets::Secrets,
651    state_dir: &std::path::Path,
652) -> Result<crate::secrets::Secrets> {
653    use crate::secrets::{Driver, local::LocalDriver, onepassword};
654    let local = Arc::new(LocalDriver::new(state_dir, secrets.keyring().clone()));
655    let token: onepassword::TokenSource =
656        Arc::new(move |org| match local.get(org, onepassword::TOKEN_SECRET) {
657            Ok((v, _)) => Ok(Some(
658                String::from_utf8(v)
659                    .map_err(|_| Error::invalid("the 1Password token is not text"))?
660                    .trim()
661                    .to_string(),
662            )),
663            Err(e) if e.is_not_found() => Ok(None),
664            Err(e) => Err(e),
665        });
666    secrets.with_driver(Arc::new(onepassword::OnePasswordDriver::new(token)))
667}
668
669/// Authentication and authorization for every listener.
670fn hooks(d: Arc<Daemon>, users: Arc<AuthStore>, allow_anonymous: bool) -> crate::server::Hooks {
671    use crate::server::Authenticated;
672    let term = terminal::terminal(d.clone());
673    let ssh = ssh::ssh(d.clone(), users.clone());
674    let u = users.clone();
675    let gate = d.gate.clone();
676    let wsa = d.workspaces.clone();
677    let authn: crate::server::mcp::Authn = Arc::new(move |req, id| {
678        match gate.resolve(req, id) {
679            superadmin::Resolved::Superadmin(s) => return Authenticated::Superadmin(s),
680            superadmin::Resolved::Refused => return Authenticated::Refused,
681            superadmin::Resolved::None => {}
682        }
683        // An org's workspace token: judged by the workspaces, which keep it.
684        if let Some(t) = bearer(req) {
685            if t.starts_with(crate::auth::secret::TokenKind::Workspace.prefix()) {
686                return match wsa.authenticate(t) {
687                    Some(p) => Authenticated::User(Arc::new(p)),
688                    None => Authenticated::Refused,
689                };
690            }
691        }
692        if req.header("authorization").is_some()
693            || req
694                .header("cookie")
695                .is_some_and(|c| c.contains("isb_session="))
696        {
697            return match u.principal_from_request(req) {
698                Some(p) => Authenticated::User(Arc::new(p)),
699                None => Authenticated::Refused,
700            };
701        }
702        // Access vouches for the email; the isb account decides the orgs.
703        if let Some(email) = id.and_then(|i| i.email.as_deref()) {
704            if let Ok(Some(p)) = u.principal_for_email(email) {
705                return Authenticated::User(Arc::new(p));
706            }
707        }
708        // A tailnet or Access caller an org mapped to a role.
709        if let Some(p) = gate.agent(req, id) {
710            return Authenticated::User(Arc::new(p));
711        }
712        Authenticated::None
713    });
714    let authorize: crate::server::mcp::Authorize = Arc::new(move |c, tool, args, scope| {
715        // `app` for `name`, `command` for `argv`, before anything reads them.
716        let args = crate::server::aliases::alias_args(tool, args);
717        authorize_class(
718            c,
719            &tool.name,
720            audit::class_for(tool, &args),
721            args,
722            scope,
723            allow_anonymous,
724        )
725    });
726    let events: crate::server::mcp::Events = Arc::new(move |c, since| {
727        if let (Caller::Unauthenticated { .. }, false) = (c, allow_anonymous) {
728            return Err(Error::Forbidden("sign in to follow events".into()));
729        }
730        if let Caller::Access(id) = c {
731            return Err(Error::Forbidden(format!(
732                "{} has no isb account",
733                id.name()
734            )));
735        }
736        let orgs = visible_orgs(c);
737        let ctl = d.ctl.clone();
738        Ok(Box::new(move |w: &mut dyn std::io::Write| {
739            let mut since = ctl.resume_from(since);
740            loop {
741                let (seq, evs) = ctl.wait_events(since, 200, Duration::from_secs(15));
742                let mut wrote = false;
743                for e in evs {
744                    if !event_visible(&orgs, &e.stack) {
745                        continue;
746                    }
747                    let data = serde_json::to_string(&e).unwrap_or_default();
748                    write!(w, "id: {}\nevent: {}\ndata: {data}\n\n", e.seq, e.level)?;
749                    wrote = true;
750                }
751                if !wrote {
752                    // Keeps proxies from closing an idle stream.
753                    w.write_all(b": keepalive\n\n")?;
754                }
755                w.flush()?;
756                since = seq.max(since);
757            }
758        }))
759    });
760    crate::server::Hooks {
761        authn: Some(authn),
762        authorize: Some(authorize),
763        events: Some(events),
764        terminal: Some(term),
765        ssh: Some(ssh),
766        audit: None,
767        route: None,
768        listed: Some(Arc::new(tool_listed)),
769        refuse_anonymous: !allow_anonymous,
770    }
771}
772
773/// The bearer token a request carries, if any.
774fn bearer(req: &crate::server::http::Request) -> Option<&str> {
775    let (scheme, token) = req.header("authorization")?.trim().split_once(' ')?;
776    scheme.eq_ignore_ascii_case("bearer").then(|| token.trim())
777}
778
779fn args<T: DeserializeOwned>(v: Value) -> Result<T> {
780    serde_json::from_value(v).map_err(|e| Error::invalid(format!("bad arguments: {e}")))
781}
782
783fn obj(mut props: Value, required: &[&str]) -> Value {
784    // Every tool works within one org.
785    props["org"] =
786        json!({"type": "string", "description": "The org to act in (default: default)."});
787    json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
788}
789
790/// A stack's qualified name from a tool's `org` and `name`.
791fn qname(org: &Option<String>, name: &str) -> Result<String> {
792    let org = match org {
793        Some(o) => crate::org::OrgId::new(o.clone())?,
794        None => crate::org::OrgId::default_org(),
795    };
796    Ok(crate::stack::qualified(&org, name))
797}
798
799fn caller_name(c: &Caller) -> String {
800    c.to_string()
801}
802
803/// Build the tool registry.
804fn registry(d: Arc<Daemon>) -> Result<Registry> {
805    let mut r = Registry::new().instructions(INSTRUCTIONS);
806    superadmin::register(&mut r, d.clone())?;
807    let ann = Ann {
808        ro: json!({"readOnlyHint": true, "openWorldHint": false}),
809        destructive: json!({"destructiveHint": true, "openWorldHint": false}),
810        write: json!({"destructiveHint": false, "openWorldHint": false}),
811    };
812
813    tools::stack_deploy_tool(&mut r, &d, &ann)?;
814    tools::overview_tool(&mut r, &d, &ann)?;
815    tools::events_tool(&mut r, &d, &ann)?;
816    tools::ingress_status_tool(&mut r, &d, &ann)?;
817    tools::stack_list_tool(&mut r, &d, &ann)?;
818    tools::stack_status_tool(&mut r, &d, &ann)?;
819    tools::stack_config_tool(&mut r, &d, &ann)?;
820    tools::stack_logs_tool(&mut r, &d, &ann)?;
821    tools::stack_scale_tool(&mut r, &d, &ann)?;
822    tools::stack_edit_tools(&mut r, &d, &ann)?;
823    tools::sandbox_create_tool(&mut r, &d, &ann)?;
824    secret_hooks::register(&mut r, &d)?;
825    builds::register(
826        &mut r,
827        builds::Ctx {
828            client: d.client.clone(),
829            policy: d.policy.clone(),
830            ctl: d.ctl.clone(),
831        },
832    )?;
833    tools::sandbox_tools(&mut r, &d, &ann)?;
834    apps::register(&mut r, d.apps.clone(), d.ingress.is_some())?;
835    previews::register(&mut r, d.apps.clone())?;
836    let mut t = templates::Templates::new(
837        &d.state_dir,
838        d.apps.clone(),
839        d.secrets.clone(),
840        d.ingress.as_ref().and_then(|m| m.public_ip()),
841    );
842    t.catalogs = d.catalogs.clone();
843    templates::register(&mut r, t)?;
844    data::register(&mut r, d.data.clone())?;
845    volumes::register(&mut r, d.volumes.clone())?;
846    tools::server_status_tool(&mut r, &d, &ann)?;
847    orgs::register(&mut r, d.clone())?;
848    notify::register(
849        &mut r,
850        d.notifier.clone(),
851        d.history.clone(),
852        d.apps.clone(),
853    )?;
854    monitors::register(&mut r, d.monitors.clone())?;
855    audit::register(&mut r, d.audit.clone())?;
856    audit::register_history(&mut r, d.audit.clone())?;
857    servers::register(&mut r, d.clone())?;
858    ssh::register(&mut r, d.clone())?;
859    workspaces::register(&mut r, d.clone())?;
860    accounts::register(&mut r, d.clone())?;
861    Ok(r)
862}
863
864const INSTRUCTIONS: &str = "isb runs incus containers and VMs on this host. Two uses: \
865stacks (long-running services from a docker-compose-style file, with replicas, health checks, \
866rolling updates and a load balancer: stack_deploy, then stack_status) and sandboxes \
867(an isolated machine to run code in: sandbox_create, sandbox_exec, sandbox_remove). \
868Images: local incus aliases (dev-base), images:debian/12, OCI images (docker:nginx:1.27, ghcr:org/app:tag), \
869or the org's own builds in the local registry (registry:APP:TAG; build_run makes them, registry_list lists them). \
870Deploys return immediately; poll stack_status, or pass wait=true. \
871Each org also has a secret store (secret_create, secret_set, secret_list; values are base64). \
872Apps (Dokploy-style): project_create, then app_create (an image, or a repository with a builder), \
873app_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>. \
874One-click apps: template_list, template_get, then template_deploy (dry_run first shows the plan). \
875Databases are apps too (database_create; connection details via database_get), backed up to S3-compatible \
876destinations on a cron schedule (backup_destination_create, backup_create, backup_run, backup_restore). \
877Scheduled jobs run commands against an app on a cron schedule (job_create, job_runs, job_run_log).";
878
879impl Daemon {
880    /// May this caller touch this instance? Local callers: always. Remote:
881    /// only what isb serve manages, unless the operator allowed any.
882    fn reachable(&self, c: &Caller, i: &SandboxInfo) -> bool {
883        // A signed-in user reaches everything in an org they belong to (the
884        // authorizer already checked the org): the org is the boundary.
885        c.is_trusted()
886            || c.principal().is_some()
887            || self.policy.any_instance
888            || i.config.contains_key("user.isb.stack")
889            || i.config.contains_key(&format!("user.{LABEL_OWNER}"))
890    }
891
892    /// A client on the org a tool call names (default: the default org),
893    /// refused up front when that org does not exist.
894    fn oc(&self, org: &Option<String>) -> Result<Client> {
895        let org = crate::org::OrgId::new(org.as_deref().unwrap_or(crate::org::DEFAULT_ORG))?;
896        crate::org::check_exists(&self.client, &org)?;
897        Ok(crate::org::client(&self.client, &org))
898    }
899
900    fn reach(&self, c: &Caller, oc: &Client, name: &str) -> Result<SandboxInfo> {
901        let info = Sandbox::get(oc, name)?.info()?;
902        if !self.reachable(c, &info) {
903            // Indistinguishable from absent, so a remote caller cannot map
904            // the host's other instances.
905            return Err(Error::NotFound(format!("sandbox {name}")));
906        }
907        Ok(info)
908    }
909
910    /// Where a remote stack's relative paths resolve by default.
911    /// An org's workspace definition, by name.
912    fn workspaces_def(
913        &self,
914        org: &crate::org::OrgId,
915        name: &str,
916    ) -> Result<crate::workspace::Workspace> {
917        crate::workspace::Store::new(&self.state_dir)
918            .get(org, name)?
919            .ok_or_else(|| Error::NotFound(format!("org {org} has no workspace {name}")))
920    }
921
922    fn files_dir(&self, stack: &str) -> Result<PathBuf> {
923        let p = self.state_dir.join("files").join(stack);
924        std::fs::create_dir_all(&p)?;
925        Ok(p)
926    }
927}
928
929/// Poll until every service is converged, or one is paused or failing (its
930/// message says why), or `timeout`.
931pub fn wait_settled(
932    ctl: &Controller,
933    name: &str,
934    timeout: Duration,
935) -> Result<crate::stack::controller::StackStatus> {
936    let started = Instant::now();
937    let def = ctl.definition(name)?;
938    loop {
939        let st = ctl.status(name)?;
940        // A status from before the worker saw this deployment has an older
941        // revision or replica count; only one that matches counts.
942        let settled = st.services.iter().all(|s| {
943            let current = def.revision(&s.service).is_ok_and(|r| r == s.rev)
944                && def
945                    .service(&s.service)
946                    .is_ok_and(|d| d.replicas() == s.replicas);
947            current && matches!(s.state.as_str(), "converged" | "paused" | "failing")
948        });
949        if settled || started.elapsed() >= timeout {
950            return Ok(st);
951        }
952        std::thread::sleep(Duration::from_secs(1));
953    }
954}
955
956#[derive(Deserialize)]
957#[serde(untagged)]
958enum SpecArg {
959    Text(String),
960    Object(Box<SandboxSpec>),
961}
962
963#[expect(
964    clippy::too_many_lines,
965    reason = "predates the lint ratchet; split it when next changed"
966)]
967fn sandbox_create(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
968    #[derive(Deserialize)]
969    struct A {
970        spec: Value,
971        #[serde(default)]
972        wait_ready: Option<bool>,
973        #[serde(default)]
974        expires: Option<String>,
975        #[serde(default)]
976        idle_timeout: Option<String>,
977        #[serde(default)]
978        org: Option<String>,
979    }
980    let org = arg_org(&a)?;
981    let a: A = args(a)?;
982    let mut spec = match serde_json::from_value::<SpecArg>(a.spec)
983        .map_err(|e| Error::invalid(format!("spec: {e}")))?
984    {
985        SpecArg::Text(t) => serde_yaml_ng::from_str::<SandboxSpec>(&t)
986            .map_err(|e| Error::invalid(format!("spec: {e}")))?,
987        SpecArg::Object(s) => *s,
988    };
989    let name = spec
990        .name
991        .clone()
992        .ok_or_else(|| Error::invalid("spec needs container_name"))?;
993    egress::check_secrets(&d.secrets, &org, &spec)?;
994    let base = if c.is_local() {
995        std::env::current_dir()?
996    } else {
997        d.files_dir("_sandboxes")?
998    };
999    if let Caller::Superadmin(s) = c {
1000        // The socket's reach, under the superadmin's own name.
1001        spec.labels.insert(LABEL_OWNER.into(), s.label());
1002    }
1003    if !c.is_trusted() {
1004        d.policy.check_spec(&spec, &base)?;
1005        if let Ok(sb) = Sandbox::get(&d.oc(&a.org)?, &name) {
1006            // Reconciling someone else's instance would be taking it over.
1007            if !d.reachable(c, &sb.info()?) {
1008                return Err(Error::AlreadyExists(name));
1009            }
1010        }
1011        spec.labels.insert(LABEL_OWNER.into(), owner_label(c));
1012    }
1013    if let Ok(sb) = Sandbox::get(&d.oc(&a.org)?, &name) {
1014        let info = sb.info()?;
1015        // A sandbox spec over the workspace would replace the org's machine.
1016        if info.config.contains_key(crate::workspace::KEY_WORKSPACE) {
1017            return Err(Error::invalid(format!(
1018                "{name} is the org's workspace; pick another name"
1019            )));
1020        }
1021    }
1022    // incus counts every disk against an org's disk quota, and refuses a
1023    // root disk without a size there.
1024    if !spec
1025        .raw_devices
1026        .get("root")
1027        .is_some_and(|r| r.contains_key("size"))
1028        && workspaces::project_has_disk_limit(&d.client, &org)
1029    {
1030        spec.raw_devices
1031            .entry("root".into())
1032            .or_default()
1033            .insert("size".into(), workspaces::SANDBOX_ROOT_SIZE.into());
1034    }
1035    // Short-lived by rule: the org's defaults unless the call says otherwise.
1036    let settings = d.workspaces.settings(&org)?;
1037    let (expires_at, idle) = crate::workspace::sandbox_deadlines(
1038        &settings,
1039        a.expires.as_deref(),
1040        a.idle_timeout.as_deref(),
1041        now_secs(),
1042    )?;
1043    spec.labels
1044        .insert("isb.expires_at".into(), expires_at.to_string());
1045    match idle {
1046        Some(s) => {
1047            spec.labels.insert("isb.idle_timeout".into(), s.to_string());
1048        }
1049        None => {
1050            spec.labels.insert("isb.idle_timeout".into(), "0".into());
1051        }
1052    }
1053    let opts = EnsureOptions {
1054        wait_ready: a.wait_ready.unwrap_or(true),
1055        ..Default::default()
1056    };
1057    let mut log: Vec<String> = Vec::new();
1058    let (sb, report) = Sandbox::connect_or_create_with_base(
1059        &d.oc(&a.org)?,
1060        &spec,
1061        &Default::default(),
1062        &base,
1063        opts,
1064        &mut |m| log.push(m.to_string()),
1065    )?;
1066    d.workspaces.mark_active(&org.incus_project(), &name);
1067    d.egress.kick();
1068    Ok(json!({
1069        "info": sb.info()?,
1070        "report": report,
1071        "log": log,
1072        "expires_at": expires_at,
1073        "idle_timeout": idle,
1074        "message": format!(
1075            "{name} expires {} from now{}; sandbox_extend pushes it out.",
1076            crate::workspace::human(expires_at.saturating_sub(now_secs())),
1077            match idle {
1078                Some(s) => format!(" and is deleted after {} idle", crate::workspace::human(s)),
1079                None => String::new(),
1080            }
1081        ),
1082    }))
1083}
1084
1085/// What `isb.owner` says about a sandbox this caller creates.
1086fn owner_label(c: &Caller) -> String {
1087    match c {
1088        Caller::Superadmin(s) => s.label(),
1089        Caller::User { principal } if principal.is_workspace() => {
1090            crate::auth::WORKSPACE_ACTOR.to_string()
1091        }
1092        _ => format!("mcp:{}", caller_name(c)),
1093    }
1094}
1095
1096fn sandbox_extend(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1097    #[derive(Deserialize)]
1098    #[serde(deny_unknown_fields)]
1099    struct A {
1100        name: String,
1101        #[serde(default)]
1102        by: Option<String>,
1103        #[serde(default)]
1104        idle_timeout: Option<String>,
1105        #[serde(default)]
1106        org: Option<String>,
1107    }
1108    let org = arg_org(&a)?;
1109    let a: A = args(a)?;
1110    let oc = d.oc(&a.org)?;
1111    let info = d.reach(c, &oc, &a.name)?;
1112    let labels: BTreeMap<String, String> = info
1113        .config
1114        .iter()
1115        .filter_map(|(k, v)| k.strip_prefix("user.").map(|k| (k.to_string(), v.clone())))
1116        .collect();
1117    if crate::workspace::kind_of(&labels) != "sandbox" {
1118        return Err(Error::invalid(format!(
1119            "{} is a {}, not a sandbox: it does not expire",
1120            a.name,
1121            crate::workspace::kind_of(&labels)
1122        )));
1123    }
1124    // Its creator, or the org's admins.
1125    let mine = labels
1126        .get("isb.owner")
1127        .is_some_and(|o| *o == owner_label(c));
1128    let admin = match c {
1129        Caller::Local { .. } | Caller::Superadmin(_) => true,
1130        Caller::User { principal } => {
1131            principal.platform_admin
1132                || principal
1133                    .role_in(&org)
1134                    .is_some_and(|r| r >= crate::auth::Role::Admin)
1135        }
1136        _ => false,
1137    };
1138    if !mine && !admin {
1139        return Err(Error::Forbidden(format!(
1140            "{} was created by {}; its creator or the org's admins extend it",
1141            a.name,
1142            labels
1143                .get("isb.owner")
1144                .map(String::as_str)
1145                .unwrap_or("someone else")
1146        )));
1147    }
1148    let now = now_secs();
1149    let mut patch = serde_json::Map::new();
1150    let current = labels
1151        .get("isb.expires_at")
1152        .and_then(|v| v.parse::<u64>().ok());
1153    let by = match &a.by {
1154        Some(b) => crate::flex::parse_duration(b).map_err(Error::invalid)?,
1155        None if a.idle_timeout.is_some() => Duration::ZERO,
1156        None => Duration::from_secs(86400),
1157    };
1158    let mut expires_at = current;
1159    if !by.is_zero() {
1160        let e = crate::workspace::extended(current, by, now)?;
1161        patch.insert(
1162            crate::workspace::KEY_EXPIRES_AT.into(),
1163            json!(e.to_string()),
1164        );
1165        expires_at = Some(e);
1166    }
1167    let mut idle = labels
1168        .get("isb.idle_timeout")
1169        .and_then(|v| v.parse::<u64>().ok())
1170        .filter(|s| *s > 0);
1171    if let Some(t) = &a.idle_timeout {
1172        idle = crate::workspace::idle(t)?.map(|d| d.as_secs());
1173        patch.insert(
1174            crate::workspace::KEY_IDLE_TIMEOUT.into(),
1175            json!(idle.unwrap_or(0).to_string()),
1176        );
1177    }
1178    oc.mutate(
1179        "PATCH",
1180        &format!("/1.0/instances/{}", crate::client::encode_segment(&a.name)),
1181        Some(&json!({"config": patch})),
1182        &format!("extend sandbox {}", a.name),
1183        oc.timeouts.other,
1184    )?;
1185    d.workspaces.mark_active(&org.incus_project(), &a.name);
1186    Ok(json!({
1187        "name": a.name,
1188        "expires_at": expires_at,
1189        "idle_timeout": idle,
1190        "message": format!(
1191            "{} now expires {} from now.",
1192            a.name,
1193            crate::workspace::human(expires_at.unwrap_or(now).saturating_sub(now))
1194        ),
1195    }))
1196}
1197
1198const OUTPUT_CAP: usize = 256 * 1024;
1199
1200fn cap(b: &[u8]) -> (String, bool) {
1201    if b.len() <= OUTPUT_CAP {
1202        return (String::from_utf8_lossy(b).into_owned(), false);
1203    }
1204    (
1205        String::from_utf8_lossy(&b[b.len() - OUTPUT_CAP..]).into_owned(),
1206        true,
1207    )
1208}
1209
1210fn sandbox_exec(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1211    #[derive(Deserialize)]
1212    struct A {
1213        name: String,
1214        #[serde(default)]
1215        org: Option<String>,
1216        argv: Vec<String>,
1217        cwd: Option<String>,
1218        user: Option<String>,
1219        #[serde(default)]
1220        env: BTreeMap<String, String>,
1221        stdin: Option<String>,
1222        timeout: Option<String>,
1223    }
1224    let org = arg_org(&a)?;
1225    let a: A = args(a)?;
1226    let oc = d.oc(&a.org)?;
1227    d.reach(c, &oc, &a.name)?;
1228    d.workspaces.mark_active(&org.incus_project(), &a.name);
1229    let timeout = match &a.timeout {
1230        Some(t) => crate::flex::parse_duration(t).map_err(Error::invalid)?,
1231        None => Duration::from_secs(600),
1232    };
1233    let mut opts = ExecOptions::default().timeout(timeout);
1234    opts.cwd = a.cwd;
1235    opts.user = a.user;
1236    opts.env = a.env;
1237    if let Some(s) = a.stdin {
1238        opts.stdin = Stdin::Bytes(s.into_bytes());
1239    }
1240    let sb = Sandbox::get(&oc, &a.name)?;
1241    let out = match sb.exec_with(a.argv, opts) {
1242        Err(Error::ExecTimeout { .. }) => {
1243            return Err(Error::invalid(format!(
1244                "timed out after {timeout:?} and was killed"
1245            )));
1246        }
1247        r => r?,
1248    };
1249    let (stdout, t1) = cap(&out.stdout);
1250    let (stderr, t2) = cap(&out.stderr);
1251    Ok(
1252        json!({"exit_code": out.exit_code, "stdout": stdout, "stderr": stderr, "truncated": t1 || t2}),
1253    )
1254}
1255
1256pub use crate::stack::local_deploy_args;
1257
1258/// Default state directory, exported for the CLI.
1259pub fn default_state_dir() -> PathBuf {
1260    Store::default_dir()
1261}
1262
1263#[cfg(test)]
1264#[path = "agent_tests.rs"]
1265mod agent_tests;
1266
1267#[cfg(test)]
1268mod tests;
1269
1270#[cfg(test)]
1271mod downscope_tests;