diff --git a/index.ts b/index.ts index cc17de1..a0d50cd 100644 --- a/index.ts +++ b/index.ts @@ -17,6 +17,7 @@ const COMMANDS = [ ["codex_fast", "Toggle Codex fast mode."], ["codex_model", "List or switch the Codex model."], ["codex_permissions", "Show Codex permissions and account status."], + ["codex_login", "Start or cancel the Codex ChatGPT login flow for this bound conversation."], ["codex_init", "Forward /init to Codex."], ["codex_diff", "Forward /diff to Codex."], ["codex_rename", "Rename the Codex thread and sync the channel name when possible."], diff --git a/src/client.test.ts b/src/client.test.ts index 473f588..4dde30a 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -104,6 +104,19 @@ describe("buildThreadResumePayloads", () => { }); }); +describe("normalizeLoginCallbackReplayUrl", () => { + it("rewrites localhost callbacks to 127.0.0.1 for replay", () => { + expect( + __testing.normalizeLoginCallbackReplayUrl({ + callbackUrl: + "http://localhost:1455/auth/callback?code=abc123&state=xyz789", + authUrl: + "https://auth.example.test/start?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback", + }), + ).toBe("http://127.0.0.1:1455/auth/callback?code=abc123&state=xyz789"); + }); +}); + describe("extractStartupProbeInfo", () => { it("extracts server info from initialize responses without losing CLI probe details", () => { expect( diff --git a/src/client.ts b/src/client.ts index f2890a7..39ace9d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -69,8 +69,17 @@ export type ActiveCodexRun = { getThreadId: () => string | undefined; }; +export type ActiveCodexLogin = { + loginId: string; + authUrl: string; + submitCallbackUrl: (callbackUrl: string) => Promise; + cancel: () => Promise; + result: Promise; +}; + const DEFAULT_PROTOCOL_VERSION = "1.0"; const TRAILING_NOTIFICATION_SETTLE_MS = 250; +const LOGIN_CALLBACK_TIMEOUT_MS = 10_000; const TURN_STEER_METHODS = ["turn/steer"] as const; const TURN_INTERRUPT_METHODS = ["turn/interrupt"] as const; const execFileAsync = promisify(execFile); @@ -417,6 +426,53 @@ function extractOptionValues(value: unknown): string[] { .filter(Boolean); } +function extractLoginAccountResponse(value: unknown): { + type?: string; + loginId?: string; + authUrl?: string; +} { + const record = asRecord(value) ?? {}; + return { + type: pickString(record, ["type"]), + loginId: pickString(record, ["loginId", "login_id"]), + authUrl: pickString(record, ["authUrl", "auth_url"]), + }; +} + +function normalizeLoginCallbackReplayUrl(params: { + callbackUrl: string; + authUrl: string; +}): string { + const target = new URL(params.callbackUrl.trim()); + const redirectUrl = new URL(params.authUrl).searchParams.get("redirect_uri"); + if (!redirectUrl) { + throw new Error("Codex login URL did not include a redirect URI."); + } + const expected = new URL(redirectUrl); + const allowedHosts = new Set(["127.0.0.1", "localhost"]); + const targetHost = target.hostname.toLowerCase(); + const expectedHost = expected.hostname.toLowerCase(); + if (!allowedHosts.has(targetHost)) { + throw new Error("Paste the localhost callback URL from the browser address bar."); + } + if ( + target.protocol !== expected.protocol || + (!allowedHosts.has(expectedHost) || !allowedHosts.has(targetHost)) && + targetHost !== expectedHost || + target.port !== expected.port || + target.pathname !== expected.pathname + ) { + throw new Error("That callback URL does not match the active Codex login request."); + } + if (!target.searchParams.get("code") || !target.searchParams.get("state")) { + throw new Error("That callback URL is missing the OAuth code or state."); + } + // Codex binds the local login server on 127.0.0.1, even though the browser + // redirect uses localhost. Force IPv4 loopback for the replay request. + target.hostname = "127.0.0.1"; + return target.toString(); +} + function isInteractiveServerRequest(method: string): boolean { const normalized = method.trim().toLowerCase(); return normalized.includes("requestuserinput") || normalized.includes("requestapproval"); @@ -2046,6 +2102,145 @@ export class CodexAppServerClient { }); } + async startChatgptLogin(params: { sessionKey?: string } = {}): Promise { + const client = createJsonRpcClient(this.settings); + let loginId = ""; + let authUrl = ""; + let settled = false; + let completeLogin: (() => void) | null = null; + let failLogin: ((error: Error) => void) | null = null; + const result = new Promise((resolve, reject) => { + completeLogin = () => { + if (settled) { + return; + } + settled = true; + resolve(); + }; + failLogin = (error) => { + if (settled) { + return; + } + settled = true; + reject(error); + }; + }).finally(async () => { + await client.close().catch(() => undefined); + }); + + client.setNotificationHandler((method, notificationParams) => { + const methodLower = method.trim().toLowerCase(); + if (methodLower !== "account/login/completed") { + return; + } + const record = asRecord(notificationParams) ?? {}; + const completedLoginId = pickString(record, ["loginId", "login_id"]); + if (loginId && completedLoginId && completedLoginId !== loginId) { + return; + } + const success = pickBoolean(record, ["success"]) ?? false; + const errorMessage = + pickString(record, ["error"], { trim: false }) ?? "Codex login failed."; + if (success) { + this.logger.info(`codex login completed loginId=${loginId || completedLoginId || ""}`); + completeLogin?.(); + } else { + failLogin?.(new Error(errorMessage)); + } + }); + client.setRequestHandler(async () => ({})); + + try { + await client.connect(); + await initializeClient({ client, settings: this.settings, sessionKey: params.sessionKey }); + const loginResponse = extractLoginAccountResponse( + await requestWithFallbacks({ + client, + methods: ["account/login/start"], + payloads: [{ type: "chatgpt" }], + timeoutMs: this.settings.requestTimeoutMs, + }), + ); + if (loginResponse.type !== "chatgpt" || !loginResponse.loginId || !loginResponse.authUrl) { + throw new Error("Codex App Server did not return a ChatGPT login URL."); + } + loginId = loginResponse.loginId; + authUrl = loginResponse.authUrl; + this.logger.info(`codex login started loginId=${loginId}`); + } catch (error) { + await client.close().catch(() => undefined); + throw error; + } + + const submitCallbackUrl = async (callbackUrl: string): Promise => { + const replayUrl = normalizeLoginCallbackReplayUrl({ + callbackUrl, + authUrl, + }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), LOGIN_CALLBACK_TIMEOUT_MS); + this.logger.info( + `codex login callback replay start loginId=${loginId || ""} url=${replayUrl}`, + ); + let response: Response; + try { + response = await fetch(replayUrl, { + method: "GET", + redirect: "manual", + signal: controller.signal, + }); + } catch (error) { + this.logger.warn( + `codex login callback replay failed loginId=${loginId || ""}: ${error instanceof Error ? error.message : String(error)}`, + ); + if (error instanceof Error && error.name === "AbortError") { + throw new Error( + "Codex login callback timed out contacting the local Codex login server. Paste the localhost URL again or run `/codex_login cancel` and retry.", + ); + } + throw error; + } finally { + clearTimeout(timeout); + } + this.logger.info( + `codex login callback replay response loginId=${loginId || ""} status=${response.status}`, + ); + if (!response.ok && response.status !== 302) { + throw new Error(`Codex login callback failed with HTTP ${response.status}.`); + } + if (response.status === 302) { + this.logger.info( + `codex login callback accepted loginId=${loginId || ""}; treating redirect as success`, + ); + completeLogin?.(); + } + }; + + const cancel = async (): Promise => { + if (settled) { + return; + } + try { + await requestWithFallbacks({ + client, + methods: ["account/login/cancel"], + payloads: [{ loginId }], + timeoutMs: this.settings.requestTimeoutMs, + }).catch(() => undefined); + } finally { + failLogin?.(new Error("Codex login cancelled.")); + } + }; + + return { + loginId, + authUrl, + submitCallbackUrl, + cancel, + result, + }; + } + async listThreads(params: { sessionKey?: string; workspaceDir?: string; @@ -3080,6 +3275,7 @@ export const __testing = { buildTurnSteerPayloads, createPendingInputCoordinator, extractFileChangePathsFromReadResult, + normalizeLoginCallbackReplayUrl, extractStartupProbeInfo, extractThreadTokenUsageSnapshot, extractRateLimitSummaries, diff --git a/src/controller.test.ts b/src/controller.test.ts index b4878dd..0ab17b5 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -132,6 +132,7 @@ async function createControllerHarness() { type: "chatgpt", })), readRateLimits: vi.fn(async () => []), + startChatgptLogin: vi.fn(), }; (controller as any).client = clientMock; (controller as any).readThreadHasChanges = vi.fn(async () => false); @@ -1380,4 +1381,154 @@ describe("Discord controller flows", () => { expect.anything(), ); }); + + it("starts a bound codex_login flow and returns the browser URL", async () => { + const { controller, clientMock } = await createControllerHarness(); + const loginHandle = { + loginId: "login-123", + authUrl: "https://auth.example.test/start?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback", + submitCallbackUrl: vi.fn(async () => {}), + cancel: vi.fn(async () => {}), + result: new Promise(() => undefined), + }; + clientMock.startChatgptLogin.mockResolvedValue(loginHandle); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + + const reply = await controller.handleCommand( + "codex_login", + buildTelegramCommandContext({ + commandBody: "/codex_login", + args: "", + getCurrentConversationBinding: vi.fn(async () => ({ pluginId: "openclaw-codex-app-server" })), + from: "telegram:8460800771", + to: "telegram:8460800771", + }), + ); + + expect(clientMock.startChatgptLogin).toHaveBeenCalledWith({ sessionKey: "session-1" }); + expect(reply.text).toContain(loginHandle.authUrl); + expect(reply.text).toContain("copy the full"); + }); + + it("replays a pasted localhost callback URL into the pending codex_login flow", async () => { + const { controller, clientMock, sendMessageTelegram } = await createControllerHarness(); + let resolveLogin: (() => void) | undefined; + const loginResult = new Promise((resolve) => { + resolveLogin = resolve; + }); + const submitCallbackUrl = vi.fn(async () => { + resolveLogin?.(); + }); + clientMock.startChatgptLogin.mockResolvedValue({ + loginId: "login-123", + authUrl: "https://auth.example.test/start?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback", + submitCallbackUrl, + cancel: vi.fn(async () => {}), + result: loginResult, + }); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + + await controller.handleCommand( + "codex_login", + buildTelegramCommandContext({ + commandBody: "/codex_login", + args: "", + getCurrentConversationBinding: vi.fn(async () => ({ pluginId: "openclaw-codex-app-server" })), + from: "telegram:8460800771", + to: "telegram:8460800771", + }), + ); + + const result = await controller.handleInboundClaim({ + content: "http://127.0.0.1:1455/auth/callback?code=abc&state=xyz", + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }); + + expect(result).toEqual({ handled: true }); + expect(submitCallbackUrl).toHaveBeenCalledWith( + "http://127.0.0.1:1455/auth/callback?code=abc&state=xyz", + ); + await flushAsyncWork(); + expect(sendMessageTelegram).toHaveBeenCalledWith( + "8460800771", + "Completing Codex login now.", + expect.anything(), + ); + expect(sendMessageTelegram).toHaveBeenCalledWith( + "8460800771", + "Codex login completed. You can try your message again.", + expect.anything(), + ); + }); + + it("does not hijack normal inbound text while a codex_login flow is pending", async () => { + const { controller, clientMock, sendMessageTelegram } = await createControllerHarness(); + const startTurn = vi.spyOn(controller as any, "startTurn").mockResolvedValue(undefined); + clientMock.startChatgptLogin.mockResolvedValue({ + loginId: "login-123", + authUrl: "https://auth.example.test/start?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback", + submitCallbackUrl: vi.fn(async () => {}), + cancel: vi.fn(async () => {}), + result: new Promise(() => undefined), + }); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + + await controller.handleCommand( + "codex_login", + buildTelegramCommandContext({ + commandBody: "/codex_login", + args: "", + getCurrentConversationBinding: vi.fn(async () => ({ pluginId: "openclaw-codex-app-server" })), + from: "telegram:8460800771", + to: "telegram:8460800771", + }), + ); + + const result = await controller.handleInboundClaim({ + content: "who are you?", + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }); + + expect(result).toEqual({ handled: true }); + expect(startTurn).toHaveBeenCalled(); + expect(sendMessageTelegram).not.toHaveBeenCalledWith( + "8460800771", + expect.stringContaining("Codex login is waiting for the localhost callback URL"), + expect.anything(), + ); + }); }); diff --git a/src/controller.ts b/src/controller.ts index 24b54f8..e2d2d18 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -14,7 +14,12 @@ import type { ConversationRef, } from "openclaw/plugin-sdk"; import { resolvePluginSettings, resolveWorkspaceDir } from "./config.js"; -import { CodexAppServerClient, type ActiveCodexRun, isMissingThreadError } from "./client.js"; +import { + CodexAppServerClient, + type ActiveCodexLogin, + type ActiveCodexRun, + isMissingThreadError, +} from "./client.js"; import { formatAccountSummary, formatBinding, @@ -76,6 +81,14 @@ type ActiveRunRecord = { handle: ActiveCodexRun; }; +type ActiveLoginRecord = { + conversation: ConversationTarget; + sessionKey?: string; + loginId: string; + authUrl: string; + handle: ActiveCodexLogin; +}; + const execFileAsync = promisify(execFile); type PickerRender = { @@ -490,6 +503,7 @@ export class CodexPluginController { private readonly settings; private readonly client; private readonly activeRuns = new Map(); + private readonly activeLogins = new Map(); private readonly threadChangesCache = new Map>(); private readonly store; private serviceWorkspaceDir?: string; @@ -549,6 +563,10 @@ export class CodexPluginController { if (!conversation) { return { handled: false }; } + const loginHandled = await this.handlePendingLoginMessage(conversation, event.content); + if (loginHandled) { + return { handled: true }; + } const activeKey = buildConversationKey(conversation); const active = this.activeRuns.get(activeKey); if (active) { @@ -830,6 +848,8 @@ export class CodexPluginController { return await this.handlePromptAlias(conversation, binding, args, "/diff"); case "codex_rename": return await this.handleRenameCommand(conversation, binding, args); + case "codex_login": + return await this.handleLoginCommand(conversation, binding, args); default: return { text: "Unknown Codex command." }; } @@ -1343,6 +1363,59 @@ export class CodexPluginController { return { text: `Sent ${alias} to Codex.` }; } + private async handleLoginCommand( + conversation: ConversationTarget | null, + binding: StoredBinding | null, + args: string, + ): Promise { + if (!conversation || !binding) { + return { text: "Bind this conversation to a Codex thread before starting Codex login." }; + } + const key = buildConversationKey(conversation); + const existing = this.activeLogins.get(key); + if (args.trim().toLowerCase() === "cancel") { + if (!existing) { + return { text: "No Codex login is currently waiting in this conversation." }; + } + await existing.handle.cancel().catch(() => undefined); + this.activeLogins.delete(key); + return { text: "Cancelled the pending Codex login flow." }; + } + if (existing) { + return { + text: this.buildLoginInstructions(existing.authUrl), + }; + } + const handle = await this.client.startChatgptLogin({ sessionKey: binding.sessionKey }); + const record: ActiveLoginRecord = { + conversation, + sessionKey: binding.sessionKey, + loginId: handle.loginId, + authUrl: handle.authUrl, + handle, + }; + this.activeLogins.set(key, record); + void handle.result + .then(async () => { + if (this.activeLogins.get(key)?.loginId === record.loginId) { + this.activeLogins.delete(key); + } + await this.sendText(conversation, "Codex login completed. You can try your message again."); + }) + .catch(async (error) => { + if (this.activeLogins.get(key)?.loginId === record.loginId) { + this.activeLogins.delete(key); + } + const message = error instanceof Error ? error.message : String(error); + if (message !== "Codex login cancelled.") { + await this.sendText(conversation, `Codex login failed: ${message}`); + } + }); + return { + text: this.buildLoginInstructions(handle.authUrl), + }; + } + private async handleRenameCommand( conversation: ConversationTarget | null, binding: StoredBinding | null, @@ -1661,6 +1734,47 @@ export class CodexPluginController { return "Codex completed without a text reply."; } + private buildLoginInstructions(authUrl: string): string { + return [ + "Open this Codex login URL in a browser:", + authUrl, + "", + "Finish the login. When the browser redirects to a localhost URL and the page fails to load, copy the full `http://127.0.0.1:...` or `http://localhost:...` URL from the address bar and paste it here.", + "Use `/codex_login cancel` to cancel this login flow.", + ].join("\n"); + } + + private async handlePendingLoginMessage( + conversation: ConversationTarget, + content: string, + ): Promise { + const pending = this.activeLogins.get(buildConversationKey(conversation)); + if (!pending || content.trim().startsWith("/")) { + return false; + } + const trimmed = content.trim(); + let url: URL; + try { + url = new URL(trimmed); + } catch { + return false; + } + const host = url.hostname.toLowerCase(); + if (!["127.0.0.1", "localhost"].includes(host)) { + return false; + } + await this.sendText(conversation, "Completing Codex login now."); + try { + await pending.handle.submitCallbackUrl(trimmed); + } catch (error) { + await this.sendText( + conversation, + error instanceof Error ? error.message : "Unable to submit that Codex login callback URL.", + ); + } + return true; + } + private formatCodexAuthFailureMessage(account: AccountSummary | undefined): string { if (account?.type === "apiKey" && account.requiresOpenaiAuth !== true) { return "Codex authentication failed on this machine. Check the configured API key and try again.";