diff --git a/nodejs/docs/extensions.md b/nodejs/docs/extensions.md index 5a50d9db3f..fb3e07137b 100644 --- a/nodejs/docs/extensions.md +++ b/nodejs/docs/extensions.md @@ -74,6 +74,69 @@ An approval is remembered against the exact set of names the user saw, so an ext An approved extension can pass a granted value to anything it starts, so ask only for what the extension genuinely needs. +## Desktop Notifications (Experimental) + +Notifications run in the extension provider process, not in canvas HTML. A +provider can notify while its canvas is hidden, provided the provider and its +session are still running. Declare the opt-in when joining: + +```js +const session = await joinSession({ requestNotifications: true }); +const capabilities = await session.notifications.getCapabilities(); +``` + +`getCapabilities()` reports `available`, `unsupported`, `unavailable`, or +`failed`. An available result includes the host platform, current extension and +OS permission states, supported click targets, and supported sound modes and +names. The opt-in alone grants no permission. After an explicit user action, +call `requestPermission()` and inspect its result before enabling delivery. +Permission requests and delivery must not run merely because a canvas opened. + +```js +const result = await session.notifications.show({ + title: "Pull request updated", + body: "A review is ready.", + onClick: { kind: "open-url", url: "https://github.com/example/project/pull/42" }, + sound: { kind: "none" }, +}); +``` + +`accepted` means the host handed the notification to the OS; it does not promise +that the OS displayed it. Other results are `denied`, `unsupported`, +`unavailable`, `failed`, or `invalid-request`. Transport errors reject the +promise. Never retry or switch to another notification mechanism after `show()` +fails: the OS may have accepted it before the response was lost. + +Titles accept 1-200 Unicode scalar values, bodies up to 1,000, and URLs up to +16,384. Oversized content is rejected with `invalid-request`, never truncated. + +All three methods accept a final, non-wire `{ signal: AbortSignal }` option. +For example, use `show(notification, { signal: controller.signal })` to cancel a +pending request when the user disables notifications. Cancellation is forwarded +to the runtime; an already-aborted signal sends nothing. It cannot retract a +notification that the OS has already accepted. + +Click targets are credential-free HTTP(S) URLs or +`{ kind: "focus-canvas", canvasId, instanceId? }` for a canvas declared by this +provider. There are no JavaScript callbacks, shell commands, or arbitrary canvas +actions. The runtime authenticates the originating provider; callers cannot +supply an identity. Supporting hosts authenticate URL activation data in OS +metadata so URL clicks can survive a restart. Canvas focus is live-session-only. + +Sound defaults to `{ kind: "default" }`. `{ kind: "none" }` requests silence; +`{ kind: "named", name }` requires a name advertised by the host. Unsupported +sounds are rejected, not silently substituted. + +An older runtime may ignore `requestNotifications`. Only capability discovery +maps JSON-RPC method-not-found to `unsupported`. A consumer may select a legacy +adapter after that negotiation, but must not bypass denied permission or switch +adapters after attempting delivery. + +The API does not add notification content to the agent conversation, session +events, or SDK logs. Do not log the notification, URL, callback context, or +host errors in provider code. OS notification retention is the explicit +exception; avoid secrets and sensitive information in notification text. + ## Further Reading - `examples.md` — Practical code examples for tools, hooks, events, and complete extensions diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index ed10b482b2..2cb6070baf 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -2195,6 +2195,9 @@ export class CopilotClient { enableManagedSettings: config.enableManagedSettings, enforceManagedModelDefaults: config.enforceManagedModelDefaults, managedSettings: config.managedSettings, + ...(extensionOptions?.requestNotifications === true + ? { requestNotifications: true } + : {}), ...(extensionOptions?.requestedEnvironmentVariables ? { requestedEnvironmentVariables: diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index f6adc32745..42cdb06961 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -56,6 +56,15 @@ export type JoinSessionConfig = Omit< * ``` */ requestedEnvironmentVariables?: string[]; + /** + * Opt this extension into the experimental desktop notification API. + * + * This declares intent, not permission. Inspect + * `session.notifications.getCapabilities()` and request permission after + * an explicit user action before showing notifications. Older runtimes + * may ignore this option; capability discovery reports unsupported. + */ + requestNotifications?: boolean; /** * Workflow handles to register when the extension joins the session. * @@ -66,6 +75,7 @@ export type JoinSessionConfig = Omit< }; export type { ExtensionInfo } from "./types.js"; +export type * from "./notifications.js"; export { defineWorkflow, WorkflowResumeError, @@ -133,6 +143,7 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise => connection.sendRequest("connectors.refresh", params), }, + /** @experimental */ + notifications: { + /** + * Registers notification callbacks on the native-nominated stdio control connection. Other connections cannot claim or replace this authority. + * + * @returns Result of registering callbacks on the native-nominated host connection. + */ + registerHost: async (): Promise => + connection.sendRequest("notifications.registerHost", {}), + /** + * Revokes this control connection's notification callback registration and pending canvas activations. + * + * @returns Result of revoking a native notification host registration. + */ + unregisterHost: async (): Promise => + connection.sendRequest("notifications.unregisterHost", {}), + }, }; } @@ -33275,6 +34422,46 @@ export function createInternalServerRpc(connection: MessageConnection) { connect: async (params: ConnectRequest): Promise => connection.sendRequest("connect", params), /** @experimental */ + mcp: { + /** @experimental */ + registry: { + /** + * Allocates an ID for one cancellable MCP registry search. The ID exists before the search starts, so callers can cancel before it starts. The networking stack supplies the cancellation namespace. The runtime retains at most 1,024 unused IDs. At capacity, another allocation can reclaim an unused ID. Active searches retain their IDs until they finish. + * + * @returns Request id naming a cancellable registry search. + */ + allocateRequestId: async (): Promise => + connection.sendRequest("mcp.registry.allocateRequestId", {}), + /** + * Searches the MCP registry the supplied credential may read, resolving the registry endpoint from policy first. Hosts usually send credential-free `AuthIdentity`; a `token` identity can be resolved only when it embeds a token, while `env` and `gh-cli` identities can use a token embedded in the request first. The runtime follows registry pages, keeps the newest entry per server name, cuts the list to `limit`, and sorts an empty-query result by GitHub stars. Each server object is carried opaquely. + * + * @param params Registry search terms, the credential to search under, and the request id that makes the search cancellable. + * + * @returns Servers selected from the registry response. + */ + search: async (params: McpRegistrySearchRequest): Promise => + connection.sendRequest("mcp.registry.search", params), + /** + * Abandons the registry search that uses the given request ID. It acts only on IDs from `mcp.registry.allocateRequestId`, so it never cancels another component's request. Answers `canceled: true` when it stops a running search. Answers `canceled: false` for unknown or reclaimed IDs, completed or canceled searches, and unused reservations. It releases an unused reservation, so a later search with that ID is refused. + * + * @param params Registry search to abandon. + * + * @returns Whether the cancel reached a live search. + */ + cancel: async (params: McpRegistryCancelRequest): Promise => + connection.sendRequest("mcp.registry.cancel", params), + }, + /** + * Reports whether the gh-replaceable GitHub MCP tools may be clipped for a session, which a host feeds back as the `excludeGhReplaceableTools` build option. The tools are redundant only when the session can reach a shell *and* the host has the `gh` that would replace them, so both halves are decided here. The shell half runs the platform shell tool through the runtime's own tool-filter matcher, so entry forms like `builtin:bash` and `builtin:*` behave exactly as they do when a session builds its tool catalog. The host half probes for `gh`, and is skipped entirely when the filters already rule the shell out. Host presence alone is not enough: a session restricted to, say, `view` would otherwise lose the built-in issue-read fallback while having no shell to replace it with. The answer describes the host the runtime runs on, so it is never forwarded to a remote engine. + * + * @param params The session tool filters that decide whether the session still has a shell to run `gh` with. + * + * @returns Whether the gh-replaceable GitHub MCP tools may be clipped for the session described by the request. + */ + shouldExcludeGitHubTools: async (params: McpShouldExcludeGitHubToolsRequest): Promise => + connection.sendRequest("mcp.shouldExcludeGitHubTools", params), + }, + /** @experimental */ agents: { /** * Lists the agents this runtime ships, by name. A consumer separating shipped agents from ones the user or a plugin authored should compare against these names rather than against `AgentInfo.source`: an authored agent may carry the `builtin` source while not being one of these, and the runtime treats the two as separate questions. `disableableNames` is the subset a user may turn off, which a client needs to decide whether to offer a toggle. `yamlBasedNames` is the subset backed by a shipped YAML definition, which a client needs before asking the runtime to load one. @@ -36027,6 +37214,41 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin stop: async (params: ScheduleStopRequest): Promise => connection.sendRequest("session.schedule.stop", { ...params, sessionId }), }, + /** @experimental */ + notifications: { + /** + * Queries native notification support and permission without prompting. Only authenticated extensions that opted in on resume may use this API. + * + * @returns Native host capabilities and permissions, queried without prompting. + */ + getCapabilities: async (): Promise => + connection.sendRequest("session.notifications.getCapabilities", { sessionId }), + /** + * Requests explicit host-owned extension consent and OS notification permission. Never called automatically during startup or delivery. + * + * @returns Result of explicit notification permission negotiation. + */ + requestPermission: async (): Promise => + connection.sendRequest("session.notifications.requestPermission", { sessionId }), + /** + * Hands one native notification to the owning host without prompting or retrying. Accepted means native enqueue acknowledgement, not guaranteed display. Content is never recorded in session history. + * + * @param params Native notification content, carried only by the live provider-to-host request. + * + * @returns Metadata-only result of one notification delivery attempt. + */ + show: async (params: SessionNotificationsShowRequest): Promise => + connection.sendRequest("session.notifications.show", { ...params, sessionId }), + /** + * Consumes a native host's one-shot canvas-focus activation, revalidating the original provider, session and instance. URL activations are owned by the native host and do not call this method. + * + * @param params Native host request to consume one live canvas activation. + * + * @returns Result of consuming a native canvas-focus activation. + */ + activate: async (params: SessionNotificationsActivateRequest): Promise => + connection.sendRequest("session.notifications.activate", { ...params, sessionId }), + }, }; } @@ -36844,6 +38066,35 @@ export interface InstallationsHandler { confirm(params: InstallationsConfirmRequest): Promise; } +/** Handler for `notifications` client global API methods. */ +/** @experimental */ +export interface NotificationsHandler { + /** + * Queries the registered native host for notification capabilities and permission for an authenticated extension. Must not prompt, persist the request, or log its origin. + * + * @param params Origin-only host capability or permission request. + * + * @returns Native host capabilities and permissions, queried without prompting. + */ + getCapabilities(params: NotificationsGetCapabilitiesRequest): Promise; + /** + * Asks the registered native host for explicit installation-scoped consent and OS permission. The host must bind runtime provenance to its trusted installation identity and honor request cancellation. + * + * @param params Origin-only host capability or permission request. + * + * @returns Result of explicit notification permission negotiation. + */ + requestPermission(params: NotificationsRequestPermissionRequest): Promise; + /** + * Enqueues one native notification on the registered host. Revalidate permission and cancellation before native handoff. Never retry, log, persist in an application content database, or emit the payload as a session event. A sealed URL activation may be retained in OS notification metadata. + * + * @param params One live native notification enqueue request. Never log or persist this payload. + * + * @returns Content-free native enqueue acknowledgement. + */ + show(params: NotificationsShowRequest): Promise; +} + /** All client global API handler groups. */ export interface ClientGlobalApiHandlers { host?: HostHandler; @@ -36852,6 +38103,7 @@ export interface ClientGlobalApiHandlers { gitHubTelemetry?: GitHubTelemetryHandler; gitHubToken?: GitHubTokenHandler; installations?: InstallationsHandler; + notifications?: NotificationsHandler; } /** @@ -36900,4 +38152,19 @@ export function registerClientGlobalApiHandlers( if (!handler) throw new Error("No installations client-global handler registered"); return handler.confirm(params); }); + connection.onRequest("notifications.getCapabilities", async (params: NotificationsGetCapabilitiesRequest) => { + const handler = handlers.notifications; + if (!handler) throw new Error("No notifications client-global handler registered"); + return handler.getCapabilities(params); + }); + connection.onRequest("notifications.requestPermission", async (params: NotificationsRequestPermissionRequest) => { + const handler = handlers.notifications; + if (!handler) throw new Error("No notifications client-global handler registered"); + return handler.requestPermission(params); + }); + connection.onRequest("notifications.show", async (params: NotificationsShowRequest) => { + const handler = handlers.notifications; + if (!handler) throw new Error("No notifications client-global handler registered"); + return handler.show(params); + }); } diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 77f15629ee..3b15ae7d4f 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -12923,7 +12923,7 @@ export interface ManagedSettingsResolvedEvent { /** @experimental */ export interface ManagedSettingsResolvedData { /** - * Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers, and forced on when `failClosed` is true. + * 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. */ bypassPermissionsDisabled: boolean; /** @@ -12935,7 +12935,7 @@ export interface ManagedSettingsResolvedData { */ deviceManaged: boolean; /** - * Whether managed policy could not be determined (e.g. a failed server fetch) and the session fell back to the fail-closed restriction. When true, restrictions such as disabling bypass-permissions are enforced even though `settings` may be absent. + * 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. */ failClosed: boolean; /** diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index ad9e427287..70539d75ae 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -20,6 +20,7 @@ export { export { DisableBypassPermissionsModes, RuntimeConnection } from "./types.js"; export { BuiltInTools, ToolSet } from "./toolSet.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; +export type * from "./notifications.js"; export { defineWorkflow, WorkflowResumeError, isWorkflowRunTerminal } from "./workflow.js"; export { Canvas, diff --git a/nodejs/src/notifications.ts b/nodejs/src/notifications.ts new file mode 100644 index 0000000000..cf5054516c --- /dev/null +++ b/nodejs/src/notifications.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +import { + CancellationTokenSource, + ErrorCodes, + ResponseError, + type MessageConnection, +} from "vscode-jsonrpc/node.js"; +import type { + NotificationCapabilitiesResult, + NotificationPermissionResult, + NotificationShowParams, + NotificationShowResult, +} from "./generated/rpc.js"; + +export type { + NotificationCapabilitiesResult, + NotificationClickAction, + NotificationClickKind, + NotificationOsPermissionState, + NotificationPermission, + NotificationPermissionResult, + NotificationPermissionState, + NotificationPlatform, + NotificationShowParams, + NotificationShowResult, + NotificationSound, + NotificationSounds, +} from "./generated/rpc.js"; + +/** Local request options. Never serialized into a notification payload. @experimental */ +export interface NotificationRequestOptions { + /** Cancels pending runtime/host work; cannot retract an OS-accepted notification. */ + signal?: AbortSignal; +} + +/** Provider-process desktop notifications. Does not require a visible canvas. @experimental */ +export interface SessionNotificationsApi { + /** Discover support and current permission without prompting or delivering a notification. */ + getCapabilities(options?: NotificationRequestOptions): Promise; + /** Request permission after an explicit user action, not when a canvas opens. */ + requestPermission(options?: NotificationRequestOptions): Promise; + /** + * Request one OS notification. Accepted means OS handoff, not confirmed display. + * Never retry or fall back after a failed or ambiguous delivery. + */ + show( + params: NotificationShowParams, + options?: NotificationRequestOptions + ): Promise; +} + +/** @internal */ +export function createSessionNotifications( + connection: MessageConnection, + sessionId: string +): SessionNotificationsApi { + async function request( + method: string, + params: object, + options?: NotificationRequestOptions + ): Promise { + const signal = options?.signal; + if (signal?.aborted) { + throw new DOMException("Notification request cancelled", "AbortError"); + } + const payload = { ...params, sessionId }; + if (!signal) { + return connection.sendRequest(method, payload); + } + const cancellation = new CancellationTokenSource(); + const abort = () => cancellation.cancel(); + signal.addEventListener("abort", abort, { once: true }); + try { + return await connection.sendRequest(method, payload, cancellation.token); + } finally { + signal.removeEventListener("abort", abort); + cancellation.dispose(); + } + } + + return { + async getCapabilities(options) { + try { + return await request( + "session.notifications.getCapabilities", + {}, + options + ); + } catch (error) { + if (error instanceof ResponseError && error.code === ErrorCodes.MethodNotFound) { + return { status: "unsupported" }; + } + throw error; + } + }, + requestPermission: (options) => + request( + "session.notifications.requestPermission", + {}, + options + ), + show: (params, options) => + request("session.notifications.show", params, options), + }; +} diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 02e60dfbcf..d2e9b2c446 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -22,6 +22,7 @@ import type { ModelSwitchAutoTierResult, } from "./generated/rpc.js"; import { type Canvas, CanvasError } from "./canvas.js"; +import { createSessionNotifications, type SessionNotificationsApi } from "./notifications.js"; import type { OpenCanvasInstance } from "./generated/rpc.js"; import { getTraceContext } from "./telemetry.js"; import { isResponseSchema, toJsonSchema } from "./schema.js"; @@ -484,6 +485,9 @@ export class CopilotSession { /** @internal Client session API handlers, populated by CopilotClient during create/resume. */ clientSessionApis: ClientSessionApiHandlers = {}; + /** Provider-process desktop notifications, independent of canvas visibility. @experimental */ + readonly notifications: SessionNotificationsApi; + /** * Friendly workflow API for running registered workflows by name or handle. * @@ -668,6 +672,7 @@ export class CopilotSession { this.mcpAuthHandler = options?.mcpAuthHandler; this.managedSettingsEnabled = options?.managedSettingsEnabled === true; this.onDisconnected = options?.onDisconnected; + this.notifications = createSessionNotifications(connection, sessionId); } /** diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 80588c2627..baead0001d 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -3267,6 +3267,8 @@ export interface TranscriptRecovery { * @internal */ export interface ExtensionJoinOptions { + /** Non-persisted extension opt-in; does not grant notification permission. */ + requestNotifications?: boolean; /** * Names of sensitive environment variables the extension asks the host to grant. * Sent on the `session.resume` wire payload as `requestedEnvironmentVariables`. diff --git a/nodejs/test/extension.test.ts b/nodejs/test/extension.test.ts index 132f5a8fc7..9218859837 100644 --- a/nodejs/test/extension.test.ts +++ b/nodejs/test/extension.test.ts @@ -80,6 +80,41 @@ describe("joinSession", () => { expect(resumeForExtension.mock.calls[1]![3]).toBeUndefined(); }); + it("forwards notification opt-in only through extension options", async () => { + process.env.SESSION_ID = "session-123"; + const resumeForExtension = vi + .spyOn(CopilotClient.prototype, "resumeSessionForExtension") + .mockResolvedValue({} as CopilotSession); + + await joinSession({ requestNotifications: true }); + await joinSession({ + requestNotifications: true, + requestedEnvironmentVariables: ["SDK_TEST_TOKEN"], + }); + + expect(resumeForExtension.mock.calls[0]![3]).toEqual({ requestNotifications: true }); + expect(resumeForExtension.mock.calls[1]![3]).toEqual({ + requestNotifications: true, + requestedEnvironmentVariables: ["SDK_TEST_TOKEN"], + }); + for (const [, config] of resumeForExtension.mock.calls) { + expect(config).not.toHaveProperty("requestNotifications"); + } + }); + + it("does not request notifications without an explicit opt-in", async () => { + process.env.SESSION_ID = "session-123"; + const resumeForExtension = vi + .spyOn(CopilotClient.prototype, "resumeSessionForExtension") + .mockResolvedValue({} as CopilotSession); + + await joinSession(); + await joinSession({ requestNotifications: false }); + + expect(resumeForExtension.mock.calls[0]![3]).toBeUndefined(); + expect(resumeForExtension.mock.calls[1]![3]).toBeUndefined(); + }); + it("strips a skill provider, which only the session's owning client may supply", async () => { process.env.SESSION_ID = "session-123"; const resumeForExtension = vi diff --git a/nodejs/test/notifications.test.ts b/nodejs/test/notifications.test.ts new file mode 100644 index 0000000000..63a8c03788 --- /dev/null +++ b/nodejs/test/notifications.test.ts @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +import { once } from "node:events"; +import { createServer, type Socket } from "node:net"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { + createMessageConnection, + ErrorCodes, + ResponseError, + type CancellationToken, + type MessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc/node.js"; +import { CopilotClient } from "../src/client.js"; +import { defaultJoinSessionPermissionHandler, RuntimeConnection } from "../src/types.js"; + +async function fixture(configure: (rpc: MessageConnection) => void = () => {}) { + const sockets = new Set(); + const connections = new Set(); + const resumed = vi.fn(({ sessionId }: { sessionId: string }) => ({ sessionId })); + const server = createServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + const rpc = createMessageConnection( + new StreamMessageReader(socket), + new StreamMessageWriter(socket) + ); + connections.add(rpc); + rpc.onRequest("connect", () => ({ protocolVersion: 3 })); + rpc.onRequest("ping", () => ({ message: "ok", timestamp: Date.now() })); + rpc.onRequest("session.resume", resumed); + configure(rpc); + rpc.listen(); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing fixture address"); + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(`127.0.0.1:${address.port}`), + }); + onTestFinished(async () => { + await client.forceStop(); + for (const rpc of connections) rpc.dispose(); + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }); + return { client, resumed }; +} + +describe("notification extension opt-in", () => { + it("sends the opt-in on extension resume without requiring a notification response", async () => { + const { client, resumed } = await fixture(); + const session = await client.resumeSessionForExtension( + "notification-session", + { onPermissionRequest: defaultJoinSessionPermissionHandler }, + undefined, + { requestNotifications: true } + ); + + expect(session.sessionId).toBe("notification-session"); + expect(resumed).toHaveBeenCalledTimes(1); + expect(resumed.mock.calls[0]?.[0]).toMatchObject({ + sessionId: "notification-session", + requestNotifications: true, + }); + }); + + describe("session.notifications", () => { + const available = { + status: "available", + platform: "darwin", + permission: { extension: "granted", os: "granted" }, + onClick: ["open-url", "focus-canvas"], + sounds: { default: true, none: true, named: ["Glass"] }, + }; + const notification = { + title: "Synthetic review", + body: "A synthetic review is ready.", + onClick: { kind: "open-url" as const, url: "https://example.test/review" }, + sound: { kind: "none" as const }, + }; + + async function join(client: CopilotClient) { + return client.resumeSessionForExtension( + "notification-session", + { onPermissionRequest: defaultJoinSessionPermissionHandler }, + undefined, + { requestNotifications: true } + ); + } + + it("discovers current capabilities without a handshake capability flag", async () => { + const getCapabilities = vi.fn((_request: unknown) => available); + const { client } = await fixture((rpc) => + rpc.onRequest("session.notifications.getCapabilities", getCapabilities) + ); + const session = await join(client); + + expect(await session.notifications.getCapabilities()).toEqual(available); + expect(getCapabilities.mock.calls[0]?.[0]).toEqual({ + sessionId: "notification-session", + }); + }); + + it("maps only capability method-not-found to unsupported", async () => { + const { client } = await fixture(); + const session = await join(client); + + await expect(session.notifications.getCapabilities()).resolves.toEqual({ + status: "unsupported", + }); + await expect(session.notifications.requestPermission()).rejects.toMatchObject({ + code: ErrorCodes.MethodNotFound, + }); + await expect(session.notifications.show(notification)).rejects.toMatchObject({ + code: ErrorCodes.MethodNotFound, + }); + }); + + it("does not turn failed capability requests into an unsupported fallback", async () => { + const { client } = await fixture((rpc) => { + rpc.onRequest("session.notifications.getCapabilities", () => { + throw new ResponseError(ErrorCodes.InternalError, "Synthetic host failure"); + }); + }); + const session = await join(client); + + await expect(session.notifications.getCapabilities()).rejects.toMatchObject({ + code: ErrorCodes.InternalError, + }); + }); + + it("forwards permissions and notification results without conversation events", async () => { + const permission = { + status: "completed", + permission: { extension: "granted", os: "not-required" }, + }; + const requestPermission = vi.fn(() => permission); + const show = vi.fn((_request: unknown) => ({ + status: "accepted", + notificationId: "synthetic-notification", + })); + const { client } = await fixture((rpc) => { + rpc.onRequest("session.notifications.requestPermission", requestPermission); + rpc.onRequest("session.notifications.show", show); + }); + const session = await join(client); + const events = vi.fn(); + session.on(events); + + await expect(session.notifications.requestPermission()).resolves.toEqual(permission); + const untypedNotification = { ...notification, sessionId: "untrusted-session" }; + await expect(session.notifications.show(untypedNotification)).resolves.toEqual({ + status: "accepted", + notificationId: "synthetic-notification", + }); + expect(show).toHaveBeenCalledTimes(1); + expect(show.mock.calls[0]?.[0]).toEqual({ + ...notification, + sessionId: "notification-session", + }); + expect(events).not.toHaveBeenCalled(); + }); + + it.each(["denied", "unsupported", "unavailable", "failed", "invalid-request"])( + "does not retry or fall back after delivery returns %s", + async (status) => { + const show = vi.fn(() => ({ status })); + const { client } = await fixture((rpc) => + rpc.onRequest("session.notifications.show", show) + ); + const session = await join(client); + + await expect(session.notifications.show(notification)).resolves.toEqual({ + status, + }); + expect(show).toHaveBeenCalledTimes(1); + } + ); + + it("does not retry a delivery whose response fails", async () => { + const show = vi.fn(() => { + throw new ResponseError(ErrorCodes.InternalError, "Synthetic handoff failure"); + }); + const { client } = await fixture((rpc) => + rpc.onRequest("session.notifications.show", show) + ); + const session = await join(client); + + await expect(session.notifications.show(notification)).rejects.toMatchObject({ + code: ErrorCodes.InternalError, + }); + expect(show).toHaveBeenCalledTimes(1); + }); + + it("sends nothing when the caller's signal is already aborted", async () => { + const requested = vi.fn(); + const { client } = await fixture((rpc) => { + rpc.onRequest("session.notifications.getCapabilities", requested); + rpc.onRequest("session.notifications.requestPermission", requested); + rpc.onRequest("session.notifications.show", requested); + }); + const session = await join(client); + const controller = new AbortController(); + controller.abort(); + const options = { signal: controller.signal }; + + await expect(session.notifications.getCapabilities(options)).rejects.toMatchObject({ + name: "AbortError", + }); + await expect(session.notifications.requestPermission(options)).rejects.toMatchObject({ + name: "AbortError", + }); + await expect(session.notifications.show(notification, options)).rejects.toMatchObject({ + name: "AbortError", + }); + expect(requested).not.toHaveBeenCalled(); + }); + + it("forwards cancellation to the runtime without serializing the signal", async () => { + const cancelled = vi.fn(); + const show = vi.fn((_request: unknown, token: CancellationToken) => { + return new Promise((_resolve, reject) => { + token.onCancellationRequested(() => { + cancelled(); + reject(new ResponseError(-32800, "Notification request cancelled")); + }); + }); + }); + const { client } = await fixture((rpc) => + rpc.onRequest("session.notifications.show", show) + ); + const session = await join(client); + const controller = new AbortController(); + const pending = session.notifications.show(notification, { + signal: controller.signal, + }); + const rejected = expect(pending).rejects.toMatchObject({ code: -32800 }); + await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(1)); + controller.abort(); + await rejected; + + expect(cancelled).toHaveBeenCalledTimes(1); + expect(show.mock.calls[0]?.[0]).toEqual({ + ...notification, + sessionId: "notification-session", + }); + expect(show).toHaveBeenCalledTimes(1); + }); + }); + + it("keeps notification opt-in off ordinary resume payloads", async () => { + const { client, resumed } = await fixture(); + const config = { + onPermissionRequest: defaultJoinSessionPermissionHandler, + requestNotifications: true, + }; + await client.resumeSession("ordinary-session", config); + + expect(resumed.mock.calls[0]?.[0]).not.toHaveProperty("requestNotifications"); + }); + + it.each([undefined, false])("omits an inactive extension opt-in (%s)", async (enabled) => { + const { client, resumed } = await fixture(); + await client.resumeSessionForExtension( + "notification-session", + { onPermissionRequest: defaultJoinSessionPermissionHandler }, + undefined, + { requestNotifications: enabled } + ); + + expect(resumed.mock.calls[0]?.[0]).not.toHaveProperty("requestNotifications"); + }); +}); diff --git a/nodejs/test/rust-codegen.test.ts b/nodejs/test/rust-codegen.test.ts index 53c471b104..cae5ad4a41 100644 --- a/nodejs/test/rust-codegen.test.ts +++ b/nodejs/test/rust-codegen.test.ts @@ -1,5 +1,6 @@ import type { ApiSchema } from "../../scripts/codegen/utils.ts"; import { + getApiSchemaPath, normalizeSchemaBrandCasing, postProcessSchema, propagateInternalVisibility, @@ -256,6 +257,44 @@ pub enum McpPlanTransportChoice { expect(code).not.toContain("Vec"); }); + it.each([ + "NotificationActivateResult", + "NotificationCapabilitiesResult", + "NotificationHostRegistrationResult", + "NotificationHostShowResult", + "NotificationHostUnregistrationResult", + "NotificationPermissionResult", + "NotificationShowResult", + ])("preserves %s as a typed status union", (name) => { + const code = generateApiTypesCode({ + definitions: { + [name]: { + title: name, + oneOf: ["accepted", "failed"].map((status) => ({ + type: "object", + required: ["status"], + properties: { status: { type: "string", const: status } }, + additionalProperties: false, + })), + }, + }, + } as ApiSchema); + + expect(code).toContain(`pub enum ${name} {`); + expect(code).toContain(`Accepted(${name}Accepted),`); + expect(code).toContain(`Failed(${name}Failed),`); + for (const [variant, wire] of [ + ["Accepted", "accepted"], + ["Failed", "failed"], + ]) { + const discriminator = code.match( + new RegExp(`pub enum ${name}${variant}Status \\{\\n([\\s\\S]*?)\\n\\}`) + )?.[1]; + expect(discriminator).toContain(`#[serde(rename = "${wire}")]`); + expect(discriminator).not.toContain("Unknown"); + } + }); + it.each(["anyOf", "oneOf"] as const)( "supports arbitrary required enum-reference discriminators in %s unions", (keyword) => { @@ -1015,18 +1054,14 @@ describe("Rust x-legacy-parameters", () => { ).toThrow(/Rust string enum Kind is requested for different values/); }); - it("keeps every const discriminator of the committed API schema distinct in Rust", () => { + it("keeps every const discriminator of the selected API schema distinct in Rust", async () => { + const schemaPath = await getApiSchemaPath(); // Mirror the generator's own schema preparation so emission order matches. const schema = propagateInternalVisibility( postProcessSchema( stripBooleanLiterals( normalizeSchemaBrandCasing( - JSON.parse( - readFileSync( - new URL("../../../../generated/api.schema.json", import.meta.url), - "utf8" - ) - ) as ApiSchema + JSON.parse(readFileSync(schemaPath, "utf8")) as ApiSchema ) ) as JSONSchema7 ) diff --git a/rust/README.md b/rust/README.md index 87484defec..eefb6144f2 100644 --- a/rust/README.md +++ b/rust/README.md @@ -137,6 +137,7 @@ transports. | `transport` | `Transport` | `Default`, `Stdio`, `InProcess`, `Tcp`, or `External` | | `extension_launch_provider` | `Option>` | Connection-global extension launch resolver | | `installation_confirmation_handler` | `Option>` | Experimental connection-global human installation review | +| `notification_handler` | `Option>` | Experimental native notification host | With the default `CliProgram::Resolve`, managed stdio and TCP transports resolve an explicit `CliProgram::Path(path)`, `COPILOT_CLI_PATH`, then the bundled `copilot-runtime` wrapper and adjacent `runtime.node`. In-process transport loads the native runtime library adjacent to that resolved runtime bundle. There is no PATH scanning. @@ -374,6 +375,48 @@ executable is a version-matched standalone Copilot binary, omit that variable and set `COPILOT_AUTO_UPDATE=false` so its embedded distribution remains selected. +#### Native notification host (experimental) + +Applications that deliver native notifications can configure +`ClientOptions::with_notification_handler(Arc)`. +The `notifications` module re-exports the generated notification DTOs. Its +`NotificationHandler` trait provides `get_capabilities`, `request_permission`, +and `show`, each with a `NotificationContext`. + +Callbacks are installed before the `notifications.registerHost` handshake, and +registration finishes before `Client::start` returns. Only a runtime-nominated +stdio root can register. Do not register helper, observer, TCP, or embedded +clients as notification hosts. Inspect `Client::notification_host_registration` +before offering notifications; configuring a handler alone is not registration +or permission. + +The runtime authenticates the callback's session, extension, and attachment. +The host must resolve that provenance against its trusted installation mapping +before granting persistent permission. In particular, an app-level bridge's +module path is not necessarily the original installed extension. Notification +opt-in, extension permission, and OS permission are separate checks. + +Handlers must observe `NotificationContext::cancellation()` and check it again +immediately before OS handoff. Request cancellation, connection closure, and +host shutdown retire pending callbacks. The SDK sanitizes callback errors and +does not send notification payloads through session events or logging. + +The `show` callback receives only a validated URL or an opaque canvas-focus +activation ID. Authenticate URL metadata before handing it to the OS if clicks +must survive an app restart; do not create a separate notification-content +database. For live canvas clicks, retain the context and call +`NotificationContext::activate_canvas`. It holds a weak connection reference, +so a focus receipt cannot keep a client alive. The runtime validates and consumes +the activation ID once; focus receipts do not survive a session restart. +Successful delivery does not cancel the callback context's token. That token is +not a receipt-validity check: use `activate_canvas` and combine pending work with +any application-owned generation cancellation. + +Report capabilities and delivery outcomes accurately. `accepted` means OS +handoff, not confirmed display. Unsupported named sounds must not silently fall +back to another sound. Never retry notification delivery after a timeout or lost +response, and do not log titles, bodies, URLs, callback contexts, or host errors. + ### Session Created via `Client::create_session` or `Client::resume_session`. Owns an internal event loop that dispatches CLI callbacks to the focused handler traits you install on `SessionConfig`, and broadcasts session events through `subscribe()`. diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 10d4e963b1..2f8b60148c 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -97,8 +97,16 @@ pub mod rpc_methods { pub const MCP_CONFIG_DISABLE: &str = "mcp.config.disable"; /// `mcp.config.reload` pub const MCP_CONFIG_RELOAD: &str = "mcp.config.reload"; + /// `mcp.registry.allocateRequestId` + pub const MCP_REGISTRY_ALLOCATEREQUESTID: &str = "mcp.registry.allocateRequestId"; + /// `mcp.registry.search` + pub const MCP_REGISTRY_SEARCH: &str = "mcp.registry.search"; + /// `mcp.registry.cancel` + pub const MCP_REGISTRY_CANCEL: &str = "mcp.registry.cancel"; /// `mcp.discover` pub const MCP_DISCOVER: &str = "mcp.discover"; + /// `mcp.shouldExcludeGitHubTools` + pub const MCP_SHOULDEXCLUDEGITHUBTOOLS: &str = "mcp.shouldExcludeGitHubTools"; /// `mcp.planInstall` pub const MCP_PLANINSTALL: &str = "mcp.planInstall"; /// `mcp.prepareInstall` @@ -330,6 +338,10 @@ pub mod rpc_methods { pub const CONNECTORS_LIST: &str = "connectors.list"; /// `connectors.refresh` pub const CONNECTORS_REFRESH: &str = "connectors.refresh"; + /// `notifications.registerHost` + pub const NOTIFICATIONS_REGISTERHOST: &str = "notifications.registerHost"; + /// `notifications.unregisterHost` + pub const NOTIFICATIONS_UNREGISTERHOST: &str = "notifications.unregisterHost"; /// `session.providers.getCatalog` pub const SESSION_PROVIDERS_GETCATALOG: &str = "session.providers.getCatalog"; /// `session.providers.discover` @@ -1022,6 +1034,15 @@ pub mod rpc_methods { pub const SESSION_SCHEDULE_REARMSELFPACED: &str = "session.schedule.rearmSelfPaced"; /// `session.schedule.stop` pub const SESSION_SCHEDULE_STOP: &str = "session.schedule.stop"; + /// `session.notifications.getCapabilities` + pub const SESSION_NOTIFICATIONS_GETCAPABILITIES: &str = "session.notifications.getCapabilities"; + /// `session.notifications.requestPermission` + pub const SESSION_NOTIFICATIONS_REQUESTPERMISSION: &str = + "session.notifications.requestPermission"; + /// `session.notifications.show` + pub const SESSION_NOTIFICATIONS_SHOW: &str = "session.notifications.show"; + /// `session.notifications.activate` + pub const SESSION_NOTIFICATIONS_ACTIVATE: &str = "session.notifications.activate"; /// `skillProvider.list` pub const SKILLPROVIDER_LIST: &str = "skillProvider.list"; /// `skillProvider.read` @@ -11328,14 +11349,14 @@ pub struct ManagedSettingsMeta { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ManagedSettingsResolvedData { - /// Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers, and forced on when `failClosed` is true. + /// 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 the session fell back to the fail-closed restriction. When true, restrictions such as disabling bypass-permissions are enforced even though `settings` may be absent. + /// 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, @@ -14026,6 +14047,91 @@ pub(crate) struct McpRegisterExternalClientRequest { pub(crate) transport: serde_json::Value, } +/// Registry search to abandon. +/// +///
+/// +/// **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(crate) struct McpRegistryCancelRequest { + /// Request ID from `mcp.registry.allocateRequestId` that the search uses. + pub request_id: i64, +} + +/// Whether the cancel reached a live search. +/// +///
+/// +/// **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(crate) struct McpRegistryCancelResult { + /// True when the cancel stopped a running search. False for unknown or reclaimed IDs, completed or canceled searches, and unused reservations. The cancel releases an unused reservation. + pub canceled: bool, +} + +/// Request id naming a cancellable registry search. +/// +///
+/// +/// **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(crate) struct McpRegistryRequestIdResult { + /// Request ID for `mcp.registry.search` and `mcp.registry.cancel`. It serves one search. Allocation can reclaim unused IDs at the 1,024-ID reservation limit. + pub request_id: i64, +} + +/// Registry search terms, the credential to search under, and the request id that makes the search cancellable. +/// +///
+/// +/// **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(crate) struct McpRegistrySearchRequest { + /// The credential the search runs under, carried opaquely. Hosts usually send credential-free `AuthIdentity`; a `token` identity can be resolved only when it embeds a token, while `env` and `gh-cli` identities can use a token embedded in the request first. + pub auth_info: serde_json::Value, + /// Maximum number of servers to return. + pub limit: i64, + /// Free-text query. Omitted or empty asks the registry for its top servers rather than searching. A value that is not a string is refused. + #[serde(skip_serializing_if = "Option::is_none")] + pub query: Option, + /// Repository used for the policy lookup, as `owner/name`. The policy selects the registry URL and whether the user token goes to the registry. The registry receives this repository only when the policy entry lists it as required context. A value that is not a string is refused. + #[serde(skip_serializing_if = "Option::is_none")] + pub repository: Option, + /// Request ID from `mcp.registry.allocateRequestId`. The search refuses unknown, reclaimed, or canceled IDs and IDs that another search or request already uses. + pub request_id: i64, +} + +/// Servers selected from the registry response. +/// +///
+/// +/// **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(crate) struct McpRegistrySearchResult { + /// The server objects, carried opaquely. The runtime follows pages, keeps the newest entry per server name, cuts the list to `limit`, and sorts an empty-query result by GitHub stars. Each server object remains unchanged because the registry owns that shape. + pub servers: serde_json::Value, +} + /// In-process MCP reload configuration. /// ///
@@ -14846,6 +14952,43 @@ pub struct McpSetEnvValueModeResult { pub mode: McpSetEnvValueModeDetails, } +/// The session tool filters that decide whether the session still has a shell to run `gh` with. +/// +///
+/// +/// **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(crate) struct McpShouldExcludeGitHubToolsRequest { + /// The session's tool allowlist, when it set one. Omitted means the session constrains nothing this way. + #[serde(skip_serializing_if = "Option::is_none")] + pub available_tools: Option>, + /// The session's tool denylist, when it set one. Omitted means the session constrains nothing this way. + #[serde(skip_serializing_if = "Option::is_none")] + pub excluded_tools: Option>, + /// How the allowlist and denylist combine when both are set. Omitted means the default every session gets, so a caller that never chose a precedence is answered as its sessions behave. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_filter_precedence: Option, +} + +/// Whether the gh-replaceable GitHub MCP tools may be clipped for the session described by the request. +/// +///
+/// +/// **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(crate) struct McpShouldExcludeGitHubToolsResult { + /// True only when both halves hold: the session's filters still reach the platform shell tool, and the host actually has the `gh` those tools would be replaced by. Feed it straight back as the `excludeGhReplaceableTools` build option. + pub exclude_gh_replaceable_tools: bool, +} + /// Server name and optional configuration for an individual MCP server start. Omit `config` for a config-free start-by-name of an already-configured server. /// ///
@@ -16888,6 +17031,847 @@ pub struct NameSetRequest { pub name: String, } +/// Native host request to consume one live canvas activation. +/// +///
+/// +/// **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 NotificationActivateParams { + /// Opaque one-shot credential originally returned only to this host. + pub activation_id: String, +} + +/// Focus this existing instance without constructing new input. +/// +///
+/// +/// **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 NotificationActivateResultActivatedTarget { + /// Provider-local canvas identifier. + pub canvas_id: String, + /// Authenticated owning extension. + pub extension_id: String, + /// Exact original ready instance. + pub instance_id: String, + /// Discriminator selecting this notification variant. + pub kind: NotificationActivateResultActivatedTargetKind, +} + +/// This original live instance may be focused once. +/// +///
+/// +/// **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 NotificationActivateResultActivated { + /// Discriminator selecting this notification variant. + pub status: NotificationActivateResultActivatedStatus, + /// Sanitized target; URL clicks never use this RPC. + pub target: NotificationActivateResultActivatedTarget, +} + +/// The caller is not the original native host. +/// +///
+/// +/// **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 NotificationActivateResultDenied { + /// Discriminator selecting this notification variant. + pub status: NotificationActivateResultDeniedStatus, +} + +/// The activation or its original owner/instance has expired. +/// +///
+/// +/// **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 NotificationActivateResultUnavailable { + /// Discriminator selecting this notification variant. + pub status: NotificationActivateResultUnavailableStatus, +} + +/// Activation failed. +/// +///
+/// +/// **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 NotificationActivateResultFailed { + /// Discriminator selecting this notification variant. + pub status: NotificationActivateResultFailedStatus, +} + +/// Notification permission at the installation and OS boundaries. +/// +///
+/// +/// **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 NotificationPermission { + /// Host-validated canonical installation consent. + pub extension: NotificationPermissionState, + /// Native OS authorization. + pub os: NotificationOsPermissionState, +} + +/// Exact sound support advertised by the native host. +/// +///
+/// +/// **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 NotificationSounds { + /// Whether the platform default notification sound is supported. + pub default: bool, + /// Supported platform sound names. Names not in this list must be rejected. + pub named: Vec, + /// Whether silent delivery is supported. + pub none: bool, +} + +/// The host supports native notifications. +/// +///
+/// +/// **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 NotificationCapabilitiesResultAvailable { + /// Supported click actions. + pub on_click: Vec, + /// Current installation and OS permission. + pub permission: NotificationPermission, + /// Platform executing native notification effects. + pub platform: NotificationPlatform, + /// Exact supported sounds. + pub sounds: NotificationSounds, + /// Discriminator selecting this notification variant. + pub status: NotificationCapabilitiesResultAvailableStatus, +} + +/// This runtime or host does not support native notifications. +/// +///
+/// +/// **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 NotificationCapabilitiesResultUnsupported { + /// Discriminator selecting this notification variant. + pub status: NotificationCapabilitiesResultUnsupportedStatus, +} + +/// The original host or extension is no longer available. +/// +///
+/// +/// **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 NotificationCapabilitiesResultUnavailable { + /// Discriminator selecting this notification variant. + pub status: NotificationCapabilitiesResultUnavailableStatus, +} + +/// The operation failed without exposing content or platform errors. +/// +///
+/// +/// **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 NotificationCapabilitiesResultFailed { + /// Discriminator selecting this notification variant. + pub status: NotificationCapabilitiesResultFailedStatus, +} + +/// Open a credential-free absolute HTTP or HTTPS URL. +/// +///
+/// +/// **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 NotificationClickActionOpenUrl { + /// Discriminator selecting this notification variant. + pub kind: NotificationClickActionOpenUrlKind, + /// Destination URL; the host may seal it in OS metadata for restart-safe activation. + pub url: String, +} + +/// Focus an existing canvas owned by this extension connection. +/// +///
+/// +/// **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 NotificationClickActionFocusCanvas { + /// Registered provider-local canvas identifier. + pub canvas_id: String, + /// Existing instance; omission requires exactly one matching ready instance. + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_id: Option, + /// Discriminator selecting this notification variant. + pub kind: NotificationClickActionFocusCanvasKind, +} + +/// Runtime-discovered extension origin, never supplied by a notification caller. +/// +///
+/// +/// **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 NotificationExtensionOrigin { + /// Runtime discovery identity. + pub id: String, + /// Host-discovered module path. App bridge origins must be resolved through trusted launch metadata before selecting a consent key. + pub module_path: String, + /// Discovered extension name, not a caller-supplied label. + pub name: String, + /// Discovery source. + pub source: String, +} + +/// Exact live canvas instance authorized for native host focus. +/// +///
+/// +/// **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 NotificationFocusTarget { + /// Provider-local canvas identifier. + pub canvas_id: String, + /// Authenticated owning extension. + pub extension_id: String, + /// Exact original ready instance. + pub instance_id: String, + /// Discriminator selecting this notification variant. + pub kind: NotificationFocusTargetKind, +} + +/// A restart-safe URL target owned by the native host. +/// +///
+/// +/// **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 NotificationHostClickActionOpenUrl { + /// Discriminator selecting this notification variant. + pub kind: NotificationHostClickActionOpenUrlKind, + /// Validated HTTP or HTTPS destination. Retain only in authenticated OS metadata, not an application content database. + pub url: String, +} + +/// A one-shot runtime canvas-focus activation. +/// +///
+/// +/// **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 NotificationHostClickActionFocusCanvas { + /// Opaque activation credential scoped to the original live host and provider. + pub activation_id: String, + /// Discriminator selecting this notification variant. + pub kind: NotificationHostClickActionFocusCanvasKind, +} + +/// Authenticated origin for a callback to the single native host. +/// +///
+/// +/// **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 NotificationHostContext { + /// Opaque live attachment identity; never a persistent installation grant. + pub attachment_id: String, + /// Trusted discovery provenance for host installation binding. + pub extension: NotificationExtensionOrigin, + /// Runtime session owning the extension, not the active UI conversation. + pub session_id: SessionId, +} + +/// This connection is the registered notification host. +/// +///
+/// +/// **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 NotificationHostRegistrationResultRegistered { + /// Discriminator selecting this notification variant. + pub status: NotificationHostRegistrationResultRegisteredStatus, +} + +/// The connection is not the nominated host. +/// +///
+/// +/// **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 NotificationHostRegistrationResultDenied { + /// Discriminator selecting this notification variant. + pub status: NotificationHostRegistrationResultDeniedStatus, +} + +/// This transport cannot host native notifications. +/// +///
+/// +/// **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 NotificationHostRegistrationResultUnsupported { + /// Discriminator selecting this notification variant. + pub status: NotificationHostRegistrationResultUnsupportedStatus, +} + +/// The connection is unavailable. +/// +///
+/// +/// **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 NotificationHostRegistrationResultUnavailable { + /// Discriminator selecting this notification variant. + pub status: NotificationHostRegistrationResultUnavailableStatus, +} + +/// Registration failed. +/// +///
+/// +/// **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 NotificationHostRegistrationResultFailed { + /// Discriminator selecting this notification variant. + pub status: NotificationHostRegistrationResultFailedStatus, +} + +/// Origin-only host capability or permission request. +/// +///
+/// +/// **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 NotificationHostRequest { + /// Engine-authenticated caller context. + pub context: NotificationHostContext, +} + +/// Use the native default sound. +/// +///
+/// +/// **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 NotificationSoundDefault { + /// Discriminator selecting this notification variant. + pub kind: NotificationSoundDefaultKind, +} + +/// Deliver without sound. +/// +///
+/// +/// **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 NotificationSoundNone { + /// Discriminator selecting this notification variant. + pub kind: NotificationSoundNoneKind, +} + +/// Use exactly one advertised platform sound. +/// +///
+/// +/// **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 NotificationSoundNamed { + /// Discriminator selecting this notification variant. + pub kind: NotificationSoundNamedKind, + /// Exact advertised sound name, not a filesystem path. + pub name: String, +} + +/// One live native notification enqueue request. Never log or persist this payload. +/// +///
+/// +/// **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, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationHostShowParams { + /// Optional plain-text subtext. + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + /// Engine-authenticated caller context. + pub context: NotificationHostContext, + /// Runtime-generated delivery operation identifier. + pub notification_id: String, + /// Validated URL or one-shot canvas activation. + #[serde(skip_serializing_if = "Option::is_none")] + pub on_click: Option, + /// Requested sound, with the default made explicit. + pub sound: NotificationSound, + /// Plain-text title. + pub title: String, +} + +/// Native enqueue acknowledged; display is not guaranteed. +/// +///
+/// +/// **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 NotificationHostShowResultAccepted { + /// Discriminator selecting this notification variant. + pub status: NotificationHostShowResultAcceptedStatus, +} + +/// Installation or OS permission does not allow delivery. +/// +///
+/// +/// **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 NotificationHostShowResultDenied { + /// Discriminator selecting this notification variant. + pub status: NotificationHostShowResultDeniedStatus, +} + +/// Requested behavior is unsupported. +/// +///
+/// +/// **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 NotificationHostShowResultUnsupported { + /// Discriminator selecting this notification variant. + pub status: NotificationHostShowResultUnsupportedStatus, +} + +/// The original host, extension or canvas is unavailable. +/// +///
+/// +/// **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 NotificationHostShowResultUnavailable { + /// Discriminator selecting this notification variant. + pub status: NotificationHostShowResultUnavailableStatus, +} + +/// Handoff failed or its result is unknown. Never automatically retry. +/// +///
+/// +/// **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 NotificationHostShowResultFailed { + /// Discriminator selecting this notification variant. + pub status: NotificationHostShowResultFailedStatus, +} + +/// Content or action validation failed. +/// +///
+/// +/// **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 NotificationHostShowResultInvalidRequest { + /// Discriminator selecting this notification variant. + pub status: NotificationHostShowResultInvalidRequestStatus, +} + +/// This host registration has been revoked. +/// +///
+/// +/// **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 NotificationHostUnregistrationResultUnregistered { + /// Discriminator selecting this notification variant. + pub status: NotificationHostUnregistrationResultUnregisteredStatus, +} + +/// The connection does not own the registration. +/// +///
+/// +/// **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 NotificationHostUnregistrationResultDenied { + /// Discriminator selecting this notification variant. + pub status: NotificationHostUnregistrationResultDeniedStatus, +} + +/// This transport cannot host native notifications. +/// +///
+/// +/// **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 NotificationHostUnregistrationResultUnsupported { + /// Discriminator selecting this notification variant. + pub status: NotificationHostUnregistrationResultUnsupportedStatus, +} + +/// The connection is unavailable. +/// +///
+/// +/// **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 NotificationHostUnregistrationResultUnavailable { + /// Discriminator selecting this notification variant. + pub status: NotificationHostUnregistrationResultUnavailableStatus, +} + +/// Unregistration failed. +/// +///
+/// +/// **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 NotificationHostUnregistrationResultFailed { + /// Discriminator selecting this notification variant. + pub status: NotificationHostUnregistrationResultFailedStatus, +} + +/// The consent flow completed or was dismissed. +/// +///
+/// +/// **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 NotificationPermissionResultCompleted { + /// Current permission; dismissal remains not-requested. + pub permission: NotificationPermission, + /// Discriminator selecting this notification variant. + pub status: NotificationPermissionResultCompletedStatus, +} + +/// Permission negotiation is unsupported. +/// +///
+/// +/// **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 NotificationPermissionResultUnsupported { + /// Discriminator selecting this notification variant. + pub status: NotificationPermissionResultUnsupportedStatus, +} + +/// The original host or extension is unavailable. +/// +///
+/// +/// **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 NotificationPermissionResultUnavailable { + /// Discriminator selecting this notification variant. + pub status: NotificationPermissionResultUnavailableStatus, +} + +/// The operation failed without exposing content or platform errors. +/// +///
+/// +/// **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 NotificationPermissionResultFailed { + /// Discriminator selecting this notification variant. + pub status: NotificationPermissionResultFailedStatus, +} + +/// Native notification content, carried only by the live provider-to-host request. +/// +///
+/// +/// **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 NotificationShowParams { + /// Optional plain-text subtext. + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + /// Optional restricted click action. + #[serde(skip_serializing_if = "Option::is_none")] + pub on_click: Option, + /// Requested sound; omission means default. + #[serde(skip_serializing_if = "Option::is_none")] + pub sound: Option, + /// Plain-text title. + pub title: String, +} + +/// The OS accepted enqueueing, which does not guarantee display. +/// +///
+/// +/// **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 NotificationShowResultAccepted { + /// Runtime-generated operation identifier, not an activation credential. + pub notification_id: String, + /// Discriminator selecting this notification variant. + pub status: NotificationShowResultAcceptedStatus, +} + +/// Installation or OS permission does not allow delivery. +/// +///
+/// +/// **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 NotificationShowResultDenied { + /// Discriminator selecting this notification variant. + pub status: NotificationShowResultDeniedStatus, +} + +/// Requested notification behavior is unsupported. +/// +///
+/// +/// **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 NotificationShowResultUnsupported { + /// Discriminator selecting this notification variant. + pub status: NotificationShowResultUnsupportedStatus, +} + +/// The original host, extension or canvas is unavailable. +/// +///
+/// +/// **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 NotificationShowResultUnavailable { + /// Discriminator selecting this notification variant. + pub status: NotificationShowResultUnavailableStatus, +} + +/// Handoff failed or its result is unknown. Do not automatically retry. +/// +///
+/// +/// **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 NotificationShowResultFailed { + /// Discriminator selecting this notification variant. + pub status: NotificationShowResultFailedStatus, +} + +/// Content or a restricted action failed validation. +/// +///
+/// +/// **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 NotificationShowResultInvalidRequest { + /// Discriminator selecting this notification variant. + pub status: NotificationShowResultInvalidRequestStatus, +} + /// Source descriptor for a `session.options.update` content-exclusion rule, with source name and type. /// ///
@@ -30584,6 +31568,21 @@ pub struct McpConfigListResult { pub servers: HashMap, } +/// Request id naming a cancellable registry search. +/// +///
+/// +/// **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(crate) struct McpRegistryAllocateRequestIdResult { + /// Request ID for `mcp.registry.search` and `mcp.registry.cancel`. It serves one search. Allocation can reclaim unused IDs at the 1,024-ID reservation limit. + pub request_id: i64, +} + /// Extensions discovered from persisted Copilot home state and their effective loading mode. Launch-scoped additional plugins are not included. /// ///
@@ -34994,14 +35993,14 @@ pub struct SessionManagedSettingsGetParams { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionManagedSettingsGetResult { - /// Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers, and forced on when `failClosed` is true. + /// 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 the session fell back to the fail-closed restriction. When true, restrictions such as disabling bypass-permissions are enforced even though `settings` may be absent. + /// 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, @@ -37869,6 +38868,75 @@ pub struct SessionScheduleStopResult { pub entry: Option, } +/// 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 SessionNotificationsGetCapabilitiesParams { + /// Target session identifier + 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 SessionNotificationsRequestPermissionParams { + /// Target session identifier + pub session_id: SessionId, +} + +/// Native notification content, carried only by the live provider-to-host request. +/// +///
+/// +/// **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 SessionNotificationsShowParams { + /// Plain-text title. + pub title: String, + /// Optional plain-text subtext. + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + /// Optional restricted click action. + #[serde(skip_serializing_if = "Option::is_none")] + pub on_click: Option, + /// Requested sound; omission means default. + #[serde(skip_serializing_if = "Option::is_none")] + pub sound: Option, +} + +/// Native host request to consume one live canvas activation. +/// +///
+/// +/// **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 SessionNotificationsActivateParams { + /// Opaque one-shot credential originally returned only to this host. + pub activation_id: String, +} + /// Identifies the target session. /// ///
@@ -43936,6 +45004,28 @@ pub enum McpSetEnvValueModeDetails { Unknown, } +/// Controls how availableTools (allowlist) and excludedTools (denylist) combine when both are set. +/// +///
+/// +/// **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, PartialEq, Eq, Serialize, Deserialize)] +pub enum OptionsUpdateToolFilterPrecedence { + /// If availableTools is set, it is the only constraint that applies (excludedTools is ignored). Preserves CLI / pre-existing client behavior. Default. + #[serde(rename = "available")] + Available, + /// A tool is enabled if and only if it matches the allowlist (or the allowlist is unset) AND it does not match the denylist. Makes 'all except X' expressible by combining the two lists. + #[serde(rename = "excluded")] + Excluded, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// Hosting platform type of the repository /// ///
@@ -44358,6 +45448,624 @@ pub enum ModelSwitchAutoTierStatus { Unknown, } +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationActivateResultActivatedStatus { + #[serde(rename = "activated")] + #[default] + Activated, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationActivateResultActivatedTargetKind { + #[serde(rename = "focus-canvas")] + #[default] + FocusCanvas, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationActivateResultDeniedStatus { + #[serde(rename = "denied")] + #[default] + Denied, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationActivateResultUnavailableStatus { + #[serde(rename = "unavailable")] + #[default] + Unavailable, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationActivateResultFailedStatus { + #[serde(rename = "failed")] + #[default] + Failed, +} + +/// Result of consuming a native canvas-focus activation. +/// +///
+/// +/// **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, Serialize, Deserialize)] +#[serde(untagged)] +pub enum NotificationActivateResult { + Activated(NotificationActivateResultActivated), + Denied(NotificationActivateResultDenied), + Unavailable(NotificationActivateResultUnavailable), + Failed(NotificationActivateResultFailed), +} + +/// Click behaviors the native host can execute. +/// +///
+/// +/// **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, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationClickKind { + /// Open a validated HTTP or HTTPS URL. + #[serde(rename = "open-url")] + OpenUrl, + /// Focus a caller-owned live canvas instance. + #[serde(rename = "focus-canvas")] + FocusCanvas, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// Explicit installation-scoped notification consent. +/// +///
+/// +/// **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, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationPermissionState { + /// No decision, including a dismissed consent prompt. + #[serde(rename = "not-requested")] + NotRequested, + /// Explicitly allowed by the user. + #[serde(rename = "granted")] + Granted, + /// Explicitly denied by the user. + #[serde(rename = "denied")] + Denied, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// Native OS authorization state, separate from installation consent. +/// +///
+/// +/// **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, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationOsPermissionState { + /// Authorization has not been requested. + #[serde(rename = "not-requested")] + NotRequested, + /// The OS has granted authorization. + #[serde(rename = "granted")] + Granted, + /// The OS has denied authorization. + #[serde(rename = "denied")] + Denied, + /// This platform has no OS authorization gate. + #[serde(rename = "not-required")] + NotRequired, + /// The OS permission facility is unsupported. + #[serde(rename = "unsupported")] + Unsupported, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// Platform of the native notification host, independent of the extension 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, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationPlatform { + /// macOS. + #[serde(rename = "darwin")] + Darwin, + /// Windows. + #[serde(rename = "win32")] + Win32, + /// Linux. + #[serde(rename = "linux")] + Linux, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationCapabilitiesResultAvailableStatus { + #[serde(rename = "available")] + #[default] + Available, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationCapabilitiesResultUnsupportedStatus { + #[serde(rename = "unsupported")] + #[default] + Unsupported, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationCapabilitiesResultUnavailableStatus { + #[serde(rename = "unavailable")] + #[default] + Unavailable, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationCapabilitiesResultFailedStatus { + #[serde(rename = "failed")] + #[default] + Failed, +} + +/// Native host capabilities and permissions, queried without prompting. +/// +///
+/// +/// **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, Serialize, Deserialize)] +#[serde(untagged)] +pub enum NotificationCapabilitiesResult { + Available(NotificationCapabilitiesResultAvailable), + Unsupported(NotificationCapabilitiesResultUnsupported), + Unavailable(NotificationCapabilitiesResultUnavailable), + Failed(NotificationCapabilitiesResultFailed), +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationClickActionOpenUrlKind { + #[serde(rename = "open-url")] + #[default] + OpenUrl, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationClickActionFocusCanvasKind { + #[serde(rename = "focus-canvas")] + #[default] + FocusCanvas, +} + +/// Restricted action performed when a notification is clicked. +/// +///
+/// +/// **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, Serialize, Deserialize)] +#[serde(untagged)] +pub enum NotificationClickAction { + OpenUrl(NotificationClickActionOpenUrl), + FocusCanvas(NotificationClickActionFocusCanvas), +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationFocusTargetKind { + #[serde(rename = "focus-canvas")] + #[default] + FocusCanvas, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostClickActionOpenUrlKind { + #[serde(rename = "open-url")] + #[default] + OpenUrl, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostClickActionFocusCanvasKind { + #[serde(rename = "focus-canvas")] + #[default] + FocusCanvas, +} + +/// Validated native-host click behavior. +/// +///
+/// +/// **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, Serialize, Deserialize)] +#[serde(untagged)] +pub enum NotificationHostClickAction { + OpenUrl(NotificationHostClickActionOpenUrl), + FocusCanvas(NotificationHostClickActionFocusCanvas), +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostRegistrationResultRegisteredStatus { + #[serde(rename = "registered")] + #[default] + Registered, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostRegistrationResultDeniedStatus { + #[serde(rename = "denied")] + #[default] + Denied, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostRegistrationResultUnsupportedStatus { + #[serde(rename = "unsupported")] + #[default] + Unsupported, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostRegistrationResultUnavailableStatus { + #[serde(rename = "unavailable")] + #[default] + Unavailable, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostRegistrationResultFailedStatus { + #[serde(rename = "failed")] + #[default] + Failed, +} + +/// Result of registering callbacks on the native-nominated host connection. +/// +///
+/// +/// **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, Serialize, Deserialize)] +#[serde(untagged)] +pub enum NotificationHostRegistrationResult { + Registered(NotificationHostRegistrationResultRegistered), + Denied(NotificationHostRegistrationResultDenied), + Unsupported(NotificationHostRegistrationResultUnsupported), + Unavailable(NotificationHostRegistrationResultUnavailable), + Failed(NotificationHostRegistrationResultFailed), +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationSoundDefaultKind { + #[serde(rename = "default")] + #[default] + Default, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationSoundNoneKind { + #[serde(rename = "none")] + #[default] + None, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationSoundNamedKind { + #[serde(rename = "named")] + #[default] + Named, +} + +/// Sound requested for a native notification; omission means default. +/// +///
+/// +/// **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, Serialize, Deserialize)] +#[serde(untagged)] +pub enum NotificationSound { + Default(NotificationSoundDefault), + None(NotificationSoundNone), + Named(NotificationSoundNamed), +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostShowResultAcceptedStatus { + #[serde(rename = "accepted")] + #[default] + Accepted, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostShowResultDeniedStatus { + #[serde(rename = "denied")] + #[default] + Denied, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostShowResultUnsupportedStatus { + #[serde(rename = "unsupported")] + #[default] + Unsupported, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostShowResultUnavailableStatus { + #[serde(rename = "unavailable")] + #[default] + Unavailable, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostShowResultFailedStatus { + #[serde(rename = "failed")] + #[default] + Failed, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostShowResultInvalidRequestStatus { + #[serde(rename = "invalid-request")] + #[default] + InvalidRequest, +} + +/// Content-free native enqueue acknowledgement. +/// +///
+/// +/// **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, Serialize, Deserialize)] +#[serde(untagged)] +pub enum NotificationHostShowResult { + Accepted(NotificationHostShowResultAccepted), + Denied(NotificationHostShowResultDenied), + Unsupported(NotificationHostShowResultUnsupported), + Unavailable(NotificationHostShowResultUnavailable), + Failed(NotificationHostShowResultFailed), + InvalidRequest(NotificationHostShowResultInvalidRequest), +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostUnregistrationResultUnregisteredStatus { + #[serde(rename = "unregistered")] + #[default] + Unregistered, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostUnregistrationResultDeniedStatus { + #[serde(rename = "denied")] + #[default] + Denied, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostUnregistrationResultUnsupportedStatus { + #[serde(rename = "unsupported")] + #[default] + Unsupported, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostUnregistrationResultUnavailableStatus { + #[serde(rename = "unavailable")] + #[default] + Unavailable, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationHostUnregistrationResultFailedStatus { + #[serde(rename = "failed")] + #[default] + Failed, +} + +/// Result of revoking a native notification host registration. +/// +///
+/// +/// **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, Serialize, Deserialize)] +#[serde(untagged)] +pub enum NotificationHostUnregistrationResult { + Unregistered(NotificationHostUnregistrationResultUnregistered), + Denied(NotificationHostUnregistrationResultDenied), + Unsupported(NotificationHostUnregistrationResultUnsupported), + Unavailable(NotificationHostUnregistrationResultUnavailable), + Failed(NotificationHostUnregistrationResultFailed), +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationPermissionResultCompletedStatus { + #[serde(rename = "completed")] + #[default] + Completed, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationPermissionResultUnsupportedStatus { + #[serde(rename = "unsupported")] + #[default] + Unsupported, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationPermissionResultUnavailableStatus { + #[serde(rename = "unavailable")] + #[default] + Unavailable, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationPermissionResultFailedStatus { + #[serde(rename = "failed")] + #[default] + Failed, +} + +/// Result of explicit notification permission negotiation. +/// +///
+/// +/// **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, Serialize, Deserialize)] +#[serde(untagged)] +pub enum NotificationPermissionResult { + Completed(NotificationPermissionResultCompleted), + Unsupported(NotificationPermissionResultUnsupported), + Unavailable(NotificationPermissionResultUnavailable), + Failed(NotificationPermissionResultFailed), +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationShowResultAcceptedStatus { + #[serde(rename = "accepted")] + #[default] + Accepted, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationShowResultDeniedStatus { + #[serde(rename = "denied")] + #[default] + Denied, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationShowResultUnsupportedStatus { + #[serde(rename = "unsupported")] + #[default] + Unsupported, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationShowResultUnavailableStatus { + #[serde(rename = "unavailable")] + #[default] + Unavailable, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationShowResultFailedStatus { + #[serde(rename = "failed")] + #[default] + Failed, +} + +/// Discriminator selecting this notification variant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationShowResultInvalidRequestStatus { + #[serde(rename = "invalid-request")] + #[default] + InvalidRequest, +} + +/// Metadata-only result of one notification delivery attempt. +/// +///
+/// +/// **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, Serialize, Deserialize)] +#[serde(untagged)] +pub enum NotificationShowResult { + Accepted(NotificationShowResultAccepted), + Denied(NotificationShowResultDenied), + Unsupported(NotificationShowResultUnsupported), + Unavailable(NotificationShowResultUnavailable), + Failed(NotificationShowResultFailed), + InvalidRequest(NotificationShowResultInvalidRequest), +} + /// Allowed values for the `OptionsUpdateAdditionalContentExclusionPolicyScope` enumeration. /// ///
@@ -44449,28 +46157,6 @@ pub enum OptionsUpdateReasoningSummary { Unknown, } -/// Controls how availableTools (allowlist) and excludedTools (denylist) combine when both are set. -/// -///
-/// -/// **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, PartialEq, Eq, Serialize, Deserialize)] -pub enum OptionsUpdateToolFilterPrecedence { - /// If availableTools is set, it is the only constraint that applies (excludedTools is ignored). Preserves CLI / pre-existing client behavior. Default. - #[serde(rename = "available")] - Available, - /// A tool is enabled if and only if it matches the allowlist (or the allowlist is unset) AND it does not match the denylist. Makes 'all except X' expressible by combining the two lists. - #[serde(rename = "excluded")] - Excluded, - /// Unknown variant for forward compatibility. - #[default] - #[serde(other)] - Unknown, -} - /// Approve this single request only #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum PermissionDecisionApproveOnceKind { diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index 2af2f66e82..c78d263401 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -155,6 +155,13 @@ impl<'a> ClientRpc<'a> { } } + /// `notifications.*` sub-namespace. + pub fn notifications(&self) -> ClientRpcNotifications<'a> { + ClientRpcNotifications { + client: self.client, + } + } + /// `plugins.*` sub-namespace. pub fn plugins(&self) -> ClientRpcPlugins<'a> { ClientRpcPlugins { @@ -2254,6 +2261,13 @@ impl<'a> ClientRpcMcp<'a> { } } + /// `mcp.registry.*` sub-namespace. + pub fn registry(&self) -> ClientRpcMcpRegistry<'a> { + ClientRpcMcpRegistry { + client: self.client, + } + } + /// Discovers MCP servers from user, workspace, plugin, and builtin sources. /// /// Wire method: `mcp.discover`. @@ -2282,6 +2296,37 @@ impl<'a> ClientRpcMcp<'a> { Ok(serde_json::from_value(_value)?) } + /// Reports whether the gh-replaceable GitHub MCP tools may be clipped for a session, which a host feeds back as the `excludeGhReplaceableTools` build option. The tools are redundant only when the session can reach a shell *and* the host has the `gh` that would replace them, so both halves are decided here. The shell half runs the platform shell tool through the runtime's own tool-filter matcher, so entry forms like `builtin:bash` and `builtin:*` behave exactly as they do when a session builds its tool catalog. The host half probes for `gh`, and is skipped entirely when the filters already rule the shell out. Host presence alone is not enough: a session restricted to, say, `view` would otherwise lose the built-in issue-read fallback while having no shell to replace it with. The answer describes the host the runtime runs on, so it is never forwarded to a remote engine. + /// + /// Wire method: `mcp.shouldExcludeGitHubTools`. + /// + /// # Parameters + /// + /// * `params` - The session tool filters that decide whether the session still has a shell to run `gh` with. + /// + /// # Returns + /// + /// Whether the gh-replaceable GitHub MCP tools may be clipped for the session described by the request. + /// + ///
+ /// + /// **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(crate) async fn should_exclude_git_hub_tools( + &self, + params: McpShouldExcludeGitHubToolsRequest, + ) -> Result { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call(rpc_methods::MCP_SHOULDEXCLUDEGITHUBTOOLS, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + /// Requests a side-effect-free MCP install plan from a catalog candidate handle or a caller-supplied card. This host-implemented server method is available through SDK/TUI hosts; standalone and C-ABI runtimes whose host does not implement server-method dispatch return JSON-RPC MethodNotFound. A runtime with planning available returns a normalised plan and opaque single-use plan handle; a runtime without it returns the typed planning-unavailable result. A completed plan reports resource identity, provenance, eligible transport choices, the user-scope target, required typed values and secret placeholders, the policy result, the configuration changes installing would make, and whether a reload would be needed. Planning never writes configuration, stores a secret, or reloads MCP servers, so abandoning a plan needs no call and leaves nothing behind. /// /// Wire method: `mcp.planInstall`. @@ -2775,6 +2820,103 @@ impl<'a> ClientRpcMcpInstallations<'a> { } } +/// `mcp.registry.*` RPCs. +#[derive(Clone, Copy)] +pub struct ClientRpcMcpRegistry<'a> { + pub(crate) client: &'a Client, +} + +impl<'a> ClientRpcMcpRegistry<'a> { + /// Allocates an ID for one cancellable MCP registry search. The ID exists before the search starts, so callers can cancel before it starts. The networking stack supplies the cancellation namespace. The runtime retains at most 1,024 unused IDs. At capacity, another allocation can reclaim an unused ID. Active searches retain their IDs until they finish. + /// + /// Wire method: `mcp.registry.allocateRequestId`. + /// + /// # Returns + /// + /// Request id naming a cancellable registry search. + /// + ///
+ /// + /// **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(crate) async fn allocate_request_id(&self) -> Result { + let wire_params = serde_json::json!({}); + let _value = self + .client + .call( + rpc_methods::MCP_REGISTRY_ALLOCATEREQUESTID, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Searches the MCP registry the supplied credential may read, resolving the registry endpoint from policy first. Hosts usually send credential-free `AuthIdentity`; a `token` identity can be resolved only when it embeds a token, while `env` and `gh-cli` identities can use a token embedded in the request first. The runtime follows registry pages, keeps the newest entry per server name, cuts the list to `limit`, and sorts an empty-query result by GitHub stars. Each server object is carried opaquely. + /// + /// Wire method: `mcp.registry.search`. + /// + /// # Parameters + /// + /// * `params` - Registry search terms, the credential to search under, and the request id that makes the search cancellable. + /// + /// # Returns + /// + /// Servers selected from the registry response. + /// + ///
+ /// + /// **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(crate) async fn search( + &self, + params: McpRegistrySearchRequest, + ) -> Result { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call(rpc_methods::MCP_REGISTRY_SEARCH, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Abandons the registry search that uses the given request ID. It acts only on IDs from `mcp.registry.allocateRequestId`, so it never cancels another component's request. Answers `canceled: true` when it stops a running search. Answers `canceled: false` for unknown or reclaimed IDs, completed or canceled searches, and unused reservations. It releases an unused reservation, so a later search with that ID is refused. + /// + /// Wire method: `mcp.registry.cancel`. + /// + /// # Parameters + /// + /// * `params` - Registry search to abandon. + /// + /// # Returns + /// + /// Whether the cancel reached a live search. + /// + ///
+ /// + /// **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(crate) async fn cancel( + &self, + params: McpRegistryCancelRequest, + ) -> Result { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call(rpc_methods::MCP_REGISTRY_CANCEL, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + /// `models.*` RPCs. #[derive(Clone, Copy)] pub struct ClientRpcModels<'a> { @@ -2859,6 +3001,62 @@ impl<'a> ClientRpcModels<'a> { } } +/// `notifications.*` RPCs. +#[derive(Clone, Copy)] +pub struct ClientRpcNotifications<'a> { + pub(crate) client: &'a Client, +} + +impl<'a> ClientRpcNotifications<'a> { + /// Registers notification callbacks on the native-nominated stdio control connection. Other connections cannot claim or replace this authority. + /// + /// Wire method: `notifications.registerHost`. + /// + /// # Returns + /// + /// Result of registering callbacks on the native-nominated host connection. + /// + ///
+ /// + /// **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 register_host(&self) -> Result { + let wire_params = serde_json::json!({}); + let _value = self + .client + .call(rpc_methods::NOTIFICATIONS_REGISTERHOST, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Revokes this control connection's notification callback registration and pending canvas activations. + /// + /// Wire method: `notifications.unregisterHost`. + /// + /// # Returns + /// + /// Result of revoking a native notification host registration. + /// + ///
+ /// + /// **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 unregister_host(&self) -> Result { + let wire_params = serde_json::json!({}); + let _value = self + .client + .call(rpc_methods::NOTIFICATIONS_UNREGISTERHOST, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + /// `plugins.*` RPCs. #[derive(Clone, Copy)] pub struct ClientRpcPlugins<'a> { @@ -5428,6 +5626,13 @@ impl<'a> SessionRpc<'a> { } } + /// `session.notifications.*` sub-namespace. + pub fn notifications(&self) -> SessionRpcNotifications<'a> { + SessionRpcNotifications { + session: self.session, + } + } + /// `session.options.*` sub-namespace. pub fn options(&self) -> SessionRpcOptions<'a> { SessionRpcOptions { @@ -11021,6 +11226,139 @@ impl<'a> SessionRpcName<'a> { } } +/// `session.notifications.*` RPCs. +#[derive(Clone, Copy)] +pub struct SessionRpcNotifications<'a> { + pub(crate) session: &'a Session, +} + +impl<'a> SessionRpcNotifications<'a> { + /// Queries native notification support and permission without prompting. Only authenticated extensions that opted in on resume may use this API. + /// + /// Wire method: `session.notifications.getCapabilities`. + /// + /// # Returns + /// + /// Native host capabilities and permissions, queried without prompting. + /// + ///
+ /// + /// **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_capabilities(&self) -> Result { + let wire_params = serde_json::json!({ "sessionId": self.session.id() }); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_NOTIFICATIONS_GETCAPABILITIES, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Requests explicit host-owned extension consent and OS notification permission. Never called automatically during startup or delivery. + /// + /// Wire method: `session.notifications.requestPermission`. + /// + /// # Returns + /// + /// Result of explicit notification permission negotiation. + /// + ///
+ /// + /// **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 request_permission(&self) -> Result { + let wire_params = serde_json::json!({ "sessionId": self.session.id() }); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_NOTIFICATIONS_REQUESTPERMISSION, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Hands one native notification to the owning host without prompting or retrying. Accepted means native enqueue acknowledgement, not guaranteed display. Content is never recorded in session history. + /// + /// Wire method: `session.notifications.show`. + /// + /// # Parameters + /// + /// * `params` - Native notification content, carried only by the live provider-to-host request. + /// + /// # Returns + /// + /// Metadata-only result of one notification delivery attempt. + /// + ///
+ /// + /// **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 show( + &self, + params: SessionNotificationsShowParams, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_NOTIFICATIONS_SHOW, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Consumes a native host's one-shot canvas-focus activation, revalidating the original provider, session and instance. URL activations are owned by the native host and do not call this method. + /// + /// Wire method: `session.notifications.activate`. + /// + /// # Parameters + /// + /// * `params` - Native host request to consume one live canvas activation. + /// + /// # Returns + /// + /// Result of consuming a native canvas-focus activation. + /// + ///
+ /// + /// **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 activate( + &self, + params: SessionNotificationsActivateParams, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_NOTIFICATIONS_ACTIVATE, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + /// `session.options.*` RPCs. #[derive(Clone, Copy)] pub struct SessionRpcOptions<'a> { diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 8d3f7a844a..8d0e4d9b65 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -8100,14 +8100,14 @@ pub struct ManagedPermissionsContext { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionManagedSettingsResolvedData { - /// Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers, and forced on when `failClosed` is true. + /// 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 the session fell back to the fail-closed restriction. When true, restrictions such as disabling bypass-permissions are enforced even though `settings` may be absent. + /// 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, diff --git a/rust/src/jsonrpc.rs b/rust/src/jsonrpc.rs index c602ca9f4d..7b9f0c808c 100644 --- a/rust/src/jsonrpc.rs +++ b/rust/src/jsonrpc.rs @@ -36,6 +36,12 @@ fn remote_error_log_message<'a>(method: &str, message: &'a str) -> &'a str { // Listener negotiation carries a token that a remote error may echo. match method { "host.getConfiguration" | "host.ready" => "listener negotiation request rejected", + method + if method.starts_with("notifications.") + || method.starts_with("session.notifications.") => + { + "notification request rejected" + } _ => message, } } @@ -292,12 +298,13 @@ impl CancellableRequests { fn honors_cancellation(method: &str) -> bool { use crate::generated::api_types::rpc_methods; - matches!( - method, - crate::installation_confirmation::CONFIRM_METHOD - | rpc_methods::SKILLPROVIDER_LIST - | rpc_methods::SKILLPROVIDER_READ - ) + crate::notifications::is_callback(method) + || matches!( + method, + crate::installation_confirmation::CONFIRM_METHOD + | rpc_methods::SKILLPROVIDER_LIST + | rpc_methods::SKILLPROVIDER_READ + ) } fn register(&self, id: u64) -> bool { diff --git a/rust/src/jsonrpc/tests.rs b/rust/src/jsonrpc/tests.rs index 0b7f154c83..468b5eab39 100644 --- a/rust/src/jsonrpc/tests.rs +++ b/rust/src/jsonrpc/tests.rs @@ -18,6 +18,26 @@ fn listener_negotiation_logs_do_not_echo_remote_errors() { assert_eq!(remote_error_log_message("ping", message), message); } +#[test] +fn notification_logs_do_not_echo_remote_errors() { + let message = "synthetic notification body and https://example.test/private"; + for method in [ + "notifications.registerHost", + "notifications.unregisterHost", + "notifications.show", + "session.notifications.getCapabilities", + "session.notifications.requestPermission", + "session.notifications.show", + "session.notifications.activate", + ] { + assert_eq!( + remote_error_log_message(method, message), + "notification request rejected" + ); + } + assert_eq!(remote_error_log_message("ping", message), message); +} + #[test] fn deserialize_notification() { let json = r#"{"jsonrpc":"2.0","method":"session.event","params":{"id":"e1"}}"#; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 090bbaf85f..f2e27e3a8d 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -54,6 +54,8 @@ pub mod hooks; /// Connection-global human confirmation for experimental installation operations. pub mod installation_confirmation; mod jsonrpc; +/// Experimental native notification host callbacks. +pub mod notifications; /// Permission-policy helpers that produce a [`handler::PermissionHandler`]. pub mod permission; mod process_tree; @@ -397,6 +399,11 @@ pub struct ClientOptions { /// It does not register or enable installation capabilities on the runtime. pub installation_confirmation_handler: Option>, + /// Optional native notification host, registered before session creation. + /// + /// Only a runtime-nominated stdio root can register. Inspect + /// [`Client::notification_host_registration`] for unsupported or denied hosts. + pub notification_handler: Option>, /// Connection-level GitHub telemetry forwarding callback (experimental). /// /// When set, every session created or resumed on this client opts into @@ -596,6 +603,10 @@ impl std::fmt::Debug for ClientOptions { .as_ref() .map(|_| ""), ) + .field( + "notification_handler", + &self.notification_handler.as_ref().map(|_| ""), + ) .field( "on_github_telemetry", &self.on_github_telemetry.as_ref().map(|_| ""), @@ -852,6 +863,7 @@ impl Default for ClientOptions { request_handler: None, extension_launch_provider: None, installation_confirmation_handler: None, + notification_handler: None, on_github_telemetry: None, on_get_trace_context: None, telemetry: None, @@ -1032,6 +1044,19 @@ impl ClientOptions { self } + /// Configure native notifications on an intentionally selected application host. + /// + /// Callbacks are installed before registration. An older runtime reports + /// unsupported through [`Client::notification_host_registration`] without + /// making unrelated sessions unusable. + pub fn with_notification_handler( + mut self, + handler: Arc, + ) -> Self { + self.notification_handler = Some(handler); + self + } + /// Register a connection-level GitHub telemetry forwarding callback /// (internal/experimental). Registering a callback auto-enables telemetry /// forwarding on every session created or resumed on this client; the @@ -1278,6 +1303,7 @@ struct ClientInner { llm_inference: OnceLock>, extension_launch_provider: Arc, installation_confirmation: Arc, + notifications: Arc, /// Connection-level GitHub telemetry forwarding callback, set from /// [`ClientOptions::on_github_telemetry`]. Drives the /// `enableGitHubTelemetryForwarding` wire flag and the @@ -1443,6 +1469,7 @@ impl Client { let request_handler = options.request_handler.clone(); let extension_launch_provider = options.extension_launch_provider.clone(); let installation_confirmation_handler = options.installation_confirmation_handler.clone(); + let notification_handler = options.notification_handler.clone(); let session_fs_sqlite_declared = session_fs_config .as_ref() .and_then(|c| c.capabilities.as_ref()) @@ -1679,6 +1706,10 @@ impl Client { .inner .installation_confirmation .set_handler(installation_confirmation_handler); + client + .inner + .notifications + .set_handler(notification_handler.clone()); debug!( elapsed_ms = start_time.elapsed().as_millis(), "Client::start transport setup complete" @@ -1699,6 +1730,9 @@ impl Client { dispatcher }); client.inner.router.ensure_started(&client.inner); + if notification_handler.is_some() { + client.register_notification_host().await?; + } if client.inner.extension_launch_provider.is_configured() { client.rpc().register_extension_launch_provider().await?; } @@ -2123,6 +2157,7 @@ impl Client { ); let installation_confirmation = Arc::new(installation_confirmation::InstallationConfirmationDispatcher::new()); + let notifications = Arc::new(notifications::NotificationDispatcher::new()); let ahp_host_sessions = Arc::new(ahp_host::HostSessions::new()); let client = Self { ahp_host_sessions: Some(ahp_host_sessions.clone()), @@ -2152,6 +2187,7 @@ impl Client { llm_inference: OnceLock::new(), extension_launch_provider: extension_launch_provider.clone(), installation_confirmation: installation_confirmation.clone(), + notifications: notifications.clone(), on_github_telemetry, on_get_trace_context, effective_connection_token, @@ -2164,6 +2200,7 @@ impl Client { github_token_registry.set_client(Arc::downgrade(&client.inner)); extension_launch_provider.set_client(Arc::downgrade(&client.inner)); installation_confirmation.set_client(Arc::downgrade(&client.inner)); + notifications.set_client(Arc::downgrade(&client.inner)); client.spawn_lifecycle_dispatcher(); client.spawn_ahp_host_dispatcher(host_notification_rx); client.register_ahp_session_factory()?; @@ -3089,6 +3126,9 @@ impl Client { let pid = self.pid(); info!(pid = ?pid, "stopping CLI process"); let mut errors: Vec = Vec::new(); + if let Err(error) = self.unregister_notification_host().await { + errors.push(error); + } self.inner.extension_launch_provider.clear(); self.inner.installation_confirmation.clear(); @@ -3265,6 +3305,7 @@ impl Client { info!(pid = ?pid, "force-stopping CLI process"); self.inner.extension_launch_provider.clear(); self.inner.installation_confirmation.clear(); + self.inner.notifications.clear(); if let Some(process_tree) = self.inner.process_tree.lock().take() && let Err(error) = process_tree.terminate() { diff --git a/rust/src/notifications.rs b/rust/src/notifications.rs new file mode 100644 index 0000000000..cb61941504 --- /dev/null +++ b/rust/src/notifications.rs @@ -0,0 +1,413 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +//! Experimental provider-process desktop notification host callbacks. + +use std::panic::AssertUnwindSafe; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, OnceLock, Weak}; +use std::time::Duration; + +use async_trait::async_trait; +use futures_util::FutureExt; +use parking_lot::RwLock; +use serde_json::Value; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +pub use crate::rpc::{ + NotificationActivateResult, NotificationCapabilitiesResult, NotificationClickAction, + NotificationClickKind, NotificationExtensionOrigin, NotificationFocusTarget, + NotificationHostClickAction, NotificationHostContext, NotificationHostRegistrationResult, + NotificationHostRequest, NotificationHostShowParams, NotificationHostShowResult, + NotificationHostUnregistrationResult, NotificationOsPermissionState, NotificationPermission, + NotificationPermissionResult, NotificationPermissionState, NotificationPlatform, + NotificationShowParams, NotificationShowResult, NotificationSound, NotificationSounds, +}; +use crate::{ + Client, ClientInner, Error, ErrorKind, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Result, + SessionId, error_codes, +}; + +pub(crate) const GET_CAPABILITIES_METHOD: &str = "notifications.getCapabilities"; +pub(crate) const REQUEST_PERMISSION_METHOD: &str = "notifications.requestPermission"; +pub(crate) const SHOW_METHOD: &str = "notifications.show"; + +pub(crate) fn is_callback(method: &str) -> bool { + matches!( + method, + GET_CAPABILITIES_METHOD | REQUEST_PERMISSION_METHOD | SHOW_METHOD + ) +} + +/// Lifetime and activation access for one host callback. +/// +/// Retaining this context does not keep its client alive. Host work spawned +/// outside the handler must observe [`Self::cancellation`] and check it before +/// OS handoff. Notification content must not be logged. +#[derive(Clone)] +pub struct NotificationContext { + cancellation: CancellationToken, + client: Weak, + session_id: SessionId, +} + +impl NotificationContext { + /// Cancels pending work when the request, connection, or host registration is retired. + /// + /// Cancelling this child token does not cancel the originating request. + /// Successful completion does not cancel it. It is not proof that a saved + /// focus receipt is still valid; [`Self::activate_canvas`] checks that with + /// the runtime. Combine it with application-owned generation cancellation + /// when spawning work outside the handler. + pub fn cancellation(&self) -> CancellationToken { + self.cancellation.child_token() + } + + /// Consume a live, one-shot canvas-focus activation on the original session. + /// + /// URLs do not use this method: supporting hosts authenticate URL activation + /// metadata before handing it to the OS. Canvas activation remains subject + /// to the runtime's attachment, ownership, and liveness checks. + pub async fn activate_canvas( + &self, + activation_id: impl Into, + ) -> Result { + let inner = self.client.upgrade().ok_or_else(|| { + Error::with_message( + ErrorKind::InvalidConfig, + "Notification host connection is closed", + ) + })?; + if inner.rpc.connection_closed_token().is_cancelled() { + return Err(Error::with_message( + ErrorKind::InvalidConfig, + "Notification host connection is closed", + )); + } + let value = Client::from_inner(inner) + .call( + "session.notifications.activate", + Some(serde_json::json!({ + "sessionId": self.session_id, + "activationId": activation_id.into(), + })), + ) + .await?; + serde_json::from_value(value).map_err(|_| { + Error::with_message(ErrorKind::Json, "Invalid notification activation response") + }) + } +} + +/// Native notification services supplied by a trusted application host. +/// +/// Configure through [`crate::ClientOptions::with_notification_handler`]. +/// Registration does not grant extension or OS permission. Resolve the +/// runtime-authenticated attachment against a trusted installation mapping; +/// caller-provided names or module paths alone are not consent identity. +#[async_trait] +pub trait NotificationHandler: Send + Sync + 'static { + /// Report support and current permission without prompting or delivering. + async fn get_capabilities( + &self, + request: NotificationHostRequest, + context: NotificationContext, + ) -> Result; + + /// Request extension and OS permission following an explicit user action. + async fn request_permission( + &self, + request: NotificationHostRequest, + context: NotificationContext, + ) -> Result; + + /// Hand one notification to the OS, checking cancellation before handoff. + /// + /// Accepted means OS handoff, not confirmed display. Do not retry delivery + /// after an ambiguous outcome or silently substitute unsupported sounds. + async fn show( + &self, + request: NotificationHostShowParams, + context: NotificationContext, + ) -> Result; +} + +pub(crate) struct NotificationDispatcher { + handler: RwLock>>, + client: OnceLock>, + shutdown: CancellationToken, + registered: AtomicBool, + registration: RwLock>, +} + +impl NotificationDispatcher { + pub(crate) fn new() -> Self { + Self { + handler: RwLock::new(None), + client: OnceLock::new(), + shutdown: CancellationToken::new(), + registered: AtomicBool::new(false), + registration: RwLock::new(None), + } + } + + pub(crate) fn set_client(&self, client: Weak) { + let _ = self.client.set(client); + } + + #[cfg(any(feature = "runtime", test))] + pub(crate) fn set_handler(&self, handler: Option>) { + *self.handler.write() = handler; + } + + pub(crate) fn clear(&self) -> bool { + self.shutdown.cancel(); + self.handler.write().take(); + self.registered.swap(false, Ordering::AcqRel) + } + + pub(crate) fn dispatch(self: &Arc, request: JsonRpcRequest) { + let Some(client) = self.client.get().and_then(Weak::upgrade) else { + return; + }; + let Some(pending) = client.rpc.cancellable_requests.claim(request.id) else { + warn!("notification request retired before dispatch"); + return; + }; + let request_cancelled = pending.cancellation().clone(); + let closed = client.rpc.connection_closed_token(); + let cancellation = closed.child_token(); + let handler = if self.registered.load(Ordering::Acquire) { + self.handler.read().clone() + } else { + None + }; + let dispatcher = self.clone(); + let owner = Arc::downgrade(&client); + tokio::spawn(async move { + let outcome = tokio::select! { + biased; + _ = closed.cancelled() => { + cancellation.cancel(); + return; + } + _ = dispatcher.shutdown.cancelled() => { + cancellation.cancel(); + Err((error_codes::REQUEST_CANCELLED, "Notification host stopped")) + } + _ = request_cancelled.cancelled() => { + cancellation.cancel(); + Err((error_codes::REQUEST_CANCELLED, "Notification request cancelled")) + } + outcome = AssertUnwindSafe(Self::handle( + handler, + &request.method, + request.params.unwrap_or(Value::Null), + owner, + cancellation.clone(), + )).catch_unwind() => { + outcome.unwrap_or(Err((error_codes::INTERNAL_ERROR, "Notification handler failed"))) + } + }; + if closed.is_cancelled() { + cancellation.cancel(); + return; + } + let outcome = if request_cancelled.is_cancelled() || dispatcher.shutdown.is_cancelled() + { + cancellation.cancel(); + Err(( + error_codes::REQUEST_CANCELLED, + "Notification request cancelled", + )) + } else { + outcome + }; + let (result, error) = match outcome { + Ok(value) => (Some(value), None), + Err((code, message)) => ( + None, + Some(JsonRpcError { + code, + message: message.to_owned(), + data: None, + }), + ), + }; + if Client::from_inner(client) + .send_response(&JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: request.id, + result, + error, + }) + .await + .is_err() + { + warn!("failed to send notification response"); + } + drop(pending); + }); + } + + async fn handle( + handler: Option>, + method: &str, + params: Value, + client: Weak, + cancellation: CancellationToken, + ) -> std::result::Result { + let handler = handler.ok_or(( + error_codes::METHOD_NOT_FOUND, + "No notification host handler registered", + ))?; + let invalid = || { + ( + error_codes::INVALID_PARAMS, + "Invalid notification host request", + ) + }; + let failed = || (error_codes::INTERNAL_ERROR, "Notification handler failed"); + let context = |request: &NotificationHostContext| NotificationContext { + cancellation, + client, + session_id: request.session_id.clone(), + }; + match method { + GET_CAPABILITIES_METHOD => { + let request: NotificationHostRequest = + serde_json::from_value(params).map_err(|_| invalid())?; + let context = context(&request.context); + let response = handler + .get_capabilities(request, context) + .await + .map_err(|_| failed())?; + serde_json::to_value(response).map_err(|_| failed()) + } + REQUEST_PERMISSION_METHOD => { + let request: NotificationHostRequest = + serde_json::from_value(params).map_err(|_| invalid())?; + let context = context(&request.context); + let response = handler + .request_permission(request, context) + .await + .map_err(|_| failed())?; + serde_json::to_value(response).map_err(|_| failed()) + } + SHOW_METHOD => { + let request: NotificationHostShowParams = + serde_json::from_value(params).map_err(|_| invalid())?; + let context = context(&request.context); + let response = handler.show(request, context).await.map_err(|_| failed())?; + serde_json::to_value(response).map_err(|_| failed()) + } + _ => Err(( + error_codes::METHOD_NOT_FOUND, + "Unknown notification host method", + )), + } + } +} + +impl Client { + /// The initial notification-host registration outcome, or `None` if not configured. + /// + /// Only `registered` acknowledges registration. This is the handshake + /// outcome, not a promise that a stopped or disconnected client can deliver. + pub fn notification_host_registration(&self) -> Option { + self.inner.notifications.registration.read().clone() + } + + #[cfg(any(feature = "runtime", test))] + pub(crate) async fn register_notification_host(&self) -> Result<()> { + let notifications = self.inner.notifications.clone(); + match self + .call_with_inline_callback( + "notifications.registerHost", + Some(serde_json::json!({})), + Some(Box::new(move |response| { + let outcome: NotificationHostRegistrationResult = + serde_json::from_value(response.result.clone().unwrap_or(Value::Null)) + .map_err(|_| { + Error::with_message( + ErrorKind::Json, + "Invalid notification host registration response", + ) + })?; + notifications.registered.store( + matches!(outcome, NotificationHostRegistrationResult::Registered(_)), + Ordering::Release, + ); + *notifications.registration.write() = Some(outcome); + Ok(()) + })), + ) + .await + { + Ok(_) => Ok(()), + Err(error) if matches!(error.kind(), ErrorKind::Rpc { code: -32601 }) => { + let unsupported = + serde_json::from_value(serde_json::json!({ "status": "unsupported" })) + .map_err(|_| { + Error::with_message( + ErrorKind::Json, + "Invalid notification host registration response", + ) + })?; + self.inner + .notifications + .registered + .store(false, Ordering::Release); + *self.inner.notifications.registration.write() = Some(unsupported); + Ok(()) + } + Err(_) => Err(Error::with_message( + ErrorKind::InvalidConfig, + "Notification host registration failed", + )), + } + } + + pub(crate) async fn unregister_notification_host(&self) -> Result<()> { + if !self.inner.notifications.clear() { + return Ok(()); + } + let response = tokio::time::timeout( + Duration::from_secs(5), + self.call("notifications.unregisterHost", Some(serde_json::json!({}))), + ) + .await + .map_err(|_| { + Error::with_message( + ErrorKind::InvalidConfig, + "Notification host unregistration timed out", + ) + })? + .map_err(|_| { + Error::with_message( + ErrorKind::InvalidConfig, + "Notification host unregistration failed", + ) + })?; + let response: NotificationHostUnregistrationResult = serde_json::from_value(response) + .map_err(|_| { + Error::with_message( + ErrorKind::Json, + "Invalid notification host unregistration response", + ) + })?; + if !matches!( + response, + NotificationHostUnregistrationResult::Unregistered(_) + ) { + return Err(Error::with_message( + ErrorKind::InvalidConfig, + "Notification host unregistration failed", + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/src/notifications/tests.rs b/rust/src/notifications/tests.rs new file mode 100644 index 0000000000..69bfec181b --- /dev/null +++ b/rust/src/notifications/tests.rs @@ -0,0 +1,486 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +#![cfg(test)] +#![allow(clippy::unwrap_used)] + +use std::time::Duration; + +use serde_json::{Value, json}; +use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream, duplex}; +use tokio::sync::{mpsc, oneshot}; +use tokio::time::timeout; + +use super::*; + +const WAIT: Duration = Duration::from_secs(2); + +struct Review { + method: &'static str, + request: Value, + context: NotificationContext, + reply: oneshot::Sender>, +} + +struct Handler(mpsc::UnboundedSender); + +impl Handler { + async fn invoke( + &self, + method: &'static str, + request: impl serde::Serialize, + context: NotificationContext, + ) -> Result { + let (reply, result) = oneshot::channel(); + self.0 + .send(Review { + method, + request: serde_json::to_value(request).unwrap(), + context, + reply, + }) + .unwrap_or_else(|_| panic!("review receiver closed")); + result.await.unwrap() + } +} + +#[async_trait] +impl NotificationHandler for Handler { + async fn get_capabilities( + &self, + request: NotificationHostRequest, + context: NotificationContext, + ) -> Result { + Ok(serde_json::from_value( + self.invoke("getCapabilities", request, context).await?, + )?) + } + + async fn request_permission( + &self, + request: NotificationHostRequest, + context: NotificationContext, + ) -> Result { + Ok(serde_json::from_value( + self.invoke("requestPermission", request, context).await?, + )?) + } + + async fn show( + &self, + request: NotificationHostShowParams, + context: NotificationContext, + ) -> Result { + Ok(serde_json::from_value( + self.invoke("show", request, context).await?, + )?) + } +} + +struct Peer { + client: Client, + reader: DuplexStream, + writer: DuplexStream, + reviews: mpsc::UnboundedReceiver, + _directory: tempfile::TempDir, +} + +impl Peer { + fn new(configured: bool, start_router: bool) -> Self { + let (client_writer, reader) = duplex(32768); + let (writer, client_reader) = duplex(32768); + let directory = tempfile::tempdir().unwrap(); + let client = + Client::from_streams(client_reader, client_writer, directory.path().into()).unwrap(); + let (sender, reviews) = mpsc::unbounded_channel(); + if configured { + client + .inner + .notifications + .set_handler(Some(Arc::new(Handler(sender)))); + client + .inner + .notifications + .registered + .store(true, Ordering::Release); + } + if start_router { + client.inner.router.ensure_started(&client.inner); + } + Self { + client, + reader, + writer, + reviews, + _directory: directory, + } + } + + async fn send(&mut self, value: Value) { + let body = serde_json::to_vec(&value).unwrap(); + self.writer + .write_all(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes()) + .await + .unwrap(); + self.writer.write_all(&body).await.unwrap(); + self.writer.flush().await.unwrap(); + } + + async fn request(&mut self, id: u64, method: &str, params: Value) { + self.send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) + .await; + } + + async fn receive(&mut self) -> Value { + timeout(WAIT, async { + let mut header = Vec::new(); + while !header.ends_with(b"\r\n\r\n") { + header.push(self.reader.read_u8().await.unwrap()); + } + let length = String::from_utf8(header) + .unwrap() + .trim() + .strip_prefix("Content-Length: ") + .unwrap() + .parse() + .unwrap(); + let mut body = vec![0; length]; + self.reader.read_exact(&mut body).await.unwrap(); + serde_json::from_slice(&body).unwrap() + }) + .await + .unwrap() + } + + async fn review(&mut self) -> Review { + timeout(WAIT, self.reviews.recv()).await.unwrap().unwrap() + } +} + +impl Drop for Peer { + fn drop(&mut self) { + self.client.force_stop(); + } +} + +fn origin() -> Value { + json!({ + "sessionId": "synthetic-session", + "attachmentId": "synthetic-attachment", + "extension": { + "id": "project:synthetic", + "name": "Synthetic", + "source": "project", + "modulePath": "/synthetic/extension.mjs" + } + }) +} + +fn show() -> Value { + json!({ + "context": origin(), + "notificationId": "synthetic-notification", + "title": "Synthetic title", + "body": "Synthetic body", + "sound": { "kind": "none" }, + "onClick": { "kind": "open-url", "url": "https://example.test/review" } + }) +} + +fn capabilities() -> Value { + json!({ + "status": "available", + "platform": "darwin", + "permission": { "extension": "granted", "os": "granted" }, + "onClick": ["open-url", "focus-canvas"], + "sounds": { "default": true, "none": true, "named": ["Glass"] } + }) +} + +#[tokio::test] +async fn callbacks_dispatch_without_a_session_and_preserve_the_wire_shape() { + let mut peer = Peer::new(true, true); + for (id, method, params, result) in [ + ( + 1, + "getCapabilities", + json!({ "context": origin() }), + capabilities(), + ), + ( + 2, + "requestPermission", + json!({ "context": origin() }), + json!({ + "status": "completed", + "permission": { "extension": "denied", "os": "not-required" } + }), + ), + (3, "show", show(), json!({ "status": "accepted" })), + ] { + peer.request(id, &format!("notifications.{method}"), params.clone()) + .await; + let review = peer.review().await; + assert_eq!(review.method, method); + assert_eq!(review.request, params); + review.reply.send(Ok(result.clone())).unwrap(); + let response = peer.receive().await; + assert_eq!(response["id"], id); + assert_eq!(response["result"], result); + assert!(response.get("error").is_none()); + } +} + +#[tokio::test] +async fn callback_failures_are_explicit_and_do_not_echo_content() { + let mut peer = Peer::new(true, true); + peer.request(1, SHOW_METHOD, show()).await; + peer.review() + .await + .reply + .send(Err(Error::with_message( + ErrorKind::InvalidConfig, + "synthetic-private-error", + ))) + .unwrap(); + let response = peer.receive().await; + assert_eq!(response["error"]["code"], error_codes::INTERNAL_ERROR); + assert!(!response.to_string().contains("synthetic-private-error")); + assert!(!response.to_string().contains("Synthetic title")); +} + +#[tokio::test] +async fn malformed_requests_never_reach_the_handler() { + let mut peer = Peer::new(true, true); + let mut request = show(); + request["sound"] = json!({ "kind": "synthetic-private-invalid-kind" }); + peer.request(1, SHOW_METHOD, request).await; + let response = peer.receive().await; + assert_eq!(response["error"]["code"], error_codes::INVALID_PARAMS); + assert!(!response.to_string().contains("synthetic-private")); + assert!(peer.reviews.try_recv().is_err()); +} + +#[tokio::test] +async fn missing_handler_returns_an_error_instead_of_success() { + let mut peer = Peer::new(false, true); + peer.request(1, SHOW_METHOD, show()).await; + let response = peer.receive().await; + assert_eq!(response["error"]["code"], error_codes::METHOD_NOT_FOUND); +} + +#[tokio::test] +async fn callbacks_are_disabled_until_host_registration_is_acknowledged() { + let mut peer = Peer::new(true, true); + peer.client + .inner + .notifications + .registered + .store(false, Ordering::Release); + peer.request(1, SHOW_METHOD, show()).await; + let response = peer.receive().await; + assert_eq!(response["error"]["code"], error_codes::METHOD_NOT_FOUND); + assert!(peer.reviews.try_recv().is_err()); +} + +#[tokio::test] +async fn cancellation_retires_a_pending_handler_and_signals_spawned_work() { + let mut peer = Peer::new(true, true); + peer.request(1, SHOW_METHOD, show()).await; + let review = peer.review().await; + let cancellation = review.context.cancellation(); + peer.send(json!({ "jsonrpc": "2.0", "method": "$/cancelRequest", "params": { "id": 1 } })) + .await; + let response = peer.receive().await; + assert_eq!(response["error"]["code"], error_codes::REQUEST_CANCELLED); + assert!(cancellation.is_cancelled()); + assert!( + review + .reply + .send(Ok(json!({ "status": "accepted" }))) + .is_err() + ); +} + +#[tokio::test] +async fn cancellation_before_routing_never_invokes_the_handler() { + let mut peer = Peer::new(true, false); + peer.request(1, SHOW_METHOD, show()).await; + peer.send(json!({ "jsonrpc": "2.0", "method": "$/cancelRequest", "params": { "id": 1 } })) + .await; + + let client = peer.client.clone(); + let barrier = tokio::spawn(async move { client.call("test.barrier", None).await }); + let request = peer.receive().await; + peer.send(json!({ "jsonrpc": "2.0", "id": request["id"], "result": {} })) + .await; + barrier.await.unwrap().unwrap(); + peer.client.inner.router.ensure_started(&peer.client.inner); + + assert_eq!( + peer.receive().await["error"]["code"], + error_codes::REQUEST_CANCELLED + ); + assert!(peer.reviews.try_recv().is_err()); +} + +#[tokio::test] +async fn one_pending_callback_does_not_block_another() { + let mut peer = Peer::new(true, true); + peer.request(1, SHOW_METHOD, show()).await; + let pending = peer.review().await; + peer.request(2, GET_CAPABILITIES_METHOD, json!({ "context": origin() })) + .await; + peer.review().await.reply.send(Ok(capabilities())).unwrap(); + assert_eq!(peer.receive().await["id"], 2); + pending + .reply + .send(Ok(json!({ "status": "denied" }))) + .unwrap(); + assert_eq!(peer.receive().await["id"], 1); +} + +#[tokio::test] +async fn cancelling_a_context_child_does_not_cancel_the_request() { + let mut peer = Peer::new(true, true); + peer.request(1, SHOW_METHOD, show()).await; + let review = peer.review().await; + review.context.cancellation().cancel(); + assert!(!review.context.cancellation().is_cancelled()); + review + .reply + .send(Ok(json!({ "status": "accepted" }))) + .unwrap(); + assert_eq!(peer.receive().await["result"]["status"], "accepted"); +} + +#[tokio::test] +async fn force_stop_cancels_pending_host_work() { + let mut peer = Peer::new(true, true); + peer.request(1, SHOW_METHOD, show()).await; + let review = peer.review().await; + let cancellation = review.context.cancellation(); + peer.client.force_stop(); + timeout(WAIT, cancellation.cancelled()).await.unwrap(); +} + +#[tokio::test] +async fn registration_acknowledgement_is_explicit() { + let mut peer = Peer::new(true, true); + let client = peer.client.clone(); + let registration = tokio::spawn(async move { client.register_notification_host().await }); + let request = peer.receive().await; + assert_eq!(request["method"], "notifications.registerHost"); + assert!(peer.client.notification_host_registration().is_none()); + peer.send(json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { "status": "registered" } + })) + .await; + registration.await.unwrap().unwrap(); + assert_eq!( + serde_json::to_value(peer.client.notification_host_registration()).unwrap(), + json!({ "status": "registered" }) + ); +} + +#[tokio::test] +async fn old_runtime_registration_is_explicitly_unsupported() { + let mut peer = Peer::new(true, true); + let client = peer.client.clone(); + let registration = tokio::spawn(async move { client.register_notification_host().await }); + let request = peer.receive().await; + peer.send(json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": { "code": -32601, "message": "Method not found" } + })) + .await; + registration.await.unwrap().unwrap(); + assert_eq!( + serde_json::to_value(peer.client.notification_host_registration()).unwrap(), + json!({ "status": "unsupported" }) + ); +} + +#[tokio::test] +async fn unregisters_before_closing_a_registered_host() { + let mut peer = Peer::new(true, true); + let client = peer.client.clone(); + let registration = tokio::spawn(async move { client.register_notification_host().await }); + let request = peer.receive().await; + peer.send(json!({ + "jsonrpc": "2.0", "id": request["id"], "result": { "status": "registered" } + })) + .await; + registration.await.unwrap().unwrap(); + + let client = peer.client.clone(); + let stop = tokio::spawn(async move { client.stop().await }); + let request = peer.receive().await; + assert_eq!(request["method"], "notifications.unregisterHost"); + peer.send(json!({ + "jsonrpc": "2.0", "id": request["id"], "result": { "status": "unregistered" } + })) + .await; + stop.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn focus_activation_uses_the_original_session_not_the_current_session() { + let mut peer = Peer::new(true, true); + let mut request = show(); + request["onClick"] = json!({ "kind": "focus-canvas", "activationId": "synthetic-focus" }); + peer.request(1, SHOW_METHOD, request).await; + let review = peer.review().await; + review + .reply + .send(Ok(json!({ "status": "accepted" }))) + .unwrap(); + peer.receive().await; + + let activation = + tokio::spawn(async move { review.context.activate_canvas("synthetic-focus").await }); + let request = peer.receive().await; + assert_eq!(request["method"], "session.notifications.activate"); + assert_eq!( + request["params"], + json!({ "sessionId": "synthetic-session", "activationId": "synthetic-focus" }) + ); + peer.send(json!({ + "jsonrpc": "2.0", "id": request["id"], "result": { "status": "unavailable" } + })) + .await; + assert_eq!( + serde_json::to_value(activation.await.unwrap().unwrap()).unwrap(), + json!({ "status": "unavailable" }) + ); +} + +#[tokio::test] +async fn retained_focus_context_does_not_keep_the_client_alive() { + let mut peer = Peer::new(true, true); + peer.request(1, SHOW_METHOD, show()).await; + let review = peer.review().await; + review + .reply + .send(Ok(json!({ "status": "accepted" }))) + .unwrap(); + peer.receive().await; + drop(peer); + + timeout(WAIT, async { + while review.context.client.upgrade().is_some() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!( + review + .context + .activate_canvas("synthetic-focus") + .await + .is_err() + ); +} diff --git a/rust/src/router.rs b/rust/src/router.rs index dff61da6b1..95ba8a3d1d 100644 --- a/rust/src/router.rs +++ b/rust/src/router.rs @@ -206,6 +206,7 @@ impl SessionRouter { let github_telemetry = client.on_github_telemetry.clone(); let github_token_registry = client.github_token_registry.clone(); let installation_confirmation = client.installation_confirmation.clone(); + let notifications = client.notifications.clone(); // Notification routing task let sessions = self.sessions.clone(); @@ -317,6 +318,10 @@ impl SessionRouter { installation_confirmation.dispatch(request); continue; } + if crate::notifications::is_callback(&request.method) { + notifications.dispatch(request); + continue; + } if request.method == crate::extension_launch_provider::RESOLVE_METHOD { // The host's resolver may take arbitrarily long, so it must // not hold up routing of later requests. diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index aad957837d..2682b3a801 100644 --- a/scripts/codegen/rust.ts +++ b/scripts/codegen/rust.ts @@ -1983,6 +1983,13 @@ export function generateApiTypesCode( "AuthInfo", "EnqueueCommandResult", "McpOauthProbeResult", + "NotificationActivateResult", + "NotificationCapabilitiesResult", + "NotificationHostRegistrationResult", + "NotificationHostShowResult", + "NotificationHostUnregistrationResult", + "NotificationPermissionResult", + "NotificationShowResult", "SettableAuthInfo", "ToolResult", ],