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:
Fusion
2026-05-14 21:06:08 -07:00
committed by gsxdsm
parent cffd255b13
commit 78bb31542e
2 changed files with 117 additions and 11 deletions

View File

@@ -40,6 +40,7 @@ import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.js";
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js"; import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js";
import * as updateCheckModule from "../update-check.js"; import * as updateCheckModule from "../update-check.js";
import { __setAgentReflectionServiceForTests } from "../routes/register-agent-reflection-rating-routes.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 // Mock @fusion/core for gh CLI auth checks
const mockCentralListProjects = vi.fn().mockResolvedValue([]); const mockCentralListProjects = vi.fn().mockResolvedValue([]);
@@ -1201,6 +1202,45 @@ describe("POST /auth/login", () => {
expect(res.body.instructions).toBe("Open in browser"); 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 () => { 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) => { (authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
callbacks.onAuth({ 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.", 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 () => { it("returns 400 when provider is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), { const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), {
"Content-Type": "application/json", "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", () => { describe("POST /auth/cancel", () => {
let store: TaskStore; let store: TaskStore;
let authStorage: AuthStorageLike; let authStorage: AuthStorageLike;

View File

@@ -10,6 +10,16 @@ import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js"
import type { AuthStorageLike } from "../routes.js"; import type { AuthStorageLike } from "../routes.js";
import type { ApiRouteRegistrar } from "./types.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) => { export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
const { router, options, store, getScopedStore, rethrowAsApiError } = ctx; const { router, options, store, getScopedStore, rethrowAsApiError } = ctx;
const authStorage = options?.authStorage; const authStorage = options?.authStorage;
@@ -174,6 +184,10 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
return undefined; return undefined;
} }
function providerWantsAutoPrompt(providerId: string): boolean {
return providerId === "github-copilot";
}
async function probeDroidCliWithEffectiveBinary(req?: Request) { async function probeDroidCliWithEffectiveBinary(req?: Request) {
let pluginSettings: Record<string, unknown> | undefined; let pluginSettings: Record<string, unknown> | undefined;
if (req) { if (req) {
@@ -766,20 +780,22 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
} }
}); });
const pendingLogin: PendingLogin = { const pendingLogin: PendingLogin = {
abortController, abortController,
inputPromise, inputPromise,
resolveInput, resolveInput,
rejectInput, rejectInput,
inputSubmitted: false, inputSubmitted: false,
manualCode: getManualCodeConfig(provider, origin), manualCode: getManualCodeConfig(provider, origin),
}; };
loginInProgress.set(provider, pendingLogin); loginInProgress.set(provider, pendingLogin);
let autoPromptConsumed = false;
// We need to get the URL from the onAuth callback before responding. // We need to get the URL from the onAuth callback 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 }) => void; let authResolve: (info: { url: string; instructions?: string; deviceCode?: DeviceCodeInfo }) => void;
let authReject: (err: Error) => 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; authResolve = resolve;
authReject = reject; authReject = reject;
}); });
@@ -787,12 +803,26 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
// 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 deviceCode =
provider === "github-copilot" && info.instructions
? {
userCode: parseGitHubCopilotDeviceCode(info.instructions),
verificationUri: info.url,
}
: undefined;
authResolve({ authResolve({
url: info.url, url: info.url,
instructions: appendManualCodeHint(info.instructions, provider, origin), 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 // AuthStorage.login() forwards callbacks to provider-specific OAuth
// implementations verbatim. openai-codex supports this optional hook // implementations verbatim. openai-codex supports this optional hook
// to race pasted codes against the localhost callback server. // to race pasted codes against the localhost callback server.
@@ -837,6 +867,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
url: responseUrl, url: responseUrl,
instructions: authInfo.instructions, instructions: authInfo.instructions,
manualCode: pendingLogin.manualCode, manualCode: pendingLogin.manualCode,
deviceCode: authInfo.deviceCode,
}); });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {