Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 165 additions & 5 deletions crates/agent-gui/src-tauri/src/commands/workspace/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,12 @@ fn file_identity(meta: &fs::Metadata, _canon: &Path) -> String {
}

#[cfg(windows)]
fn file_identity(_meta: &fs::Metadata, canon: &Path) -> String {
format!("path:{}", display_path(canon).to_lowercase())
fn file_identity(meta: &fs::Metadata, canon: &Path) -> String {
use std::os::windows::fs::MetadataExt;
match (meta.volume_serial_number(), meta.file_index()) {
(Some(volume), Some(index)) => format!("{volume}:{index}"),
_ => format!("path:{}", display_path(canon).to_lowercase()),
}
}

fn levenshtein_at_most(a: &str, b: &str, max: usize) -> bool {
Expand Down Expand Up @@ -893,7 +897,6 @@ fn infer_workspace_preview_mime(path: &Path, bytes: &[u8]) -> Option<&'static st
}
}

#[cfg(target_os = "macos")]
fn truncate_process_error(bytes: &[u8]) -> String {
let text = String::from_utf8_lossy(bytes).trim().to_string();
let mut chars = text.chars();
Expand Down Expand Up @@ -941,8 +944,165 @@ fn convert_document_to_html_preview(target: &Path) -> Result<Vec<u8>, String> {
}

#[cfg(not(target_os = "macos"))]
fn convert_document_to_html_preview(_target: &Path) -> Result<Vec<u8>, String> {
Err("Legacy Word/RTF preview conversion is only available on macOS via textutil".to_string())
fn soffice_candidate_names() -> &'static [&'static str] {
if cfg!(windows) {
&["soffice.exe", "soffice.com"]
} else {
&["soffice", "libreoffice"]
}
}

#[cfg(not(target_os = "macos"))]
fn find_soffice_binary() -> Option<PathBuf> {
if let Ok(raw) = std::env::var("LIVEAGENT_SOFFICE_PATH") {
let trimmed = raw.trim().trim_matches('"');
if !trimmed.is_empty() {
let path = expand_tilde_path(trimmed);
if path.is_file() {
return Some(path);
}
}
}

let path_var = std::env::var_os("PATH").unwrap_or_default();
for dir in std::env::split_paths(&path_var) {
for name in soffice_candidate_names() {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
}

#[cfg(windows)]
{
let roots = ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"]
.iter()
.filter_map(|var| std::env::var_os(var).map(PathBuf::from))
.chain([
PathBuf::from(r"C:\Program Files"),
PathBuf::from(r"C:\Program Files (x86)"),
]);
for root in roots {
let candidate = root.join("LibreOffice").join("program").join("soffice.exe");
if candidate.is_file() {
return Some(candidate);
}
}
}

#[cfg(not(windows))]
{
for candidate in [
"/usr/bin/soffice",
"/usr/local/bin/soffice",
"/opt/libreoffice/program/soffice",
] {
let candidate = Path::new(candidate);
if candidate.is_file() {
return Some(candidate.to_path_buf());
}
}
}

None
}

#[cfg(not(target_os = "macos"))]
fn path_to_file_url(path: &Path) -> String {
let raw = path.to_string_lossy().replace('\\', "/");
let mut encoded = String::with_capacity(raw.len());
for ch in raw.chars() {
match ch {
'A'..='Z' | 'a'..='z' | '0'..='9' | '/' | ':' | '.' | '-' | '_' | '~' => {
encoded.push(ch);
}
_ => {
let mut buf = [0u8; 4];
for byte in ch.encode_utf8(&mut buf).as_bytes() {
encoded.push_str(&format!("%{byte:02X}"));
}
}
}
}
if encoded.starts_with('/') {
format!("file://{encoded}")
} else {
format!("file:///{encoded}")
}
}

#[cfg(not(target_os = "macos"))]
fn run_soffice_document_conversion(
soffice: &Path,
target: &Path,
out_dir: &Path,
) -> Result<Vec<u8>, String> {
// A dedicated profile keeps headless conversion from clashing with a running LibreOffice.
let profile_url = path_to_file_url(&out_dir.join("profile"));
let mut command = Command::new(soffice);
#[cfg(windows)]
crate::runtime::process::configure_child_process_group(&mut command);
let output = command
.arg(format!("-env:UserInstallation={profile_url}"))
.arg("--headless")
.arg("--norestore")
.arg("--convert-to")
.arg("html")
.arg("--outdir")
.arg(out_dir)
.arg(target)
.output()
.map_err(|e| format!("Failed to start LibreOffice for document preview: {e}"))?;

if !output.status.success() {
let detail = truncate_process_error(&output.stderr);
return Err(if detail.is_empty() {
"Failed to convert document preview with LibreOffice".to_string()
} else {
format!("Failed to convert document preview with LibreOffice: {detail}")
});
}

let stem = target
.file_stem()
.ok_or_else(|| "Document preview target has no file name".to_string())?;
let html_path = out_dir.join(stem).with_extension("html");
let bytes = fs::read(&html_path)
.map_err(|e| format!("LibreOffice did not produce a document preview: {e}"))?;
if bytes.is_empty() {
return Err("Document preview conversion returned empty HTML".to_string());
}
if bytes.len() > READ_MAX_PREVIEW_BYTES {
return Err(FsError::Other(format!(
"Converted document preview is too large ({} bytes, max {READ_MAX_PREVIEW_BYTES} bytes)",
bytes.len()
))
.to_string());
}
Ok(bytes)
}

#[cfg(not(target_os = "macos"))]
fn convert_document_to_html_preview(target: &Path) -> Result<Vec<u8>, String> {
let soffice = find_soffice_binary().ok_or_else(|| {
"Legacy Word/RTF preview conversion requires LibreOffice; install it or set LIVEAGENT_SOFFICE_PATH to the soffice binary".to_string()
})?;

let nanos = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let out_dir = std::env::temp_dir()
.join("liveagent")
.join("workspace-preview-convert")
.join(format!("{}-{}", std::process::id(), nanos));
fs::create_dir_all(&out_dir)
.map_err(|e| format!("Failed to prepare document preview workspace: {e}"))?;

let result = run_soffice_document_conversion(&soffice, target, &out_dir);
let _ = fs::remove_dir_all(&out_dir);
result
}

fn document_preview_cache_dir() -> PathBuf {
Expand Down
77 changes: 0 additions & 77 deletions crates/agent-gui/src-tauri/src/runtime/shell_runner.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
use serde::Serialize;
use std::collections::HashMap;
#[cfg(windows)]
use std::env;
use std::fs;
use std::io::{self, Read};
use std::path::{Component, Path, PathBuf};
Expand All @@ -12,8 +10,6 @@ use std::sync::{
};
use std::time::{Duration, Instant};

#[cfg(windows)]
use crate::runtime::platform::resolve_program_path_with_current_dir;
use crate::runtime::platform::{expand_tilde_path, maybe_augment_macos_path, shell_basename};
use crate::runtime::process::{configure_child_process_group, terminate_child_process_tree};

Expand Down Expand Up @@ -314,79 +310,6 @@ fn windows_cmd_command(cmd: &str) -> String {
format!("chcp 65001>nul & {cmd}")
}

#[cfg(windows)]
fn push_existing_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !path.is_file() {
return;
}
let normalized = path.to_string_lossy().to_ascii_lowercase();
if paths
.iter()
.any(|existing| existing.to_string_lossy().to_ascii_lowercase() == normalized)
{
return;
}
paths.push(path);
}

#[cfg(windows)]
fn push_git_bash_install_root(paths: &mut Vec<PathBuf>, root: PathBuf) {
let git_root = root.join("Git");
push_existing_unique_path(paths, git_root.join("bin").join("bash.exe"));
push_existing_unique_path(paths, git_root.join("usr").join("bin").join("bash.exe"));
}

#[cfg(windows)]
fn normalize_configured_path(raw: &str) -> Option<PathBuf> {
let trimmed = raw.trim().trim_matches('"');
if trimmed.is_empty() {
return None;
}
Some(expand_tilde_path(trimmed))
}

#[cfg(windows)]
fn is_probable_git_bash_path(path: &Path) -> bool {
let normalized = path
.to_string_lossy()
.replace('/', "\\")
.to_ascii_lowercase();
normalized.ends_with("\\git\\bin\\bash.exe")
|| normalized.ends_with("\\git\\usr\\bin\\bash.exe")
}

#[cfg(windows)]
#[allow(dead_code)]
fn find_windows_git_bash() -> Option<PathBuf> {
let mut paths = Vec::new();

for var in ["LIVEAGENT_GIT_BASH_PATH", "CLAUDE_CODE_GIT_BASH_PATH"] {
if let Some(raw) = env::var_os(var) {
let raw = raw.to_string_lossy();
if let Some(path) = normalize_configured_path(&raw) {
push_existing_unique_path(&mut paths, path);
}
}
}

for var in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
if let Some(root) = env::var_os(var) {
push_git_bash_install_root(&mut paths, PathBuf::from(root));
}
}

for root in [r"C:\Program Files", r"C:\Program Files (x86)"] {
push_git_bash_install_root(&mut paths, PathBuf::from(root));
}

let path_bash = resolve_program_path_with_current_dir("bash", None);
if is_probable_git_bash_path(&path_bash) {
push_existing_unique_path(&mut paths, path_bash);
}

paths.into_iter().next()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ShellExecutionProfile {
pub platform: &'static str,
Expand Down
2 changes: 1 addition & 1 deletion crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ pub(crate) fn configure_zsh_colored_prompt(cmd: &mut CommandBuilder) {
pub(crate) fn create_zsh_prompt_overlay(prompt: &str) -> Option<PathBuf> {
let base = dirs::cache_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("/tmp"));
.unwrap_or_else(std::env::temp_dir);
let zdotdir = base.join("liveagent-zsh");
if fs::create_dir_all(&zdotdir).is_err() {
return None;
Expand Down
4 changes: 1 addition & 3 deletions crates/agent-gui/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@
"height": 800,
"minWidth": 1200,
"minHeight": 720,
"backgroundThrottling": "disabled",
"titleBarStyle": "Overlay",
"trafficLightPosition": { "x": 18, "y": 18 }
"backgroundThrottling": "disabled"
}
],
"security": {
Expand Down
14 changes: 14 additions & 0 deletions crates/agent-gui/src-tauri/tauri.macos.conf.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
{
"$schema": "https://schema.tauri.app/config/2",
"app": {
"windows": [
{
"title": "",
"width": 1400,
"height": 800,
"minWidth": 1200,
"minHeight": 720,
"backgroundThrottling": "disabled",
"titleBarStyle": "Overlay",
"trafficLightPosition": { "x": 18, "y": 18 }
}
]
},
"bundle": {
"targets": [
"app",
Expand Down
2 changes: 1 addition & 1 deletion crates/agent-gui/src/lib/tools/shellTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,7 @@ export function createShellTools(params: {
const command =
typeof toolCall.arguments?.command === "string" ? toolCall.arguments.command.trim() : "";
if (!command) throw new Error('ManagedProcess.command is required for action="start"');
if (scanShellSyntax(command).background) {
if (runtimePlatform !== "windows" && scanShellSyntax(command).background) {
throw new Error(
"ManagedProcess.command must be a foreground command. Remove `&`; ManagedProcess starts it in the background and captures logs automatically.",
);
Expand Down
49 changes: 49 additions & 0 deletions crates/agent-gui/test/tools/shell-tools.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,55 @@ test("ManagedProcess rejects nested shell background operators", async () => {
assert.deepEqual(calls, []);
});

test("ManagedProcess does not apply POSIX ampersand background validation on Windows", async () => {
const calls = [];
const loader = createTsModuleLoader({
mocks: {
"@tauri-apps/api/core": {
async invoke(command, args) {
calls.push({ command, args });
assert.equal(command, "managed_process_start");
return {
process: {
id: "proc-win",
label: null,
command: args.command,
cwd: "C:\\repo",
shell: "pwsh",
pid: 456,
log_path: "C:\\Users\\me\\.liveagent\\process-logs\\proc-win.log",
started_at: 10,
finished_at: null,
exit_code: null,
running: true,
},
};
},
},
},
});

const { createShellTools } = loader.loadModule("src/lib/tools/shellTools.ts");
const bundle = createShellTools({
workdir: "/repo",
providerId: "claude_code",
runtimePlatform: "windows",
});

const result = await bundle.executeToolCall({
type: "toolCall",
id: "managed-start-windows",
name: "ManagedProcess",
arguments: {
action: "start",
command: '& "C:/Program Files/App/app.exe"',
},
});

assert.equal(result.isError, false);
assert.equal(calls.length, 1);
});

test("Bash tool marks stdio-open shell responses as errors", async () => {
const loader = createTsModuleLoader({
mocks: {
Expand Down
Loading