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