diff --git a/src/apps/cli/src/peer_host/commands/snapshot.rs b/src/apps/cli/src/peer_host/commands/snapshot.rs index d37d27e6a0..94ccfdf4ff 100644 --- a/src/apps/cli/src/peer_host/commands/snapshot.rs +++ b/src/apps/cli/src/peer_host/commands/snapshot.rs @@ -34,6 +34,21 @@ pub(super) async fn require_local_snapshot_workspace( Ok(()) } +async fn require_complete_rollback_workspace( + request: &Value, + workspace_path: &str, +) -> Result<(), String> { + let is_remote = optional_string(request, "remoteConnectionId").is_some() + || optional_string(request, "remoteSshHost").is_some() + || is_remote_path(workspace_path).await; + if is_remote { + return Err(format!( + "Complete rollback is not supported for remote workspaces because remote file snapshots are not recorded. No workspace files or session messages were changed: {workspace_path}" + )); + } + Ok(()) +} + pub(super) fn snapshot_compatibility_error(error: PortError) -> String { if error.kind == PortErrorKind::InvalidRequest { error.message @@ -166,7 +181,7 @@ pub(crate) async fn rollback_to_turn(state: &PeerHostState, args: &Value) -> Res let delete_turns = optional_bool(request, "deleteTurns").unwrap_or(false); bitfun_agent_runtime::session_control::validate_session_id(&session_id)?; - require_local_snapshot_workspace(request, &workspace_path).await?; + require_complete_rollback_workspace(request, &workspace_path).await?; let workspace = PathBuf::from(&workspace_path); let scope = ensure_session_workspace_runtime_ownership(state, request)?; let session_storage_path = resolved_session_storage_scope(state, scope).await?; @@ -305,8 +320,9 @@ mod tests { use super::{ history_rollback_partial_failure, local_snapshot_session_files, - local_snapshot_session_stats, require_local_snapshot_workspace, rollback_device_events, - rollback_local_workspace_files, snapshot_compatibility_error, + local_snapshot_session_stats, require_complete_rollback_workspace, + require_local_snapshot_workspace, rollback_device_events, rollback_local_workspace_files, + snapshot_compatibility_error, }; #[derive(Default)] @@ -374,12 +390,23 @@ mod tests { ); } + let rollback_error = require_complete_rollback_workspace( + &json!({ "remoteConnectionId": "remote-1" }), + "/root/repos", + ) + .await + .expect_err("complete remote rollback must report missing snapshot coverage"); + assert_eq!( + rollback_error, + "Complete rollback is not supported for remote workspaces because remote file snapshots are not recorded. No workspace files or session messages were changed: /root/repos" + ); + let source = include_str!("snapshot.rs"); let rollback_source = &source[source .find("pub(crate) async fn rollback_to_turn") .expect("rollback handler must exist")..]; let remote_guard = rollback_source - .find("require_local_snapshot_workspace(request, &workspace_path).await?") + .find("require_complete_rollback_workspace(request, &workspace_path).await?") .expect("rollback must have an explicit remote guard"); let maintenance = rollback_source .find("begin_session_maintenance") diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 6e33603c13..61ad850d38 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1547,8 +1547,8 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::WorkspaceAgnostic, ), ("rollback_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), - ("rollback_session", RemoteWorkspacePolicy::LegacyUnaudited), - ("rollback_to_turn", RemoteWorkspacePolicy::LegacyUnaudited), + ("rollback_session", RemoteWorkspacePolicy::RemoteUnsupported), + ("rollback_to_turn", RemoteWorkspacePolicy::RemoteUnsupported), ("run_init_agents_md", RemoteWorkspacePolicy::LegacyUnaudited), ("run_system_command", RemoteWorkspacePolicy::LegacyUnaudited), ( @@ -2025,6 +2025,17 @@ mod tests { } } + #[test] + fn complete_rollback_commands_explicitly_reject_remote_workspaces() { + for command in ["rollback_session", "rollback_to_turn"] { + assert_eq!( + remote_workspace_policy(command), + Some(RemoteWorkspacePolicy::RemoteUnsupported), + "{command} must not offer message-only rollback without remote file snapshots" + ); + } + } + #[test] fn external_source_control_web_command_is_registered() { const COMMAND: &str = "get_external_source_control_snapshot"; @@ -2364,8 +2375,6 @@ mod tests { "restore_session_view", "restore_session_with_turns", "rollback_miniapp", - "rollback_session", - "rollback_to_turn", "run_init_agents_md", "run_system_command", "save_acp_json_config", diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index 6cfff7981b..d66d259ad5 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -458,6 +458,18 @@ async fn ensure_local_snapshot_mutation_path( Ok(()) } +async fn ensure_complete_rollback_supported( + workspace_path: &str, + remote_scope: &SnapshotRemoteScope, +) -> Result<(), String> { + if remote_scope.declares_remote() || is_remote_path(workspace_path).await { + return Err(format!( + "Complete rollback is not supported for remote workspaces because remote file snapshots are not recorded. No workspace files or session messages were changed: {workspace_path}" + )); + } + Ok(()) +} + async fn snapshot_manager_for_view( workspace_path: &str, remote_scope: &SnapshotRemoteScope, @@ -528,8 +540,7 @@ pub async fn rollback_session( runtime: State<'_, DesktopRuntimeContext>, request: RollbackSessionRequest, ) -> Result, String> { - // Remote workspaces have no local snapshots — nothing to roll back - ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + ensure_complete_rollback_supported(&request.workspace_path, &request.remote_scope).await?; ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; let manager = @@ -563,8 +574,7 @@ pub async fn rollback_to_turn( runtime: State<'_, DesktopRuntimeContext>, request: RollbackTurnRequest, ) -> Result, String> { - // Remote workspaces have no local snapshots — nothing to roll back - ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + ensure_complete_rollback_supported(&request.workspace_path, &request.remote_scope).await?; ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; let workspace_path = resolve_workspace_dir(&request.workspace_path).await?; @@ -1285,10 +1295,10 @@ mod tests { }; use super::{ - ensure_local_snapshot_mutation_path, get_snapshot_manager_for_workspace, - local_snapshot_command_error, local_snapshot_session_files, local_snapshot_session_stats, - rollback_local_workspace_files, snapshot_manager_for_view, RollbackTurnRequest, - SnapshotRemoteScope, + ensure_complete_rollback_supported, ensure_local_snapshot_mutation_path, + get_snapshot_manager_for_workspace, local_snapshot_command_error, + local_snapshot_session_files, local_snapshot_session_stats, rollback_local_workspace_files, + snapshot_manager_for_view, RollbackTurnRequest, SnapshotRemoteScope, }; #[test] @@ -1355,6 +1365,23 @@ mod tests { assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); } + #[tokio::test] + async fn remote_complete_rollback_reports_missing_file_snapshot_coverage() { + let scope = SnapshotRemoteScope { + remote_connection_id: Some("connection-1".to_string()), + remote_ssh_host: Some("example.com".to_string()), + }; + + let error = ensure_complete_rollback_supported("/root/repos", &scope) + .await + .expect_err("remote rollback must fail before changing files or history"); + + assert_eq!( + error, + "Complete rollback is not supported for remote workspaces because remote file snapshots are not recorded. No workspace files or session messages were changed: /root/repos" + ); + } + #[test] fn rollback_commands_reject_remote_workspaces_before_local_side_effects() { let source = include_str!("snapshot_service.rs"); @@ -1375,8 +1402,8 @@ mod tests { let assert_remote_guard_precedes = |body: &str, side_effect: &str| { let guard = body - .find("ensure_local_snapshot_mutation_path") - .expect("remote mutation guard remains present"); + .find("ensure_complete_rollback_supported") + .expect("complete rollback guard remains present"); let effect = body .find(side_effect) .unwrap_or_else(|| panic!("expected side effect remains present: {side_effect}")); diff --git a/src/apps/desktop/src/runtime/mod.rs b/src/apps/desktop/src/runtime/mod.rs index f5e8899dfb..e999d245fa 100644 --- a/src/apps/desktop/src/runtime/mod.rs +++ b/src/apps/desktop/src/runtime/mod.rs @@ -184,7 +184,7 @@ mod tests { .find("pub async fn rollback_to_turn") .expect("rollback command must exist")..]; let remote_guard = rollback_source - .find("ensure_local_snapshot_mutation_path") + .find("ensure_complete_rollback_supported") .expect("remote rollback guard must remain host-owned"); let cancellation = rollback_source .find("cancel_active_turn_for_session") diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx index e50df741ed..cc29e52314 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx @@ -321,6 +321,53 @@ describe('UserMessageItem steering tag', () => { expect(container.querySelector('.user-message-item__rollback-btn')).not.toBeNull(); }); + it('disables file-consistent rollback and message editing for remote workspaces', () => { + activeSessionRef.current = { + sessionId: 'remote-session', + sessionKind: 'normal', + remoteConnectionId: 'ssh:user@example.com:22', + remoteSshHost: 'example.com', + config: {}, + dialogTurns: [ + { + id: 'turn-1', + status: 'completed', + }, + ], + }; + + act(() => { + root.render( + + + , + ); + }); + + const rollbackButton = container.querySelector( + '.user-message-item__rollback-btn', + ); + const editButton = container.querySelector('.user-message-item__edit-btn'); + + expect(rollbackButton?.disabled).toBe(true); + expect(rollbackButton?.title).toContain('message.rollbackDisabledRemote'); + expect(editButton?.disabled).toBe(true); + expect(editButton?.title).toContain('message.editDisabledRemote'); + }); + it('hides the edit button when the panel context disables user message editing', () => { activeSessionRef.current = { sessionId: 'btw-session', diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index 27da6f585d..5f3a41bd52 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -32,6 +32,7 @@ import { SessionUsageReportCard } from '../usage/SessionUsageReportCard'; import type { SessionUsagePanelTab } from '../usage/sessionUsagePanelTypes'; import { coerceSessionUsageReport } from '../usage/usageReportUtils'; import { resolveSessionRelationship } from '../../utils/sessionMetadata'; +import { isRemoteWorkspaceSession } from '../../utils/sessionWorkspace'; import { composerPresentationToAccessibleText, composerPresentationContexts, @@ -139,6 +140,7 @@ export const UserMessageItem = React.memo( const isFailed = dialogTurn?.status === 'error'; const resolvedSessionId = sessionId ?? currentSession?.sessionId; const historyActionsBlockedByPartialRestore = currentSession?.isPartial === true; + const isRemoteSession = isRemoteWorkspaceSession(currentSession ?? undefined, null); const isSystemTriggered = Boolean( message?.metadata?.triggerSource && message.metadata.triggerSource !== 'desktop_ui', ); @@ -148,6 +150,7 @@ export const UserMessageItem = React.memo( !!resolvedSessionId && turnIndex >= 0 && !historyActionsBlockedByPartialRestore && + !isRemoteSession && !isRollingBack && !isEditSubmitting; const canEditBase = @@ -155,20 +158,28 @@ export const UserMessageItem = React.memo( !!resolvedSessionId && turnIndex >= 0 && !historyActionsBlockedByPartialRestore && + !isRemoteSession && !isThreadGoalSystemMessage && !isSystemTriggered && !steeringStatus; const canEdit = canEditBase && !isEditSubmitting && !isRollingBack; const canShowEditAction = allowUserMessageEdit && !isFailed && !isThreadGoalSystemMessage; - const editDisabledReason = isSystemTriggered - ? t('message.cannotEdit') - : steeringStatus + const editDisabledReason = isRemoteSession + ? t('message.editDisabledRemote') + : isSystemTriggered ? t('message.cannotEdit') - : historyActionsBlockedByPartialRestore - ? t('message.editDisabledHistoryNotReady') - : !resolvedSessionId || turnIndex < 0 - ? t('message.editDisabledHistoryNotReady') - : t('message.cannotEdit'); + : steeringStatus + ? t('message.cannotEdit') + : historyActionsBlockedByPartialRestore + ? t('message.editDisabledHistoryNotReady') + : !resolvedSessionId || turnIndex < 0 + ? t('message.editDisabledHistoryNotReady') + : t('message.cannotEdit'); + const rollbackTooltip = canRollback + ? t('message.rollbackTo', { index: turnIndex + 1 }) + : isRemoteSession + ? t('message.rollbackDisabledRemote') + : t('message.cannotRollback'); const steeringTag = steeringStatus === 'pending' ? { className: 'user-message-item__steering-tag--pending', @@ -582,11 +593,12 @@ export const UserMessageItem = React.memo( ) : canShowRollbackAction && !steeringStatus ? ( - +