Skip to main content

anda_engine/extension/fs/
write.rs

1//! Atomic file write tool for configured workspaces.
2//!
3//! Supports UTF-8, base64, and selected legacy text encodings through the
4//! shared filesystem encoding helpers.
5
6use anda_core::{
7    BoxError, FunctionDefinition, Resource, StateFeatures, Tool, ToolGroupInfo, ToolOutput,
8};
9use ic_auth_types::ByteBufB64;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::{path::PathBuf, str::FromStr};
13
14use super::{
15    BASE64_ENCODING, FileTextEncodeError, WorkspaceScope, default_write_encoding, encode_file_text,
16    format_workspaces, normalize_workspaces,
17};
18use crate::{
19    context::BaseCtx,
20    extension::{hooked_call, tool_definition},
21    hook::DynToolHook,
22};
23
24/// Arguments for filesystem write operations.
25#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
26pub struct WriteFileArgs {
27    /// Path to the file. Relative paths resolve from the configured workspaces in priority order; absolute paths must be inside one configured workspace.
28    pub path: String,
29    /// Content to write to the file. If encoding is 'base64', this should be base64-encoded data; otherwise it is text encoded with the requested encoding.
30    pub content: String,
31    /// Encoding of the content. Can be 'utf8', 'base64', or a supported text encoding such as 'gbk'. Defaults to 'utf8'.
32    #[serde(default = "default_write_encoding")]
33    pub encoding: String,
34}
35
36impl Default for WriteFileArgs {
37    fn default() -> Self {
38        Self {
39            path: String::new(),
40            content: String::new(),
41            encoding: default_write_encoding(),
42        }
43    }
44}
45
46/// Normalized result returned by a filesystem write operation.
47#[derive(Debug, Clone, Default, Deserialize, Serialize)]
48pub struct WriteFileOutput {
49    /// Number of bytes written to the target file.
50    pub size: u64,
51}
52
53/// Typed hook for write-file tool calls.
54pub type WriteFileHook = DynToolHook<WriteFileArgs, WriteFileOutput>;
55
56/// Tool implementation for atomic writes inside configured workspaces.
57#[derive(Clone)]
58pub struct WriteFileTool {
59    workspaces: Vec<PathBuf>,
60    description: String,
61}
62
63impl WriteFileTool {
64    /// Tool name used for registration and function definition.
65    pub const NAME: &'static str = "write_file";
66
67    /// Create a new `WriteFileTool` with the default workspace directory.
68    /// A call may narrow the workspace by including `workspace` or `workspaces` in the tool
69    /// call's context meta extra. Request metadata is caller-controlled, so a requested
70    /// directory is honored only when it resolves inside a configured workspace.
71    pub fn new(workspace: PathBuf) -> Self {
72        Self::with_workspaces([workspace])
73    }
74
75    /// Create a new `WriteFileTool` with the default workspace directories.
76    /// A requested workspace that resolves inside one of these takes precedence at call
77    /// time; one that does not is ignored, so these bound everything the tool can reach.
78    pub fn with_workspaces<I>(workspaces: I) -> Self
79    where
80        I: IntoIterator<Item = PathBuf>,
81    {
82        let workspaces = normalize_workspaces(workspaces);
83        let description = format!(
84            "Atomically write files to the filesystem in the workspace directories ({})",
85            format_workspaces(&workspaces)
86        );
87        Self {
88            workspaces,
89            description,
90        }
91    }
92
93    /// Overrides the function description exposed to the model.
94    pub fn with_description(mut self, description: String) -> Self {
95        self.description = description;
96        self
97    }
98}
99
100impl Tool<BaseCtx> for WriteFileTool {
101    type Args = WriteFileArgs;
102    type Output = WriteFileOutput;
103
104    fn name(&self) -> String {
105        Self::NAME.to_string()
106    }
107
108    fn description(&self) -> String {
109        self.description.clone()
110    }
111
112    fn group(&self) -> Option<ToolGroupInfo> {
113        Some(super::fs_tool_group_info())
114    }
115
116    fn definition(&self) -> FunctionDefinition {
117        tool_definition::<Self::Args>(self.name(), self.description())
118    }
119
120    async fn call(
121        &self,
122        ctx: BaseCtx,
123        args: Self::Args,
124        _resources: Vec<Resource>,
125    ) -> Result<ToolOutput<Self::Output>, BoxError> {
126        let ctx = &ctx;
127        hooked_call(ctx, args, |args| async move {
128            let scope = WorkspaceScope::for_call(ctx.meta(), &self.workspaces).await;
129            let target = scope.open_write(&args.path).await?;
130            let workspace_display = target.workspace.display().to_string();
131
132            let data = decode_content(
133                args.content,
134                &args.encoding,
135                &args.path,
136                &workspace_display,
137                &target.path,
138            )?;
139
140            let size = data.len() as u64;
141            target.write_atomic(&data).await?;
142
143            Ok(ToolOutput::new(WriteFileOutput { size }))
144        })
145        .await
146    }
147}
148/// Decodes content according to the requested encoding.
149fn decode_content(
150    content: String,
151    encoding: &str,
152    requested_path: &str,
153    workspace: &str,
154    resolved_path: &std::path::Path,
155) -> Result<Vec<u8>, BoxError> {
156    match encoding {
157        BASE64_ENCODING => ByteBufB64::from_str(&content)
158            .map(|decoded| decoded.0)
159            .map_err(|err| {
160                format!(
161                    "Failed to decode base64 content (workspace: {}, requested_path: {}, resolved_path: {}, encoding: {}): {err}",
162                    workspace,
163                    requested_path,
164                    resolved_path.display(),
165                    encoding
166                )
167                .into()
168            }),
169        text_encoding => encode_file_text(&content, text_encoding).map_err(|err| match err {
170            FileTextEncodeError::UnsupportedEncoding => format!(
171                "Unsupported encoding {text_encoding:?}. Expected 'utf8', 'base64', or a supported text encoding such as 'gbk' (workspace: {}, requested_path: {}, resolved_path: {})",
172                workspace,
173                requested_path,
174                resolved_path.display()
175            )
176            .into(),
177            FileTextEncodeError::UnmappableCharacters => format!(
178                "Failed to encode text content (workspace: {}, requested_path: {}, resolved_path: {}, encoding: {}): {err}",
179                workspace,
180                requested_path,
181                resolved_path.display(),
182                text_encoding
183            )
184            .into(),
185        }),
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::{
193        engine::EngineBuilder,
194        extension::fs::{UTF8_ENCODING, commit_atomic_replace, write_temp_file_for_atomic_replace},
195        hook::ToolHook,
196    };
197    use serde_json::json;
198    use std::{
199        path::{Path, PathBuf},
200        sync::Arc,
201    };
202
203    struct TestTempDir(PathBuf);
204
205    impl TestTempDir {
206        async fn new() -> Self {
207            let path = std::env::temp_dir()
208                .join(format!("anda-fs-write-test-{:016x}", rand::random::<u64>()));
209            tokio::fs::create_dir_all(&path).await.unwrap();
210            Self(path)
211        }
212
213        fn path(&self) -> &Path {
214            &self.0
215        }
216    }
217
218    impl Drop for TestTempDir {
219        fn drop(&mut self) {
220            let _ = std::fs::remove_dir_all(&self.0);
221        }
222    }
223
224    fn mock_ctx() -> BaseCtx {
225        EngineBuilder::new().mock_ctx().base
226    }
227
228    fn mock_ctx_with_workspace(workspace: &Path) -> BaseCtx {
229        let mut ctx = mock_ctx();
230        ctx.meta.extra.insert(
231            "workspace".to_string(),
232            json!(workspace.to_string_lossy().to_string()),
233        );
234        ctx
235    }
236
237    fn write_tool(workspace: &Path) -> WriteFileTool {
238        WriteFileTool::new(workspace.to_path_buf())
239    }
240
241    struct RewritingWriteHook;
242
243    #[async_trait::async_trait]
244    impl ToolHook<WriteFileArgs, WriteFileOutput> for RewritingWriteHook {
245        async fn before_tool_call(
246            &self,
247            _ctx: &BaseCtx,
248            mut args: WriteFileArgs,
249        ) -> Result<WriteFileArgs, BoxError> {
250            args.path = "hook.txt".to_string();
251            args.content = "hooked".to_string();
252            args.encoding = UTF8_ENCODING.to_string();
253            Ok(args)
254        }
255
256        async fn after_tool_call(
257            &self,
258            _ctx: &BaseCtx,
259            mut output: ToolOutput<WriteFileOutput>,
260        ) -> Result<ToolOutput<WriteFileOutput>, BoxError> {
261            output.output.size += 10;
262            Ok(output)
263        }
264    }
265
266    #[tokio::test]
267    async fn defaults_metadata_invalid_base64_and_hooks_are_covered() {
268        let temp_dir = TestTempDir::new().await;
269        let workspace = temp_dir.path().join("workspace");
270        tokio::fs::create_dir_all(&workspace).await.unwrap();
271
272        let default_args = WriteFileArgs::default();
273        assert_eq!(default_args.path, "");
274        assert_eq!(default_args.content, "");
275        assert_eq!(default_args.encoding, UTF8_ENCODING);
276
277        let tool = write_tool(&workspace).with_description("custom write".to_string());
278        assert_eq!(tool.name(), WriteFileTool::NAME);
279        assert_eq!(tool.description(), "custom write");
280        let definition = tool.definition();
281        assert_eq!(definition.name, WriteFileTool::NAME);
282        assert_eq!(definition.strict, Some(true));
283        assert_eq!(
284            definition.parameters["required"],
285            json!(["path", "content", "encoding"])
286        );
287
288        let err = tool
289            .call(
290                mock_ctx(),
291                WriteFileArgs {
292                    path: "bad.bin".to_string(),
293                    content: "not base64%%".to_string(),
294                    encoding: BASE64_ENCODING.to_string(),
295                },
296                Vec::new(),
297            )
298            .await
299            .unwrap_err();
300        assert!(err.to_string().contains("Failed to decode base64 content"));
301
302        let ctx = mock_ctx();
303        ctx.set_state(WriteFileHook::new(Arc::new(RewritingWriteHook)));
304        let hooked = tool
305            .call(
306                ctx,
307                WriteFileArgs {
308                    path: "ignored.txt".to_string(),
309                    content: "ignored".to_string(),
310                    encoding: UTF8_ENCODING.to_string(),
311                },
312                Vec::new(),
313            )
314            .await
315            .unwrap();
316        assert_eq!(hooked.output.size, 16);
317        assert_eq!(
318            tokio::fs::read_to_string(workspace.join("hook.txt"))
319                .await
320                .unwrap(),
321            "hooked"
322        );
323    }
324
325    #[tokio::test]
326    async fn writes_existing_file_in_default_workspace_when_meta_workspace_has_no_match() {
327        let temp_dir = TestTempDir::new().await;
328        let runtime_workspace = temp_dir.path().join("runtime");
329        let home_workspace = temp_dir.path().join("home");
330        tokio::fs::create_dir_all(&runtime_workspace).await.unwrap();
331        tokio::fs::create_dir_all(&home_workspace).await.unwrap();
332        tokio::fs::write(home_workspace.join("notes.txt"), "before")
333            .await
334            .unwrap();
335
336        let result = write_tool(&home_workspace)
337            .call(
338                mock_ctx_with_workspace(&runtime_workspace),
339                WriteFileArgs {
340                    path: "notes.txt".to_string(),
341                    content: "after".to_string(),
342                    encoding: UTF8_ENCODING.to_string(),
343                },
344                Vec::new(),
345            )
346            .await
347            .unwrap();
348
349        assert_eq!(result.output.size, 5);
350        let written = tokio::fs::read_to_string(home_workspace.join("notes.txt"))
351            .await
352            .unwrap();
353        assert_eq!(written, "after");
354        assert!(matches!(
355            tokio::fs::metadata(runtime_workspace.join("notes.txt")).await,
356            Err(err) if err.kind() == std::io::ErrorKind::NotFound
357        ));
358    }
359
360    #[tokio::test]
361    async fn writes_new_relative_file_in_meta_workspace_first() {
362        let temp_dir = TestTempDir::new().await;
363        let home_workspace = temp_dir.path().join("home");
364        let nested_workspace = home_workspace.join("nested");
365        tokio::fs::create_dir_all(&nested_workspace).await.unwrap();
366
367        // A requested workspace inside the configured one is honored and wins.
368        write_tool(&home_workspace)
369            .call(
370                mock_ctx_with_workspace(&nested_workspace),
371                WriteFileArgs {
372                    path: "notes.txt".to_string(),
373                    content: "nested".to_string(),
374                    encoding: UTF8_ENCODING.to_string(),
375                },
376                Vec::new(),
377            )
378            .await
379            .unwrap();
380
381        let written = tokio::fs::read_to_string(nested_workspace.join("notes.txt"))
382            .await
383            .unwrap();
384        assert_eq!(written, "nested");
385        assert!(matches!(
386            tokio::fs::metadata(home_workspace.join("notes.txt")).await,
387            Err(err) if err.kind() == std::io::ErrorKind::NotFound
388        ));
389    }
390
391    #[tokio::test]
392    async fn ignores_meta_workspace_outside_the_configured_workspace() {
393        let temp_dir = TestTempDir::new().await;
394        let runtime_workspace = temp_dir.path().join("runtime");
395        let home_workspace = temp_dir.path().join("home");
396        tokio::fs::create_dir_all(&runtime_workspace).await.unwrap();
397        tokio::fs::create_dir_all(&home_workspace).await.unwrap();
398
399        // Request metadata is caller-controlled, so a sibling directory must not become a
400        // write target; the write lands in the configured workspace instead.
401        write_tool(&home_workspace)
402            .call(
403                mock_ctx_with_workspace(&runtime_workspace),
404                WriteFileArgs {
405                    path: "notes.txt".to_string(),
406                    content: "home".to_string(),
407                    encoding: UTF8_ENCODING.to_string(),
408                },
409                Vec::new(),
410            )
411            .await
412            .unwrap();
413
414        let written = tokio::fs::read_to_string(home_workspace.join("notes.txt"))
415            .await
416            .unwrap();
417        assert_eq!(written, "home");
418        assert!(matches!(
419            tokio::fs::metadata(runtime_workspace.join("notes.txt")).await,
420            Err(err) if err.kind() == std::io::ErrorKind::NotFound
421        ));
422    }
423
424    #[tokio::test]
425    async fn creates_new_file_with_missing_parent_directories() {
426        let temp_dir = TestTempDir::new().await;
427        let workspace = temp_dir.path().join("workspace");
428        tokio::fs::create_dir_all(&workspace).await.unwrap();
429
430        let result = write_tool(&workspace)
431            .call(
432                mock_ctx(),
433                WriteFileArgs {
434                    path: "nested/dir/output.txt".to_string(),
435                    content: "hello".to_string(),
436                    encoding: UTF8_ENCODING.to_string(),
437                },
438                Vec::new(),
439            )
440            .await
441            .unwrap();
442
443        assert_eq!(result.output.size, 5);
444        let written = tokio::fs::read_to_string(workspace.join("nested/dir/output.txt"))
445            .await
446            .unwrap();
447        assert_eq!(written, "hello");
448    }
449
450    #[tokio::test]
451    async fn defaults_encoding_to_utf8_when_missing_from_raw_args() {
452        let temp_dir = TestTempDir::new().await;
453        let workspace = temp_dir.path().join("workspace");
454        tokio::fs::create_dir_all(&workspace).await.unwrap();
455
456        write_tool(&workspace)
457            .call_raw(
458                mock_ctx(),
459                json!({
460                    "path": "notes.txt",
461                    "content": "hello"
462                }),
463                Vec::new(),
464            )
465            .await
466            .unwrap();
467
468        let written = tokio::fs::read_to_string(workspace.join("notes.txt"))
469            .await
470            .unwrap();
471        assert_eq!(written, "hello");
472    }
473
474    #[tokio::test]
475    async fn writes_base64_encoded_content() {
476        let temp_dir = TestTempDir::new().await;
477        let workspace = temp_dir.path().join("workspace");
478        let binary = vec![0x00, 0x7f, 0x80, 0xff];
479        tokio::fs::create_dir_all(&workspace).await.unwrap();
480
481        let result = write_tool(&workspace)
482            .call(
483                mock_ctx(),
484                WriteFileArgs {
485                    path: "payload.bin".to_string(),
486                    content: ByteBufB64(binary.clone()).to_base64(),
487                    encoding: BASE64_ENCODING.to_string(),
488                },
489                Vec::new(),
490            )
491            .await
492            .unwrap();
493
494        assert_eq!(result.output.size, 4);
495        let written = tokio::fs::read(workspace.join("payload.bin"))
496            .await
497            .unwrap();
498        assert_eq!(written, binary);
499    }
500
501    #[tokio::test]
502    async fn writes_legacy_text_encoding_content() {
503        let temp_dir = TestTempDir::new().await;
504        let workspace = temp_dir.path().join("workspace");
505        tokio::fs::create_dir_all(&workspace).await.unwrap();
506
507        let result = write_tool(&workspace)
508            .call(
509                mock_ctx(),
510                WriteFileArgs {
511                    path: "notes.txt".to_string(),
512                    content: "中文.txt\n".to_string(),
513                    encoding: "gbk".to_string(),
514                },
515                Vec::new(),
516            )
517            .await
518            .unwrap();
519
520        assert_eq!(result.output.size, 9);
521        let written = tokio::fs::read(workspace.join("notes.txt")).await.unwrap();
522        assert_eq!(
523            written,
524            vec![0xd6, 0xd0, 0xce, 0xc4, b'.', b't', b'x', b't', b'\n']
525        );
526    }
527
528    #[tokio::test]
529    async fn rejects_unsupported_encoding() {
530        let temp_dir = TestTempDir::new().await;
531        let workspace = temp_dir.path().join("workspace");
532        tokio::fs::create_dir_all(&workspace).await.unwrap();
533
534        let err = write_tool(&workspace)
535            .call(
536                mock_ctx(),
537                WriteFileArgs {
538                    path: "notes.txt".to_string(),
539                    content: "hello".to_string(),
540                    encoding: "hex".to_string(),
541                },
542                Vec::new(),
543            )
544            .await
545            .unwrap_err();
546
547        assert!(err.to_string().contains("Unsupported encoding"));
548    }
549
550    #[tokio::test]
551    async fn staged_atomic_replace_keeps_previous_content_visible_until_commit() {
552        let temp_dir = TestTempDir::new().await;
553        let workspace = temp_dir.path().join("workspace");
554        let target = workspace.join("notes.txt");
555        tokio::fs::create_dir_all(&workspace).await.unwrap();
556        tokio::fs::write(&target, "before").await.unwrap();
557
558        let metadata = tokio::fs::metadata(&target).await.unwrap();
559        let temp_path =
560            write_temp_file_for_atomic_replace(&target, b"after", Some(&metadata.permissions()))
561                .await
562                .unwrap();
563
564        assert_eq!(tokio::fs::read_to_string(&target).await.unwrap(), "before");
565        assert_eq!(
566            tokio::fs::read_to_string(&temp_path).await.unwrap(),
567            "after"
568        );
569
570        commit_atomic_replace(&temp_path, &target).await.unwrap();
571
572        assert_eq!(tokio::fs::read_to_string(&target).await.unwrap(), "after");
573        assert!(matches!(
574            tokio::fs::metadata(&temp_path).await,
575            Err(err) if err.kind() == std::io::ErrorKind::NotFound
576        ));
577    }
578
579    #[cfg(unix)]
580    #[tokio::test]
581    async fn preserves_permissions_when_replacing_existing_file() {
582        use std::os::unix::fs::PermissionsExt;
583
584        let temp_dir = TestTempDir::new().await;
585        let workspace = temp_dir.path().join("workspace");
586        let target = workspace.join("notes.txt");
587        tokio::fs::create_dir_all(&workspace).await.unwrap();
588        tokio::fs::write(&target, "before").await.unwrap();
589        tokio::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640))
590            .await
591            .unwrap();
592
593        write_tool(&workspace)
594            .call(
595                mock_ctx(),
596                WriteFileArgs {
597                    path: "notes.txt".to_string(),
598                    content: "after".to_string(),
599                    encoding: UTF8_ENCODING.to_string(),
600                },
601                Vec::new(),
602            )
603            .await
604            .unwrap();
605
606        let mode = tokio::fs::metadata(&target)
607            .await
608            .unwrap()
609            .permissions()
610            .mode()
611            & 0o777;
612        assert_eq!(mode, 0o640);
613    }
614
615    #[cfg(unix)]
616    #[tokio::test]
617    async fn writes_files_from_a_symlinked_workspace_root() {
618        use std::os::unix::fs::symlink;
619
620        let temp_dir = TestTempDir::new().await;
621        let workspace = temp_dir.path().join("workspace");
622        let workspace_link = temp_dir.path().join("workspace-link");
623        tokio::fs::create_dir_all(&workspace).await.unwrap();
624        symlink(&workspace, &workspace_link).unwrap();
625
626        let result = write_tool(&workspace_link)
627            .call(
628                mock_ctx(),
629                WriteFileArgs {
630                    path: "notes.txt".to_string(),
631                    content: "hello".to_string(),
632                    encoding: UTF8_ENCODING.to_string(),
633                },
634                Vec::new(),
635            )
636            .await
637            .unwrap();
638
639        assert_eq!(result.output.size, 5);
640        let written = tokio::fs::read_to_string(workspace.join("notes.txt"))
641            .await
642            .unwrap();
643        assert_eq!(written, "hello");
644    }
645
646    #[cfg(unix)]
647    #[tokio::test]
648    async fn rejects_writing_to_symbolic_link_target() {
649        use std::os::unix::fs::symlink;
650
651        let temp_dir = TestTempDir::new().await;
652        let workspace = temp_dir.path().join("workspace");
653        let target = workspace.join("real.txt");
654        tokio::fs::create_dir_all(&workspace).await.unwrap();
655        tokio::fs::write(&target, "before").await.unwrap();
656        symlink(&target, workspace.join("alias.txt")).unwrap();
657
658        let err = write_tool(&workspace)
659            .call(
660                mock_ctx(),
661                WriteFileArgs {
662                    path: "alias.txt".to_string(),
663                    content: "after".to_string(),
664                    encoding: UTF8_ENCODING.to_string(),
665                },
666                Vec::new(),
667            )
668            .await
669            .unwrap_err();
670
671        assert!(
672            err.to_string()
673                .contains("Writing to symbolic links is not allowed")
674        );
675    }
676
677    #[cfg(unix)]
678    #[tokio::test]
679    async fn rejects_symlink_escape_outside_workspace_for_new_files() {
680        use std::os::unix::fs::symlink;
681
682        let temp_dir = TestTempDir::new().await;
683        let workspace = temp_dir.path().join("workspace");
684        let external = temp_dir.path().join("external");
685        tokio::fs::create_dir_all(&workspace).await.unwrap();
686        tokio::fs::create_dir_all(&external).await.unwrap();
687        symlink(&external, workspace.join("escape")).unwrap();
688
689        let err = write_tool(&workspace)
690            .call(
691                mock_ctx(),
692                WriteFileArgs {
693                    path: "escape/secret.txt".to_string(),
694                    content: "secret".to_string(),
695                    encoding: UTF8_ENCODING.to_string(),
696                },
697                Vec::new(),
698            )
699            .await
700            .unwrap_err();
701
702        assert!(
703            err.to_string()
704                .contains("Access to paths outside the workspace is not allowed")
705        );
706    }
707}