mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 02:01:29 +00:00
Improve OAuth login UI with consistent dialog component
- Add LoginDialogComponent with proper borders (top/bottom DynamicBorder) - Refactor all OAuth providers to use racing approach (browser callback vs manual paste) - Add onEscape handler to Input component for cancellation - Add abortable sleep for GitHub Copilot polling (instant cancel on Escape) - Show OS-specific click hint (Cmd+click on macOS, Ctrl+click elsewhere) - Clear content between login phases (fixes GitHub Copilot two-phase flow) - Use InteractiveMode's showStatus/showError for result messages - Reorder providers: Anthropic, ChatGPT, GitHub Copilot, Gemini CLI, Antigravity
This commit is contained in:
parent
05b9d55656
commit
9b12719ab1
9 changed files with 550 additions and 200 deletions
|
|
@ -136,17 +136,45 @@ async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep that can be interrupted by an AbortSignal
|
||||
*/
|
||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Login cancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(resolve, ms);
|
||||
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Login cancelled"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function pollForGitHubAccessToken(
|
||||
domain: string,
|
||||
deviceCode: string,
|
||||
intervalSeconds: number,
|
||||
expiresIn: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const urls = getUrls(domain);
|
||||
const deadline = Date.now() + expiresIn * 1000;
|
||||
let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000));
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
|
||||
const raw = await fetchJson(urls.accessTokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -168,20 +196,20 @@ async function pollForGitHubAccessToken(
|
|||
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
|
||||
const err = (raw as DeviceTokenErrorResponse).error;
|
||||
if (err === "authorization_pending") {
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
await abortableSleep(intervalMs, signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (err === "slow_down") {
|
||||
intervalMs += 5000;
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
await abortableSleep(intervalMs, signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Device flow failed: ${err}`);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
await abortableSleep(intervalMs, signal);
|
||||
}
|
||||
|
||||
throw new Error("Device flow timed out");
|
||||
|
|
@ -274,11 +302,13 @@ async function enableAllGitHubCopilotModels(
|
|||
* @param options.onAuth - Callback with URL and optional instructions (user code)
|
||||
* @param options.onPrompt - Callback to prompt user for input
|
||||
* @param options.onProgress - Optional progress callback
|
||||
* @param options.signal - Optional AbortSignal for cancellation
|
||||
*/
|
||||
export async function loginGitHubCopilot(options: {
|
||||
onAuth: (url: string, instructions?: string) => void;
|
||||
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
|
||||
onProgress?: (message: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<OAuthCredentials> {
|
||||
const input = await options.onPrompt({
|
||||
message: "GitHub Enterprise URL/domain (blank for github.com)",
|
||||
|
|
@ -286,6 +316,10 @@ export async function loginGitHubCopilot(options: {
|
|||
allowEmpty: true,
|
||||
});
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
const enterpriseDomain = normalizeDomain(input);
|
||||
if (trimmed && !enterpriseDomain) {
|
||||
|
|
@ -301,6 +335,7 @@ export async function loginGitHubCopilot(options: {
|
|||
device.device_code,
|
||||
device.interval,
|
||||
device.expires_in,
|
||||
options.signal,
|
||||
);
|
||||
const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined);
|
||||
|
||||
|
|
|
|||
|
|
@ -33,23 +33,21 @@ const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|||
// Fallback project ID when discovery fails
|
||||
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
|
||||
|
||||
type CallbackServerInfo = {
|
||||
server: Server;
|
||||
cancelWait: () => void;
|
||||
waitForCode: () => Promise<{ code: string; state: string } | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a local HTTP server to receive the OAuth callback
|
||||
*/
|
||||
async function startCallbackServer(): Promise<{
|
||||
server: Server;
|
||||
getCode: () => Promise<{ code: string; state: string }>;
|
||||
}> {
|
||||
async function startCallbackServer(): Promise<CallbackServerInfo> {
|
||||
const { createServer } = await import("http");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let codeResolve: (value: { code: string; state: string }) => void;
|
||||
let codeReject: (error: Error) => void;
|
||||
|
||||
const codePromise = new Promise<{ code: string; state: string }>((res, rej) => {
|
||||
codeResolve = res;
|
||||
codeReject = rej;
|
||||
});
|
||||
let result: { code: string; state: string } | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url || "", `http://localhost:51121`);
|
||||
|
|
@ -64,7 +62,6 @@ async function startCallbackServer(): Promise<{
|
|||
res.end(
|
||||
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
|
||||
);
|
||||
codeReject(new Error(`OAuth error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -73,13 +70,12 @@ async function startCallbackServer(): Promise<{
|
|||
res.end(
|
||||
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
|
||||
);
|
||||
codeResolve({ code, state });
|
||||
result = { code, state };
|
||||
} else {
|
||||
res.writeHead(400, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
|
||||
);
|
||||
codeReject(new Error("Missing code or state in callback"));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
|
|
@ -94,12 +90,40 @@ async function startCallbackServer(): Promise<{
|
|||
server.listen(51121, "127.0.0.1", () => {
|
||||
resolve({
|
||||
server,
|
||||
getCode: () => codePromise,
|
||||
cancelWait: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
waitForCode: async () => {
|
||||
const sleep = () => new Promise((r) => setTimeout(r, 100));
|
||||
while (!result && !cancelled) {
|
||||
await sleep();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse redirect URL to extract code and state
|
||||
*/
|
||||
function parseRedirectUrl(input: string): { code?: string; state?: string } {
|
||||
const value = input.trim();
|
||||
if (!value) return {};
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return {
|
||||
code: url.searchParams.get("code") ?? undefined,
|
||||
state: url.searchParams.get("state") ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
// Not a URL, return empty
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
interface LoadCodeAssistPayload {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
currentTier?: { id?: string };
|
||||
|
|
@ -226,16 +250,21 @@ export async function refreshAntigravityToken(refreshToken: string, projectId: s
|
|||
*
|
||||
* @param onAuth - Callback with URL and optional instructions
|
||||
* @param onProgress - Optional progress callback
|
||||
* @param onManualCodeInput - Optional promise that resolves with user-pasted redirect URL.
|
||||
* Races with browser callback - whichever completes first wins.
|
||||
*/
|
||||
export async function loginAntigravity(
|
||||
onAuth: (info: { url: string; instructions?: string }) => void,
|
||||
onProgress?: (message: string) => void,
|
||||
onManualCodeInput?: () => Promise<string>,
|
||||
): Promise<OAuthCredentials> {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
|
||||
// Start local server for callback
|
||||
onProgress?.("Starting local server for OAuth callback...");
|
||||
const { server, getCode } = await startCallbackServer();
|
||||
const server = await startCallbackServer();
|
||||
|
||||
let code: string | undefined;
|
||||
|
||||
try {
|
||||
// Build authorization URL
|
||||
|
|
@ -256,16 +285,75 @@ export async function loginAntigravity(
|
|||
// Notify caller with URL to open
|
||||
onAuth({
|
||||
url: authUrl,
|
||||
instructions: "Complete the sign-in in your browser. The callback will be captured automatically.",
|
||||
instructions: "Complete the sign-in in your browser.",
|
||||
});
|
||||
|
||||
// Wait for the callback
|
||||
// Wait for the callback, racing with manual input if provided
|
||||
onProgress?.("Waiting for OAuth callback...");
|
||||
const { code, state } = await getCode();
|
||||
|
||||
// Verify state matches
|
||||
if (state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - possible CSRF attack");
|
||||
if (onManualCodeInput) {
|
||||
// Race between browser callback and manual input
|
||||
let manualInput: string | undefined;
|
||||
let manualError: Error | undefined;
|
||||
const manualPromise = onManualCodeInput()
|
||||
.then((input) => {
|
||||
manualInput = input;
|
||||
server.cancelWait();
|
||||
})
|
||||
.catch((err) => {
|
||||
manualError = err instanceof Error ? err : new Error(String(err));
|
||||
server.cancelWait();
|
||||
});
|
||||
|
||||
const result = await server.waitForCode();
|
||||
|
||||
// If manual input was cancelled, throw that error
|
||||
if (manualError) {
|
||||
throw manualError;
|
||||
}
|
||||
|
||||
if (result?.code) {
|
||||
// Browser callback won - verify state
|
||||
if (result.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - possible CSRF attack");
|
||||
}
|
||||
code = result.code;
|
||||
} else if (manualInput) {
|
||||
// Manual input won
|
||||
const parsed = parseRedirectUrl(manualInput);
|
||||
if (parsed.state && parsed.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - possible CSRF attack");
|
||||
}
|
||||
code = parsed.code;
|
||||
}
|
||||
|
||||
// If still no code, wait for manual promise and try that
|
||||
if (!code) {
|
||||
await manualPromise;
|
||||
if (manualError) {
|
||||
throw manualError;
|
||||
}
|
||||
if (manualInput) {
|
||||
const parsed = parseRedirectUrl(manualInput);
|
||||
if (parsed.state && parsed.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - possible CSRF attack");
|
||||
}
|
||||
code = parsed.code;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Original flow: just wait for callback
|
||||
const result = await server.waitForCode();
|
||||
if (result?.code) {
|
||||
if (result.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - possible CSRF attack");
|
||||
}
|
||||
code = result.code;
|
||||
}
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error("No authorization code received");
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
|
|
@ -320,6 +408,6 @@ export async function loginAntigravity(
|
|||
|
||||
return credentials;
|
||||
} finally {
|
||||
server.close();
|
||||
server.server.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,23 +25,21 @@ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
||||
|
||||
type CallbackServerInfo = {
|
||||
server: Server;
|
||||
cancelWait: () => void;
|
||||
waitForCode: () => Promise<{ code: string; state: string } | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a local HTTP server to receive the OAuth callback
|
||||
*/
|
||||
async function startCallbackServer(): Promise<{
|
||||
server: Server;
|
||||
getCode: () => Promise<{ code: string; state: string }>;
|
||||
}> {
|
||||
async function startCallbackServer(): Promise<CallbackServerInfo> {
|
||||
const { createServer } = await import("http");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let codeResolve: (value: { code: string; state: string }) => void;
|
||||
let codeReject: (error: Error) => void;
|
||||
|
||||
const codePromise = new Promise<{ code: string; state: string }>((res, rej) => {
|
||||
codeResolve = res;
|
||||
codeReject = rej;
|
||||
});
|
||||
let result: { code: string; state: string } | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url || "", `http://localhost:8085`);
|
||||
|
|
@ -56,7 +54,6 @@ async function startCallbackServer(): Promise<{
|
|||
res.end(
|
||||
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
|
||||
);
|
||||
codeReject(new Error(`OAuth error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -65,13 +62,12 @@ async function startCallbackServer(): Promise<{
|
|||
res.end(
|
||||
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
|
||||
);
|
||||
codeResolve({ code, state });
|
||||
result = { code, state };
|
||||
} else {
|
||||
res.writeHead(400, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
|
||||
);
|
||||
codeReject(new Error("Missing code or state in callback"));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
|
|
@ -86,12 +82,40 @@ async function startCallbackServer(): Promise<{
|
|||
server.listen(8085, "127.0.0.1", () => {
|
||||
resolve({
|
||||
server,
|
||||
getCode: () => codePromise,
|
||||
cancelWait: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
waitForCode: async () => {
|
||||
const sleep = () => new Promise((r) => setTimeout(r, 100));
|
||||
while (!result && !cancelled) {
|
||||
await sleep();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse redirect URL to extract code and state
|
||||
*/
|
||||
function parseRedirectUrl(input: string): { code?: string; state?: string } {
|
||||
const value = input.trim();
|
||||
if (!value) return {};
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return {
|
||||
code: url.searchParams.get("code") ?? undefined,
|
||||
state: url.searchParams.get("state") ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
// Not a URL, return empty
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
interface LoadCodeAssistPayload {
|
||||
cloudaicompanionProject?: string;
|
||||
currentTier?: { id?: string };
|
||||
|
|
@ -257,16 +281,21 @@ export async function refreshGoogleCloudToken(refreshToken: string, projectId: s
|
|||
*
|
||||
* @param onAuth - Callback with URL and optional instructions
|
||||
* @param onProgress - Optional progress callback
|
||||
* @param onManualCodeInput - Optional promise that resolves with user-pasted redirect URL.
|
||||
* Races with browser callback - whichever completes first wins.
|
||||
*/
|
||||
export async function loginGeminiCli(
|
||||
onAuth: (info: { url: string; instructions?: string }) => void,
|
||||
onProgress?: (message: string) => void,
|
||||
onManualCodeInput?: () => Promise<string>,
|
||||
): Promise<OAuthCredentials> {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
|
||||
// Start local server for callback
|
||||
onProgress?.("Starting local server for OAuth callback...");
|
||||
const { server, getCode } = await startCallbackServer();
|
||||
const server = await startCallbackServer();
|
||||
|
||||
let code: string | undefined;
|
||||
|
||||
try {
|
||||
// Build authorization URL
|
||||
|
|
@ -287,16 +316,75 @@ export async function loginGeminiCli(
|
|||
// Notify caller with URL to open
|
||||
onAuth({
|
||||
url: authUrl,
|
||||
instructions: "Complete the sign-in in your browser. The callback will be captured automatically.",
|
||||
instructions: "Complete the sign-in in your browser.",
|
||||
});
|
||||
|
||||
// Wait for the callback
|
||||
// Wait for the callback, racing with manual input if provided
|
||||
onProgress?.("Waiting for OAuth callback...");
|
||||
const { code, state } = await getCode();
|
||||
|
||||
// Verify state matches
|
||||
if (state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - possible CSRF attack");
|
||||
if (onManualCodeInput) {
|
||||
// Race between browser callback and manual input
|
||||
let manualInput: string | undefined;
|
||||
let manualError: Error | undefined;
|
||||
const manualPromise = onManualCodeInput()
|
||||
.then((input) => {
|
||||
manualInput = input;
|
||||
server.cancelWait();
|
||||
})
|
||||
.catch((err) => {
|
||||
manualError = err instanceof Error ? err : new Error(String(err));
|
||||
server.cancelWait();
|
||||
});
|
||||
|
||||
const result = await server.waitForCode();
|
||||
|
||||
// If manual input was cancelled, throw that error
|
||||
if (manualError) {
|
||||
throw manualError;
|
||||
}
|
||||
|
||||
if (result?.code) {
|
||||
// Browser callback won - verify state
|
||||
if (result.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - possible CSRF attack");
|
||||
}
|
||||
code = result.code;
|
||||
} else if (manualInput) {
|
||||
// Manual input won
|
||||
const parsed = parseRedirectUrl(manualInput);
|
||||
if (parsed.state && parsed.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - possible CSRF attack");
|
||||
}
|
||||
code = parsed.code;
|
||||
}
|
||||
|
||||
// If still no code, wait for manual promise and try that
|
||||
if (!code) {
|
||||
await manualPromise;
|
||||
if (manualError) {
|
||||
throw manualError;
|
||||
}
|
||||
if (manualInput) {
|
||||
const parsed = parseRedirectUrl(manualInput);
|
||||
if (parsed.state && parsed.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - possible CSRF attack");
|
||||
}
|
||||
code = parsed.code;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Original flow: just wait for callback
|
||||
const result = await server.waitForCode();
|
||||
if (result?.code) {
|
||||
if (result.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - possible CSRF attack");
|
||||
}
|
||||
code = result.code;
|
||||
}
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error("No authorization code received");
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
|
|
@ -351,6 +439,6 @@ export async function loginGeminiCli(
|
|||
|
||||
return credentials;
|
||||
} finally {
|
||||
server.close();
|
||||
server.server.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,11 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
|
|||
name: "Anthropic (Claude Pro/Max)",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "openai-codex",
|
||||
name: "ChatGPT Plus/Pro (Codex Subscription)",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "github-copilot",
|
||||
name: "GitHub Copilot",
|
||||
|
|
@ -148,10 +153,5 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
|
|||
name: "Antigravity (Gemini 3, Claude, GPT-OSS)",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "openai-codex",
|
||||
name: "ChatGPT Plus/Pro (Codex Subscription)",
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -296,15 +296,25 @@ export async function loginOpenAICodex(options: {
|
|||
if (options.onManualCodeInput) {
|
||||
// Race between browser callback and manual input
|
||||
let manualCode: string | undefined;
|
||||
let manualError: Error | undefined;
|
||||
const manualPromise = options
|
||||
.onManualCodeInput()
|
||||
.then((input) => {
|
||||
manualCode = input;
|
||||
server.cancelWait();
|
||||
})
|
||||
.catch(() => {}); // Ignore rejection
|
||||
.catch((err) => {
|
||||
manualError = err instanceof Error ? err : new Error(String(err));
|
||||
server.cancelWait();
|
||||
});
|
||||
|
||||
const result = await server.waitForCode();
|
||||
|
||||
// If manual input was cancelled, throw that error
|
||||
if (manualError) {
|
||||
throw manualError;
|
||||
}
|
||||
|
||||
if (result?.code) {
|
||||
// Browser callback won
|
||||
code = result.code;
|
||||
|
|
@ -320,6 +330,9 @@ export async function loginOpenAICodex(options: {
|
|||
// If still no code, wait for manual promise to complete and try that
|
||||
if (!code) {
|
||||
await manualPromise;
|
||||
if (manualError) {
|
||||
throw manualError;
|
||||
}
|
||||
if (manualCode) {
|
||||
const parsed = parseAuthorizationInput(manualCode);
|
||||
if (parsed.state && parsed.state !== state) {
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ export class AuthStorage {
|
|||
onProgress?: (message: string) => void;
|
||||
/** For providers with local callback servers (e.g., openai-codex), races with browser callback */
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
/** For cancellation support (e.g., github-copilot polling) */
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<void> {
|
||||
let credentials: OAuthCredentials;
|
||||
|
|
@ -179,13 +181,14 @@ export class AuthStorage {
|
|||
onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
signal: callbacks.signal,
|
||||
});
|
||||
break;
|
||||
case "google-gemini-cli":
|
||||
credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress);
|
||||
credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);
|
||||
break;
|
||||
case "google-antigravity":
|
||||
credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress);
|
||||
credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);
|
||||
break;
|
||||
case "openai-codex":
|
||||
credentials = await loginOpenAICodex({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
import { getOAuthProviders } from "@mariozechner/pi-ai";
|
||||
import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import { exec } from "child_process";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
/**
|
||||
* Login dialog component - replaces editor during OAuth login flow
|
||||
*/
|
||||
export class LoginDialogComponent extends Container {
|
||||
private contentContainer: Container;
|
||||
private input: Input;
|
||||
private tui: TUI;
|
||||
private abortController = new AbortController();
|
||||
private inputResolver?: (value: string) => void;
|
||||
private inputRejecter?: (error: Error) => void;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
providerId: string,
|
||||
private onComplete: (success: boolean, message?: string) => void,
|
||||
) {
|
||||
super();
|
||||
this.tui = tui;
|
||||
|
||||
const providerInfo = getOAuthProviders().find((p) => p.id === providerId);
|
||||
const providerName = providerInfo?.name || providerId;
|
||||
|
||||
// Top border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Title
|
||||
this.addChild(new Text(theme.fg("warning", `Login to ${providerName}`), 1, 0));
|
||||
|
||||
// Dynamic content area
|
||||
this.contentContainer = new Container();
|
||||
this.addChild(this.contentContainer);
|
||||
|
||||
// Input (always present, used when needed)
|
||||
this.input = new Input();
|
||||
this.input.onSubmit = () => {
|
||||
if (this.inputResolver) {
|
||||
this.inputResolver(this.input.getValue());
|
||||
this.inputResolver = undefined;
|
||||
this.inputRejecter = undefined;
|
||||
}
|
||||
};
|
||||
this.input.onEscape = () => {
|
||||
this.cancel();
|
||||
};
|
||||
|
||||
// Bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
get signal(): AbortSignal {
|
||||
return this.abortController.signal;
|
||||
}
|
||||
|
||||
private cancel(): void {
|
||||
this.abortController.abort();
|
||||
if (this.inputRejecter) {
|
||||
this.inputRejecter(new Error("Login cancelled"));
|
||||
this.inputResolver = undefined;
|
||||
this.inputRejecter = undefined;
|
||||
}
|
||||
this.onComplete(false, "Login cancelled");
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by onAuth callback - show URL and optional instructions
|
||||
*/
|
||||
showAuth(url: string, instructions?: string): void {
|
||||
this.contentContainer.clear();
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
this.contentContainer.addChild(new Text(theme.fg("accent", url), 1, 0));
|
||||
|
||||
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
|
||||
const hyperlink = `\x1b]8;;${url}\x07${clickHint}\x1b]8;;\x07`;
|
||||
this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0));
|
||||
|
||||
if (instructions) {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0));
|
||||
}
|
||||
|
||||
// Try to open browser
|
||||
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
||||
exec(`${openCmd} "${url}"`);
|
||||
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show input for manual code/URL entry (for callback server providers)
|
||||
*/
|
||||
showManualInput(prompt: string): Promise<string> {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
|
||||
this.contentContainer.addChild(this.input);
|
||||
this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0));
|
||||
this.tui.requestRender();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.inputResolver = resolve;
|
||||
this.inputRejecter = reject;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by onPrompt callback - show prompt and wait for input
|
||||
*/
|
||||
showPrompt(message: string, placeholder?: string): Promise<string> {
|
||||
this.contentContainer.clear();
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
this.contentContainer.addChild(new Text(theme.fg("text", message), 1, 0));
|
||||
if (placeholder) {
|
||||
this.contentContainer.addChild(new Text(theme.fg("dim", `e.g., ${placeholder}`), 1, 0));
|
||||
}
|
||||
this.contentContainer.addChild(this.input);
|
||||
this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel, Enter to submit)"), 1, 0));
|
||||
|
||||
this.input.setValue("");
|
||||
this.tui.requestRender();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.inputResolver = resolve;
|
||||
this.inputRejecter = reject;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show waiting message (for polling flows like GitHub Copilot)
|
||||
*/
|
||||
showWaiting(message: string): void {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
|
||||
this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0));
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by onProgress callback
|
||||
*/
|
||||
showProgress(message: string): void {
|
||||
this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
const kb = getEditorKeybindings();
|
||||
|
||||
if (kb.matches(data, "selectCancel")) {
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass to input
|
||||
this.input.handleInput(data);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,14 +9,13 @@ import * as os from "node:os";
|
|||
import * as path from "node:path";
|
||||
import Clipboard from "@crosscopy/clipboard";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, Message, OAuthProvider } from "@mariozechner/pi-ai";
|
||||
import { type AssistantMessage, getOAuthProviders, type Message, type OAuthProvider } from "@mariozechner/pi-ai";
|
||||
import type { KeyId, SlashCommand } from "@mariozechner/pi-tui";
|
||||
import {
|
||||
CombinedAutocompleteProvider,
|
||||
type Component,
|
||||
Container,
|
||||
getEditorKeybindings,
|
||||
Input,
|
||||
Loader,
|
||||
Markdown,
|
||||
matchesKey,
|
||||
|
|
@ -27,7 +26,7 @@ import {
|
|||
TUI,
|
||||
visibleWidth,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { exec, spawn, spawnSync } from "child_process";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js";
|
||||
import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js";
|
||||
import type {
|
||||
|
|
@ -57,6 +56,7 @@ import { ExtensionEditorComponent } from "./components/extension-editor.js";
|
|||
import { ExtensionInputComponent } from "./components/extension-input.js";
|
||||
import { ExtensionSelectorComponent } from "./components/extension-selector.js";
|
||||
import { FooterComponent } from "./components/footer.js";
|
||||
import { LoginDialogComponent } from "./components/login-dialog.js";
|
||||
import { ModelSelectorComponent } from "./components/model-selector.js";
|
||||
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
|
||||
import { SessionSelectorComponent } from "./components/session-selector.js";
|
||||
|
|
@ -2122,150 +2122,16 @@ export class InteractiveMode {
|
|||
done();
|
||||
|
||||
if (mode === "login") {
|
||||
this.showStatus(`Logging in to ${providerId}...`);
|
||||
|
||||
// For openai-codex: promise that resolves when user pastes code manually
|
||||
let manualCodeResolve: ((code: string) => void) | undefined;
|
||||
const manualCodePromise = new Promise<string>((resolve) => {
|
||||
manualCodeResolve = resolve;
|
||||
});
|
||||
let manualCodeInput: Input | undefined;
|
||||
|
||||
try {
|
||||
|
||||
await this.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
|
||||
onAuth: (info: { url: string; instructions?: string }) => {
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
|
||||
// OSC 8 hyperlink for desktop terminals that support clicking
|
||||
const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
|
||||
this.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
|
||||
|
||||
// OSC 52 to copy URL to clipboard (works over SSH, e.g., Termux)
|
||||
const urlBase64 = Buffer.from(info.url).toString("base64");
|
||||
process.stdout.write(`\x1b]52;c;${urlBase64}\x07`);
|
||||
this.chatContainer.addChild(
|
||||
new Text(theme.fg("dim", "(URL copied to clipboard - paste in browser)"), 1, 0),
|
||||
);
|
||||
|
||||
if (info.instructions) {
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
|
||||
}
|
||||
|
||||
this.ui.requestRender();
|
||||
|
||||
// Try to open browser on desktop
|
||||
const openCmd =
|
||||
process.platform === "darwin"
|
||||
? "open"
|
||||
: process.platform === "win32"
|
||||
? "start"
|
||||
: "xdg-open";
|
||||
exec(`${openCmd} "${info.url}"`);
|
||||
|
||||
// For openai-codex: show paste input immediately (races with browser callback)
|
||||
if (providerId === "openai-codex") {
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(
|
||||
new Text(
|
||||
theme.fg("dim", "Alternatively, paste the authorization code or redirect URL below:"),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
);
|
||||
manualCodeInput = new Input();
|
||||
manualCodeInput.onSubmit = () => {
|
||||
const code = manualCodeInput!.getValue();
|
||||
if (code && manualCodeResolve) {
|
||||
manualCodeResolve(code);
|
||||
manualCodeResolve = undefined;
|
||||
}
|
||||
};
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(manualCodeInput);
|
||||
this.ui.setFocus(manualCodeInput);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
},
|
||||
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
|
||||
// Clean up manual code input if it exists (fallback case)
|
||||
if (manualCodeInput) {
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
manualCodeInput = undefined;
|
||||
}
|
||||
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
|
||||
if (prompt.placeholder) {
|
||||
this.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
|
||||
}
|
||||
this.ui.requestRender();
|
||||
|
||||
return new Promise<string>((resolve) => {
|
||||
const codeInput = new Input();
|
||||
codeInput.onSubmit = () => {
|
||||
const code = codeInput.getValue();
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.ui.setFocus(this.editor);
|
||||
resolve(code);
|
||||
};
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(codeInput);
|
||||
this.ui.setFocus(codeInput);
|
||||
this.ui.requestRender();
|
||||
});
|
||||
},
|
||||
onProgress: (message: string) => {
|
||||
this.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
|
||||
this.ui.requestRender();
|
||||
},
|
||||
onManualCodeInput: () => manualCodePromise,
|
||||
});
|
||||
|
||||
// Clean up manual code input if browser callback succeeded
|
||||
if (manualCodeInput) {
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.ui.setFocus(this.editor);
|
||||
manualCodeInput = undefined;
|
||||
}
|
||||
|
||||
// Refresh models to pick up new baseUrl (e.g., github-copilot)
|
||||
this.session.modelRegistry.refresh();
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(
|
||||
new Text(theme.fg("success", `✓ Successfully logged in to ${providerId}`), 1, 0),
|
||||
);
|
||||
this.chatContainer.addChild(
|
||||
new Text(theme.fg("dim", `Credentials saved to ${getAuthPath()}`), 1, 0),
|
||||
);
|
||||
this.ui.requestRender();
|
||||
} catch (error: unknown) {
|
||||
// Clean up manual code input on error
|
||||
if (manualCodeInput) {
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.ui.setFocus(this.editor);
|
||||
manualCodeInput = undefined;
|
||||
}
|
||||
this.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
await this.showLoginDialog(providerId);
|
||||
} else {
|
||||
// Logout flow
|
||||
const providerInfo = getOAuthProviders().find((p) => p.id === providerId);
|
||||
const providerName = providerInfo?.name || providerId;
|
||||
|
||||
try {
|
||||
this.session.modelRegistry.authStorage.logout(providerId);
|
||||
// Refresh models to reset baseUrl
|
||||
this.session.modelRegistry.refresh();
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(
|
||||
new Text(theme.fg("success", `✓ Successfully logged out of ${providerId}`), 1, 0),
|
||||
);
|
||||
this.chatContainer.addChild(
|
||||
new Text(theme.fg("dim", `Credentials removed from ${getAuthPath()}`), 1, 0),
|
||||
);
|
||||
this.ui.requestRender();
|
||||
this.showStatus(`Logged out of ${providerName}`);
|
||||
} catch (error: unknown) {
|
||||
this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
|
@ -2280,6 +2146,95 @@ export class InteractiveMode {
|
|||
});
|
||||
}
|
||||
|
||||
private async showLoginDialog(providerId: string): Promise<void> {
|
||||
const providerInfo = getOAuthProviders().find((p) => p.id === providerId);
|
||||
const providerName = providerInfo?.name || providerId;
|
||||
|
||||
// Providers that use callback servers (can paste redirect URL)
|
||||
const usesCallbackServer =
|
||||
providerId === "openai-codex" || providerId === "google-gemini-cli" || providerId === "google-antigravity";
|
||||
|
||||
// Create login dialog component
|
||||
const dialog = new LoginDialogComponent(this.ui, providerId, (_success, _message) => {
|
||||
// Completion handled below
|
||||
});
|
||||
|
||||
// Show dialog in editor container
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(dialog);
|
||||
this.ui.setFocus(dialog);
|
||||
this.ui.requestRender();
|
||||
|
||||
// Promise for manual code input (racing with callback server)
|
||||
let manualCodeResolve: ((code: string) => void) | undefined;
|
||||
let manualCodeReject: ((err: Error) => void) | undefined;
|
||||
const manualCodePromise = new Promise<string>((resolve, reject) => {
|
||||
manualCodeResolve = resolve;
|
||||
manualCodeReject = reject;
|
||||
});
|
||||
|
||||
// Restore editor helper
|
||||
const restoreEditor = () => {
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.ui.setFocus(this.editor);
|
||||
this.ui.requestRender();
|
||||
};
|
||||
|
||||
try {
|
||||
await this.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
|
||||
onAuth: (info: { url: string; instructions?: string }) => {
|
||||
dialog.showAuth(info.url, info.instructions);
|
||||
|
||||
if (usesCallbackServer) {
|
||||
// Show input for manual paste, racing with callback
|
||||
dialog
|
||||
.showManualInput("Paste redirect URL below, or complete login in browser:")
|
||||
.then((value) => {
|
||||
if (value && manualCodeResolve) {
|
||||
manualCodeResolve(value);
|
||||
manualCodeResolve = undefined;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (manualCodeReject) {
|
||||
manualCodeReject(new Error("Login cancelled"));
|
||||
manualCodeReject = undefined;
|
||||
}
|
||||
});
|
||||
} else if (providerId === "github-copilot") {
|
||||
// GitHub Copilot polls after onAuth
|
||||
dialog.showWaiting("Waiting for browser authentication...");
|
||||
}
|
||||
// For Anthropic: onPrompt is called immediately after
|
||||
},
|
||||
|
||||
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
|
||||
return dialog.showPrompt(prompt.message, prompt.placeholder);
|
||||
},
|
||||
|
||||
onProgress: (message: string) => {
|
||||
dialog.showProgress(message);
|
||||
},
|
||||
|
||||
onManualCodeInput: () => manualCodePromise,
|
||||
|
||||
signal: dialog.signal,
|
||||
});
|
||||
|
||||
// Success
|
||||
restoreEditor();
|
||||
this.session.modelRegistry.refresh();
|
||||
this.showStatus(`Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`);
|
||||
} catch (error: unknown) {
|
||||
restoreEditor();
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
if (errorMsg !== "Login cancelled") {
|
||||
this.showError(`Failed to login to ${providerName}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Command handlers
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export class Input implements Component {
|
|||
private value: string = "";
|
||||
private cursor: number = 0; // Cursor position in the value
|
||||
public onSubmit?: (value: string) => void;
|
||||
public onEscape?: () => void;
|
||||
|
||||
// Bracketed paste mode buffering
|
||||
private pasteBuffer: string = "";
|
||||
|
|
@ -64,6 +65,12 @@ export class Input implements Component {
|
|||
}
|
||||
const kb = getEditorKeybindings();
|
||||
|
||||
// Escape/Cancel
|
||||
if (kb.matches(data, "selectCancel")) {
|
||||
if (this.onEscape) this.onEscape();
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit
|
||||
if (kb.matches(data, "submit") || data === "\n") {
|
||||
if (this.onSubmit) this.onSubmit(this.value);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue