FN-5699: fix github-copilot device-code login callback handling

Prevent dashboard auth initiation from crashing when Copilot emits device-code callbacks.

- wire optional auth callbacks (`onDeviceCode`, `onSelect`) through dashboard auth route typing
- capture and return device-code payload from either `onDeviceCode` or parsed Copilot instructions when `/api/auth/login` starts
- guard auth-init promise settlement to avoid double resolve/reject races and normalize async login errors
- add regression coverage for Copilot device-code callback handling in auth route tests
- sync roadmap plugin schema-version test expectation from 94 to 95 to keep suite assertions aligned

Files changed:
 .changeset/fn-5699-copilot-device-code-callback.md |  5 ++
 packages/dashboard/src/__tests__/routes-auth.test.ts    | 21 ++++++++
 packages/dashboard/src/routes.ts                   |  7 +++
 packages/dashboard/src/routes/register-auth-routes.ts   | 62 +++++++++++++++++-----
 plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts      |  4 +-
 5 files changed, 83 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-5699
Fusion-Task-Lineage: 24bf59f2-3b22-4087-96ca-35409fe00895
This commit is contained in:
gsxdsm
2026-05-29 11:22:18 -07:00
parent 72214132d2
commit 0044c23c64
5 changed files with 83 additions and 16 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix dashboard OAuth login for `github-copilot` when upstream auth storage invokes device-code callbacks. The `/api/auth/login` route now provides the expected callback wiring and preserves `deviceCode: { userCode, verificationUri }` in responses so Copilot login no longer crashes with `options.onDeviceCode is not a function`.

View File

@@ -1289,6 +1289,27 @@ describe("POST /auth/login", () => {
}); });
}); });
it("handles github-copilot device-code callback without crashing", async () => {
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
callbacks.onDeviceCode({
userCode: "WXYZ-9876",
verificationUri: "https://github.com/login/device",
});
return Promise.resolve();
});
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "github-copilot" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.deviceCode).toEqual({
userCode: "WXYZ-9876",
verificationUri: "https://github.com/login/device",
});
expect(res.body.url).toBe("https://github.com/login/device");
});
it("auto-resolves first onPrompt invocation for github-copilot with blank input", async () => { it("auto-resolves first onPrompt invocation for github-copilot with blank input", async () => {
let promptValue: string | undefined; let promptValue: string | undefined;
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(async (_provider: string, callbacks: any) => { (authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(async (_provider: string, callbacks: any) => {

View File

@@ -206,9 +206,16 @@ export interface AuthStorageLike {
providerId: string, providerId: string,
callbacks: { callbacks: {
onAuth: (info: { url: string; instructions?: string }) => void; onAuth: (info: { url: string; instructions?: string }) => void;
onDeviceCode?: (info: {
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
}) => void;
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>; onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
onManualCodeInput?: () => Promise<string>; onManualCodeInput?: () => Promise<string>;
onProgress?: (message: string) => void; onProgress?: (message: string) => void;
onSelect?: (prompt: { message: string; options: Array<{ id: string; label: string }> }) => Promise<string | undefined>;
signal?: AbortSignal; signal?: AbortSignal;
}, },
): Promise<void>; ): Promise<void>;

View File

@@ -798,32 +798,60 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
let autoPromptConsumed = false; let autoPromptConsumed = false;
// We need to get the URL from the onAuth callback before responding. // We need to get auth kickoff info from callbacks before responding.
// The login() call continues in the background until the user completes OAuth. // The login() call continues in the background until the user completes OAuth.
let authResolve: (info: { url: string; instructions?: string; deviceCode?: DeviceCodeInfo }) => void; let authResolve: (info: { url: string; instructions?: string; deviceCode?: DeviceCodeInfo }) => void;
let authReject: (err: Error) => void; let authReject: (err: Error) => void;
let authSettled = false;
const authUrlPromise = new Promise<{ url: string; instructions?: string; deviceCode?: DeviceCodeInfo }>((resolve, reject) => { const authUrlPromise = new Promise<{ url: string; instructions?: string; deviceCode?: DeviceCodeInfo }>((resolve, reject) => {
authResolve = resolve; authResolve = resolve;
authReject = reject; authReject = reject;
}); });
const resolveAuthInfo = (info: { url: string; instructions?: string; deviceCode?: DeviceCodeInfo }) => {
if (authSettled) return;
authSettled = true;
authResolve(info);
};
const rejectAuthInfo = (err: Error) => {
if (authSettled) return;
authSettled = true;
authReject(err);
};
let resolvedDeviceCode: DeviceCodeInfo | undefined;
// Start login flow in background — don't await the full login // Start login flow in background — don't await the full login
const loginPromise = storage.login(provider, { const loginPromise = storage.login(provider, {
onAuth: (info) => { onAuth: (info) => {
const parsedUserCode = if (!resolvedDeviceCode) {
provider === "github-copilot" && info.instructions const parsedUserCode =
? parseGitHubCopilotDeviceCode(info.instructions) provider === "github-copilot" && info.instructions
: undefined; ? parseGitHubCopilotDeviceCode(info.instructions)
const deviceCode = parsedUserCode : undefined;
? { if (parsedUserCode) {
resolvedDeviceCode = {
userCode: parsedUserCode, userCode: parsedUserCode,
verificationUri: info.url, verificationUri: info.url,
} };
: undefined; }
authResolve({ }
resolveAuthInfo({
url: info.url, url: info.url,
instructions: appendManualCodeHint(info.instructions, provider, origin), instructions: appendManualCodeHint(info.instructions, provider, origin),
deviceCode, deviceCode: resolvedDeviceCode,
});
},
onDeviceCode: (info) => {
resolvedDeviceCode = {
userCode: info.userCode,
verificationUri: info.verificationUri,
};
resolveAuthInfo({
url: info.verificationUri,
instructions: appendManualCodeHint(undefined, provider, origin),
deviceCode: resolvedDeviceCode,
}); });
}, },
onPrompt: async (_prompt) => { onPrompt: async (_prompt) => {
@@ -838,21 +866,27 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
// to race pasted codes against the localhost callback server. // to race pasted codes against the localhost callback server.
onManualCodeInput: async () => await pendingLogin.inputPromise, onManualCodeInput: async () => await pendingLogin.inputPromise,
onProgress: () => {}, // no-op for web UI onProgress: () => {}, // no-op for web UI
onSelect: async (prompt) => {
if (prompt.options.length === 1) {
return prompt.options[0]?.id;
}
return undefined;
},
signal: abortController.signal, signal: abortController.signal,
}); });
// Race: either we get the auth URL or the login completes/fails first // Race: either we get the auth URL or the login completes/fails first
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
authReject(new Error("Login initiation timed out")); rejectAuthInfo(new Error("Login initiation timed out"));
}, 30_000); }, 30_000);
loginPromise loginPromise
.then(() => { .then(() => {
// Login completed (user finished OAuth in browser) // Login completed (user finished OAuth in browser)
}) })
.catch((err) => { .catch((err: unknown) => {
// Login failed — also reject auth URL if not yet received // Login failed — also reject auth URL if not yet received
authReject(err); rejectAuthInfo(err instanceof Error ? err : new Error(String(err)));
}) })
.finally(() => { .finally(() => {
clearTimeout(timeout); clearTimeout(timeout);

View File

@@ -743,8 +743,8 @@ describe("RoadmapStore", () => {
}); });
describe("schema version", () => { describe("schema version", () => {
it("schema version is 94 after init", () => { it("schema version is 95 after init", () => {
expect(db.getSchemaVersion()).toBe(94); expect(db.getSchemaVersion()).toBe(95);
}); });
}); });