feat(FN-4554): complete Step 2 — server device-code handling
Fusion-Task-Id: FN-4554 Fusion-Task-Lineage: 12d517c5-1954-4b27-8c63-c7f0dde93d3f
This commit is contained in:
@@ -40,6 +40,7 @@ import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.js";
|
||||
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js";
|
||||
import * as updateCheckModule from "../update-check.js";
|
||||
import { __setAgentReflectionServiceForTests } from "../routes/register-agent-reflection-rating-routes.js";
|
||||
import { parseGitHubCopilotDeviceCode } from "../routes/register-auth-routes.js";
|
||||
|
||||
// Mock @fusion/core for gh CLI auth checks
|
||||
const mockCentralListProjects = vi.fn().mockResolvedValue([]);
|
||||
@@ -1201,6 +1202,45 @@ describe("POST /auth/login", () => {
|
||||
expect(res.body.instructions).toBe("Open in browser");
|
||||
});
|
||||
|
||||
it("returns parsed deviceCode for github-copilot", async () => {
|
||||
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
|
||||
callbacks.onAuth({
|
||||
url: "https://github.com/login/device",
|
||||
instructions: "Enter code: ABCD-1234",
|
||||
});
|
||||
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: "ABCD-1234",
|
||||
verificationUri: "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) => {
|
||||
promptValue = await callbacks.onPrompt({
|
||||
message: "GitHub Enterprise URL/domain (blank for github.com)",
|
||||
placeholder: "company.ghe.com",
|
||||
allowEmpty: true,
|
||||
});
|
||||
callbacks.onAuth({ url: "https://github.com/login/device", instructions: "Enter code: ABCD-1234" });
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "github-copilot" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(promptValue).toBe("");
|
||||
});
|
||||
|
||||
it("rewrites redirect_uri to dashboard oauth proxy when origin is non-localhost", async () => {
|
||||
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
|
||||
callbacks.onAuth({
|
||||
@@ -1322,6 +1362,31 @@ describe("POST /auth/login", () => {
|
||||
helpText: "After Claude sign-in, copy the full browser URL (or just the code) and paste it here to finish login from this dashboard host.",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-resolve prompt for anthropic", async () => {
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([{ id: "anthropic", name: "Anthropic" }]);
|
||||
|
||||
let observedPromptInput: string | undefined;
|
||||
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(async (_provider: string, callbacks: any) => {
|
||||
callbacks.onAuth({ url: "https://claude.ai/oauth/authorize?state=s&redirect_uri=http%3A%2F%2Flocalhost%3A3210%2Fcb" });
|
||||
observedPromptInput = await callbacks.onPrompt({ message: "Paste callback" });
|
||||
});
|
||||
|
||||
const app = buildApp();
|
||||
const loginReq = REQUEST(app, "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic", origin: "https://remote.example.com" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const submitRes = await REQUEST(app, "POST", "/api/auth/manual-code", JSON.stringify({ provider: "anthropic", code: "manual-code" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(submitRes.status).toBe(200);
|
||||
|
||||
await loginReq;
|
||||
expect(observedPromptInput).toBe("manual-code");
|
||||
});
|
||||
|
||||
it("returns 400 when provider is missing", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
@@ -1384,6 +1449,16 @@ describe("POST /auth/login", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGitHubCopilotDeviceCode", () => {
|
||||
it("parses a well-formed github copilot instruction string", () => {
|
||||
expect(parseGitHubCopilotDeviceCode("Enter code: ABCD-1234")).toBe("ABCD-1234");
|
||||
});
|
||||
|
||||
it("returns undefined for malformed instruction strings", () => {
|
||||
expect(parseGitHubCopilotDeviceCode("Open browser and continue")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /auth/cancel", () => {
|
||||
let store: TaskStore;
|
||||
let authStorage: AuthStorageLike;
|
||||
|
||||
@@ -10,6 +10,16 @@ import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js"
|
||||
import type { AuthStorageLike } from "../routes.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
export type DeviceCodeInfo = {
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
};
|
||||
|
||||
export function parseGitHubCopilotDeviceCode(instructions: string): string | undefined {
|
||||
const match = instructions.match(/Enter code:\s*([A-Z0-9-]+)\b/i);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, options, store, getScopedStore, rethrowAsApiError } = ctx;
|
||||
const authStorage = options?.authStorage;
|
||||
@@ -174,6 +184,10 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function providerWantsAutoPrompt(providerId: string): boolean {
|
||||
return providerId === "github-copilot";
|
||||
}
|
||||
|
||||
async function probeDroidCliWithEffectiveBinary(req?: Request) {
|
||||
let pluginSettings: Record<string, unknown> | undefined;
|
||||
if (req) {
|
||||
@@ -766,20 +780,22 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
}
|
||||
});
|
||||
const pendingLogin: PendingLogin = {
|
||||
abortController,
|
||||
inputPromise,
|
||||
resolveInput,
|
||||
rejectInput,
|
||||
inputSubmitted: false,
|
||||
manualCode: getManualCodeConfig(provider, origin),
|
||||
};
|
||||
loginInProgress.set(provider, pendingLogin);
|
||||
abortController,
|
||||
inputPromise,
|
||||
resolveInput,
|
||||
rejectInput,
|
||||
inputSubmitted: false,
|
||||
manualCode: getManualCodeConfig(provider, origin),
|
||||
};
|
||||
loginInProgress.set(provider, pendingLogin);
|
||||
|
||||
let autoPromptConsumed = false;
|
||||
|
||||
// We need to get the URL from the onAuth callback before responding.
|
||||
// The login() call continues in the background until the user completes OAuth.
|
||||
let authResolve: (info: { url: string; instructions?: string }) => void;
|
||||
let authResolve: (info: { url: string; instructions?: string; deviceCode?: DeviceCodeInfo }) => void;
|
||||
let authReject: (err: Error) => void;
|
||||
const authUrlPromise = new Promise<{ url: string; instructions?: string }>((resolve, reject) => {
|
||||
const authUrlPromise = new Promise<{ url: string; instructions?: string; deviceCode?: DeviceCodeInfo }>((resolve, reject) => {
|
||||
authResolve = resolve;
|
||||
authReject = reject;
|
||||
});
|
||||
@@ -787,12 +803,26 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
// Start login flow in background — don't await the full login
|
||||
const loginPromise = storage.login(provider, {
|
||||
onAuth: (info) => {
|
||||
const deviceCode =
|
||||
provider === "github-copilot" && info.instructions
|
||||
? {
|
||||
userCode: parseGitHubCopilotDeviceCode(info.instructions),
|
||||
verificationUri: info.url,
|
||||
}
|
||||
: undefined;
|
||||
authResolve({
|
||||
url: info.url,
|
||||
instructions: appendManualCodeHint(info.instructions, provider, origin),
|
||||
deviceCode: deviceCode?.userCode ? deviceCode : undefined,
|
||||
});
|
||||
},
|
||||
onPrompt: async () => await pendingLogin.inputPromise,
|
||||
onPrompt: async (_prompt) => {
|
||||
if (providerWantsAutoPrompt(provider) && !autoPromptConsumed) {
|
||||
autoPromptConsumed = true;
|
||||
return "";
|
||||
}
|
||||
return await pendingLogin.inputPromise;
|
||||
},
|
||||
// AuthStorage.login() forwards callbacks to provider-specific OAuth
|
||||
// implementations verbatim. openai-codex supports this optional hook
|
||||
// to race pasted codes against the localhost callback server.
|
||||
@@ -837,6 +867,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
url: responseUrl,
|
||||
instructions: authInfo.instructions,
|
||||
manualCode: pendingLogin.manualCode,
|
||||
deviceCode: authInfo.deviceCode,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
Reference in New Issue
Block a user