feat: support local→cloud handoff snapshot in remote SSH sessions#11453
feat: support local→cloud handoff snapshot in remote SSH sessions#11453kevinyang372 wants to merge 7 commits into
Conversation
Add UploadHandoffSnapshot proto message and daemon handler that gathers git patches from the remote host's local filesystem and uploads them directly to GCS via ServerApiProvider.
…pshot Detect remote SSH sessions in workspace/view.rs and delegate snapshot gathering to the remote server daemon via UploadHandoffSnapshot RPC. Falls back to SkippedEmptyWorkspace when daemon is unavailable.
Resolves conflicts in server_model.rs: takes daemon's real handler over client's placeholder, combines import lists from both sides.
Add UploadHandoffSnapshot proto message and daemon handler that gathers git patches from the remote host's local filesystem and uploads them directly to GCS via ServerApiProvider. The client detects SSH sessions and delegates snapshot work to the daemon, avoiding piping large diffs over the SSH tunnel. - Proto: UploadHandoffSnapshot/UploadHandoffSnapshotResponse (field 26) - Daemon: handoff_snapshot.rs module + ServerModel handler - Client: RemoteServerClient method + session detection in workspace/view.rs - Shared settle_handoff_snapshot_result helper for both local/remote paths - Use Vec<String> instead of Vec<PathBuf> for cross-platform path safety - Set empty TouchedWorkspace for remote path to unblock auto-submit gate Co-Authored-By: Warp <agent@warp.dev>
This stack of pull requests is managed by Graphite. Learn more about stacking. |
|
I'm starting a first review of this pull request. You can view the conversation on Warp. I completed the review and no human review was requested for this pull request. Comment Powered by Oz |
There was a problem hiding this comment.
Overview
This PR adds a remote-server RPC so local→cloud handoff from SSH sessions can gather and upload snapshots on the remote host. No approved spec context was available, and I did not find security-specific findings in the changed diff.
Concerns
- The no-client fallback settles only the snapshot upload status, leaving
touched_workspaceunset, so remote handoff can remain stuck instead of submitting without a snapshot. - Remote forked-conversation paths are still converted through
PathBufon the client before being sent to the daemon, which breaks POSIX remote paths from Windows clients and can omit touched files outside the current pwd. - This is a user-facing behavior change, but the PR does not include screenshots or a screen recording demonstrating the remote handoff working end to end. Please attach visual evidence for this change.
Verdict
Found: 0 critical, 3 important, 0 suggestions
Request changes
Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).
Powered by Oz
| UploadHandoffSnapshotResponse { | ||
| initial_snapshot_token: None, | ||
| success: false, | ||
| error: Some(format!("{e:#}")), |
There was a problem hiding this comment.
Do we need any typed errors over the wire? Is this an error we need to deserialize?
There was a problem hiding this comment.
I don't think we need typed errors here. There is no actionable errors from the client's side that would require a different UX depending on the error type
| #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] | ||
| fn spawn_handoff_snapshot_upload( | ||
| paths: Vec<PathBuf>, | ||
| paths: Vec<String>, |
There was a problem hiding this comment.
can we do the path --> string conversion deeper in the stack? for the actual API, path is definitely right
There was a problem hiding this comment.
We cannot do path to string conversion deeper. This is also used for remote sessions which means we cannot assume local OS encoding
| let mapped = match result { | ||
| Ok(resp) if resp.success => { | ||
| if let Some(token) = resp.initial_snapshot_token { | ||
| let token: InitialSnapshotToken = | ||
| serde_json::from_value(serde_json::Value::String(token)) | ||
| .expect("InitialSnapshotToken is a String newtype"); | ||
| Ok(Some(token)) | ||
| } else { | ||
| Ok(None) | ||
| } | ||
| } | ||
| Ok(resp) => { | ||
| let error_msg = resp.error.unwrap_or_default(); | ||
| Err(anyhow::anyhow!( | ||
| "Remote handoff snapshot failed: {error_msg}" | ||
| )) | ||
| } | ||
| Err(err) => { | ||
| Err(anyhow::anyhow!(err) | ||
| .context("Remote handoff snapshot RPC failed")) | ||
| } | ||
| }; |
There was a problem hiding this comment.
Not blocking but I feel like we need a cleaner boundary between the remote code server and the actual functions that wrap it
In this case, we call some async function that directly returns raw remote code types in the workspace.
In reality, I think we want a module (or set of modules) that do the translation for us so we actually operate on more abstract types instead of leaking remote code proto types all over the codebase
There was a problem hiding this comment.
I am not sure if I fully understand the value of that thin translation layer tho. My mental model of the current contract is the proto definition is the schema for the request / response type a caller should be expect by calling the method. And they should be able to read out of it to populate their internal states
| let (paths, remote_session_id) = if is_remote { | ||
| let remote_pwd = source_view.as_ref(ctx).pwd(); | ||
| let session_id = source_view.as_ref(ctx).active_block_session_id(); | ||
| // For remote sessions, conversation paths are remote strings; | ||
| // use String instead of PathBuf to avoid local OS encoding. | ||
| let mut p: Vec<String> = extract_paths_from_conversation(&source_conversation) | ||
| .into_iter() | ||
| .map(|pb| pb.to_string_lossy().into_owned()) | ||
| .collect(); | ||
| if let Some(ref pwd) = remote_pwd { | ||
| p.push(pwd.clone()); | ||
| } | ||
| (p, session_id) | ||
| } else { | ||
| let source_pwd = source_view.as_ref(ctx).active_session_path_if_local(ctx); | ||
| // Derive touched repos and upload the initial snapshot off the UI thread. | ||
| // The paths list is built from the conversation's write actions plus the | ||
| // source pane's pwd (so the current repo is always captured). | ||
| let mut p: Vec<String> = extract_paths_from_conversation(&source_conversation) | ||
| .into_iter() | ||
| .map(|pb| pb.to_string_lossy().into_owned()) | ||
| .collect(); | ||
| if let Some(pwd) = source_pwd { | ||
| p.push(pwd); | ||
| p.push(pwd.to_string_lossy().into_owned()); | ||
| } | ||
| p | ||
| (p, None) |
There was a problem hiding this comment.
feels like this could be fully consolidated by
- adding a new active_session_path function that returns
RemoteOrLocalPath - keeping the paths as
PathBufs (if we can) - Call into
spawn_handoff_snapshot_uploadwith session ID and the paths

Description
Local→cloud handoff was completely broken for remote SSH sessions. The snapshot pipeline assumed local filesystem access, causing every step (path resolution, git diff, file reads) to fail silently — the cloud agent always started with no snapshot context.
Architecture
The remote server daemon already has
ServerApiProvider(HTTP client + auth + warp-server URL) and can run git commands locally on the remote host. Instead of piping bytes back through SSH, the daemon gathers patches and uploads directly to GCS, returning only theInitialSnapshotTokenstring.Changes
Proto (
remote_server.proto):UploadHandoffSnapshot/UploadHandoffSnapshotResponsemessages (field 26)Daemon (
handoff_snapshot.rs,server_model.rs):derive_touched_workspace+upload_snapshot_for_handofflocally on the remote hostServerApiProvider::get_ai_client()andget_http_client()for direct GCS uploadClient (
workspace/view.rs,client/mod.rs,manager.rs):active_session_is_local()RemoteServerClient::upload_handoff_snapshot()SkippedEmptyWorkspacewhen daemon unavailable or oldVec<String>instead ofVec<PathBuf>for cross-platform path safetysettle_handoff_snapshot_resultshared helper for local/remote pathsTouchedWorkspacefor remote path to satisfy auto-submit gateTest Plan
Tested manually:
cd alacritty→& can you explain the README.md?Oz conversation | Plan
Co-Authored-By: Warp agent@warp.dev