Skip to content

Commit f8e9885

Browse files
jchuervaCopilot
andcommitted
Add host-mediated GitHub API for extensions
Add a tokenless session.api.github.request surface for extensions that routes current-repository GitHub REST requests through the host runtime. The default API is GET-only, repository-relative, and includes client-side validation against arbitrary URLs or repository selection. Regenerate Node and Rust RPC surfaces, add focused Node coverage for wire payloads and unsafe input rejection, and document the extension API contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent c9a7784 commit f8e9885

12 files changed

Lines changed: 508 additions & 4 deletions

File tree

nodejs/docs/extensions.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,29 @@ const session = await joinSession({
5151
});
5252
```
5353

54-
The `session` object provides methods for sending messages, logging to the timeline, listening to events, and accessing the RPC API. See the `.d.ts` files in the SDK package for full type information.
54+
The `session` object provides methods for sending messages, logging to the timeline, listening to events, and accessing host-mediated APIs. See the `.d.ts` files in the SDK package for full type information.
55+
56+
## GitHub API requests
57+
58+
Extensions can request GitHub REST data for the current session repository through `session.api.github.request(...)`:
59+
60+
```js
61+
const alerts = await session.api.github.request({
62+
method: "GET",
63+
path: "/code-scanning/alerts",
64+
query: { state: "open", per_page: 100 },
65+
paginate: true,
66+
});
67+
```
68+
69+
This API is tokenless from the extension's perspective. The extension sends a repository-relative path, and the host derives the current repository, selects the signed-in or project account, performs the authenticated request, and returns the response data. The extension does not receive a GitHub token.
70+
71+
The default GitHub API surface is intentionally narrow:
72+
73+
- Only `GET` requests are supported.
74+
- Paths must be repository-relative, such as `/code-scanning/alerts`.
75+
- Full URLs and `/repos/{owner}/{repo}/...` paths are rejected because the extension cannot choose an arbitrary repository.
76+
- Cross-repository, organization-wide, GraphQL, and write access are not part of the default API.
5577

5678
## Further Reading
5779

nodejs/src/extension.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ export type JoinSessionConfig = Omit<
3030
};
3131

3232
export type { ExtensionInfo };
33+
export type {
34+
GitHubApiQueryValue,
35+
GitHubApiRequest,
36+
GitHubApiRequestMethod,
37+
GitHubApiResponse,
38+
SessionApi,
39+
} from "./types.js";
3340

3441
/**
3542
* Joins the current foreground session.

nodejs/src/generated/rpc.ts

Lines changed: 71 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nodejs/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ export type {
7676
ForegroundSessionInfo,
7777
GetAuthStatusResponse,
7878
GetStatusResponse,
79+
GitHubApiQueryValue,
80+
GitHubApiRequest,
81+
GitHubApiRequestMethod,
82+
GitHubApiResponse,
7983
GitHubTelemetryNotification,
8084
GitHubTelemetryEvent,
8185
GitHubTelemetryClientInfo,
@@ -125,6 +129,7 @@ export type {
125129
SessionUpdatedEvent,
126130
SessionForegroundEvent,
127131
SessionBackgroundEvent,
132+
SessionApi,
128133
SessionContext,
129134
SessionListFilter,
130135
SessionMetadata,

nodejs/src/session.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import type {
2727
ElicitationParams,
2828
ElicitationResult,
2929
ElicitationContext,
30+
GitHubApiRequest,
31+
GitHubApiResponse,
3032
ExitPlanModeHandler,
3133
ExitPlanModeRequest,
3234
ExitPlanModeResult,
@@ -48,6 +50,7 @@ import type {
4850
SessionEventPayload,
4951
SessionEventType,
5052
SessionHooks,
53+
SessionApi,
5154
SessionUiApi,
5255
Tool,
5356
ToolHandler,
@@ -214,6 +217,18 @@ export class CopilotSession {
214217
};
215218
}
216219

220+
/**
221+
* Host-mediated APIs for this session.
222+
*/
223+
get api(): SessionApi {
224+
return {
225+
github: {
226+
request: <T = unknown>(request: GitHubApiRequest) =>
227+
this._githubApiRequest<T>(request),
228+
},
229+
};
230+
}
231+
217232
/**
218233
* Sends a message to this session and waits for the response.
219234
*
@@ -1386,6 +1401,52 @@ export class CopilotSession {
13861401
): Promise<void> {
13871402
await this.rpc.log({ message, ...options });
13881403
}
1404+
1405+
private async _githubApiRequest<T = unknown>(
1406+
request: GitHubApiRequest
1407+
): Promise<GitHubApiResponse<T>> {
1408+
validateGitHubApiRequest(request);
1409+
const response = await this.rpc.api.github.request({
1410+
scope: "current_repository",
1411+
method: "GET",
1412+
path: request.path,
1413+
query: request.query,
1414+
paginate: request.paginate,
1415+
});
1416+
return response as GitHubApiResponse<T>;
1417+
}
1418+
}
1419+
1420+
function validateGitHubApiRequest(request: GitHubApiRequest): void {
1421+
if (!request || typeof request !== "object") {
1422+
throw new Error("GitHub API request must be an object.");
1423+
}
1424+
1425+
if (request.method !== "GET") {
1426+
throw new Error("session.api.github.request currently supports only GET requests.");
1427+
}
1428+
1429+
if (typeof request.path !== "string" || request.path.length === 0) {
1430+
throw new Error("GitHub API request path must be a non-empty string.");
1431+
}
1432+
1433+
if (/^[A-Za-z][A-Za-z\d+.-]*:\/\//.test(request.path)) {
1434+
throw new Error("GitHub API request path must be repository-relative, not a full URL.");
1435+
}
1436+
1437+
if (!request.path.startsWith("/") || request.path.startsWith("//")) {
1438+
throw new Error("GitHub API request path must start with a single `/`.");
1439+
}
1440+
1441+
if (/^\/repos\/[^/]+\/[^/]+(?:\/|$)/i.test(request.path)) {
1442+
throw new Error(
1443+
"GitHub API request path must not include `/repos/{owner}/{repo}`; the host derives the current repository."
1444+
);
1445+
}
1446+
1447+
if (request.path.split("/").includes("..")) {
1448+
throw new Error("GitHub API request path must not contain `..` segments.");
1449+
}
13891450
}
13901451

13911452
/**

nodejs/src/types.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
} from "./generated/session-events.js";
1818
import type { CopilotSession } from "./session.js";
1919
import type {
20+
GitHubApiResponse as GeneratedGitHubApiResponse,
2021
GitHubTelemetryNotification,
2122
ModelBillingTokenPrices,
2223
OpenCanvasInstance,
@@ -838,6 +839,49 @@ export interface SessionUiApi {
838839
input(message: string, options?: UiInputOptions): Promise<string | null>;
839840
}
840841

842+
export type GitHubApiRequestMethod = "GET";
843+
844+
export type GitHubApiQueryValue =
845+
| string
846+
| number
847+
| boolean
848+
| null
849+
| Array<string | number | boolean | null>;
850+
851+
export interface GitHubApiRequest {
852+
/** GitHub REST method. The initial API is read-only. */
853+
method: GitHubApiRequestMethod;
854+
/**
855+
* Repository-relative REST path for the current session repository,
856+
* for example `/code-scanning/alerts`.
857+
*/
858+
path: string;
859+
/** Query parameters for the GitHub REST request. */
860+
query?: Record<string, GitHubApiQueryValue>;
861+
/**
862+
* Ask the host/runtime to follow GitHub REST pagination and return the combined data.
863+
* Exact pagination limits and merge behavior are host-defined.
864+
*/
865+
paginate?: boolean;
866+
}
867+
868+
export type GitHubApiResponse<T = unknown> = Omit<GeneratedGitHubApiResponse, "data"> & {
869+
data: T;
870+
};
871+
872+
export interface SessionApi {
873+
/** Tokenless, host-mediated APIs available to this session. */
874+
github: {
875+
/**
876+
* Requests GitHub REST data for the current session repository through the host/runtime.
877+
*
878+
* The extension supplies a repository-relative path. The host/runtime derives the
879+
* repository, performs authentication internally, and does not expose tokens.
880+
*/
881+
request<T = unknown>(request: GitHubApiRequest): Promise<GitHubApiResponse<T>>;
882+
};
883+
}
884+
841885
export interface ToolCallRequestPayload {
842886
sessionId: string;
843887
toolCallId: string;

nodejs/test/client.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,67 @@ describe("CopilotClient", () => {
9898
});
9999
});
100100

101+
it("sends current-repository GitHub API requests through session RPC", async () => {
102+
const sendRequest = vi.fn(async () => ({
103+
status: 200,
104+
headers: { "x-github-request-id": "request-1" },
105+
data: [{ number: 1 }],
106+
}));
107+
const session = new CopilotSession("session-1", { sendRequest } as any);
108+
109+
const result = await session.api.github.request<Array<{ number: number }>>({
110+
method: "GET",
111+
path: "/code-scanning/alerts",
112+
query: { state: "open", per_page: 100 },
113+
paginate: true,
114+
});
115+
116+
expect(sendRequest).toHaveBeenCalledWith("session.api.github.request", {
117+
sessionId: "session-1",
118+
scope: "current_repository",
119+
method: "GET",
120+
path: "/code-scanning/alerts",
121+
query: { state: "open", per_page: 100 },
122+
paginate: true,
123+
});
124+
expect(result.data[0]?.number).toBe(1);
125+
});
126+
127+
it.each([
128+
["non-GET method", { method: "POST", path: "/code-scanning/alerts" }],
129+
["empty path", { method: "GET", path: "" }],
130+
["path without leading slash", { method: "GET", path: "code-scanning/alerts" }],
131+
["full URL", { method: "GET", path: "https://api.github.com/repos/github/copilot-sdk" }],
132+
[
133+
"protocol-relative URL",
134+
{ method: "GET", path: "//api.github.com/repos/github/copilot-sdk" },
135+
],
136+
[
137+
"explicit repo scope",
138+
{ method: "GET", path: "/repos/github/copilot-sdk/code-scanning/alerts" },
139+
],
140+
["traversal segment", { method: "GET", path: "/code-scanning/../alerts" }],
141+
])("rejects unsafe GitHub API request input: %s", async (_name, request) => {
142+
const sendRequest = vi.fn();
143+
const session = new CopilotSession("session-1", { sendRequest } as any);
144+
145+
await expect(session.api.github.request(request as any)).rejects.toThrow();
146+
expect(sendRequest).not.toHaveBeenCalled();
147+
});
148+
149+
it("rejects PATCH GitHub API requests before dispatch", async () => {
150+
const sendRequest = vi.fn();
151+
const session = new CopilotSession("session-1", { sendRequest } as any);
152+
153+
await expect(
154+
session.api.github.request({
155+
method: "PATCH",
156+
path: "/code-scanning/alerts/1",
157+
} as any)
158+
).rejects.toThrow("session.api.github.request currently supports only GET requests.");
159+
expect(sendRequest).not.toHaveBeenCalled();
160+
});
161+
101162
it("passes MCP OAuth requests through when optional metadata is absent", async () => {
102163
let observedRequest: any;
103164
const session = new CopilotSession(

0 commit comments

Comments
 (0)