diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 65b679f70a..15e04e305f 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -131,6 +131,13 @@ export function DialogSelectServer() { busy: false, status: undefined as boolean | undefined, }, + + ssh: { + command: "", + connecting: false, + error: "", + showForm: false, + }, }) const [defaultUrl, defaultUrlActions] = createResource( async () => { @@ -150,6 +157,7 @@ export function DialogSelectServer() { { initialValue: null }, ) const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) + const canSsh = createMemo(() => !!platform.sshConnect) const fetcher = platform.fetch ?? globalThis.fetch const looksComplete = (value: string) => { @@ -189,6 +197,15 @@ export function DialogSelectServer() { }) } + const resetSsh = () => { + setStore("ssh", { + command: "", + connecting: false, + error: "", + showForm: false, + }) + } + const replaceServer = (original: string, next: string) => { const active = server.url const nextActive = active === original ? next : active @@ -360,6 +377,35 @@ export function DialogSelectServer() { } } + async function handleSshConnect() { + if (!platform.sshConnect) return + if (store.ssh.connecting) return + + const command = store.ssh.command.trim() + if (!command) { + resetSsh() + return + } + + setStore("ssh", { connecting: true, error: "" }) + try { + const result = await platform.sshConnect(command) + const url = normalizeServerUrl(result.url) + if (!url) { + setStore("ssh", { error: language.t("dialog.server.add.error") }) + return + } + resetSsh() + await select(url, true) + } catch (err) { + setStore("ssh", { + error: err instanceof Error ? err.message : String(err), + }) + } finally { + setStore("ssh", { connecting: false }) + } + } + return (
@@ -517,18 +563,80 @@ export function DialogSelectServer() {
- +
+
+ + + + + +
+ + +
+ { + if (store.ssh.connecting) return + setStore("ssh", { command: value, error: "" }) + }} + onKeyDown={(event: KeyboardEvent) => { + event.stopPropagation() + if (event.key === "Escape") { + event.preventDefault() + resetSsh() + return + } + if (event.key !== "Enter" || event.isComposing) return + event.preventDefault() + void handleSshConnect() + }} + /> +
+ + +
+
+
+
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index a96bdcbad5..f193a9ffe3 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -136,6 +136,15 @@ export function createPromptSubmit(input: PromptSubmitInput) { input.resetHistoryNavigation() const projectDirectory = sdk.directory + if (!projectDirectory) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/") + return + } const isNewSession = !params.id const worktreeSelection = input.newSessionWorktree ?? "main" @@ -194,7 +203,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { description: errorMessage(err), }) return undefined - }) + }); + + console.log({sessionDirectory}) if (session) { layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 3baafe5111..6a69897a74 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -166,7 +166,11 @@ export const Terminal = (props: TerminalProps) => { const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) url.protocol = url.protocol === "https:" ? "wss:" : "ws:" - if (window.__OPENCODE__?.serverPassword) { + const auth = platform.wsAuth?.(sdk.url) + if (auth) { + url.username = auth.username + url.password = auth.password + } else if (window.__OPENCODE__?.serverPassword) { url.username = "opencode" url.password = window.__OPENCODE__?.serverPassword } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 3fca502bad..5b4a26bd0b 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -57,6 +57,21 @@ export type Platform = { /** Set the default server URL to use on app startup (platform-specific) */ setDefaultServerUrl?(url: string | null): Promise | void + /** Override how the app groups server state (projects/history) for a URL */ + serverKey?(url: string): string + + /** Override whether a server URL should be treated as local */ + isServerLocal?(url: string): boolean + + /** Connect to a remote server over SSH (desktop only) */ + sshConnect?(command: string): Promise<{ url: string; key: string; password: string | null }> + + /** Disconnect an SSH session (desktop only) */ + sshDisconnect?(key: string): Promise + + /** Credentials to embed in WebSocket URLs (desktop only) */ + wsAuth?(url: string): { username: string; password: string } | null + /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ parseMarkdown?(markdown: string): Promise diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 351407d91b..49254ba2ed 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -148,9 +148,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) }) - const origin = createMemo(() => projectsKey(state.active)) + const origin = createMemo(() => { + const url = state.active + if (!url) return "" + return platform.serverKey?.(url) ?? projectsKey(url) + }) const projectsList = createMemo(() => store.projects[origin()] ?? []) - const isLocal = createMemo(() => origin() === "local") + const isLocal = createMemo(() => { + const url = state.active + if (!url) return false + return platform.isServerLocal?.(url) ?? origin() === "local" + }) return { ready: isReady, diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index b2a17b96b9..f331dabc4e 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -21,7 +21,7 @@ export default function Layout(props: ParentProps) { }) createEffect(() => { - if (!params.dir) return + if (params.dir === undefined) return if (directory()) return if (invalid === params.dir) return invalid = params.dir diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 7678ea6a8d..a552ebb313 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -591,7 +591,8 @@ export default function Page() { const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" const project = sync.project - if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory + const directory = sync.data.path.directory + if (project && directory && directory !== project.worktree) return directory return "main" }) diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 537a7c9c56..8bd701528c 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -3076,6 +3076,7 @@ dependencies = [ "semver", "serde", "serde_json", + "shell-words", "specta", "specta-typescript", "tauri", @@ -4423,6 +4424,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 2d6f13eca0..7b0cc2a3b3 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -34,7 +34,7 @@ tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = "1.48.0" +tokio = { version = "1.48.0", features = ["process", "net", "io-util", "time", "sync", "rt", "macros"] } listeners = "0.3" tauri-plugin-os = "2" futures = "0.3.31" @@ -47,6 +47,7 @@ specta = "=2.0.0-rc.22" specta-typescript = "0.0.9" tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } dirs = "6.0.0" +shell-words = "1.1.0" [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 14105e5dd3..472d9e233a 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod constants; mod job_object; mod markdown; mod server; +mod ssh; mod window_customizer; mod windows; @@ -210,7 +211,10 @@ pub fn run() { server::get_default_server_url, server::set_default_server_url, markdown::parse_markdown_command, - check_app_exists + check_app_exists, + ssh::ssh_connect, + ssh::ssh_disconnect, + ssh::ssh_prompt_reply ]) .events(tauri_specta::collect_events![LoadingWindowComplete]) .error_handling(tauri_specta::ErrorHandlingMode::Throw); @@ -276,6 +280,7 @@ pub fn run() { println!("Received Exit"); kill_sidecar(app.clone()); + ssh::shutdown(app.clone()); } }); } @@ -426,6 +431,8 @@ fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver) { // Initialize log state app.manage(LogState(Arc::new(Mutex::new(VecDeque::new())))); + app.manage(ssh::SshState::default()); + #[cfg(windows)] app.manage(JobObjectState::new()); diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 9ffee8aa5c..cdd5b7d5dd 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -52,6 +52,52 @@ fn configure_display_backend() -> Option { } fn main() { + #[cfg(unix)] + { + if let Some(socket) = std::env::args().skip_while(|a| a != "--ssh-askpass").nth(1) { + use std::io::{Read as _, Write as _}; + use std::os::unix::net::UnixStream; + use std::process::exit; + + let prompt = std::env::args() + .skip_while(|a| a != "--ssh-askpass") + .skip(2) + .collect::>() + .join(" "); + + let mut stream = match UnixStream::connect(&socket) { + Ok(v) => v, + Err(err) => { + eprintln!("askpass connect failed: {err}"); + exit(1); + } + }; + + let bytes = prompt.as_bytes(); + let len = u32::try_from(bytes.len()).unwrap_or(0); + if stream.write_all(&len.to_be_bytes()).is_err() || stream.write_all(bytes).is_err() { + eprintln!("askpass write failed"); + exit(1); + } + + let mut len_buf = [0u8; 4]; + if stream.read_exact(&mut len_buf).is_err() { + eprintln!("askpass read failed"); + exit(1); + } + let reply_len = u32::from_be_bytes(len_buf) as usize; + let mut reply = vec![0u8; reply_len]; + if stream.read_exact(&mut reply).is_err() { + eprintln!("askpass read failed"); + exit(1); + } + + let _ = std::io::stdout().write_all(&reply); + let _ = std::io::stdout().write_all(b"\n"); + return; + } + } + // Ensure loopback connections are never sent through proxy settings. // Some VPNs/proxies set HTTP_PROXY/HTTPS_PROXY/ALL_PROXY without excluding localhost. const LOOPBACK: [&str; 3] = ["127.0.0.1", "localhost", "::1"]; diff --git a/packages/desktop/src-tauri/src/ssh.rs b/packages/desktop/src-tauri/src/ssh.rs new file mode 100644 index 0000000000..4895724eaa --- /dev/null +++ b/packages/desktop/src-tauri/src/ssh.rs @@ -0,0 +1,777 @@ +use std::{ + collections::HashMap, + net::TcpListener, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +use tauri::{AppHandle, Emitter as _, Manager}; +use tokio::{ + io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}, + net::UnixListener, + process::{Child, Command}, + sync::{Mutex, oneshot}, +}; + +use crate::server; + +fn log(line: impl AsRef) { + eprintln!("[SSH] {}", line.as_ref()); +} + +#[derive(Clone, serde::Serialize, specta::Type, Debug)] +pub struct SshConnectData { + pub key: String, + pub url: String, + pub password: String, + pub destination: String, +} + +#[derive(Clone, serde::Serialize, specta::Type, Debug)] +pub struct SshPrompt { + pub id: String, + pub prompt: String, +} + +#[derive(Clone, serde::Serialize, specta::Type, Debug)] +pub struct SshConnectState { + pub state: String, + pub error: Option, +} + +#[derive(Default)] +pub struct SshState { + session: Mutex>, + prompts: Mutex>>, +} + +struct SshSession { + key: String, + destination: String, + dir: PathBuf, + socket_path: PathBuf, + askpass_task: tokio::task::JoinHandle<()>, + master: Child, + forward: Child, + server: Child, +} + +#[derive(Debug, Clone)] +struct Spec { + destination: String, + args: Vec, +} + +fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("Failed to bind to find free port") + .local_addr() + .expect("Failed to get local address") + .port() +} + +fn parse_ssh_command(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("SSH command is empty".to_string()); + } + + let without_prefix = trimmed.strip_prefix("ssh ").unwrap_or(trimmed); + let tokens = shell_words::split(without_prefix) + .map_err(|e| format!("Invalid SSH command: {e}"))?; + if tokens.is_empty() { + return Err("SSH command is empty".to_string()); + } + + const ALLOWED_OPTS: &[&str] = &[ + "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y", + ]; + const ALLOWED_ARGS: &[&str] = &[ + "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", + "-p", "-w", + ]; + + // Disallowed: -E, -e, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W, -L, -R + let mut args = Vec::::new(); + let mut i = 0; + let mut destination: Option = None; + + while i < tokens.len() { + let tok = &tokens[i]; + + if destination.is_some() { + return Err( + "SSH command cannot include a remote command; only destination + options are supported" + .to_string(), + ); + } + + if ALLOWED_OPTS.contains(&tok.as_str()) { + args.push(tok.clone()); + i += 1; + continue; + } + + if tok == "-L" || tok.starts_with("-L") || tok == "-R" || tok.starts_with("-R") { + return Err("SSH port forwarding flags (-L/-R) are not supported yet".to_string()); + } + + if tok.starts_with('-') { + let mut matched = false; + for opt in ALLOWED_ARGS { + if tok == opt { + matched = true; + args.push(tok.clone()); + i += 1; + if i < tokens.len() { + args.push(tokens[i].clone()); + i += 1; + } + break; + } + if tok.starts_with(opt) { + matched = true; + args.push(tok.clone()); + i += 1; + break; + } + } + if matched { + continue; + } + return Err(format!("Unsupported ssh argument: {tok}")); + } + + destination = Some(tok.clone()); + i += 1; + } + + let Some(destination) = destination else { + return Err("Missing ssh destination (e.g. user@host)".to_string()); + }; + + Ok(Spec { destination, args }) +} + +fn sh_quote(input: &str) -> String { + let escaped = input.replace('\'', "'\\'''"); + format!("'{}'", escaped) +} + +fn exe_path(app: &AppHandle) -> Result { + tauri::process::current_binary(&app.env()).map_err(|e| format!("Failed to locate current binary: {e}")) +} + +fn write_executable(path: &Path, content: &str) -> Result<(), String> { + std::fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("Failed to chmod {}: {e}", path.display()))?; + } + + Ok(()) +} + +fn askpass_script(app: &AppHandle, socket: &Path, dst: &Path) -> Result<(), String> { + let exe = exe_path(app)?; + let script = format!( + "#!/bin/sh\nexec {} --ssh-askpass {} \"$@\"\n", + sh_quote(&exe.to_string_lossy()), + sh_quote(&socket.to_string_lossy()), + ); + write_executable(dst, &script) +} + +fn ssh_command(script: &Path, args: Vec) -> Command { + let mut cmd = Command::new("ssh"); + cmd.args(args); + cmd.stdin(std::process::Stdio::null()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + cmd.env("SSH_ASKPASS_REQUIRE", "force"); + cmd.env("SSH_ASKPASS", script); + + if std::env::var_os("DISPLAY").is_none() { + cmd.env("DISPLAY", "1"); + } + + // keep behavior consistent even if ssh wants a tty. + cmd.env("TERM", "dumb"); + cmd +} + +fn ssh_spawn_bg(script: &Path, args: Vec) -> Command { + let mut cmd = Command::new("ssh"); + cmd.args(args); + cmd.stdin(std::process::Stdio::null()); + cmd.stdout(std::process::Stdio::null()); + cmd.stderr(std::process::Stdio::piped()); + + cmd.env("SSH_ASKPASS_REQUIRE", "force"); + cmd.env("SSH_ASKPASS", script); + + if std::env::var_os("DISPLAY").is_none() { + cmd.env("DISPLAY", "1"); + } + + cmd.env("TERM", "dumb"); + cmd +} + +async fn ssh_output(_app: &AppHandle, script: &Path, args: Vec) -> Result { + let out = ssh_command(script, args) + .output() + .await + .map_err(|e| format!("Failed to run ssh: {e}"))?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let msg = stderr.trim(); + if msg.is_empty() { + return Err("SSH command failed".to_string()); + } + return Err(msg.to_string()); + } + + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + +async fn wait_master_ready(_app: &AppHandle, script: &Path, spec: &Spec, socket_path: &Path) -> Result<(), String> { + let start = Instant::now(); + loop { + if start.elapsed() > Duration::from_secs(30) { + return Err("Timed out waiting for SSH connection".to_string()); + } + + let res = ssh_command( + script, + vec![ + "-o".into(), + format!("ControlPath={}", socket_path.display()), + "-O".into(), + "check".into(), + spec.destination.clone(), + ], + ) + .output() + .await; + + if let Ok(out) = res { + if out.status.success() { + return Ok(()); + } + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +async fn ensure_remote_opencode( + app: &AppHandle, + script: &Path, + spec: &Spec, + socket_path: &Path, +) -> Result<(), String> { + let version = app.package_info().version.to_string(); + + let installed = ssh_output( + app, + script, + [ + spec.args.clone(), + vec![ + "-o".into(), + "ControlMaster=no".into(), + "-o".into(), + format!("ControlPath={}", socket_path.display()), + spec.destination.clone(), + "cd; ~/.opencode/bin/opencode --version".into(), + ], + ] + .concat(), + ) + .await + .ok() + .map(|v| v.trim().to_string()); + + match installed.as_deref() { + Some(version) => log(format!("Remote opencode detected: {version}")), + None => log("Remote opencode not found"), + } + + if installed.as_deref() == Some(version.as_str()) { + return Ok(()); + } + + log("Starting remote install"); + let cmd = format!( + "cd; bash -lc {}", + sh_quote(&format!( + "curl -fsSL https://opencode.ai/install | bash -s -- --version {version} --no-modify-path" + )) + ); + + ssh_output( + app, + script, + [ + spec.args.clone(), + vec![ + "-o".into(), + "ControlMaster=no".into(), + "-o".into(), + format!("ControlPath={}", socket_path.display()), + spec.destination.clone(), + cmd, + ], + ] + .concat(), + ) + .await + .map(|_| ())?; + + log("Remote install finished"); + + Ok(()) +} + +async fn spawn_master(_app: &AppHandle, script: &Path, spec: &Spec, socket_path: &Path) -> Result { + let mut child = ssh_spawn_bg( + script, + [ + spec.args.clone(), + vec![ + "-N".into(), + "-o".into(), + "ControlMaster=yes".into(), + "-o".into(), + "ControlPersist=no".into(), + "-o".into(), + format!("ControlPath={}", socket_path.display()), + spec.destination.clone(), + ], + ] + .concat(), + ) + .spawn() + .map_err(|e| format!("Failed to start ssh: {e}"))?; + + if let Some(stderr) = child.stderr.take() { + tokio::spawn(async move { + let mut err = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = err.next_line().await { + if !line.trim().is_empty() { + log(format!("[master] {line}")); + } + } + }); + } + + Ok(child) +} + +fn parse_listening_port(line: &str) -> Option { + let needle = "opencode server listening on http://"; + let rest = line.trim(); + let rest = rest.strip_prefix(needle)?; + let hostport = rest.split_whitespace().next().unwrap_or(rest); + let port = hostport.rsplit(':').next()?; + port.trim().parse().ok() +} + +async fn spawn_remote_server( + _app: &AppHandle, + script: &Path, + spec: &Spec, + socket_path: &Path, + password: &str, +) -> Result<(Child, u16), String> { + let cmd = format!( + "cd; env OPENCODE_SERVER_USERNAME=opencode OPENCODE_SERVER_PASSWORD={password} OPENCODE_CLIENT=desktop ~/.opencode/bin/opencode serve --hostname 127.0.0.1 --port 0" + ); + + let mut child = ssh_command( + script, + [ + spec.args.clone(), + vec![ + "-o".into(), + "ControlMaster=no".into(), + "-o".into(), + format!("ControlPath={}", socket_path.display()), + spec.destination.clone(), + cmd, + ], + ] + .concat(), + ) + .spawn() + .map_err(|e| format!("Failed to start remote server: {e}"))?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| "Failed to capture remote server stdout".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "Failed to capture remote server stderr".to_string())?; + + let (tx, mut rx) = tokio::sync::mpsc::channel::(1); + tokio::spawn(async move { + let mut out = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = out.next_line().await { + if !line.trim().is_empty() { + log(format!("[server] {line}")); + } + if let Some(port) = parse_listening_port(&line) { + let _ = tx.try_send(port); + } + } + }); + tokio::spawn(async move { + let mut err = BufReader::new(stderr).lines(); + while let Ok(Some(_line)) = err.next_line().await { + if !_line.trim().is_empty() { + log(format!("[server] {_line}")); + } + } + }); + + let port = tokio::time::timeout(Duration::from_secs(30), rx.recv()) + .await + .map_err(|_| "Timed out waiting for remote server to start".to_string())? + .ok_or_else(|| "Remote server exited before becoming ready".to_string())?; + + Ok((child, port)) +} + +async fn spawn_forward( + _app: &AppHandle, + script: &Path, + spec: &Spec, + socket_path: &Path, + local_port: u16, + remote_port: u16, +) -> Result { + let forward = format!("127.0.0.1:{local_port}:127.0.0.1:{remote_port}"); + let mut child = ssh_spawn_bg( + script, + [ + spec.args.clone(), + vec![ + "-N".into(), + "-L".into(), + forward, + "-o".into(), + "ExitOnForwardFailure=yes".into(), + "-o".into(), + "ControlMaster=no".into(), + "-o".into(), + format!("ControlPath={}", socket_path.display()), + spec.destination.clone(), + ], + ] + .concat(), + ) + .spawn() + .map_err(|e| format!("Failed to start port forward: {e}"))?; + + if let Some(stderr) = child.stderr.take() { + tokio::spawn(async move { + let mut err = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = err.next_line().await { + if !line.trim().is_empty() { + log(format!("[forward] {line}")); + } + } + }); + } + + Ok(child) +} + +async fn disconnect_session(mut session: SshSession) { + let _ = session.forward.kill().await; + let _ = session.server.kill().await; + let _ = session.master.kill().await; + + session.askpass_task.abort(); + let _ = std::fs::remove_dir_all(session.dir); +} + +async fn read_prompt(stream: &mut tokio::net::UnixStream) -> Result { + let mut len_buf = [0u8; 4]; + stream + .read_exact(&mut len_buf) + .await + .map_err(|e| format!("Failed to read prompt length: {e}"))?; + let len = u32::from_be_bytes(len_buf) as usize; + if len > 64 * 1024 { + return Err("Askpass prompt too large".to_string()); + } + let mut buf = vec![0u8; len]; + stream + .read_exact(&mut buf) + .await + .map_err(|e| format!("Failed to read prompt: {e}"))?; + let prompt = String::from_utf8(buf).map_err(|_| "Askpass prompt was not UTF-8".to_string())?; + Ok(prompt) +} + +async fn write_reply(stream: &mut tokio::net::UnixStream, value: &str) -> Result<(), String> { + let bytes = value.as_bytes(); + let len = u32::try_from(bytes.len()).map_err(|_| "Askpass reply too large".to_string())?; + stream + .write_all(&len.to_be_bytes()) + .await + .map_err(|e| format!("Failed to write reply length: {e}"))?; + stream + .write_all(bytes) + .await + .map_err(|e| format!("Failed to write reply: {e}"))?; + Ok(()) +} + +fn spawn_askpass_server(app: AppHandle, socket: PathBuf) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let listener = match UnixListener::bind(&socket) { + Ok(v) => v, + Err(e) => { + log(format!("Failed to bind askpass socket {}: {e}", socket.display())); + return; + } + }; + + log(format!("Askpass listening on {}", socket.display())); + + loop { + let Ok((mut stream, _)) = listener.accept().await else { + return; + }; + + let app = app.clone(); + tokio::spawn(async move { + let prompt = match read_prompt(&mut stream).await { + Ok(v) => v, + Err(_) => return, + }; + + log(format!("Prompt received: {}", prompt.replace('\n', "\\n"))); + + let id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel::(); + + { + let state = app.state::(); + state.prompts.lock().await.insert(id.clone(), tx); + } + + match app.emit( + "ssh_prompt", + SshPrompt { + id: id.clone(), + prompt, + }, + ) { + Ok(()) => log(format!("Prompt emitted: {id}")), + Err(e) => log(format!("Prompt emit failed: {id}: {e}")), + }; + + let value = tokio::time::timeout(Duration::from_secs(120), rx) + .await + .ok() + .and_then(|r| r.ok()) + .unwrap_or_default(); + + if value.is_empty() { + log(format!("Prompt reply empty/timeout: {id}")); + } else { + log(format!("Prompt reply received: {id}")); + } + + { + let state = app.state::(); + state.prompts.lock().await.remove(&id); + } + + let _ = write_reply(&mut stream, &value).await; + }); + } + }) +} + +#[tauri::command] +#[specta::specta] +pub async fn ssh_prompt_reply(app: AppHandle, id: String, value: String) -> Result<(), String> { + log(format!("Prompt reply from UI: {id} ({} chars)", value.len())); + let state = app.state::(); + let tx = state.prompts.lock().await.remove(&id); + let Some(tx) = tx else { + return Ok(()); + }; + let _ = tx.send(value); + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn ssh_disconnect(app: AppHandle, key: String) -> Result<(), String> { + let state = app.state::(); + let session = { + let mut lock = state.session.lock().await; + if lock.as_ref().is_some_and(|s| s.key == key) { + lock.take() + } else { + None + } + }; + + if let Some(session) = session { + tokio::spawn(async move { + disconnect_session(session).await; + }); + } + + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn ssh_connect(app: AppHandle, command: String) -> Result { + if cfg!(not(unix)) { + return Err("SSH connect is only supported on macOS & Linux".to_string()); + } + + let _ = app.emit( + "ssh_connect_state", + SshConnectState { + state: "connecting".to_string(), + error: None, + }, + ); + + let result = async { + let spec = parse_ssh_command(&command)?; + + log(format!("Connect requested: {}", spec.destination)); + + // Disconnect any existing session. + { + let state = app.state::(); + if let Some(session) = state.session.lock().await.take() { + disconnect_session(session).await; + } + } + + let key = uuid::Uuid::new_v4().to_string(); + let password = uuid::Uuid::new_v4().to_string(); + let local_port = free_port(); + let url = format!("http://127.0.0.1:{local_port}"); + + // Unix domain sockets (and OpenSSH ControlPath) have strict length limits on macOS. + // Avoid long per-user temp dirs like /var/folders/... by using /tmp. + let dir = PathBuf::from("/tmp").join(format!("opencode-ssh-{key}")); + std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create temp dir: {e}"))?; + + let socket_path = dir.join("ssh.sock"); + let askpass_socket = dir.join("askpass.sock"); + let askpass_path = dir.join("askpass.sh"); + askpass_script(&app, &askpass_socket, &askpass_path)?; + + log(format!("Session dir: {}", dir.display())); + log(format!("ControlPath: {}", socket_path.display())); + log(format!("Askpass socket: {}", askpass_socket.display())); + + let askpass_task = spawn_askpass_server(app.clone(), askpass_socket); + + log("Starting SSH master"); + let master = spawn_master(&app, &askpass_path, &spec, &socket_path).await?; + log("Waiting for master ready"); + wait_master_ready(&app, &askpass_path, &spec, &socket_path).await?; + log("Master ready"); + + log("Ensuring remote opencode"); + ensure_remote_opencode(&app, &askpass_path, &spec, &socket_path).await?; + log("Remote opencode ready"); + + log("Starting remote opencode server"); + let (server_child, remote_port) = + spawn_remote_server(&app, &askpass_path, &spec, &socket_path, &password).await?; + + log(format!("Remote server port: {remote_port}")); + log(format!("Starting port forward to {url}")); + let forward_child = + spawn_forward(&app, &askpass_path, &spec, &socket_path, local_port, remote_port).await?; + + log("Waiting for forwarded health"); + let start = Instant::now(); + loop { + if start.elapsed() > Duration::from_secs(30) { + return Err("Timed out waiting for forwarded server health".to_string()); + } + if server::check_health(&url, Some(&password)).await { + log("Forwarded health OK"); + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + let session = SshSession { + key: key.clone(), + destination: spec.destination.clone(), + dir: dir.clone(), + socket_path, + askpass_task, + master, + forward: forward_child, + server: server_child, + }; + + app.state::() + .session + .lock() + .await + .replace(session); + + Ok(SshConnectData { + key, + url, + password, + destination: spec.destination, + }) + } + .await; + + let _ = match &result { + Ok(_) => app.emit( + "ssh_connect_state", + SshConnectState { + state: "done".to_string(), + error: None, + }, + ), + Err(err) => app.emit( + "ssh_connect_state", + SshConnectState { + state: "error".to_string(), + error: Some(err.clone()), + }, + ), + }; + + result +} + +pub fn shutdown(app: AppHandle) { + tauri::async_runtime::spawn(async move { + let state = app.state::(); + if let Some(session) = state.session.lock().await.take() { + disconnect_session(session).await; + } + }); +} diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 562a98acae..6f1f4c58fc 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -12,6 +12,9 @@ export const commands = { setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), checkAppExists: (appName: string) => __TAURI_INVOKE("check_app_exists", { appName }), + sshConnect: (command: string) => __TAURI_INVOKE("ssh_connect", { command }), + sshDisconnect: (key: string) => __TAURI_INVOKE("ssh_disconnect", { key }), + sshPromptReply: (id: string, value: string) => __TAURI_INVOKE("ssh_prompt_reply", { id, value }), }; /** Events */ @@ -29,6 +32,13 @@ export type ServerReadyData = { password: string | null, }; +export type SshConnectData = { + key: string, + url: string, + password: string, + destination: string, + }; + /* Tauri Specta runtime */ function makeEvent(name: string) { const base = { diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index cf007bdd3c..c5ed1900f0 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -17,13 +17,19 @@ import { Store } from "@tauri-apps/plugin-store" import { Splash } from "@opencode-ai/ui/logo" import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" import { readImage } from "@tauri-apps/plugin-clipboard-manager" +import { createStore } from "solid-js/store" +import { listen } from "@tauri-apps/api/event" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { TextField } from "@opencode-ai/ui/text-field" +import { Button } from "@opencode-ai/ui/button" import { UPDATER_ENABLED } from "./updater" import { initI18n, t } from "./i18n" import pkg from "../package.json" import "./styles.css" import { commands, InitStep } from "./bindings" -import { Channel } from "@tauri-apps/api/core" +import { Channel, invoke } from "@tauri-apps/api/core" import { createMenu } from "./menu" const root = document.getElementById("root") @@ -33,6 +39,50 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { void initI18n() +const ssh = new Map() +const auth = new Map() + +let base = null as string | null + +type SshPrompt = { id: string; prompt: string } +type SshConnectState = { state: "connecting" | "done" | "error"; error?: string | null } + +const sshPromptEvent = "opencode:ssh-prompt" +const sshPrompts: SshPrompt[] = [] + +void listen("ssh_prompt", (event) => { + sshPrompts.push(event.payload) + window.dispatchEvent(new CustomEvent(sshPromptEvent)) +}).catch((err) => { + console.error("Failed to listen for ssh_prompt", err) +}) + +const sshConnectEvent = "opencode:ssh-connect" +const sshConnectState: SshConnectState[] = [] + +void listen("ssh_connect_state", (event) => { + sshConnectState.push(event.payload) + window.dispatchEvent(new CustomEvent(sshConnectEvent)) +}).catch((err) => { + console.error("Failed to listen for ssh_connect_state", err) +}) + +const isConfirmPrompt = (prompt: string) => { + const text = prompt.toLowerCase() + return text.includes("yes/no") || text.includes("continue connecting") +} + +const isMaskedPrompt = (prompt: string) => { + const text = prompt.toLowerCase() + return ( + text.includes("password") || + text.includes("passphrase") || + text.includes("verification code") || + text.includes("one-time") || + text.includes("otp") + ) +} + let update: Update | null = null const deepLinkEvent = "opencode:deep-link" @@ -275,6 +325,8 @@ const createPlatform = (password: Accessor): Platform => ({ }, restart: async () => { + const keys = Array.from(new Set(ssh.values())) + await Promise.all(keys.map((key) => invoke("ssh_disconnect", { key }).catch(() => undefined))) await commands.killSidecar().catch(() => undefined) await relaunch() }, @@ -310,21 +362,51 @@ const createPlatform = (password: Accessor): Platform => ({ }, fetch: (input, init) => { - const pw = password() + if (typeof input === "string" && input.startsWith("/") && base) { + input = base + input + } + + const origin = (() => { + try { + const url = input instanceof Request ? input.url : String(input) + return new URL(url).origin + } catch { + return null + } + })() + + const pw = origin ? (auth.get(origin) ?? null) : password() const addHeader = (headers: Headers, password: string) => { headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`) } + const logError = async (url: string, res: Response) => { + if (res.ok) return + // keep it minimal; enough to debug auth/baseUrl issues + const text = await res + .clone() + .text() + .catch(() => "") + console.error("fetch failed", { url, status: res.status, statusText: res.statusText, body: text.slice(0, 400) }) + } + if (input instanceof Request) { if (pw) addHeader(input.headers, pw) - return tauriFetch(input) + return tauriFetch(input).then((res) => { + void logError(input.url, res) + return res + }) } else { const headers = new Headers(init?.headers) if (pw) addHeader(headers, pw) - return tauriFetch(input, { + const url = String(input) + return tauriFetch(url, { ...(init as any), headers: headers, + }).then((res) => { + void logError(url, res) + return res }) } }, @@ -338,6 +420,63 @@ const createPlatform = (password: Accessor): Platform => ({ await commands.setDefaultServerUrl(url) }, + serverKey: (url) => { + const origin = (() => { + try { + return new URL(url).origin + } catch { + return "" + } + })() + const key = origin ? ssh.get(origin) : undefined + if (key) return `ssh:${key}` + if (origin.includes("localhost") || origin.includes("127.0.0.1") || origin.includes("[::1]")) return "local" + return url + }, + + isServerLocal: (url) => { + const origin = (() => { + try { + return new URL(url).origin + } catch { + return null + } + })() + if (origin && ssh.has(origin)) return false + if (!origin) return false + return origin.includes("localhost") || origin.includes("127.0.0.1") || origin.includes("[::1]") + }, + + sshConnect: async (command) => { + const result = await invoke<{ key: string; url: string; password: string; destination: string }>("ssh_connect", { + command, + }) + const origin = new URL(result.url).origin + ssh.set(origin, result.key) + auth.set(origin, result.password) + return { url: result.url, key: result.key, password: result.password } + }, + + sshDisconnect: async (key) => { + await invoke("ssh_disconnect", { key }) + for (const [origin, k] of ssh.entries()) { + if (k !== key) continue + ssh.delete(origin) + auth.delete(origin) + } + }, + + wsAuth: (url) => { + try { + const origin = new URL(url).origin + const pw = auth.get(origin) ?? password() + if (!pw) return null + return { username: "opencode", password: pw } + } catch { + return null + } + }, + parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), webviewZoom, @@ -380,6 +519,152 @@ render(() => { const [serverPassword, setServerPassword] = createSignal(null) const platform = createPlatform(() => serverPassword()) + function SshPromptDialog(props: { + prompt: Accessor + pending: Accessor + onSubmit: (value: string) => void + onCancel: () => void + }) { + const confirm = () => isConfirmPrompt(props.prompt()) + const masked = () => isMaskedPrompt(props.prompt()) + const [value, setValue] = createSignal("") + + return ( + +
+
{props.prompt()}
+ + + setValue(v)} + onKeyDown={(event: KeyboardEvent) => { + event.stopPropagation() + if (event.key === "Escape") { + event.preventDefault() + props.onCancel() + return + } + if (event.key !== "Enter" || event.isComposing) return + event.preventDefault() + props.onSubmit(value()) + }} + /> + + +
+ + + + + } + > + + + +
+
+
+ ) + } + + function SshPromptHandler() { + const dialog = useDialog() + const [store, setStore] = createStore({ + prompt: null as SshPrompt | null, + pending: false, + open: false, + }) + + const open = () => { + if (store.open) return + setStore("open", true) + dialog.show( + () => ( + store.prompt?.prompt ?? ""} + pending={() => store.pending} + onSubmit={async (value) => { + const current = store.prompt + if (!current) return + setStore({ pending: true }) + await invoke("ssh_prompt_reply", { id: current.id, value }).catch((err) => { + console.error("Failed to send ssh_prompt_reply", err) + }) + }} + onCancel={async () => { + const current = store.prompt + setStore({ pending: true }) + if (current) { + await invoke("ssh_prompt_reply", { id: current.id, value: "" }).catch((err) => { + console.error("Failed to send ssh_prompt_reply", err) + }) + } + close() + }} + /> + ), + () => close(), + ) + } + + const close = () => { + if (!store.open) return + dialog.close() + setStore({ open: false, pending: false, prompt: null }) + } + + const showNext = () => { + const next = sshPrompts.shift() + if (!next) return + setStore({ prompt: next, pending: false }) + open() + } + + const onConnectState = (state: SshConnectState) => { + if (state.state === "connecting") { + if (store.prompt) setStore("pending", true) + return + } + close() + } + + onMount(() => { + const onPrompt = () => showNext() + const onConnect = () => { + const next = sshConnectState.shift() + if (!next) return + onConnectState(next) + } + window.addEventListener(sshPromptEvent, onPrompt) + window.addEventListener(sshConnectEvent, onConnect) + showNext() + onConnect() + onCleanup(() => { + window.removeEventListener(sshPromptEvent, onPrompt) + window.removeEventListener(sshConnectEvent, onConnect) + }) + }) + + return null + } + function handleClick(e: MouseEvent) { const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null if (link?.href) { @@ -401,6 +686,15 @@ render(() => { {(data) => { setServerPassword(data().password) + try { + const origin = new URL(data().url).origin + base = origin + const pw = data().password + if (pw) auth.set(origin, pw) + if (!pw) auth.delete(origin) + } catch { + // ignore + } window.__OPENCODE__ ??= {} window.__OPENCODE__.serverPassword = data().password ?? undefined @@ -413,9 +707,12 @@ render(() => { } return ( - - - + <> + + + + + ) }}