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 () => {
let promptValue: string | undefined;
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(async (_provider: string, callbacks: any) => {

View File

@@ -206,9 +206,16 @@ export interface AuthStorageLike {
providerId: string,
callbacks: {
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>;
onManualCodeInput?: () => Promise<string>;
onProgress?: (message: string) => void;
onSelect?: (prompt: { message: string; options: Array<{ id: string; label: string }> }) => Promise<string | undefined>;
signal?: AbortSignal;
},
): Promise<void>;

View File

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

View File

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