Skip to content

feat: support local→cloud handoff snapshot in remote SSH sessions#11453

Open
kevinyang372 wants to merge 7 commits into
masterfrom
orchestrator/handoff-client
Open

feat: support local→cloud handoff snapshot in remote SSH sessions#11453
kevinyang372 wants to merge 7 commits into
masterfrom
orchestrator/handoff-client

Conversation

@kevinyang372
Copy link
Copy Markdown
Member

@kevinyang372 kevinyang372 commented May 20, 2026

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 the InitialSnapshotToken string.

Changes

Proto (remote_server.proto):

  • UploadHandoffSnapshot / UploadHandoffSnapshotResponse messages (field 26)

Daemon (handoff_snapshot.rs, server_model.rs):

  • Handler runs derive_touched_workspace + upload_snapshot_for_handoff locally on the remote host
  • Uses ServerApiProvider::get_ai_client() and get_http_client() for direct GCS upload

Client (workspace/view.rs, client/mod.rs, manager.rs):

  • Detects SSH sessions via active_session_is_local()
  • Delegates to daemon via RemoteServerClient::upload_handoff_snapshot()
  • Graceful fallback to SkippedEmptyWorkspace when daemon unavailable or old
  • Uses Vec<String> instead of Vec<PathBuf> for cross-platform path safety
  • Extracted settle_handoff_snapshot_result shared helper for local/remote paths
  • Sets empty TouchedWorkspace for remote path to satisfy auto-submit gate

Test Plan

Tested manually:

  1. SSH into remote host → cd alacritty& can you explain the README.md?
  2. Daemon logs show successful snapshot upload (2/2 files uploaded)
  3. Cloud agent receives the snapshot token and starts with repo context

Oz conversation | Plan

Co-Authored-By: Warp agent@warp.dev

kevinyang372 and others added 5 commits May 20, 2026 16:23
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>
@cla-bot cla-bot Bot added the cla-signed label May 20, 2026
Copy link
Copy Markdown
Member Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@kevinyang372 kevinyang372 changed the title feat: daemon-side handler for SSH handoff snapshot upload feat: support local→cloud handoff snapshot in remote SSH sessions May 20, 2026
@kevinyang372 kevinyang372 marked this pull request as ready for review May 20, 2026 22:25
@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 20, 2026

@kevinyang372

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 /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_workspace unset, so remote handoff can remain stuck instead of submitting without a snapshot.
  • Remote forked-conversation paths are still converted through PathBuf on 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

Comment thread app/src/workspace/view.rs Outdated
Comment thread app/src/workspace/view.rs Outdated
Comment thread app/src/remote_server/handoff_snapshot.rs Outdated
UploadHandoffSnapshotResponse {
initial_snapshot_token: None,
success: false,
error: Some(format!("{e:#}")),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need any typed errors over the wire? Is this an error we need to deserialize?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread app/src/workspace/view.rs Outdated
Comment thread app/src/workspace/view.rs
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
fn spawn_handoff_snapshot_upload(
paths: Vec<PathBuf>,
paths: Vec<String>,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we do the path --> string conversion deeper in the stack? for the actual API, path is definitely right

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We cannot do path to string conversion deeper. This is also used for remote sessions which means we cannot assume local OS encoding

Comment thread app/src/workspace/view.rs Outdated
Comment thread app/src/workspace/view.rs Outdated
Comment on lines +13619 to +13640
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"))
}
};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread app/src/workspace/view.rs Outdated
Comment on lines +14234 to +14259
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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feels like this could be fully consolidated by

  1. adding a new active_session_path function that returns RemoteOrLocalPath
  2. keeping the paths as PathBufs (if we can)
  3. Call into spawn_handoff_snapshot_upload with session ID and the paths

@kevinyang372 kevinyang372 requested a review from alokedesai May 21, 2026 04:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants