diff --git a/nodejs/docs/extensions.md b/nodejs/docs/extensions.md index 3b7161f055..087c40326b 100644 --- a/nodejs/docs/extensions.md +++ b/nodejs/docs/extensions.md @@ -74,6 +74,36 @@ 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. +## Contributing app session badges + +App hosts can retain a hidden, non-conversational session for executable extensions that contribute branch or pull request badges. Use `joinAppSessionBadges()` to opt in explicitly: + +```js +import { joinAppSessionBadges } from "@github/copilot-sdk/extension"; + +const badges = await joinAppSessionBadges(); + +badges.onSnapshot(async (snapshot) => { + await badges.setBadges( + snapshot.sessions.map((target) => ({ + workspaceId: target.workspaceId, + sessionId: target.sessionId, + badge: { + state: "open", + label: target.branch, + }, + })) + ); +}); +``` + +`setBadges()` validates the complete ordered batch before sending one +`extensions.appSessionBadges.setBadges` JSON-RPC request. Duplicate workspace and session target +pairs reject the complete batch. Use `badge: null` to clear a target. An empty batch is a local +no-op. `setBadge()` and `clearBadge()` remain available for individual updates. + +Each snapshot is a full replacement of the sessions that the app considers eligible. The app owns hidden-session lifecycle, visibility filtering, and repository inspection. Native GitHub pull request badges remain authoritative, and the app can reject an update when a native badge appears after the snapshot. Extensions can publish only `draft`, `open`, `merged`, or `closed` states with an optional label; arbitrary markup, icons, colors, and URLs are not supported. + ## Further Reading - `examples.md` — Practical code examples for tools, hooks, events, and complete extensions diff --git a/nodejs/package.json b/nodejs/package.json index 783c4d5390..6d91d677ed 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -29,6 +29,16 @@ "types": "./dist/extension.d.ts", "default": "./dist/cjs/extension.js" } + }, + "./private/app-extension": { + "import": { + "types": "./dist/appExtension.d.ts", + "default": "./dist/appExtension.js" + }, + "require": { + "types": "./dist/appExtension.d.ts", + "default": "./dist/cjs/appExtension.js" + } } }, "type": "module", diff --git a/nodejs/src/appExtension.ts b/nodejs/src/appExtension.ts new file mode 100644 index 0000000000..8702c2c6ea --- /dev/null +++ b/nodejs/src/appExtension.ts @@ -0,0 +1,1842 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import type { CopilotClient } from "./client.js"; +import type { CopilotSession } from "./session.js"; +import type { JsonValue } from "./factory.js"; +import type { CancellationToken } from "vscode-jsonrpc/node.js"; +import type { + AppCanvasActionCallbackRequest, + AppCanvasCloseCallbackRequest, + AppCanvasContext as WireAppCanvasContext, + AppCanvasOpenCallbackRequest, + AppCanvasOpenResult as WireAppCanvasOpenResult, + AppForgeInvokeCallbackRequest, + AppMediatedFetchRequest as WireAppMediatedFetchRequest, + AppMediatedFetchResponse as WireAppMediatedFetchResponse, + AppSessionActionCallbackRequest, +} from "./generated/rpc.js"; +import { + type AppSessionBadge, + type AppSessionBadgeTarget, + type AppSessionBadgesSnapshot, + type AppSessionBadgeTargetIdentity, + type AppSessionBadgeUpdate, + type AppSessionBadgesExtension, + type AppSessionPresentation, + type AppSessionPresentationUpdate, +} from "./appSessionBadges.js"; +import { + onExtensionTransportClosedSymbol, + registerPrivateAppCanvasSymbol, + registerPrivateAppExtensionSymbol, + registerPrivateAppForgeProviderSymbol, + registerPrivateAppSessionBadgesSymbol, + requestPrivateAppMediatedFetchSymbol, + unregisterPrivateAppCanvasSymbol, + unregisterPrivateAppForgeProviderSymbol, +} from "./appExtensionClientAccess.js"; +import { joinExtensionSession } from "./extensionSession.js"; + +const APP_EXTENSION_PROTOCOL_VERSION = 1 as const; +const MAX_CONTRIBUTION_ID_LENGTH = 256; +const MAX_OPERATION_NAME_LENGTH = 256; +const MAX_CANVAS_INSTANCE_ID_LENGTH = 256; +const MAX_CANVAS_METADATA_LENGTH = 512; +const MAX_CANVAS_ACTIONS = 32; +const MAX_JSON_PAYLOAD_BYTES = 256 * 1024; +const MAX_ACTION_PROMPT_BYTES = 32 * 1024; +const MAX_FETCH_PATH_LENGTH = 8192; +const MAX_FETCH_HEADERS = 64; +const MAX_FETCH_HEADER_BYTES = 32 * 1024; +const MAX_FETCH_BODY_BYTES = 256 * 1024; +const MAX_FETCH_RESPONSE_BODY_BYTES = 1024 * 1024; + +declare const packageIdBrand: unique symbol; +declare const activationIdBrand: unique symbol; +declare const contributionIdBrand: unique symbol; + +/** Opaque identity for a bundled or allowlisted app-extension package. */ +export type AppExtensionPackageId = string & { readonly [packageIdBrand]: never }; + +/** Opaque identity for one runtime-authenticated launch generation. */ +export type AppExtensionActivationId = string & { readonly [activationIdBrand]: never }; + +/** Opaque identity for one contribution owned by an activation principal. */ +export type AppExtensionContributionId = string & { + readonly [contributionIdBrand]: never; +}; + +/** Capability contribution point declared by a trusted app-extension manifest. */ +export type AppExtensionContributionPoint = "sessionBadges" | "canvases" | "forgeProvider"; + +/** Runtime-authenticated package and activation identity. */ +export interface AppExtensionPrincipal { + readonly packageId: AppExtensionPackageId; + readonly activationId: AppExtensionActivationId; +} + +/** Capability grants bound to the authenticated principal. */ +export interface AppExtensionCapabilityGrants { + readonly sessionBadges?: true; + readonly canvases?: true; + readonly forgeProvider?: true; + readonly mediatedFetch?: true; +} + +/** Identity attached to a capability registration. */ +export interface AppExtensionContributionIdentity< + TContributionPoint extends AppExtensionContributionPoint = AppExtensionContributionPoint, +> { + readonly principal: AppExtensionPrincipal; + readonly contributionPoint: TContributionPoint; + readonly contributionId: AppExtensionContributionId; +} + +/** Declared app-canvas contribution identity. */ +export interface AppCanvasContributionDeclaration { + readonly contributionPoint: "canvases"; + readonly contributionId: AppExtensionContributionId; +} + +/** Declared session-badge contribution identity. */ +export interface AppSessionBadgesContributionDeclaration { + readonly contributionPoint: "sessionBadges"; + readonly contributionId: AppExtensionContributionId; +} + +/** Declared forge-provider contribution identity. */ +export interface AppForgeProviderContributionDeclaration { + readonly contributionPoint: "forgeProvider"; + readonly contributionId: AppExtensionContributionId; +} + +/** Runtime-authenticated declaration union for capability-specific registration APIs. */ +export type AppExtensionDeclaredContribution = + | AppSessionBadgesContributionDeclaration + | AppCanvasContributionDeclaration + | AppForgeProviderContributionDeclaration; + +/** Alias for a runtime-authenticated app-extension contribution declaration. */ +export type AppExtensionContributionDeclaration = AppExtensionDeclaredContribution; + +/** Trusted project context supplied by the app host to an app canvas. */ +export interface AppCanvasProjectContext { + readonly forgeProviderId: string; + readonly repositoryLocator: JsonValue; + readonly forgeAccountId?: string; +} + +/** Optional trusted application context associated with a canvas instance. */ +export interface AppCanvasContext { + readonly projectId?: string; + readonly workspaceId?: string; + readonly project?: AppCanvasProjectContext; +} + +/** Request delivered when the app opens a canvas contribution. */ +export interface AppCanvasOpenRequest { + readonly instanceId: string; + readonly input?: JsonValue; + readonly context?: AppCanvasContext; + readonly signal: AbortSignal; +} + +/** Request delivered when the app invokes a canvas action. */ +export interface AppCanvasActionRequest { + readonly instanceId: string; + readonly actionName: string; + readonly input?: JsonValue; + readonly context?: AppCanvasContext; + readonly signal: AbortSignal; +} + +/** Request delivered when the app closes a canvas instance. */ +export interface AppCanvasCloseRequest { + readonly instanceId: string; + readonly context?: AppCanvasContext; + readonly signal: AbortSignal; +} + +/** Bounded generic action rendered by the trusted app canvas host. */ +export interface AppCanvasActionDescriptor { + readonly name: string; + readonly label: string; + readonly input?: JsonValue; + readonly variant?: "default" | "primary" | "danger"; + readonly disabled?: boolean; +} + +/** Bounded state and display metadata returned when a canvas opens. */ +export interface AppCanvasOpenResult { + readonly state?: JsonValue; + readonly title?: string; + readonly status?: string; + readonly actions?: readonly AppCanvasActionDescriptor[]; +} + +/** Handlers for one statically declared app-canvas contribution. */ +export interface AppCanvasRegistrationOptions { + readonly contributionId: AppExtensionContributionId; + readonly onOpen: ( + request: AppCanvasOpenRequest + ) => AppCanvasOpenResult | Promise; + readonly onAction: ( + request: AppCanvasActionRequest + ) => JsonValue | AppCanvasOpenResult | Promise; + readonly onClose?: (request: AppCanvasCloseRequest) => void | Promise; +} + +/** Disposable app-canvas registration. */ +export interface AppCanvasRegistration { + readonly identity: AppExtensionContributionIdentity<"canvases">; + dispose(): Promise; +} + +/** Registration surface for app-scoped canvases. */ +export interface AppCanvasesHost { + register(options: AppCanvasRegistrationOptions): Promise; +} + +/** Request delivered to one registered forge-provider operation. */ +export interface AppForgeOperationRequest { + readonly operation: string; + readonly accountId?: string; + readonly input?: JsonValue; + readonly signal: AbortSignal; +} + +/** Handler for one forge-provider operation. */ +export type AppForgeOperationHandler = ( + request: AppForgeOperationRequest +) => JsonValue | Promise; + +/** Registration options for one statically declared forge provider. */ +export interface AppForgeProviderRegistrationOptions { + readonly contributionId: AppExtensionContributionId; + readonly operations: Readonly>; +} + +/** Disposable forge-provider registration. */ +export interface AppForgeProviderRegistration { + readonly identity: AppExtensionContributionIdentity<"forgeProvider">; + readonly operations: readonly string[]; + dispose(): Promise; +} + +/** Registration surface for app-scoped forge providers. */ +export interface AppForgeProvidersHost { + register(options: AppForgeProviderRegistrationOptions): Promise; +} + +/** HTTP methods supported by mediated fetch. */ +export type AppMediatedFetchMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +/** Capability-gated mediated-fetch request. */ +export interface AppMediatedFetchRequest { + readonly contributionId: AppExtensionContributionId; + readonly accountId: string; + readonly operation: string; + readonly method: AppMediatedFetchMethod; + readonly path: string; + readonly headers?: Readonly>; + readonly body?: string; + readonly signal?: AbortSignal; +} + +/** Bounded sanitized mediated-fetch response. */ +export interface AppMediatedFetchResponse { + readonly status: number; + readonly headers: Readonly>; + readonly body?: string; + readonly truncated: boolean; +} + +/** Capability-limited mediated network surface for a registered forge contribution. */ +export interface AppMediatedFetchHost { + request(options: AppMediatedFetchRequest): Promise; +} + +/** Principal-aware callback for replacement badge snapshots. */ +export type AppSessionBadgesRegistrationHandler = ( + snapshot: AppSessionBadgesSnapshot, + identity: AppExtensionContributionIdentity<"sessionBadges"> +) => void | Promise; + +/** Create Pull Request action request routed to the registered badge contribution. */ +export interface AppSessionBadgesActionRequest { + readonly target: AppSessionBadgeTarget; + readonly kind: "createPullRequest"; + readonly draft: boolean; + readonly signal: AbortSignal; +} + +/** Extension-authored prompt and required session tool for a Create Pull Request action. */ +export interface AppSessionBadgesActionResult { + readonly prompt: string; + readonly requiredTool: string; +} + +/** Callback invoked when the app selects a contributed Create Pull Request action. */ +export type AppSessionBadgesActionHandler = ( + request: AppSessionBadgesActionRequest +) => AppSessionBadgesActionResult | null | Promise; + +/** Options for registering the activation's single badge contribution. */ +export interface AppSessionBadgesRegistration { + readonly onSnapshot?: AppSessionBadgesRegistrationHandler; + readonly onAction?: AppSessionBadgesActionHandler; +} + +/** Capability-limited badge contribution owned by an app-extension principal. */ +export interface AppSessionBadgesContribution { + readonly identity: AppExtensionContributionIdentity<"sessionBadges">; + readonly snapshot: AppSessionBadgesSnapshot | undefined; + onSnapshot(handler: AppSessionBadgesRegistrationHandler): () => void; + setBadge(target: AppSessionBadgeTargetIdentity, badge: AppSessionBadge | null): Promise; + setBadges(updates: readonly AppSessionBadgeUpdate[]): Promise; + setPresentation( + target: AppSessionBadgeTargetIdentity, + presentation: AppSessionPresentation + ): Promise; + setPresentations(updates: readonly AppSessionPresentationUpdate[]): Promise; + clearBadge(target: AppSessionBadgeTargetIdentity): Promise; + dispose(): void; +} + +/** Registration surface for the session-badge capability. */ +export interface AppSessionBadgesHost { + register(options?: AppSessionBadgesRegistration): Promise; +} + +/** + * Private capability-limited host supplied to an allowlisted app extension. + * + * This object deliberately contains no session, client, raw JSON-RPC, + * credential, generic mutation, or unrestricted network surface. + */ +export interface AppExtensionHost { + readonly principal: AppExtensionPrincipal; + readonly capabilities: AppExtensionCapabilityGrants; + readonly contributions: readonly AppExtensionDeclaredContribution[]; + readonly signal: AbortSignal; + readonly sessionBadges: AppSessionBadgesHost; + readonly canvases: AppCanvasesHost; + readonly forgeProviders: AppForgeProvidersHost; + readonly mediatedFetch: AppMediatedFetchHost; +} + +/** Cleanup returned by an app-extension activation callback. */ +export type AppExtensionDisposer = + | (() => void | Promise) + | { dispose(): void | Promise }; + +/** App-extension activation callback. */ +export type AppExtensionDefinition = ( + host: AppExtensionHost +) => void | AppExtensionDisposer | Promise; + +/** Lifecycle handle returned after a private app extension activates. */ +export interface AppExtensionActivation { + readonly principal: AppExtensionPrincipal; + readonly signal: AbortSignal; + dispose(): Promise; +} + +class SessionBadgesContribution implements AppSessionBadgesContribution { + readonly identity: AppExtensionContributionIdentity<"sessionBadges">; + #delegate: AppSessionBadgesExtension; + #subscriptions = new Set<() => void>(); + #controllers = new Set(); + #disposed = false; + + constructor( + principal: AppExtensionPrincipal, + contributionId: AppExtensionContributionId, + delegate: AppSessionBadgesExtension, + private readonly onAction?: AppSessionBadgesActionHandler + ) { + this.identity = Object.freeze({ + principal, + contributionPoint: "sessionBadges", + contributionId, + }); + this.#delegate = delegate; + } + + get snapshot(): AppSessionBadgesSnapshot | undefined { + return this.#delegate.snapshot; + } + + onSnapshot(handler: AppSessionBadgesRegistrationHandler): () => void { + this.assertActive(); + const unsubscribe = this.#delegate.onSnapshot((snapshot) => { + try { + Promise.resolve(handler(snapshot, this.identity)).catch((error) => { + console.error("App session badge snapshot handler failed", error); + }); + } catch (error) { + console.error("App session badge snapshot handler failed", error); + } + }); + this.#subscriptions.add(unsubscribe); + return () => { + if (this.#subscriptions.delete(unsubscribe)) { + unsubscribe(); + } + }; + } + + async setBadge( + target: AppSessionBadgeTargetIdentity, + badge: AppSessionBadge | null + ): Promise { + this.assertActive(); + await this.#delegate.setBadge(target, badge); + } + + async setBadges(updates: readonly AppSessionBadgeUpdate[]): Promise { + this.assertActive(); + await this.#delegate.setBadges(updates); + } + + async setPresentation( + target: AppSessionBadgeTargetIdentity, + presentation: AppSessionPresentation + ): Promise { + this.assertActive(); + await this.#delegate.setPresentation(target, presentation); + } + + async setPresentations(updates: readonly AppSessionPresentationUpdate[]): Promise { + this.assertActive(); + await this.#delegate.setPresentations(updates); + } + + async clearBadge(target: AppSessionBadgeTargetIdentity): Promise { + this.assertActive(); + await this.#delegate.clearBadge(target); + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + for (const unsubscribe of this.#subscriptions) { + unsubscribe(); + } + this.#subscriptions.clear(); + for (const controller of this.#controllers) { + controller.abort(); + } + this.#controllers.clear(); + this.#delegate.dispose(); + } + + async invokeAction( + params: AppSessionActionCallbackRequest, + cancellation?: CancellationToken + ): Promise { + this.assertActive(); + assertProtocolVersion(params.protocolVersion); + if (params.contributionId !== this.identity.contributionId) { + throw new Error( + `App session badge action contribution mismatch: ${params.contributionId}` + ); + } + const target = validateAppSessionBadgeTarget(params.target); + if (params.action.kind !== "createPullRequest") { + throw new TypeError(`Unsupported app session action kind: ${params.action.kind}`); + } + if (typeof params.action.draft !== "boolean") { + throw new TypeError("app session action draft must be a boolean"); + } + if (!this.onAction) { + return null; + } + + const controller = new AbortController(); + this.#controllers.add(controller); + const subscription = cancellation?.onCancellationRequested(() => controller.abort()); + if (this.#disposed || cancellation?.isCancellationRequested) { + controller.abort(); + } + try { + const result = await this.onAction( + Object.freeze({ + target, + kind: "createPullRequest", + draft: params.action.draft, + signal: controller.signal, + }) + ); + const validated = validateAppSessionActionResult(result); + assertJsonPayload(validated, "session badge onAction result"); + return validated; + } finally { + subscription?.dispose(); + this.#controllers.delete(controller); + } + } + + deferSnapshotHandler(handler: AppSessionBadgesRegistrationHandler): void { + const timeout = setTimeout(() => { + this.#subscriptions.delete(cancel); + if (!this.#disposed) { + this.onSnapshot(handler); + } + }, 0); + const cancel = () => clearTimeout(timeout); + this.#subscriptions.add(cancel); + } + + private assertActive(): void { + if (this.#disposed) { + throw new Error("App session badge contribution is disposed"); + } + } +} + +class SessionBadgesRegistrar implements AppSessionBadgesHost { + readonly #handler: NonNullable; + #registration: SessionBadgesContribution | undefined; + #registering = false; + #disposed = false; + + constructor( + private readonly principal: AppExtensionPrincipal, + private readonly granted: boolean, + private readonly declaredContributions: readonly AppExtensionDeclaredContribution[], + private readonly registerDelegate: () => Promise, + private readonly session: CopilotSession + ) { + this.#handler = { + invoke: (params, cancellation) => { + const registration = this.#registration; + if (!registration) { + throw new Error("No app session badge contribution is registered"); + } + return registration.invokeAction(params, cancellation); + }, + }; + this.session.clientSessionApis.appSessionBadges = this.#handler; + } + + async register( + options: AppSessionBadgesRegistration = {} + ): Promise { + if (this.#disposed) { + throw new Error("The app extension activation is disposed"); + } + if (options === null || typeof options !== "object") { + throw new TypeError("sessionBadges.register options must be an object"); + } + if (options.onSnapshot !== undefined && typeof options.onSnapshot !== "function") { + throw new TypeError("sessionBadges.register onSnapshot must be a function"); + } + if (options.onAction !== undefined && typeof options.onAction !== "function") { + throw new TypeError("sessionBadges.register onAction must be a function"); + } + if (!this.granted) { + throw new Error("The app extension principal was not granted sessionBadges"); + } + const declarations = this.declaredContributions.filter( + (contribution) => contribution.contributionPoint === "sessionBadges" + ); + if (declarations.length !== 1) { + throw new Error( + `The app extension must declare exactly one sessionBadges contribution; received ${declarations.length}` + ); + } + if (this.#registration) { + throw new Error("The app extension already registered sessionBadges"); + } + if (this.#registering) { + throw new Error("The app extension is already registering sessionBadges"); + } + this.#registering = true; + let delegate: AppSessionBadgesExtension | undefined; + let contribution: SessionBadgesContribution | undefined; + try { + delegate = await this.registerDelegate(); + if (this.#disposed) { + delegate.dispose(); + throw new Error("The app extension activation was disposed during registration"); + } + contribution = new SessionBadgesContribution( + this.principal, + declarations[0]!.contributionId, + delegate, + options.onAction + ); + this.#registration = contribution; + if (options.onSnapshot) { + contribution.deferSnapshotHandler(options.onSnapshot); + } + return contribution; + } catch (error) { + if (contribution) { + contribution.dispose(); + if (this.#registration === contribution) { + this.#registration = undefined; + } + } else if (delegate && !this.#disposed) { + delegate.dispose(); + } + throw error; + } finally { + this.#registering = false; + } + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + if (this.session.clientSessionApis.appSessionBadges === this.#handler) { + delete this.session.clientSessionApis.appSessionBadges; + } + this.#registration?.dispose(); + this.#registration = undefined; + } +} + +class CanvasRegistration implements AppCanvasRegistration { + readonly identity: AppExtensionContributionIdentity<"canvases">; + readonly publicRegistration: AppCanvasRegistration; + remoteRegistered = false; + disposed = false; + readonly #controllers = new Set(); + + constructor( + private readonly owner: CanvasesRegistrar, + principal: AppExtensionPrincipal, + readonly contributionId: AppExtensionContributionId, + readonly options: AppCanvasRegistrationOptions + ) { + this.identity = Object.freeze({ + principal, + contributionPoint: "canvases", + contributionId, + }); + this.publicRegistration = Object.freeze( + Object.defineProperty({ identity: this.identity }, "dispose", { + value: this.dispose.bind(this), + }) + ) as AppCanvasRegistration; + } + + dispose(): Promise { + return this.owner.unregister(this, true); + } + + abort(): void { + if (this.disposed) return; + this.disposed = true; + for (const controller of this.#controllers) { + controller.abort(); + } + this.#controllers.clear(); + } + + async open( + params: AppCanvasOpenCallbackRequest, + cancellation?: CancellationToken + ): Promise { + this.assertActive(); + assertProtocolVersion(params.protocolVersion); + assertBoundedString(params.instanceId, "instanceId", MAX_CANVAS_INSTANCE_ID_LENGTH); + const result = await this.run( + (signal) => + this.options.onOpen( + Object.freeze({ + instanceId: params.instanceId, + input: params.input, + context: copyCanvasContext(params.context), + signal, + }) + ), + cancellation + ); + return validateCanvasOpenResult(result); + } + + async action( + params: AppCanvasActionCallbackRequest, + cancellation?: CancellationToken + ): Promise { + this.assertActive(); + assertProtocolVersion(params.protocolVersion); + assertBoundedString(params.instanceId, "instanceId", MAX_CANVAS_INSTANCE_ID_LENGTH); + assertBoundedString(params.actionName, "actionName", MAX_OPERATION_NAME_LENGTH); + const result = await this.run( + (signal) => + this.options.onAction( + Object.freeze({ + instanceId: params.instanceId, + actionName: params.actionName, + input: params.input, + context: copyCanvasContext(params.context), + signal, + }) + ), + cancellation + ); + if (isCanvasOpenResultShape(result)) { + const refreshed = validateCanvasOpenResult(result); + assertJsonPayload(refreshed, "canvas action result"); + return refreshed; + } + assertJsonPayload(result, "canvas action result"); + return result; + } + + async close( + params: AppCanvasCloseCallbackRequest, + cancellation?: CancellationToken + ): Promise { + this.assertActive(); + assertProtocolVersion(params.protocolVersion); + assertBoundedString(params.instanceId, "instanceId", MAX_CANVAS_INSTANCE_ID_LENGTH); + if (!this.options.onClose) return; + await this.run( + (signal) => + this.options.onClose!( + Object.freeze({ + instanceId: params.instanceId, + context: copyCanvasContext(params.context), + signal, + }) + ), + cancellation + ); + } + + private async run( + callback: (signal: AbortSignal) => T | Promise, + cancellation?: CancellationToken + ): Promise { + const controller = new AbortController(); + this.#controllers.add(controller); + const subscription = cancellation?.onCancellationRequested(() => controller.abort()); + if (this.disposed || cancellation?.isCancellationRequested) { + controller.abort(); + } + try { + return await callback(controller.signal); + } finally { + subscription?.dispose(); + this.#controllers.delete(controller); + } + } + + private assertActive(): void { + if (this.disposed) { + throw new Error("App canvas contribution is disposed"); + } + } +} + +class CanvasesRegistrar implements AppCanvasesHost { + readonly #registrations = new Map(); + readonly #handler: NonNullable; + #disposed = false; + #transportAvailable = true; + + constructor( + private readonly principal: AppExtensionPrincipal, + private readonly granted: boolean, + private readonly declaredContributions: readonly AppExtensionDeclaredContribution[], + private readonly client: CopilotClient, + private readonly session: CopilotSession + ) { + this.#handler = { + open: (params, cancellation) => + this.dispatch(params.contributionId, (registration) => + registration.open(params, cancellation) + ), + invoke: (params, cancellation) => + this.dispatch(params.contributionId, (registration) => + registration.action(params, cancellation) + ), + close: (params, cancellation) => + this.dispatch(params.contributionId, (registration) => + registration.close(params, cancellation) + ), + }; + this.session.clientSessionApis.appCanvas = this.#handler; + } + + async register(options: AppCanvasRegistrationOptions): Promise { + this.assertCanRegister(options); + const contributionId = resolveDeclaredContribution( + this.declaredContributions, + "canvases", + options.contributionId + ); + if (this.#registrations.has(contributionId)) { + throw new Error(`App canvas contribution ${contributionId} is already registered`); + } + + const registration = new CanvasRegistration( + this, + this.principal, + contributionId, + Object.freeze({ + contributionId, + onOpen: options.onOpen, + onAction: options.onAction, + onClose: options.onClose, + }) + ); + this.#registrations.set(contributionId, registration); + try { + await this.client[registerPrivateAppCanvasSymbol](contributionId); + registration.remoteRegistered = true; + if (this.#disposed || registration.disposed) { + if (this.#transportAvailable) { + await this.client[unregisterPrivateAppCanvasSymbol](contributionId); + } + registration.remoteRegistered = false; + throw new Error("The app extension activation was disposed during registration"); + } + return registration.publicRegistration; + } catch (error) { + if (this.#registrations.get(contributionId) === registration) { + this.#registrations.delete(contributionId); + } + registration.abort(); + throw error; + } + } + + async unregister(registration: CanvasRegistration, notifyRuntime: boolean): Promise { + registration.abort(); + if (notifyRuntime && this.#transportAvailable && registration.remoteRegistered) { + await this.client[unregisterPrivateAppCanvasSymbol](registration.contributionId); + registration.remoteRegistered = false; + } + if (this.#registrations.get(registration.contributionId) === registration) { + this.#registrations.delete(registration.contributionId); + } + } + + async dispose(transportAvailable: boolean): Promise { + if (this.#disposed) return; + this.#disposed = true; + this.#transportAvailable = transportAvailable; + if (this.session.clientSessionApis.appCanvas === this.#handler) { + delete this.session.clientSessionApis.appCanvas; + } + const registrations = [...this.#registrations.values()]; + await Promise.all( + registrations.map((registration) => this.unregister(registration, transportAvailable)) + ); + } + + private dispatch( + contributionId: string, + callback: (registration: CanvasRegistration) => Promise + ): Promise { + const registration = this.#registrations.get(contributionId); + if (!registration) { + throw new Error(`No app canvas contribution registered for ${contributionId}`); + } + return callback(registration); + } + + private assertCanRegister(options: AppCanvasRegistrationOptions): void { + if (this.#disposed) { + throw new Error("The app extension activation is disposed"); + } + if (!this.granted) { + throw new Error("The app extension principal was not granted canvases"); + } + if (options === null || typeof options !== "object") { + throw new TypeError("canvases.register options must be an object"); + } + if (typeof options.onOpen !== "function") { + throw new TypeError("canvases.register onOpen must be a function"); + } + if (typeof options.onAction !== "function") { + throw new TypeError("canvases.register onAction must be a function"); + } + if (options.onClose !== undefined && typeof options.onClose !== "function") { + throw new TypeError("canvases.register onClose must be a function"); + } + } +} + +class ForgeProviderRegistration implements AppForgeProviderRegistration { + readonly identity: AppExtensionContributionIdentity<"forgeProvider">; + readonly operations: readonly string[]; + readonly publicRegistration: AppForgeProviderRegistration; + remoteRegistered = false; + disposed = false; + readonly #controllers = new Set(); + + constructor( + private readonly owner: ForgeProvidersRegistrar, + principal: AppExtensionPrincipal, + readonly contributionId: AppExtensionContributionId, + readonly handlers: ReadonlyMap + ) { + this.identity = Object.freeze({ + principal, + contributionPoint: "forgeProvider", + contributionId, + }); + this.operations = Object.freeze([...handlers.keys()]); + this.publicRegistration = Object.freeze( + Object.defineProperty( + { identity: this.identity, operations: this.operations }, + "dispose", + { value: this.dispose.bind(this) } + ) + ) as AppForgeProviderRegistration; + } + + dispose(): Promise { + return this.owner.unregister(this, true); + } + + abort(): void { + if (this.disposed) return; + this.disposed = true; + for (const controller of this.#controllers) { + controller.abort(); + } + this.#controllers.clear(); + } + + async invoke( + params: AppForgeInvokeCallbackRequest, + cancellation?: CancellationToken + ): Promise { + this.assertActive(); + assertProtocolVersion(params.protocolVersion); + const handler = this.handlers.get(params.operation); + if (!handler) { + throw new Error( + `Forge provider ${this.contributionId} does not support operation ${params.operation}` + ); + } + const controller = new AbortController(); + this.#controllers.add(controller); + const subscription = cancellation?.onCancellationRequested(() => controller.abort()); + if (this.disposed || cancellation?.isCancellationRequested) { + controller.abort(); + } + try { + const result = await handler( + Object.freeze({ + operation: params.operation, + accountId: params.accountId, + input: params.input, + signal: controller.signal, + }) + ); + assertJsonPayload(result, "forge provider result"); + return result; + } finally { + subscription?.dispose(); + this.#controllers.delete(controller); + } + } + + private assertActive(): void { + if (this.disposed) { + throw new Error("App forge-provider contribution is disposed"); + } + } +} + +class ForgeProvidersRegistrar implements AppForgeProvidersHost { + readonly #registrations = new Map(); + readonly #handler: NonNullable; + #disposed = false; + #transportAvailable = true; + + constructor( + private readonly principal: AppExtensionPrincipal, + private readonly granted: boolean, + private readonly declaredContributions: readonly AppExtensionDeclaredContribution[], + private readonly client: CopilotClient, + private readonly session: CopilotSession + ) { + this.#handler = { + invoke: (params, cancellation) => + this.dispatch(params.contributionId, (registration) => + registration.invoke(params, cancellation) + ), + }; + this.session.clientSessionApis.appForgeProvider = this.#handler; + } + + async register( + options: AppForgeProviderRegistrationOptions + ): Promise { + const handlers = this.validateOptions(options); + const contributionId = resolveDeclaredContribution( + this.declaredContributions, + "forgeProvider", + options.contributionId + ); + if (this.#registrations.has(contributionId)) { + throw new Error(`Forge-provider contribution ${contributionId} is already registered`); + } + + const registration = new ForgeProviderRegistration( + this, + this.principal, + contributionId, + handlers + ); + this.#registrations.set(contributionId, registration); + try { + const [firstOperation, ...remainingOperations] = registration.operations; + await this.client[registerPrivateAppForgeProviderSymbol](contributionId, [ + firstOperation!, + ...remainingOperations, + ]); + registration.remoteRegistered = true; + if (this.#disposed || registration.disposed) { + if (this.#transportAvailable) { + await this.client[unregisterPrivateAppForgeProviderSymbol](contributionId); + } + registration.remoteRegistered = false; + throw new Error("The app extension activation was disposed during registration"); + } + return registration.publicRegistration; + } catch (error) { + if (this.#registrations.get(contributionId) === registration) { + this.#registrations.delete(contributionId); + } + registration.abort(); + throw error; + } + } + + async unregister( + registration: ForgeProviderRegistration, + notifyRuntime: boolean + ): Promise { + registration.abort(); + if (notifyRuntime && this.#transportAvailable && registration.remoteRegistered) { + await this.client[unregisterPrivateAppForgeProviderSymbol](registration.contributionId); + registration.remoteRegistered = false; + } + if (this.#registrations.get(registration.contributionId) === registration) { + this.#registrations.delete(registration.contributionId); + } + } + + async dispose(transportAvailable: boolean): Promise { + if (this.#disposed) return; + this.#disposed = true; + this.#transportAvailable = transportAvailable; + if (this.session.clientSessionApis.appForgeProvider === this.#handler) { + delete this.session.clientSessionApis.appForgeProvider; + } + const registrations = [...this.#registrations.values()]; + await Promise.all( + registrations.map((registration) => this.unregister(registration, transportAvailable)) + ); + } + + isRegistered(contributionId: AppExtensionContributionId): boolean { + const registration = this.#registrations.get(contributionId); + return ( + registration !== undefined && registration.remoteRegistered && !registration.disposed + ); + } + + private dispatch( + contributionId: string, + callback: (registration: ForgeProviderRegistration) => Promise + ): Promise { + const registration = this.#registrations.get(contributionId); + if (!registration) { + throw new Error(`No app forge-provider contribution registered for ${contributionId}`); + } + return callback(registration); + } + + private validateOptions( + options: AppForgeProviderRegistrationOptions + ): ReadonlyMap { + if (this.#disposed) { + throw new Error("The app extension activation is disposed"); + } + if (!this.granted) { + throw new Error("The app extension principal was not granted forgeProvider"); + } + if (options === null || typeof options !== "object") { + throw new TypeError("forgeProviders.register options must be an object"); + } + if ( + options.operations === null || + typeof options.operations !== "object" || + Array.isArray(options.operations) + ) { + throw new TypeError("forgeProviders.register operations must be an object"); + } + const handlers = new Map(); + for (const [operation, handler] of Object.entries(options.operations)) { + assertBoundedString(operation, "operation", MAX_OPERATION_NAME_LENGTH); + if (typeof handler !== "function") { + throw new TypeError( + `forgeProviders.register operation ${operation} must be a function` + ); + } + handlers.set(operation, handler); + } + if (handlers.size === 0) { + throw new TypeError("forgeProviders.register requires at least one operation"); + } + return handlers; + } +} + +class MediatedFetchHost implements AppMediatedFetchHost { + constructor( + private readonly granted: boolean, + private readonly declaredContributions: readonly AppExtensionDeclaredContribution[], + private readonly forgeProviders: ForgeProvidersRegistrar, + private readonly client: CopilotClient, + private readonly lifecycleSignal: AbortSignal + ) {} + + async request(options: AppMediatedFetchRequest): Promise { + if (!this.granted) { + throw new Error("The app extension principal was not granted mediatedFetch"); + } + if (options === null || typeof options !== "object") { + throw new TypeError("mediatedFetch.request options must be an object"); + } + const contributionId = resolveDeclaredContribution( + this.declaredContributions, + "forgeProvider", + options.contributionId + ); + if (!this.forgeProviders.isRegistered(contributionId)) { + throw new Error( + `Forge-provider contribution ${contributionId} must be registered before mediated fetch` + ); + } + assertBoundedString(options.accountId, "accountId", MAX_CONTRIBUTION_ID_LENGTH); + assertBoundedString(options.operation, "operation", MAX_OPERATION_NAME_LENGTH); + const request = validateMediatedFetchRequest(options); + const signal = options.signal + ? AbortSignal.any([this.lifecycleSignal, options.signal]) + : this.lifecycleSignal; + if (signal.aborted) { + throw new DOMException("The mediated fetch request was aborted", "AbortError"); + } + const response = await this.client[requestPrivateAppMediatedFetchSymbol]( + { + protocolVersion: APP_EXTENSION_PROTOCOL_VERSION, + contributionId, + accountId: options.accountId, + operation: options.operation, + request, + }, + signal + ); + return validateMediatedFetchResponse(response); + } +} + +class AppExtensionRuntime { + readonly signal: AbortSignal; + readonly principal: AppExtensionPrincipal; + #disposer: AppExtensionDisposer | undefined; + #disposed = false; + #disposePromise: Promise | undefined; + #removeTransportCloseHandler: () => void = () => {}; + readonly #client: CopilotClient; + readonly #session: CopilotSession; + readonly #sessionBadges: SessionBadgesRegistrar; + readonly #canvases: CanvasesRegistrar; + readonly #forgeProviders: ForgeProvidersRegistrar; + + constructor( + principal: AppExtensionPrincipal, + client: CopilotClient, + session: CopilotSession, + sessionBadges: SessionBadgesRegistrar, + canvases: CanvasesRegistrar, + forgeProviders: ForgeProvidersRegistrar, + private readonly abortController: AbortController + ) { + this.principal = principal; + this.#client = client; + this.#session = session; + this.#sessionBadges = sessionBadges; + this.#canvases = canvases; + this.#forgeProviders = forgeProviders; + this.signal = this.abortController.signal; + this.#removeTransportCloseHandler = this.#client[onExtensionTransportClosedSymbol](() => { + void this.#dispose(false).catch((error) => { + console.error("App extension transport cleanup failed", error); + }); + }); + } + + async adoptDisposer(disposer: void | AppExtensionDisposer): Promise { + if (this.#disposed) { + if (disposer !== undefined) { + await invokeDisposer(disposer); + } + throw new Error("App extension transport closed during activation"); + } + if (disposer !== undefined) { + this.#disposer = disposer; + } + } + + activation(): AppExtensionActivation { + return Object.freeze({ + principal: this.principal, + signal: this.signal, + dispose: () => this.#dispose(true), + }); + } + + cleanupAfterActivationFailure(): Promise { + return this.#dispose(true); + } + + #dispose(disconnect: boolean): Promise { + if (!this.#disposePromise) { + this.#disposePromise = this.#disposeOnce(disconnect); + } + return this.#disposePromise; + } + + async #disposeOnce(disconnect: boolean): Promise { + if (this.#disposed) return; + this.#disposed = true; + this.#removeTransportCloseHandler(); + this.abortController.abort(); + this.#sessionBadges.dispose(); + + const errors: unknown[] = []; + for (const registrar of [this.#canvases, this.#forgeProviders]) { + try { + await registrar.dispose(disconnect); + } catch (error) { + errors.push(error); + } + } + try { + await invokeDisposer(this.#disposer); + } catch (error) { + errors.push(error); + } + if (disconnect) { + try { + await this.#session.disconnect(); + } catch (error) { + errors.push(error); + } + try { + errors.push(...(await this.#client.stop())); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw new AggregateError(errors, "Failed to dispose app extension"); + } + } +} + +/** + * Define and activate a private bundled app extension. + * + * The runtime authenticates the package and launch generation before the + * callback runs. Unallowlisted or legacy extension connections are rejected. + * + * @internal This entry point is intended only for app-bundled packages. + */ +export async function defineAppExtension( + definition: AppExtensionDefinition +): Promise { + if (typeof definition !== "function") { + throw new TypeError("defineAppExtension requires an activation function"); + } + + const { client, session } = await joinExtensionSession({}); + let runtime: AppExtensionRuntime | undefined; + try { + const registration = await client[registerPrivateAppExtensionSymbol](); + const principal = parsePrincipal(registration); + const capabilities = parseCapabilities(registration.capabilities); + const contributions = parseContributions(registration.contributions); + const abortController = new AbortController(); + const sessionBadges = new SessionBadgesRegistrar( + principal, + capabilities.sessionBadges === true, + contributions, + () => client[registerPrivateAppSessionBadgesSymbol](session), + session + ); + const canvases = new CanvasesRegistrar( + principal, + capabilities.canvases === true, + contributions, + client, + session + ); + const forgeProviders = new ForgeProvidersRegistrar( + principal, + capabilities.forgeProvider === true, + contributions, + client, + session + ); + const mediatedFetch = new MediatedFetchHost( + capabilities.mediatedFetch === true, + contributions, + forgeProviders, + client, + abortController.signal + ); + runtime = new AppExtensionRuntime( + principal, + client, + session, + sessionBadges, + canvases, + forgeProviders, + abortController + ); + const host: AppExtensionHost = Object.freeze({ + principal, + capabilities, + contributions, + signal: runtime.signal, + sessionBadges: Object.freeze({ + register: sessionBadges.register.bind(sessionBadges), + }), + canvases: Object.freeze({ + register: canvases.register.bind(canvases), + }), + forgeProviders: Object.freeze({ + register: forgeProviders.register.bind(forgeProviders), + }), + mediatedFetch: Object.freeze({ + request: mediatedFetch.request.bind(mediatedFetch), + }), + }); + await runtime.adoptDisposer(await definition(host)); + return runtime.activation(); + } catch (error) { + if (runtime) { + try { + await runtime.cleanupAfterActivationFailure(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Failed to activate and dispose app extension" + ); + } + } else { + const cleanupErrors: unknown[] = []; + try { + await session.disconnect(); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + try { + cleanupErrors.push(...(await client.stop())); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + if (cleanupErrors.length > 0) { + throw new AggregateError( + [error, ...cleanupErrors], + "Failed to authenticate and disconnect app extension" + ); + } + } + throw error; + } +} + +function parsePrincipal(registration: unknown): AppExtensionPrincipal { + if (registration === null || typeof registration !== "object") { + throw new TypeError("App extension registration must be an object"); + } + const value = registration as Record; + if (value.protocolVersion !== APP_EXTENSION_PROTOCOL_VERSION) { + throw new TypeError( + `Unsupported app extension protocol version: ${String(value.protocolVersion)}` + ); + } + if (value.principal === null || typeof value.principal !== "object") { + throw new TypeError("principal must be an object"); + } + const principal = value.principal as Record; + assertBoundedString(principal.packageId, "principal.packageId", MAX_CONTRIBUTION_ID_LENGTH); + assertBoundedString( + principal.activationId, + "principal.activationId", + MAX_CONTRIBUTION_ID_LENGTH + ); + return Object.freeze({ + packageId: principal.packageId as AppExtensionPackageId, + activationId: principal.activationId as AppExtensionActivationId, + }); +} + +function parseContributions(contributions: unknown): readonly AppExtensionDeclaredContribution[] { + if (!Array.isArray(contributions)) { + throw new TypeError("contributions must be an array"); + } + const seen = new Set(); + return Object.freeze( + contributions.map((contribution, index) => { + if (contribution === null || typeof contribution !== "object") { + throw new TypeError(`contributions[${index}] must be an object`); + } + const { contributionPoint, contributionId } = contribution as Record; + if ( + contributionPoint !== "sessionBadges" && + contributionPoint !== "canvases" && + contributionPoint !== "forgeProvider" + ) { + throw new TypeError(`contributions[${index}].contributionPoint is not supported`); + } + assertBoundedString( + contributionId, + `contributions[${index}].contributionId`, + MAX_CONTRIBUTION_ID_LENGTH + ); + const key = `${contributionPoint}\0${contributionId}`; + if (seen.has(key)) { + throw new TypeError( + `contributions contains duplicate identity ${contributionPoint}/${contributionId}` + ); + } + seen.add(key); + return Object.freeze({ + contributionPoint, + contributionId: contributionId as AppExtensionContributionId, + }); + }) + ); +} + +function parseCapabilities(capabilities: unknown): AppExtensionCapabilityGrants { + if (capabilities === null || typeof capabilities !== "object") { + throw new TypeError("capabilities must be an object"); + } + const grants: { + sessionBadges?: true; + canvases?: true; + forgeProvider?: true; + mediatedFetch?: true; + } = {}; + for (const name of ["sessionBadges", "canvases", "forgeProvider", "mediatedFetch"] as const) { + const value = (capabilities as Record)[name]; + if (value !== undefined && value !== true) { + throw new TypeError(`capabilities.${name} must be true when present`); + } + if (value === true) { + grants[name] = true; + } + } + return Object.freeze(grants); +} + +async function invokeDisposer(disposer: AppExtensionDisposer | undefined): Promise { + if (typeof disposer === "function") { + await disposer(); + } else if (disposer) { + await disposer.dispose(); + } +} + +function assertNonEmptyString(value: unknown, name: string): asserts value is string { + if (typeof value !== "string" || value.length === 0) { + throw new TypeError(`${name} must be a non-empty string`); + } +} + +function assertBoundedString( + value: unknown, + name: string, + maxLength: number +): asserts value is string { + assertNonEmptyString(value, name); + if (value.length > maxLength) { + throw new TypeError(`${name} must be at most ${maxLength} characters`); + } +} + +function assertProtocolVersion(version: unknown): asserts version is 1 { + if (version !== APP_EXTENSION_PROTOCOL_VERSION) { + throw new TypeError(`Unsupported app extension protocol version: ${String(version)}`); + } +} + +function resolveDeclaredContribution( + contributions: readonly AppExtensionDeclaredContribution[], + contributionPoint: TContributionPoint, + requestedId: unknown +): AppExtensionContributionId { + assertBoundedString(requestedId, "contributionId", MAX_CONTRIBUTION_ID_LENGTH); + const declaration = contributions.find( + (candidate) => + candidate.contributionPoint === contributionPoint && + candidate.contributionId === requestedId + ); + if (!declaration) { + throw new Error( + `The app extension principal did not declare ${contributionPoint}/${requestedId}` + ); + } + return declaration.contributionId; +} + +function copyCanvasContext( + context: WireAppCanvasContext | undefined +): AppCanvasContext | undefined { + if (!context) return undefined; + const project = context.project + ? Object.freeze({ + forgeProviderId: context.project.forgeProviderId, + repositoryLocator: context.project.repositoryLocator, + ...(context.project.forgeAccountId === undefined + ? {} + : { forgeAccountId: context.project.forgeAccountId }), + }) + : undefined; + return Object.freeze({ + ...(context.projectId === undefined ? {} : { projectId: context.projectId }), + ...(context.workspaceId === undefined ? {} : { workspaceId: context.workspaceId }), + ...(project === undefined ? {} : { project }), + }); +} + +function validateCanvasOpenResult(result: AppCanvasOpenResult): WireAppCanvasOpenResult { + if (result === null || typeof result !== "object") { + throw new TypeError("canvas onOpen result must be an object"); + } + if (result.title !== undefined) { + assertOptionalBoundedText(result.title, "canvas title", MAX_CANVAS_METADATA_LENGTH); + } + if (result.status !== undefined) { + assertOptionalBoundedText(result.status, "canvas status", MAX_CANVAS_METADATA_LENGTH); + } + if (result.state !== undefined) { + assertJsonPayload(result.state, "canvas state"); + } + const actions = validateCanvasActions(result.actions); + const validated = { + ...(result.state === undefined ? {} : { state: result.state }), + ...(result.title === undefined ? {} : { title: result.title }), + ...(result.status === undefined ? {} : { status: result.status }), + ...(actions === undefined ? {} : { actions }), + }; + assertJsonPayload(validated, "canvas open result"); + return validated; +} + +/** Return whether a value is a valid bounded app-canvas open result. */ +export function isAppCanvasOpenResult(value: unknown): value is AppCanvasOpenResult { + if (!isCanvasOpenResultShape(value)) { + return false; + } + try { + validateCanvasOpenResult(value); + return true; + } catch { + return false; + } +} + +function isCanvasOpenResultShape(value: unknown): value is AppCanvasOpenResult { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const keys = Object.keys(value); + return keys.every( + (key) => key === "state" || key === "title" || key === "status" || key === "actions" + ); +} + +function validateCanvasActions( + actions: readonly AppCanvasActionDescriptor[] | undefined +): WireAppCanvasOpenResult["actions"] { + if (actions === undefined) { + return undefined; + } + if (!Array.isArray(actions)) { + throw new TypeError("canvas actions must be an array"); + } + if (actions.length > MAX_CANVAS_ACTIONS) { + throw new TypeError(`canvas actions must contain at most ${MAX_CANVAS_ACTIONS} items`); + } + const names = new Set(); + return actions.map((action, index) => { + if (action === null || typeof action !== "object" || Array.isArray(action)) { + throw new TypeError(`canvas actions[${index}] must be an object`); + } + assertBoundedString( + action.name, + `canvas actions[${index}].name`, + MAX_OPERATION_NAME_LENGTH + ); + assertBoundedString( + action.label, + `canvas actions[${index}].label`, + MAX_CANVAS_METADATA_LENGTH + ); + if (names.has(action.name)) { + throw new TypeError(`canvas actions contains duplicate name: ${action.name}`); + } + names.add(action.name); + if ( + action.variant !== undefined && + action.variant !== "default" && + action.variant !== "primary" && + action.variant !== "danger" + ) { + throw new TypeError( + `canvas actions[${index}].variant must be default, primary, or danger` + ); + } + if (action.disabled !== undefined && typeof action.disabled !== "boolean") { + throw new TypeError(`canvas actions[${index}].disabled must be a boolean`); + } + if (action.input !== undefined) { + assertJsonPayload(action.input, `canvas actions[${index}].input`); + } + return { + name: action.name, + label: action.label, + ...(action.input === undefined ? {} : { input: action.input }), + ...(action.variant === undefined ? {} : { variant: action.variant }), + ...(action.disabled === undefined ? {} : { disabled: action.disabled }), + }; + }); +} + +function validateAppSessionBadgeTarget(value: unknown): AppSessionBadgeTarget { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("app session action target must be an object"); + } + const target = value as Record; + assertBoundedString(target.workspaceId, "target.workspaceId", MAX_CONTRIBUTION_ID_LENGTH); + assertBoundedString(target.sessionId, "target.sessionId", MAX_CONTRIBUTION_ID_LENGTH); + assertBoundedString(target.repositoryPath, "target.repositoryPath", MAX_ACTION_PROMPT_BYTES); + assertBoundedString(target.worktreePath, "target.worktreePath", MAX_ACTION_PROMPT_BYTES); + if (target.branch !== undefined && typeof target.branch !== "string") { + throw new TypeError("target.branch must be a string when provided"); + } + return Object.freeze({ + workspaceId: target.workspaceId, + sessionId: target.sessionId, + repositoryPath: target.repositoryPath, + worktreePath: target.worktreePath, + ...(target.branch === undefined ? {} : { branch: target.branch }), + }); +} + +function validateAppSessionActionResult( + result: AppSessionBadgesActionResult | null +): AppSessionBadgesActionResult | null { + if (result === null) { + return null; + } + if (result === undefined || typeof result !== "object" || Array.isArray(result)) { + throw new TypeError("session badge onAction result must be an object or null"); + } + if (typeof result.prompt !== "string" || result.prompt.length === 0) { + throw new TypeError("session badge onAction prompt must be a non-empty string"); + } + if (Buffer.byteLength(result.prompt, "utf8") > MAX_ACTION_PROMPT_BYTES) { + throw new TypeError( + `session badge onAction prompt must not exceed ${MAX_ACTION_PROMPT_BYTES} bytes` + ); + } + if (result.prompt.split(/\r?\n/, 1)[0] !== "# Pull Request Creation") { + throw new TypeError( + 'session badge onAction prompt must start with the exact "# Pull Request Creation" header' + ); + } + assertBoundedString( + result.requiredTool, + "session badge onAction requiredTool", + MAX_OPERATION_NAME_LENGTH + ); + if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(result.requiredTool)) { + throw new TypeError("session badge onAction requiredTool is not a valid tool identifier"); + } + return { + prompt: result.prompt, + requiredTool: result.requiredTool, + }; +} + +function assertOptionalBoundedText(value: unknown, name: string, maxLength: number): void { + if (typeof value !== "string" || value.length > maxLength) { + throw new TypeError(`${name} must be a string of at most ${maxLength} characters`); + } +} + +function assertJsonPayload(value: unknown, name: string): asserts value is JsonValue { + let serialized: string | undefined; + try { + serialized = JSON.stringify(value); + } catch (error) { + throw new TypeError(`${name} must be JSON-serializable`, { cause: error }); + } + if (serialized === undefined) { + throw new TypeError(`${name} must be JSON-serializable`); + } + if (Buffer.byteLength(serialized, "utf8") > MAX_JSON_PAYLOAD_BYTES) { + throw new TypeError(`${name} must not exceed ${MAX_JSON_PAYLOAD_BYTES} bytes`); + } +} + +function validateMediatedFetchRequest( + options: AppMediatedFetchRequest +): WireAppMediatedFetchRequest["request"] { + if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(options.method)) { + throw new TypeError(`Unsupported mediated fetch method: ${String(options.method)}`); + } + assertBoundedString(options.path, "path", MAX_FETCH_PATH_LENGTH); + if ( + !options.path.startsWith("/") || + /^[a-z][a-z0-9+.-]*:/i.test(options.path) || + options.path.startsWith("//") || + options.path.includes("\\") || + options.path.includes("\r") || + options.path.includes("\n") + ) { + throw new TypeError("path must be a credential-free root-relative URL path"); + } + const pathWithoutQuery = options.path.split(/[?#]/, 1)[0]!; + let decodedPath: string; + try { + decodedPath = decodeURIComponent(pathWithoutQuery); + } catch (error) { + throw new TypeError("path contains invalid percent encoding", { cause: error }); + } + if (decodedPath.includes("\\") || decodedPath.split("/").some((segment) => segment === "..")) { + throw new TypeError("path must not contain parent traversal segments"); + } + const headers = validateFetchHeaders(options.headers); + if (options.body !== undefined) { + if (typeof options.body !== "string") { + throw new TypeError("body must be a string"); + } + if (Buffer.byteLength(options.body, "utf8") > MAX_FETCH_BODY_BYTES) { + throw new TypeError(`body must not exceed ${MAX_FETCH_BODY_BYTES} bytes`); + } + } + return { + method: options.method, + path: options.path, + ...(headers === undefined ? {} : { headers }), + ...(options.body === undefined ? {} : { body: options.body }), + }; +} + +function validateFetchHeaders( + headers: Readonly> | undefined +): Record | undefined { + if (headers === undefined) return undefined; + if (headers === null || typeof headers !== "object" || Array.isArray(headers)) { + throw new TypeError("headers must be an object"); + } + const entries = Object.entries(headers); + if (entries.length > MAX_FETCH_HEADERS) { + throw new TypeError(`headers must contain at most ${MAX_FETCH_HEADERS} entries`); + } + const denied = /^(authorization|cookie|host|proxy-authorization|x-forwarded-.+)$/i; + const result: Record = {}; + let bytes = 0; + for (const [name, value] of entries) { + if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name) || denied.test(name)) { + throw new TypeError(`header ${name} is not permitted`); + } + if (typeof value !== "string" || /[\r\n]/.test(value)) { + throw new TypeError(`header ${name} must be a single-line string`); + } + bytes += Buffer.byteLength(name, "utf8") + Buffer.byteLength(value, "utf8"); + if (bytes > MAX_FETCH_HEADER_BYTES) { + throw new TypeError(`headers must not exceed ${MAX_FETCH_HEADER_BYTES} bytes`); + } + result[name] = value; + } + return result; +} + +function validateMediatedFetchResponse( + response: WireAppMediatedFetchResponse +): AppMediatedFetchResponse { + if ( + response === null || + typeof response !== "object" || + !Number.isInteger(response.status) || + response.status < 100 || + response.status > 599 + ) { + throw new TypeError("mediated fetch response status must be an HTTP status code"); + } + if (typeof response.truncated !== "boolean") { + throw new TypeError("mediated fetch response truncated must be a boolean"); + } + const headers = validateFetchResponseHeaders(response.headers); + if (response.body !== undefined) { + if (typeof response.body !== "string") { + throw new TypeError("mediated fetch response body must be a string"); + } + if (Buffer.byteLength(response.body, "utf8") > MAX_FETCH_RESPONSE_BODY_BYTES) { + throw new TypeError( + `mediated fetch response body must not exceed ${MAX_FETCH_RESPONSE_BODY_BYTES} bytes` + ); + } + } + return Object.freeze({ + status: response.status, + headers: Object.freeze(headers), + ...(response.body === undefined ? {} : { body: response.body }), + truncated: response.truncated, + }); +} + +function validateFetchResponseHeaders(headers: unknown): Record { + if (headers === null || typeof headers !== "object" || Array.isArray(headers)) { + throw new TypeError("mediated fetch response headers must be an object"); + } + const result: Record = {}; + const entries = Object.entries(headers); + let bytes = 0; + if (entries.length > MAX_FETCH_HEADERS) { + throw new TypeError( + `mediated fetch response headers must contain at most ${MAX_FETCH_HEADERS} entries` + ); + } + for (const [name, value] of entries) { + if ( + !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name) || + typeof value !== "string" || + /[\r\n]/.test(value) + ) { + throw new TypeError("mediated fetch response headers must contain strings"); + } + bytes += Buffer.byteLength(name, "utf8") + Buffer.byteLength(value, "utf8"); + if (bytes > MAX_FETCH_HEADER_BYTES) { + throw new TypeError( + `mediated fetch response headers must not exceed ${MAX_FETCH_HEADER_BYTES} bytes` + ); + } + result[name] = value; + } + return result; +} diff --git a/nodejs/src/appExtensionClientAccess.ts b/nodejs/src/appExtensionClientAccess.ts new file mode 100644 index 0000000000..151d41378d --- /dev/null +++ b/nodejs/src/appExtensionClientAccess.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +export const registerPrivateAppExtensionSymbol = Symbol("registerPrivateAppExtension"); +export const registerPrivateAppSessionBadgesSymbol = Symbol("registerPrivateAppSessionBadges"); +export const onExtensionTransportClosedSymbol = Symbol("onExtensionTransportClosed"); +export const registerPrivateAppCanvasSymbol = Symbol("registerPrivateAppCanvas"); +export const unregisterPrivateAppCanvasSymbol = Symbol("unregisterPrivateAppCanvas"); +export const registerPrivateAppForgeProviderSymbol = Symbol("registerPrivateAppForgeProvider"); +export const unregisterPrivateAppForgeProviderSymbol = Symbol("unregisterPrivateAppForgeProvider"); +export const requestPrivateAppMediatedFetchSymbol = Symbol("requestPrivateAppMediatedFetch"); diff --git a/nodejs/src/appSessionBadges.ts b/nodejs/src/appSessionBadges.ts new file mode 100644 index 0000000000..783e46cdc7 --- /dev/null +++ b/nodejs/src/appSessionBadges.ts @@ -0,0 +1,434 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import type { Disposable, MessageConnection } from "vscode-jsonrpc/node.js"; +import type { CopilotSession } from "./session.js"; + +const APP_SESSION_BADGES_PROTOCOL_VERSION = 1 as const; +const REGISTER_METHOD = "extensions.appSessionBadges.register"; +const SET_BADGE_METHOD = "extensions.appSessionBadges.setBadge"; +const SET_BADGES_METHOD = "extensions.appSessionBadges.setBadges"; +const SET_PRESENTATION_METHOD = "extensions.appSessionBadges.setPresentation"; +const SET_PRESENTATIONS_METHOD = "extensions.appSessionBadges.setPresentations"; +const SNAPSHOT_NOTIFICATION = "appSessionBadges.snapshot"; +const MAX_BADGE_LABEL_LENGTH = 512; +const MAX_PRESENTATION_UPDATES = 1024; + +/** Badge states an app-level extension can contribute for a workspace. */ +export type AppSessionBadgeState = "draft" | "open" | "merged" | "closed"; + +/** Constrained badge presentation contributed by an app-level extension. */ +export interface AppSessionBadge { + state: AppSessionBadgeState; + label?: string; +} + +/** Create Pull Request action state contributed for an eligible app session. */ +export interface AppSessionPullRequestAction { + readonly kind: "createPullRequest"; + readonly state: "available" | "inProgress"; + readonly supportsDraft: boolean; +} + +/** Atomic extension-provided badge and action presentation. */ +export interface AppSessionPresentation { + readonly badge: AppSessionBadge | null; + readonly action: AppSessionPullRequestAction | null; +} + +/** An app-visible session that is eligible for an extension-provided badge. */ +export interface AppSessionBadgeTarget { + workspaceId: string; + sessionId: string; + repositoryPath: string; + worktreePath: string; + branch?: string; +} + +/** Complete replacement snapshot of app-visible sessions eligible for extension badges. */ +export interface AppSessionBadgesSnapshot { + readonly protocolVersion: 1; + readonly revision: number; + readonly sessions: readonly AppSessionBadgeTarget[]; +} + +/** Target identity used when publishing or clearing a badge. */ +export type AppSessionBadgeTargetIdentity = Pick< + AppSessionBadgeTarget, + "workspaceId" | "sessionId" +>; + +/** One badge publication or clear in an atomic batch. */ +export interface AppSessionBadgeUpdate { + readonly workspaceId: string; + readonly sessionId: string; + readonly badge: AppSessionBadge | null; +} + +/** One ordered atomic presentation replacement. */ +export interface AppSessionPresentationUpdate { + readonly workspaceId: string; + readonly sessionId: string; + readonly presentation: AppSessionPresentation; +} + +/** Callback invoked for each full eligible-session snapshot. */ +export type AppSessionBadgesSnapshotHandler = (snapshot: AppSessionBadgesSnapshot) => void; + +/** + * Registered app-session badge contribution for an executable extension. + * + * The host supplies full replacement snapshots. Native app badges remain + * authoritative; the host decides which sessions are eligible and validates + * eligibility again when applying an extension update. + */ +export class AppSessionBadgesExtension { + private readonly handlers = new Set(); + private latestSnapshot: AppSessionBadgesSnapshot | undefined; + private notificationRegistration: Disposable | undefined; + + private constructor( + /** The retained hidden session this contribution joined. */ + readonly session: CopilotSession, + private readonly connection: MessageConnection + ) {} + + /** Most recently received full snapshot, if the host has supplied one. */ + get snapshot(): AppSessionBadgesSnapshot | undefined { + return this.latestSnapshot; + } + + /** + * Subscribe to full replacement snapshots. + * + * If a snapshot has already arrived, the handler receives it synchronously + * before this method returns. + */ + onSnapshot(handler: AppSessionBadgesSnapshotHandler): () => void { + this.handlers.add(handler); + if (this.latestSnapshot) { + handler(this.latestSnapshot); + } + return () => { + this.handlers.delete(handler); + }; + } + + /** Publish a constrained badge for a currently eligible target. */ + async setBadge( + target: AppSessionBadgeTargetIdentity, + badge: AppSessionBadge | null + ): Promise { + const update = normalizeBadgeUpdate({ ...target, badge }, 0); + + await this.connection.sendRequest(SET_BADGE_METHOD, { + protocolVersion: APP_SESSION_BADGES_PROTOCOL_VERSION, + ...update, + }); + } + + /** + * Atomically publish or clear badges for multiple eligible targets. + * + * The complete batch is validated before one request is sent. Duplicate + * workspace and session target pairs are rejected. + */ + async setBadges(updates: readonly AppSessionBadgeUpdate[]): Promise { + const normalizedUpdates = normalizeBadgeUpdates(updates); + if (normalizedUpdates.length === 0) { + return; + } + + await this.connection.sendRequest(SET_BADGES_METHOD, { + protocolVersion: APP_SESSION_BADGES_PROTOCOL_VERSION, + updates: normalizedUpdates, + }); + } + + /** Replace the complete badge and action presentation for one eligible target. */ + async setPresentation( + target: AppSessionBadgeTargetIdentity, + presentation: AppSessionPresentation + ): Promise { + const update = normalizePresentationUpdate({ ...target, presentation }, 0); + + await this.connection.sendRequest(SET_PRESENTATION_METHOD, { + protocolVersion: APP_SESSION_BADGES_PROTOCOL_VERSION, + ...update, + }); + } + + /** + * Atomically replace complete presentations for multiple eligible targets. + * + * Updates retain caller order. The complete batch is validated before one + * request is sent, and duplicate workspace/session target pairs are rejected. + */ + async setPresentations(updates: readonly AppSessionPresentationUpdate[]): Promise { + const normalizedUpdates = normalizePresentationUpdates(updates); + if (normalizedUpdates.length === 0) { + return; + } + + await this.connection.sendRequest(SET_PRESENTATIONS_METHOD, { + protocolVersion: APP_SESSION_BADGES_PROTOCOL_VERSION, + updates: normalizedUpdates, + }); + } + + /** Clear a previously published badge for a target. */ + async clearBadge(target: AppSessionBadgeTargetIdentity): Promise { + await this.setBadge(target, null); + } + + /** + * Stop receiving snapshots in this process. + * + * The runtime clears published state when the extension disconnects or is + * disabled; disposing this local listener does not alter that lifecycle. + */ + dispose(): void { + this.notificationRegistration?.dispose(); + this.notificationRegistration = undefined; + this.handlers.clear(); + } + + /** @internal */ + static async register( + session: CopilotSession, + connection: MessageConnection + ): Promise { + const contribution = new AppSessionBadgesExtension(session, connection); + contribution.notificationRegistration = connection.onNotification( + SNAPSHOT_NOTIFICATION, + (payload: unknown) => { + try { + contribution.handleSnapshot(payload); + } catch (error) { + console.error("Invalid app session badges snapshot ignored", error); + } + } + ); + + try { + await connection.sendRequest(REGISTER_METHOD); + return contribution; + } catch (error) { + contribution.dispose(); + throw error; + } + } + + private handleSnapshot(payload: unknown): void { + const snapshot = parseSnapshot(payload); + this.latestSnapshot = snapshot; + for (const handler of this.handlers) { + try { + handler(snapshot); + } catch (error) { + console.error("App session badges snapshot handler failed", error); + } + } + } +} + +function parseSnapshot(payload: unknown): AppSessionBadgesSnapshot { + if (!isRecord(payload)) { + throw new TypeError("appSessionBadges.snapshot must be an object"); + } + if (payload.protocolVersion !== APP_SESSION_BADGES_PROTOCOL_VERSION) { + throw new TypeError( + `Unsupported app session badges protocol version: ${String(payload.protocolVersion)}` + ); + } + if (!Number.isSafeInteger(payload.revision) || (payload.revision as number) < 0) { + throw new TypeError("appSessionBadges.snapshot revision must be a non-negative integer"); + } + if (!Array.isArray(payload.sessions)) { + throw new TypeError("appSessionBadges.snapshot sessions must be an array"); + } + + const targetIds = new Set(); + const sessions = payload.sessions.map((session, index) => { + if (!isRecord(session)) { + throw new TypeError(`appSessionBadges.snapshot sessions[${index}] must be an object`); + } + const workspaceId = readNonEmptyString(session, "workspaceId", index); + const sessionId = readNonEmptyString(session, "sessionId", index); + const targetId = `${workspaceId}\0${sessionId}`; + if (targetIds.has(targetId)) { + throw new TypeError( + `appSessionBadges.snapshot contains duplicate target: ${workspaceId}/${sessionId}` + ); + } + targetIds.add(targetId); + + const target: AppSessionBadgeTarget = { + workspaceId, + sessionId, + repositoryPath: readNonEmptyString(session, "repositoryPath", index), + worktreePath: readNonEmptyString(session, "worktreePath", index), + }; + if (session.branch !== undefined) { + if (typeof session.branch !== "string") { + throw new TypeError( + `appSessionBadges.snapshot sessions[${index}].branch must be a string` + ); + } + target.branch = session.branch; + } + return Object.freeze(target); + }); + + return Object.freeze({ + protocolVersion: APP_SESSION_BADGES_PROTOCOL_VERSION, + revision: payload.revision as number, + sessions: Object.freeze(sessions), + }); +} + +function normalizeBadge(badge: AppSessionBadge | null): AppSessionBadge | null { + if (badge === null) { + return null; + } + if (!isRecord(badge)) { + throw new TypeError("badge must be an object or null"); + } + if (!["draft", "open", "merged", "closed"].includes(badge.state as string)) { + throw new TypeError(`Unsupported app session badge state: ${String(badge.state)}`); + } + if (badge.label !== undefined && typeof badge.label !== "string") { + throw new TypeError("badge.label must be a string when provided"); + } + if (badge.label !== undefined && badge.label.length > MAX_BADGE_LABEL_LENGTH) { + throw new TypeError(`badge.label must be at most ${MAX_BADGE_LABEL_LENGTH} characters`); + } + return badge.label === undefined + ? { state: badge.state as AppSessionBadgeState } + : { state: badge.state as AppSessionBadgeState, label: badge.label }; +} + +function normalizePresentation(presentation: AppSessionPresentation): AppSessionPresentation { + if (!isRecord(presentation)) { + throw new TypeError("presentation must be an object"); + } + const action = normalizePullRequestAction(presentation.action); + return { + badge: normalizeBadge(presentation.badge), + action, + }; +} + +function normalizePullRequestAction( + action: AppSessionPullRequestAction | null +): AppSessionPullRequestAction | null { + if (action === null) { + return null; + } + if (!isRecord(action)) { + throw new TypeError("presentation.action must be an object or null"); + } + if (action.kind !== "createPullRequest") { + throw new TypeError(`Unsupported app session action kind: ${String(action.kind)}`); + } + if (action.state !== "available" && action.state !== "inProgress") { + throw new TypeError(`Unsupported app session action state: ${String(action.state)}`); + } + if (typeof action.supportsDraft !== "boolean") { + throw new TypeError("presentation.action.supportsDraft must be a boolean"); + } + return { + kind: "createPullRequest", + state: action.state, + supportsDraft: action.supportsDraft, + }; +} + +function normalizePresentationUpdates( + updates: readonly AppSessionPresentationUpdate[] +): AppSessionPresentationUpdate[] { + if (!Array.isArray(updates)) { + throw new TypeError("updates must be an array"); + } + if (updates.length > MAX_PRESENTATION_UPDATES) { + throw new TypeError(`updates must contain at most ${MAX_PRESENTATION_UPDATES} items`); + } + + const targetIds = new Set(); + return updates.map((update, index) => { + const normalized = normalizePresentationUpdate(update, index); + const targetId = `${normalized.workspaceId}\0${normalized.sessionId}`; + if (targetIds.has(targetId)) { + throw new TypeError( + `updates contains duplicate target: ${normalized.workspaceId}/${normalized.sessionId}` + ); + } + targetIds.add(targetId); + return normalized; + }); +} + +function normalizePresentationUpdate( + update: AppSessionPresentationUpdate, + index: number +): AppSessionPresentationUpdate { + if (!isRecord(update)) { + throw new TypeError(`updates[${index}] must be an object`); + } + assertNonEmptyString(update.workspaceId, `updates[${index}].workspaceId`); + assertNonEmptyString(update.sessionId, `updates[${index}].sessionId`); + return { + workspaceId: update.workspaceId, + sessionId: update.sessionId, + presentation: normalizePresentation(update.presentation), + }; +} + +function normalizeBadgeUpdates(updates: readonly AppSessionBadgeUpdate[]): AppSessionBadgeUpdate[] { + if (!Array.isArray(updates)) { + throw new TypeError("updates must be an array"); + } + + const targetIds = new Set(); + return updates.map((update, index) => { + const normalized = normalizeBadgeUpdate(update, index); + const targetId = `${normalized.workspaceId}\0${normalized.sessionId}`; + if (targetIds.has(targetId)) { + throw new TypeError( + `updates contains duplicate target: ${normalized.workspaceId}/${normalized.sessionId}` + ); + } + targetIds.add(targetId); + return normalized; + }); +} + +function normalizeBadgeUpdate(update: AppSessionBadgeUpdate, index: number): AppSessionBadgeUpdate { + if (!isRecord(update)) { + throw new TypeError(`updates[${index}] must be an object`); + } + assertNonEmptyString(update.workspaceId, `updates[${index}].workspaceId`); + assertNonEmptyString(update.sessionId, `updates[${index}].sessionId`); + return { + workspaceId: update.workspaceId, + sessionId: update.sessionId, + badge: normalizeBadge(update.badge), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertNonEmptyString(value: unknown, name: string): asserts value is string { + if (typeof value !== "string" || value.length === 0) { + throw new TypeError(`${name} must be a non-empty string`); + } +} + +function readNonEmptyString(value: Record, field: string, index: number): string { + const fieldValue = value[field]; + assertNonEmptyString(fieldValue, `sessions[${index}].${field}`); + return fieldValue; +} diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index eb92cf0bed..7d03fab540 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -17,6 +17,7 @@ import { existsSync } from "node:fs"; import { isIPv6, Socket } from "node:net"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { + CancellationTokenSource, createMessageConnection, ErrorCodes, type Message, @@ -32,6 +33,9 @@ import { registerClientSessionApiHandlers, } from "./generated/rpc.js"; import type { + AppExtensionRegisterResult, + AppMediatedFetchRequest, + AppMediatedFetchResponse, ConnectClientInfo, GitHubTelemetryNotification, GitHubTokenAcquireRequest, @@ -93,6 +97,17 @@ import type { } from "./types.js"; import { defaultJoinSessionPermissionHandler } from "./types.js"; import type { FactoryHandle } from "./factory.js"; +import { AppSessionBadgesExtension } from "./appSessionBadges.js"; +import { + onExtensionTransportClosedSymbol, + registerPrivateAppCanvasSymbol, + registerPrivateAppExtensionSymbol, + registerPrivateAppForgeProviderSymbol, + registerPrivateAppSessionBadgesSymbol, + requestPrivateAppMediatedFetchSymbol, + unregisterPrivateAppCanvasSymbol, + unregisterPrivateAppForgeProviderSymbol, +} from "./appExtensionClientAccess.js"; /** * Minimum protocol version this SDK can communicate with. @@ -496,6 +511,7 @@ export class CopilotClient { string, { provider: GitHubTokenProvider; sessionId?: string; committed: boolean } >(); + private extensionTransportCloseHandlers = new Set<() => void>(); /** * Typed server-scoped RPC methods. @@ -1808,6 +1824,97 @@ export class CopilotClient { return this.resumeSessionInternal(sessionId, config, factories, extensionOptions); } + /** @internal */ + async registerAppSessionBadges(session: CopilotSession): Promise { + if (!this.connection) { + throw new Error("Client not connected"); + } + return AppSessionBadgesExtension.register(session, this.connection); + } + + /** @internal */ + async [registerPrivateAppSessionBadgesSymbol]( + session: CopilotSession + ): Promise { + return this.registerAppSessionBadges(session); + } + + /** @internal */ + async [registerPrivateAppExtensionSymbol](): Promise { + return this.internalRpc.extensions.appExtension.register({ protocolVersion: 1 }); + } + + /** @internal */ + async [registerPrivateAppCanvasSymbol](contributionId: string): Promise { + await this.internalRpc.extensions.appCanvas.register({ + protocolVersion: 1, + contributionId, + }); + } + + /** @internal */ + async [unregisterPrivateAppCanvasSymbol](contributionId: string): Promise { + await this.internalRpc.extensions.appCanvas.unregister({ + protocolVersion: 1, + contributionId, + }); + } + + /** @internal */ + async [registerPrivateAppForgeProviderSymbol]( + contributionId: string, + operations: [string, ...string[]] + ): Promise { + await this.internalRpc.extensions.appForge.register({ + protocolVersion: 1, + contributionId, + operations, + }); + } + + /** @internal */ + async [unregisterPrivateAppForgeProviderSymbol](contributionId: string): Promise { + await this.internalRpc.extensions.appForge.unregister({ + protocolVersion: 1, + contributionId, + }); + } + + /** @internal */ + async [requestPrivateAppMediatedFetchSymbol]( + params: AppMediatedFetchRequest, + signal: AbortSignal + ): Promise { + if (!this.connection) { + throw new Error("Client not connected"); + } + const cancellation = new CancellationTokenSource(); + const abort = () => cancellation.cancel(); + if (signal.aborted) { + abort(); + } else { + signal.addEventListener("abort", abort, { once: true }); + } + try { + return await this.connection.sendRequest( + "extensions.appForge.fetch", + params, + cancellation.token + ); + } finally { + signal.removeEventListener("abort", abort); + cancellation.dispose(); + } + } + + /** @internal */ + [onExtensionTransportClosedSymbol](handler: () => void): () => void { + this.extensionTransportCloseHandlers.add(handler); + return () => { + this.extensionTransportCloseHandlers.delete(handler); + }; + } + private async resumeSessionInternal( sessionId: string, config: ResumeSessionConfig, @@ -3073,15 +3180,25 @@ export class CopilotClient { } this.sessions.clear(); this.githubTokenProviders.clear(); + this.notifyExtensionTransportClosed(); }; this.connection.onClose(markDisconnected); this.connection.onError(() => { if (this.connection === connection) { this.state = "disconnected"; + this.notifyExtensionTransportClosed(); } }); } + private notifyExtensionTransportClosed(): void { + const handlers = [...this.extensionTransportCloseHandlers]; + this.extensionTransportCloseHandlers.clear(); + for (const handler of handlers) { + handler(); + } + } + private handleSessionEventNotification(notification: unknown): void { if ( typeof notification !== "object" || diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index ac0ccdb7ce..6f7ec300a8 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -2,14 +2,25 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import { CopilotClient } from "./client.js"; import type { CopilotSession } from "./session.js"; -import { - defaultJoinSessionPermissionHandler, - type PermissionHandler, - type ResumeSessionConfig, -} from "./types.js"; +import { type PermissionHandler, type ResumeSessionConfig } from "./types.js"; import type { FactoryHandle } from "./factory.js"; +import type { AppSessionBadgesExtension } from "./appSessionBadges.js"; +import { joinExtensionSession } from "./extensionSession.js"; + +export { + AppSessionBadgesExtension, + type AppSessionBadge, + type AppSessionBadgeState, + type AppSessionBadgeTarget, + type AppSessionBadgeTargetIdentity, + type AppSessionBadgeUpdate, + type AppSessionPresentation, + type AppSessionPresentationUpdate, + type AppSessionPullRequestAction, + type AppSessionBadgesSnapshot, + type AppSessionBadgesSnapshotHandler, +} from "./appSessionBadges.js"; export { Canvas, @@ -109,37 +120,31 @@ export { * ``` */ export async function joinSession(config: JoinSessionConfig = {}): Promise { - const sessionId = process.env.SESSION_ID; - if (!sessionId) { - throw new Error( - "joinSession() is intended for extensions running as child processes of the Copilot CLI." - ); - } - - const client = new CopilotClient({ _internalConnection: { kind: "parent-process" } }); - - // Strip `extensionSdkPath` at runtime even though `JoinSessionConfig` omits it - // at the type level — untyped (JS) callers can still slip it through, and - // honoring it here would be misleading since the extension subprocess has - // already been forked by the host with the SDK the host chose. - const { - extensionSdkPath: _stripped, - factories, - requestedEnvironmentVariables, - ...rest - } = config as JoinSessionConfig & { - extensionSdkPath?: string; - }; - void _stripped; + const { session } = await joinExtensionSession(config); + return session; +} - return client.resumeSessionForExtension( - sessionId, - { - ...rest, - onPermissionRequest: config.onPermissionRequest ?? defaultJoinSessionPermissionHandler, - suppressResumeEvent: config.suppressResumeEvent ?? true, - }, - factories, - requestedEnvironmentVariables?.length ? { requestedEnvironmentVariables } : undefined - ); +/** + * Joins the retained app session and explicitly registers a session-badge contribution. + * + * The app owns the hidden session lifecycle and supplies full replacement + * snapshots containing only sessions eligible for extension-provided badges. + */ +export async function joinAppSessionBadges( + config: JoinSessionConfig = {} +): Promise { + const { client, session } = await joinExtensionSession(config); + try { + return await client.registerAppSessionBadges(session); + } catch (error) { + try { + await session.disconnect(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Failed to register app session badges and disconnect the extension session" + ); + } + throw error; + } } diff --git a/nodejs/src/extensionSession.ts b/nodejs/src/extensionSession.ts new file mode 100644 index 0000000000..daad584514 --- /dev/null +++ b/nodejs/src/extensionSession.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { CopilotClient } from "./client.js"; +import type { FactoryHandle } from "./factory.js"; +import type { CopilotSession } from "./session.js"; +import { + defaultJoinSessionPermissionHandler, + type ExtensionJoinOptions, + type PermissionHandler, + type ResumeSessionConfig, +} from "./types.js"; + +export interface ExtensionSessionConfig extends Omit< + ResumeSessionConfig, + "onPermissionRequest" | "extensionSdkPath" +> { + onPermissionRequest?: PermissionHandler; + requestedEnvironmentVariables?: string[]; + factories?: FactoryHandle[]; +} + +/** @internal */ +export async function joinExtensionSession( + config: ExtensionSessionConfig +): Promise<{ client: CopilotClient; session: CopilotSession }> { + const sessionId = process.env.SESSION_ID; + if (!sessionId) { + throw new Error( + "Extension entry points are intended for child processes launched by the Copilot runtime." + ); + } + + const client = new CopilotClient({ _internalConnection: { kind: "parent-process" } }); + const { + extensionSdkPath: _stripped, + factories, + requestedEnvironmentVariables, + ...rest + } = config as ExtensionSessionConfig & { + extensionSdkPath?: string; + }; + void _stripped; + + const extensionOptions: ExtensionJoinOptions | undefined = requestedEnvironmentVariables?.length + ? { requestedEnvironmentVariables } + : undefined; + try { + const session = await client.resumeSessionForExtension( + sessionId, + { + ...rest, + onPermissionRequest: + config.onPermissionRequest ?? defaultJoinSessionPermissionHandler, + suppressResumeEvent: config.suppressResumeEvent ?? true, + }, + factories, + extensionOptions + ); + return { client, session }; + } catch (error) { + const cleanupErrors: unknown[] = []; + try { + cleanupErrors.push(...(await client.stop())); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + if (cleanupErrors.length > 0) { + throw new AggregateError( + [error, ...cleanupErrors], + "Failed to join and stop extension session" + ); + } + throw error; + } +} diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index f4978de1ff..d8c7cdfc10 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -3,7 +3,7 @@ * Generated from: api.schema.json */ -import type { MessageConnection } from "vscode-jsonrpc/node.js"; +import type { CancellationToken, MessageConnection } from "vscode-jsonrpc/node.js"; import type { AbortReason, AgentModelPolicy, Attachment, AutoTier, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerMetadata, McpServerSource, McpServerStatus, ModelChangeSource, PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, RemediationAction, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompleteData, TaskCompletionOutcome, UserToolSessionApproval, Verbosity } from "./session-events.js"; @@ -3978,6 +3978,15 @@ export type WorkspacesWorkspaceDetailsHostType = | "github" /** Workspace repository is hosted on Azure DevOps. */ | "ado"; +/** + * Capability contribution point declared by a trusted app-extension manifest. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppExtensionContributionPoint". + */ +/** @experimental */ +/** @internal */ +export type AppExtensionContributionPoint = "sessionBadges" | "canvases" | "forgeProvider"; /** * List of all authenticated users * @@ -3986,6 +3995,30 @@ export type WorkspacesWorkspaceDetailsHostType = */ /** @experimental */ export type AccountGetAllUsersResult = AccountAllUsers[]; +/** + * AppSessionActionResult or null when the extension declines the action. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ExtensionsAppSessionBadgesActionInvokeResult". + */ +/** @experimental */ +export type ExtensionsAppSessionBadgesActionInvokeResult = JsonValue; +/** + * Serializable action result. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ExtensionsAppCanvasActionInvokeResult". + */ +/** @experimental */ +export type ExtensionsAppCanvasActionInvokeResult = JsonValue; +/** + * Serializable forge-provider operation result. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ExtensionsAppForgeInvokeResult". + */ +/** @experimental */ +export type ExtensionsAppForgeInvokeResult = JsonValue; /** * The number of running background agents (task-registry agents) that were cancelled. * @@ -4018,6 +4051,30 @@ export type SessionGitHubAuthLogoutResult = boolean; */ /** @experimental */ export type SessionGitHubAuthLogoutUserResult = boolean; +/** + * AppSessionActionResult or null when the extension declines the action. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppSessionBadgesActionInvokeResult". + */ +/** @experimental */ +export type AppSessionBadgesActionInvokeResult = JsonValue; +/** + * Serializable action result. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppCanvasActionInvokeResult". + */ +/** @experimental */ +export type AppCanvasActionInvokeResult = JsonValue; +/** + * Serializable forge-provider operation result. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppForgeInvokeResult". + */ +/** @experimental */ +export type AppForgeInvokeResult = JsonValue; /** * Parameters for aborting the current turn @@ -23577,6 +23634,543 @@ export interface WorkspacesWriteAutopilotObjectiveResult { */ operation: string; } +/** + * Private app-extension activation handshake. Identity is derived from trusted runtime connection metadata and is never accepted from request parameters. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppExtensionRegisterRequest". + */ +/** @experimental */ +/** @internal */ +export interface AppExtensionRegisterRequest { + protocolVersion: 1; +} +/** + * Opaque runtime-authenticated identity for one allowlisted app-extension activation. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppExtensionPrincipal". + */ +/** @experimental */ +/** @internal */ +export interface AppExtensionPrincipal { + packageId: string; + activationId: string; +} +/** + * Capability grants bound to an authenticated app-extension principal. Keys are present only when granted. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppExtensionCapabilities". + */ +/** @experimental */ +/** @internal */ +export interface AppExtensionCapabilities { + sessionBadges?: true; + canvases?: true; + forgeProvider?: true; + mediatedFetch?: true; +} +/** + * Runtime-authenticated identity of one statically declared app-extension contribution. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppExtensionDeclaredContribution". + */ +/** @experimental */ +/** @internal */ +export interface AppExtensionDeclaredContribution { + contributionPoint: AppExtensionContributionPoint; + contributionId: string; +} +/** + * Authenticated principal and capability grants for one private app-extension activation. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppExtensionRegisterResult". + */ +/** @experimental */ +/** @internal */ +export interface AppExtensionRegisterResult { + protocolVersion: 1; + principal: AppExtensionPrincipal; + capabilities: AppExtensionCapabilities; + contributions: AppExtensionDeclaredContribution[]; +} +/** + * Registers or unregisters one runtime-authenticated app-extension contribution on its owning connection. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppExtensionContributionRegistrationRequest". + */ +/** @experimental */ +/** @internal */ +export interface AppExtensionContributionRegistrationRequest { + protocolVersion: 1; + contributionId: string; +} +/** + * Registers one runtime-authenticated forge-provider contribution and its supported operation names. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppForgeRegisterRequest". + */ +/** @experimental */ +/** @internal */ +export interface AppForgeRegisterRequest { + protocolVersion: 1; + contributionId: string; + /** + * @minItems 1 + * @maxItems 128 + */ + operations: [string, ...string[]]; +} +/** + * Stable runtime-authenticated app-extension contribution identity used by a trusted app host. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppExtensionContributionTarget". + */ +/** @internal */ +export interface AppExtensionContributionTarget { + packageId: string; + activationId: string; + contributionId: string; +} +/** + * Exact app-visible session target from the current eligible-session snapshot. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppSessionPresentationTarget". + */ +/** @experimental */ +export interface AppSessionPresentationTarget { + workspaceId: string; + sessionId: string; + repositoryPath: string; + worktreePath: string; + branch?: string; +} +/** + * Constrained pull-request identity presentation contributed for one eligible app session. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppSessionBadgePresentation". + */ +/** @experimental */ +export interface AppSessionBadgePresentation { + state: "draft" | "open" | "merged" | "closed"; + label?: string; +} +/** + * Constrained Create Pull Request action state contributed for one eligible app session. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppSessionPullRequestAction". + */ +/** @experimental */ +export interface AppSessionPullRequestAction { + kind: "createPullRequest"; + state: "available" | "inProgress"; + supportsDraft: boolean; +} +/** + * Atomic extension-provided badge and Create Pull Request action presentation. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppSessionPresentation". + */ +/** @experimental */ +export interface AppSessionPresentation { + badge: AppSessionBadgePresentation | null; + action: AppSessionPullRequestAction | null; +} +/** + * One ordered atomic presentation replacement for an eligible app session. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppSessionPresentationUpdate". + */ +/** @experimental */ +export interface AppSessionPresentationUpdate { + workspaceId: string; + sessionId: string; + presentation: AppSessionPresentation; +} +/** + * Publishes one atomic badge and action presentation for an eligible app session. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppSessionSetPresentationRequest". + */ +/** @experimental */ +/** @internal */ +export interface AppSessionSetPresentationRequest { + protocolVersion: 1; + workspaceId: string; + sessionId: string; + presentation: AppSessionPresentation; +} +/** + * Publishes an ordered batch of atomic badge and action presentations. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppSessionSetPresentationsRequest". + */ +/** @experimental */ +/** @internal */ +export interface AppSessionSetPresentationsRequest { + protocolVersion: 1; + /** + * @minItems 1 + * @maxItems 1024 + */ + updates: [AppSessionPresentationUpdate, ...AppSessionPresentationUpdate[]]; +} +/** + * Create Pull Request action selected by the app host. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppSessionPullRequestActionInvocation". + */ +/** @experimental */ +export interface AppSessionPullRequestActionInvocation { + kind: "createPullRequest"; + draft: boolean; +} +/** + * Create Pull Request callback routed to the owning app extension. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppSessionActionCallbackRequest". + */ +/** @experimental */ +export interface AppSessionActionCallbackRequest { + sessionId: string; + protocolVersion: 1; + contributionId: string; + target: AppSessionPresentationTarget; + action: AppSessionPullRequestActionInvocation; +} +/** + * Bounded extension-authored prompt and required session tool for a Create Pull Request action. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppSessionActionResult". + */ +export interface AppSessionActionResult { + prompt: string; + requiredTool: string; +} +/** + * Trusted app-host request to invoke a Create Pull Request action on its owning extension contribution. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppSessionActionHostRequest". + */ +/** @experimental */ +/** @internal */ +export interface AppSessionActionHostRequest { + appSessionId: string; + protocolVersion: 1; + packageId: string; + activationId: string; + contributionId: string; + target: AppSessionPresentationTarget; + action: AppSessionPullRequestActionInvocation; +} +/** + * Authenticated provider presentation update emitted on the retained hidden app session. A null presentation resets provider state. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppSessionPresentationChangedEventData". + */ +export interface AppSessionPresentationChangedEventData { + protocolVersion: 1; + extensionId: string; + packageId: string; + activationId: string; + contributionId: string; + workspaceId: string; + sessionId: string; + presentation: AppSessionPresentation | null; +} +/** + * Bounded generic action descriptor rendered by the trusted app host. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppCanvasActionDescriptor". + */ +/** @experimental */ +export interface AppCanvasActionDescriptor { + name: string; + label: string; + /** + * Serializable action input returned when the action is selected. + */ + input?: JsonValue; + variant?: "default" | "primary" | "danger"; + disabled?: boolean; +} +/** + * Trusted project context supplied by the app host to an app-scoped canvas. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppCanvasProjectContext". + */ +/** @experimental */ +export interface AppCanvasProjectContext { + forgeProviderId: string; + /** + * Opaque versioned repository locator interpreted only by the owning forge provider. + */ + repositoryLocator: JsonValue; + forgeAccountId?: string; +} +/** + * Optional trusted app context for one canvas instance. The hidden control-session identity is never exposed. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppCanvasContext". + */ +/** @experimental */ +export interface AppCanvasContext { + projectId?: string; + workspaceId?: string; + project?: AppCanvasProjectContext; +} +/** + * App-canvas open callback routed to the owning extension connection. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppCanvasOpenCallbackRequest". + */ +/** @experimental */ +export interface AppCanvasOpenCallbackRequest { + sessionId: string; + protocolVersion: 1; + contributionId: string; + instanceId: string; + /** + * Serializable canvas input. + */ + input?: JsonValue; + context?: AppCanvasContext; +} +/** + * App-canvas action callback routed to the owning extension connection. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppCanvasActionCallbackRequest". + */ +/** @experimental */ +export interface AppCanvasActionCallbackRequest { + sessionId: string; + protocolVersion: 1; + contributionId: string; + instanceId: string; + actionName: string; + /** + * Serializable action input. + */ + input?: JsonValue; + context?: AppCanvasContext; +} +/** + * App-canvas close callback routed to the owning extension connection. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppCanvasCloseCallbackRequest". + */ +/** @experimental */ +export interface AppCanvasCloseCallbackRequest { + sessionId: string; + protocolVersion: 1; + contributionId: string; + instanceId: string; + context?: AppCanvasContext; +} +/** + * Bounded app-canvas state and display metadata. Arbitrary navigation URLs are not supported. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppCanvasOpenResult". + */ +/** @experimental */ +export interface AppCanvasOpenResult { + /** + * Serializable initial canvas state. + */ + state?: JsonValue; + title?: string; + status?: string; + /** + * @maxItems 32 + */ + actions?: AppCanvasActionDescriptor[]; +} +/** + * Trusted app-host request to open an app-extension canvas. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppCanvasHostOpenRequest". + */ +/** @experimental */ +/** @internal */ +export interface AppCanvasHostOpenRequest { + appSessionId: string; + protocolVersion: 1; + packageId: string; + activationId: string; + contributionId: string; + instanceId: string; + /** + * Serializable canvas input. + */ + input?: JsonValue; + context?: AppCanvasContext; +} +/** + * Trusted app-host request to invoke an app-extension canvas action. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppCanvasHostActionRequest". + */ +/** @experimental */ +/** @internal */ +export interface AppCanvasHostActionRequest { + appSessionId: string; + protocolVersion: 1; + packageId: string; + activationId: string; + contributionId: string; + instanceId: string; + actionName: string; + /** + * Serializable action input. + */ + input?: JsonValue; + context?: AppCanvasContext; +} +/** + * Trusted app-host request to close an app-extension canvas. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppCanvasHostCloseRequest". + */ +/** @experimental */ +/** @internal */ +export interface AppCanvasHostCloseRequest { + appSessionId: string; + protocolVersion: 1; + packageId: string; + activationId: string; + contributionId: string; + instanceId: string; + context?: AppCanvasContext; +} +/** + * Forge-provider operation callback routed to the owning extension connection. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppForgeInvokeCallbackRequest". + */ +/** @experimental */ +export interface AppForgeInvokeCallbackRequest { + sessionId: string; + protocolVersion: 1; + contributionId: string; + operation: string; + accountId?: string; + /** + * Serializable operation input. + */ + input?: JsonValue; +} +/** + * Trusted app-host request to invoke an app-extension forge provider. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppForgeHostInvokeRequest". + */ +/** @experimental */ +/** @internal */ +export interface AppForgeHostInvokeRequest { + appSessionId: string; + protocolVersion: 1; + packageId: string; + activationId: string; + contributionId: string; + operation: string; + accountId?: string; + /** + * Serializable operation input. + */ + input?: JsonValue; +} +/** + * Constrained credential-free HTTP request interpreted by the trusted app host. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppMediatedFetchHttpRequest". + */ +/** @experimental */ +export interface AppMediatedFetchHttpRequest { + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + path: string; + headers?: { + [k: string]: string | undefined; + }; + body?: string; +} +/** + * Requests a capability-gated forge operation through the trusted app host. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppMediatedFetchRequest". + */ +/** @experimental */ +/** @internal */ +export interface AppMediatedFetchRequest { + protocolVersion: 1; + contributionId: string; + accountId: string; + operation: string; + request: AppMediatedFetchHttpRequest; +} +/** + * Validated mediated-fetch effect routed to the trusted app session host. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppMediatedFetchHostRequest". + */ +/** @experimental */ +export interface AppMediatedFetchHostRequest { + sessionId: string; + protocolVersion: 1; + packageId: string; + activationId: string; + contributionId: string; + accountId: string; + operation: string; + request: AppMediatedFetchHttpRequest; +} +/** + * Bounded sanitized HTTP response returned by the trusted app host. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AppMediatedFetchResponse". + */ +/** @experimental */ +export interface AppMediatedFetchResponse { + status: number; + headers: { + [k: string]: string | undefined; + }; + body?: string; + truncated: boolean; +} /** @experimental */ export interface SessionModelListRequest { @@ -24478,6 +25072,130 @@ export function createInternalServerRpc(connection: MessageConnection) { connect: async (params: ConnectRequest): Promise => connection.sendRequest("connect", params), /** @experimental */ + extensions: { + /** @experimental */ + appExtension: { + /** + * Authenticates an allowlisted app-extension connection and returns its opaque principal and capability grants. + * + * @param params Private app-extension activation handshake. Identity is derived from trusted runtime connection metadata and is never accepted from request parameters. + * + * @returns Authenticated principal and capability grants for one private app-extension activation. + */ + register: async (params: AppExtensionRegisterRequest): Promise => + connection.sendRequest("extensions.appExtension.register", params), + }, + /** @experimental */ + appSessionBadges: { + /** + * Publishes one atomic badge and Create Pull Request action presentation. + * + * @param params Publishes one atomic badge and action presentation for an eligible app session. + */ + setPresentation: async (params: AppSessionSetPresentationRequest): Promise => + connection.sendRequest("extensions.appSessionBadges.setPresentation", params), + /** + * Publishes an ordered atomic batch of badge and Create Pull Request action presentations. + * + * @param params Publishes an ordered batch of atomic badge and action presentations. + */ + setPresentations: async (params: AppSessionSetPresentationsRequest): Promise => + connection.sendRequest("extensions.appSessionBadges.setPresentations", params), + /** @experimental */ + action: { + /** + * Routes a trusted app-host Create Pull Request action to its owning extension contribution. + * + * @param params Trusted app-host request to invoke a Create Pull Request action on its owning extension contribution. + * + * @returns AppSessionActionResult or null when the extension declines the action. + */ + invoke: async (params: AppSessionActionHostRequest): Promise => + connection.sendRequest("extensions.appSessionBadges.action.invoke", params), + }, + }, + /** @experimental */ + appCanvas: { + /** + * Registers one trusted app-canvas contribution on its owning extension connection. + * + * @param params Registers or unregisters one runtime-authenticated app-extension contribution on its owning connection. + */ + register: async (params: AppExtensionContributionRegistrationRequest): Promise => + connection.sendRequest("extensions.appCanvas.register", params), + /** + * Unregisters one trusted app-canvas contribution from its owning extension connection. + * + * @param params Registers or unregisters one runtime-authenticated app-extension contribution on its owning connection. + */ + unregister: async (params: AppExtensionContributionRegistrationRequest): Promise => + connection.sendRequest("extensions.appCanvas.unregister", params), + /** + * Routes a trusted app-host canvas open request to its owning extension contribution. + * + * @param params Trusted app-host request to open an app-extension canvas. + * + * @returns Bounded app-canvas state and display metadata. Arbitrary navigation URLs are not supported. + */ + open: async (params: AppCanvasHostOpenRequest): Promise => + connection.sendRequest("extensions.appCanvas.open", params), + /** @experimental */ + action: { + /** + * Routes a trusted app-host canvas action to its owning extension contribution. + * + * @param params Trusted app-host request to invoke an app-extension canvas action. + * + * @returns Serializable action result. + */ + invoke: async (params: AppCanvasHostActionRequest): Promise => + connection.sendRequest("extensions.appCanvas.action.invoke", params), + }, + /** + * Routes a trusted app-host canvas close request to its owning extension contribution. + * + * @param params Trusted app-host request to close an app-extension canvas. + */ + close: async (params: AppCanvasHostCloseRequest): Promise => + connection.sendRequest("extensions.appCanvas.close", params), + }, + /** @experimental */ + appForge: { + /** + * Registers one trusted forge-provider contribution and its supported operations. + * + * @param params Registers one runtime-authenticated forge-provider contribution and its supported operation names. + */ + register: async (params: AppForgeRegisterRequest): Promise => + connection.sendRequest("extensions.appForge.register", params), + /** + * Unregisters one trusted forge-provider contribution from its owning extension connection. + * + * @param params Registers or unregisters one runtime-authenticated app-extension contribution on its owning connection. + */ + unregister: async (params: AppExtensionContributionRegistrationRequest): Promise => + connection.sendRequest("extensions.appForge.unregister", params), + /** + * Routes a trusted app-host operation to its owning forge-provider contribution. + * + * @param params Trusted app-host request to invoke an app-extension forge provider. + * + * @returns Serializable forge-provider operation result. + */ + invoke: async (params: AppForgeHostInvokeRequest): Promise => + connection.sendRequest("extensions.appForge.invoke", params), + /** + * Requests a bounded capability-gated HTTP operation through the trusted app host. + * + * @param params Requests a capability-gated forge operation through the trusted app host. + * + * @returns Bounded sanitized HTTP response returned by the trusted app host. + */ + fetch: async (params: AppMediatedFetchRequest): Promise => + connection.sendRequest("extensions.appForge.fetch", params), + }, + }, + /** @experimental */ sessions: { /** * Reads lightweight persisted metadata for one local session without opening it. @@ -27035,6 +27753,72 @@ export interface CanvasHandler { invoke(params: CanvasProviderInvokeActionRequest): Promise; } +/** Handler for `appSessionBadges` client session API methods. */ +/** @experimental */ +export interface AppSessionBadgesHandler { + /** + * Invokes a Create Pull Request action on the owning app-extension contribution. + * + * @param params Create Pull Request callback routed to the owning app extension. + * + * @returns AppSessionActionResult or null when the extension declines the action. + */ + invoke(params: AppSessionActionCallbackRequest, cancellation?: CancellationToken): Promise; +} + +/** Handler for `appCanvas` client session API methods. */ +/** @experimental */ +export interface AppCanvasHandler { + /** + * Opens an app-scoped canvas contribution on its owning extension connection. + * + * @param params App-canvas open callback routed to the owning extension connection. + * + * @returns Bounded app-canvas state and display metadata. Arbitrary navigation URLs are not supported. + */ + open(params: AppCanvasOpenCallbackRequest, cancellation?: CancellationToken): Promise; + /** + * Closes an app-scoped canvas contribution on its owning extension connection. + * + * @param params App-canvas close callback routed to the owning extension connection. + */ + close(params: AppCanvasCloseCallbackRequest, cancellation?: CancellationToken): Promise; + /** + * Invokes an action on an app-scoped canvas contribution. + * + * @param params App-canvas action callback routed to the owning extension connection. + * + * @returns Serializable action result. + */ + invoke(params: AppCanvasActionCallbackRequest, cancellation?: CancellationToken): Promise; +} + +/** Handler for `appForgeProvider` client session API methods. */ +/** @experimental */ +export interface AppForgeProviderHandler { + /** + * Invokes one registered forge-provider operation on its owning extension connection. + * + * @param params Forge-provider operation callback routed to the owning extension connection. + * + * @returns Serializable forge-provider operation result. + */ + invoke(params: AppForgeInvokeCallbackRequest, cancellation?: CancellationToken): Promise; +} + +/** Handler for `appForgeHost` client session API methods. */ +/** @experimental */ +export interface AppForgeHostHandler { + /** + * Delegates one validated mediated-fetch effect to the trusted app session host. + * + * @param params Validated mediated-fetch effect routed to the trusted app session host. + * + * @returns Bounded sanitized HTTP response returned by the trusted app host. + */ + fetch(params: AppMediatedFetchHostRequest): Promise; +} + /** All client session API handler groups. */ export interface ClientSessionApiHandlers { providerToken?: ProviderTokenHandler; @@ -27042,6 +27826,10 @@ export interface ClientSessionApiHandlers { tasks?: TasksHandler; sessionFs?: SessionFsHandler; canvas?: CanvasHandler; + appSessionBadges?: AppSessionBadgesHandler; + appCanvas?: AppCanvasHandler; + appForgeProvider?: AppForgeProviderHandler; + appForgeHost?: AppForgeHostHandler; } /** @@ -27154,6 +27942,36 @@ export function registerClientSessionApiHandlers( if (!handler) throw new Error(`No canvas handler registered for session: ${params.sessionId}`); return handler.invoke(params); }); + connection.onRequest("appSessionBadges.action.invoke", async (params: AppSessionActionCallbackRequest, cancellation: CancellationToken) => { + const handler = getHandlers(params.sessionId).appSessionBadges; + if (!handler) throw new Error(`No appSessionBadges handler registered for session: ${params.sessionId}`); + return handler.invoke(params, cancellation); + }); + connection.onRequest("appCanvas.open", async (params: AppCanvasOpenCallbackRequest, cancellation: CancellationToken) => { + const handler = getHandlers(params.sessionId).appCanvas; + if (!handler) throw new Error(`No appCanvas handler registered for session: ${params.sessionId}`); + return handler.open(params, cancellation); + }); + connection.onRequest("appCanvas.close", async (params: AppCanvasCloseCallbackRequest, cancellation: CancellationToken) => { + const handler = getHandlers(params.sessionId).appCanvas; + if (!handler) throw new Error(`No appCanvas handler registered for session: ${params.sessionId}`); + return handler.close(params, cancellation); + }); + connection.onRequest("appCanvas.action.invoke", async (params: AppCanvasActionCallbackRequest, cancellation: CancellationToken) => { + const handler = getHandlers(params.sessionId).appCanvas; + if (!handler) throw new Error(`No appCanvas handler registered for session: ${params.sessionId}`); + return handler.invoke(params, cancellation); + }); + connection.onRequest("appForge.invoke", async (params: AppForgeInvokeCallbackRequest, cancellation: CancellationToken) => { + const handler = getHandlers(params.sessionId).appForgeProvider; + if (!handler) throw new Error(`No appForgeProvider handler registered for session: ${params.sessionId}`); + return handler.invoke(params, cancellation); + }); + connection.onRequest("appForge.fetch", async (params: AppMediatedFetchHostRequest) => { + const handler = getHandlers(params.sessionId).appForgeHost; + if (!handler) throw new Error(`No appForgeHost handler registered for session: ${params.sessionId}`); + return handler.fetch(params); + }); } /** Handler for `extensionLaunchProvider` client global API methods. */ diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index b7fc7837a5..91830c4195 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -720,6 +720,7 @@ export class CopilotSession { sessionId: this.sessionId, prompt: options.prompt, displayPrompt: options.displayPrompt, + requiredTool: options.requiredTool, attachments: options.attachments, mode: options.mode, agentMode: options.agentMode, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 0f15749f71..c75bd7d44f 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -3377,6 +3377,13 @@ export interface MessageOptions { * If provided, this is shown in the timeline instead of `prompt`. */ displayPrompt?: string; + + /** + * Require this tool to be available for the turn. + * + * The request fails before execution when the named tool is unavailable. + */ + requiredTool?: string; } /** diff --git a/nodejs/test/appExtension.test.ts b/nodejs/test/appExtension.test.ts new file mode 100644 index 0000000000..7249f2eec6 --- /dev/null +++ b/nodejs/test/appExtension.test.ts @@ -0,0 +1,1251 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CancellationTokenSource } from "vscode-jsonrpc/node.js"; +import { + defineAppExtension, + type AppExtensionHost, + type AppSessionBadgesContribution, + type AppSessionBadgesHost, +} from "../src/appExtension.js"; +import type { + AppSessionBadgesExtension, + AppSessionBadgesSnapshot, +} from "../src/appSessionBadges.js"; +import { + onExtensionTransportClosedSymbol, + registerPrivateAppCanvasSymbol, + registerPrivateAppExtensionSymbol, + registerPrivateAppForgeProviderSymbol, + requestPrivateAppMediatedFetchSymbol, + unregisterPrivateAppCanvasSymbol, + unregisterPrivateAppForgeProviderSymbol, +} from "../src/appExtensionClientAccess.js"; +import { CopilotClient } from "../src/client.js"; +import type { CopilotSession } from "../src/session.js"; + +describe("defineAppExtension", () => { + const originalSessionId = process.env.SESSION_ID; + + afterEach(() => { + if (originalSessionId === undefined) { + delete process.env.SESSION_ID; + } else { + process.env.SESSION_ID = originalSessionId; + } + vi.restoreAllMocks(); + }); + + function arrange() { + process.env.SESSION_ID = "hidden-app-session"; + const session = { + disconnect: vi.fn().mockResolvedValue(undefined), + clientSessionApis: {}, + } as unknown as CopilotSession; + vi.spyOn(CopilotClient.prototype, "resumeSessionForExtension").mockResolvedValue(session); + vi.spyOn(CopilotClient.prototype, registerPrivateAppExtensionSymbol).mockResolvedValue({ + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:badges", + activationId: "activation-7", + }, + capabilities: { sessionBadges: true }, + contributions: [ + { + contributionPoint: "sessionBadges", + contributionId: "github-pr", + }, + ], + }); + vi.spyOn(CopilotClient.prototype, "stop").mockResolvedValue([]); + vi.spyOn(CopilotClient.prototype, registerPrivateAppCanvasSymbol).mockResolvedValue(); + vi.spyOn(CopilotClient.prototype, unregisterPrivateAppCanvasSymbol).mockResolvedValue(); + vi.spyOn( + CopilotClient.prototype, + registerPrivateAppForgeProviderSymbol + ).mockResolvedValue(); + vi.spyOn( + CopilotClient.prototype, + unregisterPrivateAppForgeProviderSymbol + ).mockResolvedValue(); + vi.spyOn(CopilotClient.prototype, requestPrivateAppMediatedFetchSymbol).mockResolvedValue({ + status: 200, + headers: {}, + truncated: false, + }); + let closeTransport: (() => void) | undefined; + vi.spyOn(CopilotClient.prototype, onExtensionTransportClosedSymbol).mockImplementation( + (handler) => { + closeTransport = handler; + return () => { + closeTransport = undefined; + }; + } + ); + return { session, closeTransport: () => closeTransport?.() }; + } + + it("authenticates before activation and exposes only capability-limited host fields", async () => { + arrange(); + let hostKeys: string[] = []; + const activation = await defineAppExtension((host) => { + hostKeys = Object.keys(host).sort(); + expect(host.principal).toEqual({ + packageId: "bundled:github-app:badges", + activationId: "activation-7", + }); + expect(host.capabilities).toEqual({ sessionBadges: true }); + expect(host.signal.aborted).toBe(false); + expect(host).not.toHaveProperty("session"); + expect(host).not.toHaveProperty("client"); + expect(host).not.toHaveProperty("rpc"); + expect(host).not.toHaveProperty("credentials"); + expect(host).not.toHaveProperty("fetch"); + }); + + expect(hostKeys).toEqual([ + "canvases", + "capabilities", + "contributions", + "forgeProviders", + "mediatedFetch", + "principal", + "sessionBadges", + "signal", + ]); + expect(Object.keys(activation).sort()).toEqual(["dispose", "principal", "signal"]); + expect(activation).not.toHaveProperty("client"); + expect(activation).not.toHaveProperty("session"); + expect(activation).not.toHaveProperty("sessionBadges"); + expect(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).toHaveBeenCalledOnce(); + await activation.dispose(); + }); + + it("registers app canvases, strips routing identity, and unregisters on disposal", async () => { + const { session } = arrange(); + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValueOnce( + { + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:canvas", + activationId: "activation-canvas", + }, + capabilities: { canvases: true }, + contributions: [ + { + contributionPoint: "canvases", + contributionId: "repository-overview", + }, + ], + } + ); + const onOpen = vi.fn().mockReturnValue({ + state: { selected: 1 }, + title: "Repository", + status: "Ready", + actions: [ + { + name: "create", + label: "Create pull request", + input: { draft: false }, + variant: "primary", + }, + ], + }); + const onAction = vi.fn().mockReturnValue({ + state: { selected: 2 }, + status: "Creating", + actions: [ + { + name: "create", + label: "Creating pull request", + disabled: true, + }, + ], + }); + const onClose = vi.fn(); + let host: AppExtensionHost | undefined; + const activation = await defineAppExtension(async (value) => { + host = value; + await value.canvases.register({ + contributionId: value.contributions[0]!.contributionId, + onOpen, + onAction, + onClose, + }); + }); + + expect(CopilotClient.prototype[registerPrivateAppCanvasSymbol]).toHaveBeenCalledWith( + "repository-overview" + ); + const context = { + projectId: "project-1", + workspaceId: "workspace-1", + project: { + forgeProviderId: "github", + repositoryLocator: { owner: "github", repo: "copilot-sdk" }, + forgeAccountId: "account-1", + }, + }; + await expect( + session.clientSessionApis.appCanvas!.open({ + sessionId: "hidden-app-session", + protocolVersion: 1, + contributionId: "repository-overview", + instanceId: "canvas-1", + input: { tab: "pulls" }, + context, + }) + ).resolves.toEqual({ + state: { selected: 1 }, + title: "Repository", + status: "Ready", + actions: [ + { + name: "create", + label: "Create pull request", + input: { draft: false }, + variant: "primary", + }, + ], + }); + expect(onOpen).toHaveBeenCalledWith({ + instanceId: "canvas-1", + input: { tab: "pulls" }, + context, + signal: expect.any(AbortSignal), + }); + expect(onOpen.mock.calls[0]![0]).not.toHaveProperty("sessionId"); + + await expect( + session.clientSessionApis.appCanvas!.invoke({ + sessionId: "hidden-app-session", + protocolVersion: 1, + contributionId: "repository-overview", + instanceId: "canvas-1", + actionName: "select", + input: 2, + context, + }) + ).resolves.toEqual({ + state: { selected: 2 }, + status: "Creating", + actions: [ + { + name: "create", + label: "Creating pull request", + disabled: true, + }, + ], + }); + await session.clientSessionApis.appCanvas!.close({ + sessionId: "hidden-app-session", + protocolVersion: 1, + contributionId: "repository-overview", + instanceId: "canvas-1", + context, + }); + expect(onAction.mock.calls[0]![0]).not.toHaveProperty("sessionId"); + expect(onClose.mock.calls[0]![0]).not.toHaveProperty("sessionId"); + expect(host).not.toHaveProperty("session"); + expect(host).not.toHaveProperty("client"); + + await activation.dispose(); + expect(CopilotClient.prototype[unregisterPrivateAppCanvasSymbol]).toHaveBeenCalledWith( + "repository-overview" + ); + expect(session.clientSessionApis.appCanvas).toBeUndefined(); + }); + + it("registers forge operations and mediates bounded credential-free fetch", async () => { + const { session } = arrange(); + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValueOnce( + { + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:forge", + activationId: "activation-forge", + }, + capabilities: { forgeProvider: true, mediatedFetch: true }, + contributions: [ + { + contributionPoint: "forgeProvider", + contributionId: "github", + }, + ], + } + ); + const getPullRequest = vi.fn().mockReturnValue({ number: 2574 }); + const listPullRequests = vi.fn().mockReturnValue([]); + const fetch = vi + .mocked(CopilotClient.prototype[requestPrivateAppMediatedFetchSymbol]) + .mockResolvedValue({ + status: 200, + headers: { "content-type": "application/json" }, + body: '{"number":2574}', + truncated: false, + }); + + let host: AppExtensionHost | undefined; + const activation = await defineAppExtension(async (value) => { + host = value; + const contributionId = value.contributions[0]!.contributionId; + await value.forgeProviders.register({ + contributionId, + operations: { getPullRequest, listPullRequests }, + }); + }); + const contributionId = host!.contributions[0]!.contributionId; + + expect(CopilotClient.prototype[registerPrivateAppForgeProviderSymbol]).toHaveBeenCalledWith( + "github", + ["getPullRequest", "listPullRequests"] + ); + await expect( + session.clientSessionApis.appForgeProvider!.invoke({ + sessionId: "hidden-app-session", + protocolVersion: 1, + contributionId: "github", + operation: "getPullRequest", + accountId: "account-1", + input: { number: 2574 }, + }) + ).resolves.toEqual({ number: 2574 }); + expect(getPullRequest).toHaveBeenCalledWith({ + operation: "getPullRequest", + accountId: "account-1", + input: { number: 2574 }, + signal: expect.any(AbortSignal), + }); + expect(getPullRequest.mock.calls[0]![0]).not.toHaveProperty("sessionId"); + + await expect( + host!.mediatedFetch.request({ + contributionId, + accountId: "account-1", + operation: "getPullRequest", + method: "GET", + path: "/repos/github/copilot-sdk/pulls/2574", + headers: { Accept: "application/json" }, + }) + ).resolves.toEqual({ + status: 200, + headers: { "content-type": "application/json" }, + body: '{"number":2574}', + truncated: false, + }); + expect(fetch).toHaveBeenCalledWith( + { + protocolVersion: 1, + contributionId: "github", + accountId: "account-1", + operation: "getPullRequest", + request: { + method: "GET", + path: "/repos/github/copilot-sdk/pulls/2574", + headers: { Accept: "application/json" }, + }, + }, + expect.any(AbortSignal) + ); + await expect( + host!.mediatedFetch.request({ + contributionId, + accountId: "account-1", + operation: "getPullRequest", + method: "GET", + path: "https://api.github.com/repos/github/copilot-sdk", + }) + ).rejects.toThrow("root-relative URL path"); + await expect( + host!.mediatedFetch.request({ + contributionId, + accountId: "account-1", + operation: "getPullRequest", + method: "GET", + path: "/repos/github/copilot-sdk", + headers: { Authorization: "secret" }, + }) + ).rejects.toThrow("header Authorization is not permitted"); + + await activation.dispose(); + expect( + CopilotClient.prototype[unregisterPrivateAppForgeProviderSymbol] + ).toHaveBeenCalledWith("github"); + expect(session.clientSessionApis.appForgeProvider).toBeUndefined(); + }); + + it("rejects malformed and oversized generic canvas action descriptors", async () => { + const { session } = arrange(); + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValueOnce( + { + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:canvas", + activationId: "activation-canvas", + }, + capabilities: { canvases: true }, + contributions: [ + { + contributionPoint: "canvases", + contributionId: "repository-overview", + }, + ], + } + ); + const onOpen = vi + .fn() + .mockReturnValueOnce({ + actions: [ + { name: "create", label: "Create" }, + { name: "create", label: "Duplicate" }, + ], + }) + .mockReturnValueOnce({ + actions: [{ name: "create", label: "x".repeat(513) }], + }) + .mockReturnValue({ + actions: [{ name: "create", label: "Create", variant: "primary" }], + }); + const activation = await defineAppExtension(async (host) => { + await host.canvases.register({ + contributionId: host.contributions[0]!.contributionId, + onOpen, + onAction: () => null, + }); + }); + const params = { + sessionId: "hidden-app-session", + protocolVersion: 1 as const, + contributionId: "repository-overview", + instanceId: "canvas-1", + }; + + await expect(session.clientSessionApis.appCanvas!.open(params)).rejects.toThrow( + "duplicate name" + ); + await expect(session.clientSessionApis.appCanvas!.open(params)).rejects.toThrow( + "at most 512 characters" + ); + await expect(session.clientSessionApis.appCanvas!.open(params)).resolves.toEqual({ + actions: [{ name: "create", label: "Create", variant: "primary" }], + }); + + await activation.dispose(); + }); + + it("requires a live forge registration and aborts in-flight provider callbacks", async () => { + const { session } = arrange(); + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValueOnce( + { + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:forge", + activationId: "activation-forge", + }, + capabilities: { forgeProvider: true, mediatedFetch: true }, + contributions: [ + { + contributionPoint: "forgeProvider", + contributionId: "github", + }, + ], + } + ); + let host: AppExtensionHost | undefined; + let callbackSignal: AbortSignal | undefined; + let finishCallback: ((value: object) => void) | undefined; + const activation = await defineAppExtension(async (value) => { + host = value; + await value.forgeProviders.register({ + contributionId: value.contributions[0]!.contributionId, + operations: { + pending: ({ signal }) => + new Promise((resolve) => { + callbackSignal = signal; + finishCallback = resolve; + }), + }, + }); + }); + const contributionId = host!.contributions[0]!.contributionId; + const invocation = session.clientSessionApis.appForgeProvider!.invoke({ + sessionId: "hidden-app-session", + protocolVersion: 1, + contributionId: "github", + operation: "pending", + }); + await vi.waitFor(() => expect(callbackSignal).toBeDefined()); + + await activation.dispose(); + + expect(callbackSignal!.aborted).toBe(true); + await expect( + host!.mediatedFetch.request({ + contributionId, + accountId: "account-1", + operation: "pending", + method: "GET", + path: "/user", + }) + ).rejects.toThrow("must be registered before mediated fetch"); + finishCallback!({}); + await expect(invocation).resolves.toEqual({}); + }); + + it("propagates peer cancellation to app canvas callbacks", async () => { + const { session } = arrange(); + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValueOnce( + { + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:canvas", + activationId: "activation-canvas", + }, + capabilities: { canvases: true }, + contributions: [ + { + contributionPoint: "canvases", + contributionId: "repository-overview", + }, + ], + } + ); + let callbackSignal: AbortSignal | undefined; + let finishCallback: (() => void) | undefined; + const activation = await defineAppExtension(async (host) => { + await host.canvases.register({ + contributionId: host.contributions[0]!.contributionId, + onOpen: ({ signal }) => + new Promise((resolve) => { + callbackSignal = signal; + finishCallback = () => resolve({}); + }), + onAction: () => null, + }); + }); + const cancellation = new CancellationTokenSource(); + const invocation = session.clientSessionApis.appCanvas!.open( + { + sessionId: "hidden-app-session", + protocolVersion: 1, + contributionId: "repository-overview", + instanceId: "canvas-1", + }, + cancellation.token + ); + await vi.waitFor(() => expect(callbackSignal).toBeDefined()); + + cancellation.cancel(); + + expect(callbackSignal!.aborted).toBe(true); + finishCallback!(); + await expect(invocation).resolves.toEqual({}); + cancellation.dispose(); + await activation.dispose(); + }); + + it("unregisters a canvas whose registration completes after activation disposal", async () => { + arrange(); + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValueOnce( + { + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:canvas", + activationId: "activation-canvas", + }, + capabilities: { canvases: true }, + contributions: [ + { + contributionPoint: "canvases", + contributionId: "repository-overview", + }, + ], + } + ); + let finishRegistration: (() => void) | undefined; + vi.mocked(CopilotClient.prototype[registerPrivateAppCanvasSymbol]).mockImplementation( + () => + new Promise((resolve) => { + finishRegistration = resolve; + }) + ); + let registration: Promise | undefined; + const activation = await defineAppExtension((host) => { + registration = host.canvases.register({ + contributionId: host.contributions[0]!.contributionId, + onOpen: () => ({}), + onAction: () => null, + }); + void registration.catch(() => {}); + }); + await vi.waitFor(() => expect(finishRegistration).toBeDefined()); + + const disposal = activation.dispose(); + finishRegistration!(); + + await expect(registration).rejects.toThrow("disposed during registration"); + await disposal; + expect(CopilotClient.prototype[unregisterPrivateAppCanvasSymbol]).toHaveBeenCalledWith( + "repository-overview" + ); + }); + + it("retains remote registration state so failed unregister can be retried", async () => { + arrange(); + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValueOnce( + { + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:canvas", + activationId: "activation-canvas", + }, + capabilities: { canvases: true }, + contributions: [ + { + contributionPoint: "canvases", + contributionId: "repository-overview", + }, + ], + } + ); + vi.mocked(CopilotClient.prototype[unregisterPrivateAppCanvasSymbol]) + .mockRejectedValueOnce(new Error("temporary unregister failure")) + .mockResolvedValueOnce(undefined); + let registration: Awaited> | undefined; + const activation = await defineAppExtension(async (host) => { + registration = await host.canvases.register({ + contributionId: host.contributions[0]!.contributionId, + onOpen: () => ({}), + onAction: () => null, + }); + }); + + await expect(registration!.dispose()).rejects.toThrow("temporary unregister failure"); + await expect(registration!.dispose()).resolves.toBeUndefined(); + expect(CopilotClient.prototype[unregisterPrivateAppCanvasSymbol]).toHaveBeenCalledTimes(2); + await activation.dispose(); + }); + + it("registers one principal-owned badge contribution through the compatibility transport", async () => { + arrange(); + const unsubscribe = vi.fn(); + const delegate = { + snapshot: undefined, + onSnapshot: vi.fn((handler: (snapshot: object) => void) => { + handler({ protocolVersion: 1, revision: 1, sessions: [] }); + return unsubscribe; + }), + setBadge: vi.fn().mockResolvedValue(undefined), + setBadges: vi.fn().mockResolvedValue(undefined), + setPresentation: vi.fn().mockResolvedValue(undefined), + setPresentations: vi.fn().mockResolvedValue(undefined), + clearBadge: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + } as unknown as AppSessionBadgesExtension; + vi.spyOn(CopilotClient.prototype, "registerAppSessionBadges").mockResolvedValue(delegate); + let contribution: AppSessionBadgesContribution | undefined; + const snapshotHandler = vi.fn(); + + const activation = await defineAppExtension(async (host) => { + const registered = await host.sessionBadges.register({ + onSnapshot: (_snapshot, identity) => { + expect(contribution).toBe(registered); + expect(identity.principal).toBe(host.principal); + expect(identity.contributionPoint).toBe("sessionBadges"); + expect(identity.contributionId).toBe("github-pr"); + snapshotHandler(); + }, + }); + contribution = registered; + await registered.setBadges([ + { + workspaceId: "workspace-1", + sessionId: "session-1", + badge: null, + }, + ]); + await registered.setPresentation( + { workspaceId: "workspace-1", sessionId: "session-1" }, + { + badge: null, + action: { + kind: "createPullRequest", + state: "available", + supportsDraft: true, + }, + } + ); + contribution = registered; + }); + + expect(CopilotClient.prototype.registerAppSessionBadges).toHaveBeenCalledOnce(); + expect(delegate.setBadges).toHaveBeenCalledWith([ + { + workspaceId: "workspace-1", + sessionId: "session-1", + badge: null, + }, + ]); + expect(delegate.setPresentation).toHaveBeenCalledWith( + { workspaceId: "workspace-1", sessionId: "session-1" }, + { + badge: null, + action: { + kind: "createPullRequest", + state: "available", + supportsDraft: true, + }, + } + ); + expect(contribution).not.toHaveProperty("session"); + expect(contribution).not.toHaveProperty("connection"); + await vi.waitFor(() => expect(snapshotHandler).toHaveBeenCalledOnce()); + await activation.dispose(); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(delegate.dispose).toHaveBeenCalledOnce(); + }); + + it("routes bounded Create PR actions without exposing session authority", async () => { + const { session } = arrange(); + const delegate = { + snapshot: undefined, + onSnapshot: vi.fn(() => vi.fn()), + dispose: vi.fn(), + } as unknown as AppSessionBadgesExtension; + vi.spyOn(CopilotClient.prototype, "registerAppSessionBadges").mockResolvedValue(delegate); + const onAction = vi.fn().mockReturnValue({ + prompt: "# Pull Request Creation\nCreate a fake Azure DevOps pull request.", + requiredTool: "create_ado_pull_request", + }); + const activation = await defineAppExtension(async (host) => { + await host.sessionBadges.register({ onAction }); + }); + + await expect( + session.clientSessionApis.appSessionBadges!.invoke({ + sessionId: "hidden-app-session", + protocolVersion: 1, + contributionId: "github-pr", + target: { + workspaceId: "workspace-1", + sessionId: "product-session-1", + repositoryPath: "C:\\src\\repo", + worktreePath: "C:\\src\\worktree", + branch: "feature", + }, + action: { kind: "createPullRequest", draft: true }, + }) + ).resolves.toEqual({ + prompt: "# Pull Request Creation\nCreate a fake Azure DevOps pull request.", + requiredTool: "create_ado_pull_request", + }); + expect(onAction).toHaveBeenCalledWith({ + target: { + workspaceId: "workspace-1", + sessionId: "product-session-1", + repositoryPath: "C:\\src\\repo", + worktreePath: "C:\\src\\worktree", + branch: "feature", + }, + kind: "createPullRequest", + draft: true, + signal: expect.any(AbortSignal), + }); + expect(onAction.mock.calls[0]![0]).not.toHaveProperty("appSessionId"); + + await activation.dispose(); + expect(session.clientSessionApis.appSessionBadges).toBeUndefined(); + }); + + it("rejects malformed action results and continues serving later callbacks", async () => { + const { session } = arrange(); + const delegate = { + snapshot: undefined, + onSnapshot: vi.fn(() => vi.fn()), + dispose: vi.fn(), + } as unknown as AppSessionBadgesExtension; + vi.spyOn(CopilotClient.prototype, "registerAppSessionBadges").mockResolvedValue(delegate); + const onAction = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("sync failure"); + }) + .mockRejectedValueOnce(new Error("async failure")) + .mockReturnValueOnce({ + prompt: `# Pull Request Creation\n${"x".repeat(32 * 1024)}`, + requiredTool: "create_ado_pull_request", + }) + .mockReturnValueOnce({ + prompt: "# Pull Request Creation\nCreate it.", + requiredTool: "not a valid tool", + }) + .mockReturnValue({ + prompt: "# Pull Request Creation\nCreate it.", + requiredTool: "create_ado_pull_request", + }); + const activation = await defineAppExtension(async (host) => { + await host.sessionBadges.register({ onAction }); + }); + const invoke = () => + session.clientSessionApis.appSessionBadges!.invoke({ + sessionId: "hidden-app-session", + protocolVersion: 1, + contributionId: "github-pr", + target: { + workspaceId: "workspace-1", + sessionId: "product-session-1", + repositoryPath: "C:\\src\\repo", + worktreePath: "C:\\src\\worktree", + }, + action: { kind: "createPullRequest", draft: false }, + }); + + await expect(invoke()).rejects.toThrow("sync failure"); + await expect(invoke()).rejects.toThrow("async failure"); + await expect(invoke()).rejects.toThrow("must not exceed 32768 bytes"); + await expect(invoke()).rejects.toThrow("not a valid tool identifier"); + await expect(invoke()).resolves.toEqual({ + prompt: "# Pull Request Creation\nCreate it.", + requiredTool: "create_ado_pull_request", + }); + + await activation.dispose(); + }); + + it("cancels badge actions on peer cancellation and activation disposal", async () => { + const { session } = arrange(); + const delegate = { + snapshot: undefined, + onSnapshot: vi.fn(() => vi.fn()), + dispose: vi.fn(), + } as unknown as AppSessionBadgesExtension; + vi.spyOn(CopilotClient.prototype, "registerAppSessionBadges").mockResolvedValue(delegate); + const signals: AbortSignal[] = []; + const resolvers: Array<() => void> = []; + const activation = await defineAppExtension(async (host) => { + await host.sessionBadges.register({ + onAction: ({ signal }) => + new Promise((resolve) => { + signals.push(signal); + resolvers.push(() => + resolve({ + prompt: "# Pull Request Creation\nCreate it.", + requiredTool: "create_ado_pull_request", + }) + ); + }), + }); + }); + const params = { + sessionId: "hidden-app-session", + protocolVersion: 1 as const, + contributionId: "github-pr", + target: { + workspaceId: "workspace-1", + sessionId: "product-session-1", + repositoryPath: "C:\\src\\repo", + worktreePath: "C:\\src\\worktree", + }, + action: { kind: "createPullRequest" as const, draft: false }, + }; + const cancellation = new CancellationTokenSource(); + const first = session.clientSessionApis.appSessionBadges!.invoke( + params, + cancellation.token + ); + await vi.waitFor(() => expect(signals).toHaveLength(1)); + cancellation.cancel(); + expect(signals[0]!.aborted).toBe(true); + resolvers[0]!(); + await expect(first).resolves.toBeDefined(); + cancellation.dispose(); + + const second = session.clientSessionApis.appSessionBadges!.invoke(params); + await vi.waitFor(() => expect(signals).toHaveLength(2)); + const disposal = activation.dispose(); + expect(signals[1]!.aborted).toBe(true); + resolvers[1]!(); + await expect(second).resolves.toBeDefined(); + await disposal; + expect(session.clientSessionApis.appSessionBadges).toBeUndefined(); + }); + + it("aborts cancellation and runs cleanup once on explicit disposal", async () => { + const { session } = arrange(); + let signal: AbortSignal | undefined; + const disposer = vi.fn(() => { + expect(signal?.aborted).toBe(true); + }); + const activation = await defineAppExtension((host) => { + signal = host.signal; + return disposer; + }); + + await Promise.all([activation.dispose(), activation.dispose()]); + + expect(signal?.aborted).toBe(true); + expect(disposer).toHaveBeenCalledOnce(); + expect(session.disconnect).toHaveBeenCalledOnce(); + expect(CopilotClient.prototype.stop).toHaveBeenCalledOnce(); + }); + + it("supports object disposers", async () => { + arrange(); + const disposer = { dispose: vi.fn().mockResolvedValue(undefined) }; + const activation = await defineAppExtension(() => disposer); + + await activation.dispose(); + + expect(disposer.dispose).toHaveBeenCalledOnce(); + }); + + it("cancels and disposes activation when the runtime transport closes", async () => { + const arranged = arrange(); + const disposer = vi.fn(); + const activation = await defineAppExtension(() => disposer); + + arranged.closeTransport(); + await vi.waitFor(() => { + expect(activation.signal.aborted).toBe(true); + expect(disposer).toHaveBeenCalledOnce(); + }); + expect(arranged.session.disconnect).not.toHaveBeenCalled(); + }); + + it("disposes late activation cleanup when transport closes during activation", async () => { + const arranged = arrange(); + let finishActivation: ((disposer: () => void) => void) | undefined; + const disposer = vi.fn(); + const activation = defineAppExtension( + () => + new Promise<() => void>((resolve) => { + finishActivation = resolve; + }) + ); + await vi.waitFor(() => { + expect(finishActivation).toBeDefined(); + }); + + arranged.closeTransport(); + finishActivation!(disposer); + + await expect(activation).rejects.toThrow("transport closed during activation"); + expect(disposer).toHaveBeenCalledOnce(); + expect(arranged.session.disconnect).not.toHaveBeenCalled(); + }); + + it("rejects unavailable, duplicate, and malformed badge registrations before transport use", async () => { + arrange(); + const registerDelegate = vi + .spyOn(CopilotClient.prototype, "registerAppSessionBadges") + .mockResolvedValue({ + dispose: vi.fn(), + } as unknown as AppSessionBadgesExtension); + let badgesHost: AppSessionBadgesHost | undefined; + const activation = await defineAppExtension((host) => { + badgesHost = host.sessionBadges; + }); + + await expect(badgesHost!.register({ onSnapshot: "invalid" } as never)).rejects.toThrow( + "onSnapshot must be a function" + ); + await expect(badgesHost!.register({ onAction: "invalid" } as never)).rejects.toThrow( + "onAction must be a function" + ); + expect(registerDelegate).not.toHaveBeenCalled(); + + const contribution = await badgesHost!.register(); + await expect(badgesHost!.register()).rejects.toThrow("already registered sessionBadges"); + expect(registerDelegate).toHaveBeenCalledOnce(); + contribution.dispose(); + await activation.dispose(); + await expect(badgesHost!.register()).rejects.toThrow( + "app extension activation is disposed" + ); + + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValue({ + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:no-badges", + activationId: "activation-8", + }, + capabilities: {}, + contributions: [], + }); + await defineAppExtension(async (host) => { + await expect(host.sessionBadges.register()).rejects.toThrow( + "not granted sessionBadges" + ); + }).then((result) => result.dispose()); + }); + + it("rejects concurrent badge registration and disposes a late delegate after cancellation", async () => { + const arranged = arrange(); + let resolveDelegate: ((delegate: AppSessionBadgesExtension) => void) | undefined; + const delegate = { + dispose: vi.fn(), + } as unknown as AppSessionBadgesExtension; + vi.spyOn(CopilotClient.prototype, "registerAppSessionBadges").mockImplementation( + () => + new Promise((resolve) => { + resolveDelegate = resolve; + }) + ); + let badgesHost: AppSessionBadgesHost | undefined; + const activation = await defineAppExtension((host) => { + badgesHost = host.sessionBadges; + }); + + const firstRegistration = badgesHost!.register(); + await vi.waitFor(() => expect(resolveDelegate).toBeDefined()); + await expect(badgesHost!.register()).rejects.toThrow("already registering sessionBadges"); + + arranged.closeTransport(); + resolveDelegate!(delegate); + + await expect(firstRegistration).rejects.toThrow("disposed during registration"); + expect(delegate.dispose).toHaveBeenCalledOnce(); + expect(activation.signal.aborted).toBe(true); + }); + + it("observes async snapshot callback failures without disposing the contribution", async () => { + arrange(); + let notify: ((snapshot: AppSessionBadgesSnapshot) => void) | undefined; + const delegate = { + snapshot: undefined, + onSnapshot: vi.fn((handler: (snapshot: AppSessionBadgesSnapshot) => void) => { + notify = handler; + return vi.fn(); + }), + dispose: vi.fn(), + } as unknown as AppSessionBadgesExtension; + vi.spyOn(CopilotClient.prototype, "registerAppSessionBadges").mockResolvedValue(delegate); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const callback = vi + .fn() + .mockRejectedValueOnce(new Error("snapshot failed")) + .mockResolvedValue(undefined); + let contribution: AppSessionBadgesContribution | undefined; + const activation = await defineAppExtension(async (host) => { + contribution = await host.sessionBadges.register({ onSnapshot: callback }); + }); + await vi.waitFor(() => expect(notify).toBeDefined()); + + notify!({ protocolVersion: 1, revision: 1, sessions: [] }); + notify!({ protocolVersion: 1, revision: 2, sessions: [] }); + + await vi.waitFor(() => { + expect(callback).toHaveBeenCalledTimes(2); + expect(consoleError).toHaveBeenCalledWith( + "App session badge snapshot handler failed", + expect.any(Error) + ); + }); + expect(contribution).toBeDefined(); + expect(delegate.dispose).not.toHaveBeenCalled(); + await activation.dispose(); + }); + + it("rejects malformed runtime identity and capability grants before activation", async () => { + const { session } = arrange(); + const definition = vi.fn(); + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValueOnce( + { + protocolVersion: 1, + principal: { + packageId: "", + activationId: "activation-7", + }, + capabilities: { sessionBadges: true }, + contributions: [ + { + contributionPoint: "sessionBadges", + contributionId: "github-pr", + }, + ], + } + ); + + await expect(defineAppExtension(definition)).rejects.toThrow( + "principal.packageId must be a non-empty string" + ); + expect(definition).not.toHaveBeenCalled(); + expect(session.disconnect).toHaveBeenCalledOnce(); + + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValueOnce( + { + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:badges", + activationId: "activation-8", + }, + capabilities: { sessionBadges: false }, + contributions: [ + { + contributionPoint: "sessionBadges", + contributionId: "github-pr", + }, + ], + } as never + ); + await expect(defineAppExtension(definition)).rejects.toThrow( + "capabilities.sessionBadges must be true when present" + ); + expect(definition).not.toHaveBeenCalled(); + }); + + it("cleans up registrations when activation fails", async () => { + arrange(); + const delegate = { + dispose: vi.fn(), + } as unknown as AppSessionBadgesExtension; + vi.spyOn(CopilotClient.prototype, "registerAppSessionBadges").mockResolvedValue(delegate); + + await expect( + defineAppExtension(async (host) => { + await host.sessionBadges.register(); + throw new Error("activation failed"); + }) + ).rejects.toThrow("activation failed"); + + expect(delegate.dispose).toHaveBeenCalledOnce(); + expect(CopilotClient.prototype.stop).toHaveBeenCalledOnce(); + }); + + it("requires one trusted session badge declaration and rejects duplicate identities", async () => { + arrange(); + const registerDelegate = vi.spyOn(CopilotClient.prototype, "registerAppSessionBadges"); + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValueOnce( + { + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:badges", + activationId: "activation-zero", + }, + capabilities: { sessionBadges: true }, + contributions: [], + } + ); + await defineAppExtension(async (host) => { + await expect(host.sessionBadges.register()).rejects.toThrow( + "exactly one sessionBadges contribution; received 0" + ); + }).then((activation) => activation.dispose()); + expect(registerDelegate).not.toHaveBeenCalled(); + + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValueOnce( + { + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:badges", + activationId: "activation-multiple", + }, + capabilities: { sessionBadges: true }, + contributions: [ + { contributionPoint: "sessionBadges", contributionId: "github-pr" }, + { contributionPoint: "sessionBadges", contributionId: "checks" }, + ], + } + ); + await defineAppExtension(async (host) => { + await expect(host.sessionBadges.register()).rejects.toThrow( + "exactly one sessionBadges contribution; received 2" + ); + }).then((activation) => activation.dispose()); + expect(registerDelegate).not.toHaveBeenCalled(); + + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValueOnce( + { + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:badges", + activationId: "activation-duplicate", + }, + capabilities: { sessionBadges: true }, + contributions: [ + { contributionPoint: "sessionBadges", contributionId: "github-pr" }, + { contributionPoint: "sessionBadges", contributionId: "github-pr" }, + ], + } + ); + await expect(defineAppExtension(() => undefined)).rejects.toThrow( + "contributions contains duplicate identity sessionBadges/github-pr" + ); + }); + + it("rejects mediatedFetch as a contribution identity", async () => { + arrange(); + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockResolvedValueOnce( + { + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:provider", + activationId: "activation-fetch", + }, + capabilities: { forgeProvider: true, mediatedFetch: true }, + contributions: [ + { + contributionPoint: "mediatedFetch" as never, + contributionId: "fetch", + }, + ], + } + ); + + await expect(defineAppExtension(() => undefined)).rejects.toThrow( + "contributions[0].contributionPoint is not supported" + ); + }); + + it("surfaces client stop errors from explicit disposal", async () => { + arrange(); + vi.mocked(CopilotClient.prototype.stop).mockResolvedValue([ + new Error("runtime shutdown failed"), + ]); + const activation = await defineAppExtension(() => undefined); + + await expect(activation.dispose()).rejects.toThrow("Failed to dispose app extension"); + }); + + it("rejects unauthenticated activation and disconnects without invoking user code", async () => { + const { session } = arrange(); + vi.mocked(CopilotClient.prototype[registerPrivateAppExtensionSymbol]).mockRejectedValue( + new Error("not allowlisted") + ); + const definition = vi.fn(); + + await expect(defineAppExtension(definition)).rejects.toThrow("not allowlisted"); + + expect(definition).not.toHaveBeenCalled(); + expect(session.disconnect).toHaveBeenCalledOnce(); + expect(CopilotClient.prototype.stop).toHaveBeenCalledOnce(); + }); + + it("ships only through the explicit private package export", () => { + const packageJson = JSON.parse( + readFileSync(resolve(import.meta.dirname, "..", "package.json"), "utf8") + ) as { exports: Record }; + + expect(packageJson.exports["./private/app-extension"]).toBeDefined(); + expect(packageJson.exports["."]).not.toHaveProperty("defineAppExtension"); + expect(packageJson.exports["./extension"]).not.toHaveProperty("defineAppExtension"); + }); + + it("generates the exact private presentation and action RPC names", () => { + const generated = readFileSync( + resolve(import.meta.dirname, "..", "src", "generated", "rpc.ts"), + "utf8" + ); + + for (const method of [ + "extensions.appSessionBadges.setPresentation", + "extensions.appSessionBadges.setPresentations", + "extensions.appSessionBadges.action.invoke", + "appSessionBadges.action.invoke", + ]) { + expect(generated).toContain(`"${method}"`); + } + expect(generated).toContain("export interface AppSessionPresentationChangedEventData"); + }); +}); diff --git a/nodejs/test/appSessionBadges.test.ts b/nodejs/test/appSessionBadges.test.ts new file mode 100644 index 0000000000..c9f91dcc77 --- /dev/null +++ b/nodejs/test/appSessionBadges.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MessageConnection } from "vscode-jsonrpc/node.js"; +import { AppSessionBadgesExtension } from "../src/appSessionBadges.js"; +import type { CopilotSession } from "../src/session.js"; + +interface RecordedRequest { + method: string; + params: unknown; +} + +function createConnection() { + const requests: RecordedRequest[] = []; + const notifications = new Map void>(); + const disposed = vi.fn(); + const connection = { + onNotification(method: string, handler: (payload: unknown) => void) { + notifications.set(method, handler); + return { dispose: disposed }; + }, + async sendRequest(method: string, params?: unknown) { + requests.push({ method, params }); + if (method === "extensions.appSessionBadges.register") { + notifications.get("appSessionBadges.snapshot")?.({ + protocolVersion: 1, + revision: 7, + sessions: [ + { + workspaceId: "workspace-1", + sessionId: "session-1", + repositoryPath: "C:\\src\\repo", + worktreePath: "C:\\src\\worktree", + branch: "feature", + }, + ], + }); + } + return null; + }, + } as unknown as MessageConnection; + + return { connection, requests, notifications, disposed }; +} + +describe("AppSessionBadgesExtension", () => { + it("registers after installing the snapshot listener and retains the immediate snapshot", async () => { + const { connection, requests } = createConnection(); + + const contribution = await AppSessionBadgesExtension.register( + {} as CopilotSession, + connection + ); + + expect(requests).toEqual([ + { + method: "extensions.appSessionBadges.register", + params: undefined, + }, + ]); + expect(contribution.snapshot).toEqual({ + protocolVersion: 1, + revision: 7, + sessions: [ + { + workspaceId: "workspace-1", + sessionId: "session-1", + repositoryPath: "C:\\src\\repo", + worktreePath: "C:\\src\\worktree", + branch: "feature", + }, + ], + }); + }); + + it("replays the latest snapshot and delivers later full replacements", async () => { + const { connection, notifications } = createConnection(); + const contribution = await AppSessionBadgesExtension.register( + {} as CopilotSession, + connection + ); + const handler = vi.fn(); + + const unsubscribe = contribution.onSnapshot(handler); + notifications.get("appSessionBadges.snapshot")?.({ + protocolVersion: 1, + revision: 8, + sessions: [], + }); + unsubscribe(); + notifications.get("appSessionBadges.snapshot")?.({ + protocolVersion: 1, + revision: 9, + sessions: [], + }); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler.mock.calls[0]![0].revision).toBe(7); + expect(handler.mock.calls[1]![0]).toEqual({ + protocolVersion: 1, + revision: 8, + sessions: [], + }); + expect(contribution.snapshot?.revision).toBe(9); + }); + + it("continues snapshot delivery when one handler throws", async () => { + const { connection, notifications } = createConnection(); + const contribution = await AppSessionBadgesExtension.register( + {} as CopilotSession, + connection + ); + const error = new Error("handler failed"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const secondHandler = vi.fn(); + let shouldThrow = false; + contribution.onSnapshot(() => { + if (shouldThrow) { + throw error; + } + }); + contribution.onSnapshot(secondHandler); + secondHandler.mockClear(); + shouldThrow = true; + + notifications.get("appSessionBadges.snapshot")?.({ + protocolVersion: 1, + revision: 8, + sessions: [], + }); + + expect(secondHandler).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith( + "App session badges snapshot handler failed", + error + ); + consoleError.mockRestore(); + }); + + it("publishes and clears exact constrained v1 badge payloads", async () => { + const { connection, requests } = createConnection(); + const contribution = await AppSessionBadgesExtension.register( + {} as CopilotSession, + connection + ); + const target = { workspaceId: "workspace-1", sessionId: "session-1" }; + + await contribution.setBadge(target, { state: "open", label: "PR available" }); + await contribution.clearBadge(target); + + expect(requests.slice(1)).toEqual([ + { + method: "extensions.appSessionBadges.setBadge", + params: { + protocolVersion: 1, + workspaceId: "workspace-1", + sessionId: "session-1", + badge: { state: "open", label: "PR available" }, + }, + }, + { + method: "extensions.appSessionBadges.setBadge", + params: { + protocolVersion: 1, + workspaceId: "workspace-1", + sessionId: "session-1", + badge: null, + }, + }, + ]); + }); + + it("publishes one ordered atomic batch with null clears", async () => { + const { connection, requests } = createConnection(); + const contribution = await AppSessionBadgesExtension.register( + {} as CopilotSession, + connection + ); + + await contribution.setBadges([ + { + workspaceId: "workspace-2", + sessionId: "session-2", + badge: { state: "draft", label: "Draft" }, + }, + { + workspaceId: "workspace-1", + sessionId: "session-1", + badge: null, + }, + ]); + + expect(requests.slice(1)).toEqual([ + { + method: "extensions.appSessionBadges.setBadges", + params: { + protocolVersion: 1, + updates: [ + { + workspaceId: "workspace-2", + sessionId: "session-2", + badge: { state: "draft", label: "Draft" }, + }, + { + workspaceId: "workspace-1", + sessionId: "session-1", + badge: null, + }, + ], + }, + }, + ]); + }); + + it("publishes ordered atomic presentations while legacy badges omit action changes", async () => { + const { connection, requests } = createConnection(); + const contribution = await AppSessionBadgesExtension.register( + {} as CopilotSession, + connection + ); + + await contribution.setPresentations([ + { + workspaceId: "workspace-2", + sessionId: "session-2", + presentation: { + badge: { state: "draft", label: "Draft" }, + action: { + kind: "createPullRequest", + state: "inProgress", + supportsDraft: true, + }, + }, + }, + { + workspaceId: "workspace-1", + sessionId: "session-1", + presentation: { + badge: null, + action: { + kind: "createPullRequest", + state: "available", + supportsDraft: false, + }, + }, + }, + ]); + await contribution.setBadge( + { workspaceId: "workspace-1", sessionId: "session-1" }, + { state: "open" } + ); + + expect(requests.slice(1)).toEqual([ + { + method: "extensions.appSessionBadges.setPresentations", + params: { + protocolVersion: 1, + updates: [ + { + workspaceId: "workspace-2", + sessionId: "session-2", + presentation: { + badge: { state: "draft", label: "Draft" }, + action: { + kind: "createPullRequest", + state: "inProgress", + supportsDraft: true, + }, + }, + }, + { + workspaceId: "workspace-1", + sessionId: "session-1", + presentation: { + badge: null, + action: { + kind: "createPullRequest", + state: "available", + supportsDraft: false, + }, + }, + }, + ], + }, + }, + { + method: "extensions.appSessionBadges.setBadge", + params: { + protocolVersion: 1, + workspaceId: "workspace-1", + sessionId: "session-1", + badge: { state: "open" }, + }, + }, + ]); + }); + + it("validates complete presentations before transport", async () => { + const { connection, requests } = createConnection(); + const contribution = await AppSessionBadgesExtension.register( + {} as CopilotSession, + connection + ); + + await expect( + contribution.setPresentation({ workspaceId: "workspace-1", sessionId: "session-1" }, { + badge: null, + action: { + kind: "createPullRequest", + state: "queued", + supportsDraft: true, + }, + } as never) + ).rejects.toThrow("Unsupported app session action state"); + await expect( + contribution.setPresentations([ + { + workspaceId: "workspace-1", + sessionId: "session-1", + presentation: { badge: null, action: null }, + }, + { + workspaceId: "workspace-1", + sessionId: "session-1", + presentation: { badge: null, action: null }, + }, + ]) + ).rejects.toThrow("updates contains duplicate target"); + await contribution.setPresentations([]); + + expect(requests).toHaveLength(1); + }); + + it("validates an entire batch before sending and rejects duplicate targets", async () => { + const { connection, requests } = createConnection(); + const contribution = await AppSessionBadgesExtension.register( + {} as CopilotSession, + connection + ); + + await expect( + contribution.setBadges([ + { + workspaceId: "workspace-1", + sessionId: "session-1", + badge: { state: "open" }, + }, + { + workspaceId: "workspace-2", + sessionId: "session-2", + badge: { state: "queued" }, + }, + ] as never) + ).rejects.toThrow("Unsupported app session badge state"); + await expect( + contribution.setBadges([ + { + workspaceId: "workspace-1", + sessionId: "session-1", + badge: { state: "open" }, + }, + { + workspaceId: "workspace-1", + sessionId: "session-1", + badge: null, + }, + ]) + ).rejects.toThrow("updates contains duplicate target: workspace-1/session-1"); + await contribution.setBadges([]); + + expect(requests).toHaveLength(1); + }); + + it("rejects unsupported badge states", async () => { + const { connection, notifications } = createConnection(); + const contribution = await AppSessionBadgesExtension.register( + {} as CopilotSession, + connection + ); + + await expect( + contribution.setBadge({ workspaceId: "workspace-1", sessionId: "session-1" }, { + state: "queued", + } as never) + ).rejects.toThrow("Unsupported app session badge state"); + expect(notifications.has("appSessionBadges.snapshot")).toBe(true); + }); + + it("reports malformed snapshots and continues delivering valid snapshots", async () => { + const { connection, notifications } = createConnection(); + const contribution = await AppSessionBadgesExtension.register( + {} as CopilotSession, + connection + ); + const handler = vi.fn(); + contribution.onSnapshot(handler); + handler.mockClear(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + notifications.get("appSessionBadges.snapshot")?.({ + protocolVersion: 1, + revision: 8, + sessions: [ + { + workspace_id: "workspace-1", + session_id: "session-1", + repository_path: "C:\\src\\repo", + worktree_path: "C:\\src\\worktree", + }, + ], + }); + notifications.get("appSessionBadges.snapshot")?.({ + protocolVersion: 1, + revision: 9, + sessions: [], + }); + + expect(consoleError).toHaveBeenCalledOnce(); + expect(consoleError.mock.calls[0]?.[0]).toBe("Invalid app session badges snapshot ignored"); + expect(consoleError.mock.calls[0]?.[1]).toBeInstanceOf(TypeError); + expect(handler).toHaveBeenCalledOnce(); + expect(handler.mock.calls[0]?.[0].revision).toBe(9); + expect(contribution.snapshot?.revision).toBe(9); + consoleError.mockRestore(); + }); + + it("disposes its notification registration", async () => { + const { connection, disposed } = createConnection(); + const contribution = await AppSessionBadgesExtension.register( + {} as CopilotSession, + connection + ); + + contribution.dispose(); + + expect(disposed).toHaveBeenCalledOnce(); + }); +}); diff --git a/nodejs/test/e2e/app_extension_transport.e2e.test.ts b/nodejs/test/e2e/app_extension_transport.e2e.test.ts new file mode 100644 index 0000000000..63f4f996a5 --- /dev/null +++ b/nodejs/test/e2e/app_extension_transport.e2e.test.ts @@ -0,0 +1,396 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect, it } from "vitest"; +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc/node.js"; +import type { AppSessionBadgesSnapshot } from "../../src/appSessionBadges.js"; +import { getSdkProtocolVersion } from "../../src/sdkProtocolVersion.js"; +import { retry } from "./harness/sdkTestHelper.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = join(__dirname, "fixtures", "app-extension.mjs"); +const DIST_DIR = resolve(__dirname, "..", "..", "dist"); + +function largeSnapshot(): AppSessionBadgesSnapshot { + return { + protocolVersion: 1, + revision: 8, + sessions: Array.from({ length: 40 }, (_, index) => ({ + workspaceId: `workspace-${index}`, + sessionId: `visible-session-${index}`, + repositoryPath: `C:\\src\\repository-${index}-${"r".repeat(80)}`, + worktreePath: `C:\\src\\worktree-${index}-${"w".repeat(80)}`, + branch: `feature/${index}/${"b".repeat(40)}`, + })), + }; +} + +it("transports private app extension badges, canvases, forge operations, and mediated fetch", async () => { + const privateModule = join(DIST_DIR, "appExtension.js"); + if (!existsSync(privateModule)) { + throw new Error(`Built SDK not found at ${DIST_DIR}. Run \`npm run build\` first.`); + } + + const dir = mkdtempSync(join(tmpdir(), "copilot-app-extension-")); + const readyFile = join(dir, "ready"); + const snapshotFile = join(dir, "snapshot"); + const errorFile = join(dir, "error"); + const batchSentFile = join(dir, "batch-sent"); + const badgeUpdates = [ + { + workspaceId: "workspace-0", + sessionId: "visible-session-0", + badge: { state: "open", label: "Open" }, + }, + { + workspaceId: "workspace-1", + sessionId: "visible-session-1", + badge: null, + }, + ]; + const presentationUpdates = [ + { + workspaceId: "workspace-0", + sessionId: "visible-session-0", + presentation: { + badge: { state: "open", label: "Open" }, + action: { + kind: "createPullRequest", + state: "available", + supportsDraft: true, + }, + }, + }, + ]; + const child = spawn(process.execPath, [FIXTURE], { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + SESSION_ID: "hidden-app-session", + APP_EXTENSION_SDK_MODULE: pathToFileURL(privateModule).href, + APP_EXTENSION_READY_FILE: readyFile, + APP_EXTENSION_SNAPSHOT_FILE: snapshotFile, + APP_EXTENSION_ERROR_FILE: errorFile, + APP_EXTENSION_BATCH_SENT_FILE: batchSentFile, + APP_EXTENSION_BADGE_UPDATES: JSON.stringify(badgeUpdates), + APP_EXTENSION_PRESENTATION_UPDATES: JSON.stringify(presentationUpdates), + }, + }); + const stderr: string[] = []; + child.stderr!.on("data", (chunk) => stderr.push(String(chunk))); + + const connection = createMessageConnection( + new StreamMessageReader(child.stdout!), + new StreamMessageWriter(child.stdin!) + ); + const requests: Array<{ method: string; params: unknown }> = []; + connection.onRequest("connect", () => ({ protocolVersion: getSdkProtocolVersion() })); + connection.onRequest("session.resume", (params: Record) => ({ + sessionId: params.sessionId, + })); + connection.onRequest("extensions.appExtension.register", (params: unknown) => { + requests.push({ method: "extensions.appExtension.register", params }); + return { + protocolVersion: 1, + principal: { + packageId: "bundled:github-app:badges", + activationId: "activation-stdio", + }, + capabilities: { + sessionBadges: true, + canvases: true, + forgeProvider: true, + mediatedFetch: true, + }, + contributions: [ + { + contributionPoint: "sessionBadges", + contributionId: "github-pr", + }, + { + contributionPoint: "canvases", + contributionId: "repository-overview", + }, + { + contributionPoint: "forgeProvider", + contributionId: "github", + }, + ], + }; + }); + connection.onRequest("extensions.appSessionBadges.register", () => { + requests.push({ method: "extensions.appSessionBadges.register", params: undefined }); + return null; + }); + connection.onRequest("extensions.appSessionBadges.setBadges", (params: unknown) => { + requests.push({ method: "extensions.appSessionBadges.setBadges", params }); + return null; + }); + connection.onRequest("extensions.appSessionBadges.setPresentations", (params: unknown) => { + requests.push({ method: "extensions.appSessionBadges.setPresentations", params }); + return null; + }); + connection.onRequest("extensions.appCanvas.register", (params: unknown) => { + requests.push({ method: "extensions.appCanvas.register", params }); + return null; + }); + connection.onRequest("extensions.appForge.register", (params: unknown) => { + requests.push({ method: "extensions.appForge.register", params }); + return null; + }); + connection.onRequest("extensions.appForge.fetch", (params: unknown) => { + requests.push({ method: "extensions.appForge.fetch", params }); + return { + status: 200, + headers: { "content-type": "application/json" }, + body: '{"number":2574}', + truncated: false, + }; + }); + connection.onRequest(() => ({})); + connection.onNotification(() => {}); + connection.listen(); + + const snapshot = largeSnapshot(); + try { + await retry( + "wait for private app extension registration", + async () => { + expect( + existsSync(readyFile), + `extension did not become ready; error: ${ + existsSync(errorFile) ? readFileSync(errorFile, "utf8") : "" + }; stderr: ${stderr.join("")}` + ).toBe(true); + expect(requests[0]).toEqual({ + method: "extensions.appExtension.register", + params: { protocolVersion: 1 }, + }); + expect(requests[1]?.method).toBe("extensions.appSessionBadges.register"); + expect(requests[1]?.params ?? null).toBeNull(); + expect(requests[2]).toEqual({ + method: "extensions.appCanvas.register", + params: { protocolVersion: 1, contributionId: "repository-overview" }, + }); + expect(requests[3]).toEqual({ + method: "extensions.appForge.register", + params: { + protocolVersion: 1, + contributionId: "github", + operations: ["getPullRequest"], + }, + }); + }, + 100, + 50 + ); + + await connection.sendNotification("appSessionBadges.snapshot", { + protocolVersion: 1, + revision: 7, + sessions: [ + { + workspace_id: "incorrect", + session_id: "incorrect", + repository_path: "C:\\src\\repository", + worktree_path: "C:\\src\\worktree", + }, + ], + }); + await connection.sendNotification("appSessionBadges.snapshot", snapshot); + + await expect( + connection.sendRequest("appCanvas.open", { + sessionId: "hidden-app-session", + protocolVersion: 2, + contributionId: "repository-overview", + instanceId: "canvas-invalid", + }) + ).rejects.toThrow(); + const context = { + projectId: "project-1", + workspaceId: "workspace-0", + project: { + forgeProviderId: "github", + repositoryLocator: { owner: "github", repo: "copilot-sdk" }, + forgeAccountId: "account-1", + }, + }; + await expect( + connection.sendRequest("appCanvas.open", { + sessionId: "hidden-app-session", + protocolVersion: 1, + contributionId: "repository-overview", + instanceId: "canvas-1", + input: { tab: "pulls" }, + context, + }) + ).resolves.toEqual({ + state: { + instanceId: "canvas-1", + input: { tab: "pulls" }, + context, + }, + title: "Repository overview", + status: "Ready", + actions: [ + { + name: "create", + label: "Create pull request", + input: { draft: false }, + variant: "primary", + }, + ], + }); + await expect( + connection.sendRequest("appCanvas.action.invoke", { + sessionId: "hidden-app-session", + protocolVersion: 1, + contributionId: "repository-overview", + instanceId: "canvas-1", + actionName: "select", + input: { number: 2574 }, + context, + }) + ).resolves.toEqual({ + actionName: "select", + input: { number: 2574 }, + }); + await expect( + connection.sendRequest("appSessionBadges.action.invoke", { + sessionId: "hidden-app-session", + protocolVersion: 1, + contributionId: "github-pr", + target: snapshot.sessions[0], + action: { kind: "createPullRequest", draft: true }, + }) + ).resolves.toEqual({ + prompt: `# Pull Request Creation\nCreate a fake draft pull request for ${snapshot.sessions[0]!.branch}.`, + requiredTool: "create_ado_pull_request", + }); + await expect( + connection.sendRequest("appForge.invoke", { + sessionId: "hidden-app-session", + protocolVersion: 1, + contributionId: "github", + operation: "getPullRequest", + accountId: "account-1", + input: { number: 2574 }, + }) + ).resolves.toEqual({ + status: 200, + body: '{"number":2574}', + truncated: false, + }); + await expect( + connection.sendRequest("appCanvas.close", { + sessionId: "hidden-app-session", + protocolVersion: 1, + contributionId: "repository-overview", + instanceId: "canvas-1", + context, + }) + ).resolves.toBeNull(); + + await retry( + "wait for post-notification badge batch", + async () => { + expect(existsSync(batchSentFile), `stderr: ${stderr.join("")}`).toBe(true); + expect(requests).toContainEqual({ + method: "extensions.appSessionBadges.setBadges", + params: { protocolVersion: 1, updates: badgeUpdates }, + }); + expect(requests).toContainEqual({ + method: "extensions.appSessionBadges.setPresentations", + params: { protocolVersion: 1, updates: presentationUpdates }, + }); + expect(requests).toContainEqual({ + method: "extensions.appForge.fetch", + params: { + protocolVersion: 1, + contributionId: "github", + accountId: "account-1", + operation: "getPullRequest", + request: { + method: "GET", + path: "/repos/github/copilot-sdk/pulls/2574", + headers: { Accept: "application/json" }, + }, + }, + }); + }, + 100, + 50 + ); + + const activation = JSON.parse(readFileSync(readyFile, "utf8")) as { + principal: object; + hostKeys: string[]; + contributions: { + badges: string[]; + canvas: string[]; + forge: string[]; + }; + }; + expect(activation).toEqual({ + principal: { + packageId: "bundled:github-app:badges", + activationId: "activation-stdio", + }, + hostKeys: [ + "canvases", + "capabilities", + "contributions", + "forgeProviders", + "mediatedFetch", + "principal", + "sessionBadges", + "signal", + ], + contributions: { + badges: ["identity", "onAction"], + canvas: ["identity"], + forge: ["identity", "operations"], + }, + }); + const delivered = readFileSync(snapshotFile, "utf8") + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { + snapshot: AppSessionBadgesSnapshot; + identity: { contributionPoint: string; contributionId: string }; + } + ); + expect(delivered).toHaveLength(1); + expect(delivered[0]!.snapshot).toEqual(snapshot); + expect(delivered[0]!.identity).toMatchObject({ + contributionPoint: "sessionBadges", + contributionId: "github-pr", + }); + expect(stderr.join("")).toContain("Invalid app session badges snapshot ignored"); + } finally { + connection.dispose(); + child.kill(); + await new Promise((resolveExit) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolveExit(); + return; + } + child.once("exit", () => resolveExit()); + }); + await rm(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); + } +}); diff --git a/nodejs/test/e2e/app_session_badges_transport.e2e.test.ts b/nodejs/test/e2e/app_session_badges_transport.e2e.test.ts new file mode 100644 index 0000000000..f29c2162b4 --- /dev/null +++ b/nodejs/test/e2e/app_session_badges_transport.e2e.test.ts @@ -0,0 +1,208 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect, it } from "vitest"; +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc/node.js"; +import type { AppSessionBadgesSnapshot } from "../../src/appSessionBadges.js"; +import { getSdkProtocolVersion } from "../../src/sdkProtocolVersion.js"; +import { retry } from "./harness/sdkTestHelper.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = join(__dirname, "fixtures", "app-session-badges-extension.mjs"); +const DIST_DIR = resolve(__dirname, "..", "..", "dist"); + +function encodeNotification(snapshot: AppSessionBadgesSnapshot): Buffer { + const body = Buffer.from( + JSON.stringify({ + jsonrpc: "2.0", + method: "appSessionBadges.snapshot", + params: snapshot, + }) + ); + return Buffer.concat([Buffer.from(`Content-Length: ${body.byteLength}\r\n\r\n`), body]); +} + +function readSnapshots(path: string): AppSessionBadgesSnapshot[] { + if (!existsSync(path)) { + return []; + } + return readFileSync(path, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as AppSessionBadgesSnapshot); +} + +function largeSnapshot(revision: number): AppSessionBadgesSnapshot { + return { + protocolVersion: 1, + revision, + sessions: Array.from({ length: 40 }, (_, index) => ({ + workspaceId: `workspace-${revision}-${index}`, + sessionId: `visible-session-${revision}-${index}`, + repositoryPath: `C:\\src\\repository-${index}-${"r".repeat(80)}`, + worktreePath: `C:\\src\\worktree-${index}-${"w".repeat(80)}`, + branch: `feature/${revision}/${index}/${"b".repeat(40)}`, + })), + }; +} + +function snakeCaseSnapshot(revision: number): object { + return { + protocolVersion: 1, + revision, + sessions: [ + { + workspace_id: "workspace-incorrect", + session_id: "visible-session-incorrect", + repository_path: "C:\\src\\repo", + worktree_path: "C:\\src\\worktree", + }, + ], + }; +} + +it("delivers sequential, coalesced, and fragmented large snapshots over extension stdio", async () => { + if (!existsSync(join(DIST_DIR, "extension.js"))) { + throw new Error(`Built SDK not found at ${DIST_DIR}. Run \`npm run build\` first.`); + } + + const dir = mkdtempSync(join(tmpdir(), "copilot-app-session-badges-")); + const readyFile = join(dir, "ready"); + const snapshotFile = join(dir, "snapshot"); + const errorFile = join(dir, "error"); + const invalidBatchFile = join(dir, "invalid-batch"); + const badgeUpdates = [ + { + workspaceId: "workspace-2", + sessionId: "visible-session-2", + badge: { state: "open", label: "PR open" }, + }, + { + workspaceId: "workspace-1", + sessionId: "visible-session-1", + badge: null, + }, + ]; + const child = spawn(process.execPath, [FIXTURE], { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + SESSION_ID: "hidden-app-session", + EXTENSION_SDK_MODULE: pathToFileURL(join(DIST_DIR, "extension.js")).href, + EXTENSION_READY_FILE: readyFile, + EXTENSION_SNAPSHOT_FILE: snapshotFile, + EXTENSION_ERROR_FILE: errorFile, + EXTENSION_INVALID_BATCH_FILE: invalidBatchFile, + EXTENSION_BADGE_UPDATES: JSON.stringify(badgeUpdates), + }, + }); + const stderr: string[] = []; + child.stderr!.on("data", (chunk) => stderr.push(String(chunk))); + + const connection = createMessageConnection( + new StreamMessageReader(child.stdout!), + new StreamMessageWriter(child.stdin!) + ); + let registered = false; + const batchRequests: unknown[] = []; + connection.onRequest("connect", () => ({ protocolVersion: getSdkProtocolVersion() })); + connection.onRequest("session.resume", (params: Record) => ({ + sessionId: params.sessionId, + })); + connection.onRequest("extensions.appSessionBadges.register", () => { + registered = true; + return null; + }); + connection.onRequest("extensions.appSessionBadges.setBadges", (params: unknown) => { + batchRequests.push(params); + return null; + }); + connection.onRequest(() => ({})); + connection.onNotification(() => {}); + connection.listen(); + + const initialSnapshot: AppSessionBadgesSnapshot = { + protocolVersion: 1, + revision: 4, + sessions: [], + }; + const snapshots = [largeSnapshot(5), largeSnapshot(6), largeSnapshot(7)]; + + try { + await retry( + "wait for the app-session badge extension to register", + async () => { + expect( + existsSync(readyFile), + `extension did not become ready; error: ${ + existsSync(errorFile) ? readFileSync(errorFile, "utf8") : "" + }; stderr: ${stderr.join("")}` + ).toBe(true); + expect(registered).toBe(true); + expect(batchRequests).toEqual([ + { + protocolVersion: 1, + updates: badgeUpdates, + }, + ]); + expect(readFileSync(invalidBatchFile, "utf8")).toContain( + "updates contains duplicate target" + ); + }, + 100, + 50 + ); + + await connection.sendNotification("appSessionBadges.snapshot", initialSnapshot); + await connection.sendNotification("appSessionBadges.snapshot", snakeCaseSnapshot(5)); + + const coalesced = Buffer.concat([ + encodeNotification(snapshots[0]!), + encodeNotification(snapshots[1]!), + ]); + child.stdin!.write(coalesced); + + const fragmented = encodeNotification(snapshots[2]!); + for (let offset = 0; offset < fragmented.byteLength; offset += 97) { + child.stdin!.write(fragmented.subarray(offset, offset + 97)); + } + + await retry( + "wait for every later snapshot callback", + async () => { + expect( + readSnapshots(snapshotFile), + `not every snapshot callback fired; stderr: ${stderr.join("")}` + ).toHaveLength(4); + }, + 100, + 50 + ); + expect(readSnapshots(snapshotFile)).toEqual([initialSnapshot, ...snapshots]); + expect(encodeNotification(snapshots[0]!).byteLength).toBeGreaterThan(11_000); + expect(stderr.join("")).toContain("Invalid app session badges snapshot ignored"); + } finally { + connection.dispose(); + child.kill(); + await new Promise((resolveExit) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolveExit(); + return; + } + child.once("exit", () => resolveExit()); + }); + await rm(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); + } +}); diff --git a/nodejs/test/e2e/fixtures/app-extension.mjs b/nodejs/test/e2e/fixtures/app-extension.mjs new file mode 100644 index 0000000000..633085ad06 --- /dev/null +++ b/nodejs/test/e2e/fixtures/app-extension.mjs @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { appendFileSync, writeFileSync } from "node:fs"; + +const sdkModule = + process.env.APP_EXTENSION_SDK_MODULE ?? "@github/copilot-sdk/private/app-extension"; +const { defineAppExtension } = await import(sdkModule); + +const record = (path, value) => { + if (path) { + writeFileSync(path, value); + } +}; + +try { + await defineAppExtension(async (host) => { + const contributionId = (point) => { + const declaration = host.contributions.find( + (candidate) => candidate.contributionPoint === point + ); + if (!declaration) { + throw new Error(`Missing ${point} contribution`); + } + return declaration.contributionId; + }; + const badges = await host.sessionBadges.register({ + onSnapshot: async (snapshot, identity) => { + if (process.env.APP_EXTENSION_SNAPSHOT_FILE) { + appendFileSync( + process.env.APP_EXTENSION_SNAPSHOT_FILE, + `${JSON.stringify({ snapshot, identity })}\n` + ); + } + if (snapshot.sessions.length > 0 && process.env.APP_EXTENSION_BADGE_UPDATES) { + await badges.setBadges(JSON.parse(process.env.APP_EXTENSION_BADGE_UPDATES)); + await badges.setPresentations( + JSON.parse(process.env.APP_EXTENSION_PRESENTATION_UPDATES) + ); + record(process.env.APP_EXTENSION_BATCH_SENT_FILE, "sent"); + } + }, + onAction: ({ target, draft }) => ({ + prompt: `# Pull Request Creation\nCreate a fake${draft ? " draft" : ""} pull request for ${target.branch ?? target.workspaceId}.`, + requiredTool: "create_ado_pull_request", + }), + }); + const canvas = await host.canvases.register({ + contributionId: contributionId("canvases"), + onOpen: ({ instanceId, input, context }) => ({ + state: { instanceId, input, context }, + title: "Repository overview", + status: "Ready", + actions: [ + { + name: "create", + label: "Create pull request", + input: { draft: false }, + variant: "primary", + }, + ], + }), + onAction: ({ actionName, input }) => ({ actionName, input }), + }); + const forge = await host.forgeProviders.register({ + contributionId: contributionId("forgeProvider"), + operations: { + getPullRequest: async ({ accountId, input, signal }) => { + const response = await host.mediatedFetch.request({ + contributionId: contributionId("forgeProvider"), + accountId, + operation: "getPullRequest", + method: "GET", + path: `/repos/github/copilot-sdk/pulls/${input.number}`, + headers: { Accept: "application/json" }, + signal, + }); + return { + status: response.status, + body: response.body, + truncated: response.truncated, + }; + }, + }, + }); + record( + process.env.APP_EXTENSION_READY_FILE, + JSON.stringify({ + principal: host.principal, + hostKeys: Object.keys(host).sort(), + contributions: { + badges: Object.keys(badges).sort(), + canvas: Object.keys(canvas).sort(), + forge: Object.keys(forge).sort(), + }, + }) + ); + }); +} catch (error) { + record( + process.env.APP_EXTENSION_ERROR_FILE, + error instanceof Error ? (error.stack ?? error.message) : String(error) + ); +} diff --git a/nodejs/test/e2e/fixtures/app-session-badges-extension.mjs b/nodejs/test/e2e/fixtures/app-session-badges-extension.mjs new file mode 100644 index 0000000000..11839bfc90 --- /dev/null +++ b/nodejs/test/e2e/fixtures/app-session-badges-extension.mjs @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { appendFileSync, writeFileSync } from "node:fs"; + +const sdkModule = process.env.EXTENSION_SDK_MODULE ?? "@github/copilot-sdk/extension"; +const { joinAppSessionBadges } = await import(sdkModule); + +const record = (path, value) => { + if (path) { + writeFileSync(path, value); + } +}; + +try { + const badges = await joinAppSessionBadges(); + badges.onSnapshot((snapshot) => { + if (process.env.EXTENSION_SNAPSHOT_FILE) { + appendFileSync(process.env.EXTENSION_SNAPSHOT_FILE, `${JSON.stringify(snapshot)}\n`); + } + }); + if (process.env.EXTENSION_INVALID_BATCH_FILE) { + try { + await badges.setBadges([ + { + workspaceId: "duplicate-workspace", + sessionId: "duplicate-session", + badge: { state: "open" }, + }, + { + workspaceId: "duplicate-workspace", + sessionId: "duplicate-session", + badge: null, + }, + ]); + } catch (error) { + record( + process.env.EXTENSION_INVALID_BATCH_FILE, + error instanceof Error ? error.message : String(error) + ); + } + } + if (process.env.EXTENSION_BADGE_UPDATES) { + await badges.setBadges(JSON.parse(process.env.EXTENSION_BADGE_UPDATES)); + } + record(process.env.EXTENSION_READY_FILE, "ready"); +} catch (error) { + record( + process.env.EXTENSION_ERROR_FILE, + error instanceof Error ? (error.stack ?? error.message) : String(error) + ); +} diff --git a/nodejs/test/extension.test.ts b/nodejs/test/extension.test.ts index bebe8db7f0..d2f86590ac 100644 --- a/nodejs/test/extension.test.ts +++ b/nodejs/test/extension.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CopilotClient } from "../src/client.js"; import { approveAll } from "../src/index.js"; -import { createCanvas, joinSession } from "../src/extension.js"; +import { createCanvas, joinAppSessionBadges, joinSession } from "../src/extension.js"; +import type { AppSessionBadgesExtension } from "../src/appSessionBadges.js"; +import type { CopilotSession } from "../src/session.js"; import { defaultJoinSessionPermissionHandler } from "../src/types.js"; describe("joinSession", () => { @@ -90,4 +92,30 @@ describe("joinSession", () => { expect(canvas.declaration.id).toBe("counter"); }); + + it("explicitly registers an app-session badge contribution", async () => { + process.env.SESSION_ID = "session-123"; + const session = { disconnect: vi.fn() } as unknown as CopilotSession; + vi.spyOn(CopilotClient.prototype, "resumeSessionForExtension").mockResolvedValue(session); + const contribution = { session } as unknown as AppSessionBadgesExtension; + const register = vi + .spyOn(CopilotClient.prototype, "registerAppSessionBadges") + .mockResolvedValue(contribution); + + await expect(joinAppSessionBadges()).resolves.toBe(contribution); + + expect(register).toHaveBeenCalledWith(session); + }); + + it("stops the parent-process client when extension session resume fails", async () => { + process.env.SESSION_ID = "session-123"; + vi.spyOn(CopilotClient.prototype, "resumeSessionForExtension").mockRejectedValue( + new Error("resume failed") + ); + const stop = vi.spyOn(CopilotClient.prototype, "stop").mockResolvedValue([]); + + await expect(joinSession()).rejects.toThrow("resume failed"); + + expect(stop).toHaveBeenCalledOnce(); + }); }); diff --git a/nodejs/test/session-send-and-wait.test.ts b/nodejs/test/session-send-and-wait.test.ts index 4ee2e8eebc..9c2f619254 100644 --- a/nodejs/test/session-send-and-wait.test.ts +++ b/nodejs/test/session-send-and-wait.test.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import { describe, expect, it, onTestFinished } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; import type { MessageConnection } from "vscode-jsonrpc/node.js"; import { CopilotSession } from "../src/session.js"; import type { SessionEvent } from "../src/generated/session-events.js"; @@ -61,6 +61,28 @@ function controlledSession(): { }; } +describe("send", () => { + it("forwards requiredTool in the session.send request", async () => { + const sendRequest = vi.fn().mockResolvedValue({ messageId: "msg-1" }); + const connection = { sendRequest } as unknown as MessageConnection; + const session = new CopilotSession("session-1", connection); + + await session.send({ + prompt: "Create the pull request", + requiredTool: "create_ado_pull_request", + }); + + expect(sendRequest).toHaveBeenCalledWith( + "session.send", + expect.objectContaining({ + sessionId: "session-1", + prompt: "Create the pull request", + requiredTool: "create_ado_pull_request", + }) + ); + }); +}); + describe("sendAndWait", () => { it("does not emit an unhandled rejection when session.error arrives before the idle race is armed", async () => { const { session, sendStarted, resolveSend } = controlledSession(); diff --git a/nodejs/test/shared-codegen.test.ts b/nodejs/test/shared-codegen.test.ts index 54f9d39e94..c728a6809a 100644 --- a/nodejs/test/shared-codegen.test.ts +++ b/nodejs/test/shared-codegen.test.ts @@ -2,6 +2,7 @@ import type { JSONSchema7 } from "json-schema"; import { describe, expect, it } from "vitest"; import { + type ApiSchema, collectDefinitionCollections, collectExperimentalOnlyRpcReferencedDefinitionNames, collectReachableDefinitionNames, @@ -9,10 +10,105 @@ import { getEnumValueDescriptions, inlineExternalSchemaDefinitions, isIntegerSchemaBoundedToInt32, + mergeApiSchemaOverlay, rewriteSharedDefinitionReferences, } from "../../scripts/codegen/utils.ts"; describe("shared schema definition codegen utilities", () => { + it("merges non-conflicting SDK-owned API definitions and methods", () => { + const base: ApiSchema = { + definitions: { + Existing: { type: "string" }, + }, + server: { + existing: { + rpcMethod: "existing.method", + params: { type: "null" }, + }, + }, + }; + const overlay: ApiSchema = { + definitions: { + PrivatePrincipal: { type: "object" }, + }, + server: { + extensions: { + appExtension: { + register: { + rpcMethod: "extensions.appExtension.register", + params: { type: "object" }, + }, + }, + }, + }, + }; + + const merged = mergeApiSchemaOverlay(base, overlay); + + expect(merged.definitions).toEqual({ + Existing: { type: "string" }, + PrivatePrincipal: { type: "object" }, + }); + expect(merged.server).toEqual({ + existing: { + rpcMethod: "existing.method", + params: { type: "null" }, + }, + extensions: { + appExtension: { + register: { + rpcMethod: "extensions.appExtension.register", + params: { type: "object" }, + }, + }, + }, + }); + expect(base).toEqual({ + definitions: { + Existing: { type: "string" }, + }, + server: { + existing: { + rpcMethod: "existing.method", + params: { type: "null" }, + }, + }, + }); + }); + + it("rejects an SDK API overlay that conflicts with the runtime schema", () => { + expect(() => + mergeApiSchemaOverlay( + { + server: { + extensions: { + appExtension: { + register: { + rpcMethod: "extensions.appExtension.register", + params: { type: "object" }, + }, + }, + }, + }, + }, + { + server: { + extensions: { + appExtension: { + register: { + rpcMethod: "extensions.appExtension.register", + params: { type: "null" }, + }, + }, + }, + }, + } + ) + ).toThrow( + "SDK API schema overlay conflicts with the runtime schema at server.extensions.appExtension.register" + ); + }); + it("detects integer schemas bounded to the 32-bit signed range", () => { expect( isIntegerSchemaBoundedToInt32({ diff --git a/rust/src/app_extension.rs b/rust/src/app_extension.rs new file mode 100644 index 0000000000..1770ad8005 --- /dev/null +++ b/rust/src/app_extension.rs @@ -0,0 +1,1524 @@ +//! Private app-extension principal and capability registration types. +//! +//! This module supports app-bundled integrations and is not a stable public SDK surface. + +use std::collections::HashSet; +use std::sync::Arc; + +use async_trait::async_trait; +use serde::Serialize; + +pub use crate::generated::api_types::{ + AppCanvasActionDescriptor, AppCanvasActionDescriptorVariant, AppCanvasContext, + AppCanvasOpenResult, AppCanvasProjectContext, AppMediatedFetchHttpRequest, + AppMediatedFetchHttpRequestMethod, AppMediatedFetchResponse, AppSessionActionResult, + AppSessionPresentationTarget, +}; +use crate::generated::api_types::{ + AppCanvasHostActionRequest, AppCanvasHostCloseRequest, AppCanvasHostOpenRequest, + AppExtensionContributionPoint as WireContributionPoint, AppExtensionRegisterRequest, + AppExtensionRegisterResult as WireRegisterResult, AppForgeHostInvokeRequest, + AppMediatedFetchHostRequest as WireMediatedFetchHostRequest, AppSessionActionHostRequest, + AppSessionPullRequestActionInvocation, AppSessionPullRequestActionInvocationKind, rpc_methods, +}; +use crate::session::Session; +use crate::types::SessionId; +use crate::{Client, Error, ErrorKind, JsonRpcRequest, JsonRpcResponse, error_codes}; + +const PROTOCOL_VERSION: u64 = 1; +const MAX_ID_LENGTH: usize = 256; +const MAX_OPERATION_LENGTH: usize = 256; +const MAX_CANVAS_INSTANCE_ID_LENGTH: usize = 256; +const MAX_FETCH_PATH_LENGTH: usize = 8192; +const MAX_FETCH_HEADERS: usize = 64; +const MAX_FETCH_HEADER_BYTES: usize = 32 * 1024; +const MAX_FETCH_BODY_BYTES: usize = 256 * 1024; +const MAX_FETCH_RESPONSE_BODY_BYTES: usize = 1024 * 1024; +const MAX_ACTION_PROMPT_BYTES: usize = 32 * 1024; + +/// Constrained HTTP method accepted by mediated fetch. +pub type AppMediatedFetchMethod = AppMediatedFetchHttpRequestMethod; + +/// Opaque identity for an allowlisted app-extension package. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppExtensionPackageId(String); + +/// Opaque identity for one app-extension launch generation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppExtensionActivationId(String); + +/// Opaque identity for one principal-owned contribution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppExtensionContributionId(String); + +/// Capability contribution point declared by a trusted app-extension manifest. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AppExtensionContributionPoint { + /// Session badge contribution. + SessionBadges, + /// Future app-canvas contribution. + Canvases, + /// Future forge-provider contribution. + ForgeProvider, +} + +/// Runtime-authenticated identity of one statically declared contribution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppExtensionDeclaredContribution { + contribution_point: AppExtensionContributionPoint, + contribution_id: AppExtensionContributionId, +} + +/// Runtime-authenticated package and activation identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppExtensionPrincipal { + package_id: AppExtensionPackageId, + activation_id: AppExtensionActivationId, +} + +/// Capability grants bound to an authenticated principal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AppExtensionCapabilityGrants { + session_badges: bool, + canvases: bool, + forge_provider: bool, + mediated_fetch: bool, +} + +/// Identity attached to a capability contribution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppExtensionContributionIdentity { + principal: AppExtensionPrincipal, + contribution_id: AppExtensionContributionId, +} + +/// Authenticated principal registration returned by the runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppExtensionRegistration { + principal: AppExtensionPrincipal, + capabilities: AppExtensionCapabilityGrants, + contributions: Vec, +} + +/// Opaque target for one runtime-authenticated app-canvas contribution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppCanvasTarget { + principal: AppExtensionPrincipal, + contribution_id: AppExtensionContributionId, +} + +/// Opaque target for one runtime-authenticated forge-provider contribution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppForgeProviderTarget { + principal: AppExtensionPrincipal, + contribution_id: AppExtensionContributionId, +} + +/// Typed request for opening an app-scoped canvas. +#[derive(Debug, Clone)] +pub struct AppCanvasOpenRequest { + /// Target contribution. + pub target: AppCanvasTarget, + /// App-owned canvas instance identity. + pub instance_id: String, + /// Optional serializable input supplied by the app. + pub input: Option, + /// Optional trusted project/workspace context. + pub context: Option, +} + +/// Typed request for invoking an app-scoped canvas action. +#[derive(Debug, Clone)] +pub struct AppCanvasActionRequest { + /// Target contribution. + pub target: AppCanvasTarget, + /// App-owned canvas instance identity. + pub instance_id: String, + /// Action name declared by the canvas contract. + pub action_name: String, + /// Optional serializable action input. + pub input: Option, + /// Optional trusted project/workspace context. + pub context: Option, +} + +/// Typed request for closing an app-scoped canvas. +#[derive(Debug, Clone)] +pub struct AppCanvasCloseRequest { + /// Target contribution. + pub target: AppCanvasTarget, + /// App-owned canvas instance identity. + pub instance_id: String, + /// Optional trusted project/workspace context. + pub context: Option, +} + +/// Typed request for invoking a forge-provider operation. +#[derive(Debug, Clone)] +pub struct AppForgeInvokeRequest { + /// Target contribution. + pub target: AppForgeProviderTarget, + /// Provider-defined operation name. + pub operation: String, + /// Optional app-owned forge account identity. + pub account_id: Option, + /// Optional serializable operation input. + pub input: Option, +} + +/// Typed request for invoking a Create Pull Request action. +#[derive(Debug, Clone)] +pub struct AppSessionBadgeActionRequest { + /// Runtime-authenticated session-badge contribution identity. + pub target: AppExtensionContributionIdentity, + /// Exact app-visible target from the current eligible-session snapshot. + pub session: AppSessionPresentationTarget, + /// Whether the user selected draft pull request creation. + pub draft: bool, +} + +/// Validated mediated-fetch effect delivered to the trusted app host. +#[derive(Debug, Clone)] +pub struct AppMediatedFetchRequest { + /// Runtime-authenticated extension principal. + pub principal: AppExtensionPrincipal, + /// Runtime-authenticated forge-provider contribution identity. + pub contribution_id: AppExtensionContributionId, + /// App-owned forge account identity authorized by the runtime. + pub account_id: String, + /// Provider operation authorizing this request. + pub operation: String, + /// Credential-free bounded HTTP request. + pub request: AppMediatedFetchHttpRequest, +} + +/// Session-scoped trusted host implementation for mediated network access. +#[async_trait] +pub trait AppMediatedFetchHandler: Send + Sync + 'static { + /// Execute one validated mediated request and return a sanitized response. + async fn fetch( + &self, + request: AppMediatedFetchRequest, + ) -> Result; +} + +/// App-host controller for routing typed canvas and forge requests. +#[derive(Clone)] +pub struct AppExtensionsHost { + client: Client, + app_session_id: SessionId, +} + +impl AppExtensionRegistration { + /// Return the authenticated principal. + pub fn principal(&self) -> &AppExtensionPrincipal { + &self.principal + } + + /// Return the runtime-granted capabilities. + pub fn capabilities(&self) -> AppExtensionCapabilityGrants { + self.capabilities + } + + /// Return the identity of the activation's single badge contribution. + pub fn session_badges_identity(&self) -> Result { + let mut declarations = self.contributions.iter().filter(|contribution| { + contribution.contribution_point == AppExtensionContributionPoint::SessionBadges + }); + let Some(declaration) = declarations.next() else { + return Err(invalid_registration( + "expected exactly one sessionBadges contribution; received 0".to_string(), + )); + }; + if declarations.next().is_some() { + let count = self + .contributions + .iter() + .filter(|contribution| { + contribution.contribution_point == AppExtensionContributionPoint::SessionBadges + }) + .count(); + return Err(invalid_registration(format!( + "expected exactly one sessionBadges contribution; received {count}" + ))); + } + Ok(AppExtensionContributionIdentity { + principal: self.principal.clone(), + contribution_id: declaration.contribution_id.clone(), + }) + } +} + +impl AppExtensionPrincipal { + /// Create an opaque principal target from trusted runtime/app metadata. + pub fn new(package_id: impl Into, activation_id: impl Into) -> Self { + Self { + package_id: AppExtensionPackageId(package_id.into()), + activation_id: AppExtensionActivationId(activation_id.into()), + } + } + + /// Return the opaque package identity. + pub fn package_id(&self) -> &AppExtensionPackageId { + &self.package_id + } + + /// Return the opaque activation identity. + pub fn activation_id(&self) -> &AppExtensionActivationId { + &self.activation_id + } +} + +impl AppExtensionPackageId { + /// Return the identity for logging or equality comparison. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AppExtensionActivationId { + /// Return the identity for logging or equality comparison. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AppExtensionContributionId { + /// Create an opaque contribution identity from trusted manifest metadata. + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Return the identity for logging or equality comparison. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AppCanvasTarget { + /// Create a canvas target from trusted principal and manifest metadata. + pub fn new( + principal: AppExtensionPrincipal, + contribution_id: AppExtensionContributionId, + ) -> Self { + Self { + principal, + contribution_id, + } + } + + /// Return the target principal. + pub fn principal(&self) -> &AppExtensionPrincipal { + &self.principal + } + + /// Return the target contribution identity. + pub fn contribution_id(&self) -> &AppExtensionContributionId { + &self.contribution_id + } +} + +impl AppForgeProviderTarget { + /// Create a forge-provider target from trusted principal and manifest metadata. + pub fn new( + principal: AppExtensionPrincipal, + contribution_id: AppExtensionContributionId, + ) -> Self { + Self { + principal, + contribution_id, + } + } + + /// Return the target principal. + pub fn principal(&self) -> &AppExtensionPrincipal { + &self.principal + } + + /// Return the target contribution identity. + pub fn contribution_id(&self) -> &AppExtensionContributionId { + &self.contribution_id + } +} + +impl AppExtensionsHost { + /// Open one app-scoped canvas contribution. + pub async fn open_canvas( + &self, + request: AppCanvasOpenRequest, + ) -> Result { + validate_target(&request.target.principal, &request.target.contribution_id)?; + validate_bounded( + &request.instance_id, + "instanceId", + MAX_CANVAS_INSTANCE_ID_LENGTH, + )?; + self.client + .rpc() + .extensions() + .app_canvas() + .open(AppCanvasHostOpenRequest { + activation_id: request.target.principal.activation_id.0, + app_session_id: self.app_session_id.to_string(), + context: request.context, + contribution_id: request.target.contribution_id.0, + input: request.input, + instance_id: request.instance_id, + package_id: request.target.principal.package_id.0, + protocol_version: serde_json::json!(PROTOCOL_VERSION), + }) + .await + } + + /// Invoke one action on an open app-scoped canvas. + pub async fn invoke_canvas_action( + &self, + request: AppCanvasActionRequest, + ) -> Result { + validate_target(&request.target.principal, &request.target.contribution_id)?; + validate_bounded( + &request.instance_id, + "instanceId", + MAX_CANVAS_INSTANCE_ID_LENGTH, + )?; + validate_bounded(&request.action_name, "actionName", MAX_OPERATION_LENGTH)?; + self.client + .rpc() + .extensions() + .app_canvas() + .action() + .invoke(AppCanvasHostActionRequest { + action_name: request.action_name, + activation_id: request.target.principal.activation_id.0, + app_session_id: self.app_session_id.to_string(), + context: request.context, + contribution_id: request.target.contribution_id.0, + input: request.input, + instance_id: request.instance_id, + package_id: request.target.principal.package_id.0, + protocol_version: serde_json::json!(PROTOCOL_VERSION), + }) + .await + } + + /// Close one app-scoped canvas instance. + pub async fn close_canvas(&self, request: AppCanvasCloseRequest) -> Result<(), Error> { + validate_target(&request.target.principal, &request.target.contribution_id)?; + validate_bounded( + &request.instance_id, + "instanceId", + MAX_CANVAS_INSTANCE_ID_LENGTH, + )?; + self.client + .rpc() + .extensions() + .app_canvas() + .close(AppCanvasHostCloseRequest { + activation_id: request.target.principal.activation_id.0, + app_session_id: self.app_session_id.to_string(), + context: request.context, + contribution_id: request.target.contribution_id.0, + instance_id: request.instance_id, + package_id: request.target.principal.package_id.0, + protocol_version: serde_json::json!(PROTOCOL_VERSION), + }) + .await + } + + /// Invoke one operation on an app-scoped forge-provider contribution. + pub async fn invoke_forge_provider( + &self, + request: AppForgeInvokeRequest, + ) -> Result { + validate_target(&request.target.principal, &request.target.contribution_id)?; + validate_bounded(&request.operation, "operation", MAX_OPERATION_LENGTH)?; + self.client + .rpc() + .extensions() + .app_forge() + .invoke(AppForgeHostInvokeRequest { + account_id: request.account_id, + activation_id: request.target.principal.activation_id.0, + app_session_id: self.app_session_id.to_string(), + contribution_id: request.target.contribution_id.0, + input: request.input, + operation: request.operation, + package_id: request.target.principal.package_id.0, + protocol_version: serde_json::json!(PROTOCOL_VERSION), + }) + .await + } + + /// Invoke one Create Pull Request action on an app-session badge contribution. + pub async fn invoke_session_badge_action( + &self, + request: AppSessionBadgeActionRequest, + ) -> Result, Error> { + validate_target(&request.target.principal, &request.target.contribution_id)?; + validate_session_presentation_target(&request.session)?; + let value = self + .client + .rpc() + .extensions() + .app_session_badges() + .action() + .invoke(AppSessionActionHostRequest { + action: AppSessionPullRequestActionInvocation { + draft: request.draft, + kind: AppSessionPullRequestActionInvocationKind::CreatePullRequest, + }, + activation_id: request.target.principal.activation_id.0, + app_session_id: self.app_session_id.to_string(), + contribution_id: request.target.contribution_id.0, + package_id: request.target.principal.package_id.0, + protocol_version: serde_json::json!(PROTOCOL_VERSION), + target: request.session, + }) + .await?; + if value.is_null() { + return Ok(None); + } + let result: AppSessionActionResult = serde_json::from_value(value)?; + validate_session_action_result(&result)?; + Ok(Some(result)) + } +} + +impl Session { + /// Create the private app-extension host controller for this retained session. + pub fn app_extensions(&self) -> AppExtensionsHost { + AppExtensionsHost { + client: self.client().clone(), + app_session_id: self.id().clone(), + } + } +} + +impl AppExtensionCapabilityGrants { + /// Whether session badge registration is granted. + pub fn session_badges(&self) -> bool { + self.session_badges + } + + /// Whether future app-canvas registration is granted. + pub fn canvases(&self) -> bool { + self.canvases + } + + /// Whether future forge-provider registration is granted. + pub fn forge_provider(&self) -> bool { + self.forge_provider + } + + /// Whether future mediated fetch is granted. + pub fn mediated_fetch(&self) -> bool { + self.mediated_fetch + } +} + +impl AppExtensionContributionIdentity { + /// Return the contribution owner. + pub fn principal(&self) -> &AppExtensionPrincipal { + &self.principal + } + + /// Return the opaque contribution identity. + pub fn contribution_id(&self) -> &AppExtensionContributionId { + &self.contribution_id + } +} + +impl AppExtensionDeclaredContribution { + /// Return the declared contribution point. + pub fn contribution_point(&self) -> AppExtensionContributionPoint { + self.contribution_point + } + + /// Return the opaque contribution identity. + pub fn contribution_id(&self) -> &AppExtensionContributionId { + &self.contribution_id + } +} + +#[cfg_attr(not(test), expect(dead_code))] +pub(crate) async fn register(client: &Client) -> Result { + let result = client + .rpc() + .extensions() + .app_extension() + .register(AppExtensionRegisterRequest { + protocol_version: serde_json::json!(PROTOCOL_VERSION), + }) + .await?; + parse_registration(result) +} + +fn parse_registration(result: WireRegisterResult) -> Result { + if result.protocol_version != serde_json::json!(PROTOCOL_VERSION) { + return Err(invalid_registration(format!( + "unsupported app extension protocol version: {}", + result.protocol_version + ))); + } + if result.principal.package_id.is_empty() { + return Err(invalid_registration( + "principal.packageId must be a non-empty string".to_string(), + )); + } + if result.principal.activation_id.is_empty() { + return Err(invalid_registration( + "principal.activationId must be a non-empty string".to_string(), + )); + } + let mut seen = HashSet::new(); + let mut contributions = Vec::with_capacity(result.contributions.len()); + for contribution in result.contributions { + if contribution.contribution_id.is_empty() { + return Err(invalid_registration( + "contributionId must be a non-empty string".to_string(), + )); + } + let contribution_point = match contribution.contribution_point { + WireContributionPoint::SessionBadges => AppExtensionContributionPoint::SessionBadges, + WireContributionPoint::Canvases => AppExtensionContributionPoint::Canvases, + WireContributionPoint::ForgeProvider => AppExtensionContributionPoint::ForgeProvider, + WireContributionPoint::Unknown => { + return Err(invalid_registration( + "unsupported app extension contribution point".to_string(), + )); + } + }; + if !seen.insert((contribution_point, contribution.contribution_id.clone())) { + return Err(invalid_registration(format!( + "duplicate app extension contribution identity: {contribution_point:?}/{}", + contribution.contribution_id + ))); + } + contributions.push(AppExtensionDeclaredContribution { + contribution_point, + contribution_id: AppExtensionContributionId(contribution.contribution_id), + }); + } + + Ok(AppExtensionRegistration { + principal: AppExtensionPrincipal { + package_id: AppExtensionPackageId(result.principal.package_id), + activation_id: AppExtensionActivationId(result.principal.activation_id), + }, + capabilities: AppExtensionCapabilityGrants { + session_badges: result.capabilities.session_badges == Some(true), + canvases: result.capabilities.canvases == Some(true), + forge_provider: result.capabilities.forge_provider == Some(true), + mediated_fetch: result.capabilities.mediated_fetch == Some(true), + }, + contributions, + }) +} + +fn invalid_registration(message: String) -> Error { + Error::with_message(ErrorKind::InvalidConfig, message) +} + +fn validate_target( + principal: &AppExtensionPrincipal, + contribution_id: &AppExtensionContributionId, +) -> Result<(), Error> { + validate_bounded(principal.package_id.as_str(), "packageId", MAX_ID_LENGTH)?; + validate_bounded( + principal.activation_id.as_str(), + "activationId", + MAX_ID_LENGTH, + )?; + validate_bounded(contribution_id.as_str(), "contributionId", MAX_ID_LENGTH) +} + +fn validate_session_presentation_target( + target: &AppSessionPresentationTarget, +) -> Result<(), Error> { + validate_bounded(&target.workspace_id, "workspaceId", MAX_ID_LENGTH)?; + validate_bounded(target.session_id.as_str(), "sessionId", MAX_ID_LENGTH)?; + validate_bounded( + &target.repository_path, + "repositoryPath", + MAX_ACTION_PROMPT_BYTES, + )?; + validate_bounded( + &target.worktree_path, + "worktreePath", + MAX_ACTION_PROMPT_BYTES, + )?; + if target + .branch + .as_ref() + .is_some_and(|branch| branch.len() > 4096) + { + return Err(invalid_registration( + "branch must be at most 4096 bytes".to_string(), + )); + } + Ok(()) +} + +fn validate_session_action_result(result: &AppSessionActionResult) -> Result<(), Error> { + validate_bounded(&result.prompt, "prompt", MAX_ACTION_PROMPT_BYTES)?; + if result.prompt.lines().next() != Some("# Pull Request Creation") { + return Err(invalid_registration( + "prompt must start with the exact \"# Pull Request Creation\" header".to_string(), + )); + } + validate_bounded(&result.required_tool, "requiredTool", MAX_OPERATION_LENGTH)?; + if !result + .required_tool + .bytes() + .enumerate() + .all(|(index, byte)| { + byte.is_ascii_alphanumeric() || (index > 0 && matches!(byte, b'_' | b'.' | b':' | b'-')) + }) + { + return Err(invalid_registration( + "requiredTool is not a valid tool identifier".to_string(), + )); + } + Ok(()) +} + +fn validate_non_empty(value: &str, name: &str) -> Result<(), Error> { + if value.is_empty() { + return Err(invalid_registration(format!( + "{name} must be a non-empty string" + ))); + } + Ok(()) +} + +fn validate_bounded(value: &str, name: &str, max_length: usize) -> Result<(), Error> { + validate_non_empty(value, name)?; + if value.len() > max_length { + return Err(invalid_registration(format!( + "{name} must be at most {max_length} bytes" + ))); + } + Ok(()) +} + +fn validate_mediated_fetch_request(request: &AppMediatedFetchRequest) -> Result<(), Error> { + validate_target(&request.principal, &request.contribution_id)?; + validate_bounded(&request.account_id, "accountId", MAX_ID_LENGTH)?; + validate_bounded(&request.operation, "operation", MAX_OPERATION_LENGTH)?; + validate_bounded(&request.request.path, "path", MAX_FETCH_PATH_LENGTH)?; + if matches!(request.request.method, AppMediatedFetchMethod::Unknown) { + return Err(invalid_registration( + "unsupported mediated fetch method".to_string(), + )); + } + validate_fetch_path(&request.request.path)?; + if request.request.headers.as_ref().is_some_and(|headers| { + headers.len() > MAX_FETCH_HEADERS + || headers.iter().any(|(name, value)| { + name.len() + value.len() > MAX_FETCH_HEADER_BYTES + || !is_http_header_name(name) + || is_sensitive_request_header(name) + || value.contains(['\r', '\n']) + }) + || headers + .iter() + .map(|(name, value)| name.len() + value.len()) + .sum::() + > MAX_FETCH_HEADER_BYTES + }) { + return Err(invalid_registration( + "mediated fetch headers exceed transport bounds".to_string(), + )); + } + if request + .request + .body + .as_ref() + .is_some_and(|body| body.len() > MAX_FETCH_BODY_BYTES) + { + return Err(invalid_registration( + "mediated fetch body exceeds transport bounds".to_string(), + )); + } + Ok(()) +} + +fn validate_mediated_fetch_response(response: &AppMediatedFetchResponse) -> Result<(), Error> { + if !(100..=599).contains(&response.status) { + return Err(invalid_registration( + "mediated fetch response status is invalid".to_string(), + )); + } + if response.headers.len() > MAX_FETCH_HEADERS + || response.headers.iter().any(|(name, value)| { + name.len() + value.len() > MAX_FETCH_HEADER_BYTES + || !is_http_header_name(name) + || value.contains(['\r', '\n']) + }) + || response + .headers + .iter() + .map(|(name, value)| name.len() + value.len()) + .sum::() + > MAX_FETCH_HEADER_BYTES + { + return Err(invalid_registration( + "mediated fetch response headers exceed transport bounds".to_string(), + )); + } + if response + .body + .as_ref() + .is_some_and(|body| body.len() > MAX_FETCH_RESPONSE_BODY_BYTES) + { + return Err(invalid_registration( + "mediated fetch response body exceeds transport bounds".to_string(), + )); + } + Ok(()) +} + +fn validate_fetch_path(path: &str) -> Result<(), Error> { + if !path.starts_with('/') || path.starts_with("//") || path.contains(['\\', '\r', '\n']) { + return Err(invalid_registration( + "mediated fetch path must be a credential-free root-relative URL path".to_string(), + )); + } + let path = path.split(['?', '#']).next().unwrap_or(path); + let mut decoded = Vec::with_capacity(path.len()); + let bytes = path.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let Some(high) = bytes.get(index + 1).and_then(|byte| hex_value(*byte)) else { + return Err(invalid_registration( + "mediated fetch path contains invalid percent encoding".to_string(), + )); + }; + let Some(low) = bytes.get(index + 2).and_then(|byte| hex_value(*byte)) else { + return Err(invalid_registration( + "mediated fetch path contains invalid percent encoding".to_string(), + )); + }; + decoded.push((high << 4) | low); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + if decoded.contains(&b'\\') + || decoded + .split(|byte| *byte == b'/') + .any(|part| part == b"..") + { + return Err(invalid_registration( + "mediated fetch path must not contain parent traversal segments".to_string(), + )); + } + Ok(()) +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn is_http_header_name(name: &str) -> bool { + !name.is_empty() + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +fn is_sensitive_request_header(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + matches!( + name.as_str(), + "authorization" | "cookie" | "host" | "proxy-authorization" + ) || name.starts_with("x-forwarded-") +} + +async fn send_response(client: &Client, request_id: u64, result: T) { + match serde_json::to_value(result) { + Ok(result) => { + let _ = client + .send_response(&JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request_id, + result: Some(result), + error: None, + }) + .await; + } + Err(error) => { + send_error( + client, + request_id, + error_codes::INTERNAL_ERROR, + &format!("failed to serialize mediated fetch response: {error}"), + ) + .await; + } + } +} + +async fn send_error(client: &Client, request_id: u64, code: i32, message: &str) { + let _ = client + .send_response(&JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request_id, + result: None, + error: Some(crate::JsonRpcError { + code, + message: message.to_string(), + data: None, + }), + }) + .await; +} + +pub(crate) async fn dispatch_mediated_fetch( + client: &Client, + handler: Option<&Arc>, + request: JsonRpcRequest, +) -> bool { + if request.method != rpc_methods::APPFORGE_FETCH { + return false; + } + let params = request + .params + .as_ref() + .cloned() + .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); + let wire = match serde_json::from_value::(params) { + Ok(wire) => wire, + Err(error) => { + send_error( + client, + request.id, + error_codes::INVALID_PARAMS, + &format!("invalid appForge.fetch params: {error}"), + ) + .await; + return true; + } + }; + if wire.protocol_version != serde_json::json!(PROTOCOL_VERSION) { + send_error( + client, + request.id, + error_codes::INVALID_PARAMS, + "unsupported appForge.fetch protocol version", + ) + .await; + return true; + } + if let Err(error) = validate_non_empty(&wire.session_id, "sessionId") { + send_error( + client, + request.id, + error_codes::INVALID_PARAMS, + &error.to_string(), + ) + .await; + return true; + } + let Some(handler) = handler.cloned() else { + send_error( + client, + request.id, + error_codes::METHOD_NOT_FOUND, + "No AppMediatedFetchHandler installed on this session", + ) + .await; + return true; + }; + let effect = AppMediatedFetchRequest { + principal: AppExtensionPrincipal::new(wire.package_id, wire.activation_id), + contribution_id: AppExtensionContributionId::new(wire.contribution_id), + account_id: wire.account_id, + operation: wire.operation, + request: wire.request, + }; + if let Err(error) = validate_mediated_fetch_request(&effect) { + send_error( + client, + request.id, + error_codes::INVALID_PARAMS, + &error.to_string(), + ) + .await; + return true; + } + match handler.fetch(effect).await { + Ok(response) => { + if let Err(error) = validate_mediated_fetch_response(&response) { + send_error( + client, + request.id, + error_codes::INTERNAL_ERROR, + &error.to_string(), + ) + .await; + } else { + send_response(client, request.id, response).await; + } + } + Err(error) => { + send_error( + client, + request.id, + error_codes::INTERNAL_ERROR, + &error.to_string(), + ) + .await; + } + } + true +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use serde_json::{Value, json}; + use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, duplex}; + + use super::*; + + async fn read_framed(reader: &mut (impl AsyncRead + Unpin)) -> Value { + let mut header = String::new(); + loop { + let mut byte = [0u8; 1]; + reader.read_exact(&mut byte).await.unwrap(); + header.push(byte[0] as char); + if header.ends_with("\r\n\r\n") { + break; + } + } + let length = header + .trim() + .strip_prefix("Content-Length: ") + .unwrap() + .parse() + .unwrap(); + let mut body = vec![0; length]; + reader.read_exact(&mut body).await.unwrap(); + serde_json::from_slice(&body).unwrap() + } + + async fn write_framed(writer: &mut (impl AsyncWrite + Unpin), value: &Value) { + let body = serde_json::to_vec(value).unwrap(); + writer + .write_all(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes()) + .await + .unwrap(); + writer.write_all(&body).await.unwrap(); + writer.flush().await.unwrap(); + } + + fn request(id: u64, method: &str, params: Value) -> JsonRpcRequest { + JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id, + method: method.to_string(), + params: Some(params), + } + } + + #[tokio::test] + async fn registration_sends_no_spoofable_identity_and_types_the_principal() { + let (client_write, mut server_read) = duplex(8192); + let (mut server_write, client_read) = duplex(8192); + let client = + Client::from_streams(client_read, client_write, PathBuf::from(r"C:\src")).unwrap(); + let server = tokio::spawn(async move { + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "extensions.appExtension.register"); + assert_eq!(request["params"], json!({ "protocolVersion": 1 })); + assert!(request["params"].get("packageId").is_none()); + assert!(request["params"].get("activationId").is_none()); + write_framed( + &mut server_write, + &json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { + "protocolVersion": 1, + "principal": { + "packageId": "bundled:github-app:badges", + "activationId": "activation-7" + }, + "capabilities": { + "sessionBadges": true + }, + "contributions": [{ + "contributionPoint": "sessionBadges", + "contributionId": "github-pr" + }] + } + }), + ) + .await; + }); + + let registration = register(&client).await.unwrap(); + assert_eq!( + registration.principal.package_id, + AppExtensionPackageId("bundled:github-app:badges".to_string()) + ); + assert_eq!( + registration.principal.activation_id, + AppExtensionActivationId("activation-7".to_string()) + ); + assert!(registration.capabilities.session_badges); + assert!(!registration.capabilities.canvases); + assert!(!registration.capabilities.forge_provider); + assert!(!registration.capabilities.mediated_fetch); + assert_eq!( + registration.session_badges_identity().unwrap(), + AppExtensionContributionIdentity { + principal: registration.principal, + contribution_id: AppExtensionContributionId("github-pr".to_string()), + } + ); + server.await.unwrap(); + } + + #[test] + fn registration_rejects_empty_runtime_principal_identity() { + let result: WireRegisterResult = serde_json::from_value(json!({ + "protocolVersion": 1, + "principal": { + "packageId": "", + "activationId": "activation-7" + }, + "capabilities": { + "sessionBadges": true + }, + "contributions": [{ + "contributionPoint": "sessionBadges", + "contributionId": "github-pr" + }] + })) + .unwrap(); + + assert!(parse_registration(result).is_err()); + } + + #[test] + fn registration_rejects_capabilities_as_contribution_points() { + let result: WireRegisterResult = serde_json::from_value(json!({ + "protocolVersion": 1, + "principal": { + "packageId": "package", + "activationId": "activation" + }, + "capabilities": { + "forgeProvider": true, + "mediatedFetch": true + }, + "contributions": [{ + "contributionPoint": "mediatedFetch", + "contributionId": "fetch" + }] + })) + .unwrap(); + + assert!(parse_registration(result).is_err()); + } + + #[test] + fn session_badges_identity_requires_one_trusted_declaration() { + let registration = AppExtensionRegistration { + principal: AppExtensionPrincipal { + package_id: AppExtensionPackageId("package".to_string()), + activation_id: AppExtensionActivationId("activation".to_string()), + }, + capabilities: AppExtensionCapabilityGrants { + session_badges: true, + canvases: false, + forge_provider: false, + mediated_fetch: false, + }, + contributions: vec![], + }; + assert!(registration.session_badges_identity().is_err()); + + let declaration = AppExtensionDeclaredContribution { + contribution_point: AppExtensionContributionPoint::SessionBadges, + contribution_id: AppExtensionContributionId("github-pr".to_string()), + }; + let registration = AppExtensionRegistration { + contributions: vec![declaration.clone(), declaration], + ..registration + }; + assert!(registration.session_badges_identity().is_err()); + } + + #[tokio::test] + async fn app_host_routes_canvas_and_forge_requests_with_trusted_identity() { + let (client_write, mut server_read) = duplex(16384); + let (mut server_write, client_read) = duplex(16384); + let client = + Client::from_streams(client_read, client_write, PathBuf::from(r"C:\src")).unwrap(); + let host = AppExtensionsHost { + client, + app_session_id: SessionId::new("hidden-app-session"), + }; + let principal = AppExtensionPrincipal::new("package", "activation"); + let canvas = AppCanvasTarget::new( + principal.clone(), + AppExtensionContributionId::new("repository-overview"), + ); + let forge = AppForgeProviderTarget::new( + principal.clone(), + AppExtensionContributionId::new("github"), + ); + let badges = AppExtensionContributionIdentity { + principal, + contribution_id: AppExtensionContributionId::new("github-pr"), + }; + let server = tokio::spawn(async move { + let open = read_framed(&mut server_read).await; + assert_eq!(open["method"], "extensions.appCanvas.open"); + assert_eq!( + open["params"], + json!({ + "appSessionId": "hidden-app-session", + "protocolVersion": 1, + "packageId": "package", + "activationId": "activation", + "contributionId": "repository-overview", + "instanceId": "canvas-1", + "input": {"tab": "pulls"} + }) + ); + write_framed( + &mut server_write, + &json!({ + "jsonrpc": "2.0", + "id": open["id"], + "result": { + "state": {"selected": 1}, + "title": "Repository", + "status": "Ready" + } + }), + ) + .await; + + let action = read_framed(&mut server_read).await; + assert_eq!(action["method"], "extensions.appCanvas.action.invoke"); + assert_eq!(action["params"]["appSessionId"], "hidden-app-session"); + assert_eq!(action["params"]["actionName"], "select"); + write_framed( + &mut server_write, + &json!({ + "jsonrpc": "2.0", + "id": action["id"], + "result": {"selected": 2} + }), + ) + .await; + + let close = read_framed(&mut server_read).await; + assert_eq!(close["method"], "extensions.appCanvas.close"); + assert_eq!(close["params"]["instanceId"], "canvas-1"); + write_framed( + &mut server_write, + &json!({"jsonrpc": "2.0", "id": close["id"], "result": null}), + ) + .await; + + let invoke = read_framed(&mut server_read).await; + assert_eq!(invoke["method"], "extensions.appForge.invoke"); + assert_eq!( + invoke["params"], + json!({ + "appSessionId": "hidden-app-session", + "protocolVersion": 1, + "packageId": "package", + "activationId": "activation", + "contributionId": "github", + "operation": "getPullRequest", + "accountId": "account-1", + "input": {"number": 2574} + }) + ); + write_framed( + &mut server_write, + &json!({ + "jsonrpc": "2.0", + "id": invoke["id"], + "result": {"number": 2574} + }), + ) + .await; + + let create_pr = read_framed(&mut server_read).await; + assert_eq!( + create_pr["method"], + "extensions.appSessionBadges.action.invoke" + ); + assert_eq!( + create_pr["params"], + json!({ + "appSessionId": "hidden-app-session", + "protocolVersion": 1, + "packageId": "package", + "activationId": "activation", + "contributionId": "github-pr", + "target": { + "workspaceId": "workspace-1", + "sessionId": "product-session-1", + "repositoryPath": r"C:\src\repo", + "worktreePath": r"C:\src\worktree", + "branch": "feature" + }, + "action": { + "kind": "createPullRequest", + "draft": true + } + }) + ); + write_framed( + &mut server_write, + &json!({ + "jsonrpc": "2.0", + "id": create_pr["id"], + "result": { + "prompt": "# Pull Request Creation\nCreate the fake pull request.", + "requiredTool": "create_ado_pull_request" + } + }), + ) + .await; + }); + + let opened = host + .open_canvas(AppCanvasOpenRequest { + target: canvas.clone(), + instance_id: "canvas-1".to_string(), + input: Some(json!({"tab": "pulls"})), + context: None, + }) + .await + .unwrap(); + assert_eq!(opened.state, Some(json!({"selected": 1}))); + assert_eq!(opened.title.as_deref(), Some("Repository")); + assert_eq!(opened.status.as_deref(), Some("Ready")); + assert_eq!( + host.invoke_canvas_action(AppCanvasActionRequest { + target: canvas.clone(), + instance_id: "canvas-1".to_string(), + action_name: "select".to_string(), + input: Some(json!({"number": 2})), + context: None, + }) + .await + .unwrap(), + json!({"selected": 2}) + ); + host.close_canvas(AppCanvasCloseRequest { + target: canvas, + instance_id: "canvas-1".to_string(), + context: None, + }) + .await + .unwrap(); + assert_eq!( + host.invoke_forge_provider(AppForgeInvokeRequest { + target: forge, + operation: "getPullRequest".to_string(), + account_id: Some("account-1".to_string()), + input: Some(json!({"number": 2574})), + }) + .await + .unwrap(), + json!({"number": 2574}) + ); + let action = host + .invoke_session_badge_action(AppSessionBadgeActionRequest { + target: badges, + session: AppSessionPresentationTarget { + branch: Some("feature".to_string()), + repository_path: r"C:\src\repo".to_string(), + session_id: SessionId::new("product-session-1"), + workspace_id: "workspace-1".to_string(), + worktree_path: r"C:\src\worktree".to_string(), + }, + draft: true, + }) + .await + .unwrap() + .unwrap(); + assert_eq!( + action.prompt, + "# Pull Request Creation\nCreate the fake pull request." + ); + assert_eq!(action.required_tool, "create_ado_pull_request"); + server.await.unwrap(); + } + + #[test] + fn session_action_result_validation_rejects_malformed_values() { + assert!( + validate_session_action_result(&AppSessionActionResult { + prompt: "Missing header".to_string(), + required_tool: "create_ado_pull_request".to_string(), + }) + .is_err() + ); + assert!( + validate_session_action_result(&AppSessionActionResult { + prompt: "# Pull Request Creation\nCreate it.".to_string(), + required_tool: "invalid tool".to_string(), + }) + .is_err() + ); + assert!( + validate_session_action_result(&AppSessionActionResult { + prompt: format!( + "# Pull Request Creation\n{}", + "x".repeat(MAX_ACTION_PROMPT_BYTES) + ), + required_tool: "create_ado_pull_request".to_string(), + }) + .is_err() + ); + } + + struct EchoMediatedFetch; + + #[async_trait] + impl AppMediatedFetchHandler for EchoMediatedFetch { + async fn fetch( + &self, + request: AppMediatedFetchRequest, + ) -> Result { + assert_eq!(request.principal.package_id().as_str(), "package"); + assert_eq!(request.principal.activation_id().as_str(), "activation"); + assert_eq!(request.contribution_id.as_str(), "github"); + assert_eq!(request.account_id, "account-1"); + assert_eq!(request.operation, "getPullRequest"); + assert_eq!(request.request.path, "/repos/github/copilot-sdk/pulls/2574"); + Ok(AppMediatedFetchResponse { + body: Some(r#"{"number":2574}"#.to_string()), + headers: HashMap::from([( + "content-type".to_string(), + "application/json".to_string(), + )]), + status: 200, + truncated: false, + }) + } + } + + #[tokio::test] + async fn mediated_fetch_dispatch_rejects_bad_versions_and_recovers() { + let (client_write, mut server_read) = duplex(16384); + let (_server_write, client_read) = duplex(16384); + let client = + Client::from_streams(client_read, client_write, PathBuf::from(r"C:\src")).unwrap(); + let handler: Arc = Arc::new(EchoMediatedFetch); + let params = json!({ + "sessionId": "hidden-app-session", + "protocolVersion": 2, + "packageId": "package", + "activationId": "activation", + "contributionId": "github", + "accountId": "account-1", + "operation": "getPullRequest", + "request": { + "method": "GET", + "path": "/repos/github/copilot-sdk/pulls/2574" + } + }); + + assert!( + dispatch_mediated_fetch( + &client, + Some(&handler), + request(1, rpc_methods::APPFORGE_FETCH, params.clone()), + ) + .await + ); + let invalid = read_framed(&mut server_read).await; + assert_eq!(invalid["error"]["code"], error_codes::INVALID_PARAMS); + + let mut valid_params = params; + valid_params["protocolVersion"] = json!(1); + valid_params["request"]["path"] = json!("https://api.github.com/user"); + assert!( + dispatch_mediated_fetch( + &client, + Some(&handler), + request(2, rpc_methods::APPFORGE_FETCH, valid_params.clone()), + ) + .await + ); + let invalid_path = read_framed(&mut server_read).await; + assert_eq!(invalid_path["error"]["code"], error_codes::INVALID_PARAMS); + + valid_params["request"]["path"] = json!("/repos/github/copilot-sdk/pulls/2574"); + valid_params["request"]["headers"] = json!({"Authorization": "secret"}); + assert!( + dispatch_mediated_fetch( + &client, + Some(&handler), + request(3, rpc_methods::APPFORGE_FETCH, valid_params.clone()), + ) + .await + ); + let invalid_header = read_framed(&mut server_read).await; + assert_eq!(invalid_header["error"]["code"], error_codes::INVALID_PARAMS); + + valid_params["request"] + .as_object_mut() + .unwrap() + .remove("headers"); + assert!( + dispatch_mediated_fetch( + &client, + Some(&handler), + request(4, rpc_methods::APPFORGE_FETCH, valid_params), + ) + .await + ); + let valid = read_framed(&mut server_read).await; + assert_eq!( + valid["result"], + json!({ + "status": 200, + "headers": {"content-type": "application/json"}, + "body": "{\"number\":2574}", + "truncated": false + }) + ); + } +} diff --git a/rust/src/app_session_badges.rs b/rust/src/app_session_badges.rs new file mode 100644 index 0000000000..13f379bc6e --- /dev/null +++ b/rust/src/app_session_badges.rs @@ -0,0 +1,696 @@ +//! App-level executable-extension badge protocol. +//! +//! The app owns hidden-session lifecycle, eligible-session filtering, and +//! native GitHub badge precedence. This module only exposes typed v1 transport +//! wrappers and provider-attributed event decoding. + +use std::fmt; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::session::Session; +use crate::subscription::{EventSubscription, RecvError}; +use crate::types::{SessionEvent, SessionId}; +use crate::{Client, Error, ErrorKind}; + +const PROTOCOL_VERSION: u8 = 1; +const UPDATE_SNAPSHOT_METHOD: &str = "extensions.appSessionBadges.updateSnapshot"; +const BADGE_CHANGED_EVENT: &str = "session.extensions.app_session_badge_changed"; +const PRESENTATION_CHANGED_EVENT: &str = "session.extensions.app_session_presentation_changed"; + +/// Constrained visual state for an extension-provided workspace badge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AppSessionBadgeState { + /// Draft pull request semantics. + Draft, + /// Open pull request semantics. + Open, + /// Merged pull request semantics. + Merged, + /// Closed pull request semantics. + Closed, +} + +/// Constrained badge presentation supplied by an executable extension. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSessionBadge { + /// Host-defined icon semantics. + pub state: AppSessionBadgeState, + /// Optional text label displayed with the constrained state. + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +/// Availability state for a contributed Create Pull Request action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AppSessionPullRequestActionState { + /// The action can be selected. + Available, + /// The extension is currently handling the action. + InProgress, +} + +/// Constrained Create Pull Request action presentation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSessionPullRequestAction { + kind: AppSessionPullRequestActionKind, + /// Current action availability. + pub state: AppSessionPullRequestActionState, + /// Whether the extension supports a draft choice. + pub supports_draft: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum AppSessionPullRequestActionKind { + CreatePullRequest, +} + +impl AppSessionPullRequestAction { + /// Create a Create Pull Request action presentation. + pub fn new(state: AppSessionPullRequestActionState, supports_draft: bool) -> Self { + Self { + kind: AppSessionPullRequestActionKind::CreatePullRequest, + state, + supports_draft, + } + } +} + +/// Atomic badge and Create Pull Request action presentation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSessionPresentation { + /// Constrained badge, or `None` to clear only the badge. + pub badge: Option, + /// Create Pull Request action, or `None` to clear only the action. + pub action: Option, +} + +impl AppSessionBadge { + /// Create a badge with no text label. + pub fn new(state: AppSessionBadgeState) -> Self { + Self { state, label: None } + } + + /// Set the optional badge label. + pub fn with_label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } +} + +/// Stable identity and paths for an app-visible badge target. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSessionBadgeTarget { + /// Stable app workspace/sidebar identity. + pub workspace_id: String, + /// Active app session linked to the workspace. + pub session_id: String, + /// Repository root path known to the app. + pub repository_path: PathBuf, + /// Worktree path for the active workspace. + pub worktree_path: PathBuf, + /// Current branch name, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, +} + +impl AppSessionBadgeTarget { + /// Create an eligible app-visible badge target. + pub fn new( + workspace_id: impl Into, + session_id: impl Into, + repository_path: impl Into, + worktree_path: impl Into, + ) -> Self { + Self { + workspace_id: workspace_id.into(), + session_id: session_id.into(), + repository_path: repository_path.into(), + worktree_path: worktree_path.into(), + branch: None, + } + } + + /// Set the current branch name. + pub fn with_branch(mut self, branch: impl Into) -> Self { + self.branch = Some(branch.into()); + self + } +} + +/// Full replacement snapshot of sessions eligible for extension-provided badges. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSessionBadgesSnapshot { + protocol_version: u8, + /// Monotonically increasing app-owned snapshot revision. + pub revision: u64, + /// Complete eligible-session replacement set. + pub sessions: Vec, +} + +impl AppSessionBadgesSnapshot { + /// Create a v1 full replacement snapshot. + pub fn new(revision: u64, sessions: Vec) -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + revision, + sessions, + } + } +} + +/// Provider-attributed badge change emitted on the retained hidden session. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSessionBadgeChanged { + protocol_version: u8, + /// Stable runtime extension identity that published the change. + pub extension_id: String, + /// Stable app workspace/sidebar identity. + pub workspace_id: String, + /// Active app session linked to the workspace. + pub session_id: String, + /// New badge, or `None` when the provider state was cleared. + pub badge: Option, +} + +/// Authenticated provider presentation update emitted on the retained hidden session. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSessionPresentationChanged { + protocol_version: u8, + /// Legacy runtime extension identity retained for compatibility. + pub extension_id: String, + /// Runtime-authenticated package identity. + pub package_id: String, + /// Runtime-authenticated activation identity. + pub activation_id: String, + /// Runtime-authenticated contribution identity. + pub contribution_id: String, + /// Stable app workspace/sidebar identity. + pub workspace_id: String, + /// Active app session linked to the workspace. + pub session_id: String, + /// New presentation, or `None` when provider lifecycle state was reset. + pub presentation: Option, +} + +impl AppSessionPresentationChanged { + /// Protocol version carried by the event. + pub fn protocol_version(&self) -> u8 { + self.protocol_version + } +} + +impl AppSessionBadgeChanged { + /// Protocol version carried by the event. + pub fn protocol_version(&self) -> u8 { + self.protocol_version + } +} + +/// Error returned while decoding an app-session badge event. +#[derive(Debug)] +pub struct AppSessionBadgeDecodeError { + message: String, +} + +impl fmt::Display for AppSessionBadgeDecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for AppSessionBadgeDecodeError {} + +/// Decode a generic hidden-session event when it is an app-session badge change. +pub fn decode_app_session_badge_changed( + event: &SessionEvent, +) -> Result, AppSessionBadgeDecodeError> { + if event.event_type != BADGE_CHANGED_EVENT { + return Ok(None); + } + let changed: AppSessionBadgeChanged = + serde_json::from_value(event.data.clone()).map_err(|error| AppSessionBadgeDecodeError { + message: format!("invalid {BADGE_CHANGED_EVENT} payload: {error}"), + })?; + if changed.protocol_version != PROTOCOL_VERSION { + return Err(AppSessionBadgeDecodeError { + message: format!( + "unsupported app session badges protocol version: {}", + changed.protocol_version + ), + }); + } + validate_non_empty(&changed.extension_id, "extensionId") + .and_then(|_| validate_non_empty(&changed.workspace_id, "workspaceId")) + .and_then(|_| validate_non_empty(&changed.session_id, "sessionId")) + .map_err(|error| AppSessionBadgeDecodeError { + message: error.to_string(), + })?; + Ok(Some(changed)) +} + +/// Decode a generic hidden-session event when it is an app-session presentation change. +pub fn decode_app_session_presentation_changed( + event: &SessionEvent, +) -> Result, AppSessionBadgeDecodeError> { + if event.event_type != PRESENTATION_CHANGED_EVENT { + return Ok(None); + } + let changed: AppSessionPresentationChanged = serde_json::from_value(event.data.clone()) + .map_err(|error| AppSessionBadgeDecodeError { + message: format!("invalid {PRESENTATION_CHANGED_EVENT} payload: {error}"), + })?; + if changed.protocol_version != PROTOCOL_VERSION { + return Err(AppSessionBadgeDecodeError { + message: format!( + "unsupported app session badges protocol version: {}", + changed.protocol_version + ), + }); + } + for (value, name) in [ + (&changed.extension_id, "extensionId"), + (&changed.package_id, "packageId"), + (&changed.activation_id, "activationId"), + (&changed.contribution_id, "contributionId"), + (&changed.workspace_id, "workspaceId"), + (&changed.session_id, "sessionId"), + ] { + validate_non_empty(value, name).map_err(|error| AppSessionBadgeDecodeError { + message: error.to_string(), + })?; + } + if changed + .presentation + .as_ref() + .and_then(|presentation| presentation.badge.as_ref()) + .and_then(|badge| badge.label.as_ref()) + .is_some_and(|label| label.len() > 512) + { + return Err(AppSessionBadgeDecodeError { + message: "badge.label must be at most 512 bytes".to_string(), + }); + } + Ok(Some(changed)) +} + +/// Receive error for a typed app-session badge event subscription. +#[derive(Debug)] +pub enum AppSessionBadgeSubscriptionError { + /// The underlying hidden-session subscription closed or lagged. + Receive(RecvError), + /// A matching event carried an invalid v1 payload. + Decode(AppSessionBadgeDecodeError), +} + +impl fmt::Display for AppSessionBadgeSubscriptionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Receive(error) => error.fmt(f), + Self::Decode(error) => error.fmt(f), + } + } +} + +impl std::error::Error for AppSessionBadgeSubscriptionError {} + +/// Typed subscription that skips unrelated hidden-session events. +pub struct AppSessionBadgeSubscription { + inner: EventSubscription, +} + +/// Typed subscription for authenticated app-session presentation updates. +pub struct AppSessionPresentationSubscription { + inner: EventSubscription, +} + +impl AppSessionPresentationSubscription { + /// Receive the next authenticated presentation update. + pub async fn recv( + &mut self, + ) -> Result { + loop { + let event = self + .inner + .recv() + .await + .map_err(AppSessionBadgeSubscriptionError::Receive)?; + match decode_app_session_presentation_changed(&event) + .map_err(AppSessionBadgeSubscriptionError::Decode)? + { + Some(changed) => return Ok(changed), + None => continue, + } + } + } +} + +impl AppSessionBadgeSubscription { + /// Receive the next provider-attributed badge change. + pub async fn recv( + &mut self, + ) -> Result { + loop { + let event = self + .inner + .recv() + .await + .map_err(AppSessionBadgeSubscriptionError::Receive)?; + match decode_app_session_badge_changed(&event) + .map_err(AppSessionBadgeSubscriptionError::Decode)? + { + Some(changed) => return Ok(changed), + None => continue, + } + } + } +} + +/// Host-side controller for publishing app-curated eligible-session snapshots. +#[derive(Clone)] +pub struct AppSessionBadgesHost { + client: Client, + app_session_id: SessionId, +} + +impl AppSessionBadgesHost { + /// Replace the runtime's complete eligible-session snapshot. + pub async fn update_snapshot(&self, snapshot: AppSessionBadgesSnapshot) -> Result<(), Error> { + validate_snapshot(&snapshot)?; + let params = snapshot_params(&self.app_session_id, snapshot)?; + self.client + .call(UPDATE_SNAPSHOT_METHOD, Some(params)) + .await?; + Ok(()) + } +} + +impl Session { + /// Create a host controller for snapshots associated with this retained session. + pub fn app_session_badges(&self) -> AppSessionBadgesHost { + AppSessionBadgesHost { + client: self.client().clone(), + app_session_id: self.id().clone(), + } + } + + /// Subscribe to provider-attributed badge changes on this hidden session. + pub fn subscribe_app_session_badges(&self) -> AppSessionBadgeSubscription { + AppSessionBadgeSubscription { + inner: self.subscribe(), + } + } + + /// Subscribe to authenticated provider presentation updates on this hidden session. + pub fn subscribe_app_session_presentations(&self) -> AppSessionPresentationSubscription { + AppSessionPresentationSubscription { + inner: self.subscribe(), + } + } +} + +fn validate_snapshot(snapshot: &AppSessionBadgesSnapshot) -> Result<(), Error> { + let mut target_ids = std::collections::HashSet::new(); + for target in &snapshot.sessions { + validate_non_empty(&target.workspace_id, "workspaceId")?; + validate_non_empty(&target.session_id, "sessionId")?; + if target.repository_path.as_os_str().is_empty() { + return Err(invalid_config( + "repositoryPath must be a non-empty path".to_string(), + )); + } + if target.worktree_path.as_os_str().is_empty() { + return Err(invalid_config( + "worktreePath must be a non-empty path".to_string(), + )); + } + if !target_ids.insert((&target.workspace_id, &target.session_id)) { + return Err(invalid_config(format!( + "duplicate app session badge target: {}/{}", + target.workspace_id, target.session_id + ))); + } + } + Ok(()) +} + +fn snapshot_params( + app_session_id: &SessionId, + snapshot: AppSessionBadgesSnapshot, +) -> Result { + let mut params = serde_json::to_value(snapshot)?; + params["appSessionId"] = serde_json::to_value(app_session_id)?; + Ok(params) +} + +fn validate_non_empty(value: &str, name: &str) -> Result<(), Error> { + if value.is_empty() { + return Err(invalid_config(format!("{name} must be a non-empty string"))); + } + Ok(()) +} + +fn invalid_config(message: String) -> Error { + Error::with_message(ErrorKind::InvalidConfig, message) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use serde_json::{Value, json}; + use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, duplex}; + + use super::*; + + async fn read_framed(reader: &mut (impl AsyncRead + Unpin)) -> Value { + let mut header = String::new(); + loop { + let mut byte = [0u8; 1]; + reader.read_exact(&mut byte).await.unwrap(); + header.push(byte[0] as char); + if header.ends_with("\r\n\r\n") { + break; + } + } + let length = header + .trim() + .strip_prefix("Content-Length: ") + .unwrap() + .parse() + .unwrap(); + let mut body = vec![0; length]; + reader.read_exact(&mut body).await.unwrap(); + serde_json::from_slice(&body).unwrap() + } + + async fn write_framed(writer: &mut (impl AsyncWrite + Unpin), value: &Value) { + let body = serde_json::to_vec(value).unwrap(); + writer + .write_all(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes()) + .await + .unwrap(); + writer.write_all(&body).await.unwrap(); + writer.flush().await.unwrap(); + } + + #[test] + fn snapshot_serializes_as_the_exact_v1_wire_shape() { + let snapshot = AppSessionBadgesSnapshot::new( + 7, + vec![ + AppSessionBadgeTarget::new( + "workspace-1", + "session-1", + r"C:\src\repo", + r"C:\src\worktree", + ) + .with_branch("feature"), + ], + ); + + assert_eq!( + serde_json::to_value(snapshot).unwrap(), + json!({ + "protocolVersion": 1, + "revision": 7, + "sessions": [{ + "workspaceId": "workspace-1", + "sessionId": "session-1", + "repositoryPath": r"C:\src\repo", + "worktreePath": r"C:\src\worktree", + "branch": "feature" + }] + }) + ); + } + + #[tokio::test] + async fn host_sends_the_exact_snapshot_rpc() { + let (client_write, mut server_read) = duplex(8192); + let (mut server_write, client_read) = duplex(8192); + let client = + Client::from_streams(client_read, client_write, PathBuf::from(r"C:\src")).unwrap(); + let server = tokio::spawn(async move { + let request = read_framed(&mut server_read).await; + assert_eq!( + request, + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "extensions.appSessionBadges.updateSnapshot", + "params": { + "appSessionId": "hidden-session", + "protocolVersion": 1, + "revision": 2, + "sessions": [] + } + }) + ); + write_framed( + &mut server_write, + &json!({"jsonrpc": "2.0", "id": request["id"], "result": null}), + ) + .await; + }); + + AppSessionBadgesHost { + client, + app_session_id: SessionId::new("hidden-session"), + } + .update_snapshot(AppSessionBadgesSnapshot::new(2, Vec::new())) + .await + .unwrap(); + server.await.unwrap(); + } + + #[test] + fn decoder_accepts_badges_and_null_clears() { + let event = session_event(json!({ + "protocolVersion": 1, + "extensionId": "project:badges", + "workspaceId": "workspace-1", + "sessionId": "session-1", + "badge": { + "state": "merged", + "label": "Merged" + } + })); + let changed = decode_app_session_badge_changed(&event).unwrap().unwrap(); + assert_eq!(changed.protocol_version(), 1); + assert_eq!(changed.extension_id, "project:badges"); + assert_eq!( + changed.badge, + Some(AppSessionBadge::new(AppSessionBadgeState::Merged).with_label("Merged")) + ); + + let clear = session_event(json!({ + "protocolVersion": 1, + "extensionId": "project:badges", + "workspaceId": "workspace-1", + "sessionId": "session-1", + "badge": null + })); + assert_eq!( + decode_app_session_badge_changed(&clear) + .unwrap() + .unwrap() + .badge, + None + ); + } + + #[test] + fn presentation_decoder_preserves_authenticated_identity_and_reset() { + let mut event = session_event(json!({ + "protocolVersion": 1, + "extensionId": "project:badges", + "packageId": "package", + "activationId": "activation-7", + "contributionId": "github-pr", + "workspaceId": "workspace-1", + "sessionId": "session-1", + "presentation": { + "badge": {"state": "draft", "label": "Draft"}, + "action": { + "kind": "createPullRequest", + "state": "available", + "supportsDraft": true + } + } + })); + event.event_type = PRESENTATION_CHANGED_EVENT.to_string(); + let changed = decode_app_session_presentation_changed(&event) + .unwrap() + .unwrap(); + assert_eq!(changed.protocol_version(), 1); + assert_eq!(changed.package_id, "package"); + assert_eq!(changed.activation_id, "activation-7"); + assert_eq!(changed.contribution_id, "github-pr"); + assert_eq!( + changed.presentation, + Some(AppSessionPresentation { + badge: Some(AppSessionBadge::new(AppSessionBadgeState::Draft).with_label("Draft")), + action: Some(AppSessionPullRequestAction::new( + AppSessionPullRequestActionState::Available, + true, + )), + }) + ); + + event.data["presentation"] = Value::Null; + assert_eq!( + decode_app_session_presentation_changed(&event) + .unwrap() + .unwrap() + .presentation, + None + ); + } + + #[test] + fn decoder_skips_other_events_and_rejects_other_versions() { + let mut event = session_event(json!({})); + event.event_type = "session.idle".to_string(); + assert!(decode_app_session_badge_changed(&event).unwrap().is_none()); + + let invalid = session_event(json!({ + "protocolVersion": 2, + "extensionId": "project:badges", + "workspaceId": "workspace-1", + "sessionId": "session-1", + "badge": null + })); + assert!( + decode_app_session_badge_changed(&invalid) + .unwrap_err() + .to_string() + .contains("unsupported app session badges protocol version") + ); + } + + fn session_event(data: Value) -> SessionEvent { + SessionEvent { + id: "event-1".to_string(), + timestamp: "2026-09-04T00:00:00Z".to_string(), + parent_id: None, + ephemeral: Some(true), + agent_id: None, + debug_cli_received_at_ms: None, + debug_ws_forwarded_at_ms: None, + event_type: BADGE_CHANGED_EVENT.to_string(), + data, + } + } +} diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 1bd83a50f7..3f2d9480da 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -68,6 +68,35 @@ pub mod rpc_methods { pub const EXTENSIONS_ENABLE: &str = "extensions.enable"; /// `extensions.disable` pub const EXTENSIONS_DISABLE: &str = "extensions.disable"; + /// `extensions.appExtension.register` + pub const EXTENSIONS_APPEXTENSION_REGISTER: &str = "extensions.appExtension.register"; + /// `extensions.appSessionBadges.setPresentation` + pub const EXTENSIONS_APPSESSIONBADGES_SETPRESENTATION: &str = + "extensions.appSessionBadges.setPresentation"; + /// `extensions.appSessionBadges.setPresentations` + pub const EXTENSIONS_APPSESSIONBADGES_SETPRESENTATIONS: &str = + "extensions.appSessionBadges.setPresentations"; + /// `extensions.appSessionBadges.action.invoke` + pub const EXTENSIONS_APPSESSIONBADGES_ACTION_INVOKE: &str = + "extensions.appSessionBadges.action.invoke"; + /// `extensions.appCanvas.register` + pub const EXTENSIONS_APPCANVAS_REGISTER: &str = "extensions.appCanvas.register"; + /// `extensions.appCanvas.unregister` + pub const EXTENSIONS_APPCANVAS_UNREGISTER: &str = "extensions.appCanvas.unregister"; + /// `extensions.appCanvas.open` + pub const EXTENSIONS_APPCANVAS_OPEN: &str = "extensions.appCanvas.open"; + /// `extensions.appCanvas.action.invoke` + pub const EXTENSIONS_APPCANVAS_ACTION_INVOKE: &str = "extensions.appCanvas.action.invoke"; + /// `extensions.appCanvas.close` + pub const EXTENSIONS_APPCANVAS_CLOSE: &str = "extensions.appCanvas.close"; + /// `extensions.appForge.register` + pub const EXTENSIONS_APPFORGE_REGISTER: &str = "extensions.appForge.register"; + /// `extensions.appForge.unregister` + pub const EXTENSIONS_APPFORGE_UNREGISTER: &str = "extensions.appForge.unregister"; + /// `extensions.appForge.invoke` + pub const EXTENSIONS_APPFORGE_INVOKE: &str = "extensions.appForge.invoke"; + /// `extensions.appForge.fetch` + pub const EXTENSIONS_APPFORGE_FETCH: &str = "extensions.appForge.fetch"; /// `registerExtensionLaunchProvider` pub const REGISTEREXTENSIONLAUNCHPROVIDER: &str = "registerExtensionLaunchProvider"; /// `catalog.search` @@ -795,6 +824,18 @@ pub mod rpc_methods { pub const CANVAS_CLOSE: &str = "canvas.close"; /// `canvas.action.invoke` pub const CANVAS_ACTION_INVOKE: &str = "canvas.action.invoke"; + /// `appSessionBadges.action.invoke` + pub const APPSESSIONBADGES_ACTION_INVOKE: &str = "appSessionBadges.action.invoke"; + /// `appCanvas.open` + pub const APPCANVAS_OPEN: &str = "appCanvas.open"; + /// `appCanvas.close` + pub const APPCANVAS_CLOSE: &str = "appCanvas.close"; + /// `appCanvas.action.invoke` + pub const APPCANVAS_ACTION_INVOKE: &str = "appCanvas.action.invoke"; + /// `appForge.invoke` + pub const APPFORGE_INVOKE: &str = "appForge.invoke"; + /// `appForge.fetch` + pub const APPFORGE_FETCH: &str = "appForge.fetch"; } /// Parameters for aborting the current turn @@ -22088,6 +22129,658 @@ pub struct WorkspacesWriteAutopilotObjectiveResult { pub operation: String, } +/// Private app-extension activation handshake. Identity is derived from trusted runtime connection metadata and is never accepted from request parameters. +/// +///
+/// +/// **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 AppExtensionRegisterRequest { + pub protocol_version: serde_json::Value, +} + +/// Opaque runtime-authenticated identity for one allowlisted app-extension 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(crate) struct AppExtensionPrincipal { + pub activation_id: String, + pub package_id: String, +} + +/// Capability grants bound to an authenticated app-extension principal. Keys are present only when granted. +/// +///
+/// +/// **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 AppExtensionCapabilities { + #[serde(skip_serializing_if = "Option::is_none")] + pub canvases: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub forge_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mediated_fetch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_badges: Option, +} + +/// Runtime-authenticated identity of one statically declared app-extension contribution. +/// +///
+/// +/// **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 AppExtensionDeclaredContribution { + pub contribution_id: String, + #[doc(hidden)] + pub(crate) contribution_point: AppExtensionContributionPoint, +} + +/// Authenticated principal and capability grants for one private app-extension 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(crate) struct AppExtensionRegisterResult { + #[doc(hidden)] + pub(crate) capabilities: AppExtensionCapabilities, + #[doc(hidden)] + pub(crate) contributions: Vec, + #[doc(hidden)] + pub(crate) principal: AppExtensionPrincipal, + pub protocol_version: serde_json::Value, +} + +/// Registers or unregisters one runtime-authenticated app-extension contribution on its owning 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(crate) struct AppExtensionContributionRegistrationRequest { + pub contribution_id: String, + pub protocol_version: serde_json::Value, +} + +/// Registers one runtime-authenticated forge-provider contribution and its supported operation names. +/// +///
+/// +/// **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 AppForgeRegisterRequest { + pub contribution_id: String, + pub operations: Vec, + pub protocol_version: serde_json::Value, +} + +/// Stable runtime-authenticated app-extension contribution identity used by a trusted app host. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AppExtensionContributionTarget { + pub activation_id: String, + pub contribution_id: String, + pub package_id: String, +} + +/// Exact app-visible session target from the current eligible-session snapshot. +/// +///
+/// +/// **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 AppSessionPresentationTarget { + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + pub repository_path: String, + pub session_id: SessionId, + pub workspace_id: String, + pub worktree_path: String, +} + +/// Constrained pull-request identity presentation contributed for one eligible app 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 AppSessionBadgePresentation { + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + pub state: AppSessionBadgePresentationState, +} + +/// Constrained Create Pull Request action state contributed for one eligible app 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 AppSessionPullRequestAction { + pub kind: AppSessionPullRequestActionKind, + pub state: AppSessionPullRequestActionState, + pub supports_draft: bool, +} + +/// Atomic extension-provided badge and Create Pull Request action presentation. +/// +///
+/// +/// **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 AppSessionPresentation { + pub action: Option, + pub badge: Option, +} + +/// One ordered atomic presentation replacement for an eligible app 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 AppSessionPresentationUpdate { + pub presentation: AppSessionPresentation, + pub session_id: SessionId, + pub workspace_id: String, +} + +/// Publishes one atomic badge and action presentation for an eligible app 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(crate) struct AppSessionSetPresentationRequest { + pub presentation: AppSessionPresentation, + pub protocol_version: serde_json::Value, + pub session_id: SessionId, + pub workspace_id: String, +} + +/// Publishes an ordered batch of atomic badge and action presentations. +/// +///
+/// +/// **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 AppSessionSetPresentationsRequest { + pub protocol_version: serde_json::Value, + pub updates: Vec, +} + +/// Create Pull Request action selected by the app 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 AppSessionPullRequestActionInvocation { + pub draft: bool, + pub kind: AppSessionPullRequestActionInvocationKind, +} + +/// Create Pull Request callback routed to the owning app extension. +/// +///
+/// +/// **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 AppSessionActionCallbackRequest { + pub action: AppSessionPullRequestActionInvocation, + pub contribution_id: String, + pub protocol_version: serde_json::Value, + pub session_id: SessionId, + pub target: AppSessionPresentationTarget, +} + +/// Bounded extension-authored prompt and required session tool for a Create Pull Request action. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSessionActionResult { + pub prompt: String, + pub required_tool: String, +} + +/// Trusted app-host request to invoke a Create Pull Request action on its owning extension contribution. +/// +///
+/// +/// **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 AppSessionActionHostRequest { + pub action: AppSessionPullRequestActionInvocation, + pub activation_id: String, + pub app_session_id: String, + pub contribution_id: String, + pub package_id: String, + pub protocol_version: serde_json::Value, + pub target: AppSessionPresentationTarget, +} + +/// Authenticated provider presentation update emitted on the retained hidden app session. A null presentation resets provider state. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSessionPresentationChangedEventData { + pub activation_id: String, + pub contribution_id: String, + pub extension_id: String, + pub package_id: String, + pub presentation: Option, + pub protocol_version: serde_json::Value, + pub session_id: SessionId, + pub workspace_id: String, +} + +/// Bounded generic action descriptor rendered by the trusted app 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 AppCanvasActionDescriptor { + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled: Option, + /// Serializable action input returned when the action is selected. + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + pub label: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub variant: Option, +} + +/// Trusted project context supplied by the app host to an app-scoped canvas. +/// +///
+/// +/// **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 AppCanvasProjectContext { + #[serde(skip_serializing_if = "Option::is_none")] + pub forge_account_id: Option, + pub forge_provider_id: String, + /// Opaque versioned repository locator interpreted only by the owning forge provider. + pub repository_locator: serde_json::Value, +} + +/// Optional trusted app context for one canvas instance. The hidden control-session identity is never exposed. +/// +///
+/// +/// **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 AppCanvasContext { + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, +} + +/// App-canvas open callback routed to the owning 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 AppCanvasOpenCallbackRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + pub contribution_id: String, + /// Serializable canvas input. + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + pub instance_id: String, + pub protocol_version: serde_json::Value, + pub session_id: SessionId, +} + +/// App-canvas action callback routed to the owning 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 AppCanvasActionCallbackRequest { + pub action_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + pub contribution_id: String, + /// Serializable action input. + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + pub instance_id: String, + pub protocol_version: serde_json::Value, + pub session_id: SessionId, +} + +/// App-canvas close callback routed to the owning 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 AppCanvasCloseCallbackRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + pub contribution_id: String, + pub instance_id: String, + pub protocol_version: serde_json::Value, + pub session_id: SessionId, +} + +/// Bounded app-canvas state and display metadata. Arbitrary navigation URLs are not supported. +/// +///
+/// +/// **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 AppCanvasOpenResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub actions: Option>, + /// Serializable initial canvas state. + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +/// Trusted app-host request to open an app-extension canvas. +/// +///
+/// +/// **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 AppCanvasHostOpenRequest { + pub activation_id: String, + pub app_session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + pub contribution_id: String, + /// Serializable canvas input. + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + pub instance_id: String, + pub package_id: String, + pub protocol_version: serde_json::Value, +} + +/// Trusted app-host request to invoke an app-extension canvas action. +/// +///
+/// +/// **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 AppCanvasHostActionRequest { + pub action_name: String, + pub activation_id: String, + pub app_session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + pub contribution_id: String, + /// Serializable action input. + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + pub instance_id: String, + pub package_id: String, + pub protocol_version: serde_json::Value, +} + +/// Trusted app-host request to close an app-extension canvas. +/// +///
+/// +/// **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 AppCanvasHostCloseRequest { + pub activation_id: String, + pub app_session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + pub contribution_id: String, + pub instance_id: String, + pub package_id: String, + pub protocol_version: serde_json::Value, +} + +/// Forge-provider operation callback routed to the owning 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 AppForgeInvokeCallbackRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub account_id: Option, + pub contribution_id: String, + /// Serializable operation input. + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + pub operation: String, + pub protocol_version: serde_json::Value, + pub session_id: SessionId, +} + +/// Trusted app-host request to invoke an app-extension forge provider. +/// +///
+/// +/// **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 AppForgeHostInvokeRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub account_id: Option, + pub activation_id: String, + pub app_session_id: String, + pub contribution_id: String, + /// Serializable operation input. + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + pub operation: String, + pub package_id: String, + pub protocol_version: serde_json::Value, +} + +/// Constrained credential-free HTTP request interpreted by the trusted app 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 AppMediatedFetchHttpRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, + pub method: AppMediatedFetchHttpRequestMethod, + pub path: String, +} + +/// Requests a capability-gated forge operation through the trusted app 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(crate) struct AppMediatedFetchRequest { + pub account_id: String, + pub contribution_id: String, + pub operation: String, + pub protocol_version: serde_json::Value, + pub request: AppMediatedFetchHttpRequest, +} + +/// Validated mediated-fetch effect routed to the trusted app session 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 AppMediatedFetchHostRequest { + pub account_id: String, + pub activation_id: String, + pub contribution_id: String, + pub operation: String, + pub package_id: String, + pub protocol_version: serde_json::Value, + pub request: AppMediatedFetchHttpRequest, + pub session_id: SessionId, +} + +/// Bounded sanitized HTTP response returned by the trusted app 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 AppMediatedFetchResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + pub headers: HashMap, + pub status: i32, + pub truncated: bool, +} + /// List of Copilot models available to the resolved user, including capabilities and billing metadata. /// ///
@@ -22165,6 +22858,66 @@ pub struct ExtensionsDiscoverResult { pub mode: DiscoveredExtensionMode, } +/// Authenticated principal and capability grants for one private app-extension 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(crate) struct ExtensionsAppExtensionRegisterResult { + #[doc(hidden)] + pub(crate) capabilities: AppExtensionCapabilities, + #[doc(hidden)] + pub(crate) contributions: Vec, + #[doc(hidden)] + pub(crate) principal: AppExtensionPrincipal, + pub protocol_version: serde_json::Value, +} + +/// Bounded app-canvas state and display metadata. Arbitrary navigation URLs are not supported. +/// +///
+/// +/// **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 ExtensionsAppCanvasOpenResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub actions: Option>, + /// Serializable initial canvas state. + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +/// Bounded sanitized HTTP response returned by the trusted app 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 ExtensionsAppForgeFetchResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + pub headers: HashMap, + pub status: i32, + pub truncated: bool, +} + /// Plugins installed in user/global state. /// ///
@@ -27948,6 +28701,24 @@ pub struct CanvasOpenResult { pub url: Option, } +/// Bounded sanitized HTTP response returned by the trusted app 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 AppForgeFetchResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + pub headers: HashMap, + pub status: i32, + pub truncated: bool, +} + /// Validation errors from the most recent authentication attempt. /// ///
@@ -34859,3 +35630,94 @@ pub enum WorkspacesWorkspaceDetailsHostType { #[serde(other)] Unknown, } + +/// Capability contribution point declared by a trusted app-extension manifest. +/// +///
+/// +/// **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 AppExtensionContributionPoint { + #[serde(rename = "sessionBadges")] + SessionBadges, + #[serde(rename = "canvases")] + Canvases, + #[serde(rename = "forgeProvider")] + ForgeProvider, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AppSessionBadgePresentationState { + #[serde(rename = "draft")] + Draft, + #[serde(rename = "open")] + Open, + #[serde(rename = "merged")] + Merged, + #[serde(rename = "closed")] + Closed, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AppSessionPullRequestActionKind { + #[serde(rename = "createPullRequest")] + #[default] + CreatePullRequest, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AppSessionPullRequestActionState { + #[serde(rename = "available")] + Available, + #[serde(rename = "inProgress")] + InProgress, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AppSessionPullRequestActionInvocationKind { + #[serde(rename = "createPullRequest")] + #[default] + CreatePullRequest, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AppCanvasActionDescriptorVariant { + #[serde(rename = "default")] + Default, + #[serde(rename = "primary")] + Primary, + #[serde(rename = "danger")] + Danger, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AppMediatedFetchHttpRequestMethod { + GET, + POST, + PUT, + PATCH, + DELETE, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index 50eee0f1fb..933fb018e7 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -590,6 +590,34 @@ pub struct ClientRpcExtensions<'a> { } impl<'a> ClientRpcExtensions<'a> { + /// `extensions.appCanvas.*` sub-namespace. + pub fn app_canvas(&self) -> ClientRpcExtensionsAppCanvas<'a> { + ClientRpcExtensionsAppCanvas { + client: self.client, + } + } + + /// `extensions.appExtension.*` sub-namespace. + pub fn app_extension(&self) -> ClientRpcExtensionsAppExtension<'a> { + ClientRpcExtensionsAppExtension { + client: self.client, + } + } + + /// `extensions.appForge.*` sub-namespace. + pub fn app_forge(&self) -> ClientRpcExtensionsAppForge<'a> { + ClientRpcExtensionsAppForge { + client: self.client, + } + } + + /// `extensions.appSessionBadges.*` sub-namespace. + pub fn app_session_badges(&self) -> ClientRpcExtensionsAppSessionBadges<'a> { + ClientRpcExtensionsAppSessionBadges { + client: self.client, + } + } + /// Discovers user and enabled installed-plugin extensions from persisted Copilot home state, including enablement preferences. Launch-scoped additional plugins are not included. /// /// Wire method: `extensions.discover`. @@ -663,6 +691,461 @@ impl<'a> ClientRpcExtensions<'a> { } } +/// `extensions.appCanvas.*` RPCs. +#[derive(Clone, Copy)] +pub struct ClientRpcExtensionsAppCanvas<'a> { + pub(crate) client: &'a Client, +} + +impl<'a> ClientRpcExtensionsAppCanvas<'a> { + /// `appCanvas.action.*` sub-namespace. + pub fn action(&self) -> ClientRpcExtensionsAppCanvasAction<'a> { + ClientRpcExtensionsAppCanvasAction { + client: self.client, + } + } + + /// Registers one trusted app-canvas contribution on its owning extension connection. + /// + /// Wire method: `extensions.appCanvas.register`. + /// + /// # Parameters + /// + /// * `params` - Registers or unregisters one runtime-authenticated app-extension contribution on its owning 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(crate) async fn register( + &self, + params: AppExtensionContributionRegistrationRequest, + ) -> Result<(), Error> { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call( + rpc_methods::EXTENSIONS_APPCANVAS_REGISTER, + Some(wire_params), + ) + .await?; + Ok(()) + } + + /// Unregisters one trusted app-canvas contribution from its owning extension connection. + /// + /// Wire method: `extensions.appCanvas.unregister`. + /// + /// # Parameters + /// + /// * `params` - Registers or unregisters one runtime-authenticated app-extension contribution on its owning 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(crate) async fn unregister( + &self, + params: AppExtensionContributionRegistrationRequest, + ) -> Result<(), Error> { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call( + rpc_methods::EXTENSIONS_APPCANVAS_UNREGISTER, + Some(wire_params), + ) + .await?; + Ok(()) + } + + /// Routes a trusted app-host canvas open request to its owning extension contribution. + /// + /// Wire method: `extensions.appCanvas.open`. + /// + /// # Parameters + /// + /// * `params` - Trusted app-host request to open an app-extension canvas. + /// + /// # Returns + /// + /// Bounded app-canvas state and display metadata. Arbitrary navigation URLs are not supported. + /// + ///
+ /// + /// **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 open( + &self, + params: AppCanvasHostOpenRequest, + ) -> Result { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call(rpc_methods::EXTENSIONS_APPCANVAS_OPEN, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Routes a trusted app-host canvas close request to its owning extension contribution. + /// + /// Wire method: `extensions.appCanvas.close`. + /// + /// # Parameters + /// + /// * `params` - Trusted app-host request to close an app-extension canvas. + /// + ///
+ /// + /// **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 close(&self, params: AppCanvasHostCloseRequest) -> Result<(), Error> { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call(rpc_methods::EXTENSIONS_APPCANVAS_CLOSE, Some(wire_params)) + .await?; + Ok(()) + } +} + +/// `appCanvas.action.*` RPCs. +#[derive(Clone, Copy)] +pub struct ClientRpcExtensionsAppCanvasAction<'a> { + pub(crate) client: &'a Client, +} + +impl<'a> ClientRpcExtensionsAppCanvasAction<'a> { + /// Routes a trusted app-host canvas action to its owning extension contribution. + /// + /// Wire method: `extensions.appCanvas.action.invoke`. + /// + /// # Parameters + /// + /// * `params` - Trusted app-host request to invoke an app-extension canvas action. + /// + /// # Returns + /// + /// Serializable action result. + /// + ///
+ /// + /// **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 invoke( + &self, + params: AppCanvasHostActionRequest, + ) -> Result { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call( + rpc_methods::EXTENSIONS_APPCANVAS_ACTION_INVOKE, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + +/// `extensions.appExtension.*` RPCs. +#[derive(Clone, Copy)] +pub struct ClientRpcExtensionsAppExtension<'a> { + pub(crate) client: &'a Client, +} + +impl<'a> ClientRpcExtensionsAppExtension<'a> { + /// Authenticates an allowlisted app-extension connection and returns its opaque principal and capability grants. + /// + /// Wire method: `extensions.appExtension.register`. + /// + /// # Parameters + /// + /// * `params` - Private app-extension activation handshake. Identity is derived from trusted runtime connection metadata and is never accepted from request parameters. + /// + /// # Returns + /// + /// Authenticated principal and capability grants for one private app-extension 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(crate) async fn register( + &self, + params: AppExtensionRegisterRequest, + ) -> Result { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call( + rpc_methods::EXTENSIONS_APPEXTENSION_REGISTER, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + +/// `extensions.appForge.*` RPCs. +#[derive(Clone, Copy)] +pub struct ClientRpcExtensionsAppForge<'a> { + pub(crate) client: &'a Client, +} + +impl<'a> ClientRpcExtensionsAppForge<'a> { + /// Registers one trusted forge-provider contribution and its supported operations. + /// + /// Wire method: `extensions.appForge.register`. + /// + /// # Parameters + /// + /// * `params` - Registers one runtime-authenticated forge-provider contribution and its supported operation names. + /// + ///
+ /// + /// **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 register(&self, params: AppForgeRegisterRequest) -> Result<(), Error> { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call(rpc_methods::EXTENSIONS_APPFORGE_REGISTER, Some(wire_params)) + .await?; + Ok(()) + } + + /// Unregisters one trusted forge-provider contribution from its owning extension connection. + /// + /// Wire method: `extensions.appForge.unregister`. + /// + /// # Parameters + /// + /// * `params` - Registers or unregisters one runtime-authenticated app-extension contribution on its owning 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(crate) async fn unregister( + &self, + params: AppExtensionContributionRegistrationRequest, + ) -> Result<(), Error> { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call( + rpc_methods::EXTENSIONS_APPFORGE_UNREGISTER, + Some(wire_params), + ) + .await?; + Ok(()) + } + + /// Routes a trusted app-host operation to its owning forge-provider contribution. + /// + /// Wire method: `extensions.appForge.invoke`. + /// + /// # Parameters + /// + /// * `params` - Trusted app-host request to invoke an app-extension forge provider. + /// + /// # Returns + /// + /// Serializable forge-provider operation result. + /// + ///
+ /// + /// **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 invoke( + &self, + params: AppForgeHostInvokeRequest, + ) -> Result { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call(rpc_methods::EXTENSIONS_APPFORGE_INVOKE, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Requests a bounded capability-gated HTTP operation through the trusted app host. + /// + /// Wire method: `extensions.appForge.fetch`. + /// + /// # Parameters + /// + /// * `params` - Requests a capability-gated forge operation through the trusted app host. + /// + /// # Returns + /// + /// Bounded sanitized HTTP response returned by the trusted app host. + /// + ///
+ /// + /// **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 fetch( + &self, + params: AppMediatedFetchRequest, + ) -> Result { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call(rpc_methods::EXTENSIONS_APPFORGE_FETCH, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + +/// `extensions.appSessionBadges.*` RPCs. +#[derive(Clone, Copy)] +pub struct ClientRpcExtensionsAppSessionBadges<'a> { + pub(crate) client: &'a Client, +} + +impl<'a> ClientRpcExtensionsAppSessionBadges<'a> { + /// `appSessionBadges.action.*` sub-namespace. + pub fn action(&self) -> ClientRpcExtensionsAppSessionBadgesAction<'a> { + ClientRpcExtensionsAppSessionBadgesAction { + client: self.client, + } + } + + /// Publishes one atomic badge and Create Pull Request action presentation. + /// + /// Wire method: `extensions.appSessionBadges.setPresentation`. + /// + /// # Parameters + /// + /// * `params` - Publishes one atomic badge and action presentation for an eligible app session. + /// + ///
+ /// + /// **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 set_presentation( + &self, + params: AppSessionSetPresentationRequest, + ) -> Result<(), Error> { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call( + rpc_methods::EXTENSIONS_APPSESSIONBADGES_SETPRESENTATION, + Some(wire_params), + ) + .await?; + Ok(()) + } + + /// Publishes an ordered atomic batch of badge and Create Pull Request action presentations. + /// + /// Wire method: `extensions.appSessionBadges.setPresentations`. + /// + /// # Parameters + /// + /// * `params` - Publishes an ordered batch of atomic badge and action presentations. + /// + ///
+ /// + /// **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 set_presentations( + &self, + params: AppSessionSetPresentationsRequest, + ) -> Result<(), Error> { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call( + rpc_methods::EXTENSIONS_APPSESSIONBADGES_SETPRESENTATIONS, + Some(wire_params), + ) + .await?; + Ok(()) + } +} + +/// `appSessionBadges.action.*` RPCs. +#[derive(Clone, Copy)] +pub struct ClientRpcExtensionsAppSessionBadgesAction<'a> { + pub(crate) client: &'a Client, +} + +impl<'a> ClientRpcExtensionsAppSessionBadgesAction<'a> { + /// Routes a trusted app-host Create Pull Request action to its owning extension contribution. + /// + /// Wire method: `extensions.appSessionBadges.action.invoke`. + /// + /// # Parameters + /// + /// * `params` - Trusted app-host request to invoke a Create Pull Request action on its owning extension contribution. + /// + /// # Returns + /// + /// AppSessionActionResult or null when the extension declines the action. + /// + ///
+ /// + /// **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 invoke( + &self, + params: AppSessionActionHostRequest, + ) -> Result { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call( + rpc_methods::EXTENSIONS_APPSESSIONBADGES_ACTION_INVOKE, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + /// `hooks.*` RPCs. #[derive(Clone, Copy)] pub struct ClientRpcHooks<'a> { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index c95ed2087a..8e396beb7a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -3,6 +3,11 @@ #![deny(rustdoc::broken_intra_doc_links)] #![cfg_attr(test, allow(clippy::unwrap_used))] +/// Private app-extension principal and capability registration types. +#[doc(hidden)] +pub mod app_extension; +/// Typed app-level executable-extension badge protocol. +pub mod app_session_badges; /// Canvas declarations, provider callbacks, and host-side canvas RPC types. pub mod canvas; mod canvas_dispatch; diff --git a/rust/src/resolve.rs b/rust/src/resolve.rs index d8b996a11a..5fcc11b0c4 100644 --- a/rust/src/resolve.rs +++ b/rust/src/resolve.rs @@ -147,6 +147,7 @@ fn extracted_program(_use_runtime_wrapper: bool) -> Option { None } +#[cfg(any(feature = "bundled-cli", has_extracted_cli, test))] fn validate_runtime_pair(wrapper: &Path) -> Result<(), Error> { let wrapper_valid = wrapper .metadata() diff --git a/rust/src/session.rs b/rust/src/session.rs index 0e64d6061c..9647c98ff7 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -12,6 +12,7 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::{Instrument, error, warn}; +use crate::app_extension::AppMediatedFetchHandler; use crate::canvas::CanvasHandler; use crate::generated::api_types::{ LogRequest, ModelSwitchAutoTierRequest, ModelSwitchAutoTierResult, ModelSwitchToRequest, @@ -540,6 +541,9 @@ impl Session { if let Some(display_prompt) = opts.display_prompt { params["displayPrompt"] = serde_json::to_value(display_prompt)?; } + if let Some(required_tool) = opts.required_tool { + params["requiredTool"] = serde_json::to_value(required_tool)?; + } let trace_ctx = if opts.traceparent.is_some() || opts.tracestate.is_some() { TraceContext { traceparent: opts.traceparent, @@ -1275,6 +1279,7 @@ impl Client { let has_hooks = hooks.is_some(); let command_handlers = build_command_handler_map(runtime.commands.as_deref()); let canvas_handler = runtime.canvas_handler.take(); + let app_mediated_fetch_handler = runtime.app_mediated_fetch_handler.take(); let session_fs_provider = runtime.session_fs_provider.take(); let bearer_token_providers = std::mem::take(&mut runtime.bearer_token_providers); let github_token_registration = runtime @@ -1421,6 +1426,7 @@ impl Client { transforms, command_handlers, canvas_handler, + app_mediated_fetch_handler, session_fs_provider, bearer_token_providers, channels, @@ -1594,6 +1600,7 @@ impl Client { let has_hooks = hooks.is_some(); let command_handlers = build_command_handler_map(runtime.commands.as_deref()); let canvas_handler = runtime.canvas_handler.take(); + let app_mediated_fetch_handler = runtime.app_mediated_fetch_handler.take(); let session_fs_provider = runtime.session_fs_provider.take(); let bearer_token_providers = std::mem::take(&mut runtime.bearer_token_providers); let github_token_registration = runtime @@ -1638,6 +1645,7 @@ impl Client { transforms, command_handlers, canvas_handler, + app_mediated_fetch_handler, session_fs_provider, bearer_token_providers, channels, @@ -2046,6 +2054,7 @@ fn spawn_event_loop( transforms: Option>, command_handlers: Arc, canvas_handler: Option>, + app_mediated_fetch_handler: Option>, session_fs_provider: Option>, bearer_token_providers: HashMap>, channels: crate::router::SessionChannels, @@ -2108,6 +2117,7 @@ fn spawn_event_loop( let hooks = hooks.clone(); let transforms = transforms.clone(); let canvas_handler = canvas_handler.clone(); + let app_mediated_fetch_handler = app_mediated_fetch_handler.clone(); let session_fs_provider = session_fs_provider.clone(); let bearer_token_providers = bearer_token_providers.clone(); let request_id = request.id; @@ -2120,6 +2130,7 @@ fn spawn_event_loop( hooks: hooks.as_deref(), transforms: transforms.as_deref(), canvas_handler: canvas_handler.as_ref(), + app_mediated_fetch_handler: app_mediated_fetch_handler.as_ref(), session_fs_provider: session_fs_provider.as_ref(), bearer_token_providers: &bearer_token_providers, }; @@ -2942,6 +2953,7 @@ struct RequestDispatchContext<'a> { hooks: Option<&'a dyn SessionHooks>, transforms: Option<&'a dyn SystemMessageTransform>, canvas_handler: Option<&'a Arc>, + app_mediated_fetch_handler: Option<&'a Arc>, session_fs_provider: Option<&'a Arc>, bearer_token_providers: &'a HashMap>, } @@ -2958,6 +2970,7 @@ async fn handle_request( let hooks = ctx.hooks; let transforms = ctx.transforms; let canvas_handler = ctx.canvas_handler; + let app_mediated_fetch_handler = ctx.app_mediated_fetch_handler; let session_fs_provider = ctx.session_fs_provider; let bearer_token_providers = ctx.bearer_token_providers; @@ -2971,6 +2984,12 @@ async fn handle_request( return; } + if request.method == crate::generated::api_types::rpc_methods::APPFORGE_FETCH { + crate::app_extension::dispatch_mediated_fetch(client, app_mediated_fetch_handler, request) + .await; + return; + } + if request.method == crate::generated::api_types::rpc_methods::PROVIDERTOKEN_GETTOKEN { crate::provider_token_dispatch::dispatch(client, bearer_token_providers, request).await; return; diff --git a/rust/src/types.rs b/rust/src/types.rs index a99f00a19f..1a93b46b7d 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -13,6 +13,7 @@ use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::app_extension::AppMediatedFetchHandler; use crate::canvas::{CanvasDeclaration, CanvasHandler}; pub use crate::copilot_request_handler::{ CopilotHttpRequest, CopilotHttpResponse, CopilotHttpResponseBody, CopilotRequestContext, @@ -1973,10 +1974,17 @@ pub struct SessionConfig { /// this handler. Use [`with_canvas_handler`](Self::with_canvas_handler) /// to install one. pub canvas_handler: Option>, + /// Trusted app-host handler for validated mediated-fetch effects. + #[doc(hidden)] + pub app_mediated_fetch_handler: Option>, /// Request canvas renderer tools for this connection. pub request_canvas_renderer: Option, /// Request extension tools and dispatch for this connection. pub request_extensions: Option, + /// Package IDs whose trusted app-scoped activations may run in this + /// session. Intended for app hosts that discover bundled packages. + #[doc(hidden)] + pub app_extension_package_ids: Option>, /// Optional override path to a `copilot-sdk/` folder to inject into /// extension subprocesses for this session. Invalid paths fall back /// to the bundled SDK; takes precedence over the host's default. @@ -2308,8 +2316,13 @@ impl std::fmt::Debug for SessionConfig { "canvas_handler", &self.canvas_handler.as_ref().map(|_| ""), ) + .field( + "app_mediated_fetch_handler", + &self.app_mediated_fetch_handler.as_ref().map(|_| ""), + ) .field("request_canvas_renderer", &self.request_canvas_renderer) .field("request_extensions", &self.request_extensions) + .field("app_extension_package_ids", &self.app_extension_package_ids) .field("extension_sdk_path", &self.extension_sdk_path) .field("extension_info", &self.extension_info) .field("canvas_provider", &self.canvas_provider) @@ -2448,8 +2461,10 @@ impl Default for SessionConfig { tools: None, canvases: None, canvas_handler: None, + app_mediated_fetch_handler: None, request_canvas_renderer: None, request_extensions: None, + app_extension_package_ids: None, extension_sdk_path: None, extension_info: None, canvas_provider: None, @@ -2542,6 +2557,7 @@ pub(crate) struct SessionConfigRuntime { pub system_message_transform: Option>, pub tool_handlers: HashMap>, pub canvas_handler: Option>, + pub app_mediated_fetch_handler: Option>, pub session_fs_provider: Option>, pub bearer_token_providers: HashMap>, pub github_token_provider: Option>, @@ -2602,6 +2618,7 @@ impl SessionConfig { }); let wire_canvases = self.canvases.clone(); let canvas_handler = self.canvas_handler.clone(); + let app_mediated_fetch_handler = self.app_mediated_fetch_handler.clone(); let bearer_token_providers = prepare_bearer_token_providers(&mut self.provider, &mut self.providers); @@ -2619,6 +2636,7 @@ impl SessionConfig { canvases: wire_canvases, request_canvas_renderer: self.request_canvas_renderer, request_extensions: self.request_extensions, + app_extension_package_ids: self.app_extension_package_ids, extension_sdk_path: self.extension_sdk_path, extension_info: self.extension_info, canvas_provider: self.canvas_provider, @@ -2697,6 +2715,7 @@ impl SessionConfig { system_message_transform: self.system_message_transform, tool_handlers, canvas_handler, + app_mediated_fetch_handler, session_fs_provider: self.session_fs_provider, bearer_token_providers, github_token_provider: self.github_token_provider, @@ -2888,6 +2907,16 @@ impl SessionConfig { self } + /// Install the trusted app-host mediated-fetch handler for this session. + #[doc(hidden)] + pub fn with_app_mediated_fetch_handler( + mut self, + handler: Arc, + ) -> Self { + self.app_mediated_fetch_handler = Some(handler); + self + } + /// Request host canvas renderer tools for this connection. pub fn with_request_canvas_renderer(mut self, request: bool) -> Self { self.request_canvas_renderer = Some(request); @@ -2900,6 +2929,17 @@ impl SessionConfig { self } + /// Set trusted app-extension package IDs available to this session. + #[doc(hidden)] + pub fn with_app_extension_package_ids(mut self, package_ids: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.app_extension_package_ids = Some(package_ids.into_iter().map(Into::into).collect()); + self + } + /// Override the bundled `@github/copilot-sdk` drop injected into extension /// subprocesses for this session. Invalid paths fall back to the bundled /// SDK silently. @@ -3411,12 +3451,18 @@ pub struct ResumeSessionConfig { /// Provider-side canvas lifecycle handler. See /// [`SessionConfig::canvas_handler`]. pub canvas_handler: Option>, + /// Trusted app-host handler for validated mediated-fetch effects. + #[doc(hidden)] + pub app_mediated_fetch_handler: Option>, /// Open canvas instances the caller knows were open before this resume. pub open_canvases: Option>, /// Request canvas renderer tools for this connection. pub request_canvas_renderer: Option, /// Request extension tools and dispatch for this connection. pub request_extensions: Option, + /// Re-supply trusted app-extension package IDs on resume. + #[doc(hidden)] + pub app_extension_package_ids: Option>, /// Optional override path to a `copilot-sdk/` folder to inject into /// extension subprocesses for this session on resume. See /// `SessionConfig::extension_sdk_path`. @@ -3660,9 +3706,14 @@ impl std::fmt::Debug for ResumeSessionConfig { "canvas_handler", &self.canvas_handler.as_ref().map(|_| ""), ) + .field( + "app_mediated_fetch_handler", + &self.app_mediated_fetch_handler.as_ref().map(|_| ""), + ) .field("open_canvases", &self.open_canvases) .field("request_canvas_renderer", &self.request_canvas_renderer) .field("request_extensions", &self.request_extensions) + .field("app_extension_package_ids", &self.app_extension_package_ids) .field("extension_sdk_path", &self.extension_sdk_path) .field("extension_info", &self.extension_info) .field("canvas_provider", &self.canvas_provider) @@ -3827,6 +3878,7 @@ impl ResumeSessionConfig { }); let wire_canvases = self.canvases.clone(); let canvas_handler = self.canvas_handler.clone(); + let app_mediated_fetch_handler = self.app_mediated_fetch_handler.clone(); let bearer_token_providers = prepare_bearer_token_providers(&mut self.provider, &mut self.providers); @@ -3845,6 +3897,7 @@ impl ResumeSessionConfig { open_canvases: self.open_canvases, request_canvas_renderer: self.request_canvas_renderer, request_extensions: self.request_extensions, + app_extension_package_ids: self.app_extension_package_ids, extension_sdk_path: self.extension_sdk_path, extension_info: self.extension_info, canvas_provider: self.canvas_provider, @@ -3924,6 +3977,7 @@ impl ResumeSessionConfig { system_message_transform: self.system_message_transform, tool_handlers, canvas_handler, + app_mediated_fetch_handler, session_fs_provider: self.session_fs_provider, bearer_token_providers, github_token_provider: self.github_token_provider, @@ -3951,9 +4005,11 @@ impl ResumeSessionConfig { tools: None, canvases: None, canvas_handler: None, + app_mediated_fetch_handler: None, open_canvases: None, request_canvas_renderer: None, request_extensions: None, + app_extension_package_ids: None, extension_sdk_path: None, extension_info: None, canvas_provider: None, @@ -4191,6 +4247,16 @@ impl ResumeSessionConfig { self } + /// Install the trusted app-host mediated-fetch handler for the resumed session. + #[doc(hidden)] + pub fn with_app_mediated_fetch_handler( + mut self, + handler: Arc, + ) -> Self { + self.app_mediated_fetch_handler = Some(handler); + self + } + /// Seed open canvas instances that were visible before resuming. pub fn with_open_canvases>( mut self, @@ -4212,6 +4278,17 @@ impl ResumeSessionConfig { self } + /// Re-supply trusted app-extension package IDs on resume. + #[doc(hidden)] + pub fn with_app_extension_package_ids(mut self, package_ids: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.app_extension_package_ids = Some(package_ids.into_iter().map(Into::into).collect()); + self + } + /// Override the bundled `@github/copilot-sdk` drop injected into extension /// subprocesses for this resumed session. Invalid paths fall back to the /// bundled SDK silently. @@ -5412,6 +5489,10 @@ pub struct MessageOptions { pub tracestate: Option, /// If provided, this is shown in the timeline instead of `prompt`. pub display_prompt: Option, + /// Require this tool to be available for the turn. + /// + /// The request fails before execution when the named tool is unavailable. + pub required_tool: Option, } impl MessageOptions { @@ -5427,6 +5508,7 @@ impl MessageOptions { traceparent: None, tracestate: None, display_prompt: None, + required_tool: None, } } @@ -5493,6 +5575,12 @@ impl MessageOptions { self.display_prompt = Some(display_prompt.into()); self } + + /// Require this tool to be available for the turn. + pub fn with_required_tool(mut self, required_tool: impl Into) -> Self { + self.required_tool = Some(required_tool.into()); + self + } } impl From<&str> for MessageOptions { @@ -6465,6 +6553,64 @@ mod tests { assert!(unset_resume_json.get("customAgentsLocalOnly").is_none()); } + #[test] + fn app_extension_package_ids_serialize_on_create_and_resume() { + let package_ids = ["github.app.pr-badges", "github.app.checks"]; + assert_eq!(SessionConfig::default().app_extension_package_ids, None); + assert_eq!( + ResumeSessionConfig::new(SessionId::from("default-resume")).app_extension_package_ids, + None + ); + + let create_config = SessionConfig::default().with_app_extension_package_ids(package_ids); + assert!(format!("{create_config:?}").contains("app_extension_package_ids")); + let create = create_config + .into_wire(Some(SessionId::from("create-app-extensions"))) + .expect("create config has no duplicate handlers") + .0; + let create_json = serde_json::to_value(&create).unwrap(); + assert_eq!( + create_json["appExtensionPackageIds"], + json!(["github.app.pr-badges", "github.app.checks"]) + ); + + let resume_config = ResumeSessionConfig::new(SessionId::from("resume-app-extensions")) + .with_app_extension_package_ids(package_ids); + assert!(format!("{resume_config:?}").contains("app_extension_package_ids")); + let resume = resume_config + .into_wire() + .expect("resume config has no duplicate handlers") + .0; + let resume_json = serde_json::to_value(&resume).unwrap(); + assert_eq!( + resume_json["appExtensionPackageIds"], + json!(["github.app.pr-badges", "github.app.checks"]) + ); + + let unset_create = SessionConfig::default() + .into_wire(Some(SessionId::from("create-without-app-extensions"))) + .expect("create config has no duplicate handlers") + .0; + assert!( + serde_json::to_value(&unset_create) + .unwrap() + .get("appExtensionPackageIds") + .is_none() + ); + + let unset_resume = + ResumeSessionConfig::new(SessionId::from("resume-without-app-extensions")) + .into_wire() + .expect("resume config has no duplicate handlers") + .0; + assert!( + serde_json::to_value(&unset_resume) + .unwrap() + .get("appExtensionPackageIds") + .is_none() + ); + } + #[test] fn session_config_enable_mcp_apps_sets_wire_flag_and_serializes() { let cfg = SessionConfig::default().with_enable_mcp_apps(true); @@ -7039,6 +7185,7 @@ mod tests { .with_capi(CapiSessionOptions::new().with_enable_web_socket_responses(false)) .with_enable_session_telemetry(false) .with_include_sub_agent_streaming_events(false) + .with_app_extension_package_ids(["github.app.pr-badges"]) .with_extension_info(ExtensionInfo::new("github-app", "counter")); assert_eq!(cfg.session_id.as_ref().map(|s| s.as_str()), Some("sess-1")); @@ -7087,6 +7234,10 @@ mod tests { ); assert_eq!(cfg.enable_session_telemetry, Some(false)); assert_eq!(cfg.include_sub_agent_streaming_events, Some(false)); + assert_eq!( + cfg.app_extension_package_ids.as_deref(), + Some(&["github.app.pr-badges".to_string()][..]) + ); assert_eq!( cfg.extension_info, Some(ExtensionInfo::new("github-app", "counter")) @@ -7122,6 +7273,7 @@ mod tests { .with_include_sub_agent_streaming_events(true) .with_suppress_resume_event(true) .with_continue_pending_work(true) + .with_app_extension_package_ids(["github.app.pr-badges"]) .with_extension_info(ExtensionInfo::new("github-app", "counter")); assert_eq!(cfg.session_id.as_str(), "sess-2"); @@ -7170,6 +7322,10 @@ mod tests { assert_eq!(cfg.include_sub_agent_streaming_events, Some(true)); assert_eq!(cfg.suppress_resume_event, Some(true)); assert_eq!(cfg.continue_pending_work, Some(true)); + assert_eq!( + cfg.app_extension_package_ids.as_deref(), + Some(&["github.app.pr-badges".to_string()][..]) + ); assert_eq!( cfg.extension_info, Some(ExtensionInfo::new("github-app", "counter")) diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 75e17f4e9c..fdde2c2996 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -75,6 +75,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub request_extensions: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub app_extension_package_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub extension_sdk_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub extension_info: Option, @@ -235,6 +237,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub request_extensions: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub app_extension_package_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub extension_sdk_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub extension_info: Option, diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index e7bafc683c..e4af4ab5f4 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -1978,6 +1978,42 @@ async fn send_omits_display_prompt_when_unset() { timeout(TIMEOUT, handle).await.unwrap().unwrap().unwrap(); } +#[tokio::test] +async fn send_serializes_required_tool_only_when_set() { + let (session, mut server) = create_session_pair().await; + let session = Arc::new(session); + + let handle = tokio::spawn({ + let session = session.clone(); + async move { + session + .send( + MessageOptions::new("Create the pull request") + .with_required_tool("create_ado_pull_request"), + ) + .await + } + }); + let request = server.read_request().await; + assert_eq!(request["method"], "session.send"); + assert_eq!(request["params"]["requiredTool"], "create_ado_pull_request"); + server.respond(&request, serde_json::json!({})).await; + timeout(TIMEOUT, handle).await.unwrap().unwrap().unwrap(); + + let handle = tokio::spawn({ + let session = session.clone(); + async move { session.send(MessageOptions::new("plain")).await } + }); + let request = server.read_request().await; + assert!( + request["params"].get("requiredTool").is_none(), + "requiredTool should be omitted when unset, got: {}", + request["params"] + ); + server.respond(&request, serde_json::json!({})).await; + timeout(TIMEOUT, handle).await.unwrap().unwrap().unwrap(); +} + #[tokio::test] async fn session_rpc_methods_send_correct_method_names() { let (session, mut server) = create_session_pair().await; diff --git a/scripts/codegen/.gitignore b/scripts/codegen/.gitignore index c2658d7d1b..cbf0615e76 100644 --- a/scripts/codegen/.gitignore +++ b/scripts/codegen/.gitignore @@ -1 +1,2 @@ node_modules/ +.cache/ diff --git a/scripts/codegen/app-extension-api.schema.json b/scripts/codegen/app-extension-api.schema.json new file mode 100644 index 0000000000..3080a608cd --- /dev/null +++ b/scripts/codegen/app-extension-api.schema.json @@ -0,0 +1,1436 @@ +{ + "definitions": { + "AppExtensionRegisterRequest": { + "title": "AppExtensionRegisterRequest", + "description": "Private app-extension activation handshake. Identity is derived from trusted runtime connection metadata and is never accepted from request parameters.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": ["protocolVersion"], + "properties": { + "protocolVersion": { + "type": "integer", + "const": 1 + } + } + }, + "AppExtensionPrincipal": { + "title": "AppExtensionPrincipal", + "description": "Opaque runtime-authenticated identity for one allowlisted app-extension activation.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": ["packageId", "activationId"], + "properties": { + "packageId": { + "type": "string", + "minLength": 1 + }, + "activationId": { + "type": "string", + "minLength": 1 + } + } + }, + "AppExtensionCapabilities": { + "title": "AppExtensionCapabilities", + "description": "Capability grants bound to an authenticated app-extension principal. Keys are present only when granted.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "properties": { + "sessionBadges": { + "type": "boolean", + "const": true + }, + "canvases": { + "type": "boolean", + "const": true + }, + "forgeProvider": { + "type": "boolean", + "const": true + }, + "mediatedFetch": { + "type": "boolean", + "const": true + } + } + }, + "AppExtensionContributionPoint": { + "title": "AppExtensionContributionPoint", + "description": "Capability contribution point declared by a trusted app-extension manifest.", + "visibility": "internal", + "type": "string", + "enum": ["sessionBadges", "canvases", "forgeProvider"] + }, + "AppExtensionDeclaredContribution": { + "title": "AppExtensionDeclaredContribution", + "description": "Runtime-authenticated identity of one statically declared app-extension contribution.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": ["contributionPoint", "contributionId"], + "properties": { + "contributionPoint": { + "$ref": "#/definitions/AppExtensionContributionPoint" + }, + "contributionId": { + "type": "string", + "minLength": 1 + } + } + }, + "AppExtensionRegisterResult": { + "title": "AppExtensionRegisterResult", + "description": "Authenticated principal and capability grants for one private app-extension activation.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": [ + "protocolVersion", + "principal", + "capabilities", + "contributions" + ], + "properties": { + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "principal": { + "$ref": "#/definitions/AppExtensionPrincipal" + }, + "capabilities": { + "$ref": "#/definitions/AppExtensionCapabilities" + }, + "contributions": { + "type": "array", + "items": { + "$ref": "#/definitions/AppExtensionDeclaredContribution" + } + } + } + }, + "AppExtensionContributionRegistrationRequest": { + "title": "AppExtensionContributionRegistrationRequest", + "description": "Registers or unregisters one runtime-authenticated app-extension contribution on its owning connection.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": ["protocolVersion", "contributionId"], + "properties": { + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, + "AppForgeRegisterRequest": { + "title": "AppForgeRegisterRequest", + "description": "Registers one runtime-authenticated forge-provider contribution and its supported operation names.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": ["protocolVersion", "contributionId", "operations"], + "properties": { + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "operations": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + } + }, + "AppExtensionContributionTarget": { + "title": "AppExtensionContributionTarget", + "description": "Stable runtime-authenticated app-extension contribution identity used by a trusted app host.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": ["packageId", "activationId", "contributionId"], + "properties": { + "packageId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "activationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, + "AppSessionPresentationTarget": { + "title": "AppSessionPresentationTarget", + "description": "Exact app-visible session target from the current eligible-session snapshot.", + "type": "object", + "additionalProperties": false, + "required": [ + "workspaceId", + "sessionId", + "repositoryPath", + "worktreePath" + ], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "repositoryPath": { + "type": "string", + "minLength": 1, + "maxLength": 32768 + }, + "worktreePath": { + "type": "string", + "minLength": 1, + "maxLength": 32768 + }, + "branch": { + "type": "string", + "maxLength": 4096 + } + } + }, + "AppSessionBadgePresentation": { + "title": "AppSessionBadgePresentation", + "description": "Constrained pull-request identity presentation contributed for one eligible app session.", + "type": "object", + "additionalProperties": false, + "required": ["state"], + "properties": { + "state": { + "type": "string", + "enum": ["draft", "open", "merged", "closed"] + }, + "label": { + "type": "string", + "maxLength": 512 + } + } + }, + "AppSessionPullRequestAction": { + "title": "AppSessionPullRequestAction", + "description": "Constrained Create Pull Request action state contributed for one eligible app session.", + "type": "object", + "additionalProperties": false, + "required": ["kind", "state", "supportsDraft"], + "properties": { + "kind": { + "type": "string", + "const": "createPullRequest" + }, + "state": { + "type": "string", + "enum": ["available", "inProgress"] + }, + "supportsDraft": { + "type": "boolean" + } + } + }, + "AppSessionPresentation": { + "title": "AppSessionPresentation", + "description": "Atomic extension-provided badge and Create Pull Request action presentation.", + "type": "object", + "additionalProperties": false, + "required": ["badge", "action"], + "properties": { + "badge": { + "anyOf": [ + { + "$ref": "#/definitions/AppSessionBadgePresentation" + }, + { + "type": "null" + } + ] + }, + "action": { + "anyOf": [ + { + "$ref": "#/definitions/AppSessionPullRequestAction" + }, + { + "type": "null" + } + ] + } + } + }, + "AppSessionPresentationUpdate": { + "title": "AppSessionPresentationUpdate", + "description": "One ordered atomic presentation replacement for an eligible app session.", + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "sessionId", "presentation"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "presentation": { + "$ref": "#/definitions/AppSessionPresentation" + } + } + }, + "AppSessionSetPresentationRequest": { + "title": "AppSessionSetPresentationRequest", + "description": "Publishes one atomic badge and action presentation for an eligible app session.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": [ + "protocolVersion", + "workspaceId", + "sessionId", + "presentation" + ], + "properties": { + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "presentation": { + "$ref": "#/definitions/AppSessionPresentation" + } + } + }, + "AppSessionSetPresentationsRequest": { + "title": "AppSessionSetPresentationsRequest", + "description": "Publishes an ordered batch of atomic badge and action presentations.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": ["protocolVersion", "updates"], + "properties": { + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "updates": { + "type": "array", + "minItems": 1, + "maxItems": 1024, + "items": { + "$ref": "#/definitions/AppSessionPresentationUpdate" + } + } + } + }, + "AppSessionPullRequestActionInvocation": { + "title": "AppSessionPullRequestActionInvocation", + "description": "Create Pull Request action selected by the app host.", + "type": "object", + "additionalProperties": false, + "required": ["kind", "draft"], + "properties": { + "kind": { + "type": "string", + "const": "createPullRequest" + }, + "draft": { + "type": "boolean" + } + } + }, + "AppSessionActionCallbackRequest": { + "title": "AppSessionActionCallbackRequest", + "description": "Create Pull Request callback routed to the owning app extension.", + "type": "object", + "additionalProperties": false, + "required": [ + "sessionId", + "protocolVersion", + "contributionId", + "target", + "action" + ], + "properties": { + "sessionId": { + "type": "string", + "minLength": 1 + }, + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "target": { + "$ref": "#/definitions/AppSessionPresentationTarget" + }, + "action": { + "$ref": "#/definitions/AppSessionPullRequestActionInvocation" + } + } + }, + "AppSessionActionResult": { + "title": "AppSessionActionResult", + "description": "Bounded extension-authored prompt and required session tool for a Create Pull Request action.", + "type": "object", + "additionalProperties": false, + "required": ["prompt", "requiredTool"], + "properties": { + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 32768 + }, + "requiredTool": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]*$" + } + } + }, + "AppSessionActionHostRequest": { + "title": "AppSessionActionHostRequest", + "description": "Trusted app-host request to invoke a Create Pull Request action on its owning extension contribution.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": [ + "appSessionId", + "protocolVersion", + "packageId", + "activationId", + "contributionId", + "target", + "action" + ], + "properties": { + "appSessionId": { + "type": "string", + "minLength": 1 + }, + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "packageId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "activationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "target": { + "$ref": "#/definitions/AppSessionPresentationTarget" + }, + "action": { + "$ref": "#/definitions/AppSessionPullRequestActionInvocation" + } + } + }, + "AppSessionPresentationChangedEventData": { + "title": "AppSessionPresentationChangedEventData", + "description": "Authenticated provider presentation update emitted on the retained hidden app session. A null presentation resets provider state.", + "type": "object", + "additionalProperties": false, + "required": [ + "protocolVersion", + "extensionId", + "packageId", + "activationId", + "contributionId", + "workspaceId", + "sessionId", + "presentation" + ], + "properties": { + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "extensionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "packageId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "activationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "presentation": { + "anyOf": [ + { + "$ref": "#/definitions/AppSessionPresentation" + }, + { + "type": "null" + } + ] + } + } + }, + "AppCanvasActionDescriptor": { + "title": "AppCanvasActionDescriptor", + "description": "Bounded generic action descriptor rendered by the trusted app host.", + "type": "object", + "additionalProperties": false, + "required": ["name", "label"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "input": { + "description": "Serializable action input returned when the action is selected.", + "x-opaque-json": true + }, + "variant": { + "type": "string", + "enum": ["default", "primary", "danger"] + }, + "disabled": { + "type": "boolean" + } + } + }, + "AppCanvasProjectContext": { + "title": "AppCanvasProjectContext", + "description": "Trusted project context supplied by the app host to an app-scoped canvas.", + "type": "object", + "additionalProperties": false, + "required": ["forgeProviderId", "repositoryLocator"], + "properties": { + "forgeProviderId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "repositoryLocator": { + "description": "Opaque versioned repository locator interpreted only by the owning forge provider.", + "x-opaque-json": true + }, + "forgeAccountId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, + "AppCanvasContext": { + "title": "AppCanvasContext", + "description": "Optional trusted app context for one canvas instance. The hidden control-session identity is never exposed.", + "type": "object", + "additionalProperties": false, + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "project": { + "$ref": "#/definitions/AppCanvasProjectContext" + } + } + }, + "AppCanvasOpenCallbackRequest": { + "title": "AppCanvasOpenCallbackRequest", + "description": "App-canvas open callback routed to the owning extension connection.", + "type": "object", + "additionalProperties": false, + "required": [ + "sessionId", + "protocolVersion", + "contributionId", + "instanceId" + ], + "properties": { + "sessionId": { + "type": "string", + "minLength": 1 + }, + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "instanceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "input": { + "description": "Serializable canvas input.", + "x-opaque-json": true + }, + "context": { + "$ref": "#/definitions/AppCanvasContext" + } + } + }, + "AppCanvasActionCallbackRequest": { + "title": "AppCanvasActionCallbackRequest", + "description": "App-canvas action callback routed to the owning extension connection.", + "type": "object", + "additionalProperties": false, + "required": [ + "sessionId", + "protocolVersion", + "contributionId", + "instanceId", + "actionName" + ], + "properties": { + "sessionId": { + "type": "string", + "minLength": 1 + }, + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "instanceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "actionName": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "input": { + "description": "Serializable action input.", + "x-opaque-json": true + }, + "context": { + "$ref": "#/definitions/AppCanvasContext" + } + } + }, + "AppCanvasCloseCallbackRequest": { + "title": "AppCanvasCloseCallbackRequest", + "description": "App-canvas close callback routed to the owning extension connection.", + "type": "object", + "additionalProperties": false, + "required": [ + "sessionId", + "protocolVersion", + "contributionId", + "instanceId" + ], + "properties": { + "sessionId": { + "type": "string", + "minLength": 1 + }, + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "instanceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "context": { + "$ref": "#/definitions/AppCanvasContext" + } + } + }, + "AppCanvasOpenResult": { + "title": "AppCanvasOpenResult", + "description": "Bounded app-canvas state and display metadata. Arbitrary navigation URLs are not supported.", + "type": "object", + "additionalProperties": false, + "properties": { + "state": { + "description": "Serializable initial canvas state.", + "x-opaque-json": true + }, + "title": { + "type": "string", + "maxLength": 512 + }, + "status": { + "type": "string", + "maxLength": 512 + }, + "actions": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/definitions/AppCanvasActionDescriptor" + } + } + } + }, + "AppCanvasHostOpenRequest": { + "title": "AppCanvasHostOpenRequest", + "description": "Trusted app-host request to open an app-extension canvas.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": [ + "appSessionId", + "protocolVersion", + "packageId", + "activationId", + "contributionId", + "instanceId" + ], + "properties": { + "appSessionId": { + "type": "string", + "minLength": 1 + }, + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "packageId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "activationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "instanceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "input": { + "description": "Serializable canvas input.", + "x-opaque-json": true + }, + "context": { + "$ref": "#/definitions/AppCanvasContext" + } + } + }, + "AppCanvasHostActionRequest": { + "title": "AppCanvasHostActionRequest", + "description": "Trusted app-host request to invoke an app-extension canvas action.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": [ + "appSessionId", + "protocolVersion", + "packageId", + "activationId", + "contributionId", + "instanceId", + "actionName" + ], + "properties": { + "appSessionId": { + "type": "string", + "minLength": 1 + }, + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "packageId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "activationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "instanceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "actionName": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "input": { + "description": "Serializable action input.", + "x-opaque-json": true + }, + "context": { + "$ref": "#/definitions/AppCanvasContext" + } + } + }, + "AppCanvasHostCloseRequest": { + "title": "AppCanvasHostCloseRequest", + "description": "Trusted app-host request to close an app-extension canvas.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": [ + "appSessionId", + "protocolVersion", + "packageId", + "activationId", + "contributionId", + "instanceId" + ], + "properties": { + "appSessionId": { + "type": "string", + "minLength": 1 + }, + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "packageId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "activationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "instanceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "context": { + "$ref": "#/definitions/AppCanvasContext" + } + } + }, + "AppForgeInvokeCallbackRequest": { + "title": "AppForgeInvokeCallbackRequest", + "description": "Forge-provider operation callback routed to the owning extension connection.", + "type": "object", + "additionalProperties": false, + "required": [ + "sessionId", + "protocolVersion", + "contributionId", + "operation" + ], + "properties": { + "sessionId": { + "type": "string", + "minLength": 1 + }, + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "operation": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "accountId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "input": { + "description": "Serializable operation input.", + "x-opaque-json": true + } + } + }, + "AppForgeHostInvokeRequest": { + "title": "AppForgeHostInvokeRequest", + "description": "Trusted app-host request to invoke an app-extension forge provider.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": [ + "appSessionId", + "protocolVersion", + "packageId", + "activationId", + "contributionId", + "operation" + ], + "properties": { + "appSessionId": { + "type": "string", + "minLength": 1 + }, + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "packageId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "activationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "operation": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "accountId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "input": { + "description": "Serializable operation input.", + "x-opaque-json": true + } + } + }, + "AppMediatedFetchHttpRequest": { + "title": "AppMediatedFetchHttpRequest", + "description": "Constrained credential-free HTTP request interpreted by the trusted app host.", + "type": "object", + "additionalProperties": false, + "required": ["method", "path"], + "properties": { + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "headers": { + "type": "object", + "maxProperties": 64, + "additionalProperties": { + "type": "string", + "maxLength": 8192 + } + }, + "body": { + "type": "string", + "maxLength": 262144 + } + } + }, + "AppMediatedFetchRequest": { + "title": "AppMediatedFetchRequest", + "description": "Requests a capability-gated forge operation through the trusted app host.", + "visibility": "internal", + "type": "object", + "additionalProperties": false, + "required": [ + "protocolVersion", + "contributionId", + "accountId", + "operation", + "request" + ], + "properties": { + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "accountId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "operation": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "request": { + "$ref": "#/definitions/AppMediatedFetchHttpRequest" + } + } + }, + "AppMediatedFetchHostRequest": { + "title": "AppMediatedFetchHostRequest", + "description": "Validated mediated-fetch effect routed to the trusted app session host.", + "type": "object", + "additionalProperties": false, + "required": [ + "sessionId", + "protocolVersion", + "packageId", + "activationId", + "contributionId", + "accountId", + "operation", + "request" + ], + "properties": { + "sessionId": { + "type": "string", + "minLength": 1 + }, + "protocolVersion": { + "type": "integer", + "const": 1 + }, + "packageId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "activationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "contributionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "accountId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "operation": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "request": { + "$ref": "#/definitions/AppMediatedFetchHttpRequest" + } + } + }, + "AppMediatedFetchResponse": { + "title": "AppMediatedFetchResponse", + "description": "Bounded sanitized HTTP response returned by the trusted app host.", + "type": "object", + "additionalProperties": false, + "required": ["status", "headers", "truncated"], + "properties": { + "status": { + "type": "integer", + "minimum": 100, + "maximum": 599 + }, + "headers": { + "type": "object", + "maxProperties": 64, + "additionalProperties": { + "type": "string", + "maxLength": 8192 + } + }, + "body": { + "type": "string", + "maxLength": 1048576 + }, + "truncated": { + "type": "boolean" + } + } + } + }, + "server": { + "extensions": { + "appExtension": { + "register": { + "rpcMethod": "extensions.appExtension.register", + "description": "Authenticates an allowlisted app-extension connection and returns its opaque principal and capability grants.", + "visibility": "internal", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppExtensionRegisterRequest" + }, + "result": { + "$ref": "#/definitions/AppExtensionRegisterResult" + } + } + }, + "appSessionBadges": { + "setPresentation": { + "rpcMethod": "extensions.appSessionBadges.setPresentation", + "description": "Publishes one atomic badge and Create Pull Request action presentation.", + "visibility": "internal", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppSessionSetPresentationRequest" + }, + "result": { + "type": "null" + } + }, + "setPresentations": { + "rpcMethod": "extensions.appSessionBadges.setPresentations", + "description": "Publishes an ordered atomic batch of badge and Create Pull Request action presentations.", + "visibility": "internal", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppSessionSetPresentationsRequest" + }, + "result": { + "type": "null" + } + }, + "action": { + "invoke": { + "rpcMethod": "extensions.appSessionBadges.action.invoke", + "description": "Routes a trusted app-host Create Pull Request action to its owning extension contribution.", + "visibility": "internal", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppSessionActionHostRequest" + }, + "result": { + "description": "AppSessionActionResult or null when the extension declines the action.", + "x-opaque-json": true + } + } + } + }, + "appCanvas": { + "register": { + "rpcMethod": "extensions.appCanvas.register", + "description": "Registers one trusted app-canvas contribution on its owning extension connection.", + "visibility": "internal", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppExtensionContributionRegistrationRequest" + }, + "result": { + "type": "null" + } + }, + "unregister": { + "rpcMethod": "extensions.appCanvas.unregister", + "description": "Unregisters one trusted app-canvas contribution from its owning extension connection.", + "visibility": "internal", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppExtensionContributionRegistrationRequest" + }, + "result": { + "type": "null" + } + }, + "open": { + "rpcMethod": "extensions.appCanvas.open", + "description": "Routes a trusted app-host canvas open request to its owning extension contribution.", + "visibility": "internal", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppCanvasHostOpenRequest" + }, + "result": { + "$ref": "#/definitions/AppCanvasOpenResult" + } + }, + "action": { + "invoke": { + "rpcMethod": "extensions.appCanvas.action.invoke", + "description": "Routes a trusted app-host canvas action to its owning extension contribution.", + "visibility": "internal", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppCanvasHostActionRequest" + }, + "result": { + "description": "Serializable action result.", + "x-opaque-json": true + } + } + }, + "close": { + "rpcMethod": "extensions.appCanvas.close", + "description": "Routes a trusted app-host canvas close request to its owning extension contribution.", + "visibility": "internal", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppCanvasHostCloseRequest" + }, + "result": { + "type": "null" + } + } + }, + "appForge": { + "register": { + "rpcMethod": "extensions.appForge.register", + "description": "Registers one trusted forge-provider contribution and its supported operations.", + "visibility": "internal", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppForgeRegisterRequest" + }, + "result": { + "type": "null" + } + }, + "unregister": { + "rpcMethod": "extensions.appForge.unregister", + "description": "Unregisters one trusted forge-provider contribution from its owning extension connection.", + "visibility": "internal", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppExtensionContributionRegistrationRequest" + }, + "result": { + "type": "null" + } + }, + "invoke": { + "rpcMethod": "extensions.appForge.invoke", + "description": "Routes a trusted app-host operation to its owning forge-provider contribution.", + "visibility": "internal", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppForgeHostInvokeRequest" + }, + "result": { + "description": "Serializable forge-provider operation result.", + "x-opaque-json": true + } + }, + "fetch": { + "rpcMethod": "extensions.appForge.fetch", + "description": "Requests a bounded capability-gated HTTP operation through the trusted app host.", + "visibility": "internal", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppMediatedFetchRequest" + }, + "result": { + "$ref": "#/definitions/AppMediatedFetchResponse" + } + } + } + } + }, + "clientSession": { + "appSessionBadges": { + "action": { + "invoke": { + "rpcMethod": "appSessionBadges.action.invoke", + "description": "Invokes a Create Pull Request action on the owning app-extension contribution.", + "supportsCancellation": true, + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppSessionActionCallbackRequest" + }, + "result": { + "description": "AppSessionActionResult or null when the extension declines the action.", + "x-opaque-json": true + } + } + } + }, + "appCanvas": { + "open": { + "rpcMethod": "appCanvas.open", + "description": "Opens an app-scoped canvas contribution on its owning extension connection.", + "supportsCancellation": true, + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppCanvasOpenCallbackRequest" + }, + "result": { + "$ref": "#/definitions/AppCanvasOpenResult" + } + }, + "close": { + "rpcMethod": "appCanvas.close", + "description": "Closes an app-scoped canvas contribution on its owning extension connection.", + "supportsCancellation": true, + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppCanvasCloseCallbackRequest" + }, + "result": { + "type": "null" + } + }, + "action": { + "invoke": { + "rpcMethod": "appCanvas.action.invoke", + "description": "Invokes an action on an app-scoped canvas contribution.", + "supportsCancellation": true, + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppCanvasActionCallbackRequest" + }, + "result": { + "description": "Serializable action result.", + "x-opaque-json": true + } + } + } + }, + "appForgeProvider": { + "invoke": { + "rpcMethod": "appForge.invoke", + "description": "Invokes one registered forge-provider operation on its owning extension connection.", + "supportsCancellation": true, + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppForgeInvokeCallbackRequest" + }, + "result": { + "description": "Serializable forge-provider operation result.", + "x-opaque-json": true + } + } + }, + "appForgeHost": { + "fetch": { + "rpcMethod": "appForge.fetch", + "description": "Delegates one validated mediated-fetch effect to the trusted app session host.", + "stability": "experimental", + "params": { + "$ref": "#/definitions/AppMediatedFetchHostRequest" + }, + "result": { + "$ref": "#/definitions/AppMediatedFetchResponse" + } + } + } + } +} diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index b3cc5d5753..e7f914516f 100644 --- a/scripts/codegen/rust.ts +++ b/scripts/codegen/rust.ts @@ -29,13 +29,14 @@ import { collectReachableDefinitionNames, collectRpcMethodReferencedDefinitionNames, findSharedSchemaDefinitions, - getApiSchemaPath, + getSdkApiSchemaPath, getEnumValueDescriptions, getNullableInner, getRpcSchemaTypeName, getSessionEventsSchemaPath, isIntegerSchemaBoundedToInt32, isObjectSchema, + isOpaqueJson, isRpcMethod, isSchemaDeprecated, isSchemaExperimental, @@ -1379,6 +1380,9 @@ function rustParamsTypeName( } function rustResultTypeName(method: RpcMethod, ctx: RustCodegenCtx): string { + if (isOpaqueJson(method.result)) { + return "serde_json::Value"; + } if (method.result?.$ref && parseExternalSchemaRef(method.result.$ref)) { recordExternalRustTypeRef(method.result.$ref, ctx); return rustRefTypeName(method.result.$ref); @@ -1870,6 +1874,7 @@ function getResultTypeName( ): string | null { const result = method.result as (JSONSchema7 & { $ref?: string }) | null; if (!result || isVoidSchema(result)) return null; + if (isOpaqueJson(result)) return "serde_json::Value"; if (typeof result.$ref === "string") { return refTypeName(result.$ref, defCollections); } @@ -2214,7 +2219,7 @@ async function generate(): Promise { const schemaArgs = parseSchemaArgs(); const sessionEventsSchemaPath = schemaArgs.sessionEventsSchemaPath || (await getSessionEventsSchemaPath()); - const apiSchemaPath = await getApiSchemaPath(schemaArgs.apiSchemaPath); + const apiSchemaPath = await getSdkApiSchemaPath(schemaArgs.apiSchemaPath); const sessionEventsRaw = normalizeSchemaBrandCasing( JSON.parse(await fs.readFile(sessionEventsSchemaPath, "utf-8")), diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index f5e8acb146..01aaa556fd 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -12,7 +12,7 @@ import { compile } from "json-schema-to-typescript"; import path from "path"; import { fileURLToPath } from "url"; import { - getApiSchemaPath, + getSdkApiSchemaPath, fixNullableRequiredRefsInApiSchema, getNullableInner, getRpcSchemaTypeName, @@ -695,7 +695,7 @@ function paramsTypeName(method: RpcMethod): string { async function generateRpc(schemaPath?: string, sessionEventsSchema?: JSONSchema7): Promise { console.log("TypeScript: generating RPC types..."); - const resolvedPath = schemaPath ?? (await getApiSchemaPath()); + const resolvedPath = await getSdkApiSchemaPath(schemaPath); let schema = fixNullableRequiredRefsInApiSchema((await loadSchemaJson(resolvedPath)) as ApiSchema); if (sessionEventsSchema) { const sharedDefinitions = findSharedSchemaDefinitions( @@ -717,7 +717,7 @@ async function generateRpc(schemaPath?: string, sessionEventsSchema?: JSONSchema * Generated from: api.schema.json */ -import type { MessageConnection } from "vscode-jsonrpc/node.js"; +import type { CancellationToken, MessageConnection } from "vscode-jsonrpc/node.js"; `); const externalSchemaRefs = collectExternalSchemaRefNames(schema); @@ -1110,7 +1110,10 @@ export function emitClientSessionApiRegistration(clientSchema: Record;`); + const cancellation = method.supportsCancellation + ? `, cancellation?: CancellationToken` + : ""; + lines.push(` ${name}(params: ${pType}${cancellation}): Promise<${rType}>;`); } else { lines.push(` ${name}(): Promise<${rType}>;`); } @@ -1148,10 +1151,16 @@ export function emitClientSessionApiRegistration(clientSchema: Record {`); + const cancellationParameter = method.supportsCancellation + ? `, cancellation: CancellationToken` + : ""; + const cancellationArgument = method.supportsCancellation ? `, cancellation` : ""; + lines.push( + ` connection.onRequest("${method.rpcMethod}", async (params: ${pType}${cancellationParameter}) => {` + ); lines.push(` const handler = getHandlers(params.sessionId).${groupName};`); lines.push(` if (!handler) throw new Error(\`No ${groupName} handler registered for session: \${params.sessionId}\`);`); - lines.push(` return handler.${name}(params);`); + lines.push(` return handler.${name}(params${cancellationArgument});`); lines.push(` });`); } else { lines.push(` connection.onRequest("${method.rpcMethod}", async () => {`); diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index bad31a7675..885e56fb1f 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -66,6 +66,27 @@ export async function getApiSchemaPath(cliArg?: string): Promise { return resolveCopilotSchemaPath("api.schema.json"); } +/** + * Resolve the pinned API schema with SDK-owned private app-extension methods. + * + * The runtime schema remains authoritative for public APIs. This overlay is + * intentionally consumed only by Node and Rust, which implement the private + * bundled app-extension architecture. + */ +export async function getSdkApiSchemaPath(cliArg?: string): Promise { + const basePath = await getApiSchemaPath(cliArg); + const overlayPath = path.join(__dirname, "app-extension-api.schema.json"); + const [base, overlay] = await Promise.all([ + loadSchemaJson(basePath) as Promise, + loadSchemaJson(overlayPath) as Promise, + ]); + const merged = mergeApiSchemaOverlay(base, overlay); + const outputPath = path.join(__dirname, ".cache", "api.schema.json"); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8"); + return outputPath; +} + // ── Brand casing normalization ────────────────────────────────────────────── /** @@ -371,6 +392,7 @@ export interface RpcMethod { visibility?: string; deprecated?: boolean; notification?: boolean; + supportsCancellation?: boolean; } export function getRpcSchemaTypeName(schema: JSONSchema7 | null | undefined, fallback: string): string { @@ -541,6 +563,72 @@ export interface ApiSchema { clientGlobal?: Record; } +export function mergeApiSchemaOverlay(base: ApiSchema, overlay: ApiSchema): ApiSchema { + const mergeRecord = ( + baseRecord: Record | undefined, + overlayRecord: Record | undefined, + pathPrefix: string, + ): Record | undefined => { + if (!overlayRecord) return baseRecord; + const merged = cloneSchemaForCodegen(baseRecord ?? {}); + for (const [key, overlayValue] of Object.entries(overlayRecord)) { + const pathName = pathPrefix ? `${pathPrefix}.${key}` : key; + const baseValue = merged[key]; + if (baseValue === undefined) { + merged[key] = cloneSchemaForCodegen(overlayValue); + continue; + } + if ( + typeof baseValue === "object" && + baseValue !== null && + !Array.isArray(baseValue) && + typeof overlayValue === "object" && + overlayValue !== null && + !Array.isArray(overlayValue) && + !isRpcMethod(baseValue) && + !isRpcMethod(overlayValue) + ) { + merged[key] = mergeRecord( + baseValue as Record, + overlayValue as Record, + pathName, + ); + continue; + } + if (stableStringify(baseValue) !== stableStringify(overlayValue)) { + throw new Error( + `SDK API schema overlay conflicts with the runtime schema at ${pathName}`, + ); + } + } + return merged; + }; + + return { + ...base, + definitions: mergeRecord( + base.definitions, + overlay.definitions, + "definitions", + ) as Record | undefined, + $defs: mergeRecord(base.$defs, overlay.$defs, "$defs") as + | Record + | undefined, + server: mergeRecord(base.server, overlay.server, "server"), + session: mergeRecord(base.session, overlay.session, "session"), + clientSession: mergeRecord( + base.clientSession, + overlay.clientSession, + "clientSession", + ), + clientGlobal: mergeRecord( + base.clientGlobal, + overlay.clientGlobal, + "clientGlobal", + ), + }; +} + export function isRpcMethod(node: unknown): node is RpcMethod { return typeof node === "object" && node !== null && "rpcMethod" in node; }