From bcf8bdb893585223b84230fe8ac6feaec46d0dea Mon Sep 17 00:00:00 2001 From: Nemanja Mikic Date: Wed, 1 Apr 2026 15:33:41 +0200 Subject: [PATCH 1/2] remote support from macos --- .gitignore | 1 + Cargo.lock | 1 + lib/Cargo.toml | 1 + lib/src/services/combined.rs | 231 ++++++++++++++++++++++++++--------- lib/src/services/config.rs | 19 +++ remote/src/orchestration.rs | 190 ++++++++++++++++++++-------- tui/src/app.rs | 99 +++++++++++---- tui/src/ops.rs | 118 +++++++++++++----- tui/src/tests.rs | 6 +- 9 files changed, 501 insertions(+), 165 deletions(-) diff --git a/.gitignore b/.gitignore index c403c34..807922d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target .idea/ +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 7f47cf5..2def9ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1755,6 +1755,7 @@ version = "0.1.0" dependencies = [ "diesel", "diesel_migrations", + "libc", "libsqlite3-sys", "octocrab", "openapiv3", diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 340c363..1ed72ee 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -25,6 +25,7 @@ tracing = "0" tracing-subscriber = { version = "0", features = ["fmt", "ansi"] } shell-words = "1" size = "0" +libc = "0" [build-dependencies] openapiv3 = "2" diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index 626fd9e..ce0d326 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -398,6 +398,7 @@ impl CombinedService { pub async fn stop_workspace(&self, key: &str) -> Result<(), CombinedServiceError> { let key = validate_workspace_key(key)?; + self.ensure_workspace_directory_exists(&key)?; let workspace = self.manager.get_workspace(&key)?; let workspace_rx = workspace.subscribe(); let unit = workspace_rx @@ -436,13 +437,14 @@ impl CombinedService { .await } - /// Build a command to run a tool in an isolate, such as a user-defined exec-type tool, or the review tool. - pub async fn build_pty_tool_command( + pub async fn build_pty_tool_command_with_env( &self, key: &str, command: Vec, + extra_inherited_env: Vec<(String, String)>, ) -> Result { let key = validate_workspace_key(key)?; + self.ensure_workspace_directory_exists(&key)?; self.ensure_workspace_not_archived(&key)?; if command.is_empty() { return Err(CombinedServiceError::InvalidToolExecution( @@ -450,9 +452,6 @@ impl CombinedService { )); } - let workspace_path = self.workspace_directory_path.join(&key); - tokio::fs::create_dir_all(&workspace_path).await?; - let unit = generate_transient_unit_name(); let mut args = vec![ "--user".to_string(), @@ -460,9 +459,7 @@ impl CombinedService { "--collect".to_string(), "--pty".to_string(), ]; - let inherited_env = self - .sandbox_env_pairs(Vec::<(String, String)>::new()) - .await?; + let inherited_env = self.sandbox_env_pairs(extra_inherited_env).await?; append_systemd_run_inherit_env(&mut args, &inherited_env); args.push("--unit".to_string()); args.push(unit); @@ -476,12 +473,23 @@ impl CombinedService { }) } + /// Build a command to run a tool in an isolate, such as a user-defined exec-type tool, or the review tool. + pub async fn build_pty_tool_command( + &self, + key: &str, + command: Vec, + ) -> Result { + self.build_pty_tool_command_with_env(key, command, Vec::new()) + .await + } + pub async fn archive_workspace( &self, key: &str, progress_tx: tokio::sync::watch::Sender, ) -> Result<(), CombinedServiceError> { let key = validate_workspace_key(key)?; + self.ensure_workspace_directory_exists(&key)?; let workspace = self.manager.get_workspace(&key)?; let snapshot = workspace.subscribe().borrow().clone(); if snapshot.persistent.archived { @@ -548,6 +556,7 @@ impl CombinedService { progress_tx: tokio::sync::watch::Sender, ) -> Result<(), CombinedServiceError> { let key = validate_workspace_key(key)?; + self.ensure_workspace_directory_exists(&key)?; let workspace = self.manager.get_workspace(&key)?; let workspace_path = self.workspace_directory_path.join(&key); @@ -649,6 +658,7 @@ impl CombinedService { self.append_bwrap_sandbox_args(&mut args, key).await?; args.push(self.opencode_command.clone()); args.push("serve".to_string()); + args.push("--print-logs".to_string()); args.push("--hostname".to_string()); args.push("127.0.0.1".to_string()); args.push("--port".to_string()); @@ -682,7 +692,6 @@ impl CombinedService { extra_env: Vec<(String, String)>, ) -> Result, CombinedServiceError> { let mut env = self.github_git_credentials_env_vars(); - env.extend(extra_env); env.extend( self.expanded_isolation .inherit_env @@ -693,6 +702,8 @@ impl CombinedService { .map(|env_value| (env_name.clone(), env_value)) }), ); + env.extend(self.expanded_isolation.set_env.clone()); + env.extend(extra_env); Ok(env) } @@ -924,6 +935,20 @@ impl CombinedService { Ok(()) } + fn ensure_workspace_directory_exists(&self, key: &str) -> Result<(), CombinedServiceError> { + let workspace_path = self.workspace_directory_path.join(key); + match std::fs::metadata(&workspace_path) { + Ok(metadata) if metadata.is_dir() => Ok(()), + Ok(_) => Err(CombinedServiceError::Manager( + WorkspaceManagerError::WorkspaceNotFound(key.to_string()), + )), + Err(err) if err.kind() == ErrorKind::NotFound => Err(CombinedServiceError::Manager( + WorkspaceManagerError::WorkspaceNotFound(key.to_string()), + )), + Err(err) => return Err(CombinedServiceError::Io(err)), + } + } + async fn ensure_no_archive_conflict(&self, key: &str) -> Result<(), CombinedServiceError> { for format in [ WorkspaceArchiveFormat::TarZstd, @@ -988,14 +1013,7 @@ async fn github_git_credentials_env_from_config( } let token = github_status_service.resolved_github_token().await?; - let username = github_status_service - .authenticated_login() - .await? - .ok_or_else(|| { - CombinedServiceError::GithubGitCredentials( - "GitHub authenticated login unavailable for git credentials".to_string(), - ) - })?; + let username = "x-access-token".to_string(); Ok(Some(GithubGitCredentialsEnv { username, token })) } @@ -1442,6 +1460,7 @@ populate-git-credentials = true let _home_guard = EnvVarGuard::set("HOME", &home); let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _user_guard = EnvVarGuard::set("USER", Path::new("alice")); let config_path = root.path().join("config.toml"); fs::write(&config_path, config_with_isolation("~/workspaces")) @@ -1799,11 +1818,12 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] let service = CombinedService::from_config(config) .await - .expect_err("startup should fail without live GitHub username lookup in test env"); - assert!(matches!( - service, - CombinedServiceError::GithubStatusService(_) - )); + .expect("combined service should start without GitHub login lookup"); + let env_vars = service.github_git_credentials_env_vars(); + assert!(env_vars.contains(&( + "MULTICODE_GITHUB_USERNAME".to_string(), + "x-access-token".to_string(), + ))); }); } @@ -1821,49 +1841,15 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] let root = TestDir::new(); let home = root.path().join("home"); let runtime_dir = root.path().join("runtime"); - let github_api_dir = root.path().join("github-api"); - let github_server = github_api_dir.join("server.py"); - let github_port = 38492; fs::create_dir_all(&home).expect("home should exist"); fs::create_dir_all(&runtime_dir).expect("runtime should exist"); - fs::create_dir_all(&github_api_dir).expect("github api dir should exist"); let workspace_directory = home.join("workspaces"); fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); - fs::write( - &github_server, - format!( - r#"from http.server import BaseHTTPRequestHandler, HTTPServer -class Handler(BaseHTTPRequestHandler): - def do_GET(self): - if self.path == "/user": - body = b'{{"login":"sandbox-user","id":1,"node_id":"MDQ6VXNlcjE=","avatar_url":"https://example.com/avatar","gravatar_id":"","url":"https://api.github.com/users/sandbox-user","html_url":"https://github.com/sandbox-user","followers_url":"https://api.github.com/users/sandbox-user/followers","following_url":"https://api.github.com/users/sandbox-user/following{{/other_user}}","gists_url":"https://api.github.com/users/sandbox-user/gists{{/gist_id}}","starred_url":"https://api.github.com/users/sandbox-user/starred{{/owner}}{{/repo}}","subscriptions_url":"https://api.github.com/users/sandbox-user/subscriptions","organizations_url":"https://api.github.com/users/sandbox-user/orgs","repos_url":"https://api.github.com/users/sandbox-user/repos","events_url":"https://api.github.com/users/sandbox-user/events{{/privacy}}","received_events_url":"https://api.github.com/users/sandbox-user/received_events","type":"User","site_admin":false,"name":"Sandbox User","company":null,"blog":"","location":null,"email":null,"hireable":null,"bio":null,"twitter_username":null,"public_repos":0,"public_gists":0,"followers":0,"following":0,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","private_gists":0,"total_private_repos":0,"owned_private_repos":0,"disk_usage":0,"collaborators":0,"two_factor_authentication":false}}' - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - else: - self.send_response(404) - self.end_headers() - def log_message(self, format, *args): - pass -HTTPServer(("127.0.0.1", {github_port}), Handler).serve_forever() -"# - ), - ) - .expect("github server script should be written"); - let mut github_process = std::process::Command::new("python3") - .arg(&github_server) - .spawn() - .expect("github api server should start"); - std::thread::sleep(std::time::Duration::from_millis(250)); - let _home_guard = EnvVarGuard::set("HOME", &home); let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); unsafe { std::env::set_var("MULTICODE_GITHUB_TEST_TOKEN", "secret-token"); - std::env::set_var("GITHUB_API_URL", format!("http://127.0.0.1:{github_port}")); } let config: Config = toml::from_str( @@ -1887,7 +1873,7 @@ token = {{ env = "MULTICODE_GITHUB_TEST_TOKEN" }} let env_vars = service.github_git_credentials_env_vars(); assert!(env_vars.contains(&( "MULTICODE_GITHUB_USERNAME".to_string(), - "sandbox-user".to_string(), + "x-access-token".to_string(), ))); assert!(env_vars.contains(&( "MULTICODE_GITHUB_TOKEN".to_string(), @@ -1908,10 +1894,7 @@ token = {{ env = "MULTICODE_GITHUB_TEST_TOKEN" }} unsafe { std::env::remove_var("MULTICODE_GITHUB_TEST_TOKEN"); - std::env::remove_var("GITHUB_API_URL"); } - let _ = github_process.kill(); - let _ = github_process.wait(); }); } @@ -2051,6 +2034,7 @@ cpu = "400%" &[ service.opencode_command(), "serve", + "--print-logs", "--hostname", "127.0.0.1", "--port", @@ -3167,6 +3151,60 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] }); } + #[test] + fn build_exec_tool_command_rejects_missing_workspace_directory() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime should exist"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + r#"workspace-directory = "~/workspaces" + +[isolation] +"#, + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + fs::remove_dir_all(workspace_directory.join("alpha")) + .expect("workspace directory should be removed for test"); + + let err = service + .build_exec_tool_command("alpha", "/bin/bash") + .await + .expect_err("missing workspace path must be rejected"); + assert!(matches!( + err, + CombinedServiceError::Manager(crate::WorkspaceManagerError::WorkspaceNotFound(key)) if key == "alpha" + )); + }); + } + #[test] fn build_exec_tool_args_does_not_inherit_term_without_config() { let runtime = tokio::runtime::Builder::new_current_thread() @@ -3302,6 +3340,79 @@ inherit-env = ["TERM", "COLORTERM"] }); } + #[test] + fn build_systemd_bwrap_args_does_not_inject_configured_set_env_variables() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime should exist"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + r#"workspace-directory = "~/workspaces" + +[isolation] +inherit-env = ["HOME"] + +[isolation.set-env] +TMPDIR = "/opt/opencode-tmp/${USER}" +RUST_LOG = "debug" +"#, + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + + let command = service + .build_systemd_bwrap_command( + "alpha", + "test-password", + 33111, + "multicode-test.service", + ) + .await + .expect("command args should be built"); + let args = command.args; + + assert!(!contains_sequence(&args, &["--setenv", "TMPDIR"])); + assert!(!contains_sequence(&args, &["--setenv", "RUST_LOG"])); + let expected_tmpdir = format!( + "/opt/opencode-tmp/{}", + std::env::var("USER").unwrap_or_else(|_| "alice".to_string()) + ); + assert!(!command.inherited_env.contains(&( + "TMPDIR".to_string(), + expected_tmpdir, + ))); + assert!(!command + .inherited_env + .contains(&("RUST_LOG".to_string(), "debug".to_string()))); + }); + } + #[test] fn stop_workspace_requires_transient_snapshot() { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/lib/src/services/config.rs b/lib/src/services/config.rs index 2527899..383cb90 100644 --- a/lib/src/services/config.rs +++ b/lib/src/services/config.rs @@ -145,6 +145,8 @@ pub struct IsolationConfig { pub add_skills_from: Vec, #[serde(default, alias = "inherit-env")] pub inherit_env: Vec, + #[serde(default, alias = "set-env")] + pub set_env: BTreeMap, #[serde(default, alias = "memory-high")] pub memory_high: Option, #[serde(default, alias = "memory-max")] @@ -167,6 +169,7 @@ pub(super) struct ExpandedIsolationConfig { pub(super) tmpfs: Vec, pub(super) added_skills: Vec, pub(super) inherit_env: Vec, + pub(super) set_env: Vec<(String, String)>, pub(super) memory_high_bytes: Option, pub(super) memory_max_bytes: Option, pub(super) cpu: Option, @@ -184,6 +187,13 @@ impl ExpandedIsolationConfig { tmpfs: expand_isolation_paths(&config.tmpfs, "tmpfs")?, added_skills: resolve_added_skill_mounts(&config.add_skills_from, config_path)?, inherit_env: config.inherit_env.clone(), + set_env: config + .set_env + .iter() + .map(|(name, value)| { + expand_set_env_value(value).map(|expanded| (name.clone(), expanded)) + }) + .collect::>()?, memory_high_bytes: parse_optional_size_bytes( config.memory_high.as_deref(), "memory_high", @@ -339,6 +349,15 @@ fn expand_isolation_paths( .collect() } +fn expand_set_env_value(value: &str) -> Result { + let mut expanded = shellexpand::tilde(value).into_owned(); + for (name, env_value) in env::vars() { + expanded = expanded.replace(&format!("${{{name}}}"), &env_value); + expanded = expanded.replace(&format!("${name}"), &env_value); + } + Ok(expanded) +} + fn resolve_added_skill_mounts( paths: &[String], config_path: Option<&Path>, diff --git a/remote/src/orchestration.rs b/remote/src/orchestration.rs index d4b15c1..fb0e3b0 100644 --- a/remote/src/orchestration.rs +++ b/remote/src/orchestration.rs @@ -199,17 +199,30 @@ async fn run_remote_session( info!("launching remote multicode-tui session"); let stdout_logging_guard = logging::suppress_stdout_logging(); + let remote_socket = PathBuf::from("/tmp/multicode-remote.sock"); + let mut ssh_args = build_ssh_base_args(&args.ssh_uri, options); + let destination = ssh_args.pop().expect("ssh destination should exist"); + ssh_args.push("-o".to_string()); + ssh_args.push("ExitOnForwardFailure=yes".to_string()); + ssh_args.push("-o".to_string()); + ssh_args.push("StreamLocalBindUnlink=yes".to_string()); + ssh_args.push("-o".to_string()); + ssh_args.push("StreamLocalBindMask=0177".to_string()); + ssh_args.push("-tt".to_string()); + ssh_args.push("-R".to_string()); + ssh_args.push(format!( + "{}:{}", + remote_socket.to_string_lossy(), + relay.local_socket_path.to_string_lossy() + )); + ssh_args.push(destination); + ssh_args.push(build_remote_tui_command_with_relay_and_options( + config, + Some(remote_socket.as_path()), + options, + )); let status = Command::new("ssh") - .args(build_ssh_interactive_args_with_forwarding( - &args.ssh_uri, - &[(&relay.local_socket_path, &relay.remote_socket_path)], - options, - )) - .arg(build_remote_tui_command_with_relay_and_options( - config, - Some(relay.remote_socket_path.as_path()), - options, - )) + .args(&ssh_args) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) @@ -262,24 +275,22 @@ struct RemoteLayout { async fn prepare_relay( args: &CliArgs, - config: &ResolvedRuntimeConfig, + _config: &ResolvedRuntimeConfig, options: &RemoteCliOptions, ) -> io::Result { - let layout = remote_layout(config)?; - let local_socket_path = std::env::temp_dir().join(format!( + let local_socket_path = Path::new("/tmp").join(format!( "multicode-remote-{}-{}.sock", std::process::id(), Uuid::new_v4() )); - let remote_socket_path = layout - .remote_relay_dir - .join(format!("{}.sock", Uuid::new_v4())); + let remote_socket_path = PathBuf::from("/tmp/multicode-remote.sock"); let _ = tokio::fs::remove_file(&local_socket_path).await; run_ssh_command( args, options, &format!( - "rm -f {}", + "rm -f {} || (sudo -n rm -f {} || true)", + shell_single_quote(&remote_socket_path.to_string_lossy()), shell_single_quote(&remote_socket_path.to_string_lossy()) ), ) @@ -302,17 +313,22 @@ async fn ensure_remote_runtime_directories( remote_workspace_directory = %config.remote_workspace_directory.display(), "creating remote runtime directories" ); - run_ssh_command( - args, - options, - &format!( - "mkdir -p {} {} {}", - shell_single_quote(&layout.root.to_string_lossy()), - shell_single_quote(&layout.remote_relay_dir.to_string_lossy()), - shell_single_quote(&config.remote_workspace_directory.to_string_lossy()), - ), - ) - .await + run_ssh_command(args, options, &build_runtime_directories_command(config)?).await +} + +fn build_runtime_directories_command(config: &ResolvedRuntimeConfig) -> io::Result { + let layout = remote_layout(config)?; + let support_parent = layout + .root + .parent() + .ok_or_else(|| io::Error::other("remote runtime root has no parent directory"))?; + let support = shell_single_quote(&support_parent.to_string_lossy()); + let root = shell_single_quote(&layout.root.to_string_lossy()); + let relay = shell_single_quote(&layout.remote_relay_dir.to_string_lossy()); + let workspace = shell_single_quote(&config.remote_workspace_directory.to_string_lossy()); + Ok(format!( + "if sudo -n true >/dev/null 2>&1; then sudo mkdir -p {workspace} {support} {root} {relay} && sudo chown \"$(id -un):$(id -gn)\" {workspace} {support} {root} {relay}; else mkdir -p {workspace} {support} {root} {relay}; fi; test -w {workspace} && test -w {support} && test -w {root} && test -w {relay}" + )) } fn spawn_relay_listener( @@ -436,7 +452,13 @@ async fn sync_mappings_up( options: &RemoteCliOptions, ) -> io::Result<()> { for mapping in mappings { - ensure_local_sync_source_exists(mapping)?; + if !ensure_local_sync_source_exists(mapping)? { + warn!( + local = %mapping.local.display(), + "skipping sync-up mapping because local source path is missing" + ); + continue; + } run_rsync_command( build_rsync_up_args(&args.ssh_uri, mapping, options, true)?, format!("rsync upload for '{}'", mapping.local.display()), @@ -446,11 +468,15 @@ async fn sync_mappings_up( Ok(()) } -fn ensure_local_sync_source_exists(mapping: &ResolvedSyncPathMapping) -> io::Result<()> { - if mapping.local_is_dir && !mapping.local.exists() { +fn ensure_local_sync_source_exists(mapping: &ResolvedSyncPathMapping) -> io::Result { + if mapping.local.exists() { + return Ok(true); + } + if mapping.local_is_dir { std::fs::create_dir_all(&mapping.local)?; + return Ok(true); } - Ok(()) + Ok(false) } async fn sync_bidi_mappings_up_if_local_is_not_older( @@ -923,7 +949,9 @@ fn resolve_added_skill_sync_mappings( } mappings.insert( std::fs::canonicalize(entry.path())?, - remote_skills_root.join(remote_relative_dir), + remote_skills_root + .join(remote_relative_dir) + .join(entry.file_name()), ); } } @@ -1039,12 +1067,9 @@ fn build_ssh_interactive_args_with_forwarding( } fn build_install_command(config: &ResolvedRuntimeConfig) -> String { - let layout = - remote_layout(config).expect("resolved runtime config should produce remote layout"); format!( - "mkdir -p {} && cd {} && {}", - shell_single_quote(&layout.root.to_string_lossy()), - shell_single_quote(&layout.root.to_string_lossy()), + "cd {} && {}", + shell_single_quote(&config.remote_home_directory.to_string_lossy()), config.install_command ) } @@ -1074,17 +1099,28 @@ fn build_remote_tui_command_with_relay_and_options( argv.push(shell_single_quote("--github-token-env")); argv.push(shell_single_quote("GITHUB_MCP_TOKEN")); } - let mut command = format!("exec {}", argv.join(" ")); + let mut export_pairs = config + .rewritten_remote_config + .isolation + .set_env + .iter() + .map(|(name, value)| (name.as_str(), value.as_str())) + .collect::>(); if let Some(github_token) = &config.github_token { - command = format!( - "export GITHUB_MCP_TOKEN={} && {}", - shell_single_quote(github_token), - command - ); + export_pairs.push(("GITHUB_MCP_TOKEN", github_token.as_str())); + } + let mut command = format!("exec {}", argv.join(" ")); + if !export_pairs.is_empty() { + let exports = export_pairs + .into_iter() + .map(|(name, value)| format!("export {name}={}", shell_single_quote(value))) + .collect::>() + .join(" && "); + command = format!("{exports} && {command}"); } if options.remote_tui_sanity_check { format!( - "cd {} && printf 'pwd=%s\\nargv=%s\\n' \"$(pwd)\" {} > launch-wrapper.log && sh -lc {} > launch.stdout 2> launch.stderr", + "cd {} && printf 'pwd=%s\\nargv=%s\\n' \"$(pwd)\" {} > launch-wrapper.log && env > launch.env && sh -lc {} > launch.stdout 2> launch.stderr", shell_single_quote(&layout.root.to_string_lossy()), shell_single_quote(&argv.join(" ")), shell_single_quote(&command), @@ -1282,6 +1318,11 @@ fn expand_path(value: &str) -> io::Result { fn expand_remote_path(value: &str, remote_home_directory: &Path) -> io::Result { let home = remote_home_directory.to_string_lossy().into_owned(); + let remote_user = remote_home_directory + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + .to_string(); let trimmed = value.trim(); let expanded = if trimmed == "~" || trimmed.starts_with("~/") { trimmed.replacen('~', &home, 1) @@ -1289,6 +1330,8 @@ fn expand_remote_path(value: &str, remote_home_directory: &Path) -> io::Result

command, + Err(err) => { + self.status = + format!("Failed to prepare attach command for workspace '{key}': {err:?}"); + return; + } + }; + let mut tmux_command = vec!["systemd-run".to_string()]; + let inherited_env = attach_command.inherited_env; + tmux_command.extend(attach_command.args); match attach_in_tmux( terminal, self.service.opencode_command(), &target, &key, &custom_description, + &inherited_env, + tmux_command, ) .await { @@ -805,13 +835,10 @@ impl TuiState { "-d".to_string(), "-s".to_string(), "".to_string(), - "env".to_string(), ]; - debug_command.extend( - inherited_env - .iter() - .map(|(name, value)| format!("{name}={value}")), - ); + if !inherited_env.is_empty() { + debug_command.push(format!("<{} inherited env vars>", inherited_env.len())); + } debug_command.extend(tmux_command.clone()); debug_command }), @@ -1110,21 +1137,51 @@ impl TuiState { return; } match self.snapshot_attach_target(&key) { - Ok(target) => { - let custom_description = self - .snapshots - .get(&key) - .map(|snapshot| snapshot.persistent.description.clone()) - .unwrap_or_default(); - match attach_in_tmux( - terminal, - self.service.opencode_command(), - &target, - &key, - &custom_description, - ) - .await - { + Ok(target) => { + let custom_description = self + .snapshots + .get(&key) + .map(|snapshot| snapshot.persistent.description.clone()) + .unwrap_or_default(); + let attach_command = match self + .service + .build_pty_tool_command_with_env( + &key, + attach_cli_args(self.service.opencode_command(), &target), + vec![ + ( + "OPENCODE_SERVER_USERNAME".to_string(), + target.username.clone(), + ), + ( + "OPENCODE_SERVER_PASSWORD".to_string(), + target.password.clone(), + ), + ], + ) + .await + { + Ok(command) => command, + Err(err) => { + self.status = + format!("Failed to prepare attach command for workspace '{key}': {err:?}"); + return; + } + }; + let mut tmux_command = vec!["systemd-run".to_string()]; + let inherited_env = attach_command.inherited_env; + tmux_command.extend(attach_command.args); + match attach_in_tmux( + terminal, + self.service.opencode_command(), + &target, + &key, + &custom_description, + &inherited_env, + tmux_command, + ) + .await + { Ok(_) => { self.status = format!( "Detached from workspace '{key}' opencode client" diff --git a/tui/src/ops.rs b/tui/src/ops.rs index 024894d..061435d 100644 --- a/tui/src/ops.rs +++ b/tui/src/ops.rs @@ -155,15 +155,15 @@ pub(crate) async fn validate_workspace_link_target( } pub(crate) fn attach_cli_args(opencode_command: &str, target: &AttachTarget) -> Vec { - let mut args = vec![opencode_command.to_string(), "attach".to_string()]; - if let Some(session_id) = target.session_id.as_deref() { - args.push("--session".to_string()); - args.push(session_id.to_string()); - } - args.push(target.uri.clone()); - args + vec![ + opencode_command.to_string(), + "attach".to_string(), + "--print-logs".to_string(), + target.uri.clone(), + ] } +#[cfg(test)] pub(crate) fn tmux_session_command( command: Vec, original_term: Option<&str>, @@ -179,22 +179,17 @@ pub(crate) fn tmux_session_command( pub(crate) async fn attach_in_tmux( terminal: &mut Terminal>, - opencode_command: &str, - target: &AttachTarget, + _opencode_command: &str, + _target: &AttachTarget, workspace_key: &str, custom_description: &str, + env: &[(String, String)], + command: Vec, ) -> io::Result<()> { - let original_term = std::env::var("TERM").ok(); - let attach_command = vec![ - format!("OPENCODE_SERVER_USERNAME={}", target.username), - format!("OPENCODE_SERVER_PASSWORD={}", target.password), - ]; - let mut attach_command = tmux_session_command(attach_command, original_term.as_deref()); - attach_command.extend(attach_cli_args(opencode_command, target)); run_tmux_new_session_command( terminal, - &[], - attach_command, + env, + command, workspace_key, custom_description, ) @@ -216,30 +211,44 @@ pub(crate) async fn run_tmux_new_session_command( let mut run_error = None; + if let Err(err) = ensure_tmux_server_persistence().await { + tracing::warn!(error = ?err, "failed to configure tmux server persistence"); + } + let mut create_command = vec![ "new-session".to_string(), "-d".to_string(), "-s".to_string(), session_name.clone(), - "env".to_string(), ]; - create_command.extend(env.iter().map(|(name, value)| format!("{name}={value}"))); - create_command.extend(command.clone()); + if !env.is_empty() { + create_command.push("env".to_string()); + create_command.extend(env.iter().map(|(name, value)| format!("{name}={value}"))); + } + let create_command_for_log = { + let mut command_for_log = create_command.clone(); + if !env.is_empty() { + command_for_log.splice( + 4..(4 + 1 + env.len()), + [ + "env".to_string(), + format!("<{} inherited env vars>", env.len()), + ], + ); + } + command_for_log.extend(build_tmux_detached_command_wrapper(&command)); + command_for_log + }; tracing::info!( - command = %format_command_line("tmux", &create_command), + command = %format_command_line("tmux", &create_command_for_log), "starting application via tmux new-session" ); let mut create_process = Command::new("tmux"); create_process.env("TERM", "xterm-256color"); let create_result = create_process - .arg("new-session") - .arg("-d") - .arg("-s") - .arg(&session_name) - .arg("env") - .args(env.iter().map(|(name, value)| format!("{name}={value}"))) - .args(command) + .args(&create_command) + .args(build_tmux_detached_command_wrapper(&command)) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) @@ -322,6 +331,57 @@ pub(crate) async fn run_tmux_new_session_command( } } +async fn ensure_tmux_server_persistence() -> io::Result<()> { + let start_status = Command::new("tmux") + .arg("start-server") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await?; + + if !start_status.success() { + return Err(io::Error::other(format!( + "tmux start-server exited with status {start_status}" + ))); + } + + for (scope, option, value) in [ + ("-s", "exit-empty", "off"), + ("-g", "destroy-unattached", "off"), + ] { + let status = Command::new("tmux") + .arg("set-option") + .arg(scope) + .arg(option) + .arg(value) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await?; + + if !status.success() { + return Err(io::Error::other(format!( + "tmux set-option {scope} {option} {value} exited with status {status}" + ))); + } + } + + Ok(()) +} + +fn build_tmux_detached_command_wrapper(command: &[String]) -> Vec { + let mut wrapped = vec![ + "sh".to_string(), + "-lc".to_string(), + "trap '' HUP; exec \"$@\"".to_string(), + "sh".to_string(), + ]; + wrapped.extend(command.iter().cloned()); + wrapped +} + pub(crate) async fn set_tmux_session_option( session_name: &str, option: &str, diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 866ebc3..83a6e77 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -154,7 +154,7 @@ mod tests { } #[test] - fn attach_cli_args_appends_session_when_present() { + fn attach_cli_args_uses_sanitized_uri_without_session() { let target = AttachTarget { uri: "http://127.0.0.1:3000/".to_string(), username: "opencode".to_string(), @@ -167,8 +167,7 @@ mod tests { vec![ "opencode".to_string(), "attach".to_string(), - "--session".to_string(), - "ses-root-latest".to_string(), + "--print-logs".to_string(), "http://127.0.0.1:3000/".to_string(), ] ); @@ -188,6 +187,7 @@ mod tests { vec![ "opencode".to_string(), "attach".to_string(), + "--print-logs".to_string(), "http://127.0.0.1:3000/".to_string(), ] ); From aa696e4cab1751197dc035f86ee10080c241b09f Mon Sep 17 00:00:00 2001 From: Nemanja Mikic Date: Wed, 1 Apr 2026 16:19:23 +0200 Subject: [PATCH 2/2] fix sessions --- tui/src/ops.rs | 11 ++++++++--- tui/src/tests.rs | 4 +++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tui/src/ops.rs b/tui/src/ops.rs index 061435d..ff76182 100644 --- a/tui/src/ops.rs +++ b/tui/src/ops.rs @@ -155,12 +155,17 @@ pub(crate) async fn validate_workspace_link_target( } pub(crate) fn attach_cli_args(opencode_command: &str, target: &AttachTarget) -> Vec { - vec![ + let mut args = vec![ opencode_command.to_string(), "attach".to_string(), "--print-logs".to_string(), - target.uri.clone(), - ] + ]; + if let Some(session_id) = target.session_id.as_deref() { + args.push("--session".to_string()); + args.push(session_id.to_string()); + } + args.push(target.uri.clone()); + args } #[cfg(test)] diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 83a6e77..a7c16ab 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -154,7 +154,7 @@ mod tests { } #[test] - fn attach_cli_args_uses_sanitized_uri_without_session() { + fn attach_cli_args_appends_session_when_present() { let target = AttachTarget { uri: "http://127.0.0.1:3000/".to_string(), username: "opencode".to_string(), @@ -168,6 +168,8 @@ mod tests { "opencode".to_string(), "attach".to_string(), "--print-logs".to_string(), + "--session".to_string(), + "ses-root-latest".to_string(), "http://127.0.0.1:3000/".to_string(), ] );