first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) -> ¬ification::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);
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2955
-217
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user