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