fix(github-copilot): support GUI/RPC wizard auth flow

The interactive GitHub Copilot device-code provider auth ("github-copilot".auth.run)
short-circuited with { profiles: [] } whenever process.stdin.isTTY was false and
otherwise delegated to githubCopilotLoginCommand, which drives readline against
stdin/stdout. That made the device flow unusable from the gateway RPC bridge
and any GUI wizard frontend (including the WinUI tray app), where there is no
controlling TTY and no shared stdin with the gateway process.

This change adds a high-level runGitHubCopilotDeviceFlow() helper in
extensions/github-copilot/login.ts that:

  - validates the GitHub verification URL (must be https://github.com/login/device)
    and trims/length-checks user_code before exposing them to the host UI;
  - exposes a small io interface (showCode + openUrl) so callers render the
    code through their own surface (ctx.prompter.note + ctx.openUrl);
  - returns a typed result of { authorized | access_denied | expired }
    instead of throwing for normal terminal states.

runGitHubCopilotAuth() in extensions/github-copilot/index.ts is rewritten to
use that helper: it drops the isTTY gate, emits the URL/code/expiry through
ctx.prompter.note, best-effort calls ctx.openUrl, and returns the credential
inline via the returned profiles. The framework then persists it under the
correct agentDir, identical to the existing non-interactive path that Val
introduced in 461c10bb51.

requestDeviceCode and pollForAccessToken stay private to login.ts (no api.ts
export). The CLI entry githubCopilotLoginCommand is unchanged.

The provider auth contract tests in
src/plugin-sdk/test-helpers/provider-auth-contract.ts are updated to reflect
the new contract: the suite stubs fetch for the github.com device-code and
access-token endpoints and asserts that

  - the credential is sourced from the device flow response (not a post-hoc
    auth-store readback);
  - the provider drives the host UI through ctx.prompter.note + ctx.openUrl
    (no stdin/stdout dependency);
  - auth completes when process.stdin.isTTY is false (GUI/RPC clients).

Validation
- pnpm tsgo:extensions
- pnpm test:extension github-copilot (all github-copilot tests pass; the
  3 failures in models.test.ts are pre-existing on main, unrelated to this
  change: vitest mock for openclaw/plugin-sdk/provider-model-shared is missing
  the resolveProviderEndpoint export)
- End-to-end manual: ran a Windows tray-app onboarding wizard against a local
  gateway, picked GitHub Copilot, browser opened to the verification URL,
  authorization succeeded, token persisted under the correct agentDir as
  github-copilot:github, chat through the new credential worked.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Harsh Mehta
2026-04-28 04:51:23 +00:00
committed by Scott Hanselman
parent d30b8dccfd
commit d1b1d60fbf
5 changed files with 330 additions and 45 deletions

View File

@@ -4,7 +4,6 @@ import path from "node:path";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
upsertAuthProfile,
} from "openclaw/plugin-sdk/agent-runtime";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -216,17 +215,36 @@ describe("github-copilot plugin", () => {
},
}),
);
mocks.githubCopilotLoginCommand.mockImplementationOnce(async (opts: { agentDir?: string }) => {
upsertAuthProfile({
profileId: "github-copilot:github",
credential: {
type: "token",
provider: "github-copilot",
token: "refreshed-token",
},
agentDir: opts.agentDir,
});
const fetchMock = vi.fn(async (input: unknown) => {
const target =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input instanceof Request
? input.url
: String(input);
if (target === "https://github.com/login/device/code") {
return new Response(
JSON.stringify({
device_code: "device-code-stub",
user_code: "ABCD-1234",
verification_uri: "https://github.com/login/device",
expires_in: 900,
interval: 0,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (target === "https://github.com/login/oauth/access_token") {
return new Response(
JSON.stringify({ access_token: "refreshed-token", token_type: "bearer" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
throw new Error(`unexpected fetch in github-copilot refresh test: ${target}`);
});
vi.stubGlobal("fetch", fetchMock);
const prompter = {
confirm: vi.fn(async () => true),
note: vi.fn(),
@@ -253,16 +271,18 @@ describe("github-copilot plugin", () => {
oauth: { createVpsAwareHandlers: vi.fn() },
} as never);
expect(mocks.githubCopilotLoginCommand).toHaveBeenCalledWith(
{ yes: true, profileId: "github-copilot:github", agentDir },
expect.any(Object),
);
expect(prompter.confirm).toHaveBeenCalledWith({
message: "GitHub Copilot auth already exists. Re-run login?",
initialValue: false,
});
expect(mocks.githubCopilotLoginCommand).not.toHaveBeenCalled();
expect(result.profiles[0]?.credential).toEqual({
type: "token",
provider: "github-copilot",
token: "refreshed-token",
});
} finally {
vi.unstubAllGlobals();
if (isTtyDescriptor) {
Object.defineProperty(process.stdin, "isTTY", isTtyDescriptor);
} else {

View File

@@ -256,15 +256,14 @@ export default definePluginEntry({
}
async function runGitHubCopilotAuth(ctx: ProviderAuthContext) {
const { githubCopilotLoginCommand } = await loadGithubCopilotRuntime();
let authResult = resolveExistingCopilotAuthResult(ctx.agentDir);
if (authResult) {
const existing = resolveExistingCopilotAuthResult(ctx.agentDir);
if (existing) {
const runLogin = await ctx.prompter.confirm({
message: "GitHub Copilot auth already exists. Re-run login?",
initialValue: false,
});
if (!runLogin) {
return authResult;
return existing;
}
}
@@ -276,26 +275,54 @@ export default definePluginEntry({
"GitHub Copilot",
);
if (!process.stdin.isTTY) {
const { runGitHubCopilotDeviceFlow } = await import("./login.js");
const result = await runGitHubCopilotDeviceFlow({
showCode: async ({ verificationUrl, userCode, expiresInMs }) => {
const expiresInMinutes = Math.max(1, Math.round(expiresInMs / 60_000));
await ctx.prompter.note(
[
"Open this URL in your browser and enter the code below.",
`URL: ${verificationUrl}`,
`Code: ${userCode}`,
`Code expires in ${expiresInMinutes} minutes. Never share it.`,
"",
"If a browser does not open automatically after you continue, copy the URL manually.",
].join("\n"),
"Authorize GitHub Copilot",
);
},
openUrl: async (url) => {
await ctx.openUrl(url);
},
});
if (result.status === "access_denied") {
await ctx.prompter.note("GitHub Copilot login was cancelled.", "GitHub Copilot");
return { profiles: [] };
}
if (result.status === "expired") {
await ctx.prompter.note(
"GitHub Copilot login requires an interactive TTY.",
"The GitHub device code expired. Retry login to get a new code.",
"GitHub Copilot",
);
return { profiles: [] };
}
try {
await githubCopilotLoginCommand(
{ yes: true, profileId: "github-copilot:github", agentDir: ctx.agentDir },
ctx.runtime,
);
} catch (err) {
await ctx.prompter.note(`GitHub Copilot login failed: ${String(err)}`, "GitHub Copilot");
return { profiles: [] };
}
authResult = resolveExistingCopilotAuthResult(ctx.agentDir);
return authResult ?? { profiles: [] };
return {
profiles: [
{
profileId: DEFAULT_COPILOT_PROFILE_ID,
credential: {
type: "token" as const,
provider: PROVIDER_ID,
token: result.accessToken,
},
},
],
defaultModel: DEFAULT_COPILOT_MODEL,
};
}
api.registerMemoryEmbeddingProvider(githubCopilotMemoryEmbeddingProviderAdapter);

View File

@@ -11,6 +11,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
const DEVICE_CODE_URL = "https://github.com/login/device/code";
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
const GITHUB_DEVICE_VERIFICATION_URL = "https://github.com/login/device";
type DeviceCodeResponse = {
device_code: string;
@@ -32,6 +33,26 @@ type DeviceTokenResponse =
error_uri?: string;
};
const GITHUB_DEVICE_ACCESS_DENIED = Symbol("github-device-access-denied");
const GITHUB_DEVICE_EXPIRED = Symbol("github-device-expired");
class GitHubDeviceFlowError extends Error {
readonly kind: symbol;
constructor(kind: symbol, message: string) {
super(message);
this.kind = kind;
this.name = "GitHubDeviceFlowError";
}
}
function isGitHubDeviceAccessDeniedError(err: unknown): boolean {
return err instanceof GitHubDeviceFlowError && err.kind === GITHUB_DEVICE_ACCESS_DENIED;
}
function isGitHubDeviceExpiredError(err: unknown): boolean {
return err instanceof GitHubDeviceFlowError && err.kind === GITHUB_DEVICE_EXPIRED;
}
function parseJsonResponse(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error("Unexpected response from GitHub");
@@ -105,15 +126,100 @@ async function pollForAccessToken(params: {
continue;
}
if (err === "expired_token") {
throw new Error("GitHub device code expired; run login again");
throw new GitHubDeviceFlowError(
GITHUB_DEVICE_EXPIRED,
"GitHub device code expired; run login again",
);
}
if (err === "access_denied") {
throw new Error("GitHub login cancelled");
throw new GitHubDeviceFlowError(GITHUB_DEVICE_ACCESS_DENIED, "GitHub login cancelled");
}
throw new Error(`GitHub device flow error: ${err}`);
}
throw new Error("GitHub device code expired; run login again");
throw new GitHubDeviceFlowError(
GITHUB_DEVICE_EXPIRED,
"GitHub device code expired; run login again",
);
}
function normalizeGitHubDeviceVerificationUrl(raw: string): string {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error("GitHub device flow returned an invalid verification URL");
}
if (
parsed.protocol !== "https:" ||
parsed.hostname !== "github.com" ||
parsed.pathname !== "/login/device" ||
parsed.username ||
parsed.password
) {
throw new Error("GitHub device flow returned an unexpected verification URL");
}
return GITHUB_DEVICE_VERIFICATION_URL;
}
function normalizeGitHubDeviceUserCode(raw: string): string {
const userCode = raw.trim();
if (!userCode || userCode.length > 64) {
throw new Error("GitHub device flow returned an invalid user code");
}
return userCode;
}
export type GitHubCopilotDeviceFlowResult =
| { status: "authorized"; accessToken: string }
| { status: "access_denied" }
| { status: "expired" };
export type GitHubCopilotDeviceFlowIO = {
showCode(args: { verificationUrl: string; userCode: string; expiresInMs: number }): Promise<void>;
openUrl?: (url: string) => Promise<void>;
};
export async function runGitHubCopilotDeviceFlow(
io: GitHubCopilotDeviceFlowIO,
): Promise<GitHubCopilotDeviceFlowResult> {
const device = await requestDeviceCode({ scope: "read:user" });
const verificationUrl = normalizeGitHubDeviceVerificationUrl(device.verification_uri);
const userCode = normalizeGitHubDeviceUserCode(device.user_code);
const expiresInMs = device.expires_in * 1000;
// Anchor expiry to when GitHub issued the code, not when the UI finishes prompting.
const expiresAt = Date.now() + expiresInMs;
await io.showCode({
verificationUrl,
userCode,
expiresInMs,
});
try {
await io.openUrl?.(verificationUrl);
} catch {
// The code and URL have already been shown. Browser launch is best-effort.
}
try {
const accessToken = await pollForAccessToken({
deviceCode: device.device_code,
intervalMs: Math.max(1000, device.interval * 1000),
expiresAt,
});
return { status: "authorized", accessToken };
} catch (err) {
if (isGitHubDeviceAccessDeniedError(err)) {
return { status: "access_denied" };
}
if (isGitHubDeviceExpiredError(err)) {
return { status: "expired" };
}
throw err;
}
}
export async function githubCopilotLoginCommand(
@@ -166,8 +272,6 @@ export async function githubCopilotLoginCommand(
type: "token",
provider: "github-copilot",
token: accessToken,
// GitHub device flow token doesn't reliably include expiry here.
// Leave expires unset; we'll exchange into Copilot token plus expiry later.
},
agentDir: opts.agentDir,
});

View File

@@ -27,8 +27,8 @@ const allowedRawFetchCallsites = new Set([
bundledPluginCallsite("elevenlabs", "speech-provider.ts", 295),
bundledPluginCallsite("elevenlabs", "tts.ts", 74),
bundledPluginCallsite("feishu", "src/monitor.webhook.test-helpers.ts", 25),
bundledPluginCallsite("github-copilot", "login.ts", 48),
bundledPluginCallsite("github-copilot", "login.ts", 80),
bundledPluginCallsite("github-copilot", "login.ts", 69),
bundledPluginCallsite("github-copilot", "login.ts", 101),
bundledPluginCallsite("googlechat", "src/auth.ts", 83),
bundledPluginCallsite("huggingface", "models.ts", 142),
bundledPluginCallsite("kilocode", "provider-models.ts", 130),

View File

@@ -350,7 +350,104 @@ export function describeGithubCopilotProviderAuthContract(load: ProviderAuthCont
}
});
it("keeps auth gated on interactive TTYs", async () => {
function stubGitHubDeviceFlowFetch(
outcome: { accessToken: string } | { error: "access_denied" | "expired_token" },
) {
const fetchMock = vi.fn(async (input: unknown) => {
const target =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input instanceof Request
? input.url
: String(input);
if (target === "https://github.com/login/device/code") {
return new Response(
JSON.stringify({
device_code: "device-code-stub",
user_code: "ABCD-1234",
verification_uri: "https://github.com/login/device",
expires_in: 900,
interval: 0,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (target === "https://github.com/login/oauth/access_token") {
const body =
"accessToken" in outcome
? { access_token: outcome.accessToken, token_type: "bearer" }
: { error: outcome.error };
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
throw new Error(`unexpected fetch in github-copilot device flow stub: ${target}`);
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function buildSpyAuthContext() {
const ctx = buildAuthContext() as ReturnType<typeof buildAuthContext> & {
openUrl: (url: string) => Promise<void>;
prompter: WizardPrompter;
};
ctx.openUrl = vi.fn(async () => {});
ctx.prompter.note = vi.fn(async () => {});
return ctx;
}
afterEach(() => {
vi.unstubAllGlobals();
});
it("keeps device auth results provider-owned", async () => {
const provider = await getProvider();
stubGitHubDeviceFlowFetch({ accessToken: "github-device-token" });
const ctx = buildSpyAuthContext();
const result = await provider.auth[0]?.run(ctx as never);
expect(result).toEqual({
profiles: [
{
profileId: "github-copilot:github",
credential: {
type: "token",
provider: "github-copilot",
token: "github-device-token",
},
},
],
defaultModel: "github-copilot/claude-opus-4.7",
});
// Credential is sourced from the device flow response, not from the existing
// on-disk auth store. ensureAuthProfileStore is still called by the
// resolveExistingCopilotAuthResult existence check, which legitimately probes
// the store before launching the device flow when no profile exists yet.
expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled();
});
it("uses the wizard prompter and openUrl hooks for the device code (no stdin/stdout)", async () => {
const provider = await getProvider();
stubGitHubDeviceFlowFetch({ accessToken: "github-device-token" });
const ctx = buildSpyAuthContext();
await provider.auth[0]?.run(ctx as never);
expect(ctx.openUrl).toHaveBeenCalledWith("https://github.com/login/device");
const noteCalls = (ctx.prompter.note as ReturnType<typeof vi.fn>).mock.calls;
const codeNote = noteCalls.find(
([msg]) => typeof msg === "string" && msg.includes("ABCD-1234"),
);
expect(codeNote).toBeDefined();
expect(codeNote?.[0]).toContain("https://github.com/login/device");
});
it("supports non-interactive (GUI/RPC) auth contexts without a TTY", async () => {
const provider = await getProvider();
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY");
@@ -360,12 +457,21 @@ export function describeGithubCopilotProviderAuthContract(load: ProviderAuthCont
enumerable: true,
get: () => false,
});
stubGitHubDeviceFlowFetch({ accessToken: "rpc-client-token" });
const ctx = buildSpyAuthContext();
try {
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
profiles: [],
});
expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled();
const result = await provider.auth[0]?.run(ctx as never);
expect(result?.profiles).toEqual([
{
profileId: "github-copilot:github",
credential: {
type: "token",
provider: "github-copilot",
token: "rpc-client-token",
},
},
]);
} finally {
if (previousIsTTYDescriptor) {
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
@@ -374,5 +480,33 @@ export function describeGithubCopilotProviderAuthContract(load: ProviderAuthCont
}
}
});
it("returns no profiles and notes cancellation when the user denies access", async () => {
const provider = await getProvider();
stubGitHubDeviceFlowFetch({ error: "access_denied" });
const ctx = buildSpyAuthContext();
const result = await provider.auth[0]?.run(ctx as never);
expect(result).toEqual({ profiles: [] });
const noteCalls = (ctx.prompter.note as ReturnType<typeof vi.fn>).mock.calls;
expect(
noteCalls.some(([msg]) => typeof msg === "string" && msg.toLowerCase().includes("cancel")),
).toBe(true);
});
it("returns no profiles and notes expiry when the device code expires", async () => {
const provider = await getProvider();
stubGitHubDeviceFlowFetch({ error: "expired_token" });
const ctx = buildSpyAuthContext();
const result = await provider.auth[0]?.run(ctx as never);
expect(result).toEqual({ profiles: [] });
const noteCalls = (ctx.prompter.note as ReturnType<typeof vi.fn>).mock.calls;
expect(
noteCalls.some(([msg]) => typeof msg === "string" && msg.toLowerCase().includes("expired")),
).toBe(true);
});
});
}