From 4bd5ad72c95463e4e9288a8fd9f6d520b56e233a Mon Sep 17 00:00:00 2001 From: Carlo Rivera Date: Thu, 15 Oct 2026 10:02:48 -0400 Subject: [PATCH 1/2] test: preserve structured session RPC error data Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/test/client.test.ts | 41 +++++++++++++++++++++++ rust/tests/prepared_session_test.rs | 50 ++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index bdb98d4c67..23f5b05264 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -9,6 +9,7 @@ import { join, resolve } from "node:path"; import { describe, expect, it, onTestFinished, vi } from "vitest"; import { createMessageConnection, + ResponseError, StreamMessageReader, StreamMessageWriter, type MessageConnection, @@ -5478,6 +5479,46 @@ connection.listen(); }); }); +describe("RPC error data preservation", () => { + it.each(["session.create", "session.resume"] as const)( + "preserves structured data from %s", + async (method) => { + const data = { + context: { + category: "structured-runtime-failure", + message: "The runtime preserved structured operation context.", + }, + }; + const responseError = new ResponseError(-32001, "session operation failed", data); + const client = new CopilotClient({ autoStart: false }); + (client as any).connection = { + sendRequest: vi.fn(async (requestedMethod: string) => { + if (requestedMethod !== method) { + throw new Error(`Unexpected method: ${requestedMethod}`); + } + throw responseError; + }), + }; + + const operation = + method === "session.create" + ? client.createSession({ onPermissionRequest: approveAll }) + : client.resumeSession("structured-error-session", { + onPermissionRequest: approveAll, + }); + const error = await operation.then( + () => undefined, + (reason: unknown) => reason + ); + + expect(error).toBe(responseError); + expect(responseError.code).toBe(-32001); + expect(responseError.message).toBe("session operation failed"); + expect(responseError.data).toBe(data); + } + ); +}); + describe("managedSettings serialization", () => { async function captureCreateParams(config: Record): Promise { const client = new CopilotClient(); diff --git a/rust/tests/prepared_session_test.rs b/rust/tests/prepared_session_test.rs index 1ae60cbbe1..cddcda5ad7 100644 --- a/rust/tests/prepared_session_test.rs +++ b/rust/tests/prepared_session_test.rs @@ -736,6 +736,12 @@ async fn cancelled_prepared_resume_cleans_up_and_allows_retry() { async fn create_rpc_error_preserves_kind_and_cleans_up() { let (client, mut server) = make_client(); let session_id = SessionId::new("prepared-create-rpc-error"); + let data = json!({ + "context": { + "category": "structured-runtime-failure", + "message": "The runtime preserved structured operation context.", + }, + }); let prepared = client .prepare_session(SessionConfig::default().with_session_id(session_id.clone())) @@ -745,7 +751,12 @@ async fn create_rpc_error_preserves_kind_and_cleans_up() { let create_req = server.read_request().await; server - .respond_error(&create_req, -32000, "session create failed") + .respond_error_with_data( + &create_req, + -32000, + "session create failed", + Some(data.clone()), + ) .await; let error = expect_error(timeout(TIMEOUT, start).await.unwrap().unwrap()); @@ -754,6 +765,43 @@ async fn create_rpc_error_preserves_kind_and_cleans_up() { "unexpected error kind: {:?}", error.kind() ); + assert_eq!(error.rpc_data(), Some(&data)); + await_no_registrations(&client).await; + expect_closed(&mut events).await; +} + +#[tokio::test] +async fn resume_rpc_error_preserves_data_and_cleans_up() { + let (client, mut server) = make_client(); + let session_id = SessionId::new("prepared-resume-rpc-error"); + let data = json!({ + "context": { + "category": "structured-runtime-failure", + "message": "The runtime preserved structured operation context.", + }, + }); + + let prepared = client + .prepare_resume_session(ResumeSessionConfig::new(session_id)) + .unwrap(); + let mut events = prepared.subscribe(); + let start = tokio::spawn(prepared.start()); + + let resume_req = server.read_request().await; + assert_eq!(resume_req["method"], "session.resume"); + server + .respond_error_with_data( + &resume_req, + -32001, + "session resume failed", + Some(data.clone()), + ) + .await; + + let error = expect_error(timeout(TIMEOUT, start).await.unwrap().unwrap()); + assert_eq!(error.kind(), &ErrorKind::Rpc { code: -32001 }); + assert_eq!(error.message(), Some("session resume failed")); + assert_eq!(error.rpc_data(), Some(&data)); await_no_registrations(&client).await; expect_closed(&mut events).await; } From fd100bbba0c50de9e824186ed0a54b3d34025cf2 Mon Sep 17 00:00:00 2001 From: Carlo Rivera Date: Thu, 15 Oct 2026 12:37:48 -0400 Subject: [PATCH 2/2] feat: expose Policy Helper failures in Node and Rust Generate the structured failure and scoped recheck surface from runtime contract b7b80ebc085, with capability, compatibility, and RPC error preservation coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 28 ++++ nodejs/README.md | 27 ++++ nodejs/src/generated/rpc.ts | 18 ++- nodejs/src/generated/session-events.ts | 91 +++++++++++ nodejs/test/client.test.ts | 19 ++- nodejs/test/policy-helper-failure.test.ts | 122 ++++++++++++++ rust/README.md | 39 +++++ rust/src/generated/api_types.rs | 109 ++++++++++++- rust/src/generated/rpc.rs | 28 ++++ rust/src/generated/session_events.rs | 74 +++++++++ rust/tests/prepared_session_test.rs | 32 +++- rust/tests/session_test.rs | 189 +++++++++++++++++++++- 12 files changed, 753 insertions(+), 23 deletions(-) create mode 100644 nodejs/test/policy-helper-failure.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ee3229ce0c..984379d6c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the fu ## [Unreleased] +### Feature: structured Policy Helper failures and scoped recheck + +Node and Rust managed-settings snapshots now expose bounded structured Policy Helper failures, including actionable messages, remediation, details, explicit help links, and secondary diagnostics. The same DTO is preserved in applicable `session.create`, `session.resume`, and policy-dependent activation RPC error data without replacing the operation's primary code or message. + +Use `policyHelperCapabilities` to detect support. Older runtimes omit the capability object; the absence of `policyHelperFailure` alone does not prove recovery or support. When `recheck` is true, the new session-scoped RPC performs a fresh helper evaluation for the current policy context. + +```ts +const snapshot = await session.rpc.managedSettings.get(); +if (snapshot.policyHelperCapabilities?.recheck) { + await session.rpc.managedSettings.recheckPolicyHelper(); +} +``` + +```rust +let snapshot = session.rpc().managed_settings().get().await?; +if snapshot + .policy_helper_capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.recheck) +{ + session + .rpc() + .managed_settings() + .recheck_policy_helper() + .await?; +} +``` + ## [v1.0.14](https://github.com/github/copilot-sdk/releases/tag/v1.0.14) (2026-09-16) ### Feature: typed message provenance for user, system, and agent sources diff --git a/nodejs/README.md b/nodejs/README.md index 222a7340eb..5d69d268cb 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -1438,6 +1438,33 @@ security checks, user permissions, or sandbox policy. `permissions.limitTo` neit enables the sandbox nor changes `sandbox.userPolicy.network`; configure sandbox network restrictions explicitly. +### Policy Helper failures and scoped recheck + +Managed-settings snapshots may include a structured `policyHelperFailure` and +the explicit `policyHelperCapabilities` supported by the runtime. Treat helper +text as untrusted plain text, keep `diagnostic` secondary, and sanitize an +explicit `helpLink` again before navigation. + +```typescript +const snapshot = await session.rpc.managedSettings.get(); + +if (snapshot.policyHelperFailure) { + console.warn(snapshot.policyHelperFailure.message); +} + +if (snapshot.policyHelperCapabilities?.recheck === true) { + const refreshed = await session.rpc.managedSettings.recheckPolicyHelper(); + // Only a current capable snapshot without policyHelperFailure establishes recovery. + console.log(refreshed.policyHelperFailure ?? "Policy Helper recovered"); +} +``` + +Older runtimes omit `policyHelperCapabilities`. Do not infer support or recovery +only because `policyHelperFailure` is absent. Applicable `session.create`, +`session.resume`, and policy-dependent activation failures preserve the same DTO +under JSON-RPC `error.data.policyHelperFailure` while retaining the primary +operation error code and message. + ### Permission Result Kinds The handler must return one of the `PermissionDecision` shapes (or `{ kind: "no-result" }`). Approval scopes are present-tense — they describe the decision to apply, not the outcome reported back on session events: diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 14df3a5393..b91f61d971 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -5,7 +5,7 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; -import type { AbortReason, AgentModelPolicy, Attachment, AutoTier, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, IndexedSearchState, ManagedPermissionsContext, ManagedSettingsResolvedData, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerMetadata, McpServerSource, McpServerStatus, ModelChangeSource, ModelProviderKind, ModelProviderRef, PermissionDecisionSource, PermissionMode, PermissionPromptRequest, PermissionRule, ProviderQuotaState, ReasoningSummary, RemediationAction, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompleteData, TaskCompletionOutcome, UsageGetMetricsResult, UserToolSessionApproval, Verbosity } from "./session-events.js"; +import type { AbortReason, AgentModelPolicy, Attachment, AutoTier, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, IndexedSearchState, ManagedPermissionsContext, ManagedSettingsResolvedData, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerMetadata, McpServerSource, McpServerStatus, ModelChangeSource, ModelProviderKind, ModelProviderRef, PermissionDecisionSource, PermissionMode, PermissionPromptRequest, PermissionRule, PolicyHelperFailure, ProviderQuotaState, ReasoningSummary, RemediationAction, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompleteData, TaskCompletionOutcome, UsageGetMetricsResult, UserToolSessionApproval, Verbosity } from "./session-events.js"; /** A value that can be represented losslessly on the SDK JSON wire. */ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; @@ -16666,6 +16666,7 @@ export interface ManagedSettingsIngestResult { * Diagnostics emitted while resolving the layers, in the order they occurred. */ logMessages: ManagedSettingsLogMessage[]; + policyHelperFailure?: PolicyHelperFailure; } /** * One diagnostic emitted while ingesting managed settings. Returned rather than logged by the runtime so the host decides where ingestion diagnostics go, which matters because an ingestion runs before a session exists to carry a log sink. @@ -16934,6 +16935,13 @@ export interface ManagedSettingsResolveRequest { * Working directory used to run an organization policy helper. When omitted, sessionless resolution does not run the helper. */ workingDirectory?: string; + /** + * Why the Policy Helper is being invoked. Defaults to sessionStart. + */ + policyHelperReason?: /** Resolve policy for session startup. */ + | "sessionStart" + /** Resolve policy before plugin activation. */ + | "pluginActivation"; } /** * Effective enterprise managed settings for an account, resolved without a session. @@ -17103,6 +17111,7 @@ export interface ManagedSettingsResolveStartupPolicyResult { * Warnings raised while resolving, in the order they occurred. */ warnings: string[]; + policyHelperFailure?: PolicyHelperFailure; } /** * The managed layers to retain as this process's startup policy, with the outcome of fetching each one. @@ -39723,6 +39732,13 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin */ get: async (): Promise => connection.sendRequest("session.managedSettings.get", { sessionId }), + /** + * Runs a fresh Policy Helper evaluation for the live session's current account, registration, working directory, repository and client context, bypassing only a completed cached helper result for that exact context while coalescing an equivalent execution already in flight. The previous failure and safeguards remain active until fresh success is applied. Returns the same retained snapshot as session.managedSettings.get after the recheck settles. + * + * @returns Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. + */ + recheckPolicyHelper: async (): Promise => + connection.sendRequest("session.managedSettings.recheckPolicyHelper", { sessionId }), }, /** @experimental */ plugins: { diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 01eadcd7fa..708d90b3aa 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -12973,6 +12973,8 @@ export interface ManagedSettingsResolvedData { */ permissionsAllowIntersected?: boolean; permissionsContext?: ManagedPermissionsContext; + policyHelperCapabilities?: PolicyHelperCapabilities; + policyHelperFailure?: PolicyHelperFailure; /** * Whether the policy-helper managed-settings layer was present. The policy helper is the weakest channel: it fills keys no enterprise source set and can never replace one. */ @@ -13004,6 +13006,95 @@ export interface ManagedPermissionsContext { */ permissions?: JsonValue; } +/** + * Policy Helper failure and recovery features implemented by this runtime. Capability presence, not absence of an optional failure, determines whether a client may rely on these contracts. + */ +export interface PolicyHelperCapabilities { + /** + * The runtime supports session.managedSettings.recheckPolicyHelper for a fresh scoped helper evaluation. + */ + recheck: boolean; + /** + * The runtime emits PolicyHelperFailure on shared managed-settings snapshots and applicable structured operation errors. + */ + structuredFailure: boolean; +} +/** + * The latest unresolved Policy Helper failure for this policy context. Helper-authored text is bounded display-intended content but remains untrusted; render it as plain text, keep diagnostics secondary, and do not automatically log, persist, export, execute, or inject it into model context. + */ +export interface PolicyHelperFailure { + /** + * Managed authority that registered the helper, when known. Known values are device and server; clients must tolerate future values. + */ + authority?: string; + /** + * Stable runtime classification. Known values are helper-reported, registration-undetermined, working-directory, resolve, resolve-task, spawn, timeout, exit-status, output-too-large, envelope, schema, and internal. This is not a closed set; clients must tolerate unknown values. + */ + category: string; + /** + * Stable bounded helper-defined identifier for a valid structured helper error. Absent for runtime-detected failures and not globally unique. + */ + code?: string; + /** + * Optional bounded field-specific findings supplied by the helper. + */ + details?: PolicyHelperFailureDetail[]; + /** + * Optional bounded untrusted secondary troubleshooting text, such as trimmed stderr. Do not use it as primary UI copy. + */ + diagnostic?: string; + /** + * Effective runtime behavior: ignore continues with remaining or retained policy, while failClosed activates restrictive safeguards. This is runtime-owned, not helper-authored. + */ + failureMode: string; + helpLink?: PolicyHelperHelpLink; + /** + * Bounded plain-text primary explanation. Runtime-authored for runtime failures and helper-authored for a valid structured helper error. + */ + message: string; + /** + * True when an earlier successful helper-derived layer still contributes to this effective snapshot. This does not promise that every relaxation from the old layer was retained. + */ + previousPolicyRetained: boolean; + /** + * Invocation reason. Known values are sessionStart and pluginActivation; clients must tolerate future values. + */ + reason: string; + /** + * Optional bounded helper-authored plain-text recovery guidance. It is explanatory and must not be executed automatically. + */ + remediation?: string; + /** + * Runtime-calculated presentation severity: warning for ignore and error for failClosed. Error severity does not imply that session creation failed. + */ + severity: string; +} +/** + * One bounded field-specific finding reported by a Policy Helper. The path is a helper-defined logical configuration path, not necessarily a filesystem path. + */ +export interface PolicyHelperFailureDetail { + /** + * Bounded helper-authored plain-text explanation for this finding. + */ + message: string; + /** + * Optional helper-defined logical configuration path. + */ + path?: string; +} +/** + * An explicit bounded absolute HTTP(S) destination supplied by a Policy Helper. The runtime validates but never fetches, previews, resolves redirects for, or opens it; clients must sanitize it again before navigation. + */ +export interface PolicyHelperHelpLink { + /** + * Optional bounded helper-authored plain-text label. Clients may use localized fallback text when absent. + */ + label?: string; + /** + * Validated absolute HTTP(S) URL. + */ + url: string; +} /** * Session event "session.managed_settings_enforced". Runtime enforcement of enterprise managed settings: fires when the session blocks or caps a runtime action because enterprise policy governs it, so SDK clients can explain *why* an action was governed. Unlike `session.managed_settings_resolved` (which reports *what* is managed), this reports a concrete governed action — e.g. a user or host tried to turn on bypass permissions or Assisted Permissions while the corresponding policy disables it. Emitted live (not persisted to the session event log) on user/host-initiated attempts only, never for silent policy application. Marked experimental while the managed-settings surface stabilizes. */ diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 23f5b05264..6512c327d5 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -26,6 +26,7 @@ import { type GitHubTelemetryNotification, type ManagedSettings, type ModelInfo, + type PolicyHelperFailure, type SubagentStartHookOutput, type SubagentStopHookOutput, type SessionFsProvider, @@ -5483,12 +5484,17 @@ describe("RPC error data preservation", () => { it.each(["session.create", "session.resume"] as const)( "preserves structured data from %s", async (method) => { - const data = { - context: { - category: "structured-runtime-failure", - message: "The runtime preserved structured operation context.", - }, - }; + const policyHelperFailure = { + authority: "future-authority", + category: "future-category", + code: "helper.future-code", + failureMode: "future-mode", + message: "The Policy Helper rejected the current configuration.", + previousPolicyRetained: true, + reason: "future-reason", + severity: "future-severity", + } satisfies PolicyHelperFailure; + const data = { policyHelperFailure }; const responseError = new ResponseError(-32001, "session operation failed", data); const client = new CopilotClient({ autoStart: false }); (client as any).connection = { @@ -5515,6 +5521,7 @@ describe("RPC error data preservation", () => { expect(responseError.code).toBe(-32001); expect(responseError.message).toBe("session operation failed"); expect(responseError.data).toBe(data); + expect(responseError.data.policyHelperFailure).toBe(policyHelperFailure); } ); }); diff --git a/nodejs/test/policy-helper-failure.test.ts b/nodejs/test/policy-helper-failure.test.ts new file mode 100644 index 0000000000..95897867eb --- /dev/null +++ b/nodejs/test/policy-helper-failure.test.ts @@ -0,0 +1,122 @@ +import { ResponseError, type MessageConnection } from "vscode-jsonrpc/node.js"; +import { describe, expect, it, vi } from "vitest"; +import { createServerRpc, createSessionRpc } from "../src/generated/rpc.js"; +import type { ManagedSettingsResolvedData, PolicyHelperFailure } from "../src/index.js"; + +const policyHelperFailure = { + authority: "future-authority", + category: "future-category", + code: "helper.future-code", + details: [ + { + path: "allowedRepositories", + message: "Expected an array.", + }, + ], + diagnostic: "bounded diagnostic", + failureMode: "future-mode", + helpLink: { + url: "https://docs.example.com/policy-helper", + label: "Review configuration", + }, + message: "The Policy Helper rejected the current configuration.", + previousPolicyRetained: true, + reason: "future-reason", + remediation: "Update the configuration and recheck managed policy.", + severity: "future-severity", +} satisfies PolicyHelperFailure; + +const managedSettingsSnapshot = { + bypassPermissionsDisabled: true, + deviceManaged: false, + failClosed: true, + managedKeys: ["permissions"], + policyHelperCapabilities: { + recheck: true, + structuredFailure: true, + }, + policyHelperFailure, + policyHelperManaged: true, + serverManaged: true, + source: "policyHelper", +} satisfies ManagedSettingsResolvedData; + +describe("Policy Helper failure contracts", () => { + it("round trips the complete DTO and preserves future string values", () => { + const roundTripped = JSON.parse( + JSON.stringify(managedSettingsSnapshot) + ) as ManagedSettingsResolvedData; + + expect(roundTripped.policyHelperCapabilities).toEqual({ + recheck: true, + structuredFailure: true, + }); + expect(roundTripped.policyHelperFailure).toEqual(policyHelperFailure); + expect(roundTripped.policyHelperFailure?.category).toBe("future-category"); + expect(roundTripped.policyHelperFailure?.failureMode).toBe("future-mode"); + expect(roundTripped.policyHelperFailure?.severity).toBe("future-severity"); + expect(roundTripped.policyHelperFailure?.reason).toBe("future-reason"); + expect(roundTripped.policyHelperFailure?.authority).toBe("future-authority"); + expect(roundTripped.policyHelperFailure?.code).toBe("helper.future-code"); + }); + + it("keeps older runtime snapshots compatible", () => { + const legacySnapshot: ManagedSettingsResolvedData = { + bypassPermissionsDisabled: false, + deviceManaged: false, + failClosed: false, + managedKeys: [], + serverManaged: false, + source: "none", + }; + + expect(legacySnapshot.policyHelperCapabilities).toBeUndefined(); + expect(legacySnapshot.policyHelperFailure).toBeUndefined(); + }); + + it("binds the session-scoped recheck RPC and returns its authoritative snapshot", async () => { + const sendRequest = vi.fn().mockResolvedValue(managedSettingsSnapshot); + const rpc = createSessionRpc( + { sendRequest } as unknown as MessageConnection, + "policy-session" + ); + + const result = await rpc.managedSettings.recheckPolicyHelper(); + + expect(sendRequest).toHaveBeenCalledWith("session.managedSettings.recheckPolicyHelper", { + sessionId: "policy-session", + }); + expect(result.policyHelperCapabilities?.recheck).toBe(true); + expect(result.policyHelperFailure).toEqual(policyHelperFailure); + }); + + it("preserves Policy Helper context on policy-dependent activation errors", async () => { + const errorData = { + code: "plugin_activation_failed", + policyHelperFailure, + }; + const responseError = new ResponseError(-32001, "plugin activation failed", errorData); + const sendRequest = vi.fn().mockRejectedValue(responseError); + const rpc = createServerRpc({ sendRequest } as unknown as MessageConnection); + + const error = await rpc.managedSettings + .resolve({ + workingDirectory: "/workspace", + policyHelperReason: "pluginActivation", + }) + .then( + () => undefined, + (reason: unknown) => reason + ); + + expect(sendRequest).toHaveBeenCalledWith("managedSettings.resolve", { + workingDirectory: "/workspace", + policyHelperReason: "pluginActivation", + }); + expect(error).toBe(responseError); + expect(responseError.code).toBe(-32001); + expect(responseError.message).toBe("plugin activation failed"); + expect(responseError.data).toBe(errorData); + expect(responseError.data.policyHelperFailure).toBe(policyHelperFailure); + }); +}); diff --git a/rust/README.md b/rust/README.md index b24f2d0402..fafb693e1f 100644 --- a/rust/README.md +++ b/rust/README.md @@ -539,6 +539,28 @@ let forked = client .await?; ``` +Managed-settings snapshots carry explicit Policy Helper capabilities. Only call +the scoped recheck when the current snapshot advertises it; older runtimes omit +the capability object. + +```rust,ignore +let snapshot = session.rpc().managed_settings().get().await?; +if snapshot + .policy_helper_capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.recheck) +{ + let refreshed = session + .rpc() + .managed_settings() + .recheck_policy_helper() + .await?; + if let Some(failure) = refreshed.policy_helper_failure { + eprintln!("{}", failure.message); + } +} +``` + 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. @@ -1446,6 +1468,23 @@ if err.is_transport_failure() { } ``` +JSON-RPC failures retain structured `error.data` separately from their primary +code and message. Applicable session startup, resume, and policy-dependent +activation failures may include the same generated `PolicyHelperFailure` used +by managed-settings snapshots: + +```rust,ignore +use github_copilot_sdk::session_events::PolicyHelperFailure; + +if let Some(value) = err + .rpc_data() + .and_then(|data| data.get("policyHelperFailure")) +{ + let failure: PolicyHelperFailure = serde_json::from_value(value.clone())?; + eprintln!("{}", failure.message); +} +``` + ## Differences From Other SDKs The Rust SDK aligns closely with the Node, Python, Go, and .NET SDKs but diverges diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 6adf260241..c218e18371 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -15,9 +15,9 @@ pub use super::session_events::{ McpOauthWWWAuthenticateParams, McpServerMetadata, McpServerSource, McpServerStatus, ModelChangeSource, ModelProviderKind, ModelProviderRef, OmittedBinaryOmittedReason, PermissionDecisionSource, PermissionMode, PermissionPromptRequest, PermissionRule, - ProviderQuotaState, ReasoningSummary, RemediationAction, SessionLimitsConfig, SessionMode, - ShutdownType, SkillSource, TaskBlocker, TaskCompletionOutcome, UsageGetMetricsResult, - UserToolSessionApproval, Verbosity, + PolicyHelperCapabilities, PolicyHelperFailure, ProviderQuotaState, ReasoningSummary, + RemediationAction, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskBlocker, + TaskCompletionOutcome, UsageGetMetricsResult, UserToolSessionApproval, Verbosity, }; use crate::types::{RequestId, SessionEvent, SessionId}; @@ -888,6 +888,9 @@ pub mod rpc_methods { pub const SESSION_CONNECTORS_WITHDRAWPROJECTION: &str = "session.connectors.withdrawProjection"; /// `session.managedSettings.get` pub const SESSION_MANAGEDSETTINGS_GET: &str = "session.managedSettings.get"; + /// `session.managedSettings.recheckPolicyHelper` + pub const SESSION_MANAGEDSETTINGS_RECHECKPOLICYHELPER: &str = + "session.managedSettings.recheckPolicyHelper"; /// `session.plugins.list` pub const SESSION_PLUGINS_LIST: &str = "session.plugins.list"; /// `session.plugins.install` @@ -12737,6 +12740,12 @@ pub struct ManagedSettingsResolvedData { /// Reusable retained managed permission policy for managedSettings.permissions.evaluate, including source composition and session-local injection. Provided by live session snapshots; absent in older events. Refresh the context when managed policy changes. #[serde(skip_serializing_if = "Option::is_none")] pub permissions_context: Option, + /// Explicit Policy Helper structured-failure and recheck capabilities supported by this runtime. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_capabilities: Option, + /// The latest unresolved Policy Helper failure for this exact policy context. It remains present across retained-policy refresh failures and clears only after fresh success or authoritative removal. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_failure: Option, /// Whether the policy-helper managed-settings layer was present. The policy helper is the weakest channel: it fills keys no enterprise source set and can never replace one. #[serde(skip_serializing_if = "Option::is_none")] pub policy_helper_managed: Option, @@ -12913,6 +12922,9 @@ pub(crate) struct ManagedSettingsIngestResult { pub policy_helper_configured: bool, /// The policy helper's failure must be treated as fail-closed rather than ignored. pub policy_helper_fail_closed: bool, + /// Structured latest Policy Helper failure for this ingestion, when one occurred. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_failure: Option, /// The policy helper was configured but could not be run or its output could not be read. pub policy_helper_load_failed: bool, /// The layer produced by the platform policy helper, when one ran. @@ -13213,6 +13225,9 @@ pub struct ManagedSettingsResolveRequest { /// GitHub token to resolve instead of the current account. The call fails when the token cannot be resolved. #[serde(skip_serializing_if = "Option::is_none")] pub git_hub_token: Option, + /// Why the Policy Helper is being invoked. Defaults to sessionStart. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_reason: Option, /// Opaque account identifier returned by `account.getAllUsers`. When omitted, the current account is used, or device policy only when no account is signed in. #[serde(skip_serializing_if = "Option::is_none")] pub selection_id: Option, @@ -13342,6 +13357,9 @@ pub(crate) struct ManagedSettingsResolveStartupPolicyResult { pub managed_policy_present: bool, /// The permissions layer specifically could not be confirmed, so permission gates apply their fail-closed posture. pub permission_policy_undetermined: bool, + /// Structured latest Policy Helper failure for this startup resolution, when one occurred. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_failure: Option, /// The policy helper was configured but could not be run, or its output could not be read. pub policy_helper_load_failed: bool, /// The layer produced by the platform policy helper, when one ran. @@ -39294,6 +39312,76 @@ pub struct SessionManagedSettingsGetResult { /// Reusable retained managed permission policy for managedSettings.permissions.evaluate, including source composition and session-local injection. Provided by live session snapshots; absent in older events. Refresh the context when managed policy changes. #[serde(skip_serializing_if = "Option::is_none")] pub permissions_context: Option, + /// Explicit Policy Helper structured-failure and recheck capabilities supported by this runtime. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_capabilities: Option, + /// The latest unresolved Policy Helper failure for this exact policy context. It remains present across retained-policy refresh failures and clears only after fresh success or authoritative removal. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_failure: Option, + /// Whether the policy-helper managed-settings layer was present. The policy helper is the weakest channel: it fills keys no enterprise source set and can never replace one. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_managed: Option, + /// Whether the effective sandbox policy forces the sandbox on *only* because managed policy could not be determined, rather than because the policy requires it. Lets clients tell a user whose `--no-sandbox` was overridden that the sandbox stayed on as a fail-closed fallback, instead of attributing it to an administrator who set no such policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_enabled_by_undetermined_policy: Option, + /// Whether the server (account/org) managed-settings layer was present + pub server_managed: bool, + /// The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. + #[serde(skip_serializing_if = "Option::is_none")] + pub settings: Option, + /// Channel summary: `server`, `device`, `client`, or `policyHelper` when exactly one channel contributed; `mixed` when multiple channels contributed; otherwise `none`. Consult the per-channel booleans for exact provenance. + pub source: ManagedSettingsResolvedSource, +} + +/// 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 SessionManagedSettingsRecheckPolicyHelperParams { + /// Target session identifier + pub session_id: SessionId, +} + +/// Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. +/// +///
+/// +/// **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 SessionManagedSettingsRecheckPolicyHelperResult { + /// Whether an explicit enterprise policy restriction disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers; an unresolved policy does not force this on by itself. + pub bypass_permissions_disabled: bool, + /// Whether a session-local permissions layer injected by the SDK host was present + #[serde(skip_serializing_if = "Option::is_none")] + pub client_managed: Option, + /// Whether an actual device MDM/plist/registry/file managed-settings layer was present + pub device_managed: bool, + /// Whether managed policy could not be determined (e.g. a failed server fetch) and unresolved-policy safeguards remain active. This does not by itself disable bypass-permissions; `bypassPermissionsDisabled` reports only an explicit policy restriction. + pub fail_closed: bool, + /// The setting keys under enterprise management in the effective managed settings (e.g. `model`, `enabledPlugins`, `permissions`). Empty when no managed settings are in force. + pub managed_keys: Vec, + /// Whether at least two managed sources supplied permission allowlists, so enforcement intersects them and the flattened settings payload omits `permissions.allow`. + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions_allow_intersected: Option, + /// Reusable retained managed permission policy for managedSettings.permissions.evaluate, including source composition and session-local injection. Provided by live session snapshots; absent in older events. Refresh the context when managed policy changes. + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions_context: Option, + /// Explicit Policy Helper structured-failure and recheck capabilities supported by this runtime. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_capabilities: Option, + /// The latest unresolved Policy Helper failure for this exact policy context. It remains present across retained-policy refresh failures and clears only after fresh success or authoritative removal. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_failure: Option, /// Whether the policy-helper managed-settings layer was present. The policy helper is the weakest channel: it fills keys no enterprise source set and can never replace one. #[serde(skip_serializing_if = "Option::is_none")] pub policy_helper_managed: Option, @@ -47772,6 +47860,21 @@ pub enum ManagedSettingsServerPolicyResolution { Unknown, } +/// Why the Policy Helper is being invoked. Defaults to sessionStart. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ManagedSettingsResolveRequestPolicyHelperReason { + /// Resolve policy for session startup. + #[serde(rename = "sessionStart")] + SessionStart, + /// Resolve policy before plugin activation. + #[serde(rename = "pluginActivation")] + PluginActivation, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// Allowed values for the `McpAppsHostContextDetailsAvailableDisplayMode` enumeration. /// ///
diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index 954222dc91..446f77b91e 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -11640,6 +11640,34 @@ impl<'a> SessionRpcManagedSettings<'a> { .await?; Ok(serde_json::from_value(_value)?) } + + /// Runs a fresh Policy Helper evaluation for the live session's current account, registration, working directory, repository and client context, bypassing only a completed cached helper result for that exact context while coalescing an equivalent execution already in flight. The previous failure and safeguards remain active until fresh success is applied. Returns the same retained snapshot as session.managedSettings.get after the recheck settles. + /// + /// Wire method: `session.managedSettings.recheckPolicyHelper`. + /// + /// # Returns + /// + /// Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. + /// + ///
+ /// + /// **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 recheck_policy_helper(&self) -> Result { + let wire_params = serde_json::json!({ "sessionId": self.session.id() }); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_MANAGEDSETTINGS_RECHECKPOLICYHELPER, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } } /// `session.mcp.*` RPCs. diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 8c80f3ad10..75012ebde4 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -8119,6 +8119,74 @@ pub struct ManagedPermissionsContext { pub permissions: Option, } +/// Policy Helper failure and recovery features implemented by this runtime. Capability presence, not absence of an optional failure, determines whether a client may rely on these contracts. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyHelperCapabilities { + /// The runtime supports session.managedSettings.recheckPolicyHelper for a fresh scoped helper evaluation. + pub recheck: bool, + /// The runtime emits PolicyHelperFailure on shared managed-settings snapshots and applicable structured operation errors. + pub structured_failure: bool, +} + +/// One bounded field-specific finding reported by a Policy Helper. The path is a helper-defined logical configuration path, not necessarily a filesystem path. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyHelperFailureDetail { + /// Bounded helper-authored plain-text explanation for this finding. + pub message: String, + /// Optional helper-defined logical configuration path. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +/// An explicit bounded absolute HTTP(S) destination supplied by a Policy Helper. The runtime validates but never fetches, previews, resolves redirects for, or opens it; clients must sanitize it again before navigation. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyHelperHelpLink { + /// Optional bounded helper-authored plain-text label. Clients may use localized fallback text when absent. + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Validated absolute HTTP(S) URL. + pub url: String, +} + +/// The latest unresolved Policy Helper failure for this policy context. Helper-authored text is bounded display-intended content but remains untrusted; render it as plain text, keep diagnostics secondary, and do not automatically log, persist, export, execute, or inject it into model context. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyHelperFailure { + /// Managed authority that registered the helper, when known. Known values are device and server; clients must tolerate future values. + #[serde(skip_serializing_if = "Option::is_none")] + pub authority: Option, + /// Stable runtime classification. Known values are helper-reported, registration-undetermined, working-directory, resolve, resolve-task, spawn, timeout, exit-status, output-too-large, envelope, schema, and internal. This is not a closed set; clients must tolerate unknown values. + pub category: String, + /// Stable bounded helper-defined identifier for a valid structured helper error. Absent for runtime-detected failures and not globally unique. + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + /// Optional bounded field-specific findings supplied by the helper. + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option>, + /// Optional bounded untrusted secondary troubleshooting text, such as trimmed stderr. Do not use it as primary UI copy. + #[serde(skip_serializing_if = "Option::is_none")] + pub diagnostic: Option, + /// Effective runtime behavior: ignore continues with remaining or retained policy, while failClosed activates restrictive safeguards. This is runtime-owned, not helper-authored. + pub failure_mode: String, + /// Optional explicit validated additional-information link. URLs embedded in other text are not links. + #[serde(skip_serializing_if = "Option::is_none")] + pub help_link: Option, + /// Bounded plain-text primary explanation. Runtime-authored for runtime failures and helper-authored for a valid structured helper error. + pub message: String, + /// True when an earlier successful helper-derived layer still contributes to this effective snapshot. This does not promise that every relaxation from the old layer was retained. + pub previous_policy_retained: bool, + /// Invocation reason. Known values are sessionStart and pluginActivation; clients must tolerate future values. + pub reason: String, + /// Optional bounded helper-authored plain-text recovery guidance. It is explanatory and must not be executed automatically. + #[serde(skip_serializing_if = "Option::is_none")] + pub remediation: Option, + /// Runtime-calculated presentation severity: warning for ignore and error for failClosed. Error severity does not imply that session creation failed. + pub severity: String, +} + /// Session event "session.managed_settings_resolved". Effective enterprise managed settings applied to the session and their contributing channels. Emitted whenever managed policy is applied or reapplied, including session start, resume, and account switch. This ephemeral live snapshot is delivered to subscribers but not persisted to the session event log; initial resolution occurs before session.start. /// ///
@@ -8147,6 +8215,12 @@ pub struct SessionManagedSettingsResolvedData { /// Reusable retained managed permission policy for managedSettings.permissions.evaluate, including source composition and session-local injection. Provided by live session snapshots; absent in older events. Refresh the context when managed policy changes. #[serde(skip_serializing_if = "Option::is_none")] pub permissions_context: Option, + /// Explicit Policy Helper structured-failure and recheck capabilities supported by this runtime. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_capabilities: Option, + /// The latest unresolved Policy Helper failure for this exact policy context. It remains present across retained-policy refresh failures and clears only after fresh success or authoritative removal. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_failure: Option, /// Whether the policy-helper managed-settings layer was present. The policy helper is the weakest channel: it fills keys no enterprise source set and can never replace one. #[serde(skip_serializing_if = "Option::is_none")] pub policy_helper_managed: Option, diff --git a/rust/tests/prepared_session_test.rs b/rust/tests/prepared_session_test.rs index cddcda5ad7..e7058c9b43 100644 --- a/rust/tests/prepared_session_test.rs +++ b/rust/tests/prepared_session_test.rs @@ -17,6 +17,7 @@ use github_copilot_sdk::handler::{ McpAuthHandler, McpAuthRequest, McpAuthResult, PermissionHandler, PermissionResult, }; use github_copilot_sdk::session::PreparedSession; +use github_copilot_sdk::session_events::PolicyHelperFailure; use github_copilot_sdk::subscription::{EventSubscription, RecvErrorKind}; use github_copilot_sdk::types::{ CloudSessionOptions, CloudSessionRepository, MessageOptions, PermissionRequestData, RequestId, @@ -230,6 +231,19 @@ fn cloud_options() -> CloudSessionOptions { CloudSessionOptions::with_repository(CloudSessionRepository::new("octocat", "hello-world")) } +fn policy_helper_failure_data() -> Value { + json!({ + "authority": "future-authority", + "category": "future-category", + "code": "helper.future-code", + "failureMode": "future-mode", + "message": "The Policy Helper rejected the current configuration.", + "previousPolicyRetained": true, + "reason": "future-reason", + "severity": "future-severity", + }) +} + #[tokio::test] async fn client_call_preserves_structured_rpc_error_data() { let (client, mut server) = make_client(); @@ -737,10 +751,7 @@ async fn create_rpc_error_preserves_kind_and_cleans_up() { let (client, mut server) = make_client(); let session_id = SessionId::new("prepared-create-rpc-error"); let data = json!({ - "context": { - "category": "structured-runtime-failure", - "message": "The runtime preserved structured operation context.", - }, + "policyHelperFailure": policy_helper_failure_data(), }); let prepared = client @@ -766,6 +777,10 @@ async fn create_rpc_error_preserves_kind_and_cleans_up() { error.kind() ); assert_eq!(error.rpc_data(), Some(&data)); + let failure: PolicyHelperFailure = + serde_json::from_value(error.rpc_data().unwrap()["policyHelperFailure"].clone()).unwrap(); + assert_eq!(failure.category, "future-category"); + assert_eq!(failure.code.as_deref(), Some("helper.future-code")); await_no_registrations(&client).await; expect_closed(&mut events).await; } @@ -775,10 +790,7 @@ async fn resume_rpc_error_preserves_data_and_cleans_up() { let (client, mut server) = make_client(); let session_id = SessionId::new("prepared-resume-rpc-error"); let data = json!({ - "context": { - "category": "structured-runtime-failure", - "message": "The runtime preserved structured operation context.", - }, + "policyHelperFailure": policy_helper_failure_data(), }); let prepared = client @@ -802,6 +814,10 @@ async fn resume_rpc_error_preserves_data_and_cleans_up() { assert_eq!(error.kind(), &ErrorKind::Rpc { code: -32001 }); assert_eq!(error.message(), Some("session resume failed")); assert_eq!(error.rpc_data(), Some(&data)); + let failure: PolicyHelperFailure = + serde_json::from_value(error.rpc_data().unwrap()["policyHelperFailure"].clone()).unwrap(); + assert_eq!(failure.reason, "future-reason"); + assert_eq!(failure.authority.as_deref(), Some("future-authority")); await_no_registrations(&client).await; expect_closed(&mut events).await; } diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 48a31e9e02..4e55913302 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -23,12 +23,13 @@ use github_copilot_sdk::rpc::{ ConnectorCapabilities, ConnectorCatalogResult, ConnectorCatalogStatus, ConnectorConnectRequest, ConnectorConnectResult, ConnectorContinueRequest, ConnectorDisconnectResult, ConnectorMcpStatus, ConnectorReconcileOptions, ConnectorReconcileRequest, - ConnectorSessionAccount, ConnectorStatus, ModelSetAllowedModelsRequest, OpenCanvasInstance, - SendAgentMode, SendMode, SendRequest, SessionRpcConnectors, + ConnectorSessionAccount, ConnectorStatus, ManagedSettingsResolveRequest, + ManagedSettingsResolveRequestPolicyHelperReason, ModelSetAllowedModelsRequest, + OpenCanvasInstance, SendAgentMode, SendMode, SendRequest, SessionRpcConnectors, }; use github_copilot_sdk::session_events::{ - ManagedSettingsResolvedSource, McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig, - SessionManagedSettingsResolvedData, + ManagedSettingsResolvedSource, McpOauthRequiredData, PolicyHelperCapabilities, + PolicyHelperFailure, ReasoningSummary, SessionLimitsConfig, SessionManagedSettingsResolvedData, }; use github_copilot_sdk::types::{ AskUserVariant, CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, @@ -372,11 +373,26 @@ impl FakeServer { } async fn respond_error(&mut self, request: &Value, code: i64, message: &str) { + self.respond_error_with_data(request, code, message, None) + .await; + } + + async fn respond_error_with_data( + &mut self, + request: &Value, + code: i64, + message: &str, + data: Option, + ) { let id = request["id"].as_u64().unwrap(); + let mut error = serde_json::json!({ "code": code, "message": message }); + if let Some(data) = data { + error["data"] = data; + } let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, - "error": { "code": code, "message": message }, + "error": error, }); write_framed(&mut self.write, &serde_json::to_vec(&response).unwrap()).await; } @@ -1991,6 +2007,169 @@ fn managed_settings_resolved_event_preserves_client_provenance() { assert!(serialized.get("clientManaged").is_none()); } +fn policy_helper_failure_json() -> Value { + serde_json::json!({ + "authority": "future-authority", + "category": "future-category", + "code": "helper.future-code", + "details": [{ + "path": "allowedRepositories", + "message": "Expected an array.", + }], + "diagnostic": "bounded diagnostic", + "failureMode": "future-mode", + "helpLink": { + "url": "https://docs.example.com/policy-helper", + "label": "Review configuration", + }, + "message": "The Policy Helper rejected the current configuration.", + "previousPolicyRetained": true, + "reason": "future-reason", + "remediation": "Update the configuration and recheck managed policy.", + "severity": "future-severity", + }) +} + +fn policy_helper_snapshot_json() -> Value { + serde_json::json!({ + "bypassPermissionsDisabled": true, + "deviceManaged": false, + "failClosed": true, + "managedKeys": ["permissions"], + "policyHelperCapabilities": { + "recheck": true, + "structuredFailure": true, + }, + "policyHelperFailure": policy_helper_failure_json(), + "policyHelperManaged": true, + "serverManaged": true, + "source": "policyHelper", + }) +} + +#[test] +fn managed_settings_resolved_event_round_trips_policy_helper_failure() { + let data: SessionManagedSettingsResolvedData = + serde_json::from_value(policy_helper_snapshot_json()).unwrap(); + + let capabilities = data.policy_helper_capabilities.as_ref().unwrap(); + assert!(capabilities.recheck); + assert!(capabilities.structured_failure); + let failure = data.policy_helper_failure.as_ref().unwrap(); + assert_eq!(failure.category, "future-category"); + assert_eq!(failure.failure_mode, "future-mode"); + assert_eq!(failure.severity, "future-severity"); + assert_eq!(failure.reason, "future-reason"); + assert_eq!(failure.authority.as_deref(), Some("future-authority")); + assert_eq!(failure.code.as_deref(), Some("helper.future-code")); + assert_eq!( + serde_json::to_value(data).unwrap(), + policy_helper_snapshot_json() + ); + + let legacy: SessionManagedSettingsResolvedData = serde_json::from_value(serde_json::json!({ + "bypassPermissionsDisabled": false, + "deviceManaged": false, + "failClosed": false, + "managedKeys": [], + "serverManaged": false, + "source": "none", + })) + .unwrap(); + assert!(legacy.policy_helper_capabilities.is_none()); + assert!(legacy.policy_helper_failure.is_none()); +} + +#[tokio::test] +async fn managed_settings_recheck_policy_helper_binds_session_and_round_trips_failure() { + let (session, mut server) = create_session_pair().await; + let recheck = tokio::spawn(async move { + session + .rpc() + .managed_settings() + .recheck_policy_helper() + .await + }); + + let request = server.read_request().await; + assert_eq!( + request["method"], + "session.managedSettings.recheckPolicyHelper" + ); + assert_eq!(request["params"]["sessionId"], server.session_id); + server + .respond(&request, policy_helper_snapshot_json()) + .await; + + let result = timeout(TIMEOUT, recheck).await.unwrap().unwrap().unwrap(); + let capabilities: &PolicyHelperCapabilities = + result.policy_helper_capabilities.as_ref().unwrap(); + assert!(capabilities.recheck); + assert!(capabilities.structured_failure); + let failure: &PolicyHelperFailure = result.policy_helper_failure.as_ref().unwrap(); + assert_eq!(failure.category, "future-category"); + assert_eq!( + failure.message, + "The Policy Helper rejected the current configuration." + ); +} + +#[tokio::test] +async fn managed_settings_resolve_preserves_policy_helper_activation_error_data() { + let (client, server_read, server_write) = make_client(); + let mut server = FakeServer { + read: server_read, + write: server_write, + session_id: String::new(), + }; + let resolve = tokio::spawn({ + let client = client.clone(); + async move { + client + .rpc() + .managed_settings() + .resolve_with_params(ManagedSettingsResolveRequest { + policy_helper_reason: Some( + ManagedSettingsResolveRequestPolicyHelperReason::PluginActivation, + ), + working_directory: Some("/workspace".to_string()), + ..Default::default() + }) + .await + } + }); + + let request = server.read_request().await; + assert_eq!(request["method"], "managedSettings.resolve"); + assert_eq!(request["params"]["workingDirectory"], "/workspace"); + assert_eq!(request["params"]["policyHelperReason"], "pluginActivation"); + let data = serde_json::json!({ + "code": "plugin_activation_failed", + "policyHelperFailure": policy_helper_failure_json(), + }); + server + .respond_error_with_data( + &request, + -32001, + "plugin activation failed", + Some(data.clone()), + ) + .await; + + let error = timeout(TIMEOUT, resolve) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert_eq!(error.kind(), &ErrorKind::Rpc { code: -32001 }); + assert_eq!(error.message(), Some("plugin activation failed")); + assert_eq!(error.rpc_data(), Some(&data)); + let failure: PolicyHelperFailure = + serde_json::from_value(error.rpc_data().unwrap()["policyHelperFailure"].clone()).unwrap(); + assert_eq!(failure.category, "future-category"); + assert_eq!(failure.reason, "future-reason"); +} + fn make_client_with_telemetry( callback: github_copilot_sdk::github_telemetry::GitHubTelemetryCallback, ) -> (Client, tokio::io::DuplexStream, tokio::io::DuplexStream) {