Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/apps/cli/src/peer_host/commands/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,13 @@ pub(crate) async fn update_session_model(
let request = request_value(args);
let session_id = validated_session_id(request)?;
let model_name = get_string(request, "modelName")?;
if request
.get("workspacePath")
.and_then(Value::as_str)
.is_some_and(|path| !path.trim().is_empty())
{
ensure_coordinator_session(state, args).await?;
}
state
.agent_runtime
.update_session_model(AgentSessionModelUpdateRequest {
Expand Down
34 changes: 33 additions & 1 deletion src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ fn is_idempotent_review_create(request: &CreateSessionRequest) -> bool {
pub struct UpdateSessionModelRequest {
pub session_id: String,
pub model_name: String,
#[serde(default)]
pub workspace_path: Option<String>,
#[serde(default)]
pub remote_connection_id: Option<String>,
#[serde(default)]
pub remote_ssh_host: Option<String>,
#[serde(default)]
pub include_internal: bool,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -1644,10 +1652,34 @@ pub async fn update_session_model(
runtime: State<'_, DesktopRuntimeContext>,
request: UpdateSessionModelRequest,
) -> Result<(), String> {
let session_id = request.session_id.trim().to_string();
if session_id.is_empty() {
return Err("session_id is required".to_string());
}
if let Some(workspace_path) = request
.workspace_path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
{
runtime
.session_application()
.ensure_session_loaded(
desktop_session_scope(
workspace_path.to_string(),
request.remote_connection_id,
request.remote_ssh_host,
),
&session_id,
request.include_internal,
)
.await
.map_err(|error| format!("Failed to restore session before model update: {error}"))?;
}
runtime
.agent_runtime()
.update_session_model(AgentSessionModelUpdateRequest {
session_id: request.session_id,
session_id,
model_id: request.model_name,
})
.await
Expand Down
12 changes: 6 additions & 6 deletions src/apps/desktop/src/api/external_sources_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,19 +211,19 @@ pub(super) async fn require_local_workspace(
let Some(workspace_path) = workspace_path else {
return Ok(None);
};
let path = Path::new(workspace_path);
if !path.is_absolute() {
return Err(ExternalSourceOperationError::invalid_request(
"External AI application sources require an absolute workspace path",
));
}
if is_remote_path(workspace_path).await {
return Err(ExternalSourceOperationError::new(
ExternalSourceOperationErrorCode::HostUnavailable,
"The remote workspace is not running the external compatibility service",
true,
));
}
let path = Path::new(workspace_path);
if !path.is_absolute() {
return Err(ExternalSourceOperationError::invalid_request(
"External AI application sources require an absolute workspace path",
));
}
Ok(Some(path))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2447,6 +2447,32 @@ impl SSHConnectionManager {
config: SSHConnectionConfig,
timeout_secs: u64,
) -> anyhow::Result<SSHConnectionResult> {
// Explicit connects and transparent reconnects share the same
// connection id. Serialize them so concurrent workspace restoration
// cannot publish several sessions under one id and disconnect each
// previous winner as the next attempt completes.
let connection_lock = self.reconnect_lock_for(&config.id).await;
let _connection_guard = connection_lock.lock().await;

// Startup may restore several workspaces that share one saved SSH
// profile. Once the first caller has restored a matching live
// connection, later callers only need to open their workspace paths.
if let Some(existing_result) = {
let guard = self.connections.read().await;
guard.get(&config.id).and_then(|connection| {
(connection.alive.load(Ordering::SeqCst)
&& config.connection_params_equal(&connection.config))
.then(|| SSHConnectionResult {
success: true,
connection_id: Some(config.id.clone()),
error: None,
server_info: connection.server_info.clone(),
})
})
} {
return Ok(existing_result);
}

let established = self
.establish_session_with_retries(&config, timeout_secs)
.await?;
Expand Down Expand Up @@ -5352,6 +5378,64 @@ mod tests {
))
}

#[tokio::test]
async fn explicit_connect_reuses_a_matching_live_connection() {
let manager = SSHConnectionManager::new(test_data_dir("connect-idempotent"));
let config = SSHConnectionConfig {
id: "ssh-root@example.com".to_string(),
name: "example".to_string(),
host: "example.com".to_string(),
port: 22,
username: "root".to_string(),
auth: SSHAuthMethod::PrivateKey {
key_path: "/tmp/id_rsa".to_string(),
passphrase: None,
certificate_path: None,
},
default_workspace: Some("/srv/project".to_string()),
proxy_jump: None,
container: None,
options: Default::default(),
};
let alive = Arc::new(AtomicBool::new(true));
let server_info = ServerInfo {
os_type: "Linux".to_string(),
hostname: "example".to_string(),
home_dir: "/root".to_string(),
};
manager.connections.write().await.insert(
config.id.clone(),
ActiveConnection {
handle: None,
jump_handles: Vec::new(),
config: config.clone(),
effective_config: config.clone(),
server_info: Some(server_info.clone()),
sftp_session: Arc::new(tokio::sync::RwLock::new(None)),
server_key: None,
alive: alive.clone(),
},
);

let result = manager
.connect_with_timeout(config.clone(), 1)
.await
.unwrap();

assert!(result.success);
assert_eq!(result.connection_id.as_deref(), Some(config.id.as_str()));
assert_eq!(
result
.server_info
.as_ref()
.map(|info| info.hostname.as_str()),
Some(server_info.hostname.as_str())
);
let guard = manager.connections.read().await;
let active = guard.get(&config.id).unwrap();
assert!(Arc::ptr_eq(&active.alive, &alive));
}

#[test]
fn parses_proxy_jump_alias_user_port_and_ipv6() {
assert_eq!(
Expand Down
50 changes: 50 additions & 0 deletions src/web-ui/src/features/ssh-remote/SSHRemoteProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,56 @@ describe('SSHRemoteProvider startup restore', () => {
);
});

it('reconnects one SSH profile once when several workspaces share it', async () => {
const firstWorkspace = {
id: 'ws-shared-1',
name: 'first',
rootPath: '/srv/first',
workspaceType: WorkspaceType.SingleProject,
workspaceKind: WorkspaceKind.Remote,
languages: [] as string[],
openedAt: new Date().toISOString(),
lastAccessed: new Date().toISOString(),
tags: [] as string[],
connectionId: 'conn-shared',
connectionName: 'shared-box',
sshHost: 'shared.example.com',
};
const secondWorkspace = {
...firstWorkspace,
id: 'ws-shared-2',
name: 'second',
rootPath: '/srv/second',
};
workspaceManagerMock.getState.mockReturnValue({
loading: false,
openedWorkspaces: new Map([
[firstWorkspace.id, firstWorkspace],
[secondWorkspace.id, secondWorkspace],
]),
activeWorkspaceId: firstWorkspace.id,
});
sshApiMock.listSavedConnections.mockResolvedValue([{
id: 'conn-shared',
name: 'shared-box',
host: 'shared.example.com',
port: 22,
username: 'root',
authType: { type: 'PrivateKey', keyPath: '/tmp/id_rsa' },
}]);
sshApiMock.connect.mockResolvedValue({
success: true,
connectionId: 'conn-shared',
});

await renderProvider();

expect(sshApiMock.connect).toHaveBeenCalledTimes(1);
expect(sshApiMock.openWorkspace).toHaveBeenCalledTimes(2);
expect(sshApiMock.openWorkspace).toHaveBeenCalledWith('conn-shared', '/srv/first');
expect(sshApiMock.openWorkspace).toHaveBeenCalledWith('conn-shared', '/srv/second');
});

it('keeps a disconnected remote workspace after the 180s reconnect budget elapses', async () => {
vi.useFakeTimers();

Expand Down
Loading