Skip to main content

isb_daemon/daemon/
workspaces.rs

1//! Workspaces in the daemon (docs/concepts/workspaces.md): the `workspace_*` tools,
2//! the workspace's token and its delivery into the machine, the per-org MCP
3//! listener on the org's bridge, live sessions, and the sandbox reaper.
4//!
5//! - **The workspace** is an incus container in the org's project, named
6//!   after the workspace, with a managed volume `<org>_<name>_home` mounted
7//!   at the workspace user's home. Rebuilding replaces the container from
8//!   its image and mounts the same home again.
9//! - **Its token** (`isb_ws_...`) is minted at create, kept as a SHA-256
10//!   for authentication and, encrypted to the daemon's age key, as the
11//!   token itself so it can be delivered again after a restart. It is never
12//!   returned by a tool, written to a log, or put on a command line: it
13//!   reaches the machine through incus' file API as `/run/isb/token` (0400,
14//!   the workspace user's). Deleting the workspace revokes it.
15//! - **The bridge listener**: for each org with a workspace, the daemon
16//!   serves the org-bound MCP and REST surface (`/orgs/<org>/...` only) on
17//!   the org bridge's gateway address, port 8481 by default. Only peers in
18//!   the org's own subnet are answered, and only with a bearer token (the
19//!   workspace's, or an org API token); the authorizer pins the org.
20//! - **The reaper** deletes sandboxes past their expiry or idle timeout,
21//!   only those isb gave deadlines, never a workspace or a replica, and
22//!   records each in the history.
23
24use std::collections::{BTreeMap, HashMap};
25use std::net::{IpAddr, Ipv4Addr, SocketAddr};
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex, OnceLock};
28use std::time::Duration;
29
30use serde::Deserialize;
31use serde_json::{Value, json};
32
33use super::{Daemon, args, obj};
34use crate::auth::secret::{self, TokenKind};
35use crate::auth::{Principal, Role};
36use crate::client::{Client, encode_segment};
37use crate::error::{Error, Result};
38use crate::org::OrgId;
39use crate::sandbox::Sandbox;
40use crate::server::http::Shutdown;
41use crate::server::{Caller, Healthz, Listener, Registry, Tool};
42use crate::workspace::{self as ws, Settings, Store, TokenMeta, Workspace};
43
44mod bridge;
45mod create;
46mod herdr;
47mod image_tools;
48mod images;
49mod nesting;
50mod ports;
51mod preview;
52mod proxy;
53mod secrets;
54mod settings;
55mod setup;
56use bridge::{bridge_handler, gateway};
57use create::{check_fields, check_size, token_role, workspace_create};
58pub(super) use herdr::{Herdr, attach_argv};
59pub(super) use ports::start as start_ports;
60pub(super) use preview::route as preview_route;
61pub use preview::{PreviewBase, Previews};
62
63/// The bridge listener's port when `--workspace-mcp-port` is not given.
64pub const DEFAULT_PORT: u16 = 8481;
65
66/// How often the upkeep thread runs, and the reaper within it.
67const UPKEEP_EVERY: Duration = Duration::from_secs(15);
68const REAP_EVERY: Duration = Duration::from_secs(60);
69/// A sandbox using this much of a core is active.
70const ACTIVE_CPU_PCT: f32 = 2.0;
71/// How long an SSH session count is reused.
72const SSH_CACHE: Duration = Duration::from_secs(30);
73
74fn now() -> u64 {
75    crate::stack::now_secs()
76}
77
78fn random_hex(n: usize) -> String {
79    use ring::rand::SecureRandom;
80    let mut b = vec![0u8; n];
81    let _ = ring::rand::SystemRandom::new().fill(&mut b);
82    b.iter().map(|x| format!("{x:02x}")).collect()
83}
84
85fn hex(b: &[u8]) -> String {
86    b.iter().map(|x| format!("{x:02x}")).collect()
87}
88
89fn key(project: &str, instance: &str) -> String {
90    format!("{project}/{instance}")
91}
92
93#[derive(Debug, Clone)]
94struct TokenRef {
95    org: OrgId,
96    name: String,
97    role: Role,
98}
99
100/// A running bridge listener.
101struct Bridge {
102    addr: SocketAddr,
103    stop: Shutdown,
104}
105
106/// Live sessions on an instance (web terminals now; SSH through the
107/// daemon later), counted while their guard lives.
108pub struct SessionGuard {
109    key: String,
110    sessions: Arc<Mutex<HashMap<String, usize>>>,
111}
112
113impl Drop for SessionGuard {
114    fn drop(&mut self) {
115        let mut m = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
116        if let Some(n) = m.get_mut(&self.key) {
117            *n = n.saturating_sub(1);
118            if *n == 0 {
119                m.remove(&self.key);
120            }
121        }
122    }
123}
124
125/// Live sessions a workspace has, as far as isb can tell.
126#[derive(Debug, Clone, Default, serde::Serialize)]
127pub struct Sessions {
128    /// Web terminals open through this daemon.
129    pub terminals: usize,
130    /// Established SSH connections inside the machine (`ss`), when it can
131    /// be asked.
132    pub ssh: Option<usize>,
133    pub total: usize,
134}
135
136impl Sessions {
137    fn describe(&self) -> String {
138        let mut parts = Vec::new();
139        if self.terminals > 0 {
140            parts.push(format!("{} web terminal(s)", self.terminals));
141        }
142        if let Some(n) = self.ssh.filter(|n| *n > 0) {
143            parts.push(format!("{n} SSH connection(s)"));
144        }
145        if parts.is_empty() {
146            "no live sessions isb can see".into()
147        } else {
148            parts.join(" and ")
149        }
150    }
151}
152
153/// The daemon's workspaces.
154pub struct Workspaces {
155    store: Store,
156    client: Client,
157    keyring: Arc<crate::secrets::Keyring>,
158    secrets: Arc<crate::secrets::Secrets>,
159    recorder: Arc<crate::history::Recorder>,
160    port: u16,
161    /// `--workspace-pool`: where new homes go unless the org says.
162    home_pool: Option<String>,
163    /// `--workspace-home-root`: homes are host folders under it.
164    home_root: Option<PathBuf>,
165    tokens: Mutex<HashMap<Vec<u8>, TokenRef>>,
166    /// Token last use, by `<org>/<name>` (in memory).
167    last_used: Mutex<HashMap<String, u64>>,
168    sessions: Arc<Mutex<HashMap<String, usize>>>,
169    /// The latest activity isb saw per `<project>/<instance>`: exec, a
170    /// terminal, CPU use.
171    activity: Mutex<HashMap<String, u64>>,
172    /// The init pid each workspace's credentials were delivered for.
173    delivered: Mutex<HashMap<String, i64>>,
174    secret_poll: Mutex<secrets::Poll>,
175    bridges: Mutex<HashMap<OrgId, Bridge>>,
176    /// The SSH count per workspace and when it was asked, so pages that
177    /// poll do not exec in the machine every few seconds.
178    ssh: Mutex<HashMap<String, (std::time::Instant, Option<usize>)>>,
179    serve: OnceLock<(Listener, Arc<Registry>, Healthz)>,
180    /// Create, rebuild and delete one at a time.
181    lock: Mutex<()>,
182    started: u64,
183    /// Published ports' previews through isb.
184    pub previews: Previews,
185}
186
187impl Workspaces {
188    pub fn new(
189        state_dir: &Path,
190        client: Client,
191        secrets: Arc<crate::secrets::Secrets>,
192        recorder: Arc<crate::history::Recorder>,
193        port: u16,
194        home_pool: Option<String>,
195        home_root: Option<PathBuf>,
196    ) -> Arc<Workspaces> {
197        let w = Arc::new(Workspaces {
198            store: Store::new(state_dir),
199            client,
200            keyring: secrets.keyring().clone(),
201            secrets,
202            recorder,
203            port,
204            home_pool,
205            home_root,
206            tokens: Mutex::new(HashMap::new()),
207            last_used: Mutex::new(HashMap::new()),
208            sessions: Arc::new(Mutex::new(HashMap::new())),
209            activity: Mutex::new(HashMap::new()),
210            delivered: Mutex::new(HashMap::new()),
211            secret_poll: Mutex::default(),
212            bridges: Mutex::new(HashMap::new()),
213            ssh: Mutex::new(HashMap::new()),
214            serve: OnceLock::new(),
215            lock: Mutex::new(()),
216            started: now(),
217            previews: Previews::default(),
218        });
219        w.load_tokens();
220        w
221    }
222
223    fn load_tokens(&self) {
224        let mut m = self.tokens.lock().unwrap_or_else(|e| e.into_inner());
225        for org in self.store.orgs() {
226            for w in self.store.list(&org).unwrap_or_default() {
227                if let Some(t) = &w.token {
228                    if let Some(h) = unhex(&t.hash) {
229                        m.insert(
230                            h,
231                            TokenRef {
232                                org: org.clone(),
233                                name: w.name.clone(),
234                                role: w.token_role,
235                            },
236                        );
237                    }
238                }
239            }
240        }
241    }
242
243    /// The principal behind a workspace token, if it is one of ours.
244    pub fn authenticate(&self, token: &str) -> Option<Principal> {
245        if !secret::well_formed(token, TokenKind::Workspace) {
246            return None;
247        }
248        let h = secret::hash_token(token);
249        let m = self.tokens.lock().unwrap_or_else(|e| e.into_inner());
250        let (stored, r) = m.get_key_value(&h)?;
251        if !secret::ct_eq(stored, &h) {
252            return None;
253        }
254        self.last_used
255            .lock()
256            .unwrap_or_else(|e| e.into_inner())
257            .insert(format!("{}/{}", r.org, r.name), now());
258        Some(Principal::workspace(&r.org, &r.name, r.role))
259    }
260
261    fn index(&self, org: &OrgId, w: &Workspace) {
262        let mut m = self.tokens.lock().unwrap_or_else(|e| e.into_inner());
263        m.retain(|_, r| !(r.org == *org && r.name == w.name));
264        if let Some(h) = w.token.as_ref().and_then(|t| unhex(&t.hash)) {
265            m.insert(
266                h,
267                TokenRef {
268                    org: org.clone(),
269                    name: w.name.clone(),
270                    role: w.token_role,
271                },
272            );
273        }
274    }
275
276    /// Mint a new token for `w` (replacing any), keep it encrypted, and
277    /// index it. The old one stops working at once.
278    fn mint(&self, org: &OrgId, w: &mut Workspace) -> Result<()> {
279        let (token, hash) = secret::new_token(TokenKind::Workspace).map_err(Error::from)?;
280        let ct = self.keyring.encrypt(token.as_bytes())?;
281        crate::app::write_atomic(&self.store.token_path(org, &w.name), &ct)?;
282        w.token = Some(TokenMeta {
283            id: random_hex(4),
284            hash: hex(&hash),
285            created_at: now(),
286        });
287        self.index(org, w);
288        Ok(())
289    }
290
291    fn token_plain(&self, org: &OrgId, name: &str) -> Result<Option<Vec<u8>>> {
292        match std::fs::read(self.store.token_path(org, name)) {
293            Ok(ct) => Ok(Some(self.keyring.decrypt(&ct)?)),
294            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
295            Err(e) => Err(e.into()),
296        }
297    }
298
299    /// Count a session on an instance while the guard lives.
300    pub fn session(&self, project: &str, instance: &str) -> SessionGuard {
301        let k = key(project, instance);
302        *self
303            .sessions
304            .lock()
305            .unwrap_or_else(|e| e.into_inner())
306            .entry(k.clone())
307            .or_insert(0) += 1;
308        self.mark_active(project, instance);
309        SessionGuard {
310            key: k,
311            sessions: self.sessions.clone(),
312        }
313    }
314
315    fn terminals(&self, project: &str, instance: &str) -> usize {
316        self.sessions
317            .lock()
318            .unwrap_or_else(|e| e.into_inner())
319            .get(&key(project, instance))
320            .copied()
321            .unwrap_or(0)
322    }
323
324    /// isb saw the instance used (exec, a terminal).
325    pub fn mark_active(&self, project: &str, instance: &str) {
326        self.activity
327            .lock()
328            .unwrap_or_else(|e| e.into_inner())
329            .insert(key(project, instance), now());
330    }
331
332    /// The latest activity isb saw on an instance, if any.
333    pub fn last_seen(&self, project: &str, instance: &str) -> Option<u64> {
334        self.activity
335            .lock()
336            .unwrap_or_else(|e| e.into_inner())
337            .get(&key(project, instance))
338            .copied()
339    }
340
341    fn last_active(&self, project: &str, instance: &str) -> u64 {
342        let seen = self
343            .activity
344            .lock()
345            .unwrap_or_else(|e| e.into_inner())
346            .get(&key(project, instance))
347            .copied()
348            .unwrap_or(0);
349        // A daemon that just started has seen nothing yet: it counts from
350        // its own start, never from before.
351        seen.max(self.started)
352    }
353
354    /// The org's workspace settings.
355    pub fn settings(&self, org: &OrgId) -> Result<Settings> {
356        self.store.settings(org)
357    }
358
359    /// `http://<gateway>:<port>`: where the org's instances reach isb.
360    pub fn url(&self, org: &OrgId) -> Option<String> {
361        let (gw, _) = self.gateway(org)?;
362        Some(format!("http://{gw}:{}", self.port))
363    }
364
365    fn gateway(&self, org: &OrgId) -> Option<(Ipv4Addr, (u32, u32))> {
366        let info = crate::org::get(&self.client, org).ok()?;
367        gateway(info.subnet.as_deref()?)
368    }
369
370    /// The listener config and registry the bridge listeners serve.
371    pub fn set_serving(&self, l: Listener, registry: Arc<Registry>, healthz: Healthz) {
372        let _ = self.serve.set((l, registry, healthz));
373    }
374
375    /// Serve the org's bridge listener if the org has a workspace, stop it
376    /// if not. Idempotent.
377    fn ensure_bridge(&self, org: &OrgId) {
378        let wanted = !self.store.list(org).unwrap_or_default().is_empty();
379        let mut b = self.bridges.lock().unwrap_or_else(|e| e.into_inner());
380        if !wanted {
381            if let Some(old) = b.remove(org) {
382                old.stop.trigger();
383                eprintln!(
384                    "isb serve: org {org}: MCP on {} stopped (no workspace)",
385                    old.addr
386                );
387            }
388            return;
389        }
390        let Some((gw, net)) = self.gateway(org) else {
391            return;
392        };
393        let addr = SocketAddr::new(IpAddr::V4(gw), self.port);
394        if b.get(org).is_some_and(|x| x.addr == addr) {
395            return;
396        }
397        if let Some(old) = b.remove(org) {
398            old.stop.trigger();
399        }
400        let Some((l, reg, hz)) = self.serve.get() else {
401            return;
402        };
403        let inner = crate::server::handler(l, reg.clone(), hz.clone());
404        let h = bridge_handler(org.clone(), net, inner);
405        let stop = Shutdown::new();
406        match crate::server::spawn_private(addr, h, stop.clone()) {
407            Ok(_) => {
408                eprintln!(
409                    "isb serve: org {org}: MCP for its workspace on http://{addr}/orgs/{org}/mcp (its own subnet, bearer tokens only)"
410                );
411                b.insert(org.clone(), Bridge { addr, stop });
412            }
413            Err(e) => {
414                eprintln!("isb serve: org {org}: cannot serve MCP on {addr}: {e} (retrying)")
415            }
416        }
417    }
418
419    /// Stop every bridge listener.
420    pub fn shutdown(&self) {
421        for (_, b) in self
422            .bridges
423            .lock()
424            .unwrap_or_else(|e| e.into_inner())
425            .drain()
426        {
427            b.stop.trigger();
428        }
429    }
430
431    fn record(&self, org: &OrgId, kind: &str, object: &str, actor: &str, msg: String, d: Value) {
432        self.recorder.record(crate::history::NewRecord {
433            source: "controller".into(),
434            org: Some(org.as_str().to_string()),
435            project: Some(org.incus_project()),
436            kind: kind.into(),
437            object_type: Some(if kind.starts_with("sandbox.") {
438                "instance".into()
439            } else {
440                "workspace".into()
441            }),
442            object: Some(object.to_string()),
443            objects: vec![object.to_string()],
444            actor: Some(actor.to_string()),
445            level: Some("info".into()),
446            message: Some(msg),
447            details: d,
448            ..Default::default()
449        });
450    }
451
452    // ---- the machine ----
453
454    fn oc(&self, org: &OrgId) -> Client {
455        crate::org::client(&self.client, org)
456    }
457
458    /// The storage pool the org's instances use (its default profile's root
459    /// disk), else the host's pick.
460    fn pool(&self, oc: &Client) -> Result<String> {
461        if let Ok(Some(p)) = oc.get_opt("/1.0/profiles/default") {
462            if let Some(pool) = p["devices"]["root"]["pool"].as_str() {
463                return Ok(pool.to_string());
464            }
465        }
466        crate::sandbox::host_facts(oc)?.pick_pool(None)
467    }
468
469    /// Where a new workspace's home goes: the org's `home_pool`, else
470    /// `--workspace-pool`, else the org's default pool.
471    fn new_home_pool(&self, org: &OrgId, oc: &Client) -> Result<String> {
472        let s = self.store.settings(org)?;
473        match s.home_pool.or_else(|| self.home_pool.clone()) {
474            Some(p) => {
475                pool_driver(&self.client, &p)
476                    .map_err(|e| Error::invalid(format!("workspace home pool {p}: {e}")))?;
477                Ok(p)
478            }
479            None => self.pool(oc),
480        }
481    }
482
483    /// The pool this workspace's home is in.
484    fn home_pool_of(&self, w: &Workspace, oc: &Client) -> Result<String> {
485        match &w.pool {
486            Some(p) => Ok(p.clone()),
487            None => self.pool(oc),
488        }
489    }
490
491    /// The instance spec a workspace is created (and rebuilt) from.
492    fn spec(org: &OrgId, w: &Workspace, pool: &str) -> Result<crate::spec::SandboxSpec> {
493        let home = w.home_dir();
494        let mut v = json!({
495            "container_name": w.name,
496            "image": w.image,
497            "user": w.user,
498            "working_dir": home,
499            "labels": {"isb.workspace": w.name, "isb.owner": w.created_by},
500            "raw_config": {"boot.autostart": "true"},
501        });
502        for (k, val) in &w.labels {
503            v["labels"][k] = json!(val);
504        }
505        if let Some(c) = w.cpus {
506            v["cpus"] = json!(c.to_string());
507        }
508        if let Some(m) = &w.memory {
509            v["mem_limit"] = json!(m);
510        }
511        if let Some(r) = &w.root_size {
512            v["raw_devices"] = json!({"root": {"size": r}});
513        }
514        if w.home_bind.is_some() {
515            // The daemon's uid 1:1, so the workspace user (that uid) owns
516            // the host folder's files inside, as the host sees them.
517            v["idmap"] = json!("auto");
518        }
519        v["volumes"] = match &w.home_bind {
520            Some(dir) => json!([{"type": "bind", "source": dir, "target": home}]),
521            None => {
522                json!([{"type": "volume", "source": w.home_volume(org), "target": home, "pool": pool}])
523            }
524        };
525        serde_json::from_value(v).map_err(|e| Error::invalid(format!("workspace spec: {e}")))
526    }
527
528    /// Create (or recreate) the instance, make the user and its home, and
529    /// deliver the credentials.
530    fn build(&self, org: &OrgId, w: &Workspace, log: &mut Vec<String>) -> Result<()> {
531        let oc = self.oc(org);
532        let pool = self.home_pool_of(w, &oc)?;
533        if let Some(dir) = &w.home_bind {
534            self.prepare_host_home(org, Path::new(dir), log)?;
535        }
536        if w.home_bind.is_none() {
537            let mut cfg = crate::plan::Props::new();
538            cfg.insert("size".into(), w.home_size.clone());
539            if crate::volume::ensure(&oc, &pool, &w.home_volume(org), &cfg)? {
540                log.push(format!(
541                    "home volume {} ({}) created",
542                    w.home_volume(org),
543                    w.home_size
544                ));
545            }
546        }
547        let mut w = w.clone();
548        if w.root_size.is_none() && project_has_disk_limit(&self.client, org) {
549            // incus counts every disk against limits.disk, so a root
550            // without a size cannot be created in such a project.
551            w.root_size = Some(DEFAULT_ROOT_SIZE.into());
552        }
553        let w = &w;
554        let mut spec = Self::spec(org, w, &pool)?;
555        if nesting::org_allows(&self.client, org) {
556            nesting::apply(&mut spec);
557        }
558        let base = std::env::temp_dir();
559        let (_sb, _) = Sandbox::connect_or_create_with_base(
560            &oc,
561            &spec,
562            &Default::default(),
563            &base,
564            crate::sandbox::EnsureOptions::default(),
565            &mut |m| log.push(m.to_string()),
566        )?;
567        self.prepare(&oc, w)?;
568        self.deliver(org, w)?;
569        Ok(())
570    }
571
572    /// A host-folder home: made if missing (the daemon's user owns it, which
573    /// the instance maps 1:1), and allowed in the org's restricted project
574    /// as a disk path (its `<root>/<org>` under the home root, else the
575    /// folder itself). Done on every build, so it survives the org's bind
576    /// roots being rewritten.
577    fn prepare_host_home(&self, org: &OrgId, dir: &Path, log: &mut Vec<String>) -> Result<()> {
578        use std::os::unix::fs::PermissionsExt;
579        if !dir.is_absolute() {
580            return Err(Error::invalid(format!(
581                "home {}: an absolute host path",
582                dir.display()
583            )));
584        }
585        if !dir.exists() {
586            std::fs::create_dir_all(dir)?;
587            std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o750))?;
588            log.push(format!("home folder {} created", dir.display()));
589        }
590        let allow = match &self.home_root {
591            Some(root) if dir.starts_with(root.join(org.as_str())) => root.join(org.as_str()),
592            _ => dir.to_path_buf(),
593        };
594        crate::org::allow_home(&self.client, org, &allow)
595    }
596
597    /// The workspace user and its home: made when the image lacks them,
598    /// seeded from `/etc/skel` when the home is empty, owned by the user.
599    fn prepare(&self, oc: &Client, w: &Workspace) -> Result<()> {
600        const SCRIPT: &str = r#"set -e
601u="$1"; h="$2"; id="$3"
602if ! id -u "$u" >/dev/null 2>&1; then
603  if command -v useradd >/dev/null 2>&1; then
604    useradd ${id:+-u "$id"} -M -d "$h" -s /bin/bash "$u" 2>/dev/null || useradd ${id:+-u "$id"} -M -d "$h" "$u"
605  else
606    adduser ${id:+-u "$id"} -D -H -h "$h" "$u"
607  fi
608fi
609mkdir -p "$h"
610if [ -z "$(ls -A "$h" 2>/dev/null)" ] && [ -d /etc/skel ]; then
611  cp -rT /etc/skel "$h"
612  chown -R "$u": "$h"
613fi
614chown "$u": "$h"
615"#;
616        if w.user == "root" {
617            return Ok(());
618        }
619        let sb = Sandbox::get(oc, w.instance())?;
620        let out = sb.exec_with(
621            [
622                "sh",
623                "-c",
624                SCRIPT,
625                "sh",
626                w.user.as_str(),
627                &w.home_dir(),
628                // A host-folder home is mapped 1:1 for the daemon's uid: a
629                // user made here gets that uid, so it owns the files.
630                &if w.home_bind.is_some() {
631                    rustix::process::getuid().as_raw().to_string()
632                } else {
633                    String::new()
634                },
635            ],
636            crate::exec::ExecOptions::default().timeout(Duration::from_secs(120)),
637        )?;
638        if !out.success() {
639            return Err(Error::invalid(format!(
640                "could not prepare user {} in {}: {}",
641                w.user,
642                w.name,
643                String::from_utf8_lossy(&out.stderr).trim()
644            )));
645        }
646        Ok(())
647    }
648
649    /// Live sessions: web terminals through this daemon, and established
650    /// SSH connections inside the machine.
651    pub fn sessions(&self, org: &OrgId, w: &Workspace, running: bool) -> Sessions {
652        let terminals = self.terminals(&org.incus_project(), w.instance());
653        let k = key(&org.incus_project(), w.instance());
654        let cached = self
655            .ssh
656            .lock()
657            .unwrap_or_else(|e| e.into_inner())
658            .get(&k)
659            .filter(|(at, _)| at.elapsed() < SSH_CACHE)
660            .map(|(_, n)| *n);
661        let ssh = if let (true, Some(n)) = (running, cached) {
662            n
663        } else if running {
664            Sandbox::get(&self.oc(org), w.instance())
665                .and_then(|sb| {
666                    sb.exec_with(
667                        [
668                            "sh",
669                            "-c",
670                            "command -v ss >/dev/null 2>&1 || exit 3; ss -Htn state established '( sport = :22 )' | wc -l",
671                        ],
672                        crate::exec::ExecOptions::default().timeout(Duration::from_secs(10)),
673                    )
674                })
675                .ok()
676                .filter(|o| o.success())
677                .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
678        } else {
679            None
680        };
681        if running && cached.is_none() {
682            self.ssh
683                .lock()
684                .unwrap_or_else(|e| e.into_inner())
685                .insert(k, (std::time::Instant::now(), ssh));
686        }
687        Sessions {
688            terminals,
689            ssh,
690            total: terminals + ssh.unwrap_or(0),
691        }
692    }
693
694    // ---- upkeep ----
695
696    /// Bridges, credential delivery after restarts, and the reaper, on a
697    /// thread of their own.
698    pub fn start(self: &Arc<Self>, ctl: crate::stack::Controller, local: LocalOrg) {
699        let me = self.clone();
700        self.setups_interrupted();
701        let _ = std::thread::Builder::new()
702            .name("isb-workspaces".into())
703            .spawn(move || {
704                let mut last_reap = std::time::Instant::now();
705                loop {
706                    me.upkeep(&local);
707                    me.poll_secrets(&ctl, &local);
708                    if last_reap.elapsed() >= REAP_EVERY {
709                        me.reap(&ctl, &local);
710                        last_reap = std::time::Instant::now();
711                    }
712                    std::thread::sleep(UPKEEP_EVERY);
713                }
714            });
715    }
716
717    fn upkeep(self: &Arc<Self>, local: &LocalOrg) {
718        for org in self.store.orgs() {
719            if !local(&org) {
720                continue;
721            }
722            self.ensure_bridge(&org);
723            for w in self.store.list(&org).unwrap_or_default() {
724                let oc = self.oc(&org);
725                let pid = oc
726                    .get_opt(&format!(
727                        "/1.0/instances/{}/state",
728                        encode_segment(w.instance())
729                    ))
730                    .ok()
731                    .flatten()
732                    .and_then(|s| s["pid"].as_i64())
733                    .unwrap_or(0);
734                if pid <= 0 {
735                    continue;
736                }
737                let k = key(&org.incus_project(), w.instance());
738                let done = self
739                    .delivered
740                    .lock()
741                    .unwrap_or_else(|e| e.into_inner())
742                    .get(&k)
743                    .is_some_and(|p| *p == pid);
744                if ws::setup_due(&w) {
745                    self.kick_setup(&org, &w.name);
746                }
747                if !done {
748                    match self.deliver(&org, &w) {
749                        Ok(()) => eprintln!(
750                            "isb serve: workspace {org}/{}: credentials delivered (pid {pid})",
751                            w.name
752                        ),
753                        Err(e) => eprintln!(
754                            "isb serve: workspace {org}/{}: credentials not delivered: {e}",
755                            w.name
756                        ),
757                    }
758                }
759            }
760        }
761    }
762
763    /// Delete sandboxes past their expiry or idle timeout. Idempotent: an
764    /// instance already gone is not an error.
765    fn reap(&self, ctl: &crate::stack::Controller, local: &LocalOrg) {
766        let snap = ctl.snapshot();
767        let t = now();
768        for i in snap.instances.values() {
769            if i.cpu_pct.is_some_and(|c| c >= ACTIVE_CPU_PCT) {
770                self.mark_active(&i.project, &i.name);
771            }
772        }
773        for i in snap.instances.values() {
774            let k = key(&i.project, &i.name);
775            if self.terminals(&i.project, &i.name) > 0 {
776                continue;
777            }
778            let Some(reason) = ws::reap_reason(&i.labels, t, self.last_active(&i.project, &i.name))
779            else {
780                continue;
781            };
782            let Some(org) = crate::org::OrgId::from_incus_project(&i.project) else {
783                continue;
784            };
785            if !local(&org) {
786                continue;
787            }
788            let oc = self.oc(&org);
789            // Read it again: the sample may be stale, and only an instance
790            // that still has the deadline it was sampled with is taken.
791            let Ok(sb) = Sandbox::get(&oc, &i.name) else {
792                continue;
793            };
794            let Ok(info) = sb.info() else { continue };
795            let labels: BTreeMap<String, String> = info
796                .config
797                .iter()
798                .filter_map(|(k, v)| k.strip_prefix("user.").map(|k| (k.to_string(), v.clone())))
799                .collect();
800            if ws::reap_reason(&labels, t, self.last_active(&i.project, &i.name)).is_none() {
801                continue;
802            }
803            match Sandbox::remove(&oc, &i.name, true) {
804                Ok(()) => {
805                    eprintln!("isb serve: reaped sandbox {org}/{} ({reason})", i.name);
806                    self.record(
807                        &org,
808                        "sandbox.reaped",
809                        &i.name,
810                        "isb",
811                        format!("sandbox {} deleted: {reason}", i.name),
812                        json!({
813                            "reason": reason,
814                            "owner": labels.get("isb.owner"),
815                            "expires_at": labels.get("isb.expires_at"),
816                            "idle_timeout": labels.get("isb.idle_timeout"),
817                        }),
818                    );
819                }
820                Err(e) if e.is_not_found() => {}
821                Err(e) => eprintln!("isb serve: could not reap {org}/{}: {e}", i.name),
822            }
823            self.activity
824                .lock()
825                .unwrap_or_else(|e| e.into_inner())
826                .remove(&k);
827        }
828    }
829}
830
831/// Is this org served here (not placed on another server)?
832pub type LocalOrg = Arc<dyn Fn(&OrgId) -> bool + Send + Sync>;
833
834/// A new workspace's home snapshot schedule, and how many are kept.
835const HOME_SNAPSHOTS: &str = "@hourly";
836const HOME_SNAPSHOTS_KEEP: u32 = 24;
837
838/// The root disk's size when none is given in an org with a disk quota.
839const DEFAULT_ROOT_SIZE: &str = "20GiB";
840
841/// A sandbox's root disk size when it gives none in an org with a disk
842/// quota.
843pub const SANDBOX_ROOT_SIZE: &str = "10GiB";
844
845/// A storage pool's driver (`zfs`, `btrfs`, `lvm`, `dir`, ...).
846pub fn pool_driver(client: &Client, pool: &str) -> Result<String> {
847    let p = client
848        .get_opt(&format!("/1.0/storage-pools/{}", encode_segment(pool)))?
849        .ok_or_else(|| Error::NotFound(format!("storage pool {pool}")))?;
850    Ok(p["driver"].as_str().unwrap_or("").to_string())
851}
852
853/// Whether snapshots on this driver share blocks with the volume (cheap),
854/// rather than copying it whole (`dir`).
855pub fn copy_on_write(driver: &str) -> bool {
856    matches!(driver, "zfs" | "btrfs" | "lvm" | "ceph")
857}
858
859/// Whether the org's project has a disk quota (`limits.disk`).
860pub fn project_has_disk_limit(client: &Client, org: &OrgId) -> bool {
861    client
862        .get_opt(&format!(
863            "/1.0/projects/{}",
864            encode_segment(&org.incus_project())
865        ))
866        .ok()
867        .flatten()
868        .is_some_and(|p| p["config"]["limits.disk"].as_str().is_some())
869}
870
871fn unhex(s: &str) -> Option<Vec<u8>> {
872    if s.len() % 2 != 0 {
873        return None;
874    }
875    (0..s.len())
876        .step_by(2)
877        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
878        .collect()
879}
880
881// ---- the tools ----
882
883/// Who may do what to a workspace: admins and above create, change,
884/// rebuild and delete it; members start, stop and attach; viewers read.
885fn require(c: &Caller, org: &OrgId, min: Role, what: &str) -> Result<()> {
886    match c {
887        Caller::Local { .. } | Caller::Superadmin(_) => Ok(()),
888        Caller::User { principal: p } if p.platform_admin => Ok(()),
889        Caller::User { principal: p } => match p.role_in(org) {
890            Some(r) if r >= min => Ok(()),
891            Some(r) => Err(Error::Forbidden(format!(
892                "{what} is for the org's {}s and above; you are a {r} in {org}",
893                min.as_str()
894            ))),
895            None => Err(Error::Forbidden(format!("no access to org {org}"))),
896        },
897        _ => Err(Error::Forbidden(format!("{what} needs an isb account"))),
898    }
899}
900
901/// The name a call means: the one given, else the org's only workspace,
902/// else `workspace`.
903fn resolve_name(w: &Workspaces, org: &OrgId, given: Option<&str>) -> Result<String> {
904    if let Some(n) = given {
905        ws::check_name(n)?;
906        return Ok(n.to_string());
907    }
908    let all = w.store.list(org)?;
909    Ok(match all.as_slice() {
910        [one] => one.name.clone(),
911        _ => ws::DEFAULT_NAME.to_string(),
912    })
913}
914
915fn load(w: &Workspaces, org: &OrgId, name: &str) -> Result<Workspace> {
916    w.store
917        .get(org, name)?
918        .ok_or_else(|| Error::NotFound(format!("org {org} has no workspace {name}")))
919}
920
921/// Who created it, for `isb.owner` and the definition.
922fn creator(c: &Caller) -> String {
923    match c {
924        Caller::Local { .. } => "local".into(),
925        Caller::Superadmin(s) => s.label(),
926        Caller::User { principal } if principal.is_workspace() => {
927            crate::auth::WORKSPACE_ACTOR.into()
928        }
929        Caller::User { principal } => principal.user.email.clone(),
930        other => other.to_string(),
931    }
932}
933
934/// The confirmation a disruptive action needs, and what it says when it is
935/// missing.
936fn confirm(
937    d: &Daemon,
938    org: &OrgId,
939    w: &Workspace,
940    action: &str,
941    confirmed: bool,
942) -> Result<Sessions> {
943    let running = instance_status(d, org, w).is_some_and(|s| s.eq_ignore_ascii_case("running"));
944    let s = d.workspaces.sessions(org, w, running);
945    if !confirmed {
946        return Err(Error::invalid(format!(
947            "{action} {} in org {org} ends every session on it ({}). If that is intended, call again with confirm: true.",
948            w.name,
949            s.describe()
950        )));
951    }
952    Ok(s)
953}
954
955fn instance_status(d: &Daemon, org: &OrgId, w: &Workspace) -> Option<String> {
956    Sandbox::get(&d.workspaces.oc(org), w.instance())
957        .and_then(|s| s.info())
958        .ok()
959        .map(|i| i.status)
960}
961
962/// A workspace as the tools show it: its definition, the machine, its
963/// resources, sessions, token metadata and how to connect.
964#[expect(
965    clippy::too_many_lines,
966    reason = "predates the lint ratchet; split it when next changed"
967)]
968fn view(d: &Daemon, org: &OrgId, w: &Workspace, sessions: bool) -> Value {
969    let wsm = &d.workspaces;
970    let oc = wsm.oc(org);
971    let info = Sandbox::get(&oc, w.instance()).and_then(|s| s.info()).ok();
972    let running = info
973        .as_ref()
974        .is_some_and(|i| i.status.eq_ignore_ascii_case("running"));
975    let snap = d.ctl.snapshot();
976    let sample = snap
977        .instances
978        .get(&format!("{}/{}", org.incus_project(), w.instance()));
979    let project = org.incus_project();
980    let home = if w.home_bind.is_some() {
981        json!({"bind": w.home_bind, "path": w.home_dir(), "host_root": wsm.home_root})
982    } else {
983        let pool = wsm.home_pool_of(w, &oc).ok();
984        let driver = pool
985            .as_deref()
986            .and_then(|p| pool_driver(&wsm.client, p).ok());
987        let vol = pool.as_deref().and_then(|p| {
988            crate::volume::get(&oc, p, &w.home_volume(org))
989                .ok()
990                .flatten()
991        });
992        json!({
993            "volume": w.home_volume(org),
994            "pool": pool,
995            "driver": driver,
996            // Copy-on-write: a snapshot costs what changed, not a full copy.
997            "cow": driver.as_deref().map(copy_on_write),
998            "path": w.home_dir(),
999            "size": vol.as_ref().and_then(|v| v.config.get("size").cloned()).unwrap_or_else(|| w.home_size.clone()),
1000            "exists": vol.is_some(),
1001        })
1002    };
1003    let last_used = wsm
1004        .last_used
1005        .lock()
1006        .unwrap_or_else(|e| e.into_inner())
1007        .get(&format!("{org}/{}", w.name))
1008        .copied();
1009    let url = wsm.url(org);
1010    let sandboxes = snap
1011        .instances
1012        .values()
1013        .filter(|i| i.project == project && ws::kind_of(&i.labels) == "sandbox")
1014        .count();
1015    let mut v = serde_json::to_value(w).unwrap_or_default();
1016    if let Some(o) = v.as_object_mut() {
1017        o.remove("token");
1018    }
1019    v["org"] = json!(org.as_str());
1020    v["home_dir"] = json!(w.home_dir());
1021    v["instance"] = match &info {
1022        Some(i) => json!({
1023            "name": i.name,
1024            "status": i.status,
1025            "type": i.instance_type,
1026            "created_at": i.created_at,
1027            "ip": sample.and_then(|s| s.ip.clone()),
1028        }),
1029        None => Value::Null,
1030    };
1031    v["status"] = json!(
1032        info.as_ref()
1033            .map(|i| i.status.clone())
1034            .unwrap_or_else(|| "Missing".into())
1035    );
1036    v["resources"] = json!({
1037        "cpu_pct": sample.and_then(|s| s.cpu_pct),
1038        "cpu_history": sample.map(|s| s.cpu_history.clone()).unwrap_or_default(),
1039        "mem_bytes": sample.and_then(|s| s.mem_bytes),
1040        "disk_bytes": sample.and_then(|s| s.disk_bytes),
1041        "cpus": info.as_ref().and_then(|i| i.config.get("limits.cpu").cloned()),
1042        "memory": info.as_ref().and_then(|i| i.config.get("limits.memory").cloned()),
1043    });
1044    v["home"] = home;
1045    v["sessions"] = if sessions {
1046        json!(wsm.sessions(org, w, running))
1047    } else {
1048        json!({"terminals": wsm.terminals(&project, w.instance())})
1049    };
1050    let last = wsm
1051        .activity
1052        .lock()
1053        .unwrap_or_else(|e| e.into_inner())
1054        .get(&key(&project, w.instance()))
1055        .copied();
1056    v["last_activity"] = json!(last.max(last_used));
1057    v["token"] = match &w.token {
1058        Some(t) => json!({
1059            "id": t.id,
1060            "role": w.token_role,
1061            "created_at": t.created_at,
1062            "last_used": last_used,
1063            "path": ws::TOKEN_PATH,
1064        }),
1065        None => Value::Null,
1066    };
1067    v["connect"] = json!({
1068        "url": url,
1069        "mcp_url": url.as_ref().map(|u| format!("{u}/orgs/{org}/mcp")),
1070        "org": org.as_str(),
1071        "user": w.user,
1072        "token_path": ws::TOKEN_PATH,
1073        "env": ["ISB_URL", "ISB_ORG", "ISB_TOKEN", "ISB_WORKSPACE"],
1074    });
1075    v["sandboxes"] = json!(sandboxes);
1076    let allowed = nesting::org_allows(&d.client, org);
1077    v["nesting"] = json!({
1078        "allowed": allowed,
1079        "active": info.as_ref().is_some_and(|i| nesting::nests(&i.config)),
1080        "warning": allowed.then_some(nesting::WARNING),
1081    });
1082    v
1083}
1084
1085#[derive(Deserialize)]
1086#[serde(deny_unknown_fields)]
1087struct NameArgs {
1088    #[serde(default)]
1089    #[allow(dead_code)]
1090    org: Option<String>,
1091    #[serde(default)]
1092    name: Option<String>,
1093    #[serde(default)]
1094    confirm: bool,
1095}
1096
1097#[derive(Deserialize)]
1098#[serde(deny_unknown_fields)]
1099struct UpdateArgs {
1100    #[serde(default)]
1101    #[allow(dead_code)]
1102    org: Option<String>,
1103    #[serde(default)]
1104    name: Option<String>,
1105    #[serde(default)]
1106    image: Option<String>,
1107    #[serde(default)]
1108    cpus: Option<u32>,
1109    #[serde(default)]
1110    memory: Option<String>,
1111    #[serde(default)]
1112    root_size: Option<String>,
1113    #[serde(default)]
1114    home_size: Option<String>,
1115    #[serde(default)]
1116    env: Option<BTreeMap<String, String>>,
1117    #[serde(default)]
1118    secrets: Option<Vec<String>>,
1119    #[serde(default)]
1120    labels: Option<BTreeMap<String, String>>,
1121    #[serde(default)]
1122    token_role: Option<Role>,
1123    /// The first-boot script (`""` removes it).
1124    #[serde(default)]
1125    setup: Option<String>,
1126    #[serde(default)]
1127    confirm: bool,
1128}
1129
1130#[expect(
1131    clippy::too_many_lines,
1132    reason = "predates the lint ratchet; split it when next changed"
1133)]
1134fn workspace_update(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1135    let org = super::arg_org(&a)?;
1136    let a: UpdateArgs = args(a)?;
1137    require(c, &org, Role::Admin, "changing a workspace")?;
1138    let wsm = d.workspaces.clone();
1139    let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1140    let _g = wsm.lock.lock().unwrap_or_else(|e| e.into_inner());
1141    let mut w = load(&wsm, &org, &name)?;
1142    let resize = a.cpus.is_some_and(|x| Some(x) != w.cpus)
1143        || a.memory
1144            .as_ref()
1145            .is_some_and(|x| Some(x) != w.memory.as_ref())
1146        || a.root_size
1147            .as_ref()
1148            .is_some_and(|x| Some(x) != w.root_size.as_ref())
1149        || a.home_size.as_ref().is_some_and(|x| *x != w.home_size);
1150    if resize {
1151        confirm(d, &org, &w, "Resizing", a.confirm)?;
1152    }
1153    if let Some(s) = &a.root_size {
1154        check_size("root_size", s)?;
1155    }
1156    if let Some(s) = &a.home_size {
1157        check_size("home_size", s)?;
1158    }
1159    check_fields(
1160        a.env.as_ref().unwrap_or(&BTreeMap::new()),
1161        a.secrets.as_deref().unwrap_or(&[]),
1162        a.labels.as_ref().unwrap_or(&BTreeMap::new()),
1163    )?;
1164    if let Some(ss) = &a.secrets {
1165        for s in ss {
1166            d.secrets
1167                .inspect(&org, s)
1168                .map_err(|e| Error::invalid(format!("secret {s}: {e}")))?;
1169        }
1170    }
1171    let oc = wsm.oc(&org);
1172    let path = format!("/1.0/instances/{}", encode_segment(w.instance()));
1173    let mut changed = Vec::new();
1174    let mut patch = serde_json::Map::new();
1175    if let Some(n) = a.cpus {
1176        patch.insert("limits.cpu".into(), json!(n.to_string()));
1177        w.cpus = Some(n);
1178        changed.push("cpus");
1179    }
1180    if let Some(m) = &a.memory {
1181        patch.insert("limits.memory".into(), json!(m));
1182        w.memory = Some(m.clone());
1183        changed.push("memory");
1184    }
1185    if let Some(l) = &a.labels {
1186        for k in w.labels.keys() {
1187            if !l.contains_key(k) {
1188                patch.insert(format!("user.{k}"), json!(""));
1189            }
1190        }
1191        for (k, v) in l {
1192            patch.insert(format!("user.{k}"), json!(v));
1193        }
1194        w.labels = l.clone();
1195        changed.push("labels");
1196    }
1197    if !patch.is_empty() {
1198        oc.mutate(
1199            "PATCH",
1200            &path,
1201            Some(&json!({"config": patch})),
1202            &format!("update workspace {name}"),
1203            oc.timeouts.other,
1204        )?;
1205    }
1206    if let Some(r) = &a.root_size {
1207        let inst = oc.get(&path)?;
1208        let mut root = inst["devices"]["root"].clone();
1209        if root.is_null() {
1210            root = json!({"type": "disk", "path": "/", "pool": wsm.pool(&oc)?});
1211        }
1212        root["size"] = json!(r);
1213        oc.mutate(
1214            "PATCH",
1215            &path,
1216            Some(&json!({"devices": {"root": root}})),
1217            &format!("resize workspace {name}'s root"),
1218            oc.timeouts.other,
1219        )?;
1220        w.root_size = Some(r.clone());
1221        changed.push("root_size");
1222    }
1223    if let Some(h) = &a.home_size {
1224        if w.home_bind.is_none() {
1225            let pool = wsm.home_pool_of(&w, &oc)?;
1226            oc.mutate(
1227                "PATCH",
1228                &format!(
1229                    "/1.0/storage-pools/{}/volumes/custom/{}",
1230                    encode_segment(&pool),
1231                    encode_segment(&w.home_volume(&org))
1232                ),
1233                Some(&json!({"config": {"size": h}})),
1234                &format!("resize workspace {name}'s home"),
1235                oc.timeouts.other,
1236            )?;
1237        }
1238        w.home_size = h.clone();
1239        changed.push("home_size");
1240    }
1241    if let Some(i) = &a.image {
1242        if i.trim().is_empty() {
1243            return Err(Error::invalid("image cannot be empty"));
1244        }
1245        w.image = i.trim().to_string();
1246        changed.push("image (takes effect on rebuild)");
1247    }
1248    let mut redeliver = false;
1249    if let Some(e) = a.env {
1250        w.env = e;
1251        redeliver = true;
1252        changed.push("env");
1253    }
1254    if let Some(s) = a.secrets {
1255        w.secrets = s;
1256        redeliver = true;
1257        changed.push("secrets");
1258    }
1259    if let Some(r) = a.token_role {
1260        w.token_role = token_role(Some(r))?;
1261        wsm.index(&org, &w);
1262        changed.push("token_role");
1263    }
1264    if a.setup.is_some() {
1265        w.setup = setup::check_setup(a.setup)?;
1266        if w.setup.is_none() {
1267            w.setup_state = None;
1268        }
1269        changed.push("setup (runs on the next rebuild, or with workspace_setup_run)");
1270    }
1271    w.updated_at = now();
1272    wsm.store.put(&org, &w)?;
1273    if redeliver {
1274        wsm.deliver(&org, &w)?;
1275    }
1276    wsm.record(
1277        &org,
1278        "workspace.updated",
1279        &name,
1280        &creator(c),
1281        format!("workspace {name} changed: {}", changed.join(", ")),
1282        json!({"changed": changed}),
1283    );
1284    let mut v = view(d, &org, &w, false);
1285    v["changed"] = json!(changed);
1286    Ok(v)
1287}
1288
1289fn power(d: &Daemon, a: Value, c: &Caller, action: &str) -> Result<Value> {
1290    let org = super::arg_org(&a)?;
1291    let a: NameArgs = args(a)?;
1292    require(c, &org, Role::Member, &format!("{action} a workspace"))?;
1293    let wsm = d.workspaces.clone();
1294    let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1295    let w = load(&wsm, &org, &name)?;
1296    let sb = Sandbox::get(&wsm.oc(&org), w.instance())?;
1297    let ended = match action {
1298        "start" => {
1299            sb.start()?;
1300            None
1301        }
1302        "stop" => {
1303            let s = confirm(d, &org, &w, "Stopping", a.confirm)?;
1304            sb.stop(false, Duration::from_secs(30))
1305                .or_else(|_| sb.stop(true, Duration::from_secs(30)))?;
1306            Some(s)
1307        }
1308        _ => {
1309            let s = confirm(d, &org, &w, "Restarting", a.confirm)?;
1310            sb.restart()?;
1311            Some(s)
1312        }
1313    };
1314    if action != "stop" {
1315        // A fresh boot: /run is empty again.
1316        let _ = sb.wait_ready();
1317        wsm.deliver(&org, &w)?;
1318    }
1319    wsm.record(
1320        &org,
1321        &format!("workspace.{action}"),
1322        &name,
1323        &creator(c),
1324        format!("workspace {name}: {action} by {}", creator(c)),
1325        json!({"sessions_ended": ended}),
1326    );
1327    let mut v = view(d, &org, &w, false);
1328    if let Some(s) = ended {
1329        v["sessions_ended"] = json!(s);
1330    }
1331    Ok(v)
1332}
1333
1334#[derive(Deserialize)]
1335#[serde(deny_unknown_fields)]
1336struct RebuildArgs {
1337    #[serde(default)]
1338    #[allow(dead_code)]
1339    org: Option<String>,
1340    #[serde(default)]
1341    name: Option<String>,
1342    #[serde(default)]
1343    image: Option<String>,
1344    #[serde(default)]
1345    confirm: bool,
1346}
1347
1348fn workspace_rebuild(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1349    let org = super::arg_org(&a)?;
1350    let a: RebuildArgs = args(a)?;
1351    require(c, &org, Role::Admin, "rebuilding a workspace")?;
1352    let wsm = d.workspaces.clone();
1353    let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1354    let _g = wsm.lock.lock().unwrap_or_else(|e| e.into_inner());
1355    let mut w = load(&wsm, &org, &name)?;
1356    let ended = confirm(d, &org, &w, "Rebuilding", a.confirm)?;
1357    if let Some(i) = a.image.filter(|i| !i.trim().is_empty()) {
1358        w.image = i.trim().to_string();
1359    }
1360    let oc = wsm.oc(&org);
1361    match Sandbox::remove(&oc, w.instance(), true) {
1362        Ok(()) => {}
1363        Err(e) if e.is_not_found() => {}
1364        Err(e) => return Err(e),
1365    }
1366    let mut log = Vec::new();
1367    wsm.build(&org, &w, &mut log)?;
1368    let t = now();
1369    w.rebuilt_at = Some(t);
1370    w.updated_at = t;
1371    w.setup_state = ws::setup_next(
1372        w.setup.is_some(),
1373        w.setup_state.as_ref(),
1374        ws::SetupEvent::Built,
1375        t,
1376    )?;
1377    wsm.store.put(&org, &w)?;
1378    drop(_g);
1379    wsm.kick_setup(&org, &name);
1380    wsm.record(
1381        &org,
1382        "workspace.rebuilt",
1383        &name,
1384        &creator(c),
1385        format!("workspace {name} rebuilt from {}; home kept", w.image),
1386        json!({"image": w.image, "sessions_ended": ended}),
1387    );
1388    let mut v = view(d, &org, &w, false);
1389    v["log"] = json!(log);
1390    v["sessions_ended"] = json!(ended);
1391    Ok(v)
1392}
1393
1394#[derive(Deserialize)]
1395#[serde(deny_unknown_fields)]
1396struct DeleteArgs {
1397    #[serde(default)]
1398    #[allow(dead_code)]
1399    org: Option<String>,
1400    #[serde(default)]
1401    name: Option<String>,
1402    #[serde(default)]
1403    keep_home: bool,
1404    #[serde(default)]
1405    confirm: bool,
1406}
1407
1408fn workspace_delete(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1409    let org = super::arg_org(&a)?;
1410    let a: DeleteArgs = args(a)?;
1411    require(c, &org, Role::Admin, "deleting a workspace")?;
1412    let wsm = d.workspaces.clone();
1413    let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1414    let _g = wsm.lock.lock().unwrap_or_else(|e| e.into_inner());
1415    let w = load(&wsm, &org, &name)?;
1416    let what = if a.keep_home || w.home_bind.is_some() {
1417        "Deleting (keeping its home)"
1418    } else {
1419        "Deleting, home and all,"
1420    };
1421    let ended = confirm(d, &org, &w, what, a.confirm)?;
1422    let oc = wsm.oc(&org);
1423    match Sandbox::remove(&oc, w.instance(), true) {
1424        Ok(()) => {}
1425        Err(e) if e.is_not_found() => {}
1426        Err(e) => return Err(e),
1427    }
1428    // The token stops working before anything else can fail.
1429    wsm.index(
1430        &org,
1431        &Workspace {
1432            token: None,
1433            ..w.clone()
1434        },
1435    );
1436    let mut home_deleted = false;
1437    if !a.keep_home && w.home_bind.is_none() {
1438        let pool = wsm.home_pool_of(&w, &oc)?;
1439        match crate::volume::remove(&oc, &pool, &w.home_volume(&org)) {
1440            Ok(()) => home_deleted = true,
1441            Err(e) if e.is_not_found() => {}
1442            Err(e) => return Err(e),
1443        }
1444    }
1445    wsm.store.delete(&org, &name)?;
1446    wsm.ensure_bridge(&org);
1447    wsm.record(
1448        &org,
1449        "workspace.deleted",
1450        &name,
1451        &creator(c),
1452        format!(
1453            "workspace {name} deleted; its token revoked{}",
1454            if home_deleted {
1455                ", its home deleted"
1456            } else {
1457                ", its home kept"
1458            }
1459        ),
1460        json!({"home_deleted": home_deleted, "sessions_ended": ended}),
1461    );
1462    Ok(json!({
1463        "ok": true,
1464        "name": name,
1465        "token_revoked": true,
1466        "home_deleted": home_deleted,
1467        "home_volume": (!home_deleted && w.home_bind.is_none()).then(|| w.home_volume(&org)),
1468        "sessions_ended": ended,
1469    }))
1470}
1471
1472fn workspace_token_rotate(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1473    let org = super::arg_org(&a)?;
1474    let a: NameArgs = args(a)?;
1475    require(c, &org, Role::Admin, "rotating a workspace's token")?;
1476    let wsm = d.workspaces.clone();
1477    let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1478    let _g = wsm.lock.lock().unwrap_or_else(|e| e.into_inner());
1479    let mut w = load(&wsm, &org, &name)?;
1480    wsm.mint(&org, &mut w)?;
1481    w.updated_at = now();
1482    wsm.store.put(&org, &w)?;
1483    let delivered = match wsm.deliver(&org, &w) {
1484        Ok(()) => true,
1485        Err(e) => {
1486            eprintln!("isb serve: workspace {org}/{name}: new token not delivered yet: {e}");
1487            false
1488        }
1489    };
1490    let id = w.token.as_ref().map(|t| t.id.clone());
1491    wsm.record(
1492        &org,
1493        "workspace.token_rotated",
1494        &name,
1495        &creator(c),
1496        format!("workspace {name}: token rotated; the old one no longer works"),
1497        json!({"token_id": id}),
1498    );
1499    Ok(json!({
1500        "ok": true,
1501        "name": name,
1502        "token": {"id": id, "role": w.token_role, "created_at": w.token.as_ref().map(|t| t.created_at)},
1503        "delivered": delivered,
1504        "message": format!(
1505            "The old token no longer works. The new one is at {} inside the workspace (new login shells read it into $ISB_TOKEN); it is never shown here.",
1506            ws::TOKEN_PATH
1507        ),
1508    }))
1509}
1510
1511#[expect(
1512    clippy::too_many_lines,
1513    reason = "predates the lint ratchet; split it when next changed"
1514)]
1515pub(super) fn register(r: &mut Registry, d: Arc<Daemon>) -> Result<()> {
1516    let ro = json!({"readOnlyHint": true, "openWorldHint": false});
1517    let destructive = json!({"destructiveHint": true, "openWorldHint": false});
1518    let write = json!({"destructiveHint": false, "openWorldHint": false});
1519
1520    macro_rules! tool {
1521        ($name:expr, $title:expr, $desc:expr, $schema:expr, $ann:expr, $f:expr) => {{
1522            let d = d.clone();
1523            let f = $f;
1524            r.register(
1525                Tool::new($name, $desc, $schema, move |a, c| {
1526                    // An org that does not exist is refused before anything.
1527                    crate::org::check_exists(&d.client, &super::arg_org(&a)?)?;
1528                    f(&d, a, c)
1529                })
1530                .title($title)
1531                .annotations($ann.clone()),
1532            )?;
1533        }};
1534    }
1535    let name =
1536        || json!({"type": "string", "description": "The workspace (default: the org's only one)."});
1537    let confirm_p = || json!({"type": "boolean", "description": "Required: this ends live sessions on the workspace. Without it the call only says what would end."});
1538
1539    tool!(
1540        "workspace_get",
1541        "Get the workspace",
1542        "The org's workspace (its long-lived machine, docs/concepts/workspaces.md): image, size, home volume, status, CPU and memory, live sessions (web terminals, SSH), last activity, its token's metadata (never the token) and how to connect; `workspace` is null when the org has none yet, and `create` then says what one can be made from (`images`, `default_image`) and the org's quota and usage (`quota`). Also the org's workspace settings.",
1543        obj(json!({"name": name()}), &[]),
1544        ro,
1545        |d: &Daemon, a: Value, _c: &Caller| -> Result<Value> {
1546            let org = super::arg_org(&a)?;
1547            #[derive(Deserialize)]
1548            #[serde(deny_unknown_fields)]
1549            struct A {
1550                #[serde(default)]
1551                #[allow(dead_code)]
1552                org: Option<String>,
1553                #[serde(default)]
1554                name: Option<String>,
1555            }
1556            let a: A = args(a)?;
1557            let wsm = &d.workspaces;
1558            let name = resolve_name(wsm, &org, a.name.as_deref())?;
1559            let w = wsm.store.get(&org, &name)?;
1560            let create = w.is_none().then(|| images::create_options(wsm, &org));
1561            Ok(json!({
1562                "org": org.as_str(),
1563                "settings": wsm.store.settings(&org)?,
1564                "workspace": w.map(|w| view(d, &org, &w, true)),
1565                "create": create,
1566            }))
1567        }
1568    );
1569    tool!(
1570        "workspace_list",
1571        "List workspaces",
1572        "The org's workspaces (one per org unless a platform admin raised max_workspaces), as workspace_get shows each, without the session count.",
1573        obj(json!({}), &[]),
1574        ro,
1575        |d: &Daemon, a: Value, _c: &Caller| -> Result<Value> {
1576            let org = super::arg_org(&a)?;
1577            let wsm = &d.workspaces;
1578            let all: Vec<Value> = wsm
1579                .store
1580                .list(&org)?
1581                .iter()
1582                .map(|w| view(d, &org, w, false))
1583                .collect();
1584            Ok(
1585                json!({"org": org.as_str(), "settings": wsm.store.settings(&org)?, "workspaces": all}),
1586            )
1587        }
1588    );
1589    tool!(
1590        "workspace_create",
1591        "Create the workspace",
1592        "Create the org's workspace: an unprivileged container from `image` with a managed home volume (`home_size`, counted against the org's disk quota) at the workspace user's home, and an org token (role `token_role`, default admin) delivered inside as /run/isb/token and $ISB_TOKEN, with $ISB_URL and $ISB_ORG, so the isb CLI and MCP clients inside work with no setup. One per org. Org admins and above.",
1593        obj(
1594            json!({
1595                "name": {"type": "string", "description": "Default: workspace."},
1596                "image": {"type": "string", "description": "An incus image (a local alias such as dev-base, or a remote one such as images:ubuntu/24.04) or registry:APP:TAG. Default: dev-base when this host has it, else images:ubuntu/24.04; workspace_get lists the choices."},
1597                "user": {"type": "string", "description": "The workspace user (default dev); created when the image lacks it."},
1598                "cpus": {"type": "integer", "minimum": 1},
1599                "memory": {"type": "string", "description": "e.g. 8GiB."},
1600                "root_size": {"type": "string", "description": "The root disk, e.g. 30GiB (default: the pool's)."},
1601                "home_size": {"type": "string", "description": "The home volume (default 20GiB)."},
1602                "env": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Plain variables for login shells."},
1603                "secrets": {"type": "array", "items": {"type": "string"}, "description": "Org secrets delivered as /run/isb/secrets/NAME."},
1604                "labels": {"type": "object", "additionalProperties": {"type": "string"}},
1605                "token_role": {"type": "string", "enum": ["viewer", "member", "admin"], "description": "The workspace token's role in the org (default admin)."},
1606                "home_bind": {"type": "string", "description": "Superadmins only: this host directory as the home (an existing box's, when migrating), instead of the default home."},
1607                "setup": {"type": "string", "description": "A first-boot script, run once as root on the first start (and after each rebuild, or on request with workspace_setup_run), logged to the history. Not for secrets."}
1608            }),
1609            &[]
1610        ),
1611        write,
1612        workspace_create
1613    );
1614    tool!(
1615        "workspace_update",
1616        "Change the workspace",
1617        "Change the workspace: cpus, memory, root_size and home_size apply at once (resizing needs confirm: true, since it can end sessions); env and secrets are delivered again (new login shells see them); image applies on the next rebuild; labels; token_role. Fields left out are kept. Org admins and above.",
1618        obj(
1619            json!({
1620                "name": name(),
1621                "image": {"type": "string"},
1622                "cpus": {"type": "integer", "minimum": 1},
1623                "memory": {"type": "string"},
1624                "root_size": {"type": "string"},
1625                "home_size": {"type": "string", "description": "Grow the home volume."},
1626                "env": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Replaces the variables."},
1627                "secrets": {"type": "array", "items": {"type": "string"}, "description": "Replaces the delivered secrets."},
1628                "labels": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Replaces the labels."},
1629                "token_role": {"type": "string", "enum": ["viewer", "member", "admin"]},
1630                "setup": {"type": "string", "description": "Replaces the first-boot script (\"\" removes it); it runs on the next rebuild or with workspace_setup_run."},
1631                "confirm": confirm_p()
1632            }),
1633            &[]
1634        ),
1635        write,
1636        workspace_update
1637    );
1638    tool!(
1639        "workspace_start",
1640        "Start the workspace",
1641        "Start the workspace and deliver its credentials. Org members and above.",
1642        obj(json!({"name": name()}), &[]),
1643        write,
1644        |d: &Daemon, a: Value, c: &Caller| power(d, a, c, "start")
1645    );
1646    tool!(
1647        "workspace_stop",
1648        "Stop the workspace",
1649        "Stop the workspace. This ends every session on it (terminals, SSH, the agents running there): without confirm: true it only reports the live sessions. Org members and above.",
1650        obj(json!({"name": name(), "confirm": confirm_p()}), &[]),
1651        write,
1652        |d: &Daemon, a: Value, c: &Caller| power(d, a, c, "stop")
1653    );
1654    tool!(
1655        "workspace_restart",
1656        "Restart the workspace",
1657        "Restart the workspace. This ends every session on it: without confirm: true it only reports the live sessions. Org members and above.",
1658        obj(json!({"name": name(), "confirm": confirm_p()}), &[]),
1659        write,
1660        |d: &Daemon, a: Value, c: &Caller| power(d, a, c, "restart")
1661    );
1662    tool!(
1663        "workspace_rebuild",
1664        "Rebuild the workspace",
1665        "Replace the workspace's machine with a fresh one from its image (or `image`), keeping its home volume and token: what to do when the root is damaged. Ends every session; needs confirm: true. Software installed outside the home is gone. Org admins and above.",
1666        obj(
1667            json!({"name": name(), "image": {"type": "string", "description": "Rebuild from this image instead (it becomes the workspace's)."}, "confirm": confirm_p()}),
1668            &[]
1669        ),
1670        destructive,
1671        workspace_rebuild
1672    );
1673    tool!(
1674        "workspace_delete",
1675        "Delete the workspace",
1676        "Delete the workspace: its machine, its token (revoked at once) and, unless keep_home, its home volume. Needs confirm: true. Org admins and above.",
1677        obj(
1678            json!({"name": name(), "keep_home": {"type": "boolean", "description": "Keep the home volume (it can be attached to a new workspace of the same name)."}, "confirm": confirm_p()}),
1679            &[]
1680        ),
1681        destructive,
1682        workspace_delete
1683    );
1684    tool!(
1685        "workspace_token_rotate",
1686        "Rotate the workspace's token",
1687        "Mint the workspace a new token and deliver it inside; the old one stops working at once. The token is never returned. Org admins and above.",
1688        obj(json!({"name": name()}), &[]),
1689        write,
1690        workspace_token_rotate
1691    );
1692    tool!(
1693        "workspace_settings",
1694        "Workspace settings",
1695        "The org's workspace settings: max_workspaces (1; platform admins can raise it), and the defaults for new sandboxes, sandbox_expiry (24h, at most 30d) and sandbox_idle (2h, or none); and secret_refresh (1h, at least 10s), how often the workspaces' secrets that are driver references (vault/item/field) are checked for a new version, which is written into running workspaces without a restart. Without changes it reads them; org admins change the sandbox defaults and secret_refresh.",
1696        obj(
1697            json!({
1698                "max_workspaces": {"type": "integer", "minimum": 1, "maximum": 100},
1699                "sandbox_expiry": {"type": "string", "description": "e.g. 24h, 7d."},
1700                "sandbox_idle": {"type": "string", "description": "e.g. 2h, or none."},
1701                "secret_refresh": {"type": "string", "description": "e.g. 1h, 5m; at least 10s."},
1702                "home_kind": {"type": "string", "enum": ["", "volume", "host"], "description": "Platform admins: where new workspace homes go: a managed volume, or a host folder under isb serve's --workspace-home-root (\"\": the daemon's default)."},
1703                "home_pool": {"type": "string", "description": "Platform admins: the storage pool new workspace homes go in (\"\" clears it: the daemon's --workspace-pool, else the org's default pool)."}
1704            }),
1705            &[]
1706        ),
1707        write,
1708        settings::workspace_settings
1709    );
1710    tool!(
1711        "workspace_setup_run",
1712        "Run the workspace's setup script",
1713        "Run the workspace's first-boot setup script again, as root: now when the workspace is running, else on its next start. Its outcome and the tail of its output go to the history (workspace.setup). Org admins and above.",
1714        obj(json!({"name": name()}), &[]),
1715        write,
1716        setup::workspace_setup_run
1717    );
1718    tool!(
1719        "workspace_terminals",
1720        "The workspace's terminal sessions",
1721        "How the workspace's web terminal works and its live sessions: mode `herdr` when herdr is installed in the workspace (each tab is a herdr tab in the `isb web` workspace of the user's herdr server, so a reload or a dropped connection reattaches to the same shell; `sessions` lists them), else `shell` (plain shells that end with their tab). Org members and above.",
1722        obj(json!({"name": name()}), &[]),
1723        ro,
1724        herdr::workspace_terminals
1725    );
1726    tool!(
1727        "workspace_terminal_update",
1728        "Rename or end a terminal session",
1729        "A herdr-backed web terminal session: `rename` it (the herdr tab's label), or `end: true` to close it and every shell in it. Org members and above.",
1730        obj(
1731            json!({
1732                "name": name(),
1733                "session": {"type": "string", "description": "The session (its tab's name)."},
1734                "rename": {"type": "string", "description": "Its new name."},
1735                "end": {"type": "boolean", "description": "Close the session and its shells."}
1736            }),
1737            &["session"]
1738        ),
1739        write,
1740        herdr::workspace_terminal_update
1741    );
1742    image_tools::register(r, d.clone())?;
1743    nesting::register(r, d.clone())?;
1744    ports::register(r, d)?;
1745    Ok(())
1746}
1747
1748#[cfg(test)]
1749mod tests {
1750    use super::*;
1751
1752    #[test]
1753    fn the_instance_spec_has_the_home_the_size_and_the_labels() {
1754        let org = OrgId::new("acme").unwrap();
1755        let mut w: Workspace = serde_json::from_value(json!({
1756            "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
1757            "home_size": "5GiB", "token_role": "admin", "created_at": 0, "created_by": "a@x.io",
1758            "cpus": 2, "memory": "2GiB", "root_size": "30GiB", "labels": {"team": "ops"}
1759        }))
1760        .unwrap();
1761        let s = Workspaces::spec(&org, &w, "default").unwrap();
1762        assert_eq!(s.name.as_deref(), Some("workspace"));
1763        assert_eq!(s.user.as_deref(), Some("dev"));
1764        assert_eq!(s.working_dir.as_deref(), Some("/home/dev"));
1765        assert_eq!(s.cpus.as_deref(), Some("2"));
1766        assert!(s.memory.is_some());
1767        assert_eq!(s.labels["isb.workspace"], "workspace");
1768        assert_eq!(s.labels["isb.owner"], "a@x.io");
1769        assert_eq!(s.labels["team"], "ops");
1770        assert_eq!(s.raw_devices["root"]["size"], "30GiB");
1771        assert_eq!(s.raw_config["boot.autostart"], "true");
1772        assert_eq!(s.volumes.len(), 1);
1773        assert_eq!(s.volumes[0].source, "acme_workspace_home");
1774        assert_eq!(s.volumes[0].target, "/home/dev");
1775        // A migration bind instead of the volume.
1776        w.home_bind = Some("/srv/workspaces/acme/home".into());
1777        let s = Workspaces::spec(&org, &w, "default").unwrap();
1778        assert_eq!(s.volumes[0].source, "/srv/workspaces/acme/home");
1779    }
1780
1781    /// A workspace manager over `state`, its incus a socket that is not
1782    /// there.
1783    pub(super) fn manager(state: &Path, home_root: Option<PathBuf>) -> Workspaces {
1784        let keyring = Arc::new(crate::secrets::Keyring::new(
1785            age::x25519::Identity::generate(),
1786            vec![],
1787        ));
1788        Workspaces {
1789            store: Store::new(state),
1790            client: Client::with_socket(state.join("no-incus.sock")),
1791            keyring: keyring.clone(),
1792            secrets: Arc::new(crate::secrets::Secrets::new(
1793                crate::secrets::LocalDriver::new(state, keyring),
1794            )),
1795            recorder: crate::history::Recorder::start(Arc::new(
1796                crate::audit::AuditLog::open(&state.join("a.db"), Duration::from_secs(60)).unwrap(),
1797            )),
1798            port: DEFAULT_PORT,
1799            home_pool: None,
1800            home_root,
1801            tokens: Mutex::new(HashMap::new()),
1802            last_used: Mutex::new(HashMap::new()),
1803            sessions: Arc::new(Mutex::new(HashMap::new())),
1804            activity: Mutex::new(HashMap::new()),
1805            delivered: Mutex::new(HashMap::new()),
1806            secret_poll: Mutex::default(),
1807            bridges: Mutex::new(HashMap::new()),
1808            ssh: Mutex::new(HashMap::new()),
1809            serve: OnceLock::new(),
1810            lock: Mutex::new(()),
1811            started: 0,
1812            previews: Previews::default(),
1813        }
1814    }
1815
1816    pub(super) fn a_workspace() -> Workspace {
1817        serde_json::from_value(json!({
1818            "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
1819            "home_size": "1GiB", "token_role": "admin", "created_at": 0, "created_by": "a"
1820        }))
1821        .unwrap()
1822    }
1823
1824    #[test]
1825    fn workspace_tokens_authenticate_as_the_org_workspace_and_rotate() {
1826        let d = tempfile::tempdir().unwrap();
1827        let org = OrgId::new("acme").unwrap();
1828        let m = manager(d.path(), None);
1829        let store = m.store.clone();
1830        let mut w = a_workspace();
1831        m.mint(&org, &mut w).unwrap();
1832        store.put(&org, &w).unwrap();
1833        let t1 = String::from_utf8(m.token_plain(&org, "workspace").unwrap().unwrap()).unwrap();
1834        assert!(t1.starts_with("isb_ws_"));
1835        // The definition keeps a hash, never the token.
1836        let on_disk =
1837            std::fs::read_to_string(d.path().join("orgs/acme/workspaces/workspace.json")).unwrap();
1838        assert!(!on_disk.contains(&t1));
1839        let p = m.authenticate(&t1).unwrap();
1840        assert_eq!(p.role_in(&org), Some(Role::Admin));
1841        assert!(p.is_workspace() && !p.platform_admin);
1842        assert_eq!(p.orgs.len(), 1);
1843        assert!(m.authenticate("isb_ws_nope").is_none());
1844        // Rotating: the old token is refused at once.
1845        m.mint(&org, &mut w).unwrap();
1846        let t2 = String::from_utf8(m.token_plain(&org, "workspace").unwrap().unwrap()).unwrap();
1847        assert_ne!(t1, t2);
1848        assert!(m.authenticate(&t1).is_none());
1849        assert!(m.authenticate(&t2).is_some());
1850        // A daemon starting over the same state knows it.
1851        store.put(&org, &w).unwrap();
1852        m.tokens.lock().unwrap().clear();
1853        m.load_tokens();
1854        assert!(m.authenticate(&t2).is_some());
1855        // Deleting revokes.
1856        m.index(&org, &Workspace { token: None, ..w });
1857        assert!(m.authenticate(&t2).is_none());
1858        // Sessions count while their guard lives.
1859        let g = m.session("isb-acme", "workspace");
1860        assert_eq!(m.terminals("isb-acme", "workspace"), 1);
1861        drop(g);
1862        assert_eq!(m.terminals("isb-acme", "workspace"), 0);
1863    }
1864}