Skip to main content

harn_vm/
workspace_anchor.rs

1//! Typed workspace anchor primitives for first-class sessions.
2//!
3//! A workspace anchor names the primary directory a session operates
4//! against plus any additional roots the host has mounted alongside it.
5//! Sessions carry it as `SessionState::workspace_anchor`; the bundle
6//! exporter serializes it; permission matchers consult it; ACP / TUI
7//! flows mutate it through `agent_session_reanchor` and
8//! `agent_session_add_root` (filed separately under #2218 / #2220).
9
10use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value as JsonValue;
14
15use crate::value::VmValue;
16
17/// How a mounted root contributes to the session's effective workspace.
18///
19/// * `ReadOnly` — visible to file-reading tools, blocked for writes.
20/// * `Extend` — fully writable, treated as part of the workspace.
21/// * `Sandboxed` — writable inside the mount but isolated from the
22///   primary anchor for permission scoping.
23#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum MountMode {
26    #[default]
27    ReadOnly,
28    Extend,
29    Sandboxed,
30}
31
32impl MountMode {
33    pub const fn as_str(self) -> &'static str {
34        match self {
35            Self::ReadOnly => "read_only",
36            Self::Extend => "extend",
37            Self::Sandboxed => "sandboxed",
38        }
39    }
40
41    pub fn parse(value: &str) -> Result<Self, String> {
42        match value.trim() {
43            "read_only" => Ok(Self::ReadOnly),
44            "extend" => Ok(Self::Extend),
45            "sandboxed" => Ok(Self::Sandboxed),
46            other => Err(format!(
47                "unknown mount mode '{other}'; expected one of: read_only, extend, sandboxed"
48            )),
49        }
50    }
51}
52
53/// Session-level defaults for workspace-anchor behavior.
54#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
55pub struct WorkspacePolicy {
56    pub default_mount_mode: MountMode,
57}
58
59impl Default for WorkspacePolicy {
60    fn default() -> Self {
61        Self {
62            default_mount_mode: MountMode::ReadOnly,
63        }
64    }
65}
66
67impl WorkspacePolicy {
68    pub fn to_json(&self) -> JsonValue {
69        serde_json::to_value(self).unwrap_or(JsonValue::Null)
70    }
71
72    pub fn to_vm_value(&self) -> VmValue {
73        crate::stdlib::json_to_vm_value(&self.to_json())
74    }
75}
76
77/// Additional workspace root mounted alongside the primary anchor.
78#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
79pub struct MountedRoot {
80    pub path: PathBuf,
81    pub mount_mode: MountMode,
82    pub mounted_at: String,
83}
84
85/// Typed workspace anchor for a session.
86#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87pub struct WorkspaceAnchor {
88    pub primary: PathBuf,
89    #[serde(default)]
90    pub additional_roots: Vec<MountedRoot>,
91    pub anchored_at: String,
92}
93
94impl WorkspaceAnchor {
95    pub fn to_json(&self) -> JsonValue {
96        serde_json::to_value(self).unwrap_or(JsonValue::Null)
97    }
98
99    pub fn from_json(value: &JsonValue) -> Result<Self, String> {
100        serde_json::from_value(value.clone())
101            .map_err(|err| format!("invalid workspace_anchor: {err}"))
102    }
103
104    pub fn to_vm_value(&self) -> VmValue {
105        crate::stdlib::json_to_vm_value(&self.to_json())
106    }
107}
108
109/// Parse a `workspace_anchor` option dict in the shape used by stdlib
110/// builtins (`{primary, additional_roots?, anchored_at?}`).
111///
112/// `anchored_at` defaults to the current RFC3339 timestamp when omitted
113/// so callers don't have to wire a clock through for every open call.
114pub fn parse_anchor_dict(value: &VmValue) -> Result<WorkspaceAnchor, String> {
115    parse_anchor_dict_with_default_mount_mode(value, MountMode::default())
116}
117
118pub fn parse_anchor_dict_with_default_mount_mode(
119    value: &VmValue,
120    default_mount_mode: MountMode,
121) -> Result<WorkspaceAnchor, String> {
122    let dict = value
123        .as_dict()
124        .ok_or_else(|| "workspace_anchor must be a dict".to_string())?;
125
126    let primary = match dict.get("primary") {
127        Some(VmValue::String(s)) if !s.trim().is_empty() => PathBuf::from(s.to_string()),
128        Some(_) => {
129            return Err("workspace_anchor.primary must be a non-empty string".to_string());
130        }
131        None => return Err("workspace_anchor.primary is required".to_string()),
132    };
133
134    let anchored_at = match dict.get("anchored_at") {
135        Some(VmValue::String(s)) if !s.trim().is_empty() => s.to_string(),
136        Some(VmValue::Nil) | None => crate::orchestration::now_rfc3339(),
137        _ => return Err("workspace_anchor.anchored_at must be a string".to_string()),
138    };
139
140    let additional_roots = match dict.get("additional_roots") {
141        None | Some(VmValue::Nil) => Vec::new(),
142        Some(VmValue::List(items)) => items
143            .iter()
144            .map(|value| parse_mounted_root_value(value, default_mount_mode))
145            .collect::<Result<Vec<_>, _>>()?,
146        _ => return Err("workspace_anchor.additional_roots must be a list of dicts".to_string()),
147    };
148
149    Ok(WorkspaceAnchor {
150        primary,
151        additional_roots,
152        anchored_at,
153    })
154}
155
156fn parse_mounted_root_value(
157    value: &VmValue,
158    default_mount_mode: MountMode,
159) -> Result<MountedRoot, String> {
160    let dict = value
161        .as_dict()
162        .ok_or_else(|| "workspace_anchor.additional_roots entry must be a dict".to_string())?;
163
164    let path = match dict.get("path") {
165        Some(VmValue::String(s)) if !s.trim().is_empty() => PathBuf::from(s.to_string()),
166        Some(_) => {
167            return Err(
168                "workspace_anchor.additional_roots[*].path must be a non-empty string".to_string(),
169            );
170        }
171        None => {
172            return Err("workspace_anchor.additional_roots[*].path is required".to_string());
173        }
174    };
175
176    let mount_mode = match dict.get("mount_mode") {
177        None | Some(VmValue::Nil) => default_mount_mode,
178        Some(VmValue::String(s)) => MountMode::parse(s)?,
179        _ => {
180            return Err(
181                "workspace_anchor.additional_roots[*].mount_mode must be a string".to_string(),
182            );
183        }
184    };
185
186    let mounted_at = match dict.get("mounted_at") {
187        Some(VmValue::String(s)) if !s.trim().is_empty() => s.to_string(),
188        Some(VmValue::Nil) | None => crate::orchestration::now_rfc3339(),
189        _ => {
190            return Err(
191                "workspace_anchor.additional_roots[*].mounted_at must be a string".to_string(),
192            );
193        }
194    };
195
196    Ok(MountedRoot {
197        path,
198        mount_mode,
199        mounted_at,
200    })
201}
202
203pub fn parse_workspace_policy_dict(value: &VmValue) -> Result<WorkspacePolicy, String> {
204    let dict = value
205        .as_dict()
206        .ok_or_else(|| "workspace_policy must be a dict".to_string())?;
207    let mut policy = WorkspacePolicy::default();
208    match dict.get("default_mount_mode") {
209        None | Some(VmValue::Nil) => {}
210        Some(VmValue::String(value)) => {
211            policy.default_mount_mode = MountMode::parse(value)?;
212        }
213        Some(_) => return Err("workspace_policy.default_mount_mode must be a string".to_string()),
214    }
215    Ok(policy)
216}
217
218/// Canonical key used for the workspace anchor inside transcript
219/// metadata dicts and bundle exports.
220pub const WORKSPACE_ANCHOR_METADATA_KEY: &str = "workspace_anchor";
221
222/// Read a workspace anchor out of a transcript metadata dict, if any.
223pub fn anchor_from_transcript_metadata_json(metadata: &JsonValue) -> Option<WorkspaceAnchor> {
224    metadata
225        .get(WORKSPACE_ANCHOR_METADATA_KEY)
226        .and_then(|value| WorkspaceAnchor::from_json(value).ok())
227}
228
229#[cfg(test)]
230mod tests {
231    use std::collections::BTreeMap;
232
233    use super::*;
234
235    #[test]
236    fn mount_mode_round_trips() {
237        for mode in [MountMode::ReadOnly, MountMode::Extend, MountMode::Sandboxed] {
238            let parsed = MountMode::parse(mode.as_str()).expect("parse");
239            assert_eq!(parsed, mode);
240        }
241        assert!(MountMode::parse("unknown").is_err());
242    }
243
244    #[test]
245    fn anchor_json_round_trips() {
246        let anchor = WorkspaceAnchor {
247            primary: PathBuf::from("/workspace/main"),
248            additional_roots: vec![MountedRoot {
249                path: PathBuf::from("/workspace/lib"),
250                mount_mode: MountMode::Extend,
251                mounted_at: "2026-05-23T00:00:00Z".to_string(),
252            }],
253            anchored_at: "2026-05-23T00:00:00Z".to_string(),
254        };
255        let json = anchor.to_json();
256        let back = WorkspaceAnchor::from_json(&json).expect("round trip");
257        assert_eq!(anchor, back);
258    }
259
260    #[test]
261    fn parse_anchor_dict_accepts_minimal_shape() {
262        let dict = VmValue::dict(BTreeMap::from([(
263            "primary".to_string(),
264            VmValue::String(arcstr::ArcStr::from("/workspace/main")),
265        )]));
266        let anchor = parse_anchor_dict(&dict).expect("parse minimal");
267        assert_eq!(anchor.primary, PathBuf::from("/workspace/main"));
268        assert!(anchor.additional_roots.is_empty());
269        assert!(!anchor.anchored_at.is_empty());
270    }
271
272    #[test]
273    fn parse_anchor_dict_rejects_missing_primary() {
274        let dict = VmValue::dict_map(Default::default());
275        let err = parse_anchor_dict(&dict).expect_err("missing primary should fail");
276        assert!(err.contains("primary"));
277    }
278
279    #[test]
280    fn parse_anchor_dict_accepts_additional_roots() {
281        let roots = vec![VmValue::dict(BTreeMap::from([
282            (
283                "path".to_string(),
284                VmValue::String(arcstr::ArcStr::from("/workspace/lib")),
285            ),
286            (
287                "mount_mode".to_string(),
288                VmValue::String(arcstr::ArcStr::from("extend")),
289            ),
290            (
291                "mounted_at".to_string(),
292                VmValue::String(arcstr::ArcStr::from("2026-05-23T00:00:00Z")),
293            ),
294        ]))];
295        let dict = VmValue::dict(BTreeMap::from([
296            (
297                "primary".to_string(),
298                VmValue::String(arcstr::ArcStr::from("/workspace/main")),
299            ),
300            (
301                "additional_roots".to_string(),
302                VmValue::List(std::sync::Arc::new(roots)),
303            ),
304        ]));
305        let anchor = parse_anchor_dict(&dict).expect("parse with roots");
306        assert_eq!(anchor.additional_roots.len(), 1);
307        assert_eq!(anchor.additional_roots[0].mount_mode, MountMode::Extend);
308    }
309
310    #[test]
311    fn parse_anchor_dict_uses_supplied_default_mount_mode() {
312        let roots = vec![VmValue::dict(BTreeMap::from([(
313            "path".to_string(),
314            VmValue::String(arcstr::ArcStr::from("/workspace/lib")),
315        )]))];
316        let dict = VmValue::dict(BTreeMap::from([
317            (
318                "primary".to_string(),
319                VmValue::String(arcstr::ArcStr::from("/workspace/main")),
320            ),
321            (
322                "additional_roots".to_string(),
323                VmValue::List(std::sync::Arc::new(roots)),
324            ),
325        ]));
326        let anchor =
327            parse_anchor_dict_with_default_mount_mode(&dict, MountMode::Extend).expect("parse");
328        assert_eq!(anchor.additional_roots[0].mount_mode, MountMode::Extend);
329    }
330
331    #[test]
332    fn parse_workspace_policy_accepts_default_mount_mode() {
333        let dict = VmValue::dict(BTreeMap::from([(
334            "default_mount_mode".to_string(),
335            VmValue::String(arcstr::ArcStr::from("sandboxed")),
336        )]));
337        let policy = parse_workspace_policy_dict(&dict).expect("parse policy");
338        assert_eq!(policy.default_mount_mode, MountMode::Sandboxed);
339    }
340}