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 { 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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user