first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+29 -3
View File
@@ -1,31 +1,45 @@
use galaxyui::r#async::BoxFuture;
use std::sync::Arc;
use galaxyui_core::r#async::BoxFuture;
type GetAuthTokenFn = dyn Fn() -> BoxFuture<'static, Option<String>> + Send + Sync;
type RemoteServerIdentityKeyFn = dyn Fn() -> String + Send + Sync;
/// App-supplied authentication context for transport-agnostic remote-server code.
/// App-supplied authentication and preference 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.
///
/// User identity and privacy preferences are forwarded to the daemon via the
/// `Initialize` handshake so it can configure Sentry crash reporting.
///
/// 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.
/// the current app credential and preferences.
#[derive(Clone)]
pub struct RemoteServerAuthContext {
get_auth_token: Arc<GetAuthTokenFn>,
remote_server_identity_key: Arc<RemoteServerIdentityKeyFn>,
user_id: String,
user_email: String,
crash_reporting_enabled: bool,
}
impl RemoteServerAuthContext {
pub fn new(
get_auth_token: impl Fn() -> BoxFuture<'static, Option<String>> + Send + Sync + 'static,
remote_server_identity_key: impl Fn() -> String + Send + Sync + 'static,
user_id: String,
user_email: String,
crash_reporting_enabled: bool,
) -> Self {
Self {
get_auth_token: Arc::new(get_auth_token),
remote_server_identity_key: Arc::new(remote_server_identity_key),
user_id,
user_email,
crash_reporting_enabled,
}
}
@@ -36,4 +50,16 @@ impl RemoteServerAuthContext {
pub fn remote_server_identity_key(&self) -> String {
(self.remote_server_identity_key)()
}
pub fn user_id(&self) -> &str {
&self.user_id
}
pub fn user_email(&self) -> &str {
&self.user_email
}
pub fn crash_reporting_enabled(&self) -> bool {
self.crash_reporting_enabled
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
use std::sync::{Arc, Mutex};
use bounded_vec_deque::BoundedVecDeque;
/// Maximum number of log lines to retain in the tail buffer.
const LOG_TAIL_MAX_LINES: usize = 5;
/// Maximum number of characters to include when draining the log buffer
/// for telemetry payloads.
const LOG_TAIL_MAX_CHARS: usize = 2048;
/// A shared buffer that retains the last [`LOG_TAIL_MAX_LINES`] lines
/// from the remote server proxy. Used to attach server-side context to
/// telemetry when the connection fails.
#[derive(Clone, Debug)]
pub struct RemoteServerLog(Arc<Mutex<BoundedVecDeque<String>>>);
impl RemoteServerLog {
pub(crate) fn new() -> Self {
Self(Arc::new(Mutex::new(BoundedVecDeque::new(
LOG_TAIL_MAX_LINES,
))))
}
pub(crate) fn push(&self, line: String) {
if let Ok(mut buf) = self.0.lock() {
buf.push_back(line);
}
}
/// Drains the buffer and returns the joined lines, or `None` if empty.
/// Truncates to [`LOG_TAIL_MAX_CHARS`] chars (keeping the tail, which
/// is the most useful context for diagnosing why the proxy died).
pub fn drain(&self) -> Option<String> {
let lines: Vec<String> = self.0.lock().ok()?.drain(..).collect();
if lines.is_empty() {
return None;
}
let joined = lines.join("\n");
if joined.chars().count() > LOG_TAIL_MAX_CHARS {
let tail: String = joined
.chars()
.rev()
.take(LOG_TAIL_MAX_CHARS)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
Some(format!("{tail}"))
} else {
Some(joined)
}
}
}
+374 -38
View File
@@ -1,15 +1,90 @@
use futures::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use crate::proto::{
client_message, run_command_response, server_message, ClientMessage, ErrorCode,
InitializeResponse, RunCommandResponse, RunCommandSuccess, ServerMessage,
};
use crate::protocol;
use galaxy_core::SessionId;
use galaxyui::r#async::executor;
use galaxyui_core::r#async::executor;
use super::*;
use crate::proto::{
client_message, host_scoped_request, notification, run_command_response, server_message,
session_scoped_request, ClientMessage, CodebaseIndexStatus, CodebaseIndexStatusState,
CodebaseIndexStatusUpdated, CodebaseIndexStatusesSnapshot, ErrorCode, GetDiffStateResponse,
InitializeResponse, OpenBufferResponse, RemoteAgentContextSnapshot, RemoteContextFileProto,
RunCommandResponse, RunCommandSuccess, ServerMessage, WriteFile,
};
use crate::protocol;
/// Extract the session-scoped inner message from a ClientMessage wrapper.
fn unwrap_session_scoped(msg: &ClientMessage) -> &session_scoped_request::Message {
match &msg.message {
Some(client_message::Message::SessionScoped(w)) => w.message.as_ref().unwrap(),
other => panic!("Expected SessionScoped, got {other:?}"),
}
}
#[tokio::test]
async fn remote_agent_context_snapshot_push_becomes_client_event() {
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);
drop(server_read);
let executor = executor::Background::default();
let (_client, event_rx, _failure_rx, _host_rx) =
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
let mut writer = server_write.compat_write();
protocol::write_server_message(
&mut writer,
&ServerMessage {
request_id: String::new(),
message: Some(server_message::Message::RemoteAgentContextSnapshot(
RemoteAgentContextSnapshot {
revision: 7,
home_dir: "/home/user".to_string(),
skills: vec![crate::proto::RemoteSkillProto {
path: "/home/user/.agents/skills/test/SKILL.md".to_string(),
content: "skill content".to_string(),
source: Some(crate::proto::remote_skill_proto::Source::Home(
crate::proto::HomeSkillMetadata {},
)),
}],
global_rules: vec![RemoteContextFileProto {
path: "/home/user/.agents/AGENTS.md".to_string(),
content: "rule content".to_string(),
}],
},
)),
},
)
.await
.unwrap();
writer.flush().await.unwrap();
match event_rx.recv().await.unwrap() {
ClientEvent::RemoteAgentContextSnapshotReceived { snapshot } => {
assert_eq!(snapshot.revision, 7);
assert_eq!(snapshot.skills[0].content, "skill content");
assert_eq!(snapshot.global_rules[0].content, "rule content");
}
other => panic!("Expected RemoteAgentContextSnapshotReceived, got {other:?}"),
}
}
/// Extract the host-scoped inner message from a ClientMessage wrapper.
fn unwrap_host_scoped(msg: &ClientMessage) -> &host_scoped_request::Message {
match &msg.message {
Some(client_message::Message::HostScoped(w)) => w.message.as_ref().unwrap(),
other => panic!("Expected HostScoped, got {other:?}"),
}
}
/// Extract the notification inner message from a ClientMessage wrapper.
fn unwrap_notification(msg: &ClientMessage) -> &notification::Message {
match &msg.message {
Some(client_message::Message::Notification(w)) => w.message.as_ref().unwrap(),
other => panic!("Expected Notification, got {other:?}"),
}
}
/// Generic mock server: loops reading ClientMessages and responds using the
/// provided closure. Exits cleanly on EOF.
@@ -37,6 +112,73 @@ async fn mock_server_with<F>(
}
}
fn not_enabled_codebase_status(repo_path: &str) -> CodebaseIndexStatus {
CodebaseIndexStatus {
repo_path: repo_path.to_string(),
state: CodebaseIndexStatusState::NotEnabled.into(),
last_updated_epoch_millis: Some(123),
progress_completed: None,
progress_total: None,
failure_message: None,
root_hash: None,
}
}
#[tokio::test]
async fn codebase_index_push_messages_become_client_events() {
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);
drop(server_read);
let executor = executor::Background::default();
let (_client, event_rx, _failure_rx, _host_rx) =
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
let mut writer = server_write.compat_write();
protocol::write_server_message(
&mut writer,
&ServerMessage {
request_id: String::new(),
message: Some(server_message::Message::CodebaseIndexStatusesSnapshot(
CodebaseIndexStatusesSnapshot {
statuses: vec![not_enabled_codebase_status("/repo")],
},
)),
},
)
.await
.unwrap();
protocol::write_server_message(
&mut writer,
&ServerMessage {
request_id: String::new(),
message: Some(server_message::Message::CodebaseIndexStatusUpdated(
CodebaseIndexStatusUpdated {
status: Some(not_enabled_codebase_status("/repo")),
},
)),
},
)
.await
.unwrap();
writer.flush().await.unwrap();
match event_rx.recv().await.unwrap() {
ClientEvent::CodebaseIndexStatusesSnapshotReceived { statuses } => {
assert_eq!(statuses.len(), 1);
assert_eq!(statuses[0].repo_path, "/repo");
}
other => panic!("Expected CodebaseIndexStatusesSnapshotReceived, got {other:?}"),
}
match event_rx.recv().await.unwrap() {
ClientEvent::CodebaseIndexStatusUpdated { status } => {
assert_eq!(status.repo_path, "/repo");
}
other => panic!("Expected CodebaseIndexStatusUpdated, got {other:?}"),
}
}
/// Sets up a duplex stream, spawns `mock_server_with` with the given responder,
/// and returns a connected `RemoteServerClient`, its event receiver, and the
/// background executor (which must be kept alive for the test duration).
@@ -61,7 +203,7 @@ where
));
let executor = executor::Background::default();
let (client, event_rx) =
let (client, event_rx, _failure_rx, _host_rx) =
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
(client, event_rx, executor)
}
@@ -75,7 +217,18 @@ async fn initialize_round_trip() {
})
});
let resp = client.initialize(None).await.unwrap();
let resp = client
.initialize(
None,
InitializeParams {
user_id: String::new(),
user_email: String::new(),
crash_reporting_enabled: true,
codebase_index_limits: None,
},
)
.await
.unwrap();
assert_eq!(resp.server_version, "test-0.1.0");
assert_eq!(resp.host_id, "test-host-id");
}
@@ -83,37 +236,55 @@ async fn initialize_round_trip() {
#[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:?}"),
}
let session_scoped_request::Message::Initialize(init) = unwrap_session_scoped(msg) else {
panic!("Expected Initialize");
};
assert!(init.auth_token.is_empty());
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();
client
.initialize(
None,
InitializeParams {
user_id: String::new(),
user_email: String::new(),
crash_reporting_enabled: true,
codebase_index_limits: 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:?}"),
}
let session_scoped_request::Message::Initialize(init) = unwrap_session_scoped(msg) else {
panic!("Expected Initialize");
};
assert_eq!(init.auth_token, "secret-token");
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();
client
.initialize(
Some("secret-token"),
InitializeParams {
user_id: String::new(),
user_email: String::new(),
crash_reporting_enabled: true,
codebase_index_limits: None,
},
)
.await
.unwrap();
}
#[tokio::test]
@@ -122,7 +293,7 @@ async fn authenticate_sends_fire_and_forget_message() {
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) =
let (client, _event_rx, _failure_rx, _host_rx) =
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
client.authenticate("rotated-secret");
@@ -130,12 +301,42 @@ async fn authenticate_sends_fire_and_forget_message() {
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:?}"),
}
let notification::Message::Authenticate(auth) = unwrap_notification(&msg) else {
panic!("Expected Authenticate");
};
assert_eq!(auth.auth_token, "rotated-secret");
}
#[tokio::test]
async fn send_host_scoped_returns_ok_when_connected() {
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, _failure_rx, _host_rx) =
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
let msg = ClientMessage::host_scoped(
"req-host-1".to_string(),
host_scoped_request::Message::WriteFile(WriteFile {
path: "/tmp/foo.txt".to_string(),
content: "hello".to_string(),
}),
);
// On a healthy connection, dispatch succeeds (the message is queued).
assert!(client.send_host_scoped(msg).is_ok());
// The queued message is written to the server with the host-scoped envelope.
let received = protocol::read_client_message(&mut server_read.compat())
.await
.unwrap();
assert_eq!(received.request_id, "req-host-1");
let host_scoped_request::Message::WriteFile(write) = unwrap_host_scoped(&received) else {
panic!("Expected WriteFile host-scoped request");
};
assert_eq!(write.path, "/tmp/foo.txt");
assert_eq!(write.content, "hello");
}
#[tokio::test]
@@ -146,25 +347,53 @@ async fn disconnected_on_closed_stream() {
let (client_read, client_write) = tokio::io::split(client_stream);
let executor = executor::Background::default();
let (client, disconnect_rx) =
let (client, disconnect_rx, _failure_rx, _host_rx) =
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(None).await;
let result = client
.initialize(
None,
InitializeParams {
user_id: String::new(),
user_email: String::new(),
crash_reporting_enabled: true,
codebase_index_limits: None,
},
)
.await;
assert!(result.is_err());
// The reader task should detect EOF and emit a Disconnected event.
let event = disconnect_rx.recv().await.unwrap();
assert!(matches!(event, ClientEvent::Disconnected));
// After the Disconnected event has been observed, the reader task has
// already stored `true` into the atomic flag (it does the store before
// sending the event), so callers can rely on `is_disconnected()` to
// short-circuit further requests.
assert!(client.is_disconnected());
}
#[tokio::test]
async fn is_disconnected_starts_false() {
let (client, _disconnect_rx, _executor) = setup_mock_client(|_| {
server_message::Message::InitializeResponse(InitializeResponse {
server_version: "test-0.1.0".to_string(),
host_id: "test-host-id".to_string(),
})
});
assert!(!client.is_disconnected());
}
#[tokio::test]
async fn run_command_round_trip() {
let (client, _disconnect_rx, _executor) = setup_mock_client(|msg| {
let command = match &msg.message {
Some(client_message::Message::RunCommand(req)) => req.command.clone(),
other => panic!("Expected RunCommand, got {other:?}"),
let session_scoped_request::Message::RunCommand(req) = unwrap_session_scoped(msg) else {
panic!("Expected RunCommand");
};
let command = req.command.clone();
server_message::Message::RunCommandResponse(RunCommandResponse {
result: Some(run_command_response::Result::Success(RunCommandSuccess {
stdout: format!("output of: {command}").into_bytes(),
@@ -206,9 +435,17 @@ 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(None)
.await
.expect("concurrent initialize failed")
c.initialize(
None,
InitializeParams {
user_id: String::new(),
user_email: String::new(),
crash_reporting_enabled: true,
codebase_index_limits: None,
},
)
.await
.expect("concurrent initialize failed")
}));
}
@@ -308,3 +545,102 @@ async fn server_returns_error_for_malformed_message_with_parseable_id() {
other => panic!("expected ErrorResponse, got: {other:?}"),
}
}
/// A malformed *server* response carrying a parseable request_id that doesn't
/// match a session-scoped pending request must surface as
/// `HostScopedDecodeFailed` so the manager can fail the host request promptly
/// instead of letting it hang until the request timeout.
#[tokio::test]
async fn malformed_host_scoped_response_emits_decode_failed_event() {
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);
drop(server_read);
let executor = executor::Background::default();
let (_client, event_rx, _failure_rx, _host_rx) =
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
let mut server_write = server_write.compat_write();
// Field 1 (string): tag=0x0a, length=15, "host-req-decode", then invalid
// trailing bytes (field 1, reserved wire type 7) so prost decode fails
// while `try_extract_request_id` still recovers the request_id.
let mut payload = Vec::new();
payload.push(0x0a);
payload.push(15);
payload.extend_from_slice(b"host-req-decode");
payload.extend_from_slice(&[0x0F, 0x01]);
let len = payload.len() as u32;
server_write.write_all(&len.to_le_bytes()).await.unwrap();
server_write.write_all(&payload).await.unwrap();
server_write.flush().await.unwrap();
match event_rx.recv().await.unwrap() {
ClientEvent::HostScopedDecodeFailed { request_id } => {
assert_eq!(request_id.to_string(), "host-req-decode");
}
other => panic!("Expected HostScopedDecodeFailed, got {other:?}"),
}
}
#[tokio::test]
async fn get_diff_state_round_trips_as_session_scoped() {
let (client, _disconnect_rx, _executor) = setup_mock_client(|msg| {
match unwrap_session_scoped(msg) {
session_scoped_request::Message::GetDiffState(req) => {
assert_eq!(req.repo_path, "/repo");
}
other => panic!("Expected GetDiffState, got {other:?}"),
}
server_message::Message::GetDiffStateResponse(GetDiffStateResponse { result: None })
});
let resp = client
.get_diff_state("/repo".to_string(), crate::proto::DiffMode::default())
.await
.expect("get_diff_state should succeed");
assert!(resp.result.is_none());
}
#[tokio::test]
async fn open_buffer_round_trips_as_session_scoped() {
let (client, _disconnect_rx, _executor) = setup_mock_client(|msg| {
match unwrap_session_scoped(msg) {
session_scoped_request::Message::OpenBuffer(req) => {
assert_eq!(req.path, "/tmp/f.txt");
assert!(!req.force_reload);
}
other => panic!("Expected OpenBuffer, got {other:?}"),
}
server_message::Message::OpenBufferResponse(OpenBufferResponse { result: None })
});
let resp = client
.open_buffer("/tmp/f.txt".to_string(), false)
.await
.expect("open_buffer should succeed");
assert!(resp.result.is_none());
}
/// A session-scoped request on a connection that has already dropped resolves
/// promptly with a transport error (no hang), because `pending_requests` is
/// cleared on disconnect.
#[tokio::test]
async fn get_diff_state_on_dead_connection_errors_promptly() {
let (client_stream, server_stream) = tokio::io::duplex(4096);
drop(server_stream);
let (client_read, client_write) = tokio::io::split(client_stream);
let executor = executor::Background::default();
let (client, disconnect_rx, _failure_rx, _host_rx) =
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
// Drain the Disconnected event so the reader-task teardown is observed.
let _ = disconnect_rx.recv().await;
let result = client
.get_diff_state("/repo".to_string(), crate::proto::DiffMode::default())
.await;
assert!(result.is_err());
}
@@ -0,0 +1,119 @@
//! Conversion between remote codebase indexing domain types and proto-generated types.
use serde::Serialize;
use crate::proto;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteCodebaseIndexStatus {
pub repo_path: String,
pub state: RemoteCodebaseIndexState,
pub last_updated_epoch_millis: Option<u64>,
pub progress_completed: Option<u64>,
pub progress_total: Option<u64>,
pub failure_message: Option<String>,
pub root_hash: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RemoteCodebaseIndexState {
NotEnabled,
Unavailable,
Disabled,
Queued,
Indexing,
Ready,
Stale,
Failed,
}
// ── Rust → Proto ────────────────────────────────────────────
impl From<&RemoteCodebaseIndexStatus> for proto::CodebaseIndexStatus {
fn from(status: &RemoteCodebaseIndexStatus) -> Self {
Self {
repo_path: status.repo_path.clone(),
state: proto_state(status.state) as i32,
last_updated_epoch_millis: status.last_updated_epoch_millis,
progress_completed: status.progress_completed,
progress_total: status.progress_total,
failure_message: status.failure_message.clone(),
root_hash: status.root_hash.clone(),
}
}
}
pub fn statuses_to_snapshot_proto<'a>(
statuses: impl IntoIterator<Item = &'a RemoteCodebaseIndexStatus>,
) -> proto::CodebaseIndexStatusesSnapshot {
proto::CodebaseIndexStatusesSnapshot {
statuses: statuses
.into_iter()
.map(proto::CodebaseIndexStatus::from)
.collect(),
}
}
fn proto_state(state: RemoteCodebaseIndexState) -> proto::CodebaseIndexStatusState {
match state {
RemoteCodebaseIndexState::NotEnabled => proto::CodebaseIndexStatusState::NotEnabled,
RemoteCodebaseIndexState::Unavailable => proto::CodebaseIndexStatusState::Unavailable,
RemoteCodebaseIndexState::Disabled => proto::CodebaseIndexStatusState::Disabled,
RemoteCodebaseIndexState::Queued => proto::CodebaseIndexStatusState::Queued,
RemoteCodebaseIndexState::Indexing => proto::CodebaseIndexStatusState::Indexing,
RemoteCodebaseIndexState::Ready => proto::CodebaseIndexStatusState::Ready,
RemoteCodebaseIndexState::Stale => proto::CodebaseIndexStatusState::Stale,
RemoteCodebaseIndexState::Failed => proto::CodebaseIndexStatusState::Failed,
}
}
// ── Proto → Rust ──────────────────────────────────────────────────
pub fn proto_to_codebase_index_status(
status: &proto::CodebaseIndexStatus,
) -> Option<RemoteCodebaseIndexStatus> {
Some(RemoteCodebaseIndexStatus {
repo_path: status.repo_path.clone(),
state: proto_to_state(proto::CodebaseIndexStatusState::try_from(status.state).ok()?)?,
last_updated_epoch_millis: status.last_updated_epoch_millis,
progress_completed: status.progress_completed,
progress_total: status.progress_total,
failure_message: status.failure_message.clone(),
root_hash: status.root_hash.clone(),
})
}
pub fn proto_to_codebase_index_statuses_snapshot(
snapshot: &proto::CodebaseIndexStatusesSnapshot,
) -> Vec<RemoteCodebaseIndexStatus> {
snapshot
.statuses
.iter()
.filter_map(proto_to_codebase_index_status)
.collect()
}
pub fn proto_to_codebase_index_status_updated(
update: &proto::CodebaseIndexStatusUpdated,
) -> Option<RemoteCodebaseIndexStatus> {
proto_to_codebase_index_status(update.status.as_ref()?)
}
fn proto_to_state(state: proto::CodebaseIndexStatusState) -> Option<RemoteCodebaseIndexState> {
match state {
proto::CodebaseIndexStatusState::NotEnabled => Some(RemoteCodebaseIndexState::NotEnabled),
proto::CodebaseIndexStatusState::Unavailable => Some(RemoteCodebaseIndexState::Unavailable),
proto::CodebaseIndexStatusState::Disabled => Some(RemoteCodebaseIndexState::Disabled),
proto::CodebaseIndexStatusState::Queued => Some(RemoteCodebaseIndexState::Queued),
proto::CodebaseIndexStatusState::Indexing => Some(RemoteCodebaseIndexState::Indexing),
proto::CodebaseIndexStatusState::Ready => Some(RemoteCodebaseIndexState::Ready),
proto::CodebaseIndexStatusState::Stale => Some(RemoteCodebaseIndexState::Stale),
proto::CodebaseIndexStatusState::Failed => Some(RemoteCodebaseIndexState::Failed),
proto::CodebaseIndexStatusState::Unspecified => None,
}
}
#[cfg(test)]
#[path = "codebase_index_proto_tests.rs"]
mod tests;
@@ -0,0 +1,84 @@
use super::*;
fn status(state: RemoteCodebaseIndexState) -> RemoteCodebaseIndexStatus {
RemoteCodebaseIndexStatus {
repo_path: "/repo".to_string(),
state,
last_updated_epoch_millis: Some(42),
progress_completed: None,
progress_total: None,
failure_message: None,
root_hash: None,
}
}
#[test]
fn all_status_states_round_trip_through_proto() {
for state in [
RemoteCodebaseIndexState::NotEnabled,
RemoteCodebaseIndexState::Unavailable,
RemoteCodebaseIndexState::Disabled,
RemoteCodebaseIndexState::Queued,
RemoteCodebaseIndexState::Indexing,
RemoteCodebaseIndexState::Ready,
RemoteCodebaseIndexState::Stale,
RemoteCodebaseIndexState::Failed,
] {
let status = status(state);
let proto = proto::CodebaseIndexStatus::from(&status);
assert_eq!(proto_to_codebase_index_status(&proto), Some(status));
}
}
#[test]
fn ready_status_round_trips_retrieval_metadata() {
let status = RemoteCodebaseIndexStatus {
root_hash: Some("root-hash".to_string()),
..status(RemoteCodebaseIndexState::Ready)
};
let proto = proto::CodebaseIndexStatus::from(&status);
assert_eq!(proto.root_hash.as_deref(), Some("root-hash"));
assert_eq!(proto_to_codebase_index_status(&proto), Some(status));
}
#[test]
fn indexing_status_round_trips_progress() {
let status = RemoteCodebaseIndexStatus {
progress_completed: Some(7),
progress_total: Some(11),
..status(RemoteCodebaseIndexState::Indexing)
};
let proto = proto::CodebaseIndexStatus::from(&status);
assert_eq!(proto.progress_completed, Some(7));
assert_eq!(proto.progress_total, Some(11));
assert_eq!(proto_to_codebase_index_status(&proto), Some(status));
}
#[test]
fn failed_status_round_trips_failure_message() {
let status = RemoteCodebaseIndexStatus {
failure_message: Some("failed to sync".to_string()),
..status(RemoteCodebaseIndexState::Failed)
};
let proto = proto::CodebaseIndexStatus::from(&status);
assert_eq!(proto.failure_message.as_deref(), Some("failed to sync"));
assert_eq!(proto_to_codebase_index_status(&proto), Some(status));
}
#[test]
fn unspecified_status_state_is_ignored() {
let status = proto::CodebaseIndexStatus {
repo_path: "/repo".to_string(),
state: proto::CodebaseIndexStatusState::Unspecified as i32,
last_updated_epoch_millis: None,
progress_completed: None,
progress_total: None,
failure_message: None,
root_hash: None,
};
assert_eq!(proto_to_codebase_index_status(&status), None);
}
+90
View File
@@ -0,0 +1,90 @@
//! Helpers for interpreting raw host-scoped `ServerMessage` responses.
//!
//! Host-scoped requests dispatched via [`crate::manager::RemoteServerManager`]
//! resolve to a raw [`ServerMessage`] (the manager only unwraps the top-level
//! [`server_message::Message::Error`] transport error). Operation-specific
//! failures, however, are nested inside the per-operation response variants
//! (e.g. [`WriteFileResponse`] can carry a [`FileOperationError`]). These
//! helpers centralize that parsing so call sites across crates don't each
//! re-implement it — and crucially so a nested error is never silently
//! treated as success.
//!
//! Each helper returns `Ok(())` on success or `Err(message)` with the
//! server-provided error message on failure. Failure includes both an
//! `Error` variant and a missing (`None`) `result`: the daemon always
//! populates exactly one of `success`/`error`, so an unset result is a
//! malformed/never-populated response, never a benign success.
//!
//! Convention for new host-scoped operations: an op whose response is a
//! plain success/error result should get a parser here; an op that returns
//! richer data (e.g. `ReadFileContext`, `GetDiffState`) is parsed at its
//! manager call site instead. The exhaustiveness guard test in
//! `host_response_tests.rs` forces every new request variant to be
//! classified one way or the other.
use crate::proto::{server_message, ServerMessage};
/// Interprets a per-operation response with the standard
/// `Success | Error | (unset)` result shape. A missing `result` is an error
/// (see module docs).
macro_rules! file_op_result {
($msg:expr, $variant:path, $result:path, $op:literal) => {{
use $result as R;
match &$msg.message {
Some($variant(resp)) => match &resp.result {
Some(R::Success(_)) => Ok(()),
Some(R::Error(e)) => Err(e.message.clone()),
None => Err(format!("Empty {} response", $op)),
},
other => Err(unexpected_variant($op, other)),
}
}};
}
/// Interprets a [`ServerMessage`] as the result of a `WriteFile` request.
pub fn write_file_result(msg: &ServerMessage) -> Result<(), String> {
file_op_result!(
msg,
server_message::Message::WriteFileResponse,
crate::proto::write_file_response::Result,
"WriteFile"
)
}
/// Interprets a [`ServerMessage`] as the result of a `SaveBuffer` request.
pub fn save_buffer_result(msg: &ServerMessage) -> Result<(), String> {
file_op_result!(
msg,
server_message::Message::SaveBufferResponse,
crate::proto::save_buffer_response::Result,
"SaveBuffer"
)
}
/// Interprets a [`ServerMessage`] as the result of a `DeleteFile` request.
pub fn delete_file_result(msg: &ServerMessage) -> Result<(), String> {
file_op_result!(
msg,
server_message::Message::DeleteFileResponse,
crate::proto::delete_file_response::Result,
"DeleteFile"
)
}
/// Interprets a [`ServerMessage`] as the result of a `DiscardFiles` request.
pub fn discard_files_result(msg: &ServerMessage) -> Result<(), String> {
file_op_result!(
msg,
server_message::Message::DiscardFilesResponse,
crate::proto::discard_files_response::Result,
"DiscardFiles"
)
}
fn unexpected_variant(op: &str, other: &Option<server_message::Message>) -> String {
format!("Unexpected response variant for {op}: {other:?}")
}
#[cfg(test)]
#[path = "host_response_tests.rs"]
mod tests;
@@ -0,0 +1,175 @@
use super::*;
use crate::proto::{
delete_file_response, discard_files_response, save_buffer_response, server_message,
write_file_response, DeleteFileResponse, DeleteFileSuccess, DiscardFilesError,
DiscardFilesResponse, DiscardFilesSuccess, FileOperationError, SaveBufferResponse,
SaveBufferSuccess, ServerMessage, WriteFileResponse, WriteFileSuccess,
};
fn msg(inner: server_message::Message) -> ServerMessage {
ServerMessage {
request_id: "req-1".to_string(),
message: Some(inner),
}
}
#[test]
fn write_file_success_is_ok_empty_is_err() {
let success = msg(server_message::Message::WriteFileResponse(
WriteFileResponse {
result: Some(write_file_response::Result::Success(WriteFileSuccess {})),
},
));
assert!(write_file_result(&success).is_ok());
let empty = msg(server_message::Message::WriteFileResponse(
WriteFileResponse { result: None },
));
assert!(write_file_result(&empty).is_err());
}
#[test]
fn write_file_error_propagates_message() {
let err = msg(server_message::Message::WriteFileResponse(
WriteFileResponse {
result: Some(write_file_response::Result::Error(FileOperationError {
message: "disk full".to_string(),
})),
},
));
assert_eq!(write_file_result(&err).unwrap_err(), "disk full");
}
#[test]
fn write_file_wrong_variant_is_err() {
let wrong = msg(server_message::Message::SaveBufferResponse(
SaveBufferResponse {
result: Some(save_buffer_response::Result::Success(SaveBufferSuccess {})),
},
));
assert!(write_file_result(&wrong).is_err());
}
#[test]
fn save_buffer_success_is_ok_empty_is_err() {
let success = msg(server_message::Message::SaveBufferResponse(
SaveBufferResponse {
result: Some(save_buffer_response::Result::Success(SaveBufferSuccess {})),
},
));
assert!(save_buffer_result(&success).is_ok());
let empty = msg(server_message::Message::SaveBufferResponse(
SaveBufferResponse { result: None },
));
assert!(save_buffer_result(&empty).is_err());
}
#[test]
fn save_buffer_error_propagates_message() {
let err = msg(server_message::Message::SaveBufferResponse(
SaveBufferResponse {
result: Some(save_buffer_response::Result::Error(FileOperationError {
message: "permission denied".to_string(),
})),
},
));
assert_eq!(save_buffer_result(&err).unwrap_err(), "permission denied");
}
#[test]
fn delete_file_success_is_ok_empty_is_err() {
let success = msg(server_message::Message::DeleteFileResponse(
DeleteFileResponse {
result: Some(delete_file_response::Result::Success(DeleteFileSuccess {})),
},
));
assert!(delete_file_result(&success).is_ok());
let empty = msg(server_message::Message::DeleteFileResponse(
DeleteFileResponse { result: None },
));
assert!(delete_file_result(&empty).is_err());
}
#[test]
fn delete_file_error_propagates_message() {
let err = msg(server_message::Message::DeleteFileResponse(
DeleteFileResponse {
result: Some(delete_file_response::Result::Error(FileOperationError {
message: "no such file".to_string(),
})),
},
));
assert_eq!(delete_file_result(&err).unwrap_err(), "no such file");
}
#[test]
fn discard_files_success_is_ok() {
let success = msg(server_message::Message::DiscardFilesResponse(
DiscardFilesResponse {
result: Some(discard_files_response::Result::Success(
DiscardFilesSuccess {},
)),
},
));
assert!(discard_files_result(&success).is_ok());
}
#[test]
fn discard_files_error_propagates_message() {
let err = msg(server_message::Message::DiscardFilesResponse(
DiscardFilesResponse {
result: Some(discard_files_response::Result::Error(DiscardFilesError {
message: "merge conflict".to_string(),
})),
},
));
assert_eq!(discard_files_result(&err).unwrap_err(), "merge conflict");
}
#[test]
fn discard_files_empty_result_is_err() {
let empty = msg(server_message::Message::DiscardFilesResponse(
DiscardFilesResponse { result: None },
));
assert!(discard_files_result(&empty).is_err());
}
/// Guard: every host-scoped request variant must have an explicit, intentional
/// response disposition. This match is exhaustive, so adding a new
/// `host_scoped_request::Message` variant fails to compile until it is
/// classified here — a prompt to add a `host_response` parser (or document why
/// the response is parsed at the manager call site).
#[test]
fn every_host_scoped_request_has_a_response_disposition() {
use crate::proto::host_scoped_request::Message as M;
fn disposition(m: &M) -> &'static str {
match m {
// Parsed via the helpers in this module.
M::WriteFile(_) => "host_response::write_file_result",
M::SaveBuffer(_) => "host_response::save_buffer_result",
M::DeleteFile(_) => "host_response::delete_file_result",
M::DiscardFiles(_) => "host_response::discard_files_result",
// Richer responses parsed at the manager call site.
M::ReadFileContext(_) => "manager::read_file_context",
M::GetFragmentMetadataFromHash(_) => "manager::get_fragment_metadata_from_hash",
M::UploadHandoffSnapshot(_) => "manager::upload_handoff_snapshot",
M::GetBranches(_) => "manager::get_branches",
M::IndexCodebase(_) => "manager::index_codebase",
M::DropCodebaseIndex(_) => "manager::drop_codebase_index",
M::ResyncCodebase(_) => "manager::resync_codebase",
M::ResolveConflict(_) => "manager::resolve_conflict",
M::GitCommitChain(_) => "manager::commit_chain",
M::GitPush(_) => "manager::push",
M::GitCreatePr(_) => "manager::create_pr",
M::GitGenerateCommitMessage(_) => "manager::generate_commit_message",
M::GitGetCommittedBranchFiles(_) => "manager::get_committed_branch_files",
M::RipgrepSearch(_) => "manager::start_ripgrep_search",
}
}
// Referenced so the exhaustive match is compiled and checked.
let _ = disposition;
}
@@ -1,16 +1,31 @@
#!/usr/bin/env bash
# Installs the Warp remote server binary on a remote host.
# Installs the Warp remote server binary on a remote host, plus the
# artifact's `resources/` tree (bundled skills, settings schema) at a
# global, version-independent location:
#
# {install_dir}/
# ├── {binary_name}{version_suffix} ← the executable
# └── bundled_resources/ ← the artifact's resources tree
#
# Resources are deliberately decoupled from the binary version: the last
# install wins. An older daemon that is still running parsed its skills at
# startup, so a slightly newer resources tree underneath it is accepted.
#
# Placeholders (substituted at runtime by setup.rs):
# {download_base_url} — e.g. https://app.warp.dev/download/cli
# {channel} — stable | preview | dev
# {install_dir} — e.g. ~/.warp/remote-server
# {binary_name} — e.g. oz | oz-dev | oz-preview
# {download_base_url} — e.g. https://app.warp.dev/download/cli
# {channel} — stable | preview | dev
# {install_dir} — e.g. ~/.warp/remote-server
# {binary_name} — e.g. oz | oz-dev | oz-preview
# {version_query} — e.g. &version=v0.2026... (empty when no release tag)
# {version_suffix} — e.g. -v0.2026... (empty when no release tag)
# {bundled_resources_dir_name} — global resources directory name (e.g. bundled_resources)
# {no_http_client_exit_code} — exit code when neither curl nor wget is available
# {staging_tarball_path} — path to a pre-uploaded tarball (SCP fallback; empty normally)
set -e
arch=$(uname -m)
case "$arch" in
x86_64) arch_name=x86_64 ;;
x86_64|amd64) arch_name=x86_64 ;;
aarch64|arm64) arch_name=aarch64 ;;
*) echo "unsupported arch: $arch" >&2; exit 2 ;;
esac
@@ -23,17 +38,75 @@ case "$os_kernel" in
esac
install_dir="{install_dir}"
install_dir="${install_dir/#\~/"$HOME"}"
# Avoid `${var/pattern/replacement}` for tilde expansion. Two
# interpreter quirks make it dangerous in this script:
# 1. bash 3.2 (macOS /bin/bash) keeps inner double-quotes around the
# replacement literal, so `"$HOME"` ends up as 6 literal
# characters and the install lands under a directory tree
# literally named `"`.
# 2. bash 5.2+ enables `patsub_replacement` by default, which makes
# `&` in the replacement expand to the matched pattern, so a
# `$HOME` containing `&` resolves to a `~`-substituted path.
# Use `case` + `${var#\~}` instead — works on bash 3.2 and bash 5.2+
# without surprises.
case "$install_dir" in
"~"|"~/"*) install_dir="${HOME}${install_dir#\~}" ;;
esac
mkdir -p "$install_dir"
tmpdir=$(mktemp -d "$install_dir/.install.XXXXXX")
trap 'rm -rf "$tmpdir"' EXIT
# Best-effort cleanup of the staging directory. A failure here (e.g.
# EBUSY or "Directory not empty" races on some filesystems/mounts)
# must not fail the install: by the time this fires the binary has
# either already been moved into its final location, or the script
# has already failed for an unrelated reason that we want to surface
# instead of clobbering with the cleanup's exit code.
cleanup() {
rm -rf "$tmpdir" 2>/dev/null || true
}
trap cleanup EXIT
staging_tarball_path="{staging_tarball_path}"
if [ -n "$staging_tarball_path" ]; then
# SCP fallback: tarball already uploaded by the client.
# Same tilde-expansion caveat as install_dir above.
case "$staging_tarball_path" in
"~"|"~/"*) staging_tarball_path="${HOME}${staging_tarball_path#\~}" ;;
esac
mv "$staging_tarball_path" "$tmpdir/oz.tar.gz"
else
# Normal path: download via curl or wget.
url="{download_base_url}?package=tar&os=$os_name&arch=$arch_name&channel={channel}{version_query}"
if command -v curl >/dev/null 2>&1; then
curl -fSL --connect-timeout 15 "$url" -o "$tmpdir/oz.tar.gz"
elif command -v wget >/dev/null 2>&1; then
wget -q -O "$tmpdir/oz.tar.gz" "$url"
else
echo "error: neither curl nor wget is available" >&2
exit {no_http_client_exit_code}
fi
fi
curl -fSL "{download_base_url}?package=tar&os=$os_name&arch=$arch_name&channel={channel}" \
-o "$tmpdir/oz.tar.gz"
tar -xzf "$tmpdir/oz.tar.gz" -C "$tmpdir"
bin=$(find "$tmpdir" -type f -name 'oz*' ! -name '*.tar.gz' | head -n1)
# The executable and its resources are siblings in the artifact. Exclude the
# resources tree from the search: bundled skills may ship companion files
# whose names also start with `oz`.
bin=$(find "$tmpdir" -type f -name 'oz*' ! -name '*.tar.gz' ! -path '*/resources/*' | head -n1)
if [ -z "$bin" ]; then echo "no binary found in tarball" >&2; exit 1; fi
chmod +x "$bin"
mv "$bin" "$install_dir/{binary_name}"
# Install the resources tree at the global, version-independent location
# the daemon reads. `$tmpdir` lives inside `$install_dir`, so the `mv` is a
# same-filesystem rename. Installed before the binary so an interrupted
# install never leaves a new binary without its resources — the binary miss
# re-triggers this script. A tarball without resources is not an error: the
# daemon simply has no bundled skills.
resources="$(dirname "$bin")/resources"
if [ -d "$resources" ]; then
rm -rf "$install_dir/{bundled_resources_dir_name}"
mv "$resources" "$install_dir/{bundled_resources_dir_name}"
fi
mv "$bin" "$install_dir/{binary_name}{version_suffix}"
+43
View File
@@ -1,6 +1,8 @@
pub mod auth;
pub mod client;
pub mod codebase_index_proto;
pub mod host_id;
pub mod host_response;
pub mod manager;
pub mod protocol;
pub mod repo_metadata_proto;
@@ -11,6 +13,47 @@ pub mod transport;
pub use host_id::HostId;
#[allow(clippy::large_enum_variant)]
pub mod proto {
include!(concat!(env!("OUT_DIR"), "/remote_server.rs"));
// ── ClientMessage constructors ──────────────────────────────────
//
// These helpers wrap inner message types in the appropriate
// HostScopedRequest / SessionScopedRequest / Notification envelope
// so call sites don't need triple-nested struct literals.
impl ClientMessage {
/// Build a `ClientMessage` carrying a host-scoped request.
pub fn host_scoped(request_id: String, inner: host_scoped_request::Message) -> Self {
Self {
request_id,
message: Some(client_message::Message::HostScoped(HostScopedRequest {
message: Some(inner),
})),
}
}
/// Build a `ClientMessage` carrying a session-scoped request.
pub fn session_scoped(request_id: String, inner: session_scoped_request::Message) -> Self {
Self {
request_id,
message: Some(client_message::Message::SessionScoped(
SessionScopedRequest {
message: Some(inner),
},
)),
}
}
/// Build a `ClientMessage` carrying a notification (fire-and-forget).
pub fn notification(inner: notification::Message) -> Self {
Self {
request_id: String::new(),
message: Some(client_message::Message::Notification(Notification {
message: Some(inner),
})),
}
}
}
}
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
use futures::channel::oneshot;
use galaxy_core::SessionId;
use warp_util::standardized_path::StandardizedPath;
use galaxyui_core::App;
use super::{
HostRequestError, PendingHostRequest, RemoteServerManager, RemoteServerManagerEvent,
RipgrepSearchParams,
};
use crate::proto::{host_scoped_request, ClientMessage, RemoteAgentContextSnapshot, WriteFile};
use crate::protocol::RequestId;
use crate::HostId;
#[test]
fn abort_host_request_removes_pending_request_and_resolves_caller() {
App::test((), |mut app| async move {
let manager = app.add_model(RemoteServerManager::new);
let host_id = HostId::new("test-host".to_string());
let request_id = RequestId::new();
let (result_tx, result_rx) = oneshot::channel();
let msg = ClientMessage::host_scoped(
request_id.to_string(),
host_scoped_request::Message::WriteFile(WriteFile {
path: "/tmp/test".to_string(),
content: String::new(),
}),
);
manager.update(&mut app, |manager, _ctx| {
manager.pending_host_requests.insert(
request_id.clone(),
PendingHostRequest {
host_id,
dispatched_session_id: SessionId::from(1),
msg,
result_tx,
timeout_abort: None,
},
);
manager.abort_host_request(&request_id);
assert!(!manager.pending_host_requests.contains_key(&request_id));
});
assert!(matches!(
result_rx.await.expect("manager should resolve caller"),
Err(HostRequestError::Aborted)
));
});
}
#[test]
fn remote_agent_context_snapshot_is_a_host_scoped_manager_event() {
let host_id = HostId::new("test-host".to_string());
let event = RemoteServerManagerEvent::RemoteAgentContextSnapshot {
host_id,
snapshot: RemoteAgentContextSnapshot {
revision: 1,
home_dir: "/home/user".to_string(),
skills: Vec::new(),
global_rules: Vec::new(),
},
};
assert!(event.session_id().is_none());
}
#[test]
fn remote_agent_context_snapshot_revisions_are_deduplicated_per_host() {
App::test((), |mut app| async move {
let manager = app.add_model(RemoteServerManager::new);
let host_id = HostId::new("test-host".to_string());
let other_host_id = HostId::new("other-host".to_string());
manager.update(&mut app, |manager, ctx| {
assert!(manager.accept_remote_agent_context_snapshot_revision(&host_id, 2));
assert!(!manager.accept_remote_agent_context_snapshot_revision(&host_id, 2));
assert!(!manager.accept_remote_agent_context_snapshot_revision(&host_id, 1));
assert!(manager.accept_remote_agent_context_snapshot_revision(&host_id, 3));
assert!(manager.accept_remote_agent_context_snapshot_revision(&other_host_id, 1));
manager.handle_host_disconnected(&host_id, ctx);
assert!(manager.accept_remote_agent_context_snapshot_revision(&host_id, 3));
});
});
}
#[test]
fn start_ripgrep_search_without_connected_host_resolves_immediately() {
App::test((), |mut app| async move {
let manager = app.add_model(RemoteServerManager::new);
let host_id = HostId::new("missing-host".to_string());
let pending = manager.update(&mut app, |manager, _ctx| {
manager.start_ripgrep_search(
&host_id,
RipgrepSearchParams {
pattern: "needle".to_string(),
roots: vec![StandardizedPath::try_new("/repo").unwrap()],
ignore_case: false,
multiline: false,
max_matches: 100,
},
)
});
assert!(matches!(
pending.result().await,
Err(HostRequestError::AllSessionsDisconnected)
));
});
}
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Preinstall check for the Warp remote-server binary.
#
# Emits a structured key=value summary on stdout. Exits 0 on success.
# A non-zero exit indicates a probe-level failure; the client treats
# those as `status=unknown` (fail open).
set -u
# The minimum glibc the prebuilt Linux CLI requires. The Linux CLI is
# built on Ubuntu 20.04 (see `.github/workflows/create_release.yml`),
# which ships glibc 2.31. Bump this when the runner image is bumped.
required_glibc="2.31"
echo "required_glibc=${required_glibc}"
# 1. Detect libc family and (when glibc) its version.
libc_family="unknown"
libc_version=""
if version=$(getconf GNU_LIBC_VERSION 2>/dev/null); then
# Output: "glibc 2.31"
libc_family="glibc"
libc_version="${version##* }"
elif ldd_out=$(ldd --version 2>&1 | head -n1); then
case "$ldd_out" in
*musl*) libc_family="musl" ;;
*uClibc*) libc_family="uclibc" ;;
*)
v=$(printf '%s\n' "$ldd_out" | grep -oE '[0-9]+\.[0-9]+' | head -n1)
if [ -n "$v" ]; then
libc_family="glibc"
libc_version="$v"
fi
;;
esac
fi
echo "libc_family=${libc_family}"
[ -n "$libc_version" ] && echo "libc_version=${libc_version}"
# 2. Decide status from the gathered facts.
status="unknown"
reason=""
if [ "$libc_family" = "glibc" ] && [ -n "$libc_version" ]; then
have_major="${libc_version%%.*}"
have_minor="${libc_version#*.}"
have_minor="${have_minor%%.*}"
req_major="${required_glibc%%.*}"
req_minor="${required_glibc#*.}"
if [ "$have_major" -gt "$req_major" ] \
|| { [ "$have_major" -eq "$req_major" ] && [ "$have_minor" -ge "$req_minor" ]; }; then
status="supported"
else
status="unsupported"
reason="glibc_too_old"
fi
elif [ "$libc_family" = "musl" ] || [ "$libc_family" = "bionic" ] || [ "$libc_family" = "uclibc" ]; then
status="unsupported"
reason="non_glibc"
fi
echo "status=${status}"
if [ -n "$reason" ]; then
echo "reason=${reason}"
fi
+92 -15
View File
@@ -1,19 +1,24 @@
use prost::Message;
use crate::proto::{
client_message, server_message, ClientMessage, Initialize, InitializeResponse, ServerMessage,
};
use super::*;
use crate::proto::{
client_message, remote_skill_proto, server_message, session_scoped_request,
BundledSkillMetadata, ClientMessage, HomeSkillMetadata, Initialize, InitializeResponse,
RemoteAgentContextSnapshot, RemoteContextFileProto, RemoteSkillProto, ServerMessage,
};
#[tokio::test]
async fn round_trip_client_message() {
let msg = ClientMessage {
request_id: "test-123".to_string(),
message: Some(client_message::Message::Initialize(Initialize {
let msg = ClientMessage::session_scoped(
"test-123".to_string(),
session_scoped_request::Message::Initialize(Initialize {
auth_token: String::new(),
})),
};
user_id: String::new(),
user_email: String::new(),
crash_reporting_enabled: true,
codebase_index_limits: None,
}),
);
let mut buf = Vec::new();
write_client_message(&mut buf, &msg).await.unwrap();
@@ -23,7 +28,74 @@ async fn round_trip_client_message() {
assert_eq!(decoded.request_id, "test-123");
match decoded.message {
Some(client_message::Message::Initialize(_)) => {}
Some(client_message::Message::SessionScoped(_)) => {}
other => panic!("unexpected message variant: {other:?}"),
}
}
#[tokio::test]
async fn round_trip_remote_agent_context_snapshot() {
let mut buf = Vec::new();
write_server_message(
&mut buf,
&ServerMessage {
request_id: String::new(),
message: Some(server_message::Message::RemoteAgentContextSnapshot(
RemoteAgentContextSnapshot {
revision: 7,
home_dir: "/home/user".to_string(),
skills: vec![
RemoteSkillProto {
path: "/bundled/pr-comments/SKILL.md".to_string(),
content: "bundled content".to_string(),
source: Some(remote_skill_proto::Source::Bundled(
BundledSkillMetadata {
id: "pr-comments".to_string(),
requires_mcp: Some("figma".to_string()),
},
)),
},
RemoteSkillProto {
path: "/home/user/.agents/skills/test/SKILL.md".to_string(),
content: "home skill content".to_string(),
source: Some(remote_skill_proto::Source::Home(HomeSkillMetadata {})),
},
],
global_rules: vec![RemoteContextFileProto {
path: "/home/user/.agents/AGENTS.md".to_string(),
content: "rule content".to_string(),
}],
},
)),
},
)
.await
.unwrap();
let decoded = read_server_message(&mut &buf[..]).await.unwrap();
match decoded.message {
Some(server_message::Message::RemoteAgentContextSnapshot(snapshot)) => {
assert_eq!(snapshot.revision, 7);
assert_eq!(snapshot.home_dir, "/home/user");
assert_eq!(snapshot.skills.len(), 2);
let Some(remote_skill_proto::Source::Bundled(bundled)) =
snapshot.skills[0].source.as_ref()
else {
panic!("expected bundled skill source");
};
assert_eq!(bundled.id, "pr-comments");
assert_eq!(bundled.requires_mcp.as_deref(), Some("figma"));
assert!(matches!(
snapshot.skills[1].source,
Some(remote_skill_proto::Source::Home(_))
));
assert_eq!(snapshot.skills[1].content, "home skill content");
assert_eq!(
snapshot.global_rules[0].path,
"/home/user/.agents/AGENTS.md"
);
assert_eq!(snapshot.global_rules[0].content, "rule content");
}
other => panic!("unexpected message variant: {other:?}"),
}
}
@@ -50,6 +122,7 @@ async fn round_trip_server_message() {
match decoded.message {
Some(server_message::Message::InitializeResponse(resp)) => {
assert_eq!(resp.server_version, "0.1.0");
assert_eq!(resp.host_id, "test-host");
}
other => panic!("unexpected message variant: {other:?}"),
}
@@ -119,12 +192,16 @@ async fn write_message_too_large() {
#[test]
fn try_extract_request_id_from_valid_message() {
let msg = ClientMessage {
request_id: "abc-123".to_string(),
message: Some(client_message::Message::Initialize(Initialize {
let msg = ClientMessage::session_scoped(
"abc-123".to_string(),
session_scoped_request::Message::Initialize(Initialize {
auth_token: String::new(),
})),
};
user_id: String::new(),
user_email: String::new(),
crash_reporting_enabled: true,
codebase_index_limits: None,
}),
);
let buf = msg.encode_to_vec();
assert_eq!(try_extract_request_id(&buf), Some("abc-123".to_string()));
}
@@ -9,6 +9,7 @@ use repo_metadata::file_tree_update::{
DirectoryNodeMetadata, FileNodeMetadata, FileTreeEntryUpdate, RepoMetadataUpdate,
RepoNodeMetadata,
};
use repo_metadata::{StandingQueryContent, StandingQueryResultsDelta};
use crate::proto;
@@ -28,6 +29,47 @@ impl From<&RepoMetadataUpdate> for proto::RepoMetadataUpdatePush {
.iter()
.map(proto::RepoMetadataEntryUpdate::from)
.collect(),
standing_results_delta: Some((&update.standing_results_delta).into()),
}
}
}
#[cfg(test)]
#[path = "repo_metadata_proto_tests.rs"]
mod tests;
impl From<&StandingQueryContent> for proto::StandingQueryContent {
fn from(content: &StandingQueryContent) -> Self {
Self {
path: content.path.to_string(),
is_directory: content.is_directory,
}
}
}
impl From<&StandingQueryResultsDelta> for proto::StandingQueryResultsDelta {
fn from(delta: &StandingQueryResultsDelta) -> Self {
Self {
upserted_project_skills: delta
.upserted_project_skills
.iter()
.map(proto::StandingQueryContent::from)
.collect(),
removed_project_skills: delta
.removed_project_skills
.iter()
.map(proto::StandingQueryContent::from)
.collect(),
upserted_project_rules: delta
.upserted_project_rules
.iter()
.map(proto::StandingQueryContent::from)
.collect(),
removed_project_rules: delta
.removed_project_rules
.iter()
.map(proto::StandingQueryContent::from)
.collect(),
}
}
}
@@ -159,9 +201,50 @@ pub fn proto_to_repo_metadata_update(
repo_path,
remove_entries,
update_entries,
standing_results_delta: push
.standing_results_delta
.as_ref()
.map(proto_to_standing_results_delta)
.unwrap_or_default(),
})
}
fn proto_to_standing_query_content(
content: &proto::StandingQueryContent,
) -> Option<StandingQueryContent> {
Some(StandingQueryContent {
path: StandardizedPath::try_new(&content.path).ok()?,
is_directory: content.is_directory,
})
}
fn proto_to_standing_results_delta(
delta: &proto::StandingQueryResultsDelta,
) -> StandingQueryResultsDelta {
StandingQueryResultsDelta {
upserted_project_skills: delta
.upserted_project_skills
.iter()
.filter_map(proto_to_standing_query_content)
.collect(),
removed_project_skills: delta
.removed_project_skills
.iter()
.filter_map(proto_to_standing_query_content)
.collect(),
upserted_project_rules: delta
.upserted_project_rules
.iter()
.filter_map(proto_to_standing_query_content)
.collect(),
removed_project_rules: delta
.removed_project_rules
.iter()
.filter_map(proto_to_standing_query_content)
.collect(),
}
}
/// Converts a `RepoMetadataSnapshot` proto into a `RepoMetadataUpdate`
/// (with no removals) that can be applied to a `RemoteRepoMetadataModel`.
pub fn proto_snapshot_to_update(
@@ -179,6 +262,11 @@ pub fn proto_snapshot_to_update(
repo_path,
remove_entries: Vec::new(),
update_entries,
standing_results_delta: snapshot
.standing_results
.as_ref()
.map(proto_to_standing_results_delta)
.unwrap_or_default(),
})
}
@@ -216,6 +304,7 @@ pub fn proto_load_repo_metadata_directory_response_to_update(
repo_path,
remove_entries: Vec::new(),
update_entries,
standing_results_delta: StandingQueryResultsDelta::default(),
})
}
@@ -0,0 +1,58 @@
use repo_metadata::file_tree_update::RepoMetadataUpdate;
use repo_metadata::{StandingQueryContent, StandingQueryResultsDelta};
use warp_util::standardized_path::StandardizedPath;
use super::{proto_snapshot_to_update, proto_to_repo_metadata_update};
use crate::proto;
fn path(path: &str) -> StandardizedPath {
StandardizedPath::try_new(path).unwrap()
}
fn standing_delta() -> StandingQueryResultsDelta {
StandingQueryResultsDelta {
upserted_project_skills: vec![StandingQueryContent::file(path(
"/repo/.agents/skills/review/SKILL.md",
))],
removed_project_skills: vec![StandingQueryContent::directory(path(
"/repo/.claude/skills",
))],
upserted_project_rules: vec![StandingQueryContent::file(path("/repo/WARP.md"))],
removed_project_rules: vec![StandingQueryContent::file(path(
"/repo/packages/api/AGENTS.md",
))],
}
}
#[test]
fn incremental_update_round_trip_preserves_standing_results_delta() {
let update = RepoMetadataUpdate {
repo_path: path("/repo"),
remove_entries: Vec::new(),
update_entries: Vec::new(),
standing_results_delta: standing_delta(),
};
let proto_update = proto::RepoMetadataUpdatePush::from(&update);
let round_trip = proto_to_repo_metadata_update(&proto_update).unwrap();
assert_eq!(
round_trip.standing_results_delta,
update.standing_results_delta
);
}
#[test]
fn snapshot_conversion_seeds_standing_results() {
let delta = standing_delta();
let snapshot = proto::RepoMetadataSnapshot {
repo_path: "/repo".to_string(),
entries: Vec::new(),
standing_results: Some((&delta).into()),
sync_complete: true,
};
let update = proto_snapshot_to_update(&snapshot).unwrap();
assert_eq!(update.standing_results_delta, delta);
}
+462 -34
View File
@@ -1,21 +1,34 @@
mod glibc;
use std::time::Duration;
use anyhow::{anyhow, Result};
use anyhow::anyhow;
pub use glibc::{GlibcVersion, RemoteLibc};
use galaxy_core::channel::{Channel, ChannelState};
pub const REMOTE_SERVER_ARTIFACT_VERSION_UNPINNED: &str = "unversioned";
/// State machine for the remote server install → launch → initialize flow.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RemoteServerSetupState {
/// Checking if the binary exists on remote.
Checking,
/// Downloading and installing the binary.
/// Downloading and installing the binary for the first time on this host.
Installing { progress_percent: Option<u8> },
/// Replacing an existing install with a differently-versioned binary.
/// Rendered as "Updating..." in the UI so the user understands this
/// isn't a fresh install.
Updating,
/// Binary is launched, waiting for InitializeResponse.
Initializing,
/// Handshake complete. Ready.
Ready,
/// Something failed. Fall back to ControlMaster.
Failed { error: String },
/// Preinstall check classified the host as incompatible with the
/// prebuilt remote-server binary. The controller treats this as a
/// clean fall-back to the legacy ControlMaster-backed SSH flow,
/// distinct from `Failed` (which is rendered as a real error).
Unsupported { reason: UnsupportedReason },
}
impl RemoteServerSetupState {
@@ -27,18 +40,215 @@ impl RemoteServerSetupState {
matches!(self, Self::Failed { .. })
}
pub fn is_unsupported(&self) -> bool {
matches!(self, Self::Unsupported { .. })
}
pub fn is_terminal(&self) -> bool {
self.is_ready() || self.is_failed()
self.is_ready() || self.is_failed() || self.is_unsupported()
}
pub fn is_in_progress(&self) -> bool {
matches!(
self,
Self::Checking | Self::Installing { .. } | Self::Initializing
Self::Checking | Self::Installing { .. } | Self::Updating | Self::Initializing
)
}
pub fn is_connecting(&self) -> bool {
matches!(
self,
Self::Installing { .. } | Self::Updating | Self::Initializing
)
}
}
impl From<&crate::transport::Error> for RemoteServerSetupState {
fn from(error: &crate::transport::Error) -> Self {
if let Some(reason) = UnsupportedReason::from_transport_error(error) {
Self::Unsupported { reason }
} else {
Self::Failed {
error: error.to_string(),
}
}
}
}
/// Outcome of [`crate::transport::RemoteTransport::run_preinstall_check`].
///
/// The script runs over the existing SSH socket before any install UI
/// surfaces and reports whether the host can run the prebuilt
/// remote-server binary. The Rust side is intentionally a thin parser
/// over the script's structured stdout (see `preinstall_check.sh`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PreinstallCheckResult {
pub status: PreinstallStatus,
pub libc: RemoteLibc,
/// Verbatim, trimmed script stdout. Forwarded to telemetry for
/// diagnosing `Unknown` outcomes on exotic distros.
pub raw: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PreinstallStatus {
Supported,
Unsupported {
reason: UnsupportedReason,
},
/// Probe ran but couldn't classify the host. Treated as supported
/// (fail open) by [`PreinstallCheckResult::is_supported`] so we keep
/// today's install-and-try behavior on hosts where the probe is
/// unreliable.
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum UnsupportedReason {
GlibcTooOld {
detected: GlibcVersion,
required: GlibcVersion,
},
NonGlibc {
name: String,
},
UnsupportedOs {
os: String,
},
UnsupportedArch {
arch: String,
},
}
impl UnsupportedReason {
pub fn from_transport_error(error: &crate::transport::Error) -> Option<Self> {
match error {
crate::transport::Error::UnsupportedOs { os } => {
Some(Self::UnsupportedOs { os: os.clone() })
}
crate::transport::Error::UnsupportedArch { arch } => {
Some(Self::UnsupportedArch { arch: arch.clone() })
}
crate::transport::Error::TimedOut
| crate::transport::Error::ScriptFailed { .. }
| crate::transport::Error::Other(_) => None,
}
}
pub fn as_telemetry_reason(&self) -> &'static str {
match self {
Self::GlibcTooOld { .. } => "glibc_too_old",
Self::NonGlibc { .. } => "non_glibc",
Self::UnsupportedOs { .. } => "unsupported_os",
Self::UnsupportedArch { .. } => "unsupported_arch",
}
}
}
impl PreinstallCheckResult {
pub fn unsupported(reason: UnsupportedReason) -> Self {
Self {
status: PreinstallStatus::Unsupported { reason },
libc: RemoteLibc::Unknown,
raw: String::new(),
}
}
/// Whether the host is supported. Both `Supported` and `Unknown`
/// return true — only positive detection of an incompatible libc
/// triggers the silent fall-back.
pub fn is_supported(&self) -> bool {
match self.status {
PreinstallStatus::Supported | PreinstallStatus::Unknown => true,
PreinstallStatus::Unsupported { .. } => false,
}
}
/// Parses the structured `key=value` stdout emitted by
/// `preinstall_check.sh`. Tolerates unknown keys and lines without
/// `=` (forward-compatibility): future versions of the script can
/// add new keys without coordinating a client release.
pub fn parse(stdout: &str) -> Self {
let mut status_str: Option<&str> = None;
let mut reason_str: Option<&str> = None;
let mut libc_family: Option<&str> = None;
let mut libc_version: Option<&str> = None;
let mut required_glibc: Option<&str> = None;
for line in stdout.lines() {
let Some((key, value)) = line.split_once('=') else {
continue;
};
match key.trim() {
"status" => status_str = Some(value.trim()),
"reason" => reason_str = Some(value.trim()),
"libc_family" => libc_family = Some(value.trim()),
"libc_version" => libc_version = Some(value.trim()),
"required_glibc" => required_glibc = Some(value.trim()),
_ => {} // ignore unknown keys
}
}
let libc = glibc::parse_libc(libc_family, libc_version);
let status = parse_status(status_str, reason_str, &libc, required_glibc);
Self {
status,
libc,
raw: stdout.trim().to_string(),
}
}
}
fn parse_status(
status: Option<&str>,
reason: Option<&str>,
libc: &RemoteLibc,
required_glibc: Option<&str>,
) -> PreinstallStatus {
match status {
Some("supported") => PreinstallStatus::Supported,
Some("unsupported") => match reason {
Some("glibc_too_old") => {
let detected = match libc {
RemoteLibc::Glibc(v) => Some(*v),
_ => None,
};
let required = required_glibc.and_then(GlibcVersion::parse);
match (detected, required) {
(Some(detected), Some(required)) => PreinstallStatus::Unsupported {
reason: UnsupportedReason::GlibcTooOld { detected, required },
},
// The script said `unsupported` + `glibc_too_old` but we
// can't recover the numbers — fail open rather than
// surface a malformed reason.
_ => PreinstallStatus::Unknown,
}
}
Some("non_glibc") => {
let name = match libc {
RemoteLibc::NonGlibc { name } => name.clone(),
_ => "unknown".to_string(),
};
PreinstallStatus::Unsupported {
reason: UnsupportedReason::NonGlibc { name },
}
}
_ => PreinstallStatus::Unknown,
},
// status=unknown, missing, or anything else → fail open.
_ => PreinstallStatus::Unknown,
}
}
/// The bundled preinstall check script. Loaded as a string so the SSH
/// transport can pipe it through the existing ControlMaster socket via
/// [`crate::ssh::run_ssh_script`].
///
/// The script is intentionally self-contained — the supported-glibc
/// floor is hardcoded inside the script (see `preinstall_check.sh`)
/// rather than templated from Rust.
pub const PREINSTALL_CHECK_SCRIPT: &str = include_str!("preinstall_check.sh");
/// Detected remote platform from `uname -sm` output.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemotePlatform {
@@ -80,31 +290,43 @@ impl RemoteArch {
///
/// The expected format is `<os> <arch>`, e.g. `Linux x86_64` or `Darwin arm64`.
/// Takes the last line to skip any shell initialization output.
pub fn parse_uname_output(output: &str) -> Result<RemotePlatform> {
pub fn parse_uname_output(
output: &str,
) -> std::result::Result<RemotePlatform, crate::transport::Error> {
use crate::transport::Error;
let line = output
.lines()
.last()
.ok_or_else(|| anyhow!("empty uname output"))?
.trim();
.ok_or_else(|| Error::Other(anyhow!("empty uname output")))
.map(str::trim)?;
let mut parts = line.split_whitespace();
let os_str = parts
.next()
.ok_or_else(|| anyhow!("missing OS in uname output: {line}"))?;
.ok_or_else(|| Error::Other(anyhow!("missing OS in uname output: {line}")))?;
let arch_str = parts
.next()
.ok_or_else(|| anyhow!("missing arch in uname output: {line}"))?;
.ok_or_else(|| Error::Other(anyhow!("missing arch in uname output: {line}")))?;
let os = match os_str {
"Linux" => RemoteOs::Linux,
"Darwin" => RemoteOs::MacOs,
other => return Err(anyhow!("unsupported OS: {other}")),
other => {
return Err(Error::UnsupportedOs {
os: other.to_string(),
})
}
};
let arch = match arch_str {
"x86_64" => RemoteArch::X86_64,
"aarch64" | "arm64" | "armv8l" => RemoteArch::Aarch64,
other => return Err(anyhow!("unsupported arch: {other}")),
"x86_64" | "amd64" => RemoteArch::X86_64,
"aarch64" | "arm64" => RemoteArch::Aarch64,
other => {
return Err(Error::UnsupportedArch {
arch: other.to_string(),
})
}
};
Ok(RemotePlatform { os, arch })
@@ -129,12 +351,29 @@ pub fn remote_server_dir() -> String {
format!("~/{warp_dir}/remote-server")
}
/// Returns a filesystem-safe directory name for a remote-server identity key.
/// Returns a short, deterministic directory name for a remote-server
/// identity key, used for the daemon socket and PID file paths.
///
/// 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.
/// Hashes the key to 8 hex chars so the socket path stays within the
/// `sun_path` limit across all channels.
pub fn remote_server_identity_dir_name(identity_key: &str) -> String {
use std::hash::{Hash, Hasher};
if identity_key.is_empty() {
return "empty".to_string();
}
let mut hasher = std::collections::hash_map::DefaultHasher::new();
identity_key.hash(&mut hasher);
format!("{:016x}", hasher.finish())[..8].to_string()
}
/// Percent-encodes an identity key for use in filesystem paths.
///
/// Keeps ASCII alphanumeric characters plus `-` and `_`; percent-encodes
/// all other bytes. Used by [`remote_server_daemon_data_dir`] for
/// persistent data that must not collide across identities.
fn percent_encode_identity_key(identity_key: &str) -> String {
if identity_key.is_empty() {
return "empty".to_string();
}
@@ -152,7 +391,8 @@ pub fn remote_server_identity_dir_name(identity_key: &str) -> String {
}
/// Returns the identity-scoped remote directory used for the daemon socket
/// and PID file.
/// and PID file. Uses the hashed identity dir name so the full socket
/// path fits within `sun_path`.
pub fn remote_server_daemon_dir(identity_key: &str) -> String {
format!(
"{}/{}",
@@ -161,6 +401,60 @@ pub fn remote_server_daemon_dir(identity_key: &str) -> String {
)
}
/// Returns the identity-scoped remote directory used for daemon-owned
/// per-user data files (e.g. SQLite databases).
///
/// Uses the full percent-encoded identity key (not the hash) so that
/// persistent data is never shared between distinct identities due to
/// a hash collision. The `sun_path` limit does not apply here because
/// this path is only used for regular file I/O, not Unix sockets.
pub fn remote_server_daemon_data_dir(identity_key: &str) -> String {
format!(
"{}/{}/data",
remote_server_dir(),
percent_encode_identity_key(identity_key)
)
}
/// Returns a short, deterministic 8-hex-char hash of the app version string.
///
/// Used to version-discriminate daemon socket and PID files without
/// embedding the full version string in the filename, which would push
/// the Unix domain socket path over the `sun_path` limit (107 bytes on
/// Linux, 103 on macOS) for users with moderately long identity keys or
/// home directory paths.
pub fn version_hash() -> Option<String> {
let version = ChannelState::app_version()?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
version.hash(&mut hasher);
Some(format!("{:016x}", hasher.finish())[..8].to_string())
}
/// Returns the daemon socket filename, versioned with a short hash when
/// a release tag is baked in.
///
/// - With `GIT_RELEASE_TAG`: `server-{hash8}.sock` (e.g. `server-a1b2c3d4.sock`)
/// - Without (plain cargo run): `server.sock`
pub fn daemon_socket_name() -> String {
match version_hash() {
Some(hash) => format!("server-{hash}.sock"),
None => "server.sock".to_string(),
}
}
/// Returns the daemon PID filename, versioned with a short hash when a
/// release tag is baked in.
///
/// - With `GIT_RELEASE_TAG`: `server-{hash8}.pid`
/// - Without (plain cargo run): `server.pid`
pub fn daemon_pid_name() -> String {
match version_hash() {
Some(hash) => format!("server-{hash}.pid"),
None => "server.pid".to_string(),
}
}
/// Returns the binary name, keyed by channel.
///
/// Matches the CLI command names: `oz` (stable), `oz-preview`, `oz-dev`.
@@ -168,16 +462,95 @@ pub fn binary_name() -> &'static str {
ChannelState::channel().cli_command_name()
}
/// Returns the full remote binary path.
/// Returns the full remote binary path for the current channel and client
/// version.
///
/// The path-versioning rule is keyed strictly off [`Channel`]:
///
/// - [`Channel::Local`] and [`Channel::Oss`] always use the bare
/// `{binary_name}` path. For `Local` this is the slot
/// `script/deploy_remote_server` writes to; `Oss` is treated the
/// same way because it has no release-pinned CDN artifact and is
/// expected to be deployed/managed locally.
/// - Every other channel always uses `{binary_name}-{version}`, where
/// `version` is the baked-in `GIT_RELEASE_TAG` when present and falls
/// back to `CARGO_PKG_VERSION` otherwise. The fallback keeps the path
/// deterministic for misconfigured `cargo run --bin {dev,preview,...}`
/// builds; the resulting `&version=...` query is expected to 404 against
/// `/download/cli` and surface a clean `SetupFailed` rather than silently
/// writing to a path that doesn't follow the rule.
pub fn remote_server_binary() -> String {
format!("{}/{}", remote_server_dir(), binary_name())
let dir = remote_server_dir();
let name = binary_name();
match ChannelState::channel() {
Channel::Local | Channel::Oss => format!("{dir}/{name}"),
Channel::Stable | Channel::Preview | Channel::Dev | Channel::Integration => {
format!("{dir}/{name}-{}", pinned_version())
}
}
}
/// Returns the shell command to check if the remote server binary exists and
/// is executable.
/// Returns the shell command to verify the remote server binary is
/// installed and functional by running it with `--version`.
///
/// Exits 0 when the binary is present, executable, and can parse its
/// own arguments. A missing binary produces exit 127 (command not
/// found) or 126 (not executable), and a corrupted binary will fail
/// with a non-zero exit of its own.
pub fn binary_check_command() -> String {
let bin = remote_server_binary();
format!("test -x {bin}")
format!("{} --version", remote_server_binary())
}
/// Returns the shell command to remove the current remote-server binary.
///
/// The global bundled resources directory is deliberately left in place:
/// the next install overwrites it, and an older daemon that is still
/// running parsed its skills at startup.
pub fn remote_server_removal_command() -> String {
format!("rm -f {}", remote_server_binary())
}
/// Returns the version string used to pin remote-server installs on
/// channels that take the versioned path (i.e. everything except
/// [`Channel::Local`] and [`Channel::Oss`]). Prefers the baked-in
/// `GIT_RELEASE_TAG` from [`ChannelState::app_version`]; falls back to
/// `CARGO_PKG_VERSION` so the path / install URL is deterministic even on
/// dev `cargo run` builds without a release tag. The `CARGO_PKG_VERSION`
/// fallback is not expected to map to a real `/download/cli` artifact —
/// it exists to produce a clean install-time failure rather than silently
/// fall through to the unversioned (Local/Oss-only) path.
fn pinned_version() -> &'static str {
ChannelState::app_version().unwrap_or(env!("CARGO_PKG_VERSION"))
}
/// Returns the version key used to identify remote-server download artifacts.
///
/// This must match the versioning used by [`download_tarball_url`] and
/// [`install_script`], so versioned download URLs do not reuse stale tarballs
/// from a previous client version.
pub fn remote_server_artifact_version() -> &'static str {
match ChannelState::channel() {
Channel::Local | Channel::Oss => REMOTE_SERVER_ARTIFACT_VERSION_UNPINNED,
Channel::Stable | Channel::Preview | Channel::Dev | Channel::Integration => {
pinned_version()
}
}
}
/// Name of the global, version-independent resources directory inside
/// [`remote_server_dir`], populated by the install script from the
/// artifact's `resources/` tree (bundled skills, settings schema).
pub const BUNDLED_RESOURCES_DIR_NAME: &str = "bundled_resources";
/// Returns the global, version-independent directory where the install
/// script places the artifact's `resources/` tree. Shell-form path
/// (`~/...`); the daemon expands it against its own home directory.
///
/// Deliberately not version-scoped: the last install wins, and slight
/// version skew between the resources and a running daemon is accepted
/// (the daemon parses its skills once at startup).
pub fn remote_server_bundled_resources_dir() -> String {
format!("{}/{}", remote_server_dir(), BUNDLED_RESOURCES_DIR_NAME)
}
/// The install script template, loaded from a standalone `.sh` file for
@@ -185,20 +558,39 @@ pub fn binary_check_command() -> String {
/// [`install_script`].
const INSTALL_SCRIPT_TEMPLATE: &str = include_str!("install_remote_server.sh");
/// Returns the install script that downloads and installs the CLI binary.
/// Returns the install script that downloads and installs the CLI binary
/// at the current client version.
///
/// The script detects the remote architecture via `uname -m`, downloads the
/// correct Oz CLI tarball from the download URL (with os, arch, package, and
/// channel query params), and extracts it to the install directory.
///
/// All parameters (URL, channel, directory, binary name) are derived
/// internally from the current channel configuration.
pub fn install_script() -> String {
/// The script detects the remote architecture via `uname -m`, downloads
/// the correct Oz CLI tarball from the download URL, and installs it at
/// the path returned by [`remote_server_binary`] so repeat invocations
/// are idempotent. The `version_query` / `version_suffix` substitutions
/// follow the same rule as [`remote_server_binary`]: empty on
/// [`Channel::Local`] and [`Channel::Oss`] (so the install lands at
/// the unversioned path used by `script/deploy_remote_server`); pinned to
/// `&version={v}` / `-{v}` on every other channel, where `v` falls back
/// to `CARGO_PKG_VERSION` when no release tag is baked in.
pub fn install_script(staging_tarball_path: Option<&str>) -> String {
let (vq, version_suffix) = match ChannelState::channel() {
Channel::Local | Channel::Oss => (String::new(), String::new()),
Channel::Stable | Channel::Preview | Channel::Dev | Channel::Integration => {
let v = pinned_version();
(format!("&version={v}"), format!("-{v}"))
}
};
INSTALL_SCRIPT_TEMPLATE
.replace("{download_base_url}", &download_url())
.replace("{channel}", download_channel())
.replace("{install_dir}", &remote_server_dir())
.replace("{binary_name}", binary_name())
.replace("{version_query}", &vq)
.replace("{version_suffix}", &version_suffix)
.replace("{bundled_resources_dir_name}", BUNDLED_RESOURCES_DIR_NAME)
.replace(
"{no_http_client_exit_code}",
&NO_HTTP_CLIENT_EXIT_CODE.to_string(),
)
.replace("{staging_tarball_path}", staging_tarball_path.unwrap_or(""))
}
/// Construct the download URL from the server root URL.
@@ -228,11 +620,47 @@ fn download_channel() -> &'static str {
}
}
/// Returns the version query string for the download URL (e.g.
/// `"&version=v0.2026.01.01"` on release channels, empty on Local/Oss).
fn version_query() -> String {
match ChannelState::channel() {
Channel::Local | Channel::Oss => String::new(),
Channel::Stable | Channel::Preview | Channel::Dev | Channel::Integration => {
format!("&version={}", pinned_version())
}
}
}
/// Returns the full download URL for the remote server tarball,
/// parameterized by the remote platform. Used by the SCP upload
/// fallback to download the same artifact the shell script would fetch.
pub fn download_tarball_url(platform: &RemotePlatform) -> String {
format!(
"{}?package=tar&os={}&arch={}&channel={}{}",
download_url(),
platform.os.as_str(),
platform.arch.as_str(),
download_channel(),
version_query(),
)
}
/// Exit code the install script uses when neither curl nor wget is
/// available on the remote host. The Rust side matches on this to
/// trigger the SCP upload fallback.
pub const NO_HTTP_CLIENT_EXIT_CODE: i32 = 3;
/// Timeout for the binary existence check.
pub const CHECK_TIMEOUT: Duration = Duration::from_secs(10);
/// Timeout for the install script.
pub const INSTALL_TIMEOUT: Duration = Duration::from_secs(60);
/// Timeout for the install script (curl/wget path).
pub const INSTALL_TIMEOUT: Duration = Duration::from_secs(180);
/// Timeout for the SCP upload fallback path (local download + SCP +
/// extraction). Higher than [`INSTALL_TIMEOUT`] because SCP transfers
/// the tarball over the user's SSH link, which is typically slower than
/// the remote host's direct internet connection.
pub const SCP_INSTALL_TIMEOUT: Duration = Duration::from_secs(240);
#[cfg(test)]
#[path = "setup_tests.rs"]
+74
View File
@@ -0,0 +1,74 @@
//! Parsing helpers for the libc family + version reported by the
//! `preinstall_check.sh` script.
//!
//! This is split into its own submodule so the libc-specific logic
//! (version parsing, family classification) can evolve independently
//! from the rest of [`crate::setup`].
use std::fmt;
/// A glibc `(major, minor)` version pair, e.g. `2.31`.
///
/// Wraps a labelled struct rather than a raw `(u32, u32)` so the meaning
/// of each field is obvious at call sites and in event payloads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GlibcVersion {
pub major: u32,
pub minor: u32,
}
impl GlibcVersion {
pub const fn new(major: u32, minor: u32) -> Self {
Self { major, minor }
}
/// Parses a `<major>.<minor>` (or `<major>.<minor>.<patch>`) string.
/// Only the first two segments are consumed; trailing components
/// (e.g. patch versions, distro suffixes) are ignored.
///
/// Returns `None` if either segment is missing or non-numeric.
pub fn parse(value: &str) -> Option<Self> {
let value = value.trim();
let (major, rest) = value.split_once('.')?;
// Allow `2.31`, `2.31.0`, `2.31-foo`, etc.
let minor = rest.split(|c: char| !c.is_ascii_digit()).next()?;
Some(Self {
major: major.parse().ok()?,
minor: minor.parse().ok()?,
})
}
}
impl fmt::Display for GlibcVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}", self.major, self.minor)
}
}
/// Detected libc on the remote host, derived from the `libc_family` /
/// `libc_version` keys emitted by `preinstall_check.sh`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RemoteLibc {
Glibc(GlibcVersion),
NonGlibc { name: String },
Unknown,
}
/// Builds a [`RemoteLibc`] from the raw `libc_family` / `libc_version`
/// values pulled out of the script's `key=value` stdout.
pub(super) fn parse_libc(family: Option<&str>, version: Option<&str>) -> RemoteLibc {
match family {
Some("glibc") => match version.and_then(GlibcVersion::parse) {
Some(v) => RemoteLibc::Glibc(v),
None => RemoteLibc::Unknown,
},
Some(name) if !name.is_empty() && name != "unknown" => RemoteLibc::NonGlibc {
name: name.to_string(),
},
_ => RemoteLibc::Unknown,
}
}
#[cfg(test)]
#[path = "glibc_tests.rs"]
mod tests;
@@ -0,0 +1,69 @@
use super::*;
#[test]
fn parses_major_minor() {
assert_eq!(GlibcVersion::parse("2.31"), Some(GlibcVersion::new(2, 31)));
}
#[test]
fn parses_major_minor_patch() {
assert_eq!(
GlibcVersion::parse("2.35.0"),
Some(GlibcVersion::new(2, 35))
);
}
#[test]
fn parses_distro_suffix() {
// e.g. "2.35-0ubuntu3.4"
assert_eq!(
GlibcVersion::parse("2.35-0ubuntu3.4"),
Some(GlibcVersion::new(2, 35))
);
}
#[test]
fn returns_none_on_garbage() {
assert_eq!(GlibcVersion::parse("garbage"), None);
assert_eq!(GlibcVersion::parse(""), None);
assert_eq!(GlibcVersion::parse("2.x"), None);
}
#[test]
fn parse_libc_glibc() {
assert_eq!(
parse_libc(Some("glibc"), Some("2.31")),
RemoteLibc::Glibc(GlibcVersion::new(2, 31))
);
}
#[test]
fn parse_libc_glibc_unparseable_version() {
assert_eq!(
parse_libc(Some("glibc"), Some("garbage")),
RemoteLibc::Unknown
);
assert_eq!(parse_libc(Some("glibc"), None), RemoteLibc::Unknown);
}
#[test]
fn parse_libc_non_glibc() {
assert_eq!(
parse_libc(Some("musl"), None),
RemoteLibc::NonGlibc {
name: "musl".to_string()
}
);
}
#[test]
fn parse_libc_unknown_family_treated_as_unknown() {
assert_eq!(parse_libc(Some("unknown"), None), RemoteLibc::Unknown);
assert_eq!(parse_libc(None, None), RemoteLibc::Unknown);
assert_eq!(parse_libc(Some(""), None), RemoteLibc::Unknown);
}
#[test]
fn glibc_version_displays_as_dotted() {
assert_eq!(format!("{}", GlibcVersion::new(2, 31)), "2.31");
}
+594 -11
View File
@@ -1,3 +1,11 @@
#[cfg(unix)]
use std::fs;
#[cfg(unix)]
use std::process::Stdio;
#[cfg(unix)]
use command::blocking::Command;
use super::*;
#[test]
@@ -29,10 +37,14 @@ fn parse_uname_darwin_x86_64() {
}
#[test]
fn parse_uname_linux_armv8l() {
let platform = parse_uname_output("Linux armv8l").unwrap();
assert_eq!(platform.os, RemoteOs::Linux);
assert_eq!(platform.arch, RemoteArch::Aarch64);
fn parse_uname_unsupported_armv8l() {
let result = parse_uname_output("Linux armv8l");
match result {
Err(crate::transport::Error::UnsupportedArch { arch }) => {
assert_eq!(arch, "armv8l");
}
other => panic!("expected UnsupportedArch, got {other:?}"),
}
}
#[test]
@@ -53,15 +65,23 @@ fn parse_uname_trims_whitespace() {
#[test]
fn parse_uname_unsupported_os() {
let result = parse_uname_output("Windows x86_64");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unsupported OS"));
match result {
Err(crate::transport::Error::UnsupportedOs { os }) => {
assert_eq!(os, "Windows");
}
other => panic!("expected UnsupportedOs, got {other:?}"),
}
}
#[test]
fn parse_uname_unsupported_arch() {
let result = parse_uname_output("Linux mips");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unsupported arch"));
match result {
Err(crate::transport::Error::UnsupportedArch { arch }) => {
assert_eq!(arch, "mips");
}
other => panic!("expected UnsupportedArch, got {other:?}"),
}
}
#[test]
@@ -76,6 +96,64 @@ fn parse_uname_missing_arch() {
assert!(result.is_err());
}
#[test]
fn identity_dir_name_is_short_hash() {
let name = remote_server_identity_dir_name("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
assert_eq!(name.len(), 8, "identity dir should be 8 hex chars: {name}");
assert!(
name.chars().all(|c| c.is_ascii_hexdigit()),
"identity dir should be hex: {name}"
);
}
#[test]
fn identity_dir_name_is_deterministic() {
let key = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
assert_eq!(
remote_server_identity_dir_name(key),
remote_server_identity_dir_name(key)
);
}
#[test]
fn identity_dir_name_differs_for_different_keys() {
assert_ne!(
remote_server_identity_dir_name("key-a"),
remote_server_identity_dir_name("key-b")
);
}
#[test]
fn data_dir_uses_percent_encoded_identity_key() {
let data_dir = remote_server_daemon_data_dir("user@example.com/ssh host");
assert_eq!(
data_dir,
format!(
"{}/user%40example%2Ecom%2Fssh%20host/data",
remote_server_dir()
)
);
}
#[test]
fn data_dir_handles_empty_identity_key() {
let data_dir = remote_server_daemon_data_dir("");
assert_eq!(data_dir, format!("{}/empty/data", remote_server_dir()));
}
#[test]
fn daemon_dir_and_data_dir_use_different_identity_paths() {
let key = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
let daemon_dir = remote_server_daemon_dir(key);
let data_dir = remote_server_daemon_data_dir(key);
// Daemon dir uses the 8-char hash.
assert!(daemon_dir.contains(&remote_server_identity_dir_name(key)));
// Data dir uses the full key (no collision risk for persistent state).
assert!(data_dir.contains(key));
// They must be different paths.
assert!(!data_dir.starts_with(&daemon_dir));
}
#[test]
fn state_is_ready() {
assert!(RemoteServerSetupState::Ready.is_ready());
@@ -99,10 +177,515 @@ fn state_is_terminal() {
error: "test".into()
}
.is_terminal());
assert!(!RemoteServerSetupState::Checking.is_terminal());
assert!(!RemoteServerSetupState::Installing {
progress_percent: None
assert!(RemoteServerSetupState::Unsupported {
reason: UnsupportedReason::NonGlibc {
name: "musl".into()
}
}
.is_terminal());
assert!(!RemoteServerSetupState::Checking.is_terminal());
assert!(!RemoteServerSetupState::Installing {
progress_percent: None,
}
.is_terminal());
assert!(!RemoteServerSetupState::Updating.is_terminal());
assert!(!RemoteServerSetupState::Initializing.is_terminal());
}
#[test]
fn parse_preinstall_supported_glibc() {
let stdout = "required_glibc=2.31\n\
libc_family=glibc\n\
libc_version=2.35\n\
status=supported\n";
let result = PreinstallCheckResult::parse(stdout);
assert_eq!(result.status, PreinstallStatus::Supported);
assert_eq!(result.libc, RemoteLibc::Glibc(GlibcVersion::new(2, 35)));
assert!(result.is_supported());
}
#[test]
fn parse_preinstall_unsupported_glibc_too_old() {
let stdout = "required_glibc=2.31\n\
libc_family=glibc\n\
libc_version=2.17\n\
status=unsupported\n\
reason=glibc_too_old\n";
let result = PreinstallCheckResult::parse(stdout);
assert_eq!(
result.status,
PreinstallStatus::Unsupported {
reason: UnsupportedReason::GlibcTooOld {
detected: GlibcVersion::new(2, 17),
required: GlibcVersion::new(2, 31),
}
}
);
assert!(!result.is_supported());
}
#[test]
fn parse_preinstall_unsupported_non_glibc() {
let stdout = "required_glibc=2.31\n\
libc_family=musl\n\
status=unsupported\n\
reason=non_glibc\n";
let result = PreinstallCheckResult::parse(stdout);
assert_eq!(
result.status,
PreinstallStatus::Unsupported {
reason: UnsupportedReason::NonGlibc {
name: "musl".to_string()
}
}
);
assert_eq!(
result.libc,
RemoteLibc::NonGlibc {
name: "musl".to_string()
}
);
assert!(!result.is_supported());
}
#[test]
fn bundled_resources_dir_is_global_and_version_independent() {
let dir = remote_server_bundled_resources_dir();
assert_eq!(
dir,
format!("{}/{}", remote_server_dir(), BUNDLED_RESOURCES_DIR_NAME)
);
// The whole point of the global location: no version in the path.
assert!(!dir.contains(remote_server_artifact_version()));
}
#[test]
fn binary_check_runs_version() {
assert_eq!(
binary_check_command(),
format!("{} --version", remote_server_binary())
);
}
#[test]
fn removal_command_removes_binary_but_leaves_global_resources() {
let command = remote_server_removal_command();
assert_eq!(command, format!("rm -f {}", remote_server_binary()));
assert!(!command.contains(BUNDLED_RESOURCES_DIR_NAME));
}
#[test]
fn install_script_substitutes_bundled_resources_dir_name() {
let script = install_script(None);
assert!(!script.contains("{bundled_resources_dir_name}"));
assert!(script.contains(&format!("$install_dir/{BUNDLED_RESOURCES_DIR_NAME}")));
}
#[cfg(unix)]
fn make_test_tarball(
test_root: &std::path::Path,
tarball_name: &str,
skill_content: &str,
include_decoy: bool,
) -> std::path::PathBuf {
let tar_source = test_root.join(format!("tar-source-{tarball_name}"));
let resources = tar_source.join("resources/bundled/skills/test-skill");
let tarball = test_root.join(format!("{tarball_name}.tar.gz"));
fs::create_dir_all(&resources).unwrap();
fs::write(
tar_source.join("oz-test"),
"#!/usr/bin/env bash\n[ \"$1\" = \"--version\" ]\n",
)
.unwrap();
fs::write(resources.join("SKILL.md"), skill_content).unwrap();
if include_decoy {
// Decoy: skills may ship companion files whose names also start
// with `oz`. The installer must not mistake them for the executable.
fs::write(
resources.join("oz-decoy.sh"),
"#!/usr/bin/env bash\nexit 1\n",
)
.unwrap();
}
let tar_output = Command::new("tar")
.arg("-czf")
.arg(&tarball)
.arg("-C")
.arg(&tar_source)
.arg("oz-test")
.arg("resources")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("failed to create test tarball");
assert!(
tar_output.status.success(),
"tar failed: {}",
String::from_utf8_lossy(&tar_output.stderr)
);
tarball
}
#[cfg(unix)]
fn run_install_script(tarball: &std::path::Path, fake_home: &std::path::Path) {
let script = install_script(Some(tarball.to_str().unwrap()));
let install_output = Command::new("bash")
.arg("-c")
.arg(&script)
.env("HOME", fake_home)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("failed to run install script");
assert!(
install_output.status.success(),
"install failed: {}",
String::from_utf8_lossy(&install_output.stderr)
);
}
#[cfg(unix)]
#[test]
fn install_script_installs_binary_and_global_resources() {
let test_root = std::env::temp_dir().join(format!(
"remote-server-global-install-{}",
uuid::Uuid::new_v4()
));
let fake_home = test_root.join("home");
fs::create_dir_all(&fake_home).unwrap();
let tarball = make_test_tarball(&test_root, "first", "test skill", true);
run_install_script(&tarball, &fake_home);
let resolve_remote_path = |path: String| path.replacen('~', fake_home.to_str().unwrap(), 1);
let binary = resolve_remote_path(remote_server_binary());
let resources = resolve_remote_path(remote_server_bundled_resources_dir());
let skill_md = std::path::Path::new(&resources).join("bundled/skills/test-skill/SKILL.md");
assert!(fs::metadata(&binary).unwrap().is_file());
assert!(fs::metadata(&resources).unwrap().is_dir());
assert_eq!(fs::read_to_string(&skill_md).unwrap(), "test skill");
assert!(std::path::Path::new(&resources)
.join("bundled/skills/test-skill/oz-decoy.sh")
.is_file());
let check_output = Command::new("bash")
.arg("-c")
.arg(binary_check_command())
.env("HOME", &fake_home)
.output()
.expect("failed to run binary check command");
assert!(check_output.status.success());
// A later install fully replaces the global resources (last install
// wins): updated content lands and files absent from the new artifact
// disappear, proving a swap rather than a merge.
let second_tarball = make_test_tarball(&test_root, "second", "updated skill", false);
run_install_script(&second_tarball, &fake_home);
assert_eq!(fs::read_to_string(&skill_md).unwrap(), "updated skill");
assert!(!std::path::Path::new(&resources)
.join("bundled/skills/test-skill/oz-decoy.sh")
.exists());
// Removal deletes the binary but leaves the global resources for the
// next install to overwrite.
let removal_output = Command::new("bash")
.arg("-c")
.arg(remote_server_removal_command())
.env("HOME", &fake_home)
.output()
.expect("failed to run removal command");
assert!(removal_output.status.success());
assert!(!std::path::Path::new(&binary).exists());
assert!(fs::metadata(&resources).unwrap().is_dir());
fs::remove_dir_all(test_root).unwrap();
}
#[cfg(unix)]
#[test]
fn install_script_tolerates_tarball_without_resources() {
let test_root = std::env::temp_dir().join(format!(
"remote-server-no-resources-install-{}",
uuid::Uuid::new_v4()
));
let fake_home = test_root.join("home");
let tar_source = test_root.join("tar-source");
let tarball = test_root.join("oz.tar.gz");
fs::create_dir_all(&fake_home).unwrap();
fs::create_dir_all(&tar_source).unwrap();
fs::write(
tar_source.join("oz-test"),
"#!/usr/bin/env bash\n[ \"$1\" = \"--version\" ]\n",
)
.unwrap();
let tar_output = Command::new("tar")
.arg("-czf")
.arg(&tarball)
.arg("-C")
.arg(&tar_source)
.arg("oz-test")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("failed to create test tarball");
assert!(tar_output.status.success());
run_install_script(&tarball, &fake_home);
let resolve_remote_path = |path: String| path.replacen('~', fake_home.to_str().unwrap(), 1);
let binary = resolve_remote_path(remote_server_binary());
let resources = resolve_remote_path(remote_server_bundled_resources_dir());
assert!(fs::metadata(&binary).unwrap().is_file());
assert!(!std::path::Path::new(&resources).exists());
fs::remove_dir_all(test_root).unwrap();
}
/// Regression: the install script's tilde-expansion logic must work
/// across the bash versions we actually invoke at install time
/// (`run_ssh_script` pipes the script into `bash -s` on the remote).
/// Two interpreter quirks have to be avoided simultaneously:
///
/// 1. bash 3.2 (macOS `/bin/bash`) keeps inner double-quotes around
/// the replacement of `${var/pattern/replacement}` literal, so
/// `"$HOME"` ends up as 6 literal characters and the install
/// lands under a directory tree literally named `"`.
/// 2. bash 5.2+ with `patsub_replacement` (default-on) treats `&`
/// in the replacement as the matched pattern, so a `$HOME`
/// containing `&` resolves to a `~`-substituted path.
///
/// Both bugs surface as the install binary landing somewhere Warp's
/// launch step doesn't look, producing a misleading "Response channel
/// closed before receiving a reply".
///
/// This test drives the *actual* production script (via
/// [`install_script`]) rather than a hand-copied snippet, and runs it
/// against several `HOME` values to exercise the patsub-`&` trap as
/// well as the quote-literal trap. We truncate just before `mkdir -p`
/// so no filesystem side effects leak out of the test, and append a
/// marker `printf` to capture the resolved `install_dir`.
///
/// Gated to Unix because the test invokes `/bin/bash` (or `bash` from
/// PATH) directly. The bug only matters on Unix remotes anyway —
/// Warp's remote-server SSH wrapper doesn't target Windows hosts.
#[cfg(unix)]
#[test]
fn install_script_tilde_expansion_resolves_correctly() {
let bash = if std::path::Path::new("/bin/bash").exists() {
"/bin/bash"
} else {
"bash"
};
let script = install_script(None);
let cutoff = script.find("mkdir -p \"$install_dir\"").expect(
"install script no longer contains the `mkdir -p \"$install_dir\"` \
checkpoint this test relies on; update the test alongside the \
script change",
);
let probe = format!(
"{prefix}\nprintf '%s' \"$install_dir\"\nexit 0\n",
prefix = &script[..cutoff],
);
// Run the probe against a matrix of HOME values. The first is an
// ordinary path; the second contains `&`, which exercises bash
// 5.2's patsub_replacement (where it would otherwise expand to
// the matched `~`).
let cases = [
("/Users/test", "ordinary HOME"),
(
"/Users/A&B",
"HOME with `&` (bash 5.2 patsub_replacement trap)",
),
];
for (fake_home, label) in cases {
let output = Command::new(bash)
.arg("-c")
.arg(&probe)
.env("HOME", fake_home)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("failed to spawn bash");
assert!(
output.status.success(),
"[{label}] bash exited with {:?}: stderr={}",
output.status,
String::from_utf8_lossy(&output.stderr),
);
let install_dir = String::from_utf8_lossy(&output.stdout);
assert!(
!install_dir.contains('"'),
"[{label}] install_dir contains literal quote characters \
(bash 3.2 quote-literal regression): {install_dir:?}",
);
// Cross-check against the production layout: tilde must
// resolve to HOME, so the result equals `remote_server_dir()`
// with the leading `~` replaced.
let expected = remote_server_dir().replacen('~', fake_home, 1);
assert_eq!(
install_dir, expected,
"[{label}] install_dir resolved incorrectly",
);
}
}
/// Regression: guards against re-introducing the
/// `${var/pattern/replacement}` tilde-substitution form, which has two
/// known interpreter bugs (see
/// [`install_script_tilde_expansion_resolves_correctly`] for details).
/// Complements the live bash test — the live test catches behavioural
/// regressions, this static check fails fast and explains *why* in
/// the assertion message so a future contributor doesn't have to
/// re-discover the constraints from a CI failure.
#[test]
fn install_script_avoids_pattern_substitution_for_tilde_expansion() {
let template = INSTALL_SCRIPT_TEMPLATE;
assert!(
!template.contains(r"/#\~/"),
"install_remote_server.sh uses `${{var/#\\~/...}}` for tilde \
expansion. This form has two known interpreter bugs that \
silently mis-resolve the install path:\n\
\n\
1. bash 3.2 (macOS /bin/bash) keeps inner double-quotes \
around the replacement literal, so `\"$HOME\"` ends up \
as 6 literal characters including the quotes.\n\
2. bash 5.2+ enables `patsub_replacement` by default, which \
makes `&` in the replacement expand to the matched \
pattern, so a `$HOME` containing `&` resolves wrong.\n\
\n\
Use `case`/`${{var#\\~}}` instead — see install_remote_server.sh \
for the pattern.",
);
}
#[test]
fn version_hash_is_deterministic() {
// version_hash uses the compile-time GIT_RELEASE_TAG which is typically
// unset in test builds, so it returns None. We test the hashing logic
// directly instead.
use std::hash::{Hash, Hasher};
let version = "v0.2026.05.13.09.15.stable_01";
let hash = |v: &str| -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
v.hash(&mut hasher);
format!("{:016x}", hasher.finish())[..8].to_string()
};
// Same input produces the same hash.
assert_eq!(hash(version), hash(version));
// Different inputs produce different hashes.
assert_ne!(hash(version), hash("v0.2026.05.14.09.15.stable_01"));
// Hash is exactly 8 hex chars.
assert_eq!(hash(version).len(), 8);
assert!(hash(version).chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn daemon_socket_name_is_short() {
// Without GIT_RELEASE_TAG (typical in tests), falls back to unversioned.
let name = daemon_socket_name();
// In test builds without GIT_RELEASE_TAG, we get "server.sock".
// In release builds, we get "server-{8hex}.sock" = 24 chars.
// Either way, the name must be ≤ 24 chars.
assert!(
name.len() <= 24,
"daemon_socket_name is too long ({} chars): {name}",
name.len()
);
assert!(name.starts_with("server"));
assert!(name.ends_with(".sock"));
}
#[test]
fn daemon_pid_name_is_short() {
let name = daemon_pid_name();
assert!(
name.len() <= 22,
"daemon_pid_name is too long ({} chars): {name}",
name.len()
);
assert!(name.starts_with("server"));
assert!(name.ends_with(".pid"));
}
#[test]
fn socket_path_fits_within_sun_path_worst_case() {
// Worst case: preview channel (longest base dir) + 32-char username
// (Linux max) + hashed identity (8 chars) + hashed socket (20 chars).
//
// Path: /home/{user}/.warp-preview/remote-server/{hash8}/server-{hash8}.sock
// 6 + 32 + 1 + 29 + 8 + 1 + 20 = 97 bytes → well under 103 (macOS)
let long_home = "/home/a]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]";
let identity_dir = remote_server_identity_dir_name("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
assert_eq!(identity_dir.len(), 8);
let hashed_socket = "server-a1b2c3d4.sock";
let old_socket = "server-v0.2026.05.13.09.15.stable_01.sock";
// Use .warp-preview (longest channel base dir) for worst case.
let daemon_dir = format!("{long_home}/.warp-preview/remote-server/{identity_dir}");
let hashed_path = format!("{daemon_dir}/{hashed_socket}");
// Must fit within macOS sun_path limit (103 bytes), the stricter of
// the two platforms.
assert!(
hashed_path.len() <= 103,
"hashed socket path exceeds macOS sun_path limit: {} bytes ({})",
hashed_path.len(),
hashed_path,
);
// The OLD naming scheme (full version + unhashed identity) should
// exceed the limit, confirming the regression.
let old_identity = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; // 36 chars unhashed
let old_daemon_dir = format!("{long_home}/.warp-preview/remote-server/{old_identity}");
let old_full_path = format!("{old_daemon_dir}/{old_socket}");
assert!(
old_full_path.len() > 107,
"old socket path should exceed Linux sun_path limit to confirm the \
regression: {} bytes ({})",
old_full_path.len(),
old_full_path,
);
}
#[test]
fn parse_uname_linux_amd64() {
let platform = parse_uname_output("Linux amd64").unwrap();
assert_eq!(platform.os, RemoteOs::Linux);
assert_eq!(platform.arch, RemoteArch::X86_64);
}
#[test]
fn parse_uname_unsupported_armv7l() {
let result = parse_uname_output("Linux armv7l");
match result {
Err(crate::transport::Error::UnsupportedArch { arch }) => {
assert_eq!(arch, "armv7l");
}
other => panic!("expected UnsupportedArch, got {other:?}"),
}
}
#[test]
fn parse_preinstall_missing_status_falls_open() {
// Garbled / partial script output — missing status field. Confirms
// the fail-open invariant: anything we can't positively classify as
// unsupported degrades to Unknown and is treated as supported, so a
// flaky probe doesn't block the install.
let stdout = "libc_family=glibc\nlibc_version=2.35\n";
let result = PreinstallCheckResult::parse(stdout);
assert_eq!(result.status, PreinstallStatus::Unknown);
assert!(result.is_supported());
}
+106 -14
View File
@@ -2,9 +2,31 @@ use std::path::Path;
use std::process::Output;
use std::time::Duration;
use anyhow::{anyhow, Result};
use anyhow::anyhow;
use command::r#async::Command;
use galaxyui::r#async::FutureExt as _;
use galaxyui_core::r#async::FutureExt as _;
use crate::transport::ControlPath;
/// Transport-level error from [`run_ssh_command`] or [`run_ssh_script`].
///
/// Distinguishes timeouts from other I/O failures so callers can promote
/// timeouts to a per-method `TimedOut` variant on the trait error types.
#[derive(Debug, thiserror::Error)]
pub enum SshCommandError {
/// The SSH command or script did not complete within the timeout.
#[error("Timed out after {timeout:?}")]
TimedOut { timeout: Duration },
/// The `ssh` process could not be spawned.
#[error("Failed to spawn ssh: {0}")]
SpawnFailed(std::io::Error),
/// Writing to the SSH process's stdin failed.
#[error("Failed to write to ssh stdin: {0}")]
StdinWriteFailed(std::io::Error),
/// The SSH process was spawned but `output()` returned an I/O error.
#[error("SSH I/O error: {0}")]
IoError(std::io::Error),
}
/// Timeout for `ssh -O exit`. The command only talks to the local
/// ControlMaster over a Unix socket, so it should return almost
@@ -28,7 +50,7 @@ pub fn ssh_args(socket_path: &Path) -> Vec<String> {
}
/// Runs `ssh -O exit -o ControlPath=<socket_path>` to force the local
/// SSH `ControlMaster` managing `socket_path` to exit immediately,
/// SSH `ControlMaster` behind `control_path` to exit immediately,
/// without waiting for multiplexed channels to finish draining.
///
/// The user's interactive ssh is spawned with `-o ControlMaster=yes` by
@@ -38,13 +60,30 @@ pub fn ssh_args(socket_path: &Path) -> Vec<String> {
/// `ssh ... remote-server-proxy`) to finish cleanup on the remote
/// side. Sending `-O exit` bypasses that wait.
///
/// Only [`ControlPath::WarpManaged`] masters are acted on: a
/// [`ControlPath::UserOwned`] master (the SSH wrapper attached to a
/// master the user already had running) is left untouched, and
/// [`ControlPath::None`] is a no-op.
///
/// **Only safe to call once the user's shell has already exited** --
/// this tears down the interactive ssh outright. In practice it is
/// invoked from the `ExitShell` teardown path on the client.
/// for Warp-managed masters this tears down the interactive ssh
/// outright. In practice it is invoked from the `ExitShell` teardown
/// path on the client.
///
/// Fire-and-forget. Errors are logged but not propagated: at teardown
/// time there is nothing useful to do with them.
pub async fn stop_control_master(socket_path: &Path) {
pub async fn stop_control_master(control_path: &ControlPath) {
let socket_path = match control_path {
ControlPath::WarpManaged(socket_path) => socket_path,
ControlPath::UserOwned(socket_path) => {
log::info!(
"stop_control_master: leaving user-owned ControlMaster at {} running",
socket_path.display()
);
return;
}
ControlPath::None => return,
};
let args = ssh_args(socket_path);
let result = async {
Command::new("ssh")
@@ -96,7 +135,7 @@ pub async fn run_ssh_command(
socket_path: &Path,
remote_command: &str,
timeout: Duration,
) -> Result<Output> {
) -> Result<Output, SshCommandError> {
async {
Command::new("ssh")
.args(ssh_args(socket_path))
@@ -107,8 +146,8 @@ pub async fn run_ssh_command(
}
.with_timeout(timeout)
.await
.map_err(|_| anyhow!("SSH command timed out after {timeout:?}"))?
.map_err(|e| anyhow!("SSH command failed to execute: {e}"))
.map_err(|_| SshCommandError::TimedOut { timeout })?
.map_err(SshCommandError::IoError)
}
/// Pipe a script into `bash -s` on the remote host via the ControlMaster
@@ -122,7 +161,11 @@ pub async fn run_ssh_command(
/// that would require complex, fragile escaping if passed as an argument.
/// The `bash -s` + stdin approach avoids all escaping issues and has no
/// argument length limits.
pub async fn run_ssh_script(socket_path: &Path, script: &str, timeout: Duration) -> Result<Output> {
pub async fn run_ssh_script(
socket_path: &Path,
script: &str,
timeout: Duration,
) -> Result<Output, SshCommandError> {
use std::process::Stdio;
let mut child = Command::new("ssh")
@@ -133,7 +176,7 @@ pub async fn run_ssh_script(socket_path: &Path, script: &str, timeout: Duration)
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| anyhow!("Failed to spawn SSH for script: {e}"))?;
.map_err(SshCommandError::SpawnFailed)?;
// Write the script to stdin.
if let Some(mut stdin) = child.stdin.take() {
@@ -141,7 +184,7 @@ pub async fn run_ssh_script(socket_path: &Path, script: &str, timeout: Duration)
stdin
.write_all(script.as_bytes())
.await
.map_err(|e| anyhow!("Failed to write script to stdin: {e}"))?;
.map_err(SshCommandError::StdinWriteFailed)?;
// Close stdin so the remote bash exits after reading the script.
drop(stdin);
}
@@ -150,6 +193,55 @@ pub async fn run_ssh_script(socket_path: &Path, script: &str, timeout: Duration)
.output()
.with_timeout(timeout)
.await
.map_err(|_| anyhow!("Script timed out after {timeout:?}"))?
.map_err(|e| anyhow!("Script failed: {e}"))
.map_err(|_| SshCommandError::TimedOut { timeout })?
.map_err(SshCommandError::IoError)
}
impl From<SshCommandError> for crate::transport::Error {
fn from(err: SshCommandError) -> Self {
match err {
SshCommandError::TimedOut { .. } => Self::TimedOut,
other => Self::Other(other.into()),
}
}
}
/// Upload a local file to the remote host via `scp`, reusing the
/// ControlMaster socket for authentication. Returns `Ok(())` on success
/// or an error describing the failure.
pub async fn scp_upload(
socket_path: &Path,
local_path: &Path,
remote_path: &str,
timeout: Duration,
) -> anyhow::Result<()> {
let output = async {
Command::new("scp")
.arg("-o")
.arg(format!("ControlPath={}", socket_path.display()))
.arg("-o")
.arg("ControlMaster=no")
.arg("-o")
.arg("ConnectTimeout=15")
.arg(local_path.as_os_str())
.arg(format!("placeholder@placeholder:{remote_path}"))
.kill_on_drop(true)
.output()
.await
}
.with_timeout(timeout)
.await
.map_err(|_| anyhow!("scp timed out after {timeout:?}"))?
.map_err(|e| anyhow!("scp failed to execute: {e}"))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(anyhow!(
"scp failed (exit {:?}): {}",
output.status.code(),
stderr
))
}
}
+218 -23
View File
@@ -10,15 +10,152 @@
//! `Arc<dyn RemoteTransport>` for reconnection.
//!
//! [`RemoteServerManager`]: crate::manager::RemoteServerManager
use std::future::Future;
#[cfg(not(target_family = "wasm"))]
use std::path::PathBuf;
use std::pin::Pin;
use async_channel::Receiver;
use galaxyui::r#async::executor;
use serde::Serialize;
use galaxyui_core::r#async::executor;
#[cfg(not(target_family = "wasm"))]
use crate::client::RemoteServerLog;
use crate::client::{ClientEvent, RemoteServerClient};
use crate::setup::RemotePlatform;
use crate::manager::RemoteServerExitStatus;
use crate::setup::{PreinstallCheckResult, RemotePlatform};
/// How the remote server binary was installed. Used for telemetry to
/// distinguish direct remote downloads from client-side SCP uploads.
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallSource {
/// The remote host downloaded the binary directly from the CDN.
Server,
/// The client downloaded the binary locally and uploaded it via SCP.
Client,
}
/// Result of [`RemoteTransport::install_binary`], bundling the install
/// result with the source that was attempted. The source is always set
/// once the install path is determined, regardless of whether the
/// install succeeded or failed.
pub struct InstallOutcome {
/// Which install path was attempted.
pub source: Option<InstallSource>,
/// Whether the install succeeded.
pub result: Result<(), Error>,
}
/// Structured error for user-facing display in the SSH remote-server
/// failed banner. Separates the always-visible body from an optional set of
/// details.
#[derive(Clone, Debug)]
pub struct UserFacingError {
/// Always-visible explanation of what went wrong,
/// e.g. "Failed to install SSH extension".
pub body: String,
/// Optional technical detail shown to the user (stderr,
/// timeout duration, unsupported OS/arch). `None` when the
/// underlying error doesn't carry anything useful for the user.
pub detail: Option<String>,
}
/// The setup stage that failed, used to generate context-appropriate
/// user-facing messages from a [`Error`].
#[derive(Clone, Copy, Debug)]
pub enum SetupStage {
DetectPlatform,
PreinstallCheck,
CheckBinary,
InstallBinary,
Launch,
}
impl SetupStage {
fn action_description(self) -> &'static str {
match self {
Self::DetectPlatform => "detect remote platform",
Self::PreinstallCheck => "run preinstall check",
Self::CheckBinary => "verify SSH extension",
Self::InstallBinary => "install SSH extension",
Self::Launch => "start SSH extension",
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The operation timed out.
#[error("timed out")]
TimedOut,
/// The remote host reported an OS not supported by the prebuilt binary.
#[error("unsupported OS: {os}")]
UnsupportedOs { os: String },
/// The remote host reported a CPU architecture not supported by the prebuilt binary.
#[error("unsupported architecture: {arch}")]
UnsupportedArch { arch: String },
/// A remote script ran but exited with a non-zero code.
#[error("script failed (exit {exit_code}): {stderr}")]
ScriptFailed { exit_code: i32, stderr: String },
/// Any other transport-level or unexpected failure.
#[error(transparent)]
Other(anyhow::Error),
}
/// Maximum number of stderr characters to include in the user-facing
/// detail for `ScriptFailed` errors. Keeps the banner reasonable even
/// when a remote script dumps a large amount of output.
const MAX_STDERR_DISPLAY_CHARS: usize = 512;
impl Error {
/// Converts this error into a [`UserFacingError`] suitable for the
/// SSH remote-server failed banner, using `stage` to provide
/// context-appropriate copy.
pub fn user_facing_error(&self, stage: SetupStage) -> UserFacingError {
let body = format!("Failed to {}", stage.action_description());
let detail = match self {
Self::TimedOut => {
Some("The operation timed out — check your network connection".into())
}
Self::UnsupportedOs { os } => Some(format!("Unsupported OS: {os}")),
Self::UnsupportedArch { arch } => Some(format!("Unsupported architecture: {arch}")),
Self::ScriptFailed { exit_code, stderr } => {
let truncated = if stderr.chars().count() > MAX_STDERR_DISPLAY_CHARS {
let end: usize = stderr
.char_indices()
.nth(MAX_STDERR_DISPLAY_CHARS)
.map(|(i, _)| i)
.unwrap_or(stderr.len());
format!("{}", &stderr[..end])
} else {
stderr.clone()
};
Some(format!("Script exited with code {exit_code}: {truncated}"))
}
Self::Other(_) => None,
};
UserFacingError { body, detail }
}
}
/// The SSH `ControlMaster` socket (if any) behind a connection, tagged
/// with who owns the master process. Ownership decides teardown
/// behavior: only `WarpManaged` masters are stopped with `ssh -O exit`
/// on explicit teardown (see [`crate::ssh::stop_control_master`]);
/// `UserOwned` masters must be left running.
#[cfg(not(target_family = "wasm"))]
#[derive(Debug, Clone)]
pub enum ControlPath {
/// Warp created the ControlMaster at this socket path and is
/// responsible for tearing it down on session exit.
WarpManaged(PathBuf),
/// The SSH wrapper attached to a ControlMaster the user already had
/// running at this socket path. Warp must never tear it down.
UserOwned(PathBuf),
/// No ControlMaster socket (e.g. in-process test transports).
None,
}
/// A successful return from [`RemoteTransport::connect`].
///
@@ -35,6 +172,14 @@ use crate::setup::RemotePlatform;
pub struct Connection {
pub client: RemoteServerClient,
pub event_rx: Receiver<ClientEvent>,
/// Receiver for request-failure telemetry events. Separate from
/// `event_rx` so the failure sender on the client doesn't keep the
/// lifecycle event channel alive.
pub failure_rx: async_channel::Receiver<crate::client::RequestFailedEvent>,
/// Receiver for host-scoped responses whose `request_id` was not in
/// this client's `pending_requests`. The manager drains this to match
/// against its `pending_host_requests`.
pub host_response_rx: async_channel::Receiver<crate::proto::ServerMessage>,
/// The subprocess whose stdio backs the client (e.g.
/// `ssh … remote-server-proxy`). Spawned with `kill_on_drop(true)`
/// by the transport, so dropping this `Child` sends SIGKILL to the
@@ -45,15 +190,16 @@ pub struct Connection {
#[cfg(not(target_family = "wasm"))]
pub child: async_process::Child,
/// For transports that multiplex through a local SSH
/// `ControlMaster` socket: the path to that socket, used on
/// explicit teardown (after the user's shell exits) to run
/// `ssh -O exit` and force the master to terminate without
/// waiting for half-closed channels. `None` for transports with
/// no separate master process (in-process tests, etc.).
///
/// See [`crate::ssh::stop_control_master`] for the exact command.
/// `ControlMaster` socket: the socket path tagged with master
/// ownership, which decides whether explicit teardown (after the
/// user's shell exits) runs `ssh -O exit` against it. See
/// [`ControlPath`].
#[cfg(not(target_family = "wasm"))]
pub control_path: Option<PathBuf>,
pub control_path: ControlPath,
/// Tail buffer of the last N stderr lines from the SSH subprocess.
/// Drained on connection failure and attached to telemetry.
#[cfg(not(target_family = "wasm"))]
pub stderr_tail: RemoteServerLog,
}
/// Transport abstraction for remote server connections.
@@ -63,11 +209,30 @@ pub struct Connection {
pub trait RemoteTransport: Send + Sync + std::fmt::Debug {
/// Detects the remote host's OS and architecture by running `uname -sm`.
///
/// Returns the parsed [`RemotePlatform`] on success, or an error string
/// if the command fails or the output cannot be parsed.
/// Returns the parsed [`RemotePlatform`] on success, or a
/// [`Error`] if the command fails or the output cannot
/// be parsed.
fn detect_platform(
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<RemotePlatform, String>> + Send>>;
) -> Pin<Box<dyn Future<Output = Result<RemotePlatform, Error>> + Send>>;
/// Runs the preinstall check script ([`crate::setup::PREINSTALL_CHECK_SCRIPT`])
/// over the existing connection and parses its structured stdout into
/// a [`PreinstallCheckResult`].
///
/// This runs **before** any user-visible install affordance (the
/// install choice block, auto-install, auto-update, or connect) and
/// is the gate that decides whether to proceed with the install
/// pipeline or fall back to the wrapper-only SSH flow.
///
/// Returns `Ok(_)` on success (including when the script reported
/// `Unknown` — that's a parser-level outcome, not a transport-level
/// failure). Returns `Err(_)` only on transport-level failure (timeout,
/// broken pipe, non-zero exit with no parseable summary), which the
/// caller treats as inconclusive (fail open).
fn run_preinstall_check(
&self,
) -> Pin<Box<dyn Future<Output = Result<PreinstallCheckResult, Error>> + Send>>;
/// Checks whether the remote server binary is present on the remote host.
///
@@ -77,10 +242,19 @@ pub trait RemoteTransport: Send + Sync + std::fmt::Debug {
///
/// Returns `Ok(true)` if the binary is installed and executable,
/// `Ok(false)` if it is definitively not installed, and
/// `Err(_)` if the check failed (e.g. SSH timeout/unreachable).
fn check_binary(
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<bool, String>> + Send>>;
/// `Err(_)` if the check failed (e.g. timeout or unreachable).
fn check_binary(&self) -> Pin<Box<dyn Future<Output = Result<bool, Error>> + Send>>;
/// Checks whether the remote host already has an existing install
/// of the remote server binary.
///
/// Used by the manager to distinguish a fresh install (no prior
/// install on disk, user should be prompted) from an update (prior
/// install present, install should happen automatically).
///
/// Returns `Ok(true)` if a prior install was detected, `Ok(false)`
/// if not, and `Err(_)` on SSH failure.
fn check_has_old_binary(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<bool>> + Send>>;
/// Installs the remote server binary on the remote host.
///
@@ -88,11 +262,9 @@ pub trait RemoteTransport: Send + Sync + std::fmt::Debug {
/// ([`RemoteServerManager::install_binary`]) is responsible for emitting
/// [`SetupStateChanged`] and [`BinaryInstallComplete`].
///
/// Returns `Ok(())` if the install succeeded, and
/// `Err(_)` if the install failed (e.g. SSH timeout, script error).
fn install_binary(
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), String>> + Send>>;
/// Returns an [`InstallOutcome`] containing the install result and
/// the [`InstallSource`] that was attempted (if known).
fn install_binary(&self) -> Pin<Box<dyn Future<Output = InstallOutcome> + Send>>;
/// Establish a new connection to the remote server.
///
@@ -107,5 +279,28 @@ pub trait RemoteTransport: Send + Sync + std::fmt::Debug {
fn connect(
&self,
executor: std::sync::Arc<executor::Background>,
) -> Pin<Box<dyn std::future::Future<Output = anyhow::Result<Connection>> + Send>>;
) -> Pin<Box<dyn Future<Output = anyhow::Result<Connection>> + Send>>;
/// Remove the remote server binary, forcing a reinstall on the next
/// [`install_binary`] call.
///
/// Called by the manager after the initialize handshake reports a
/// version that disagrees with the client's: the file at the expected
/// path is stale/wrong, so we remove it so the next setup sees a miss
/// and reinstalls from the CDN instead of looping on the same bad
/// binary.
///
/// [`install_binary`]: RemoteTransport::install_binary
fn remove_remote_server_binary(
&self,
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>>;
/// Returns `true` if the transport considers a reconnect viable after
/// a spontaneous disconnect with the given exit status.
///
/// Transports that can determine the underlying connection is
/// unrecoverable (e.g. SSH detecting a dead ControlMaster via exit
/// code 255) should return `false`, which tells the manager to skip
/// the reconnect loop entirely.
fn is_reconnectable(&self, exit_status: Option<&RemoteServerExitStatus>) -> bool;
}