From c4353eb34aa3d5e9248028f2b3f62e7b5c82a6f7 Mon Sep 17 00:00:00 2001 From: juan <2930882+juacker@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:52:50 +0200 Subject: [PATCH] feat(tools): emit forward-slash paths to the agent on Windows Agent-facing tool results (fs_read, fs_write, fs_list, fs_glob, bash_exec cwd, fs_request_grant) serialized OS-native paths, so on Windows the model received `C:\Users\...` with backslashes. Because bash_exec now runs inside Git Bash (where `\` is an escape char), those paths did not round-trip back into shell commands. Add a cfg-gated `agent_path_string` helper used at every agent-facing path-serialization site: on Windows it strips the `\\?\` verbatim prefix that `std::fs::canonicalize` adds and rewrites `\` to `/`, yielding clean `C:/Users/...` paths that both the Windows file APIs (for fs_read/fs_write round-trips) and Git Bash accept. On Unix the path is returned verbatim, since `\` is a legal byte in a filename. Unix behavior is unchanged. Added a unit test whose Windows branch is exercised by the windows-latest CI job. --- src-tauri/src/assistant/tools/local.rs | 69 +++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/assistant/tools/local.rs b/src-tauri/src/assistant/tools/local.rs index ea22cf0..e013e93 100644 --- a/src-tauri/src/assistant/tools/local.rs +++ b/src-tauri/src/assistant/tools/local.rs @@ -208,7 +208,7 @@ fn execute_fs_list( let (entries, truncated) = list_entries_at_path(&path, params.recursive, limit)?; Ok(serde_json::json!({ - "path": path.display().to_string(), + "path": agent_path_string(&path), "entries": serialize_entries(&entries), "recursive": params.recursive, "truncated": truncated, @@ -267,7 +267,7 @@ fn execute_fs_read( let slice: String = chars[start..end].iter().collect(); Ok(serde_json::json!({ - "path": path.display().to_string(), + "path": agent_path_string(&path), "content": slice, "truncated": end < chars.len(), "offset": start, @@ -304,7 +304,7 @@ fn execute_fs_write( .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?; Ok(serde_json::json!({ - "path": path.display().to_string(), + "path": agent_path_string(&path), "bytesWritten": params.content.len() })) } @@ -394,7 +394,7 @@ async fn execute_bash_exec( .map_err(|error| augment_timeout_error(error, timeout_ms, explicit_timeout))?; Ok(serde_json::json!({ - "cwd": output.cwd.display().to_string(), + "cwd": agent_path_string(&output.cwd), "exitCode": output.exit_code, "success": output.success, "stdout": output.stdout, @@ -944,7 +944,7 @@ fn serialize_entries(entries: &[FilesystemEntry]) -> Vec { .iter() .map(|entry| { serde_json::json!({ - "path": entry.path.display().to_string(), + "path": agent_path_string(&entry.path), "kind": entry.kind }) }) @@ -995,6 +995,30 @@ fn path_to_match_string(path: &Path) -> String { path.to_string_lossy().replace('\\', "/") } +/// Render a path for agent-facing tool output. +/// +/// On Windows the agent's `bash_exec` runs inside Git Bash, where `\` is an +/// escape character, so paths handed to the model must use `/` to round-trip +/// back into shell commands. We also strip the `\\?\` verbatim prefix that +/// `std::fs::canonicalize` adds, leaving clean `C:/Users/...` paths that both +/// the Windows file APIs (for `fs_read`/`fs_write` round-trips) and Git Bash +/// accept. +/// +/// On Unix the path is returned verbatim: `\` is a legal byte in a filename, +/// so rewriting it would corrupt names. +fn agent_path_string(path: &Path) -> String { + #[cfg(windows)] + { + let s = path.to_string_lossy(); + let trimmed = s.strip_prefix(r"\\?\").unwrap_or(s.as_ref()); + trimmed.replace('\\', "/") + } + #[cfg(not(windows))] + { + path.display().to_string() + } +} + /// Why a command was denied. Retained for the back-compat /// [`enforce_command_policy`] wrapper used by unit tests; new code paths /// branch on [`PolicyResult`] directly so they can surface per-segment @@ -1458,7 +1482,7 @@ async fn execute_fs_request_grant( params: FsRequestGrantParams, ) -> Result { let canonical = canonicalize_requested_path(¶ms.path)?; - let canonical_str = canonical.to_string_lossy().into_owned(); + let canonical_str = agent_path_string(&canonical); // If the path is already covered (by extra_paths, the preset, or an // earlier session grant), short-circuit — no user prompt, just say yes. @@ -1502,7 +1526,7 @@ async fn execute_fs_request_grant( context.add_notice(RunNoticeKind::PathGranted, msg); Ok(serde_json::json!({ "granted": true, - "path": path, + "path": agent_path_string(std::path::Path::new(&path)), "access": access_to_str(access), "scope": "once", "note": "Valid for the current turn only — NOT retained for later turns. If you need this path again in a future turn, request it again, or ask the user to grant it always.", @@ -1524,7 +1548,7 @@ async fn execute_fs_request_grant( context.add_notice(RunNoticeKind::PathGranted, msg); Ok(serde_json::json!({ "granted": true, - "path": path, + "path": agent_path_string(std::path::Path::new(&path)), "access": access_to_str(access), "scope": "always", })) @@ -2464,6 +2488,35 @@ mod tests { assert!(error.contains("outside the agent's allowed filesystem grants")); } + #[test] + fn agent_path_string_normalizes_paths_for_the_agent() { + // Windows branch is exercised by the windows-latest CI job. + #[cfg(windows)] + { + assert_eq!( + agent_path_string(std::path::Path::new(r"C:\Users\dev\proj\file.rs")), + "C:/Users/dev/proj/file.rs" + ); + // `std::fs::canonicalize` emits a `\\?\` verbatim prefix; strip it. + assert_eq!( + agent_path_string(std::path::Path::new(r"\\?\C:\Users\dev\file.rs")), + "C:/Users/dev/file.rs" + ); + } + #[cfg(not(windows))] + { + // Unix: returned verbatim (backslash is a legal filename byte). + assert_eq!( + agent_path_string(std::path::Path::new("/home/dev/proj/file.rs")), + "/home/dev/proj/file.rs" + ); + assert_eq!( + agent_path_string(std::path::Path::new("/home/dev/we\\ird")), + "/home/dev/we\\ird" + ); + } + } + #[test] fn extract_ddg_url_decodes_redirect_wrapper() { let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpage&rut=abc123";