From 4348ec7cb346fa3a2d817aa25803f6f90b7e79fb Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:40:49 +0000 Subject: [PATCH 1/2] feat(rust): port read-only original-session policy getter Port only published leaf 7682348bf1ba6cead28ba7304f3edfa5acb42d0e (parent 6f67591aea97825b61bd866130f3ab6e96688f07, tree 87247671332250222cd88696d3bd96723c229939) onto recorded SDK baseline ef04633cc84e4ba8e79888a39259ca276f5de732. Applied via git cherry-pick --no-commit without conflicts; stable patch ID 84ff457e169aca1de6790c95cbd024e0c449f31d matches exactly. Generated Rust declarations are reused from the published leaf, not hand-edited or independently regenerated here. Source reports API SHA256 7f2f51e8ea294ec8fa116da77ff58bf0e1fea4d8fb0b4bcfd460195e2fd667f6 and events SHA256 1e363c28ca3b2197ea4baf2ba25980138eda63213cd8a6585151deeb111a882d. Canonical runtime a1c2f57babcdd3dea5a406ca34e726745ef8ba74 and predecessor eaec03ae972f75282fe5122bebd54cba3c6cae01 are caller-supplied provenance, not an independent canonical derivation performed in this port. Validated serially using locked external-stream-only Cargo builds: 3 focused getter tests, 178 affected session tests, 11 framed JSON-RPC tests, nightly format check and focused Clippy. One unrelated Client::start configuration test requires the runtime feature and was excluded from the passing session run. Preserve all other files, public APIs, dependency manifests/locks, Node/build/harness paths and CLI pins. No app/runtime launch or guard input consumption. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/README.md | 13 +++ rust/src/generated/api_types.rs | 59 +++++++++++++ rust/src/generated/rpc.rs | 28 ++++++ rust/tests/session_test.rs | 150 +++++++++++++++++++++++++++++++- 4 files changed, 249 insertions(+), 1 deletion(-) diff --git a/rust/README.md b/rust/README.md index 6e91639175..3d16a7c718 100644 --- a/rust/README.md +++ b/rust/README.md @@ -464,6 +464,19 @@ 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. + #### 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..507ba69bcc 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -905,6 +905,8 @@ 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.notifySteerableChanged` pub const SESSION_REMOTE_NOTIFYSTEERABLECHANGED: &str = "session.remote.notifySteerableChanged"; /// `session.visibility.get` @@ -18965,6 +18967,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 +34729,42 @@ 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, +} + /// 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..9f76c2d3a6 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -12167,6 +12167,34 @@ 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)?) + } + /// 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..49400972bb 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -24,7 +24,7 @@ use github_copilot_sdk::rpc::{ ConnectorConnectResult, ConnectorContinueRequest, ConnectorDisconnectResult, ConnectorMcpStatus, ConnectorReconcileOptions, ConnectorReconcileRequest, ConnectorSessionAccount, ConnectorStatus, ModelSetAllowedModelsRequest, OpenCanvasInstance, - SendAgentMode, SendMode, SendRequest, SessionRpcConnectors, + RemotePolicyInputs, SendAgentMode, SendMode, SendRequest, SessionRpcConnectors, }; use github_copilot_sdk::session_events::{ ManagedSettingsResolvedSource, McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig, @@ -7336,6 +7336,154 @@ 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 rpc_namespace_client_models_list_dispatches_correctly() { let (session, mut server) = create_session_pair().await; From bbb80a920920b9feba9eb56dcc7a75217432a1fe Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:25:55 +0000 Subject: [PATCH 2/2] feat(rust): add typed original-owner export guard RPC Port only the canonical guardExport method constant, RemoteGuardExportResult, SessionRemoteGuardExportParams/Result and SessionRpcRemote::guard_export from published github/copilot-agent-runtime eae65d27df485bad0e2849d01672436e2c726470 (parent 1855ddfafd3194a4a28c6631c45a9cd1ddc688e3, tree ce1f17a33732584582dcc5e5a1bdd8c54454d35b) onto SDK 4348ec7cb346fa3a2d817aa25803f6f90b7e79fb. Independently verified remote source ref, immutable commit/tree and full Git blobs. API schema blob b92419fc405c1f0212691bd72ebc209512113c0f is 2234776 bytes, SHA256 cfa51e2835189c419dd68c3b90921817673802a7bbf58b48777c83ce17953be5. Events blob 7c2ce2b282daefa116f47f3a6712502ff80662ed SHA256 1e363c28ca3b2197ea4baf2ba25980138eda63213cd8a6585151deeb111a882d. Generated source blobs: api_types.rs 1908532835dfde2ca3aa12dae07b252ebb32df78, rpc.rs a25bcc79c9f1578179c1ff3632f23d81aec2bcf1. Canonical guard-only blocks extracted mechanically and verified byte-for-byte, not regenerated independently or hand-authored. Removing the additions reproduces both SDK baseline generated files exactly. Framed tests cover retained original sessionId, exact method and params, true/false receipts, missing/null/wrong types, unknown/unsubscribed/non-origin/missing-session RPC refusals and absence of extra/fallback/mutating RPCs. Serial locked external-stream validation: 6 getter/guard tests, 181 applicable session tests and 11 JSON-RPC tests passed; nightly format and focused Clippy passed. Unchanged Client::start validation test still requires runtime feature and fails in external-stream-only mode; no assertions weakened. No live runtime ownership validation claimed. Only four approved Rust leaf paths change. Preserve getter, all other APIs/files, CLI/Node/dependency/build/harness pins and protocol/events. No runtime/app launch, listener/export creation, mode change, PR or packages. SDK transport availability does not admit an executable/provider or complete github/github-app#18298. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/README.md | 15 ++++ rust/src/generated/api_types.rs | 47 ++++++++++++ rust/src/generated/rpc.rs | 25 +++++++ rust/tests/session_test.rs | 125 +++++++++++++++++++++++++++++++- 4 files changed, 211 insertions(+), 1 deletion(-) diff --git a/rust/README.md b/rust/README.md index 3d16a7c718..557d6bfb04 100644 --- a/rust/README.md +++ b/rust/README.md @@ -477,6 +477,21 @@ 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 507ba69bcc..516dcdb2a8 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -907,6 +907,8 @@ pub mod rpc_methods { 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` @@ -18940,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. /// ///
@@ -34765,6 +34782,36 @@ pub struct SessionRemoteGetPolicyInputsResult { 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 9f76c2d3a6..a7020a22eb 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -12195,6 +12195,31 @@ impl<'a> SessionRpcRemote<'a> { 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 49400972bb..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, - RemotePolicyInputs, SendAgentMode, SendMode, SendRequest, SessionRpcConnectors, + RemoteGuardExportResult, RemotePolicyInputs, SendAgentMode, SendMode, SendRequest, + SessionRpcConnectors, }; use github_copilot_sdk::session_events::{ ManagedSettingsResolvedSource, McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig, @@ -7484,6 +7485,128 @@ async fn remote_policy_inputs_propagates_rpc_errors_without_fallback() { 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;