Compare commits

...

5 Commits

Author SHA1 Message Date
Brendan Allan
fb9f1a5d65 cleanup 2026-02-11 16:56:04 +08:00
Brendan Allan
b5d8697c82 Merge branch 'dev' into brendan/desktop-ssh 2026-02-11 16:50:08 +08:00
Brendan Allan
61c4d0a0d0 desktop: align platform typing for ssh helpers 2026-02-10 12:21:54 +08:00
Brendan Allan
7c6d82b79a desktop: support Windows SSH sessions 2026-02-10 12:11:35 +08:00
Brendan Allan
3d63f86d19 desktop: remote ssh connections 2026-02-09 16:40:58 +08:00
13 changed files with 1404 additions and 30 deletions

View File

@@ -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 (
<Dialog title={language.t("dialog.server.title")}>
<div class="flex flex-col gap-2">
@@ -517,18 +563,80 @@ export function DialogSelectServer() {
</List>
<div class="px-5 pb-5">
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={() => {
setStore("addServer", { showForm: true, url: "", error: "" })
scrollListToBottom()
}}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
</Button>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={() => {
setStore("addServer", { showForm: true, url: "", error: "" })
scrollListToBottom()
}}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{store.addServer.adding
? language.t("dialog.server.add.checking")
: language.t("dialog.server.add.button")}
</Button>
<Show when={canSsh()}>
<Button
variant="secondary"
icon="server"
size="large"
onClick={() => {
setStore("ssh", { showForm: !store.ssh.showForm, error: "" })
}}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
SSH
</Button>
</Show>
</div>
<Show when={store.ssh.showForm && canSsh()}>
<div class="flex flex-col gap-2">
<TextField
type="text"
hideLabel
placeholder={"ssh user@host"}
value={store.ssh.command}
validationState={store.ssh.error ? "invalid" : "valid"}
error={store.ssh.error}
disabled={store.ssh.connecting}
onChange={(value) => {
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()
}}
/>
<div class="flex items-center gap-2">
<Button
size="normal"
variant="primary"
disabled={store.ssh.connecting}
onClick={() => void handleSshConnect()}
>
{store.ssh.connecting ? "Connecting..." : "Connect"}
</Button>
<Button size="normal" variant="ghost" disabled={store.ssh.connecting} onClick={resetSsh}>
Cancel
</Button>
</div>
</div>
</Show>
</div>
</div>
</div>
</Dialog>

View File

@@ -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}`)

View File

@@ -170,7 +170,11 @@ export const Terminal = (props: TerminalProps) => {
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
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
}

View File

@@ -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> | 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 | undefined
/** 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<void>
/** Credentials to embed in WebSocket URLs (desktop only) */
wsAuth?(url: string): { username: string; password: string } | null
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>

View File

@@ -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,

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -6,6 +6,7 @@ mod job_object;
pub mod linux_display;
mod markdown;
mod server;
mod ssh;
mod window_customizer;
mod windows;
@@ -402,7 +403,7 @@ fn check_linux_app(app_name: &str) -> bool {
#[tauri::command]
#[specta::specta]
fn wsl_path(path: String, mode: Option<WslPathMode>) -> Result<String, String> {
if !cfg(windows) {
if !cfg!(windows) {
return Ok(path);
}
@@ -497,6 +498,7 @@ pub fn run() {
println!("Received Exit");
kill_sidecar(app.clone());
ssh::shutdown(app.clone());
}
});
}
@@ -517,7 +519,10 @@ fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
markdown::parse_markdown_command,
check_app_exists,
wsl_path,
resolve_app_path
resolve_app_path,
ssh::ssh_connect,
ssh::ssh_disconnect,
ssh::ssh_prompt_reply
])
.events(tauri_specta::collect_events![LoadingWindowComplete])
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
@@ -685,6 +690,8 @@ fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
// Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
app.manage(ssh::SshState::default());
#[cfg(windows)]
app.manage(JobObjectState::new());

View File

@@ -55,7 +55,84 @@ fn configure_display_backend() -> Option<String> {
)
}
trait AskpassStream: std::io::Read + std::io::Write {}
impl<T> AskpassStream for T where T: std::io::Read + std::io::Write {}
#[cfg(unix)]
fn askpass_stream(socket: &str) -> Result<Box<dyn AskpassStream>, String> {
if let Some(addr) = socket.strip_prefix("tcp:") {
let stream = std::net::TcpStream::connect(addr)
.map_err(|e| format!("askpass connect failed: {e}"))?;
let boxed: Box<dyn AskpassStream> = Box::new(stream);
return Ok(boxed);
}
use std::os::unix::net::UnixStream;
let stream = UnixStream::connect(socket).map_err(|e| format!("askpass connect failed: {e}"))?;
let boxed: Box<dyn AskpassStream> = Box::new(stream);
Ok(boxed)
}
#[cfg(not(unix))]
fn askpass_stream(socket: &str) -> Result<Box<dyn AskpassStream>, String> {
let addr = socket
.strip_prefix("tcp:")
.ok_or_else(|| "askpass socket is not tcp on this platform".to_string())?;
let stream =
std::net::TcpStream::connect(addr).map_err(|e| format!("askpass connect failed: {e}"))?;
let boxed: Box<dyn AskpassStream> = Box::new(stream);
Ok(boxed)
}
fn main() {
if let Ok(socket) = std::env::var("OPENCODE_SSH_ASKPASS_SOCKET") {
use std::io::{Read as _, Write as _};
use std::process::exit;
let args = std::env::args().collect::<Vec<_>>();
let prompt = if let Some(pos) = args.iter().position(|a| a == "--ssh-askpass") {
args.iter()
.skip(pos + 2)
.cloned()
.collect::<Vec<_>>()
.join(" ")
} else {
args.iter().skip(1).cloned().collect::<Vec<_>>().join(" ")
};
let mut stream = match askpass_stream(&socket) {
Ok(v) => v,
Err(err) => {
eprintln!("{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"];

View File

@@ -0,0 +1,863 @@
use std::{
collections::HashMap,
net::TcpListener,
path::{Path, PathBuf},
time::{Duration, Instant},
};
use tauri::{AppHandle, Emitter as _, Manager};
use tokio::{
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
process::{Child, Command},
sync::{Mutex, oneshot},
};
#[cfg(unix)]
use tokio::net::UnixListener;
#[cfg(not(unix))]
use tokio::net::TcpListener;
use crate::server;
fn log(line: impl AsRef<str>) {
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(Default)]
pub struct SshState {
session: Mutex<Option<SshSession>>,
prompts: Mutex<HashMap<String, oneshot::Sender<String>>>,
}
struct SshSession {
key: String,
destination: String,
dir: PathBuf,
askpass_task: tokio::task::JoinHandle<()>,
socket_path: Option<PathBuf>,
master: Option<Child>,
forward: Child,
server: Child,
}
#[derive(Debug, Clone)]
struct Spec {
destination: String,
args: Vec<String>,
}
#[derive(Clone, Debug)]
struct Askpass {
socket: String,
exe: PathBuf,
}
#[derive(Clone, Copy, Debug)]
enum ControlMode {
Master,
Client,
}
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<Spec, String> {
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::<String>::new();
let mut i = 0;
let mut destination: Option<String> = 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<PathBuf, String> {
tauri::process::current_binary(&app.env())
.map_err(|e| format!("Failed to locate current binary: {e}"))
}
async fn ensure_ssh_available() -> Result<(), String> {
let res = Command::new("ssh")
.arg("-V")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await;
if res.is_err() {
if cfg!(windows) {
return Err(
"ssh.exe was not found on PATH. Install Windows OpenSSH or Git for Windows and ensure ssh.exe is on PATH."
.to_string(),
);
}
return Err("ssh was not found on PATH".to_string());
}
Ok(())
}
fn ssh_command(askpass: &Askpass, args: Vec<String>) -> 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", &askpass.exe);
cmd.env("OPENCODE_SSH_ASKPASS_SOCKET", &askpass.socket);
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(askpass: &Askpass, args: Vec<String>) -> 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", &askpass.exe);
cmd.env("OPENCODE_SSH_ASKPASS_SOCKET", &askpass.socket);
if std::env::var_os("DISPLAY").is_none() {
cmd.env("DISPLAY", "1");
}
cmd.env("TERM", "dumb");
cmd
}
async fn ssh_output(askpass: &Askpass, args: Vec<String>) -> Result<String, String> {
let out = ssh_command(askpass, 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())
}
fn control_supported() -> bool {
cfg!(unix)
}
fn control_args(socket_path: Option<&Path>, mode: ControlMode) -> Vec<String> {
if !control_supported() {
return Vec::new();
}
let Some(socket_path) = socket_path else {
return Vec::new();
};
let mut args = Vec::new();
match mode {
ControlMode::Master => {
args.push("-o".into());
args.push("ControlMaster=yes".into());
args.push("-o".into());
args.push("ControlPersist=no".into());
}
ControlMode::Client => {
args.push("-o".into());
args.push("ControlMaster=no".into());
}
}
args.push("-o".into());
args.push(format!("ControlPath={}", socket_path.display()));
args
}
async fn wait_master_ready(
askpass: &Askpass,
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(
askpass,
[
control_args(Some(socket_path), ControlMode::Client),
vec!["-O".into(), "check".into(), spec.destination.clone()],
]
.concat(),
)
.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,
askpass: &Askpass,
spec: &Spec,
socket_path: Option<&Path>,
) -> Result<(), String> {
let version = app.package_info().version.to_string();
let installed = ssh_output(
askpass,
[
spec.args.clone(),
control_args(socket_path, ControlMode::Client),
vec![
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(
askpass,
[
spec.args.clone(),
control_args(socket_path, ControlMode::Client),
vec![spec.destination.clone(), cmd],
]
.concat(),
)
.await
.map(|_| ())?;
log("Remote install finished");
Ok(())
}
async fn spawn_master(askpass: &Askpass, spec: &Spec, socket_path: &Path) -> Result<Child, String> {
let mut child = ssh_spawn_bg(
askpass,
[
spec.args.clone(),
vec!["-N".into()],
control_args(Some(socket_path), ControlMode::Master),
vec![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<u16> {
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(
askpass: &Askpass,
spec: &Spec,
socket_path: Option<&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(
askpass,
[
spec.args.clone(),
control_args(socket_path, ControlMode::Client),
vec![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::<u16>(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,
askpass: &Askpass,
spec: &Spec,
socket_path: Option<&Path>,
local_port: u16,
remote_port: u16,
) -> Result<Child, String> {
let forward = format!("127.0.0.1:{local_port}:127.0.0.1:{remote_port}");
let mut child = ssh_spawn_bg(
askpass,
[
spec.args.clone(),
vec![
"-N".into(),
"-L".into(),
forward,
"-o".into(),
"ExitOnForwardFailure=yes".into(),
],
control_args(socket_path, ControlMode::Client),
vec![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;
if let Some(mut master) = session.master {
let _ = master.kill().await;
}
session.askpass_task.abort();
let _ = std::fs::remove_dir_all(session.dir);
}
async fn read_prompt<S: AsyncReadExt + Unpin>(stream: &mut S) -> Result<String, String> {
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<S: AsyncWriteExt + Unpin>(stream: &mut S, 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(())
}
async fn spawn_askpass_server(
app: AppHandle,
dir: &Path,
) -> Result<(tokio::task::JoinHandle<()>, String), String> {
#[cfg(unix)]
{
let socket = dir.join("askpass.sock");
let listener = UnixListener::bind(&socket)
.map_err(|e| format!("Failed to bind askpass socket {}: {e}", socket.display()))?;
let location = socket.to_string_lossy().to_string();
log(format!("Askpass listening on {}", socket.display()));
let task = tokio::spawn(async move {
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::<String>();
{
let state = app.state::<SshState>();
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::<SshState>();
state.prompts.lock().await.remove(&id);
}
let _ = write_reply(&mut stream, &value).await;
});
}
});
return Ok((task, location));
}
#[cfg(not(unix))]
{
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Failed to bind askpass listener: {e}"))?;
let addr = listener
.local_addr()
.map_err(|e| format!("Failed to read askpass address: {e}"))?;
let location = format!("tcp:{addr}");
log(format!("Askpass listening on {addr}"));
let task = tokio::spawn(async move {
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::<String>();
{
let state = app.state::<SshState>();
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::<SshState>();
state.prompts.lock().await.remove(&id);
}
let _ = write_reply(&mut stream, &value).await;
});
}
});
return Ok((task, location));
}
}
#[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::<SshState>();
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::<SshState>();
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<SshConnectData, String> {
async {
ensure_ssh_available().await?;
let spec = parse_ssh_command(&command)?;
log(format!("Connect requested: {}", spec.destination));
// Disconnect any existing session.
{
let state = app.state::<SshState>();
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 = if control_supported() {
PathBuf::from("/tmp").join(format!("opencode-ssh-{key}"))
} else {
std::env::temp_dir().join(format!("opencode-ssh-{key}"))
};
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create temp dir: {e}"))?;
let socket_path = control_supported().then(|| dir.join("ssh.sock"));
let (askpass_task, askpass_socket) = spawn_askpass_server(app.clone(), &dir).await?;
let askpass = Askpass {
socket: askpass_socket,
exe: exe_path(&app)?,
};
log(format!("Session dir: {}", dir.display()));
if let Some(path) = socket_path.as_ref() {
log(format!("ControlPath: {}", path.display()));
}
log(format!("Askpass socket: {}", askpass.socket));
let master = if let Some(path) = socket_path.as_ref() {
log("Starting SSH master");
let master = spawn_master(&askpass, &spec, path).await?;
log("Waiting for master ready");
wait_master_ready(&askpass, &spec, path).await?;
log("Master ready");
Some(master)
} else {
None
};
log("Ensuring remote opencode");
ensure_remote_opencode(&app, &askpass, &spec, socket_path.as_deref()).await?;
log("Remote opencode ready");
log("Starting remote opencode server");
let (server_child, remote_port) =
spawn_remote_server(&askpass, &spec, socket_path.as_deref(), &password).await?;
log(format!("Remote server port: {remote_port}"));
log(format!("Starting port forward to {url}"));
let forward_child = spawn_forward(
&app,
&askpass,
&spec,
socket_path.as_deref(),
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::<SshState>()
.session
.lock()
.await
.replace(session);
Ok(SshConnectData {
key,
url,
password,
destination: spec.destination,
})
}
.await
}
pub fn shutdown(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let state = app.state::<SshState>();
if let Some(session) = state.session.lock().await.take() {
disconnect_session(session).await;
}
});
}

View File

@@ -18,6 +18,9 @@ export const commands = {
checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE<string>("wsl_path", { path, mode }),
resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("resolve_app_path", { appName }),
sshConnect: (command: string) => __TAURI_INVOKE<SshConnectData>("ssh_connect", { command }),
sshDisconnect: (key: string) => __TAURI_INVOKE<null>("ssh_disconnect", { key }),
sshPromptReply: (id: string, value: string) => __TAURI_INVOKE<null>("ssh_prompt_reply", { id, value }),
};
/** Events */
@@ -37,6 +40,13 @@ export type ServerReadyData = {
password: string | null,
};
export type SshConnectData = {
key: string,
url: string,
password: string,
destination: string,
};
export type WslConfig = {
enabled: boolean,
};

View File

@@ -22,15 +22,21 @@ import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
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 { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup, createEffect } 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, type WslConfig } 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")
@@ -40,6 +46,38 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
void initI18n()
const ssh = new Map<string, string>()
const auth = new Map<string, string>()
let base = null as string | null
type SshPrompt = { id: string; prompt: string }
const sshPromptEvent = "opencode:ssh-prompt"
const sshPrompts: SshPrompt[] = []
void listen<SshPrompt>("ssh_prompt", (event) => {
sshPrompts.push(event.payload)
window.dispatchEvent(new CustomEvent(sshPromptEvent))
}).catch((err) => {
console.error("Failed to listen for ssh_prompt", 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"
@@ -58,7 +96,10 @@ const listenForDeepLinks = async () => {
await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
}
const createPlatform = (password: Accessor<string | null>): Platform => {
const createPlatform = (
password: Accessor<string | null>,
sshState: { get: Accessor<boolean>; set: (value: boolean) => void },
): Platform => {
const os = (() => {
const type = ostype()
if (type === "macos" || type === "windows" || type === "linux") return type
@@ -230,8 +271,6 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
})().finally(() => {
flushing = undefined
})
return flushing
}
const schedule = () => {
@@ -347,7 +386,20 @@ const createPlatform = (password: Accessor<string | null>): 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}`)}`)
@@ -381,6 +433,70 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
return result
},
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
},
sshConnect: async (command) => {
sshState.set(true)
try {
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 }
} finally {
sshState.set(false)
}
},
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
}
},
sshDisconnect: async (key) => {
await invoke<void>("ssh_disconnect", { key })
for (const [origin, k] of ssh.entries()) {
if (k !== key) continue
ssh.delete(origin)
auth.delete(origin)
}
},
setDefaultServerUrl: async (url: string | null) => {
await commands.setDefaultServerUrl(url)
},
@@ -435,8 +551,143 @@ void listenForDeepLinks()
render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const [sshConnecting, setSshConnecting] = createSignal(false)
const platform = createPlatform(() => serverPassword(), { get: sshConnecting, set: setSshConnecting })
const platform = createPlatform(() => serverPassword())
function SshPromptDialog(props: {
prompt: Accessor<string>
pending: Accessor<boolean>
onSubmit: (value: string) => void
onCancel: () => void
}) {
const confirm = () => isConfirmPrompt(props.prompt())
const masked = () => isMaskedPrompt(props.prompt())
const [value, setValue] = createSignal("")
return (
<Dialog title="SSH" fit>
<div class="flex flex-col gap-3 px-3 pb-3">
<div class="text-14-regular text-text-base whitespace-pre-wrap px-1">{props.prompt()}</div>
<Show when={!confirm()}>
<TextField
type={masked() ? "password" : "text"}
hideLabel
placeholder={masked() ? "Password" : "Response"}
value={value()}
autofocus
disabled={props.pending()}
onChange={(v) => 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())
}}
/>
</Show>
<div class="flex items-center justify-end gap-2">
<Show
when={confirm()}
fallback={
<>
<Button variant="secondary" onClick={props.onCancel} disabled={props.pending()}>
Cancel
</Button>
<Button variant="primary" onClick={() => props.onSubmit(value())} disabled={props.pending()}>
{props.pending() ? "Connecting..." : "Continue"}
</Button>
</>
}
>
<Button variant="secondary" onClick={() => props.onSubmit("no")} disabled={props.pending()}>
No
</Button>
<Button variant="primary" onClick={() => props.onSubmit("yes")} disabled={props.pending()}>
{props.pending() ? "Connecting..." : "Yes"}
</Button>
</Show>
</div>
</div>
</Dialog>
)
}
function SshPromptHandler(props: { connecting: Accessor<boolean> }) {
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(
() => (
<SshPromptDialog
prompt={() => store.prompt?.prompt ?? ""}
pending={() => store.pending}
onSubmit={async (value) => {
const current = store.prompt
if (!current) return
setStore({ pending: true })
await invoke<void>("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<void>("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()
}
onMount(() => {
const onPrompt = () => showNext()
window.addEventListener(sshPromptEvent, onPrompt)
showNext()
onCleanup(() => {
window.removeEventListener(sshPromptEvent, onPrompt)
})
})
createEffect(() => {
if (props.connecting()) return
close()
})
return null
}
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
@@ -459,6 +710,15 @@ render(() => {
<ServerGate>
{(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
@@ -471,9 +731,12 @@ render(() => {
}
return (
<AppInterface defaultUrl={data().url} isSidecar>
<Inner />
</AppInterface>
<>
<AppInterface defaultUrl={data().url} isSidecar>
<Inner />
<SshPromptHandler connecting={() => sshConnecting()} />
</AppInterface>
</>
)
}}
</ServerGate>