Skip to main content

atomcode_core/tool/
open_file.rs

1//! `open_file` tool — launch a local file in the user's default GUI
2//! application (browser for HTML, viewer for PDF / image / SVG, etc.).
3//!
4//! This is a thin cross-platform wrapper that picks the right opener
5//! by inspecting OS + environment variables. The LLM gets one uniform
6//! tool to call; the environment-disambiguation logic lives here so
7//! the model never has to reason about whether `open` vs `xdg-open`
8//! vs `start` is correct for the current host.
9//!
10//! Headless / SSH / CI sessions can't show a window, so the tool
11//! refuses with a human-readable reason in those cases — the LLM can
12//! repeat it to the user instead of pretending a window opened.
13
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17use anyhow::Result;
18use async_trait::async_trait;
19use serde::Deserialize;
20use serde_json::json;
21
22use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
23
24pub struct OpenFileTool;
25
26#[derive(Deserialize)]
27struct OpenFileArgs {
28    path: String,
29}
30
31/// What command pattern (if any) is appropriate for opening a local
32/// file on this host. Separated from the actual spawn so the
33/// environment detection is unit-testable without side effects.
34///
35/// `dead_code` is allowed because each variant is only constructed on
36/// one target OS (`MacOpen` only on macOS, `XdgOpen` / `Wslview` only
37/// on Linux, `WindowsStart` only on Windows). Without this, every
38/// non-current-OS variant looks dead to rustc.
39#[allow(dead_code)]
40#[derive(Debug, PartialEq, Eq)]
41pub(crate) enum OpenStrategy {
42    /// `open <path>` — macOS LaunchServices.
43    MacOpen,
44    /// `xdg-open <path>` — freedesktop default opener (most Linux DEs).
45    XdgOpen,
46    /// `cmd /c start "" <path>` — Windows. Empty `""` title is required
47    /// because `start` treats the first quoted arg as a window title,
48    /// silently swallowing a quoted file path otherwise.
49    WindowsStart,
50    /// `wslview <path>` — `wslu` package's WSL→Windows bridge.
51    Wslview,
52    /// No GUI session available — refuses with a human-readable reason
53    /// naming the env signal that disqualified it so the LLM can echo
54    /// that back to the user instead of silently faking success.
55    Headless(String),
56}
57
58/// Pick an open strategy based on OS + env vars. Pure — only reads
59/// env vars and (on Linux) `/proc/version`, no GUI side effects.
60///
61/// Order matters: SSH / CI checks come BEFORE OS dispatch because
62/// `ssh user@mac` still reports `target_os = "macos"` but a window
63/// opened over SSH appears on the *server*, not the user's screen.
64pub(crate) fn pick_open_strategy() -> OpenStrategy {
65    if let Some(reason) = ssh_signal() {
66        return OpenStrategy::Headless(reason);
67    }
68    if let Some(reason) = ci_signal() {
69        return OpenStrategy::Headless(reason);
70    }
71
72    #[cfg(target_os = "macos")]
73    {
74        return OpenStrategy::MacOpen;
75    }
76
77    #[cfg(target_os = "windows")]
78    {
79        return OpenStrategy::WindowsStart;
80    }
81
82    #[cfg(all(unix, not(target_os = "macos")))]
83    {
84        if is_wsl() {
85            // wslu provides `wslview` which is the canonical WSL→
86            // Windows opener. If it's missing we'd have to call
87            // `wslpath -w` + `explorer.exe` which adds two extra
88            // process spawns and a layer of path translation — punt
89            // and tell the user to install `wslu` instead.
90            if which::which("wslview").is_ok() {
91                return OpenStrategy::Wslview;
92            }
93            return OpenStrategy::Headless(
94                "WSL detected but `wslview` is not installed (install the `wslu` \
95                 package, or open the file manually from Windows Explorer)"
96                    .into(),
97            );
98        }
99        let has_display = std::env::var("DISPLAY")
100            .map(|v| !v.is_empty())
101            .unwrap_or(false)
102            || std::env::var("WAYLAND_DISPLAY")
103                .map(|v| !v.is_empty())
104                .unwrap_or(false);
105        if !has_display {
106            return OpenStrategy::Headless(
107                "no graphical session ($DISPLAY and $WAYLAND_DISPLAY both empty — \
108                 likely a server / container / headless console)"
109                    .into(),
110            );
111        }
112        return OpenStrategy::XdgOpen;
113    }
114
115    #[allow(unreachable_code)]
116    OpenStrategy::Headless("unsupported platform".into())
117}
118
119/// SSH wins over OS detection — opening a window on the remote host
120/// shows it on the *server*'s display, not the user's.
121fn ssh_signal() -> Option<String> {
122    for v in ["SSH_CLIENT", "SSH_CONNECTION", "SSH_TTY"] {
123        if let Ok(s) = std::env::var(v) {
124            if !s.is_empty() {
125                return Some(format!("running over SSH (${} is set)", v));
126            }
127        }
128    }
129    None
130}
131
132/// CI runners have no interactive display and shouldn't pop windows.
133/// `$CI` is the de-facto convention (Travis / CircleCI / GitLab /
134/// GitHub Actions all set it); the others are belt-and-braces for
135/// runners that override `$CI`.
136fn ci_signal() -> Option<String> {
137    for v in ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE"] {
138        if let Ok(s) = std::env::var(v) {
139            if !s.is_empty() {
140                return Some(format!("running in CI (${} is set)", v));
141            }
142        }
143    }
144    None
145}
146
147#[cfg(all(unix, not(target_os = "macos")))]
148fn is_wsl() -> bool {
149    if std::env::var("WSL_DISTRO_NAME")
150        .map(|s| !s.is_empty())
151        .unwrap_or(false)
152    {
153        return true;
154    }
155    // Fallback for older WSL1 setups where $WSL_DISTRO_NAME isn't
156    // always populated: the kernel string in /proc/version contains
157    // "Microsoft" / "microsoft" on WSL.
158    std::fs::read_to_string("/proc/version")
159        .map(|s| s.to_lowercase().contains("microsoft"))
160        .unwrap_or(false)
161}
162
163fn strategy_command_name(s: &OpenStrategy) -> &'static str {
164    match s {
165        OpenStrategy::MacOpen => "open",
166        OpenStrategy::XdgOpen => "xdg-open",
167        OpenStrategy::WindowsStart => "cmd /c start",
168        OpenStrategy::Wslview => "wslview",
169        OpenStrategy::Headless(_) => "(headless)",
170    }
171}
172
173#[async_trait]
174impl Tool for OpenFileTool {
175    fn definition(&self) -> ToolDef {
176        ToolDef {
177            name: "open_file",
178            description: "Open a local file (HTML / PDF / image / SVG / etc.) in the user's default GUI application — \
179                          typically a browser for HTML, image viewer for PNG / JPG, PDF reader for PDF.\n\
180                          \n\
181                          USE ONLY WHEN:\n\
182                          1. The user explicitly asks to preview / open / view a file, OR\n\
183                          2. Previewing is the obvious next step (e.g. you just generated an HTML mockup the user requested) AND you have ASKED the user first.\n\
184                          \n\
185                          DO NOT auto-open after every write_file / edit_file. Files existing on disk don't need to pop windows; \
186                          the user will preview them when they want to. When in doubt, ask before calling this tool.\n\
187                          \n\
188                          Cross-platform: macOS uses `open`, Linux desktop `xdg-open`, Windows `cmd /c start`, WSL `wslview`. \
189                          Headless / SSH / CI sessions refuse with a clear reason so you can tell the user to fetch the file \
190                          another way instead of pretending a window opened.".to_string(),
191            parameters: json!({
192                "type": "object",
193                "properties": {
194                    "path": {
195                        "type": "string",
196                        "description": "File path to open. Absolute, or relative to the current working directory. Must exist."
197                    }
198                },
199                "required": ["path"]
200            }),
201        }
202    }
203
204    fn approval(&self, _args: &str) -> ApprovalRequirement {
205        // Fallback used only when `approval_with_context` can't read the
206        // working_dir lock (extremely rare — there's no concurrent
207        // writer in normal operation). Be conservative: if we can't
208        // tell where the path is, ask before launching a window.
209        ApprovalRequirement::RequireApproval(
210            "Launches a GUI application (browser / viewer) — user-visible side effect.".into(),
211        )
212    }
213
214    fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
215        // Path-aware approval, same pattern as `cd` and `list_dir`:
216        //   - in-workspace path             → AutoApprove (the LLM was
217        //                                      already gated by the
218        //                                      system-prompt "ask first"
219        //                                      rule; tool-level prompt
220        //                                      would be redundant)
221        //   - out-of-workspace non-sensitive → AutoApprove (matches cd /
222        //                                       list_dir for `Enumerate`)
223        //   - out-of-workspace sensitive    → RequireApprovalAlways
224        //     (.env / id_rsa / .pem / ~/.ssh/* / system-protected dirs
225        //     — never auto-open these even if the user asked, because
226        //     it's almost always a mistake / prompt-injection vector)
227        // Parsing or lock failures fall back to the conservative
228        // `approval()` above.
229        let parsed = match serde_json::from_str::<OpenFileArgs>(args) {
230            Ok(p) => p,
231            Err(_) => return self.approval(args),
232        };
233        let wd = match ctx.working_dir.try_read() {
234            Ok(g) => g.clone(),
235            Err(_) => return self.approval(args),
236        };
237        match super::approval_for_path(
238            &parsed.path,
239            &wd,
240            super::ExternalPathAction::Enumerate,
241        ) {
242            Ok(approval) => approval,
243            Err(_) => self.approval(args),
244        }
245    }
246
247    async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
248        let parsed: OpenFileArgs = serde_json::from_str(args)?;
249        let path = parsed.path.as_str();
250
251        // Resolve relative to working_dir, mirroring every other file tool.
252        let wd = ctx.working_dir.read().await.clone();
253        let target = if Path::new(path).is_absolute() {
254            PathBuf::from(path)
255        } else {
256            wd.join(path)
257        };
258        let target = std::fs::canonicalize(&target).unwrap_or(target);
259
260        if !target.exists() {
261            return Ok(ToolResult {
262                call_id: String::new(),
263                output: format!("File not found: {}", target.display()),
264                success: false,
265            });
266        }
267
268        let strategy = pick_open_strategy();
269        let target_str = target.to_string_lossy().to_string();
270
271        let mut cmd = match &strategy {
272            OpenStrategy::MacOpen => {
273                let mut c = Command::new("open");
274                c.arg(&target_str);
275                c
276            }
277            OpenStrategy::XdgOpen => {
278                let mut c = Command::new("xdg-open");
279                c.arg(&target_str);
280                c
281            }
282            OpenStrategy::WindowsStart => {
283                let mut c = Command::new("cmd");
284                c.args(["/c", "start", "", &target_str]);
285                c
286            }
287            OpenStrategy::Wslview => {
288                let mut c = Command::new("wslview");
289                c.arg(&target_str);
290                c
291            }
292            OpenStrategy::Headless(reason) => {
293                return Ok(ToolResult {
294                    call_id: String::new(),
295                    output: format!(
296                        "Cannot open in GUI: {}.\n\nFile path for manual viewing:\n  {}",
297                        reason,
298                        target.display()
299                    ),
300                    success: false,
301                });
302            }
303        };
304
305        // Detached spawn: open / xdg-open / start all hand off to the
306        // real GUI app and exit immediately, so we don't block the
307        // agent on the GUI app's lifetime. Stdio null'd so a launcher
308        // that prints warnings can't spew into the terminal.
309        cmd.stdin(std::process::Stdio::null())
310            .stdout(std::process::Stdio::null())
311            .stderr(std::process::Stdio::null());
312
313        match cmd.spawn() {
314            Ok(_child) => Ok(ToolResult {
315                call_id: String::new(),
316                output: format!(
317                    "Opened {} via `{}`.",
318                    target.display(),
319                    strategy_command_name(&strategy)
320                ),
321                success: true,
322            }),
323            Err(e) => Ok(ToolResult {
324                call_id: String::new(),
325                output: format!(
326                    "Failed to launch `{}`: {}.\n\nFile path for manual viewing:\n  {}",
327                    strategy_command_name(&strategy),
328                    e,
329                    target.display()
330                ),
331                success: false,
332            }),
333        }
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    /// Pure helpers only — these don't fork or touch the GUI. We can't
342    /// reliably mutate `$SSH_*` from a test because env access in Rust
343    /// tests is racy across threads (libstd warns), so we just verify
344    /// the "no signal set" baseline and trust the production code path
345    /// to read the right vars.
346    #[test]
347    fn ssh_signal_returns_none_in_clean_env() {
348        // Skip if the test runner itself is inside an SSH session
349        // (CI runners over SSH-tunneled docker exec do this).
350        if std::env::var("SSH_CLIENT").is_ok()
351            || std::env::var("SSH_CONNECTION").is_ok()
352            || std::env::var("SSH_TTY").is_ok()
353        {
354            return;
355        }
356        assert!(ssh_signal().is_none());
357    }
358
359    #[test]
360    fn strategy_command_name_covers_every_variant() {
361        // Compile-time exhaustiveness via the match — if a variant
362        // gets added later and `strategy_command_name` isn't updated,
363        // this test will fail to compile (rustc errors on the
364        // missing arm). Smoke-asserts that none of the known mappings
365        // return an empty string either.
366        for s in [
367            OpenStrategy::MacOpen,
368            OpenStrategy::XdgOpen,
369            OpenStrategy::WindowsStart,
370            OpenStrategy::Wslview,
371            OpenStrategy::Headless("test".into()),
372        ] {
373            assert!(!strategy_command_name(&s).is_empty());
374        }
375    }
376}