1use 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
22pub 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 #[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 #[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 #[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 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 #[must_use]
132 pub fn control(&self) -> WorkspaceToolRuntimeControl {
133 WorkspaceToolRuntimeControl {
134 sessions: Arc::clone(&self.sessions),
135 }
136 }
137}
138
139pub struct WorkspaceToolRuntimeControl {
141 sessions: Arc<ShellSessions>,
142}
143
144impl WorkspaceToolRuntimeControl {
145 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}