Skip to main content

nanocodex_tools/
workspace_runtime.rs

1//! Focused retained runtime for the canonical local workspace tools.
2//!
3//! This runtime is intentionally smaller than the agent-facing
4//! `crate::runtime` module available in native builds. It lets constrained
5//! process boundaries, including the VM guest, reuse the exact workspace
6//! handlers without linking Code Mode, MCP, HTTP clients, or provider
7//! transports.
8
9use std::{
10    ffi::OsString,
11    path::PathBuf,
12    sync::{Arc, atomic::AtomicU64},
13};
14
15use crate::{
16    StandardTool, Tool, ToolContext, ToolInput, ToolOutput,
17    apply_patch::ApplyPatchHandler,
18    shell::{ExecCommandHandler, ShellSessions, WriteStdinHandler},
19    view_image::ViewImageHandler,
20};
21
22/// Retained canonical workspace-tool runtime.
23///
24/// Shell processes and interactive sessions live for the lifetime of this
25/// value, so a later `write_stdin` call can address a session created by
26/// `exec_command`.
27pub struct WorkspaceToolRuntime {
28    apply_patch: ApplyPatchHandler,
29    exec_command: ExecCommandHandler,
30    view_image: ViewImageHandler,
31    write_stdin: WriteStdinHandler,
32    sessions: Arc<ShellSessions>,
33}
34
35impl WorkspaceToolRuntime {
36    /// Creates a runtime rooted at `workspace` with a sanitized guest process
37    /// environment.
38    #[must_use]
39    pub fn new(workspace: PathBuf) -> Self {
40        Self::with_optional_view_image_wire_limit(
41            workspace,
42            None,
43            Arc::<Vec<(OsString, OsString)>>::default(),
44        )
45    }
46
47    /// Creates a retained runtime whose `view_image` responses must fit one
48    /// bounded process-transport frame.
49    ///
50    /// This is an internal process-boundary policy. Normal in-process callers
51    /// should use [`Self::new`].
52    #[doc(hidden)]
53    #[must_use]
54    pub fn with_view_image_wire_limit(workspace: PathBuf, max_wire_bytes: u64) -> Self {
55        Self::with_optional_view_image_wire_limit(
56            workspace,
57            Some(max_wire_bytes),
58            Arc::<Vec<(OsString, OsString)>>::default(),
59        )
60    }
61
62    /// Creates a process-boundary runtime with an explicit environment that
63    /// should remain visible to guest shell commands.
64    ///
65    /// Values whose names look sensitive remain available to the isolated
66    /// process while also participating in normal subprocess-output
67    /// sanitization. Normal in-process callers should use [`Self::new`].
68    #[doc(hidden)]
69    #[must_use]
70    pub fn with_environment_and_view_image_wire_limit(
71        workspace: PathBuf,
72        max_wire_bytes: u64,
73        environment: Vec<(OsString, OsString)>,
74    ) -> Self {
75        Self::with_optional_view_image_wire_limit(
76            workspace,
77            Some(max_wire_bytes),
78            Arc::new(environment),
79        )
80    }
81
82    fn with_optional_view_image_wire_limit(
83        workspace: PathBuf,
84        max_wire_bytes: Option<u64>,
85        environment: Arc<Vec<(OsString, OsString)>>,
86    ) -> Self {
87        let sessions = Arc::new(ShellSessions::with_environment_and_turn(
88            environment,
89            Arc::new(AtomicU64::new(0)),
90        ));
91        Self {
92            apply_patch: ApplyPatchHandler::new(workspace.clone()),
93            exec_command: ExecCommandHandler::new(workspace.clone(), Arc::clone(&sessions)),
94            view_image: max_wire_bytes.map_or_else(
95                || ViewImageHandler::new(workspace.clone()),
96                |max_wire_bytes| {
97                    ViewImageHandler::with_wire_limit(workspace.clone(), max_wire_bytes)
98                },
99            ),
100            write_stdin: WriteStdinHandler::new(Arc::clone(&sessions)),
101            sessions,
102        }
103    }
104
105    /// Executes one canonical workspace tool through retained runtime state.
106    pub async fn execute_tool(
107        &self,
108        name: &str,
109        input: ToolInput,
110        context: ToolContext<'_>,
111    ) -> ToolOutput {
112        let result = match name {
113            name if name == StandardTool::ApplyPatch.name() => {
114                self.apply_patch.execute(input, context).await
115            }
116            name if name == StandardTool::ExecCommand.name() => {
117                self.exec_command.execute(input, context).await
118            }
119            name if name == StandardTool::ViewImage.name() => {
120                self.view_image.execute(input, context).await
121            }
122            name if name == StandardTool::WriteStdin.name() => {
123                self.write_stdin.execute(input, context).await
124            }
125            _ => return ToolOutput::error(format!("unknown workspace tool `{name}`")),
126        };
127        result.unwrap_or_else(|error| ToolOutput::error(error.to_string()))
128    }
129
130    /// Returns cancellation and shutdown control for retained subprocesses.
131    #[must_use]
132    pub fn control(&self) -> WorkspaceToolRuntimeControl {
133        WorkspaceToolRuntimeControl {
134            sessions: Arc::clone(&self.sessions),
135        }
136    }
137}
138
139/// Cancellation and shutdown control for a [`WorkspaceToolRuntime`].
140pub struct WorkspaceToolRuntimeControl {
141    sessions: Arc<ShellSessions>,
142}
143
144impl WorkspaceToolRuntimeControl {
145    /// Terminates every retained subprocess and its descendants.
146    pub async fn cancel(&self) {
147        self.sessions.terminate_all().await;
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use nanocodex_oai_api::tools::ToolInput;
154    use serde_json::value::to_raw_value;
155    use tempfile::tempdir;
156
157    use super::*;
158
159    #[tokio::test]
160    async fn retains_shell_sessions_and_cancels_them() {
161        let workspace = tempdir().unwrap();
162        let runtime = WorkspaceToolRuntime::new(workspace.path().to_path_buf());
163        let input = ToolInput::Function(
164            to_raw_value(&serde_json::json!({
165                "cmd": "printf ready; sleep 30",
166                "yield_time_ms": 250
167            }))
168            .unwrap(),
169        );
170        let output = runtime
171            .execute_tool(
172                StandardTool::ExecCommand.name(),
173                input,
174                ToolContext::new("model", "session", "call", &[], 10_000),
175            )
176            .await;
177        assert!(output.success);
178        runtime.control().cancel().await;
179    }
180
181    #[tokio::test]
182    async fn rejects_non_workspace_tools() {
183        let workspace = tempdir().unwrap();
184        let runtime = WorkspaceToolRuntime::new(workspace.path().to_path_buf());
185        let output = runtime
186            .execute_tool(
187                "web_search",
188                ToolInput::Function(to_raw_value(&serde_json::json!({})).unwrap()),
189                ToolContext::new("model", "session", "call", &[], 10_000),
190            )
191            .await;
192        assert!(!output.success);
193    }
194
195    #[cfg(unix)]
196    #[tokio::test]
197    async fn process_boundary_environment_reaches_guest_shell_commands() {
198        let workspace = tempdir().unwrap();
199        let runtime = WorkspaceToolRuntime::with_environment_and_view_image_wire_limit(
200            workspace.path().to_path_buf(),
201            1024 * 1024,
202            vec![(
203                OsString::from("NANOCODEX_IMAGE_ENV"),
204                OsString::from("from-image"),
205            )],
206        );
207        let input = ToolInput::Function(
208            to_raw_value(&serde_json::json!({
209                "cmd": "printf %s \"$NANOCODEX_IMAGE_ENV\""
210            }))
211            .unwrap(),
212        );
213
214        let output = runtime
215            .execute_tool(
216                StandardTool::ExecCommand.name(),
217                input,
218                ToolContext::new("model", "session", "call", &[], 10_000),
219            )
220            .await;
221
222        assert!(output.success);
223        assert_eq!(output.structured_result()["output"], "from-image");
224    }
225}