mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 11:04:51 +00:00
feat(oauth): show paste input immediately during OpenAI Codex login (#468)
Previously, users had to wait up to 60 seconds for the browser callback to timeout before being prompted to paste the authorization code. This was problematic for SSH/VPS sessions where the callback cannot work. Now the paste input is shown immediately alongside the browser flow: - Browser callback and manual paste race - whichever completes first wins - Desktop users: browser callback succeeds, input is cleaned up - SSH/VPS users: paste code immediately without waiting Changes: - Add cancelWait() to OAuth server for early termination of polling loop - Add onManualCodeInput callback that races with browser callback - Show paste input immediately in TUI for openai-codex provider - Clean up input on success, error, or when browser callback wins Co-authored-by: cc-vps <crcatala+vps@gmail.com>
This commit is contained in:
parent
817221b79f
commit
05b9d55656
3 changed files with 121 additions and 4 deletions
|
|
@ -187,11 +187,13 @@ async function createAuthorizationFlow(): Promise<{ verifier: string; state: str
|
|||
|
||||
type OAuthServerInfo = {
|
||||
close: () => void;
|
||||
cancelWait: () => void;
|
||||
waitForCode: () => Promise<{ code: string } | null>;
|
||||
};
|
||||
|
||||
function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
|
||||
let lastCode: string | null = null;
|
||||
let cancelled = false;
|
||||
const server = http.createServer((req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || "", "http://localhost");
|
||||
|
|
@ -226,10 +228,14 @@ function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
|
|||
.listen(1455, "127.0.0.1", () => {
|
||||
resolve({
|
||||
close: () => server.close(),
|
||||
cancelWait: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
waitForCode: async () => {
|
||||
const sleep = () => new Promise((r) => setTimeout(r, 100));
|
||||
for (let i = 0; i < 600; i += 1) {
|
||||
if (lastCode) return { code: lastCode };
|
||||
if (cancelled) return null;
|
||||
await sleep();
|
||||
}
|
||||
return null;
|
||||
|
|
@ -250,6 +256,7 @@ function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
|
|||
// ignore
|
||||
}
|
||||
},
|
||||
cancelWait: () => {},
|
||||
waitForCode: async () => null,
|
||||
});
|
||||
});
|
||||
|
|
@ -265,11 +272,19 @@ function getAccountId(accessToken: string): string | null {
|
|||
|
||||
/**
|
||||
* Login with OpenAI Codex OAuth
|
||||
*
|
||||
* @param options.onAuth - Called with URL and instructions when auth starts
|
||||
* @param options.onPrompt - Called to prompt user for manual code paste (fallback if no onManualCodeInput)
|
||||
* @param options.onProgress - Optional progress messages
|
||||
* @param options.onManualCodeInput - Optional promise that resolves with user-pasted code.
|
||||
* Races with browser callback - whichever completes first wins.
|
||||
* Useful for showing paste input immediately alongside browser flow.
|
||||
*/
|
||||
export async function loginOpenAICodex(options: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
|
||||
onProgress?: (message: string) => void;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}): Promise<OAuthCredentials> {
|
||||
const { verifier, state, url } = await createAuthorizationFlow();
|
||||
const server = await startLocalOAuthServer(state);
|
||||
|
|
@ -278,11 +293,50 @@ export async function loginOpenAICodex(options: {
|
|||
|
||||
let code: string | undefined;
|
||||
try {
|
||||
const result = await server.waitForCode();
|
||||
if (result?.code) {
|
||||
code = result.code;
|
||||
if (options.onManualCodeInput) {
|
||||
// Race between browser callback and manual input
|
||||
let manualCode: string | undefined;
|
||||
const manualPromise = options
|
||||
.onManualCodeInput()
|
||||
.then((input) => {
|
||||
manualCode = input;
|
||||
server.cancelWait();
|
||||
})
|
||||
.catch(() => {}); // Ignore rejection
|
||||
|
||||
const result = await server.waitForCode();
|
||||
if (result?.code) {
|
||||
// Browser callback won
|
||||
code = result.code;
|
||||
} else if (manualCode) {
|
||||
// Manual input won (or callback timed out and user had entered code)
|
||||
const parsed = parseAuthorizationInput(manualCode);
|
||||
if (parsed.state && parsed.state !== state) {
|
||||
throw new Error("State mismatch");
|
||||
}
|
||||
code = parsed.code;
|
||||
}
|
||||
|
||||
// If still no code, wait for manual promise to complete and try that
|
||||
if (!code) {
|
||||
await manualPromise;
|
||||
if (manualCode) {
|
||||
const parsed = parseAuthorizationInput(manualCode);
|
||||
if (parsed.state && parsed.state !== state) {
|
||||
throw new Error("State mismatch");
|
||||
}
|
||||
code = parsed.code;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Original flow: wait for callback, then prompt if needed
|
||||
const result = await server.waitForCode();
|
||||
if (result?.code) {
|
||||
code = result.code;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to onPrompt if still no code
|
||||
if (!code) {
|
||||
const input = await options.onPrompt({
|
||||
message: "Paste the authorization code (or full redirect URL):",
|
||||
|
|
|
|||
|
|
@ -161,6 +161,8 @@ export class AuthStorage {
|
|||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
|
||||
onProgress?: (message: string) => void;
|
||||
/** For providers with local callback servers (e.g., openai-codex), races with browser callback */
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
},
|
||||
): Promise<void> {
|
||||
let credentials: OAuthCredentials;
|
||||
|
|
@ -186,7 +188,12 @@ export class AuthStorage {
|
|||
credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress);
|
||||
break;
|
||||
case "openai-codex":
|
||||
credentials = await loginOpenAICodex(callbacks);
|
||||
credentials = await loginOpenAICodex({
|
||||
onAuth: callbacks.onAuth,
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown OAuth provider: ${provider}`);
|
||||
|
|
|
|||
|
|
@ -2124,7 +2124,15 @@ export class InteractiveMode {
|
|||
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));
|
||||
|
|
@ -2155,8 +2163,39 @@ export class InteractiveMode {
|
|||
? "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) {
|
||||
|
|
@ -2183,7 +2222,17 @@ export class InteractiveMode {
|
|||
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));
|
||||
|
|
@ -2195,6 +2244,13 @@ export class InteractiveMode {
|
|||
);
|
||||
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)}`);
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue