diff --git a/rust/README.md b/rust/README.md index 6e91639175..557d6bfb04 100644 --- a/rust/README.md +++ b/rust/README.md @@ -464,6 +464,34 @@ New RPCs land in the namespace immediately as the schema regenerates; helpers are added on top only when an ergonomic story is worth the maintenance. +The experimental `session.rpc().remote().get_policy_inputs().await?` returns +`github_copilot_sdk::rpc::RemotePolicyInputs` for that original live session, +using its existing subscription. Its four required booleans are +`managed_remote_control_setting`, `managed_remote_control_staff_override`, +`owner_adc_sandbox`, and `owner_codespaces`. The managed flags include native +defaults; the environment booleans describe the session's owning runtime +process (`ADC_SANDBOX_ID` nonempty and `CODESPACES` exactly `"true"`). +The call does not enable export or steering, apply managed policy, or expose +raw flags or environment values. Missing or unattached sessions, unsupported +runtimes, and malformed responses return errors rather than inferred inputs. +Use a matching runtime and its emitted schemas for this unreleased API; the +SDK change alone does not add support to an older pinned CLI. + +The experimental `original_session.rpc().remote().guard_export().await?` +returns `github_copilot_sdk::rpc::RemoteGuardExportResult` with a required +`guarded` boolean. It sends only `session.remote.guardExport` with that retained +session's own ID; missing or malformed receipts and runtime refusals are errors. +This explicit RPC irreversibly enrolls the original resident session's +non-steerable export protection. Native authority belongs only to the still-live +original subscribed Create or cold-Resume owner, not attached/imported peers or +resident-resume callers. Peers cannot enroll or clear an owner's guard, and +protection survives owner disconnect until actual native session retirement. +Caller policy configuration cannot impersonate authoritative storage revocation; +real native storage denial or disposing-owner retirement still permits shutdown. +The SDK does not enable or disable Native Mode, create an Export, start a +listener, or issue additional RPCs. Use a matching runtime; this API alone does +not admit an executable or provider. + #### Typed MCP installation and removal payloads (breaking change) Three payloads in the experimental MCP installation and removal workflow are now typed diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index b06fad33d4..516dcdb2a8 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -905,6 +905,10 @@ pub mod rpc_methods { pub const SESSION_REMOTE_ENABLE: &str = "session.remote.enable"; /// `session.remote.disable` pub const SESSION_REMOTE_DISABLE: &str = "session.remote.disable"; + /// `session.remote.getPolicyInputs` + pub const SESSION_REMOTE_GETPOLICYINPUTS: &str = "session.remote.getPolicyInputs"; + /// `session.remote.guardExport` + pub const SESSION_REMOTE_GUARDEXPORT: &str = "session.remote.guardExport"; /// `session.remote.notifySteerableChanged` pub const SESSION_REMOTE_NOTIFYSTEERABLECHANGED: &str = "session.remote.notifySteerableChanged"; /// `session.visibility.get` @@ -18938,6 +18942,21 @@ pub struct RemoteEnableResult { pub url: Option, } +/// Successful original-owner enrollment of the resident session's monotonic export protection. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteGuardExportResult { + /// True after successful enrollment. There is no reset operation. + pub guarded: bool, +} + /// New remote-steerability state to persist as a `session.remote_steerable_changed` event. /// ///
@@ -18965,6 +18984,27 @@ pub struct RemoteNotifySteerableChangedRequest { #[serde(rename_all = "camelCase")] pub struct RemoteNotifySteerableChangedResult {} +/// Read-only remote-policy inputs from the original live session and its owning runtime process. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemotePolicyInputs { + /// Resolved MANAGED_REMOTE_CONTROL_SETTING flag, including the runtime default when no boolean override exists. + pub managed_remote_control_setting: bool, + /// Resolved MANAGED_REMOTE_CONTROL_SETTING_STAFF_OVERRIDE flag, including the runtime default when no boolean override exists. + pub managed_remote_control_staff_override: bool, + /// Whether ADC_SANDBOX_ID is nonempty in the original session's owning runtime process. + pub owner_adc_sandbox: bool, + /// Whether CODESPACES is the literal string "true" in the original session's owning runtime process. + pub owner_codespaces: bool, +} + /// Remote session connection result. /// ///
@@ -34706,6 +34746,72 @@ pub struct SessionRemoteDisableParams { pub session_id: SessionId, } +/// Identifies the target session. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionRemoteGetPolicyInputsParams { + /// Target session identifier + pub session_id: SessionId, +} + +/// Read-only remote-policy inputs from the original live session and its owning runtime process. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionRemoteGetPolicyInputsResult { + /// Resolved MANAGED_REMOTE_CONTROL_SETTING flag, including the runtime default when no boolean override exists. + pub managed_remote_control_setting: bool, + /// Resolved MANAGED_REMOTE_CONTROL_SETTING_STAFF_OVERRIDE flag, including the runtime default when no boolean override exists. + pub managed_remote_control_staff_override: bool, + /// Whether ADC_SANDBOX_ID is nonempty in the original session's owning runtime process. + pub owner_adc_sandbox: bool, + /// Whether CODESPACES is the literal string "true" in the original session's owning runtime process. + pub owner_codespaces: bool, +} + +/// Identifies the target session. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionRemoteGuardExportParams { + /// Target session identifier + pub session_id: SessionId, +} + +/// Successful original-owner enrollment of the resident session's monotonic export protection. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionRemoteGuardExportResult { + /// True after successful enrollment. There is no reset operation. + pub guarded: bool, +} + /// Persist a steerability change as a `session.remote_steerable_changed` event. Used by the host (CLI / SDK consumer) when it has just finished enabling or disabling steering on a remote exporter that the runtime does not directly own. /// ///
diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index cf87a42bd1..a7020a22eb 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -12167,6 +12167,59 @@ impl<'a> SessionRpcRemote<'a> { Ok(()) } + /// Reads the original live session's resolved remote-policy feature flags and owner-process environment booleans without changing remote export or steering. Requires the caller's already-established session subscription. + /// + /// Wire method: `session.remote.getPolicyInputs`. + /// + /// # Returns + /// + /// Read-only remote-policy inputs from the original live session and its owning runtime process. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn get_policy_inputs(&self) -> Result { + let wire_params = serde_json::json!({ "sessionId": self.session.id() }); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_REMOTE_GETPOLICYINPUTS, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Irreversibly protects the original resident session's non-steerable export from native steering or disposal. Requires its still-live original SDK creation authority. Attachment, import, and resident resume do not confer this authority; protection survives owner disconnect until actual native session retirement. + /// + /// Wire method: `session.remote.guardExport`. + /// + /// # Returns + /// + /// Successful original-owner enrollment of the resident session's monotonic export protection. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn guard_export(&self) -> Result { + let wire_params = serde_json::json!({ "sessionId": self.session.id() }); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_REMOTE_GUARDEXPORT, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + /// Persists a remote-steerability change emitted by the host as a session event. /// /// Wire method: `session.remote.notifySteerableChanged`. diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index e38b0c427d..588535e83c 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -24,7 +24,8 @@ use github_copilot_sdk::rpc::{ ConnectorConnectResult, ConnectorContinueRequest, ConnectorDisconnectResult, ConnectorMcpStatus, ConnectorReconcileOptions, ConnectorReconcileRequest, ConnectorSessionAccount, ConnectorStatus, ModelSetAllowedModelsRequest, OpenCanvasInstance, - SendAgentMode, SendMode, SendRequest, SessionRpcConnectors, + RemoteGuardExportResult, RemotePolicyInputs, SendAgentMode, SendMode, SendRequest, + SessionRpcConnectors, }; use github_copilot_sdk::session_events::{ ManagedSettingsResolvedSource, McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig, @@ -7336,6 +7337,276 @@ async fn rpc_namespace_session_connectors_dispatches_all_methods() { timeout(TIMEOUT, server_handle).await.unwrap().unwrap(); } +#[tokio::test] +async fn remote_policy_inputs_preserves_original_session_and_all_boolean_combinations() { + let (session, mut server) = + create_session_pair_with_config(|cfg| cfg.with_session_id("original-policy-session")).await; + let session = Arc::new(session); + assert_eq!(session.id().as_str(), "original-policy-session"); + + for bits in 0..16 { + let expected = serde_json::json!({ + "managedRemoteControlSetting": bits & 1 != 0, + "managedRemoteControlStaffOverride": bits & 2 != 0, + "ownerAdcSandbox": bits & 4 != 0, + "ownerCodespaces": bits & 8 != 0, + }); + let handle = tokio::spawn({ + let session = session.clone(); + async move { session.rpc().remote().get_policy_inputs().await } + }); + let request = timeout(TIMEOUT, server.read_request()).await.unwrap(); + assert_eq!(request["method"], "session.remote.getPolicyInputs"); + assert_eq!( + request["params"], + serde_json::json!({ "sessionId": "original-policy-session" }) + ); + server.respond(&request, expected.clone()).await; + + let result: RemotePolicyInputs = timeout(TIMEOUT, handle).await.unwrap().unwrap().unwrap(); + assert_eq!(result.managed_remote_control_setting, bits & 1 != 0); + assert_eq!(result.managed_remote_control_staff_override, bits & 2 != 0); + assert_eq!(result.owner_adc_sandbox, bits & 4 != 0); + assert_eq!(result.owner_codespaces, bits & 8 != 0); + assert_eq!(serde_json::to_value(result).unwrap(), expected); + } +} + +#[tokio::test] +async fn remote_policy_inputs_rejects_missing_and_malformed_required_booleans() { + let (session, mut server) = create_session_pair().await; + let session = Arc::new(session); + let valid = serde_json::json!({ + "managedRemoteControlSetting": false, + "managedRemoteControlStaffOverride": false, + "ownerAdcSandbox": false, + "ownerCodespaces": false, + }); + let mut malformed = vec![Value::Null, serde_json::json!([]), serde_json::json!({})]; + for field in [ + "managedRemoteControlSetting", + "managedRemoteControlStaffOverride", + "ownerAdcSandbox", + "ownerCodespaces", + ] { + let mut missing = valid.clone(); + missing.as_object_mut().unwrap().remove(field); + malformed.push(missing); + for value in [ + Value::Null, + serde_json::json!("false"), + serde_json::json!(0), + serde_json::json!([]), + serde_json::json!({}), + ] { + let mut response = valid.clone(); + response[field] = value; + malformed.push(response); + } + } + + for response in malformed { + let handle = tokio::spawn({ + let session = session.clone(); + async move { session.rpc().remote().get_policy_inputs().await } + }); + let request = timeout(TIMEOUT, server.read_request()).await.unwrap(); + assert_eq!(request["method"], "session.remote.getPolicyInputs"); + assert_eq!( + request["params"], + serde_json::json!({ "sessionId": server.session_id }) + ); + server.respond(&request, response.clone()).await; + + let error = timeout(TIMEOUT, handle) + .await + .unwrap() + .unwrap() + .expect_err("malformed policy inputs must not default to false"); + assert_eq!(error.kind(), &ErrorKind::Json, "response: {response}"); + } +} + +#[tokio::test] +async fn remote_policy_inputs_propagates_rpc_errors_without_fallback() { + let (session, mut server) = create_session_pair().await; + let session = Arc::new(session); + for (code, message) in [ + (-32601, "Method not found"), + (-32000, "Original session not found"), + (-32000, "Original session is not attached"), + ] { + let handle = tokio::spawn({ + let session = session.clone(); + async move { session.rpc().remote().get_policy_inputs().await } + }); + let request = timeout(TIMEOUT, server.read_request()).await.unwrap(); + assert_eq!(request["method"], "session.remote.getPolicyInputs"); + assert_eq!( + request["params"], + serde_json::json!({ "sessionId": server.session_id }) + ); + let data = serde_json::json!({ "sessionId": server.session_id }); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": { "code": code, "message": message, "data": data }, + }); + write_framed(&mut server.write, &serde_json::to_vec(&response).unwrap()).await; + + let error = timeout(TIMEOUT, handle) + .await + .unwrap() + .unwrap() + .expect_err("RPC rejection must propagate"); + assert_eq!(error.kind(), &ErrorKind::Rpc { code }); + assert_eq!(error.message(), Some(message)); + assert_eq!(error.rpc_data(), Some(&data)); + } + + let handle = tokio::spawn(async move { session.rpc().remote().get_policy_inputs().await }); + let request = timeout(TIMEOUT, server.read_request()).await.unwrap(); + assert_eq!(request["method"], "session.remote.getPolicyInputs"); + assert_eq!( + request["params"], + serde_json::json!({ "sessionId": server.session_id }) + ); + server + .respond( + &request, + serde_json::json!({ + "managedRemoteControlSetting": false, + "managedRemoteControlStaffOverride": false, + "ownerAdcSandbox": false, + "ownerCodespaces": false, + }), + ) + .await; + timeout(TIMEOUT, handle).await.unwrap().unwrap().unwrap(); +} + +#[tokio::test] +async fn remote_guard_export_preserves_original_session_and_boolean_receipts() { + let (session, mut server) = + create_session_pair_with_config(|cfg| cfg.with_session_id("original-guard-session")).await; + let session = Arc::new(session); + assert_eq!(session.id().as_str(), "original-guard-session"); + + for guarded in [true, false] { + let handle = tokio::spawn({ + let session = session.clone(); + async move { session.rpc().remote().guard_export().await } + }); + let request = timeout(TIMEOUT, server.read_request()).await.unwrap(); + assert_eq!(request["method"], "session.remote.guardExport"); + assert_eq!( + request["params"], + serde_json::json!({ "sessionId": "original-guard-session" }) + ); + let expected = serde_json::json!({ "guarded": guarded }); + server.respond(&request, expected.clone()).await; + + let result: RemoteGuardExportResult = + timeout(TIMEOUT, handle).await.unwrap().unwrap().unwrap(); + assert_eq!(result.guarded, guarded); + assert_eq!(serde_json::to_value(result).unwrap(), expected); + assert!( + timeout(Duration::from_millis(50), server.read_request()) + .await + .is_err(), + "guard enrollment must not issue extra or mutating RPCs" + ); + } +} + +#[tokio::test] +async fn remote_guard_export_rejects_missing_and_malformed_required_boolean() { + let (session, mut server) = + create_session_pair_with_config(|cfg| cfg.with_session_id("original-guard-session")).await; + let session = Arc::new(session); + for response in [ + Value::Null, + serde_json::json!([]), + serde_json::json!({}), + serde_json::json!({ "guarded": null }), + serde_json::json!({ "guarded": "false" }), + serde_json::json!({ "guarded": 0 }), + serde_json::json!({ "guarded": [] }), + serde_json::json!({ "guarded": {} }), + ] { + let handle = tokio::spawn({ + let session = session.clone(); + async move { session.rpc().remote().guard_export().await } + }); + let request = timeout(TIMEOUT, server.read_request()).await.unwrap(); + assert_eq!(request["method"], "session.remote.guardExport"); + assert_eq!( + request["params"], + serde_json::json!({ "sessionId": "original-guard-session" }) + ); + server.respond(&request, response.clone()).await; + + let error = timeout(TIMEOUT, handle) + .await + .unwrap() + .unwrap() + .expect_err("malformed guard receipts must not default to false"); + assert_eq!(error.kind(), &ErrorKind::Json, "response: {response}"); + assert!( + timeout(Duration::from_millis(50), server.read_request()) + .await + .is_err(), + "malformed guard receipts must not trigger fallback RPCs" + ); + } +} + +#[tokio::test] +async fn remote_guard_export_propagates_rpc_refusals_without_fallback() { + let (session, mut server) = + create_session_pair_with_config(|cfg| cfg.with_session_id("original-guard-session")).await; + let session = Arc::new(session); + for (code, message) in [ + (-32601, "Method not found"), + (-32000, "Original session is not attached"), + (-32000, "Caller is not the original session owner"), + (-32000, "Original session not found"), + ] { + let handle = tokio::spawn({ + let session = session.clone(); + async move { session.rpc().remote().guard_export().await } + }); + let request = timeout(TIMEOUT, server.read_request()).await.unwrap(); + assert_eq!(request["method"], "session.remote.guardExport"); + assert_eq!( + request["params"], + serde_json::json!({ "sessionId": "original-guard-session" }) + ); + let data = serde_json::json!({ "sessionId": "original-guard-session" }); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": { "code": code, "message": message, "data": data }, + }); + write_framed(&mut server.write, &serde_json::to_vec(&response).unwrap()).await; + + let error = timeout(TIMEOUT, handle) + .await + .unwrap() + .unwrap() + .expect_err("guard enrollment refusals must propagate"); + assert_eq!(error.kind(), &ErrorKind::Rpc { code }); + assert_eq!(error.message(), Some(message)); + assert_eq!(error.rpc_data(), Some(&data)); + assert!( + timeout(Duration::from_millis(50), server.read_request()) + .await + .is_err(), + "guard enrollment refusals must not trigger fallback RPCs" + ); + } +} + #[tokio::test] async fn rpc_namespace_client_models_list_dispatches_correctly() { let (session, mut server) = create_session_pair().await;