Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/missionControlApiClient.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';6import { INTEGRATION_ID } from '../../../../platform/endpoint/common/licenseAgreement';7import { PermissiveAuthRequiredError } from '../../../../platform/github/common/githubService';8import { ILogService } from '../../../../platform/log/common/logService';9import { IFetcherService } from '../../../../platform/networking/common/fetcherService';1011/** Base path for Mission Control (agent session) endpoints. */12const SESSIONS_PATH = '/agents/sessions';1314/** Per-request timeout (ms). */15const REQUEST_TIMEOUT_MS = 10_000;1617/** Event payload forwarded to Mission Control. */18export interface McEvent {19id: string;20timestamp: string;21parentId: string | null;22ephemeral?: boolean;23type: string;24data: Record<string, unknown>;25}2627/** Steering command returned from Mission Control. */28export interface McCommand {29id: string;30content: string;31type?: string;32state: string;33}3435/** Result of a session creation call. */36export interface McSessionCreateResult {37id: string;38taskId: string;39}4041/**42* Authentication options for Mission Control requests.43*44* Mission Control endpoints write to the `/agents/sessions` API family, which45* requires a permissive GitHub token (same scope as the Copilot Coding Agent46* job dispatch endpoint). Callers pass `createIfNone` to surface an interactive47* sign-in prompt when no permissive session is available.48*/49export interface McAuthOptions {50/** If provided, shows an interactive permission-upgrade prompt when no silent session exists. */51readonly createIfNone?: { readonly detail: string };52}5354/**55* HTTP client for the Mission Control agent-session API.56*57* Wraps the four endpoints used by the Copilot CLI `/remote` command:58* - `POST /agents/sessions` — create session59* - `POST /agents/sessions/{id}/events` — submit events (+ ack completed commands)60* - `GET /agents/sessions/{id}/commands` — poll for steering commands61* - `DELETE /agents/sessions/{id}` — tear down session62*63* All requests are routed through {@link IFetcherService} so proxy, custom CA,64* and telemetry configuration are applied consistently. Authentication uses a65* permissive GitHub session (fetched on each call to pick up token refreshes);66* when no permissive session exists and interactive prompting is disabled the67* call throws {@link PermissiveAuthRequiredError}, mirroring the pattern used68* by `IOctoKitService.postCopilotAgentJob`.69*/70export class MissionControlApiClient {7172constructor(73@IAuthenticationService private readonly _authService: IAuthenticationService,74@IFetcherService private readonly _fetcherService: IFetcherService,75@ILogService private readonly _logService: ILogService,76) { }7778/**79* Create a Mission Control session for the given repo and agent task id.80*81* @throws {PermissiveAuthRequiredError} if `createIfNone` is not set and no silent permissive session exists.82*/83async createSession(84ownerId: number,85repoId: number,86agentTaskId: string,87authOptions: McAuthOptions,88): Promise<McSessionCreateResult> {89const { url, headers } = await this._buildRequest(SESSIONS_PATH, authOptions);90const res = await this._fetcherService.fetch(url, {91callSite: 'copilotcli.mc.createSession',92method: 'POST',93headers,94json: {95owner_id: ownerId,96repo_id: repoId,97agent_task_id: agentTaskId,98},99timeout: REQUEST_TIMEOUT_MS,100});101if (!res.ok) {102const body = await res.text().catch(() => '');103throw new Error(`Mission Control session creation failed: ${res.status} ${res.statusText} - ${body}`);104}105const data = await res.json() as { id: string; task_id?: string };106return { id: data.id, taskId: data.task_id ?? agentTaskId };107}108109/**110* Submit a batch of events to a Mission Control session, optionally111* acknowledging completed steering command ids in the same request.112*113* Returns `true` on success; logs and returns `false` on failure so the114* caller can re-queue events.115*/116async submitEvents(117sessionId: string,118events: readonly McEvent[],119completedCommandIds: readonly string[],120): Promise<boolean> {121try {122const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}/${sessionId}/events`, {});123const res = await this._fetcherService.fetch(url, {124callSite: 'copilotcli.mc.submitEvents',125method: 'POST',126headers,127json: {128events,129completed_command_ids: completedCommandIds.length > 0 ? completedCommandIds : undefined,130},131timeout: REQUEST_TIMEOUT_MS,132});133if (!res.ok) {134const body = await res.text().catch(() => '');135this._logService.warn(`[MissionControlApiClient] submitEvents failed: ${res.status} ${res.statusText} - ${body}`);136return false;137}138return true;139} catch (err) {140this._logService.warn(`[MissionControlApiClient] submitEvents error: ${err}`);141return false;142}143}144145/**146* Poll for pending steering commands. Returns an empty array if the147* endpoint is unreachable or returns a non-OK response.148*/149async getPendingCommands(sessionId: string): Promise<McCommand[]> {150try {151const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}/${sessionId}/commands`, {});152const res = await this._fetcherService.fetch(url, {153callSite: 'copilotcli.mc.getPendingCommands',154method: 'GET',155headers,156timeout: REQUEST_TIMEOUT_MS,157});158if (!res.ok) {159return [];160}161const data = await res.json() as { commands?: McCommand[] };162return data.commands ?? [];163} catch {164return [];165}166}167168/**169* Tear down a Mission Control session. Best-effort: failures are swallowed170* so `/remote off` always completes locally even if the server is unreachable.171*/172async deleteSession(sessionId: string): Promise<void> {173try {174const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}/${sessionId}`, {});175await this._fetcherService.fetch(url, {176callSite: 'copilotcli.mc.deleteSession',177// FetchOptions.method is typed narrowly (GET/POST/PUT) but the178// underlying fetcher forwards any string, so DELETE works at runtime.179method: 'DELETE' as 'POST',180headers,181timeout: REQUEST_TIMEOUT_MS,182});183} catch (err) {184this._logService.warn(`[MissionControlApiClient] deleteSession error: ${err}`);185}186}187188/**189* Build the absolute URL and auth headers for an MC request.190*191* Auth strategy follows `IOctoKitService.postCopilotAgentJob`: request a192* permissive GitHub session, optionally with an interactive upgrade prompt.193* If no session is available and prompting is disabled, throw194* {@link PermissiveAuthRequiredError} so callers can render a dedicated UX.195*196* The base URL is resolved via the Copilot token's `endpoints.api` which is197* GHES-aware.198*/199private async _buildRequest(200path: string,201authOptions: McAuthOptions,202): Promise<{ url: string; headers: Record<string, string> }> {203const session = authOptions.createIfNone204? await this._authService.getGitHubSession('permissive', { createIfNone: authOptions.createIfNone })205: await this._authService.getGitHubSession('permissive', { silent: true });206if (!session?.accessToken) {207throw new PermissiveAuthRequiredError();208}209210const copilotToken = await this._authService.getCopilotToken();211const baseUrl = copilotToken.endpoints?.api;212if (!baseUrl) {213throw new Error('Copilot API endpoint is not available');214}215216const url = `${baseUrl.replace(/\/+$/, '')}${path}`;217const headers: Record<string, string> = {218'Authorization': `Bearer ${session.accessToken}`,219'Copilot-Integration-Id': INTEGRATION_ID,220};221return { url, headers };222}223}224225226