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