From f0c8b7f723b799b1939508d8784fbb32243638e4 Mon Sep 17 00:00:00 2001 From: Moira Huang Date: Tue, 28 Apr 2026 21:31:23 -0500 Subject: [PATCH] [APP-3801] implement remote environments auth (#9331) --- app/src/lib.rs | 10 +- app/src/remote_server/auth_context.rs | 49 +++++++++ app/src/remote_server/auth_provider.rs | 0 app/src/remote_server/mod.rs | 34 ++++-- app/src/remote_server/server_model.rs | 51 +++++++-- app/src/remote_server/server_model_tests.rs | 97 +++++++++++++++++ app/src/remote_server/ssh_transport.rs | 79 ++++++++++++-- app/src/remote_server/unix/mod.rs | 19 ++-- app/src/remote_server/unix/proxy.rs | 27 +++-- app/src/server/server_api.rs | 22 +++- app/src/server/server_api/auth.rs | 6 ++ .../writeable_pty/remote_server_controller.rs | 53 +++++++-- .../remote_server/proto/remote_server.proto | 17 ++- crates/remote_server/src/auth.rs | 39 +++++++ crates/remote_server/src/client/mod.rs | 29 +++-- crates/remote_server/src/client_tests.rs | 66 +++++++++++- crates/remote_server/src/lib.rs | 1 + crates/remote_server/src/manager.rs | 102 +++++++++++++++++- crates/remote_server/src/protocol_tests.rs | 8 +- crates/remote_server/src/setup.rs | 32 ++++++ crates/warp_cli/src/lib.rs | 13 ++- 21 files changed, 685 insertions(+), 69 deletions(-) create mode 100644 app/src/remote_server/auth_context.rs create mode 100644 app/src/remote_server/auth_provider.rs create mode 100644 app/src/remote_server/server_model_tests.rs create mode 100644 crates/remote_server/src/auth.rs diff --git a/app/src/lib.rs b/app/src/lib.rs index e110566e..04dada41 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -565,12 +565,12 @@ pub fn run() -> Result<()> { } } #[cfg(not(target_family = "wasm"))] - warp_cli::Command::Worker(warp_cli::WorkerCommand::RemoteServerProxy) => { - return crate::remote_server::run_proxy(); + warp_cli::Command::Worker(warp_cli::WorkerCommand::RemoteServerProxy(args)) => { + return crate::remote_server::run_proxy(args.identity_key.clone()); } #[cfg(not(target_family = "wasm"))] - warp_cli::Command::Worker(warp_cli::WorkerCommand::RemoteServerDaemon) => { - return crate::remote_server::run_daemon(); + warp_cli::Command::Worker(warp_cli::WorkerCommand::RemoteServerDaemon(args)) => { + return crate::remote_server::run_daemon(args.identity_key.clone()); } #[cfg(not(target_family = "wasm"))] warp_cli::Command::Worker(warp_cli::WorkerCommand::RipgrepSearch { @@ -1253,6 +1253,8 @@ fn initialize_app( ctx.add_singleton_model(|_ctx| SyncedInputState::new()); ctx.add_singleton_model(remote_server::manager::RemoteServerManager::new); + #[cfg(not(target_family = "wasm"))] + remote_server::wire_auth_token_rotation(ctx); log::info!( "Starting warp with channel state {} and version {:?}", diff --git a/app/src/remote_server/auth_context.rs b/app/src/remote_server/auth_context.rs new file mode 100644 index 00000000..ac9ab93d --- /dev/null +++ b/app/src/remote_server/auth_context.rs @@ -0,0 +1,49 @@ +use std::sync::Arc; + +use remote_server::auth::RemoteServerAuthContext; +use warpui::r#async::BoxFuture; + +use crate::auth::auth_state::AuthState; +use crate::server::server_api::auth::AuthClient; + +/// Builds the app-wide auth context used by remote-server connections. +pub fn server_api_auth_context( + auth_state: Arc, + auth_client: Arc, +) -> RemoteServerAuthContext { + let token_auth_state = auth_state.clone(); + let token_auth_client = auth_client; + let identity_auth_state = auth_state; + + RemoteServerAuthContext::new( + move || -> BoxFuture<'static, Option> { + if !use_authenticated_user_identity(&token_auth_state) { + return Box::pin(async { None }); + } + + let auth_client = token_auth_client.clone(); + Box::pin(async move { + match auth_client.get_or_refresh_access_token().await { + Ok(token) => token.bearer_token(), + Err(_) => None, + } + }) + }, + move || remote_server_identity_key(&identity_auth_state), + ) +} + +fn use_authenticated_user_identity(auth_state: &AuthState) -> bool { + auth_state.is_logged_in() && !auth_state.is_user_anonymous().unwrap_or(true) +} + +fn remote_server_identity_key(auth_state: &AuthState) -> String { + if use_authenticated_user_identity(auth_state) { + auth_state + .user_id() + .map(|uid| uid.as_string()) + .unwrap_or_else(|| auth_state.anonymous_id()) + } else { + auth_state.anonymous_id() + } +} diff --git a/app/src/remote_server/auth_provider.rs b/app/src/remote_server/auth_provider.rs new file mode 100644 index 00000000..e69de29b diff --git a/app/src/remote_server/mod.rs b/app/src/remote_server/mod.rs index 184d9885..4677fa4b 100644 --- a/app/src/remote_server/mod.rs +++ b/app/src/remote_server/mod.rs @@ -1,7 +1,15 @@ +#[cfg(not(target_family = "wasm"))] +use crate::server::server_api::{ServerApiEvent, ServerApiProvider}; +#[cfg(not(target_family = "wasm"))] +use remote_server::manager::RemoteServerManager; +#[cfg(not(target_family = "wasm"))] +use warpui::SingletonEntity; // Re-export everything from the `remote_server` crate so existing // `crate::remote_server::*` imports in `app` continue to work. pub use remote_server::*; +#[cfg(not(target_family = "wasm"))] +pub mod auth_context; #[cfg(not(target_family = "wasm"))] pub mod server_model; #[cfg(not(target_family = "wasm"))] @@ -11,23 +19,23 @@ pub mod unix; /// Run the `remote-server-proxy` subcommand. #[cfg(unix)] -pub fn run_proxy() -> anyhow::Result<()> { - unix::run_proxy() +pub fn run_proxy(identity_key: String) -> anyhow::Result<()> { + unix::run_proxy(identity_key) } #[cfg(not(unix))] -pub fn run_proxy() -> anyhow::Result<()> { +pub fn run_proxy(_identity_key: String) -> anyhow::Result<()> { anyhow::bail!("remote-server-proxy is not supported on this platform") } /// Run the `remote-server-daemon` subcommand. #[cfg(unix)] -pub fn run_daemon() -> anyhow::Result<()> { - unix::run_daemon() +pub fn run_daemon(identity_key: String) -> anyhow::Result<()> { + unix::run_daemon(identity_key) } #[cfg(not(unix))] -pub fn run_daemon() -> anyhow::Result<()> { +pub fn run_daemon(_identity_key: String) -> anyhow::Result<()> { anyhow::bail!("remote-server-daemon is not supported on this platform") } @@ -79,3 +87,17 @@ pub(super) fn run_daemon_app( })?; Ok(()) } + +/// Forwards app auth-token rotation events to the remote-server manager. +#[cfg(not(target_family = "wasm"))] +pub fn wire_auth_token_rotation(ctx: &mut warpui::AppContext) { + let server_api = ServerApiProvider::handle(ctx); + let manager = RemoteServerManager::handle(ctx); + ctx.subscribe_to_model(&server_api, move |_, event, ctx| { + if let ServerApiEvent::AccessTokenRefreshed { token } = event { + manager.update(ctx, |manager, _| { + manager.rotate_auth_token(token.clone()); + }); + } + }); +} diff --git a/app/src/remote_server/server_model.rs b/app/src/remote_server/server_model.rs index 6983deb8..f4319059 100644 --- a/app/src/remote_server/server_model.rs +++ b/app/src/remote_server/server_model.rs @@ -18,12 +18,12 @@ use warp_util::file::FileId; use super::proto::{ client_message, delete_file_response, run_command_response, server_message, - write_file_response, Abort, ClientMessage, DeleteFile, DeleteFileResponse, DeleteFileSuccess, - ErrorCode, ErrorResponse, FailedFileRead, FileContextProto, FileOperationError, - InitializeResponse, NavigatedToDirectory, NavigatedToDirectoryResponse, - ReadFileContextResponse, RunCommandError, RunCommandErrorCode, RunCommandRequest, - RunCommandResponse, RunCommandSuccess, ServerMessage, SessionBootstrapped, WriteFile, - WriteFileResponse, WriteFileSuccess, + write_file_response, Abort, Authenticate, ClientMessage, DeleteFile, DeleteFileResponse, + DeleteFileSuccess, ErrorCode, ErrorResponse, FailedFileRead, FileContextProto, + FileOperationError, Initialize, InitializeResponse, NavigatedToDirectory, + NavigatedToDirectoryResponse, ReadFileContextResponse, RunCommandError, RunCommandErrorCode, + RunCommandRequest, RunCommandResponse, RunCommandSuccess, ServerMessage, SessionBootstrapped, + WriteFile, WriteFileResponse, WriteFileSuccess, }; /// How long the daemon waits with no connections before exiting. @@ -164,6 +164,13 @@ pub struct ServerModel { executors: HashMap>, /// Tracks in-flight file write/delete operations and handles cleanup. pending_file_ops: PendingFileOps, + /// Daemon-wide bearer credential for the identity-scoped daemon. + /// + /// The token is written by Initialize when the client supplies a + /// non-empty credential, or by Authenticate during token rotation. It is + /// intentionally retained across proxy connection teardown and cleared + /// only by daemon process exit. + auth_token: Option, } impl Entity for ServerModel { @@ -188,6 +195,7 @@ impl ServerModel { host_id, executors: HashMap::new(), pending_file_ops: PendingFileOps::new(), + auth_token: None, }; // Subscribe to FileModel and RepoMetadataModel events // file operation results and repo metadata pushes are forwarded to all @@ -377,7 +385,13 @@ impl ServerModel { let request_id = RequestId::from(msg.request_id); let outcome = match msg.message { - Some(client_message::Message::Initialize(_)) => self.handle_initialize(&request_id), + Some(client_message::Message::Initialize(msg)) => { + self.handle_initialize(msg, &request_id) + } + Some(client_message::Message::Authenticate(msg)) => { + self.handle_authenticate(msg); + return; + } Some(client_message::Message::SessionBootstrapped(msg)) => { self.handle_session_bootstrapped(msg); return; @@ -497,8 +511,11 @@ impl ServerModel { } /// Handles `Initialize` by returning the server version and host id. - fn handle_initialize(&self, request_id: &RequestId) -> HandlerOutcome { + fn handle_initialize(&mut self, msg: Initialize, request_id: &RequestId) -> HandlerOutcome { log::info!("Handling Initialize (request_id={request_id})"); + if !msg.auth_token.is_empty() { + self.auth_token = Some(msg.auth_token); + } let server_version = ChannelState::app_version() .unwrap_or(env!("CARGO_PKG_VERSION")) .to_string(); @@ -510,6 +527,20 @@ impl ServerModel { )) } + /// Handles `Authenticate` by replacing the daemon-wide credential. + /// This is a notification — no response is sent. + fn handle_authenticate(&mut self, msg: Authenticate) { + if msg.auth_token.is_empty() { + log::warn!("Received Authenticate notification with empty auth token; ignoring"); + return; + } + self.auth_token = Some(msg.auth_token); + } + + pub fn auth_token(&self) -> Option<&str> { + self.auth_token.as_deref() + } + /// Handles `Abort` by cancelling the in-progress request it targets. /// This is a notification — no response is sent. fn handle_abort(&mut self, abort: Abort, request_id: &RequestId) { @@ -1074,3 +1105,7 @@ fn file_context_result_to_proto(result: ReadFileContextResult) -> ReadFileContex failed_files, } } + +#[cfg(test)] +#[path = "server_model_tests.rs"] +mod tests; diff --git a/app/src/remote_server/server_model_tests.rs b/app/src/remote_server/server_model_tests.rs new file mode 100644 index 00000000..7e193a8a --- /dev/null +++ b/app/src/remote_server/server_model_tests.rs @@ -0,0 +1,97 @@ +use std::collections::HashMap; + +use super::super::proto::{Authenticate, Initialize}; +use super::super::protocol::RequestId; +use super::{PendingFileOps, ServerModel}; + +fn test_model() -> ServerModel { + ServerModel { + connection_senders: HashMap::new(), + snapshot_sent_roots_by_connection: HashMap::new(), + grace_timer_cancel: None, + in_progress: HashMap::new(), + host_id: "test-host-id".to_string(), + executors: HashMap::new(), + pending_file_ops: PendingFileOps::new(), + auth_token: None, + } +} + +fn request_id() -> RequestId { + RequestId::from("test-request".to_string()) +} + +#[test] +fn fresh_model_starts_without_auth_token() { + let model = test_model(); + + assert_eq!(model.auth_token(), None); +} + +#[test] +fn initialize_with_auth_token_stores_token() { + let mut model = test_model(); + + model.handle_initialize( + Initialize { + auth_token: "initial-token".to_string(), + }, + &request_id(), + ); + + assert_eq!(model.auth_token(), Some("initial-token")); +} + +#[test] +fn empty_initialize_preserves_existing_auth_token() { + let mut model = test_model(); + model.handle_initialize( + Initialize { + auth_token: "initial-token".to_string(), + }, + &request_id(), + ); + + model.handle_initialize( + Initialize { + auth_token: String::new(), + }, + &request_id(), + ); + + assert_eq!(model.auth_token(), Some("initial-token")); +} + +#[test] +fn authenticate_with_auth_token_replaces_auth_token() { + let mut model = test_model(); + model.handle_initialize( + Initialize { + auth_token: "initial-token".to_string(), + }, + &request_id(), + ); + + model.handle_authenticate(Authenticate { + auth_token: "rotated-token".to_string(), + }); + + assert_eq!(model.auth_token(), Some("rotated-token")); +} + +#[test] +fn empty_authenticate_preserves_existing_auth_token() { + let mut model = test_model(); + model.handle_initialize( + Initialize { + auth_token: "initial-token".to_string(), + }, + &request_id(), + ); + + model.handle_authenticate(Authenticate { + auth_token: String::new(), + }); + + assert_eq!(model.auth_token(), Some("initial-token")); +} diff --git a/app/src/remote_server/ssh_transport.rs b/app/src/remote_server/ssh_transport.rs index 4a78aa44..a98c6fe4 100644 --- a/app/src/remote_server/ssh_transport.rs +++ b/app/src/remote_server/ssh_transport.rs @@ -3,6 +3,7 @@ //! [`SshTransport`] uses an existing SSH ControlMaster socket to check/install //! the remote server binary and to launch the `remote-server-proxy` process //! whose stdin/stdout become the protocol channel. +use std::fmt; use std::future::Future; use std::path::PathBuf; use std::pin::Pin; @@ -12,8 +13,11 @@ use std::sync::Arc; use anyhow::Result; use warpui::r#async::executor; +use remote_server::auth::RemoteServerAuthContext; use remote_server::client::RemoteServerClient; -use remote_server::setup::{self, RemotePlatform, CHECK_TIMEOUT, INSTALL_TIMEOUT}; +use remote_server::setup::{ + self, remote_server_daemon_dir, RemotePlatform, CHECK_TIMEOUT, INSTALL_TIMEOUT, +}; use remote_server::ssh::{run_ssh_command, run_ssh_script, ssh_args}; use remote_server::transport::{Connection, RemoteTransport}; @@ -23,16 +27,54 @@ use remote_server::transport::{Connection, RemoteTransport}; /// process (`ssh -N -o ControlMaster=yes -o ControlPath=`). All SSH /// commands (binary check, install, proxy launch) are multiplexed through /// this socket without re-authenticating. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct SshTransport { socket_path: PathBuf, + auth_context: Arc, +} + +impl fmt::Debug for SshTransport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SshTransport") + .field("socket_path", &self.socket_path) + .finish_non_exhaustive() + } } impl SshTransport { - pub fn new(socket_path: PathBuf) -> Self { - Self { socket_path } + pub fn new(socket_path: PathBuf, auth_context: Arc) -> Self { + Self { + socket_path, + auth_context, + } + } + + pub fn socket_path(&self) -> &PathBuf { + &self.socket_path + } + + pub fn remote_daemon_socket_path(&self) -> String { + format!( + "{}/server.sock", + remote_server_daemon_dir(&self.auth_context.remote_server_identity_key()) + ) + } + + pub fn remote_daemon_pid_path(&self) -> String { + format!( + "{}/server.pid", + remote_server_daemon_dir(&self.auth_context.remote_server_identity_key()) + ) + } + + fn remote_proxy_command(&self) -> String { + let binary = remote_server::setup::remote_server_binary(); + let identity_key = self.auth_context.remote_server_identity_key(); + let quoted_identity_key = shell_words::quote(&identity_key); + format!("{binary} remote-server-proxy --identity-key {quoted_identity_key}") } } + impl RemoteTransport for SshTransport { fn detect_platform( &self, @@ -100,10 +142,10 @@ impl RemoteTransport for SshTransport { executor: Arc, ) -> Pin> + Send>> { let socket_path = self.socket_path.clone(); + let remote_proxy_command = self.remote_proxy_command(); Box::pin(async move { - let binary = setup::remote_server_binary(); let mut args = ssh_args(&socket_path); - args.push(format!("{binary} remote-server-proxy")); + args.push(remote_proxy_command); // `kill_on_drop(true)` pairs with ownership of the `Child` being // returned in the [`Connection`] below: the @@ -142,3 +184,28 @@ impl RemoteTransport for SshTransport { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use warpui::r#async::BoxFuture; + fn static_auth_context() -> Arc { + Arc::new(RemoteServerAuthContext::new( + || -> BoxFuture<'static, Option> { Box::pin(async { None }) }, + || "user id/with spaces".to_string(), + )) + } + + #[test] + fn remote_proxy_command_quotes_identity_key() { + let transport = SshTransport::new( + PathBuf::from("/tmp/control-master.sock"), + static_auth_context(), + ); + + let command = transport.remote_proxy_command(); + + assert!(command.contains("remote-server-proxy --identity-key")); + assert!(command.contains("'user id/with spaces'")); + } +} diff --git a/app/src/remote_server/unix/mod.rs b/app/src/remote_server/unix/mod.rs index 8960f95d..3c7f352c 100644 --- a/app/src/remote_server/unix/mod.rs +++ b/app/src/remote_server/unix/mod.rs @@ -14,6 +14,8 @@ mod proxy; use super::server_model::{ConnectionId, ServerModel}; +use std::fs::Permissions; +use std::os::unix::fs::PermissionsExt; use warpui::r#async::executor; /// Run the `remote-server-proxy` subcommand. @@ -21,11 +23,11 @@ use warpui::r#async::executor; /// Ensures the daemon is running (starting it if necessary), then bridges /// this process's stdin/stdout to the daemon's Unix socket for the lifetime /// of the SSH session. -pub fn run_proxy() -> anyhow::Result<()> { +pub fn run_proxy(identity_key: String) -> anyhow::Result<()> { env_logger::Builder::from_default_env() .target(env_logger::Target::Stderr) .init(); - proxy::run() + proxy::run(&identity_key) } /// Run the `remote-server-daemon` subcommand. @@ -33,7 +35,7 @@ pub fn run_proxy() -> anyhow::Result<()> { /// Binds a Unix domain socket and writes a PID file, then delegates the /// WarpUI app startup to [`super::run_daemon_app`] with the Unix-specific /// `ServerModel` constructor. -pub fn run_daemon() -> anyhow::Result<()> { +pub fn run_daemon(identity_key: String) -> anyhow::Result<()> { // Log to a rotating file so daemon output is preserved across invocations. // The file is written to the same directory as client logs (~/Library/Logs // on macOS, ~/.local/share/warp-terminal on Linux). Since the daemon runs @@ -43,19 +45,19 @@ pub fn run_daemon() -> anyhow::Result<()> { log_destination: Some(warp_logging::LogDestination::File), })?; - // socket_path: ~/.warp[-channel]/remote-server/server.sock + // socket_path: ~/.warp[-channel]/remote-server/{identity_key}/server.sock // The Unix domain socket the daemon binds on. Proxy processes connect // to it and bridge their SSH stdio channel through it. // - // pid_path: ~/.warp[-channel]/remote-server/server.pid + // pid_path: ~/.warp[-channel]/remote-server/{identity_key}/server.pid // Contains the daemon's PID. Proxy processes read it and use // kill(pid, 0) to detect whether the daemon is still alive before // deciding whether to start a new one. - let socket_path = proxy::socket_path(); - let pid_path = proxy::pid_path(); + let socket_path = proxy::socket_path(&identity_key); + let pid_path = proxy::pid_path(&identity_key); if let Some(parent) = socket_path.parent() { - std::fs::create_dir_all(parent)?; + proxy::ensure_private_daemon_dir(parent)?; } if socket_path.exists() { std::fs::remove_file(&socket_path)?; @@ -64,6 +66,7 @@ pub fn run_daemon() -> anyhow::Result<()> { // Bind with std (no async runtime needed yet); converted to // async_io::Async inside the closure where the executor is active. let listener = std::os::unix::net::UnixListener::bind(&socket_path)?; + std::fs::set_permissions(&socket_path, Permissions::from_mode(0o600))?; // async_io::Async::new() requires non-blocking mode. listener.set_nonblocking(true)?; log::info!("Daemon bound to {}", socket_path.display()); diff --git a/app/src/remote_server/unix/proxy.rs b/app/src/remote_server/unix/proxy.rs index 3c70fa3b..7f4787ce 100644 --- a/app/src/remote_server/unix/proxy.rs +++ b/app/src/remote_server/unix/proxy.rs @@ -10,6 +10,8 @@ //! 4. Connect to `server.sock` and bridge stdin/stdout to the socket using //! the existing 4-byte length-prefixed frame format. +use std::fs::Permissions; +use std::os::unix::fs::PermissionsExt; use std::os::unix::io::AsRawFd; use std::path::PathBuf; use std::process::Stdio; @@ -18,30 +20,37 @@ use std::time::Duration; use super::super::setup; /// Path to the daemon's Unix domain socket. -pub(super) fn socket_path() -> PathBuf { - let dir = setup::remote_server_dir(); +pub(super) fn socket_path(identity_key: &str) -> PathBuf { + let dir = setup::remote_server_daemon_dir(identity_key); let expanded = shellexpand::tilde(&dir).into_owned(); PathBuf::from(expanded).join("server.sock") } /// Path to the daemon's PID file (also used as the flock target). -pub(super) fn pid_path() -> PathBuf { - let dir = setup::remote_server_dir(); +pub(super) fn pid_path(identity_key: &str) -> PathBuf { + let dir = setup::remote_server_daemon_dir(identity_key); let expanded = shellexpand::tilde(&dir).into_owned(); PathBuf::from(expanded).join("server.pid") } +/// Ensures the daemon directory exists with owner-only permissions. +pub(super) fn ensure_private_daemon_dir(path: &std::path::Path) -> anyhow::Result<()> { + std::fs::create_dir_all(path)?; + std::fs::set_permissions(path, Permissions::from_mode(0o700))?; + Ok(()) +} + /// Entry point for `remote-server-proxy`. /// /// Ensures the daemon is running, then bridges stdin/stdout to the daemon's /// Unix socket for the lifetime of this SSH session. -pub fn run() -> anyhow::Result<()> { - let socket_path = socket_path(); - let pid_path = pid_path(); +pub fn run(identity_key: &str) -> anyhow::Result<()> { + let socket_path = socket_path(identity_key); + let pid_path = pid_path(identity_key); // Ensure the parent directory exists. if let Some(parent) = socket_path.parent() { - std::fs::create_dir_all(parent)?; + ensure_private_daemon_dir(parent)?; } // ---- Acquire exclusive flock on the PID file -------------------------------- @@ -83,6 +92,8 @@ pub fn run() -> anyhow::Result<()> { let exe = std::env::current_exe()?; let mut cmd = command::blocking::Command::new(&exe); cmd.arg("remote-server-daemon") + .arg("--identity-key") + .arg(identity_key) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); diff --git a/app/src/server/server_api.rs b/app/src/server/server_api.rs index 7405abb7..0c31d7d4 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -54,6 +54,7 @@ use parking_lot::{Mutex, RwLock}; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use std::borrow::Cow; +use std::fmt; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -355,7 +356,7 @@ cfg_if::cfg_if! { /// An event related to the server API itself (and not a particular API call). /// Most errors should be handled in callbacks to individual APIs, rather than sent over the /// server API channel. -#[derive(Debug, Clone)] +#[derive(Clone)] pub enum ServerApiEvent { /// We made a staging API call that was blocked, which may indicate a firewall misconfiguration. StagingAccessBlocked, @@ -364,6 +365,25 @@ pub enum ServerApiEvent { NeedsReauth, /// The user's account has been disabled. UserAccountDisabled, + /// The current bearer token was refreshed. + AccessTokenRefreshed { + #[cfg_attr(target_family = "wasm", allow(dead_code))] + token: String, + }, +} + +impl fmt::Debug for ServerApiEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::StagingAccessBlocked => f.write_str("StagingAccessBlocked"), + Self::NeedsReauth => f.write_str("NeedsReauth"), + Self::UserAccountDisabled => f.write_str("UserAccountDisabled"), + Self::AccessTokenRefreshed { .. } => f + .debug_struct("AccessTokenRefreshed") + .field("token", &"") + .finish(), + } + } } /// An API wrapper struct with methods to requests to warp-server. diff --git a/app/src/server/server_api/auth.rs b/app/src/server/server_api/auth.rs index c7fdd0d8..a1dbe27a 100644 --- a/app/src/server/server_api/auth.rs +++ b/app/src/server/server_api/auth.rs @@ -268,6 +268,12 @@ impl AuthClient for ServerApi { let new_firebase_token_info = result?; self.auth_state .update_firebase_tokens(new_firebase_token_info.clone()); + let _ = self + .event_sender + .send(ServerApiEvent::AccessTokenRefreshed { + token: new_firebase_token_info.id_token.clone(), + }) + .await; return Ok(AuthToken::Firebase(new_firebase_token_info.id_token)); } diff --git a/app/src/terminal/writeable_pty/remote_server_controller.rs b/app/src/terminal/writeable_pty/remote_server_controller.rs index b108d964..64b7bfdd 100644 --- a/app/src/terminal/writeable_pty/remote_server_controller.rs +++ b/app/src/terminal/writeable_pty/remote_server_controller.rs @@ -1,5 +1,10 @@ +use crate::auth::auth_state::AuthStateProvider; +use crate::remote_server::auth_context::server_api_auth_context; use instant::Instant; +use remote_server::auth::RemoteServerAuthContext; use settings::Setting; +use std::path::PathBuf; +use std::sync::Arc; use warp_core::SessionId; use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle}; @@ -7,6 +12,7 @@ use crate::terminal::warpify::settings::SshExtensionInstallMode; use crate::remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent}; use crate::remote_server::ssh_transport::SshTransport; +use crate::server::server_api::ServerApiProvider; use crate::terminal::model::session::{IsLegacySSHSession, SessionInfo}; use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; use crate::terminal::warpify::settings::WarpifySettings; @@ -59,6 +65,7 @@ enum SshInitState { pub struct RemoteServerController { pty_controller: WeakModelHandle>, model_event_dispatcher: ModelHandle, + auth_context: Arc, state: SshInitState, /// Whether the binary was installed during this setup flow. did_install: bool, @@ -76,6 +83,10 @@ impl RemoteServerController { model_event_dispatcher: ModelHandle, ctx: &mut ModelContext, ) -> Self { + let auth_context = Arc::new(server_api_auth_context( + AuthStateProvider::as_ref(ctx).get().clone(), + ServerApiProvider::as_ref(ctx).get_auth_client(), + )); ctx.subscribe_to_model(&model_event_dispatcher, |me, event, ctx| { if let ModelEvent::SshInitShell { pending_session_info, @@ -104,12 +115,25 @@ impl RemoteServerController { RemoteServerManagerEvent::SessionConnectionFailed { session_id, .. } => { me.on_session_connection_failed(*session_id, ctx); } - _ => {} + RemoteServerManagerEvent::SessionConnecting { .. } + | RemoteServerManagerEvent::SessionDisconnected { .. } + | RemoteServerManagerEvent::SessionReconnected { .. } + | RemoteServerManagerEvent::SessionDeregistered { .. } + | RemoteServerManagerEvent::HostConnected { .. } + | RemoteServerManagerEvent::HostDisconnected { .. } + | RemoteServerManagerEvent::NavigatedToDirectory { .. } + | RemoteServerManagerEvent::RepoMetadataSnapshot { .. } + | RemoteServerManagerEvent::RepoMetadataUpdated { .. } + | RemoteServerManagerEvent::RepoMetadataDirectoryLoaded { .. } + | RemoteServerManagerEvent::SetupStateChanged { .. } + | RemoteServerManagerEvent::ClientRequestFailed { .. } + | RemoteServerManagerEvent::ServerMessageDecodingError { .. } => {} }); Self { pty_controller, model_event_dispatcher, + auth_context, state: SshInitState::Idle, did_install: false, remote_platform: None, @@ -135,7 +159,6 @@ impl RemoteServerController { }; let session_id = info.session_id; let socket_path = socket_path.clone(); - debug_assert!(matches!(self.state, SshInitState::Idle)); match std::mem::replace(&mut self.state, SshInitState::Idle) { SshInitState::Idle => {} @@ -158,8 +181,7 @@ impl RemoteServerController { self.flush_stashed_bootstrap(old_info, ctx); } } - - let transport = SshTransport::new(socket_path); + let transport = SshTransport::new(socket_path, self.auth_context.clone()); self.did_install = false; self.remote_platform = None; self.state = SshInitState::AwaitingCheck { @@ -199,14 +221,13 @@ impl RemoteServerController { match result { Ok(true) => { + let socket_path = transport.socket_path().clone(); self.state = SshInitState::AwaitingConnect { session_id, session_info, setup_start, }; - RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| { - mgr.connect_session(session_id, transport, ctx); - }); + self.connect_session_for_current_identity(session_id, socket_path, ctx); } Ok(false) => { let install_mode = *WarpifySettings::as_ref(ctx) @@ -404,14 +425,13 @@ impl RemoteServerController { }; match result { Ok(()) => { + let socket_path = transport.socket_path().clone(); self.state = SshInitState::AwaitingConnect { session_id, session_info, setup_start, }; - RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| { - mgr.connect_session(session_id, transport, ctx); - }); + self.connect_session_for_current_identity(session_id, socket_path, ctx); } Err(err) => { log::error!("Binary install failed for {session_id:?}: {err}"); @@ -419,4 +439,17 @@ impl RemoteServerController { } } } + + fn connect_session_for_current_identity( + &mut self, + session_id: SessionId, + socket_path: PathBuf, + ctx: &mut ModelContext, + ) { + let transport = SshTransport::new(socket_path, self.auth_context.clone()); + let auth_context = self.auth_context.clone(); + RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| { + mgr.connect_session(session_id, transport, auth_context, ctx); + }); + } } diff --git a/crates/remote_server/proto/remote_server.proto b/crates/remote_server/proto/remote_server.proto index 9b38b002..f5c0640d 100644 --- a/crates/remote_server/proto/remote_server.proto +++ b/crates/remote_server/proto/remote_server.proto @@ -22,6 +22,7 @@ message ClientMessage { DeleteFile delete_file = 8; RunCommandRequest run_command = 9; ReadFileContextRequest read_file_context = 10; + Authenticate authenticate = 11; } } // Top-level envelope for all server → client messages. @@ -46,7 +47,21 @@ message ServerMessage { // ── Initialize handshake // Sent by the client immediately after connecting to negotiate the protocol. -message Initialize {} +message Initialize { + // Optional bearer token used by the daemon for Warp-server requests. + // Empty means no credential was available and does not clear an existing + // daemon credential. + string auth_token = 1; +} + +// Sent by the client when its bearer credential rotates after initialization. +// This is a notification (fire-and-forget) — the server does not send a response. +message Authenticate { + // Optional bearer token used by the daemon for Warp-server requests. + // Empty means no credential was available and does not clear an existing + // daemon credential. + string auth_token = 1; +} // Sent by the client to cancel an in-progress request. // This is a notification (fire-and-forget) — the server does not send a response. diff --git a/crates/remote_server/src/auth.rs b/crates/remote_server/src/auth.rs new file mode 100644 index 00000000..fe22de19 --- /dev/null +++ b/crates/remote_server/src/auth.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; +use warpui::r#async::BoxFuture; +type GetAuthTokenFn = dyn Fn() -> BoxFuture<'static, Option> + Send + Sync; +type RemoteServerIdentityKeyFn = dyn Fn() -> String + Send + Sync; + +/// App-supplied authentication context for transport-agnostic remote-server code. +/// +/// Bearer tokens are delivered only through protocol messages. Identity keys +/// are non-secret stable partition keys used to select the remote daemon's +/// socket/PID directory. +/// +/// This keeps the `remote_server` crate decoupled from app-side auth/server API +/// types while still allowing initial connect and reconnect handshakes to fetch +/// the current app credential. +#[derive(Clone)] +pub struct RemoteServerAuthContext { + get_auth_token: Arc, + remote_server_identity_key: Arc, +} + +impl RemoteServerAuthContext { + pub fn new( + get_auth_token: impl Fn() -> BoxFuture<'static, Option> + Send + Sync + 'static, + remote_server_identity_key: impl Fn() -> String + Send + Sync + 'static, + ) -> Self { + Self { + get_auth_token: Arc::new(get_auth_token), + remote_server_identity_key: Arc::new(remote_server_identity_key), + } + } + + pub fn get_auth_token(&self) -> BoxFuture<'static, Option> { + (self.get_auth_token)() + } + + pub fn remote_server_identity_key(&self) -> String { + (self.remote_server_identity_key)() + } +} diff --git a/crates/remote_server/src/client/mod.rs b/crates/remote_server/src/client/mod.rs index 228f1289..06251c68 100644 --- a/crates/remote_server/src/client/mod.rs +++ b/crates/remote_server/src/client/mod.rs @@ -10,10 +10,10 @@ use futures::io::{AsyncRead, AsyncWrite}; use warpui::r#async::{executor, FutureExt as _}; use crate::proto::{ - client_message, server_message, Abort, ClientMessage, DeleteFile, ErrorCode, Initialize, - InitializeResponse, LoadRepoMetadataDirectoryResponse, NavigatedToDirectoryResponse, - ReadFileContextRequest, ReadFileContextResponse, RunCommandRequest, RunCommandResponse, - ServerMessage, SessionBootstrapped, WriteFile, + client_message, server_message, Abort, Authenticate, ClientMessage, DeleteFile, ErrorCode, + Initialize, InitializeResponse, LoadRepoMetadataDirectoryResponse, + NavigatedToDirectoryResponse, ReadFileContextRequest, ReadFileContextResponse, + RunCommandRequest, RunCommandResponse, ServerMessage, SessionBootstrapped, WriteFile, }; use crate::protocol::{self, ProtocolError, RequestId}; @@ -182,11 +182,16 @@ impl RemoteServerClient { } /// Sends an `Initialize` request and awaits the `InitializeResponse`. - pub async fn initialize(&self) -> Result { + pub async fn initialize( + &self, + auth_token: Option<&str>, + ) -> Result { let request_id = RequestId::new(); let msg = ClientMessage { request_id: request_id.to_string(), - message: Some(client_message::Message::Initialize(Initialize {})), + message: Some(client_message::Message::Initialize(Initialize { + auth_token: auth_token.unwrap_or_default().to_owned(), + })), }; let response = self.send_request(request_id, msg).await?; @@ -200,6 +205,18 @@ impl RemoteServerClient { } } + /// Sends an `Authenticate` notification to rotate the daemon-wide + /// credential after initialization. + pub fn authenticate(&self, auth_token: &str) { + let msg = ClientMessage { + request_id: String::new(), + message: Some(client_message::Message::Authenticate(Authenticate { + auth_token: auth_token.to_owned(), + })), + }; + self.send_notification(msg); + } + /// Sends a `SessionBootstrapped` notification (fire-and-forget) so the /// server can create a `LocalCommandExecutor` for the session. pub fn notify_session_bootstrapped( diff --git a/crates/remote_server/src/client_tests.rs b/crates/remote_server/src/client_tests.rs index 7792c360..f737ce93 100644 --- a/crates/remote_server/src/client_tests.rs +++ b/crates/remote_server/src/client_tests.rs @@ -75,11 +75,69 @@ async fn initialize_round_trip() { }) }); - let resp = client.initialize().await.unwrap(); + let resp = client.initialize(None).await.unwrap(); assert_eq!(resp.server_version, "test-0.1.0"); assert_eq!(resp.host_id, "test-host-id"); } +#[tokio::test] +async fn initialize_sends_empty_auth_token_when_none() { + let (client, _disconnect_rx, _executor) = setup_mock_client(|msg| { + match &msg.message { + Some(client_message::Message::Initialize(init)) => { + assert!(init.auth_token.is_empty()); + } + other => panic!("Expected Initialize, got {other:?}"), + } + server_message::Message::InitializeResponse(InitializeResponse { + server_version: "test-0.1.0".to_string(), + host_id: "test-host-id".to_string(), + }) + }); + + client.initialize(None).await.unwrap(); +} + +#[tokio::test] +async fn initialize_sends_auth_token_when_provided() { + let (client, _disconnect_rx, _executor) = setup_mock_client(|msg| { + match &msg.message { + Some(client_message::Message::Initialize(init)) => { + assert_eq!(init.auth_token, "secret-token"); + } + other => panic!("Expected Initialize, got {other:?}"), + } + server_message::Message::InitializeResponse(InitializeResponse { + server_version: "test-0.1.0".to_string(), + host_id: "test-host-id".to_string(), + }) + }); + + client.initialize(Some("secret-token")).await.unwrap(); +} + +#[tokio::test] +async fn authenticate_sends_fire_and_forget_message() { + let (client_stream, server_stream) = tokio::io::duplex(4096); + let (server_read, _server_write) = tokio::io::split(server_stream); + let (client_read, client_write) = tokio::io::split(client_stream); + let executor = executor::Background::default(); + let (client, _event_rx) = + RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor); + + client.authenticate("rotated-secret"); + + let msg = protocol::read_client_message(&mut server_read.compat()) + .await + .unwrap(); + match msg.message { + Some(client_message::Message::Authenticate(auth)) => { + assert_eq!(auth.auth_token, "rotated-secret"); + } + other => panic!("Expected Authenticate, got {other:?}"), + } +} + #[tokio::test] async fn disconnected_on_closed_stream() { let (client_stream, server_stream) = tokio::io::duplex(4096); @@ -92,7 +150,7 @@ async fn disconnected_on_closed_stream() { RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor); // An initialize call on a dead stream must complete with an error rather than hang. - let result = client.initialize().await; + let result = client.initialize(None).await; assert!(result.is_err()); // The reader task should detect EOF and emit a Disconnected event. @@ -148,7 +206,9 @@ async fn concurrent_in_flight_requests() { for _ in 0..10 { let c = std::sync::Arc::clone(&client); handles.push(tokio::spawn(async move { - c.initialize().await.expect("concurrent initialize failed") + c.initialize(None) + .await + .expect("concurrent initialize failed") })); } diff --git a/crates/remote_server/src/lib.rs b/crates/remote_server/src/lib.rs index 6019932b..1c65ca41 100644 --- a/crates/remote_server/src/lib.rs +++ b/crates/remote_server/src/lib.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod client; pub mod host_id; pub mod manager; diff --git a/crates/remote_server/src/manager.rs b/crates/remote_server/src/manager.rs index 541cf68c..5a5a6186 100644 --- a/crates/remote_server/src/manager.rs +++ b/crates/remote_server/src/manager.rs @@ -5,6 +5,7 @@ use std::sync::Arc; #[cfg(not(target_family = "wasm"))] use std::time::Duration; +use crate::auth::RemoteServerAuthContext; #[cfg(not(target_family = "wasm"))] use crate::client::ClientEvent; use crate::client::RemoteServerClient; @@ -33,7 +34,9 @@ struct ReconnectParams { host_id: HostId, exit_status: Option, transport: Arc, + auth_context: Arc, control_path: Option, + identity_key: String, } /// Error from [`RemoteServerManager::run_connect_and_handshake`] that @@ -151,6 +154,12 @@ pub enum RemoteSessionState { Connected { client: Arc, host_id: HostId, + /// Identity key that was active when this session was established. + /// Used by `rotate_auth_token` to ensure token rotation notifications + /// are only delivered to sessions that belong to the current user + /// identity, preventing a stale session for a previous identity from + /// receiving a different user's bearer token. + identity_key: String, /// The transport's owning `Child`. See `Initializing::_child`. #[cfg(not(target_family = "wasm"))] _child: async_process::Child, @@ -335,6 +344,10 @@ pub struct RemoteServerManager { /// remote server daemon on every (re)connect. Persists until /// `deregister_session`. session_bootstrap_info: HashMap, + /// App auth context used for connection-time `Initialize` and future + /// reconnect handshakes. + #[cfg_attr(target_family = "wasm", allow(dead_code))] + auth_context: Option>, /// Detected remote platform per session, populated during the binary check /// phase via `detect_platform()`. Used for telemetry. session_platforms: HashMap, @@ -354,6 +367,7 @@ impl RemoteServerManager { spawner: ctx.spawner(), last_navigated_path: HashMap::new(), session_bootstrap_info: HashMap::new(), + auth_context: None, session_platforms: HashMap::new(), } } @@ -483,6 +497,7 @@ impl RemoteServerManager { &mut self, session_id: SessionId, transport: T, + auth_context: Arc, ctx: &mut ModelContext, ) where T: RemoteTransport + 'static, @@ -504,6 +519,7 @@ impl RemoteServerManager { self.sessions .insert(session_id, RemoteSessionState::Connecting); + self.auth_context = Some(Arc::clone(&auth_context)); ctx.emit(RemoteServerManagerEvent::SessionConnecting { session_id }); let spawner = self.spawner.clone(); @@ -511,12 +527,17 @@ impl RemoteServerManager { // Wrap the transport in an Arc so it can be stored on `Connected` // for reconnection after a spontaneous disconnect. let transport: Arc = Arc::new(transport); + let auth_context_for_task = Arc::clone(&auth_context); + // Capture the identity key synchronously so it travels with the + // session and can be used to filter token-rotation notifications. + let identity_key = auth_context.remote_server_identity_key(); ctx.background_executor() .spawn(async move { match Self::run_connect_and_handshake( session_id, &*transport, + &auth_context_for_task, &spawner, &executor, ) @@ -525,7 +546,13 @@ impl RemoteServerManager { Ok(host_id) => { let _ = spawner .spawn(move |me, ctx| { - me.mark_session_connected(session_id, host_id, transport, ctx); + me.mark_session_connected( + session_id, + host_id, + identity_key, + transport, + ctx, + ); }) .await; } @@ -562,13 +589,14 @@ impl RemoteServerManager { /// 1. Calls `transport.connect()` to establish streams. /// 2. Transitions the session to `Initializing` and starts draining the /// event channel. - /// 3. Runs the initialize handshake. + /// 3. Runs the initialize handshake with the current auth token, if any. /// /// Returns `Ok(host_id)` on success, or a phase-tagged error. #[cfg(not(target_family = "wasm"))] async fn run_connect_and_handshake( session_id: SessionId, transport: &dyn RemoteTransport, + auth_context: &RemoteServerAuthContext, spawner: &ModelSpawner, executor: &Arc, ) -> Result { @@ -624,8 +652,9 @@ impl RemoteServerManager { } // Phase 2: Initialize handshake. + let auth_token = auth_context.get_auth_token().await; let resp = client - .initialize() + .initialize(auth_token.as_deref()) .await .map_err(|e| ConnectAndHandshakeError::Initialize(anyhow::anyhow!("{e:#}")))?; Ok(HostId::new(resp.host_id)) @@ -732,6 +761,42 @@ impl RemoteServerManager { } } + /// Rotates the daemon-wide auth credential on each connected remote host. + /// + /// Only sessions whose stored `identity_key` matches the current identity + /// (from `auth_context`) receive the notification. This prevents a stale + /// session established under a previous user identity from receiving a + /// newly-rotated bearer token that belongs to a different user. + /// + /// Within the matching identity, a daemon may have multiple client + /// connections. The credential is stored daemon-wide, so sending one + /// notification per connected host is sufficient. + pub fn rotate_auth_token(&self, token: String) { + let Some(ref auth_context) = self.auth_context else { + log::warn!("rotate_auth_token: no auth_context available, skipping"); + return; + }; + let current_identity_key = auth_context.remote_server_identity_key(); + let mut authenticated_hosts = HashSet::new(); + for state in self.sessions.values() { + let RemoteSessionState::Connected { + client, + host_id, + identity_key, + .. + } = state + else { + continue; + }; + if identity_key != ¤t_identity_key { + continue; + } + if authenticated_hosts.insert(host_id.clone()) { + client.authenticate(&token); + } + } + } + /// Returns the connection state for this session. pub fn session(&self, session_id: SessionId) -> Option<&RemoteSessionState> { self.sessions.get(&session_id) @@ -955,6 +1020,7 @@ impl RemoteServerManager { &mut self, session_id: SessionId, host_id: HostId, + identity_key: String, transport: Arc, ctx: &mut ModelContext, ) { @@ -976,6 +1042,7 @@ impl RemoteServerManager { RemoteSessionState::Connected { client: client.clone(), host_id: host_id.clone(), + identity_key, _child, control_path, transport, @@ -1067,6 +1134,7 @@ impl RemoteServerManager { // with a transport available, and not being explicitly deregistered. if let RemoteSessionState::Connected { host_id, + identity_key, mut _child, control_path, transport, @@ -1076,7 +1144,24 @@ impl RemoteServerManager { let exit_status = Self::capture_exit_status(&mut _child, session_id); // Drop the old child process explicitly before reconnecting. drop(_child); - + let Some(auth_context) = self.auth_context.clone() else { + log::warn!( + "Spontaneous disconnect for session {session_id:?}, \ + but no auth context is available for reconnect" + ); + self.sessions + .insert(session_id, RemoteSessionState::Disconnected); + self.remove_from_host_index(&host_id, session_id); + ctx.emit(RemoteServerManagerEvent::SessionDisconnected { + session_id, + host_id: host_id.clone(), + exit_status, + }); + if !self.host_to_sessions.contains_key(&host_id) { + ctx.emit(RemoteServerManagerEvent::HostDisconnected { host_id }); + } + return; + }; log::info!( "Spontaneous disconnect for session {session_id:?}, \ will attempt reconnect (transport={transport:?})" @@ -1104,7 +1189,9 @@ impl RemoteServerManager { host_id, exit_status, transport, + auth_context, control_path, + identity_key, }, ctx, ); @@ -1129,7 +1216,9 @@ impl RemoteServerManager { host_id, exit_status, transport, + auth_context, control_path, + identity_key, } = params; log::info!( @@ -1149,6 +1238,7 @@ impl RemoteServerManager { let spawner = self.spawner.clone(); let executor = ctx.background_executor().clone(); let transport_clone = Arc::clone(&transport); + let auth_context_for_task = Arc::clone(&auth_context); ctx.background_executor() .spawn(async move { @@ -1168,6 +1258,7 @@ impl RemoteServerManager { match Self::run_connect_and_handshake( session_id, &*transport_clone, + &auth_context_for_task, &spawner, &executor, ) @@ -1188,6 +1279,7 @@ impl RemoteServerManager { me.mark_session_connected( session_id, new_host_id.clone(), + identity_key, transport, ctx, ); @@ -1225,7 +1317,9 @@ impl RemoteServerManager { host_id, exit_status, transport, + auth_context, control_path, + identity_key, }, ctx, ); diff --git a/crates/remote_server/src/protocol_tests.rs b/crates/remote_server/src/protocol_tests.rs index 612c9675..bac0c40a 100644 --- a/crates/remote_server/src/protocol_tests.rs +++ b/crates/remote_server/src/protocol_tests.rs @@ -10,7 +10,9 @@ use super::*; async fn round_trip_client_message() { let msg = ClientMessage { request_id: "test-123".to_string(), - message: Some(client_message::Message::Initialize(Initialize {})), + message: Some(client_message::Message::Initialize(Initialize { + auth_token: String::new(), + })), }; let mut buf = Vec::new(); @@ -119,7 +121,9 @@ async fn write_message_too_large() { fn try_extract_request_id_from_valid_message() { let msg = ClientMessage { request_id: "abc-123".to_string(), - message: Some(client_message::Message::Initialize(Initialize {})), + message: Some(client_message::Message::Initialize(Initialize { + auth_token: String::new(), + })), }; let buf = msg.encode_to_vec(); assert_eq!(try_extract_request_id(&buf), Some("abc-123".to_string())); diff --git a/crates/remote_server/src/setup.rs b/crates/remote_server/src/setup.rs index 624df906..0c3f036b 100644 --- a/crates/remote_server/src/setup.rs +++ b/crates/remote_server/src/setup.rs @@ -137,6 +137,38 @@ pub fn remote_server_dir() -> String { format!("~/{warp_dir}/remote-server") } +/// Returns a filesystem-safe directory name for a remote-server identity key. +/// +/// The identity key is not secret, but it can contain bytes that are unsafe or +/// ambiguous in paths. Keep ASCII alphanumeric characters plus `-` and `_`; +/// percent-encode all other UTF-8 bytes. +pub fn remote_server_identity_dir_name(identity_key: &str) -> String { + if identity_key.is_empty() { + return "empty".to_string(); + } + + let mut encoded = String::with_capacity(identity_key.len()); + for byte in identity_key.bytes() { + match byte { + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' => { + encoded.push(byte as char); + } + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} + +/// Returns the identity-scoped remote directory used for the daemon socket +/// and PID file. +pub fn remote_server_daemon_dir(identity_key: &str) -> String { + format!( + "{}/{}", + remote_server_dir(), + remote_server_identity_dir_name(identity_key) + ) +} + /// Returns the binary name, keyed by channel. /// /// Matches the CLI command names: `oz` (stable), `oz-preview`, `oz-dev`. diff --git a/crates/warp_cli/src/lib.rs b/crates/warp_cli/src/lib.rs index 61996597..3ec4cce0 100644 --- a/crates/warp_cli/src/lib.rs +++ b/crates/warp_cli/src/lib.rs @@ -61,6 +61,15 @@ pub struct ParentOpts { pub handle: Option, } +/// Hidden worker args used to scope remote-server proxy/daemon sockets by +/// Warp identity without exposing credentials. +#[derive(Debug, Clone, Default, clap::Args)] +pub struct RemoteServerIdentityArgs { + /// Non-secret identity partition key for the remote-server daemon. + #[arg(long = "identity-key", hide = true)] + pub identity_key: String, +} + /// Global options that apply to all CLI commands. #[derive(Debug, Default, Clone, clap::Args)] pub struct GlobalOptions { @@ -437,14 +446,14 @@ pub enum WorkerCommand { /// to the daemon via a Unix domain socket. #[cfg(not(target_family = "wasm"))] #[clap(hide = true)] - RemoteServerProxy, + RemoteServerProxy(RemoteServerIdentityArgs), /// Run the long-lived remote development server daemon. /// Listens on a Unix domain socket and accepts multiple concurrent /// connections from proxy processes. #[cfg(not(target_family = "wasm"))] #[clap(hide = true)] - RemoteServerDaemon, + RemoteServerDaemon(RemoteServerIdentityArgs), /// Run a headless ripgrep search worker. #[cfg(not(target_family = "wasm"))]