Skip to main content

anda_engine/extension/fs/
write.rs

1use anda_core::{BoxError, FunctionDefinition, Resource, StateFeatures, Tool, ToolOutput};
2use ic_auth_types::ByteBufB64;
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use std::{path::PathBuf, str::FromStr};
6
7use super::{
8    BASE64_ENCODING, UTF8_ENCODING, atomic_write_file, default_write_encoding, ensure_regular_file,
9    format_workspaces, normalize_workspaces, resolve_write_path_in_workspaces, tool_workspaces,
10};
11use crate::{
12    context::BaseCtx,
13    hook::{DynToolHook, ToolHook},
14};
15
16/// Arguments for filesystem write operations.
17#[derive(Debug, Clone, Deserialize, Serialize)]
18pub struct WriteFileArgs {
19    /// Relative or absolute path to a file inside the workspace.
20    pub path: String,
21    /// File content encoded as UTF-8 text or base64, depending on `encoding`.
22    pub content: String,
23    /// Content encoding. Supported values are `utf8` and `base64`.
24    #[serde(default = "default_write_encoding")]
25    pub encoding: String,
26}
27
28impl Default for WriteFileArgs {
29    fn default() -> Self {
30        Self {
31            path: String::new(),
32            content: String::new(),
33            encoding: default_write_encoding(),
34        }
35    }
36}
37
38/// Normalized result returned by a filesystem write operation.
39#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40pub struct WriteFileOutput {
41    /// Number of bytes written to the target file.
42    pub size: u64,
43}
44
45pub type WriteFileHook = DynToolHook<WriteFileArgs, WriteFileOutput>;
46
47#[derive(Clone)]
48pub struct WriteFileTool {
49    workspaces: Vec<PathBuf>,
50    description: String,
51}
52
53impl WriteFileTool {
54    /// Tool name used for registration and function definition.
55    pub const NAME: &'static str = "write_file";
56
57    /// Create a new `WriteFileTool` with the default workspace directory.
58    /// You can add workspace directories for each call by including `workspace` or `workspaces` in the tool call's context meta extra.
59    pub fn new(workspace: PathBuf) -> Self {
60        Self::with_workspaces([workspace])
61    }
62
63    /// Create a new `WriteFileTool` with the default workspace directories.
64    /// Context meta workspaces take precedence over these defaults at call time.
65    pub fn with_workspaces<I>(workspaces: I) -> Self
66    where
67        I: IntoIterator<Item = PathBuf>,
68    {
69        let workspaces = normalize_workspaces(workspaces);
70        let description = format!(
71            "Atomically write files to the filesystem in the workspace directories ({})",
72            format_workspaces(&workspaces)
73        );
74        Self {
75            workspaces,
76            description,
77        }
78    }
79
80    pub fn with_description(mut self, description: String) -> Self {
81        self.description = description;
82        self
83    }
84}
85
86impl Tool<BaseCtx> for WriteFileTool {
87    type Args = WriteFileArgs;
88    type Output = WriteFileOutput;
89
90    fn name(&self) -> String {
91        Self::NAME.to_string()
92    }
93
94    fn description(&self) -> String {
95        self.description.clone()
96    }
97
98    fn definition(&self) -> FunctionDefinition {
99        FunctionDefinition {
100            name: self.name(),
101            description: self.description(),
102            parameters: json!({
103                "type": "object",
104                "properties": {
105                    "path": {
106                        "type": "string",
107                        "description": "Path to the file. Relative paths resolve from the configured workspaces in priority order; absolute paths must be inside one configured workspace."
108                    },
109                    "content": {
110                        "type": "string",
111                        "description": "Content to write to the file. If encoding is 'base64', this should be base64-encoded data."
112                    },
113                    "encoding": {
114                        "type": "string",
115                        "description": "Encoding of the content. Can be 'utf8' or 'base64'. Defaults to 'utf8'."
116                    }
117                },
118                "required": ["path", "content"]
119            }),
120            strict: Some(true),
121        }
122    }
123
124    async fn call(
125        &self,
126        ctx: BaseCtx,
127        args: Self::Args,
128        _resources: Vec<Resource>,
129    ) -> Result<ToolOutput<Self::Output>, BoxError> {
130        let hook = ctx.get_state::<WriteFileHook>();
131
132        let args = if let Some(hook) = &hook {
133            hook.before_tool_call(&ctx, args).await?
134        } else {
135            args
136        };
137
138        let workspaces = tool_workspaces(ctx.meta(), &self.workspaces);
139        let resolved = resolve_write_path_in_workspaces(&workspaces, &args.path).await?;
140        let workspace_display = resolved.workspace.display().to_string();
141        let resolved_path = resolved.path;
142
143        let data = decode_content(
144            args.content,
145            &args.encoding,
146            &args.path,
147            &workspace_display,
148            &resolved_path,
149        )?;
150
151        let existing_permissions = match tokio::fs::metadata(&resolved_path).await {
152            Ok(meta) => {
153                ensure_regular_file(
154                    &meta,
155                    &resolved_path,
156                    "Writing multiply-linked files is not allowed",
157                )?;
158
159                Some(meta.permissions())
160            }
161            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
162                if let Some(parent) = resolved_path.parent() {
163                    // Ensure parent directories exist for newly created files.
164                    tokio::fs::create_dir_all(parent)
165                        .await
166                        .map_err(|err| {
167                            format!(
168                                "Failed to create parent directories (workspace: {}, requested_path: {}, resolved_path: {}, parent_path: {}): {err}",
169                                workspace_display,
170                                args.path,
171                                resolved_path.display(),
172                                parent.display()
173                            )
174                        })?;
175                }
176
177                None
178            }
179            Err(err) => {
180                return Err(format!(
181                    "Failed to read file metadata (workspace: {}, requested_path: {}, resolved_path: {}): {err}",
182                    workspace_display,
183                    args.path,
184                    resolved_path.display()
185                )
186                .into())
187            }
188        };
189
190        let size = data.len() as u64;
191        atomic_write_file(&resolved_path, &data, existing_permissions.as_ref()).await?;
192
193        if let Some(hook) = &hook {
194            return hook
195                .after_tool_call(&ctx, ToolOutput::new(WriteFileOutput { size }))
196                .await;
197        }
198
199        Ok(ToolOutput::new(WriteFileOutput { size }))
200    }
201}
202/// Decodes content according to the requested encoding.
203fn decode_content(
204    content: String,
205    encoding: &str,
206    requested_path: &str,
207    workspace: &str,
208    resolved_path: &std::path::Path,
209) -> Result<Vec<u8>, BoxError> {
210    match encoding {
211        UTF8_ENCODING => Ok(content.into_bytes()),
212        BASE64_ENCODING => ByteBufB64::from_str(&content)
213            .map(|decoded| decoded.0)
214            .map_err(|err| {
215                format!(
216                    "Failed to decode base64 content (workspace: {}, requested_path: {}, resolved_path: {}, encoding: {}): {err}",
217                    workspace,
218                    requested_path,
219                    resolved_path.display(),
220                    encoding
221                )
222                .into()
223            }),
224        other => Err(format!(
225            "Unsupported encoding {other:?}. Expected 'utf8' or 'base64' (workspace: {}, requested_path: {}, resolved_path: {})",
226            workspace,
227            requested_path,
228            resolved_path.display()
229        )
230        .into()),
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::{
238        engine::EngineBuilder,
239        extension::fs::{commit_atomic_replace, write_temp_file_for_atomic_replace},
240    };
241    use serde_json::json;
242    use std::path::{Path, PathBuf};
243
244    struct TestTempDir(PathBuf);
245
246    impl TestTempDir {
247        async fn new() -> Self {
248            let path = std::env::temp_dir()
249                .join(format!("anda-fs-write-test-{:016x}", rand::random::<u64>()));
250            tokio::fs::create_dir_all(&path).await.unwrap();
251            Self(path)
252        }
253
254        fn path(&self) -> &Path {
255            &self.0
256        }
257    }
258
259    impl Drop for TestTempDir {
260        fn drop(&mut self) {
261            let _ = std::fs::remove_dir_all(&self.0);
262        }
263    }
264
265    fn mock_ctx() -> BaseCtx {
266        EngineBuilder::new().mock_ctx().base
267    }
268
269    fn mock_ctx_with_workspace(workspace: &Path) -> BaseCtx {
270        let mut ctx = mock_ctx();
271        ctx.meta.extra.insert(
272            "workspace".to_string(),
273            json!(workspace.to_string_lossy().to_string()),
274        );
275        ctx
276    }
277
278    fn write_tool(workspace: &Path) -> WriteFileTool {
279        WriteFileTool::new(workspace.to_path_buf())
280    }
281
282    #[tokio::test]
283    async fn writes_existing_file_in_default_workspace_when_meta_workspace_has_no_match() {
284        let temp_dir = TestTempDir::new().await;
285        let runtime_workspace = temp_dir.path().join("runtime");
286        let home_workspace = temp_dir.path().join("home");
287        tokio::fs::create_dir_all(&runtime_workspace).await.unwrap();
288        tokio::fs::create_dir_all(&home_workspace).await.unwrap();
289        tokio::fs::write(home_workspace.join("notes.txt"), "before")
290            .await
291            .unwrap();
292
293        let result = write_tool(&home_workspace)
294            .call(
295                mock_ctx_with_workspace(&runtime_workspace),
296                WriteFileArgs {
297                    path: "notes.txt".to_string(),
298                    content: "after".to_string(),
299                    encoding: UTF8_ENCODING.to_string(),
300                },
301                Vec::new(),
302            )
303            .await
304            .unwrap();
305
306        assert_eq!(result.output.size, 5);
307        let written = tokio::fs::read_to_string(home_workspace.join("notes.txt"))
308            .await
309            .unwrap();
310        assert_eq!(written, "after");
311        assert!(matches!(
312            tokio::fs::metadata(runtime_workspace.join("notes.txt")).await,
313            Err(err) if err.kind() == std::io::ErrorKind::NotFound
314        ));
315    }
316
317    #[tokio::test]
318    async fn writes_new_relative_file_in_meta_workspace_first() {
319        let temp_dir = TestTempDir::new().await;
320        let runtime_workspace = temp_dir.path().join("runtime");
321        let home_workspace = temp_dir.path().join("home");
322        tokio::fs::create_dir_all(&runtime_workspace).await.unwrap();
323        tokio::fs::create_dir_all(&home_workspace).await.unwrap();
324
325        write_tool(&home_workspace)
326            .call(
327                mock_ctx_with_workspace(&runtime_workspace),
328                WriteFileArgs {
329                    path: "notes.txt".to_string(),
330                    content: "runtime".to_string(),
331                    encoding: UTF8_ENCODING.to_string(),
332                },
333                Vec::new(),
334            )
335            .await
336            .unwrap();
337
338        let written = tokio::fs::read_to_string(runtime_workspace.join("notes.txt"))
339            .await
340            .unwrap();
341        assert_eq!(written, "runtime");
342        assert!(matches!(
343            tokio::fs::metadata(home_workspace.join("notes.txt")).await,
344            Err(err) if err.kind() == std::io::ErrorKind::NotFound
345        ));
346    }
347
348    #[tokio::test]
349    async fn creates_new_file_with_missing_parent_directories() {
350        let temp_dir = TestTempDir::new().await;
351        let workspace = temp_dir.path().join("workspace");
352        tokio::fs::create_dir_all(&workspace).await.unwrap();
353
354        let result = write_tool(&workspace)
355            .call(
356                mock_ctx(),
357                WriteFileArgs {
358                    path: "nested/dir/output.txt".to_string(),
359                    content: "hello".to_string(),
360                    encoding: UTF8_ENCODING.to_string(),
361                },
362                Vec::new(),
363            )
364            .await
365            .unwrap();
366
367        assert_eq!(result.output.size, 5);
368        let written = tokio::fs::read_to_string(workspace.join("nested/dir/output.txt"))
369            .await
370            .unwrap();
371        assert_eq!(written, "hello");
372    }
373
374    #[tokio::test]
375    async fn defaults_encoding_to_utf8_when_missing_from_raw_args() {
376        let temp_dir = TestTempDir::new().await;
377        let workspace = temp_dir.path().join("workspace");
378        tokio::fs::create_dir_all(&workspace).await.unwrap();
379
380        write_tool(&workspace)
381            .call_raw(
382                mock_ctx(),
383                json!({
384                    "path": "notes.txt",
385                    "content": "hello"
386                }),
387                Vec::new(),
388            )
389            .await
390            .unwrap();
391
392        let written = tokio::fs::read_to_string(workspace.join("notes.txt"))
393            .await
394            .unwrap();
395        assert_eq!(written, "hello");
396    }
397
398    #[tokio::test]
399    async fn writes_base64_encoded_content() {
400        let temp_dir = TestTempDir::new().await;
401        let workspace = temp_dir.path().join("workspace");
402        let binary = vec![0x00, 0x7f, 0x80, 0xff];
403        tokio::fs::create_dir_all(&workspace).await.unwrap();
404
405        let result = write_tool(&workspace)
406            .call(
407                mock_ctx(),
408                WriteFileArgs {
409                    path: "payload.bin".to_string(),
410                    content: ByteBufB64(binary.clone()).to_base64(),
411                    encoding: BASE64_ENCODING.to_string(),
412                },
413                Vec::new(),
414            )
415            .await
416            .unwrap();
417
418        assert_eq!(result.output.size, 4);
419        let written = tokio::fs::read(workspace.join("payload.bin"))
420            .await
421            .unwrap();
422        assert_eq!(written, binary);
423    }
424
425    #[tokio::test]
426    async fn rejects_unsupported_encoding() {
427        let temp_dir = TestTempDir::new().await;
428        let workspace = temp_dir.path().join("workspace");
429        tokio::fs::create_dir_all(&workspace).await.unwrap();
430
431        let err = write_tool(&workspace)
432            .call(
433                mock_ctx(),
434                WriteFileArgs {
435                    path: "notes.txt".to_string(),
436                    content: "hello".to_string(),
437                    encoding: "hex".to_string(),
438                },
439                Vec::new(),
440            )
441            .await
442            .unwrap_err();
443
444        assert!(err.to_string().contains("Unsupported encoding"));
445    }
446
447    #[tokio::test]
448    async fn staged_atomic_replace_keeps_previous_content_visible_until_commit() {
449        let temp_dir = TestTempDir::new().await;
450        let workspace = temp_dir.path().join("workspace");
451        let target = workspace.join("notes.txt");
452        tokio::fs::create_dir_all(&workspace).await.unwrap();
453        tokio::fs::write(&target, "before").await.unwrap();
454
455        let metadata = tokio::fs::metadata(&target).await.unwrap();
456        let temp_path =
457            write_temp_file_for_atomic_replace(&target, b"after", Some(&metadata.permissions()))
458                .await
459                .unwrap();
460
461        assert_eq!(tokio::fs::read_to_string(&target).await.unwrap(), "before");
462        assert_eq!(
463            tokio::fs::read_to_string(&temp_path).await.unwrap(),
464            "after"
465        );
466
467        commit_atomic_replace(&temp_path, &target).await.unwrap();
468
469        assert_eq!(tokio::fs::read_to_string(&target).await.unwrap(), "after");
470        assert!(matches!(
471            tokio::fs::metadata(&temp_path).await,
472            Err(err) if err.kind() == std::io::ErrorKind::NotFound
473        ));
474    }
475
476    #[cfg(unix)]
477    #[tokio::test]
478    async fn preserves_permissions_when_replacing_existing_file() {
479        use std::os::unix::fs::PermissionsExt;
480
481        let temp_dir = TestTempDir::new().await;
482        let workspace = temp_dir.path().join("workspace");
483        let target = workspace.join("notes.txt");
484        tokio::fs::create_dir_all(&workspace).await.unwrap();
485        tokio::fs::write(&target, "before").await.unwrap();
486        tokio::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640))
487            .await
488            .unwrap();
489
490        write_tool(&workspace)
491            .call(
492                mock_ctx(),
493                WriteFileArgs {
494                    path: "notes.txt".to_string(),
495                    content: "after".to_string(),
496                    encoding: UTF8_ENCODING.to_string(),
497                },
498                Vec::new(),
499            )
500            .await
501            .unwrap();
502
503        let mode = tokio::fs::metadata(&target)
504            .await
505            .unwrap()
506            .permissions()
507            .mode()
508            & 0o777;
509        assert_eq!(mode, 0o640);
510    }
511
512    #[cfg(unix)]
513    #[tokio::test]
514    async fn writes_files_from_a_symlinked_workspace_root() {
515        use std::os::unix::fs::symlink;
516
517        let temp_dir = TestTempDir::new().await;
518        let workspace = temp_dir.path().join("workspace");
519        let workspace_link = temp_dir.path().join("workspace-link");
520        tokio::fs::create_dir_all(&workspace).await.unwrap();
521        symlink(&workspace, &workspace_link).unwrap();
522
523        let result = write_tool(&workspace_link)
524            .call(
525                mock_ctx(),
526                WriteFileArgs {
527                    path: "notes.txt".to_string(),
528                    content: "hello".to_string(),
529                    encoding: UTF8_ENCODING.to_string(),
530                },
531                Vec::new(),
532            )
533            .await
534            .unwrap();
535
536        assert_eq!(result.output.size, 5);
537        let written = tokio::fs::read_to_string(workspace.join("notes.txt"))
538            .await
539            .unwrap();
540        assert_eq!(written, "hello");
541    }
542
543    #[cfg(unix)]
544    #[tokio::test]
545    async fn rejects_writing_to_symbolic_link_target() {
546        use std::os::unix::fs::symlink;
547
548        let temp_dir = TestTempDir::new().await;
549        let workspace = temp_dir.path().join("workspace");
550        let target = workspace.join("real.txt");
551        tokio::fs::create_dir_all(&workspace).await.unwrap();
552        tokio::fs::write(&target, "before").await.unwrap();
553        symlink(&target, workspace.join("alias.txt")).unwrap();
554
555        let err = write_tool(&workspace)
556            .call(
557                mock_ctx(),
558                WriteFileArgs {
559                    path: "alias.txt".to_string(),
560                    content: "after".to_string(),
561                    encoding: UTF8_ENCODING.to_string(),
562                },
563                Vec::new(),
564            )
565            .await
566            .unwrap_err();
567
568        assert!(
569            err.to_string()
570                .contains("Writing to symbolic links is not allowed")
571        );
572    }
573
574    #[cfg(unix)]
575    #[tokio::test]
576    async fn rejects_symlink_escape_outside_workspace_for_new_files() {
577        use std::os::unix::fs::symlink;
578
579        let temp_dir = TestTempDir::new().await;
580        let workspace = temp_dir.path().join("workspace");
581        let external = temp_dir.path().join("external");
582        tokio::fs::create_dir_all(&workspace).await.unwrap();
583        tokio::fs::create_dir_all(&external).await.unwrap();
584        symlink(&external, workspace.join("escape")).unwrap();
585
586        let err = write_tool(&workspace)
587            .call(
588                mock_ctx(),
589                WriteFileArgs {
590                    path: "escape/secret.txt".to_string(),
591                    content: "secret".to_string(),
592                    encoding: UTF8_ENCODING.to_string(),
593                },
594                Vec::new(),
595            )
596            .await
597            .unwrap_err();
598
599        assert!(
600            err.to_string()
601                .contains("Access to paths outside the workspace is not allowed")
602        );
603    }
604}