1use 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#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
26pub struct WriteFileArgs {
27 pub path: String,
29 pub content: String,
31 #[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#[derive(Debug, Clone, Default, Deserialize, Serialize)]
48pub struct WriteFileOutput {
49 pub size: u64,
51}
52
53pub type WriteFileHook = DynToolHook<WriteFileArgs, WriteFileOutput>;
55
56#[derive(Clone)]
58pub struct WriteFileTool {
59 workspaces: Vec<PathBuf>,
60 description: String,
61}
62
63impl WriteFileTool {
64 pub const NAME: &'static str = "write_file";
66
67 pub fn new(workspace: PathBuf) -> Self {
72 Self::with_workspaces([workspace])
73 }
74
75 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 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}
148fn 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 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 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}