diff --git a/rust/src/lib.rs b/rust/src/lib.rs index c95ed2087a..56dcdb25af 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -2573,6 +2573,40 @@ impl Client { Ok(serde_json::from_value(value)?) } + /// Set the account identity used to scope persisted session-store operations. + /// + /// Call this after connecting and before local session-store operations such + /// as listing or resuming sessions. Pass `None` to clear the identity, such + /// as when the user logs out. + pub async fn set_session_store_identity( + &self, + identity: Option<&SessionStoreIdentity>, + ) -> Result<()> { + self.call( + "sessionStore.setIdentity", + Some(serde_json::json!({ "identity": identity })), + ) + .await?; + Ok(()) + } + + /// Claim one quarantined legacy local session for the active store identity. + /// + /// The runtime atomically binds the claim to the identity most recently set + /// on this client connection through + /// [`set_session_store_identity`](Self::set_session_store_identity). Call + /// this only after explicit trusted-host user confirmation for the selected + /// session. The runtime rejects missing identities, conflicting ownership, + /// and sessions that are not eligible legacy local sessions. + pub async fn claim_legacy_session(&self, session_id: &SessionId) -> Result<()> { + self.call( + "sessionStore.claimLegacySession", + Some(serde_json::json!({ "sessionId": session_id })), + ) + .await?; + Ok(()) + } + /// List persisted sessions, optionally filtered by working directory, /// repository, or git context. pub async fn list_sessions( diff --git a/rust/src/types.rs b/rust/src/types.rs index 332a48d18c..bc44d60ceb 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1078,6 +1078,27 @@ impl ExtensionInfo { } } +/// Identity used to isolate persisted session-store data by GitHub account. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SessionStoreIdentity { + /// Canonical HTTPS origin for GitHub.com or the GitHub Enterprise Server. + pub authority: String, + /// Positive decimal GitHub account database ID. + pub account_id: String, +} + +impl SessionStoreIdentity { + /// Create a session-store identity. + pub fn new(authority: impl Into, account_id: impl Into) -> Self { + Self { + authority: authority.into(), + account_id: account_id.into(), + } + } +} + /// Stable identity for a host/SDK connection that supplies built-in canvases. /// /// When set on session create or resume, the runtime uses [`id`] verbatim as @@ -2036,6 +2057,8 @@ pub struct SessionConfig { pub enable_host_git_operations: Option, /// When true, enables the session store for this session. pub enable_session_store: Option, + /// Identity used to isolate this session's persisted session-store data. + pub session_store_identity: Option, /// When true, enables skills for this session. pub enable_skills: Option, /// **Experimental.** This option is part of an experimental wire-protocol @@ -2350,6 +2373,7 @@ impl std::fmt::Debug for SessionConfig { &self.enable_host_git_operations, ) .field("enable_session_store", &self.enable_session_store) + .field("session_store_identity", &self.session_store_identity) .field("enable_skills", &self.enable_skills) .field("enable_mcp_apps", &self.enable_mcp_apps) .field("skill_directories", &self.skill_directories) @@ -2478,6 +2502,7 @@ impl Default for SessionConfig { enable_file_hooks: None, enable_host_git_operations: None, enable_session_store: None, + session_store_identity: None, enable_skills: None, embedding_cache_storage: None, enable_mcp_apps: None, @@ -2650,6 +2675,7 @@ impl SessionConfig { enable_file_hooks: self.enable_file_hooks, enable_host_git_operations: self.enable_host_git_operations, enable_session_store: self.enable_session_store, + session_store_identity: self.session_store_identity, enable_skills: self.enable_skills, request_user_input, request_permission: permission_active, @@ -3043,6 +3069,12 @@ impl SessionConfig { self } + /// Set the identity used to isolate persisted session-store data. + pub fn with_session_store_identity(mut self, identity: SessionStoreIdentity) -> Self { + self.session_store_identity = Some(identity); + self + } + /// Set [`Self::enable_skills`]. pub fn with_enable_skills(mut self, value: bool) -> Self { self.enable_skills = Some(value); @@ -3487,6 +3519,8 @@ pub struct ResumeSessionConfig { pub enable_host_git_operations: Option, /// When true, enables the session store on resume. pub enable_session_store: Option, + /// Identity used to isolate the resumed session's persisted session-store data. + pub session_store_identity: Option, /// When true, enables skills on resume. pub enable_skills: Option, /// **Experimental.** This option is part of an experimental wire-protocol @@ -3721,6 +3755,7 @@ impl std::fmt::Debug for ResumeSessionConfig { &self.enable_host_git_operations, ) .field("enable_session_store", &self.enable_session_store) + .field("session_store_identity", &self.session_store_identity) .field("enable_skills", &self.enable_skills) .field("enable_mcp_apps", &self.enable_mcp_apps) .field("skill_directories", &self.skill_directories) @@ -3893,6 +3928,7 @@ impl ResumeSessionConfig { enable_file_hooks: self.enable_file_hooks, enable_host_git_operations: self.enable_host_git_operations, enable_session_store: self.enable_session_store, + session_store_identity: self.session_store_identity, enable_skills: self.enable_skills, request_user_input, request_permission: permission_active, @@ -4001,6 +4037,7 @@ impl ResumeSessionConfig { enable_file_hooks: None, enable_host_git_operations: None, enable_session_store: None, + session_store_identity: None, enable_skills: None, embedding_cache_storage: None, enable_mcp_apps: None, @@ -4368,6 +4405,12 @@ impl ResumeSessionConfig { self } + /// Set the identity used to isolate persisted session-store data on resume. + pub fn with_session_store_identity(mut self, identity: SessionStoreIdentity) -> Self { + self.session_store_identity = Some(identity); + self + } + /// Set [`Self::enable_skills`]. pub fn with_enable_skills(mut self, value: bool) -> Self { self.enable_skills = Some(value); @@ -6239,8 +6282,8 @@ mod tests { InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, McpStdioServerConfig, MemoryConfiguration, NamedProviderConfig, PermissionResponseCapability, ProviderConfig, ProviderModelConfig, ReasoningSummary, ResumeSessionConfig, SessionConfig, SessionEvent, - SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, - ToolResultResponse, ensure_attachment_display_names, + SessionId, SessionStoreIdentity, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, + ToolResultExpanded, ToolResultResponse, ensure_attachment_display_names, }; use crate::generated::session_events::TypedSessionEvent; @@ -6528,6 +6571,60 @@ mod tests { assert!(json.get("askUserVariant").is_none()); } + #[test] + fn session_store_identity_serializes_to_exact_create_and_resume_wire_shape() { + let identity = SessionStoreIdentity::new("https://github.com", "123456"); + assert_eq!( + serde_json::to_value(&identity).unwrap(), + json!({ + "authority": "https://github.com", + "accountId": "123456" + }) + ); + + let (create_wire, _) = SessionConfig::default() + .with_session_store_identity(identity.clone()) + .into_wire(Some(SessionId::from("store-identity-create"))) + .expect("create config has no duplicate handlers"); + let create_json = serde_json::to_value(&create_wire).unwrap(); + assert_eq!( + create_json["sessionStoreIdentity"], + json!({ + "authority": "https://github.com", + "accountId": "123456" + }) + ); + + let (resume_wire, _) = ResumeSessionConfig::new(SessionId::from("store-identity-resume")) + .with_session_store_identity(identity) + .into_wire() + .expect("resume config has no duplicate handlers"); + let resume_json = serde_json::to_value(&resume_wire).unwrap(); + assert_eq!( + resume_json["sessionStoreIdentity"], + json!({ + "authority": "https://github.com", + "accountId": "123456" + }) + ); + } + + #[test] + fn session_store_identity_is_omitted_when_absent() { + let (create_wire, _) = SessionConfig::default() + .into_wire(Some(SessionId::from("store-identity-create-unset"))) + .expect("create config has no duplicate handlers"); + let create_json = serde_json::to_value(&create_wire).unwrap(); + assert!(create_json.get("sessionStoreIdentity").is_none()); + + let (resume_wire, _) = + ResumeSessionConfig::new(SessionId::from("store-identity-resume-unset")) + .into_wire() + .expect("resume config has no duplicate handlers"); + let resume_json = serde_json::to_value(&resume_wire).unwrap(); + assert!(resume_json.get("sessionStoreIdentity").is_none()); + } + #[test] fn custom_agents_local_only_serializes_on_create_and_resume() { let (create_wire, _) = SessionConfig::default() diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 325dfdaaf1..bda80d925b 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -29,7 +29,7 @@ use crate::types::{ CustomAgentConfig, DefaultAgentConfig, ExtensionInfo, GitHubMcpToolConfig, InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, MemoryConfiguration, NamedProviderConfig, ProviderConfig, ProviderModelConfig, SessionId, SessionLimitsConfig, - SystemMessageConfig, Tool, ToolSearchConfig, + SessionStoreIdentity, SystemMessageConfig, Tool, ToolSearchConfig, }; /// Wire representation of a slash command (name + description only). The @@ -113,6 +113,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub enable_session_store: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub session_store_identity: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub enable_skills: Option, pub request_user_input: bool, pub request_permission: bool, @@ -274,6 +276,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub enable_session_store: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub session_store_identity: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub enable_skills: Option, pub request_user_input: bool, pub request_permission: bool, diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 9c8eda07f7..2dbcd6e352 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -31,7 +31,8 @@ use github_copilot_sdk::types::{ ElicitationRequest, ElicitationResult, ExitPlanModeData, ExtensionInfo, ManagedSettings, ManagedSettingsPermissions, MessageOptions, PermissionDecisionContext, PermissionDecisionOutcome, PermissionDecisionSource, PermissionDecisionSurface, RequestId, - SessionConfig, SessionId, SetModelOptions, Tool, ToolInvocation, ToolResult, + SessionConfig, SessionId, SessionStoreIdentity, SetModelOptions, Tool, ToolInvocation, + ToolResult, }; use github_copilot_sdk::{ AgentMode, Attachment, Client, ContextTier, ErrorKind, MessageSource, ProtocolErrorKind, tool, @@ -2525,6 +2526,119 @@ async fn list_sessions_returns_typed_metadata() { assert_eq!(sessions[0].summary, Some("test session".to_string())); } +#[tokio::test] +async fn set_session_store_identity_sends_exact_wire_shape() { + let (client, mut server_read, mut server_write) = make_client(); + let identity = SessionStoreIdentity::new("https://github.com", "123456"); + + let handle = tokio::spawn({ + let client = client.clone(); + async move { client.set_session_store_identity(Some(&identity)).await } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "sessionStore.setIdentity"); + assert_eq!( + request["params"], + serde_json::json!({ + "identity": { + "authority": "https://github.com", + "accountId": "123456" + } + }) + ); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {} + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + handle.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn set_session_store_identity_sends_null_to_clear() { + let (client, mut server_read, mut server_write) = make_client(); + + let handle = tokio::spawn({ + let client = client.clone(); + async move { client.set_session_store_identity(None).await } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "sessionStore.setIdentity"); + assert_eq!(request["params"], serde_json::json!({ "identity": null })); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {} + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + handle.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn claim_legacy_session_sends_exact_wire_shape() { + let (client, mut server_read, mut server_write) = make_client(); + + let handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .claim_legacy_session(&SessionId::new("legacy-session")) + .await + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "sessionStore.claimLegacySession"); + assert_eq!( + request["params"], + serde_json::json!({ "sessionId": "legacy-session" }) + ); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {} + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + handle.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn claim_legacy_session_propagates_rpc_errors() { + let (client, mut server_read, mut server_write) = make_client(); + + let handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .claim_legacy_session(&SessionId::new("owned-session")) + .await + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "sessionStore.claimLegacySession"); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": { + "code": -32000, + "message": "legacy session is not eligible for claim" + } + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + let error = handle.await.unwrap().unwrap_err(); + assert!(matches!(error.kind(), ErrorKind::Rpc { code: -32000 })); + assert!( + error + .to_string() + .contains("legacy session is not eligible for claim") + ); +} + #[tokio::test] async fn list_sessions_serializes_typed_filter() { use github_copilot_sdk::SessionListFilter;