//! `open_file` tool — launch a local file in the user's default GUI
//! application (browser for HTML, viewer for PDF / image / SVG, etc.).
//!
//! This is a thin cross-platform wrapper that picks the right opener
//! by inspecting OS + environment variables. The LLM gets one uniform
//! tool to call; the environment-disambiguation logic lives here so
//! the model never has to reason about whether `open` vs `xdg-open`
//! vs `start` is correct for the current host.
//!
//! Headless / SSH / CI sessions can't show a window, so the tool
//! refuses with a human-readable reason in those cases — the LLM can
//! repeat it to the user instead of pretending a window opened.
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::Result;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
pub struct OpenFileTool;
#[derive(Deserialize)]
struct OpenFileArgs {
// `alias = "path"`: ce1c344f renamed the parameter from `path` to
// `file_path` to align with read/write/edit. Without an alias,
// serde rejects calls that still use `path` — which breaks
// resumed sessions that snapshotted the old tool schema and
// models whose cached schema isn't refreshed yet. Keep both
// accepted for one release cycle; remove the alias once the
// upgrade has settled.
#[serde(alias = "path")]
file_path: String,
}
/// What command pattern (if any) is appropriate for opening a local
/// file on this host. Separated from the actual spawn so the
/// environment detection is unit-testable without side effects.
///
/// `dead_code` is allowed because each variant is only constructed on
/// one target OS (`MacOpen` only on macOS, `XdgOpen` / `Wslview` only
/// on Linux, `WindowsStart` only on Windows). Without this, every
/// non-current-OS variant looks dead to rustc.
#[allow(dead_code)]
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum OpenStrategy {
/// `open <path>` — macOS LaunchServices.
MacOpen,
/// `xdg-open <path>` — freedesktop default opener (most Linux DEs).
XdgOpen,
/// `cmd /c start "" <path>` — Windows. Empty `""` title is required
/// because `start` treats the first quoted arg as a window title,
/// silently swallowing a quoted file path otherwise.
WindowsStart,
/// `wslview <path>` — `wslu` package's WSL→Windows bridge.
Wslview,
/// No GUI session available — refuses with a human-readable reason
/// naming the env signal that disqualified it so the LLM can echo
/// that back to the user instead of silently faking success.
Headless(String),
}
/// Pick an open strategy based on OS + env vars. Pure — only reads
/// env vars and (on Linux) `/proc/version`, no GUI side effects.
///
/// Order matters: SSH / CI checks come BEFORE OS dispatch because
/// `ssh user@mac` still reports `target_os = "macos"` but a window
/// opened over SSH appears on the *server*, not the user's screen.
pub(crate) fn pick_open_strategy() -> OpenStrategy {
if let Some(reason) = ssh_signal() {
return OpenStrategy::Headless(reason);
}
if let Some(reason) = ci_signal() {
return OpenStrategy::Headless(reason);
}
#[cfg(target_os = "macos")]
{
return OpenStrategy::MacOpen;
}
#[cfg(target_os = "windows")]
{
return OpenStrategy::WindowsStart;
}
#[cfg(all(unix, not(target_os = "macos")))]
{
if is_wsl() {
// wslu provides `wslview` which is the canonical WSL→
// Windows opener. If it's missing we'd have to call
// `wslpath -w` + `explorer.exe` which adds two extra
// process spawns and a layer of path translation — punt
// and tell the user to install `wslu` instead.
if which::which("wslview").is_ok() {
return OpenStrategy::Wslview;
}
return OpenStrategy::Headless(
"WSL detected but `wslview` is not installed (install the `wslu` \
package, or open the file manually from Windows Explorer)"
.into(),
);
}
let has_display = std::env::var("DISPLAY")
.map(|v| !v.is_empty())
.unwrap_or(false)
|| std::env::var("WAYLAND_DISPLAY")
.map(|v| !v.is_empty())
.unwrap_or(false);
if !has_display {
return OpenStrategy::Headless(
"no graphical session ($DISPLAY and $WAYLAND_DISPLAY both empty — \
likely a server / container / headless console)"
.into(),
);
}
return OpenStrategy::XdgOpen;
}
#[allow(unreachable_code)]
OpenStrategy::Headless("unsupported platform".into())
}
/// SSH wins over OS detection — opening a window on the remote host
/// shows it on the *server*'s display, not the user's.
fn ssh_signal() -> Option<String> {
for v in ["SSH_CLIENT", "SSH_CONNECTION", "SSH_TTY"] {
if let Ok(s) = std::env::var(v) {
if !s.is_empty() {
return Some(format!("running over SSH (${} is set)", v));
}
}
}
None
}
/// CI runners have no interactive display and shouldn't pop windows.
/// `$CI` is the de-facto convention (Travis / CircleCI / GitLab /
/// GitHub Actions all set it); the others are belt-and-braces for
/// runners that override `$CI`.
fn ci_signal() -> Option<String> {
for v in ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE"] {
if let Ok(s) = std::env::var(v) {
if !s.is_empty() {
return Some(format!("running in CI (${} is set)", v));
}
}
}
None
}
#[cfg(all(unix, not(target_os = "macos")))]
fn is_wsl() -> bool {
if std::env::var("WSL_DISTRO_NAME")
.map(|s| !s.is_empty())
.unwrap_or(false)
{
return true;
}
// Fallback for older WSL1 setups where $WSL_DISTRO_NAME isn't
// always populated: the kernel string in /proc/version contains
// "Microsoft" / "microsoft" on WSL.
std::fs::read_to_string("/proc/version")
.map(|s| s.to_lowercase().contains("microsoft"))
.unwrap_or(false)
}
fn strategy_command_name(s: &OpenStrategy) -> &'static str {
match s {
OpenStrategy::MacOpen => "open",
OpenStrategy::XdgOpen => "xdg-open",
OpenStrategy::WindowsStart => "cmd /c start",
OpenStrategy::Wslview => "wslview",
OpenStrategy::Headless(_) => "(headless)",
}
}
#[async_trait]
impl Tool for OpenFileTool {
fn definition(&self) -> ToolDef {
ToolDef {
name: "open_file",
description: "Open a local file (HTML / PDF / image / SVG / etc.) in the user's default GUI application — \
typically a browser for HTML, image viewer for PNG / JPG, PDF reader for PDF.\n\
\n\
USE ONLY WHEN:\n\
1. The user explicitly asks to preview / open / view a file, OR\n\
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\
\n\
DO NOT auto-open after every write_file / edit_file. Files existing on disk don't need to pop windows; \
the user will preview them when they want to. When in doubt, ask before calling this tool.\n\
\n\
Cross-platform: macOS uses `open`, Linux desktop `xdg-open`, Windows `cmd /c start`, WSL `wslview`. \
Headless / SSH / CI sessions refuse with a clear reason so you can tell the user to fetch the file \
another way instead of pretending a window opened.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "File path to open. Absolute, or relative to the current working directory. Must exist."
}
},
"required": ["file_path"]
}),
}
}
fn approval(&self, _args: &str) -> ApprovalRequirement {
// Fallback used only when `approval_with_context` can't read the
// working_dir lock (extremely rare — there's no concurrent
// writer in normal operation). Be conservative: if we can't
// tell where the path is, ask before launching a window.
ApprovalRequirement::RequireApproval(
"Launches a GUI application (browser / viewer) — user-visible side effect.".into(),
)
}
fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
// Path-aware approval, same pattern as `cd` and `list_dir`:
// - in-workspace path → AutoApprove (the LLM was
// already gated by the
// system-prompt "ask first"
// rule; tool-level prompt
// would be redundant)
// - out-of-workspace non-sensitive → AutoApprove (matches cd /
// list_dir for `Enumerate`)
// - out-of-workspace sensitive → RequireApprovalAlways
// (.env / id_rsa / .pem / ~/.ssh/* / system-protected dirs
// — never auto-open these even if the user asked, because
// it's almost always a mistake / prompt-injection vector)
// Parsing or lock failures fall back to the conservative
// `approval()` above.
let parsed = match serde_json::from_str::<OpenFileArgs>(args) {
Ok(p) => p,
Err(_) => return self.approval(args),
};
let wd = match ctx.working_dir.try_read() {
Ok(g) => g.clone(),
Err(_) => return self.approval(args),
};
match super::approval_for_path(
&parsed.file_path,
&wd,
super::ExternalPathAction::Enumerate,
) {
Ok(approval) => approval,
Err(_) => self.approval(args),
}
}
async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
let parsed: OpenFileArgs = serde_json::from_str(args)?;
let path = parsed.file_path.as_str();
// Resolve relative to working_dir, mirroring every other file tool.
let wd = ctx.working_dir.read().await.clone();
let target = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
wd.join(path)
};
let target = std::fs::canonicalize(&target).unwrap_or(target);
if !target.exists() {
return Ok(ToolResult {
call_id: String::new(),
output: format!("File not found: {}", target.display()),
success: false,
});
}
let strategy = pick_open_strategy();
let target_str = target.to_string_lossy().to_string();
let mut cmd = match &strategy {
OpenStrategy::MacOpen => {
let mut c = Command::new("open");
c.arg(&target_str);
c
}
OpenStrategy::XdgOpen => {
let mut c = Command::new("xdg-open");
c.arg(&target_str);
c
}
OpenStrategy::WindowsStart => {
let mut c = Command::new("cmd");
c.args(["/c", "start", "", &target_str]);
c
}
OpenStrategy::Wslview => {
let mut c = Command::new("wslview");
c.arg(&target_str);
c
}
OpenStrategy::Headless(reason) => {
return Ok(ToolResult {
call_id: String::new(),
output: format!(
"Cannot open in GUI: {}.\n\nFile path for manual viewing:\n {}",
reason,
target.display()
),
success: false,
});
}
};
// Detached spawn: open / xdg-open / start all hand off to the
// real GUI app and exit immediately, so we don't block the
// agent on the GUI app's lifetime. Stdio null'd so a launcher
// that prints warnings can't spew into the terminal.
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
match cmd.spawn() {
Ok(_child) => Ok(ToolResult {
call_id: String::new(),
output: format!(
"Opened {} via `{}`.",
target.display(),
strategy_command_name(&strategy)
),
success: true,
}),
Err(e) => Ok(ToolResult {
call_id: String::new(),
output: format!(
"Failed to launch `{}`: {}.\n\nFile path for manual viewing:\n {}",
strategy_command_name(&strategy),
e,
target.display()
),
success: false,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Pure helpers only — these don't fork or touch the GUI. We can't
/// reliably mutate `$SSH_*` from a test because env access in Rust
/// tests is racy across threads (libstd warns), so we just verify
/// the "no signal set" baseline and trust the production code path
/// to read the right vars.
#[test]
fn ssh_signal_returns_none_in_clean_env() {
// Skip if the test runner itself is inside an SSH session
// (CI runners over SSH-tunneled docker exec do this).
if std::env::var("SSH_CLIENT").is_ok()
|| std::env::var("SSH_CONNECTION").is_ok()
|| std::env::var("SSH_TTY").is_ok()
{
return;
}
assert!(ssh_signal().is_none());
}
/// Both `path` (legacy) and `file_path` (canonical) must deserialize.
/// ce1c344f renamed without an alias — that left resumed sessions
/// snapshotting the old schema getting "missing field `file_path`"
/// errors. The alias keeps the old name working for one release.
#[test]
fn open_file_args_accepts_legacy_path_alias() {
let legacy: OpenFileArgs =
serde_json::from_str(r#"{"path":"/tmp/x.html"}"#).expect("legacy `path` must parse");
assert_eq!(legacy.file_path, "/tmp/x.html");
let canonical: OpenFileArgs =
serde_json::from_str(r#"{"file_path":"/tmp/x.html"}"#).expect("canonical must parse");
assert_eq!(canonical.file_path, "/tmp/x.html");
}
#[test]
fn strategy_command_name_covers_every_variant() {
// Compile-time exhaustiveness via the match — if a variant
// gets added later and `strategy_command_name` isn't updated,
// this test will fail to compile (rustc errors on the
// missing arm). Smoke-asserts that none of the known mappings
// return an empty string either.
for s in [
OpenStrategy::MacOpen,
OpenStrategy::XdgOpen,
OpenStrategy::WindowsStart,
OpenStrategy::Wslview,
OpenStrategy::Headless("test".into()),
] {
assert!(!strategy_command_name(&s).is_empty());
}
}
}