Skip to content
Closed
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
17 changes: 16 additions & 1 deletion crates/vibehub-core/src/v3/event_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1512,7 +1512,22 @@ mod tests {
}
}
barrier.wait();
store.append_with_rebuild(event)
// Index catch-up can exceed one deadline while the other 23
// writers advance the source. Retry only this pre-append,
// explicitly retryable error with the same idempotency key.
let mut retries = 0;
loop {
match store.append_with_rebuild(event.clone()) {
Err(error)
if error.code == "V3_INDEX_SYNC_TIMEOUT"
&& error.retryable
&& retries < 2 =>
{
retries += 1;
}
result => break result,
}
}
})
})
.collect::<Vec<_>>();
Expand Down
129 changes: 102 additions & 27 deletions src-tauri/src/agent_profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,8 @@ fn launch(
let arguments = launch_arguments_for(&located, &request.launch_mode)?;
let working_directory = launch_working_directory(&target)?;
let executable = executable_for_agent(&request.agent);
let process_id = crate::launcher::Launcher::launch_agent(
let environment = launch_environment_for(&target, &located)?;
let process_id = crate::launcher::Launcher::launch_agent_with_environment(
executable,
&arguments,
&working_directory,
Expand All @@ -829,6 +830,7 @@ fn launch(
RuntimeTargetKind::Wsl => "wsl",
},
target.distribution.as_deref(),
&environment,
)
.map_err(|error| {
AgentProfileCommandError::new(
Expand Down Expand Up @@ -892,36 +894,61 @@ fn launch_arguments_for(
})
}

fn launch_working_directory(target: &RuntimeTarget) -> Result<String, AgentProfileCommandError> {
if !matches!(target.kind, RuntimeTargetKind::Wsl) {
return Ok(target.home_path.native.clone());
fn launch_environment_for(
target: &RuntimeTarget,
profile: &LocatedProfile,
) -> Result<BTreeMap<String, String>, AgentProfileCommandError> {
let mut environment = BTreeMap::new();
if let LocatedProfile::OpenCode(view) = profile {
environment.insert(
"OPENCODE_CONFIG".to_owned(),
runtime_launch_path(target, &view.source_path)?,
);
}
Ok(environment)
}

#[cfg(target_os = "windows")]
{
let distribution = target.distribution.as_deref().ok_or_else(|| {
AgentProfileCommandError::validation(
"RUNTIME_WSL_DISTRIBUTION_MISSING",
"WSL runtime target is missing its distribution",
)
})?;
let native = target.home_path.native.replace('\\', "/");
let prefix = format!("//wsl$/{}", distribution.to_ascii_lowercase());
let lower = native.to_ascii_lowercase();
let Some(suffix) = lower.strip_prefix(&prefix) else {
return Err(AgentProfileCommandError::validation(
"RUNTIME_WSL_HOME_PATH_INVALID",
"WSL home path is not inside the observed distribution",
));
};
let suffix = suffix.trim_start_matches('/');
return Ok(format!("/{suffix}"));
}
fn launch_working_directory(target: &RuntimeTarget) -> Result<String, AgentProfileCommandError> {
runtime_launch_path(target, &target.home_path.as_path())
}

#[cfg(not(target_os = "windows"))]
{
Ok(target.home_path.native.clone())
fn runtime_launch_path(
target: &RuntimeTarget,
path: &Path,
) -> Result<String, AgentProfileCommandError> {
let native = path.to_string_lossy();
if target.kind != RuntimeTargetKind::Wsl {
return Ok(native.into_owned());
}
// A VibeHub process running inside WSL already has Linux paths.
if native.starts_with('/') && !native.starts_with("//") {
return Ok(native.into_owned());
}
let distribution = target.distribution.as_deref().ok_or_else(|| {
AgentProfileCommandError::validation(
"RUNTIME_WSL_DISTRIBUTION_MISSING",
"WSL runtime target is missing its distribution",
)
})?;
let mut native = native.replace('\\', "/");
if native.starts_with("//?/UNC/") {
native = format!("//{}", &native[8..]);
}
for server in ["wsl$", "wsl.localhost"] {
let prefix = format!("//{server}/{distribution}/");
if native
.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(&prefix))
{
// Only compare the UNC prefix without case sensitivity. Linux paths
// are case sensitive and must retain the original suffix.
return Ok(format!("/{}", &native[prefix.len()..]));
}
}
Err(AgentProfileCommandError::validation(
"RUNTIME_WSL_LAUNCH_PATH_INVALID",
"launch path is not inside the observed WSL distribution",
))
}

fn restore(
Expand Down Expand Up @@ -3170,6 +3197,54 @@ mod tests {
assert!(validate_provider_input(&provider).is_ok());
}

#[test]
fn selected_opencode_file_is_bound_to_child_environment() {
let root = std::env::temp_dir().join(format!("vibehub-launch-config-{}", Uuid::new_v4()));
let dir = opencode_config_dir(&root);
fs::create_dir_all(&dir).unwrap();
let target = RuntimeTarget::host(root.clone());
for name in ["opencode.json", "opencode.jsonc"] {
let path = dir.join(name);
fs::write(&path, "{}").unwrap();
let view = v3::read_opencode_profile(&target, &path).unwrap();
let selected = LocatedProfile::OpenCode(view.clone());
let environment = launch_environment_for(&target, &selected).unwrap();
assert_eq!(
environment["OPENCODE_CONFIG"],
view.source_path.to_string_lossy()
);
}
fs::remove_dir_all(root).unwrap();
}

#[test]
fn wsl_launch_paths_preserve_case_and_reject_other_distributions() {
let target = RuntimeTarget::wsl("Ubuntu", "/home/User Name");
for path in [
r"\\wsl$\Ubuntu\home\User Name\中文\opencode.jsonc",
r"\\wsl.localhost\Ubuntu\home\User Name\中文\opencode.jsonc",
r"\\?\UNC\wsl$\Ubuntu\home\User Name\中文\opencode.jsonc",
] {
assert_eq!(
runtime_launch_path(&target, Path::new(path)).unwrap(),
"/home/User Name/中文/opencode.jsonc"
);
}
assert!(runtime_launch_path(
&target,
Path::new(r"\\wsl$\Ubuntu-other\home\User\config.json")
)
.is_err());
assert_eq!(
runtime_launch_path(
&target,
Path::new("/home/User/.config/opencode/opencode.json")
)
.unwrap(),
"/home/User/.config/opencode/opencode.json"
);
}

#[test]
fn profile_ids_for_opencode_are_stable_and_path_bound() {
let a = opencode_profile_id(Path::new("/home/user/.config/opencode/opencode.jsonc"));
Expand Down
78 changes: 76 additions & 2 deletions src-tauri/src/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::models::{Project, TagCategory, TagConfig};
#[cfg(target_os = "windows")]
use crate::process_util::silent_command;
use anyhow::{anyhow, Result};
use std::collections::BTreeMap;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use std::process::Command;
Expand Down Expand Up @@ -32,6 +33,24 @@ impl Launcher {
working_directory: &str,
runtime_target_kind: &str,
distribution: Option<&str>,
) -> Result<u32> {
Self::launch_agent_with_environment(
executable,
args,
working_directory,
runtime_target_kind,
distribution,
&BTreeMap::new(),
)
}

pub fn launch_agent_with_environment(
executable: &str,
args: &[String],
working_directory: &str,
runtime_target_kind: &str,
distribution: Option<&str>,
environment: &BTreeMap<String, String>,
) -> Result<u32> {
if executable.trim().is_empty() || working_directory.trim().is_empty() {
return Err(anyhow!(
Expand Down Expand Up @@ -61,6 +80,12 @@ impl Launcher {
.arg("--cd")
.arg(working_directory)
.arg("--")
.arg("env")
.args(
environment
.iter()
.map(|(key, value)| format!("{key}={value}")),
)
.arg(executable)
.args(args);
return Ok(command.spawn()?.id());
Expand All @@ -75,7 +100,10 @@ impl Launcher {
}
let mut last_error = None;
for candidate in candidates {
match windows_agent_command(&candidate, args, working_directory).spawn() {
match windows_agent_command(&candidate, args, working_directory)
.envs(environment)
.spawn()
{
Ok(child) => return Ok(child.id()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
last_error = Some(error)
Expand All @@ -100,8 +128,14 @@ impl Launcher {
"cd".to_owned(),
Self::shell_quote(working_directory),
"&&".to_owned(),
Self::shell_quote(executable),
"env".to_owned(),
];
command_parts.extend(
environment
.iter()
.map(|(key, value)| Self::shell_quote(&format!("{key}={value}"))),
);
command_parts.push(Self::shell_quote(executable));
command_parts.extend(args.iter().map(|arg| Self::shell_quote(arg)));
let shell_command = command_parts.join(" ");
if Self::launch_terminal_command(&shell_command)? {
Expand All @@ -118,6 +152,7 @@ impl Launcher {
let mut command = Command::new(executable);
command
.args(args)
.envs(environment)
.current_dir(working_directory)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
Expand Down Expand Up @@ -668,6 +703,45 @@ mod tests {
);
}

#[cfg(target_os = "linux")]
#[test]
fn agent_environment_reaches_child_without_mutating_parent() {
use std::{fs, thread, time::Duration};
let root = std::env::temp_dir().join(format!("vibehub-env-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
let record = root.join("selected.txt");
let expected = "/home/User Name/中文 & '/opencode.jsonc";
let previous = std::env::var_os("OPENCODE_CONFIG");
let environment = BTreeMap::from([("OPENCODE_CONFIG".to_owned(), expected.to_owned())]);
let args = vec![
"-c".to_owned(),
"printf %s \"$OPENCODE_CONFIG\" > \"$1\"".to_owned(),
"probe".to_owned(),
record.to_string_lossy().into_owned(),
];
assert!(
Launcher::launch_agent_with_environment(
"sh",
&args,
root.to_str().unwrap(),
"host",
None,
&environment
)
.unwrap()
> 0
);
for _ in 0..100 {
if fs::read_to_string(&record).ok().as_deref() == Some(expected) {
break;
}
thread::sleep(Duration::from_millis(10));
}
assert_eq!(fs::read_to_string(&record).unwrap(), expected);
assert_eq!(std::env::var_os("OPENCODE_CONFIG"), previous);
fs::remove_dir_all(root).unwrap();
}

#[cfg(target_os = "windows")]
#[test]
fn windows_native_child_receives_exact_arguments_and_reports_spawn_errors() {
Expand Down
Loading