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}