fix(oauth): GitHub Copilot token refresh sends the public client_id (#4320)

GitHub Copilot is a public device-flow OAuth client (client_id, no client_secret),
but the github provider config never populated clientId. The standalone refresh path
omitted client_id (buildFormParams drops undefined) and the executor path sent the
literal "client_id=undefined&client_secret=undefined" — both rejected by GitHub, so a
Copilot connection got stuck once its short-lived token expired and the long-lived
refresh path was needed. Populate the provider clientId from the embedded public cred
(resolvePublicCred, never a literal) and only send client_secret when one exists. The
prior github refresh test patched a fake clientId/clientSecret onto PROVIDERS.github,
masking the broken real config — it now exercises the real config.

Co-authored-by: Manuel B. <1494154+baslr@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 21:31:42 -03:00
committed by GitHub
parent 5193a595bf
commit 032387401a
5 changed files with 91 additions and 32 deletions

View File

@@ -8,6 +8,46 @@ function registerModel(provider, model) {
PROVIDER_MODELS[provider] = [...(PROVIDER_MODELS[provider] || []), model];
}
test("GithubExecutor.refreshGitHubToken sends the public client_id and omits client_secret (port from 9router#442)", async () => {
// GitHub Copilot is a public device-flow OAuth client (client_id, no client_secret).
// The previous code sent client_id/client_secret straight from this.config via
// new URLSearchParams, so an undefined config produced the literal
// "client_id=undefined&client_secret=undefined". The fix populates the real client_id
// and only sends client_secret when one actually exists.
const executor = new GithubExecutor();
const calls: any[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: any, options: any = {}) => {
calls.push({ url: String(url), options });
return {
ok: true,
json: async () => ({
access_token: "gh-access",
refresh_token: "gh-next",
expires_in: 3600,
}),
} as any;
}) as any;
try {
const result = await executor.refreshGitHubToken("gh-refresh", { info() {}, error() {} });
assert.deepEqual(result, {
accessToken: "gh-access",
refreshToken: "gh-next",
expiresIn: 3600,
});
} finally {
globalThis.fetch = originalFetch;
}
const body = String(calls[0].options.body);
assert.match(body, /client_id=Iv1\./, "the real public github client_id must be sent");
assert.ok(
!body.includes("client_secret="),
"client_secret must be omitted (never the literal 'undefined')"
);
});
test("GithubExecutor.buildUrl routes response-format models to /responses", () => {
const originalModels = [...(PROVIDER_MODELS.gh || [])];
registerModel("gh", {