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