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#[derive(Debug, Clone, Deserialize, Serialize)]
18pub struct WriteFileArgs {
19 pub path: String,
21 pub content: String,
23 #[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#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40pub struct WriteFileOutput {
41 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 pub const NAME: &'static str = "write_file";
56
57 pub fn new(workspace: PathBuf) -> Self {
60 Self::with_workspaces([workspace])
61 }
62
63 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 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}
202fn 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}