From bfa7e3ec6343f29f5cfe39f769e8d5acd273f197 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 17 Aug 2026 12:59:05 -0400 Subject: [PATCH 01/13] feat(core): add a browser-mediated Codex login service --- packages/agent-core/src/services/AGENTS.md | 1 + .../src/services/codexLogin/codexLogin.ts | 74 +++++ .../services/codexLogin/codexLoginService.ts | 292 ++++++++++++++++++ packages/agent-core/src/services/index.ts | 12 + packages/oauth/src/openai-codex-oauth.ts | 7 + packages/protocol/src/error-codes.ts | 3 + packages/protocol/src/index.ts | 1 + packages/protocol/src/rest/codexLogin.ts | 53 ++++ 8 files changed, 443 insertions(+) create mode 100644 packages/agent-core/src/services/codexLogin/codexLogin.ts create mode 100644 packages/agent-core/src/services/codexLogin/codexLoginService.ts create mode 100644 packages/protocol/src/rest/codexLogin.ts diff --git a/packages/agent-core/src/services/AGENTS.md b/packages/agent-core/src/services/AGENTS.md index 7b0e953ec..ffec072c2 100644 --- a/packages/agent-core/src/services/AGENTS.md +++ b/packages/agent-core/src/services/AGENTS.md @@ -106,6 +106,7 @@ no new suffixes get reintroduced. | `task/` | `task.ts` | `taskService.ts` | `ITaskService` | | `oauth/` | `oauth.ts` | `oauthService.ts` | `IOAuthService` | | `authSummary/` | `authSummary.ts` | `authSummaryService.ts` | `IAuthSummaryService` | +| `codexLogin/` | `codexLogin.ts` | `codexLoginService.ts` | `ICodexLoginService` | Adding a new service: create the folder + contracts + impl pair, add a bottom-of-file `registerSingleton(IXxxService, XxxService, diff --git a/packages/agent-core/src/services/codexLogin/codexLogin.ts b/packages/agent-core/src/services/codexLogin/codexLogin.ts new file mode 100644 index 000000000..6e1be60ee --- /dev/null +++ b/packages/agent-core/src/services/codexLogin/codexLogin.ts @@ -0,0 +1,74 @@ +/** + * `ICodexLoginService` — browser-mediated OpenAI Codex sign-in for clients + * with no terminal. + * + * The CLI runs this flow in one call (`runOpenAICodexOAuthFlow`) because it can + * block on stdin. A web or desktop client cannot: it needs a URL to open and a + * status to poll. So the flow splits into `start()` and `status()`, and the + * token exchange, model fetch, and config write all stay on the server. + * **Tokens never reach the client** — the status reply names the selected model + * alias and nothing else. + * + * Two ways in, because only one of them always works: + * - Loopback. `startOpenAICodexCallbackServer` binds `127.0.0.1:1455`, the + * redirect URI registered for this client id. It needs the browser and the + * server on one host, with the port free. + * - Manual paste. When the port is taken — a `codex login` or an abandoned + * attempt holds it — or the browser sits on another machine, no callback + * arrives, and `submitCode()` takes the redirect URL from the user. + * `start()` reports which one applies through `loopback`, and `submitCode()` + * works either way, so a loopback that fails silently still has a way out. + * + * One login runs at a time. Starting a second cancels the first, which releases + * port 1455; without that an abandoned attempt would block every later one + * until its timeout. + */ + +import { createDecorator } from '../../di'; +import type { CodexLoginStart, CodexLoginStatus } from '@pymodel/protocol'; + +export interface ICodexLoginService { + readonly _serviceBrand: undefined; + + /** + * Build the authorize URL and arm the callback listener, cancelling any + * login still in flight. + */ + start(): Promise; + + /** Current state of `loginId`. Throws `CodexLoginNotFoundError` if unknown. */ + status(loginId: string): CodexLoginStatus; + + /** + * Finish a login from a pasted redirect URL, query string, or bare code. + * Resolves once the config is written. + */ + submitCode(loginId: string, redirectUrl: string): Promise; + + /** Drop the login and release the callback listener. */ + cancel(loginId: string): CodexLoginStatus; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ICodexLoginService = createDecorator( + 'codexLoginService', +); + +/** `40416 codex_login.not_found` — unknown or already-discarded login id. */ +export class CodexLoginNotFoundError extends Error { + readonly loginId: string; + + constructor(loginId: string) { + super(`codex login ${loginId} is not in flight`); + this.name = 'CodexLoginNotFoundError'; + this.loginId = loginId; + } +} + +/** `40001 validation.failed` — the pasted redirect carried no usable code. */ +export class CodexLoginInvalidCodeError extends Error { + constructor(message: string) { + super(message); + this.name = 'CodexLoginInvalidCodeError'; + } +} diff --git a/packages/agent-core/src/services/codexLogin/codexLoginService.ts b/packages/agent-core/src/services/codexLogin/codexLoginService.ts new file mode 100644 index 000000000..392d1d19d --- /dev/null +++ b/packages/agent-core/src/services/codexLogin/codexLoginService.ts @@ -0,0 +1,292 @@ +/** + * `CodexLoginService` — implementation of `ICodexLoginService`. + * + * Composes the primitives in `@pymodel/pythinker-code-oauth` rather than + * calling `runOpenAICodexOAuthFlow`: that helper waits for the loopback + * callback for its whole timeout before offering manual paste, which a polling + * client cannot use. Here the loopback wait runs in the background and paste + * stays open the entire time. + */ + +import { randomUUID } from 'node:crypto'; + +import { + applyOpenAICodexOAuthConfig, + buildOpenAICodexAuthorizeUrl, + createOpenAICodexPkcePair, + exchangeOpenAICodexAuthorizationCode, + fetchOpenAICodexModels, + OPENAI_CODEX_PROVIDER_ID, + parseOpenAICodexAuthorizationInput, + startOpenAICodexCallbackServer, + type OpenAICodexCallbackServer, + type OpenAICodexPkcePair, + type PlatformConfigShape, + type PlatformModelInfo, +} from '@pymodel/pythinker-code-oauth'; +import type { CodexLoginStart, CodexLoginState, CodexLoginStatus } from '@pymodel/protocol'; + +import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import { ICoreProcessService } from '../coreProcess/coreProcess'; +import { + CodexLoginInvalidCodeError, + CodexLoginNotFoundError, + ICodexLoginService, +} from './codexLogin'; + +/** + * How long an attempt stays usable. The browser round trip includes a sign-in + * and possibly a device check, and the user may have to copy the redirect URL + * by hand, so this is far longer than the CLI's 120s loopback wait. + */ +const LOGIN_TTL_MS = 10 * 60 * 1000; + +/** Seams the tests replace; production uses the real OAuth calls. */ +export interface CodexLoginDeps { + readonly createPkce: () => OpenAICodexPkcePair; + readonly buildAuthorizeUrl: (pair: OpenAICodexPkcePair) => string; + readonly startCallbackServer: (state: string) => Promise; + readonly exchangeCode: ( + code: string, + verifier: string, + ) => Promise<{ accessToken: string; refreshToken: string; accountId: string }>; + readonly fetchModels: (input: { + accessToken: string; + accountId: string; + }) => Promise; + readonly now: () => number; +} + +const defaultDeps: CodexLoginDeps = { + createPkce: createOpenAICodexPkcePair, + buildAuthorizeUrl: buildOpenAICodexAuthorizeUrl, + startCallbackServer: startOpenAICodexCallbackServer, + exchangeCode: (code, verifier) => exchangeOpenAICodexAuthorizationCode(code, verifier), + fetchModels: (input) => fetchOpenAICodexModels(input), + now: () => Date.now(), +}; + +interface Attempt { + readonly id: string; + readonly pkce: OpenAICodexPkcePair; + readonly callback: OpenAICodexCallbackServer; + readonly expiresAtMs: number; + state: CodexLoginState; + defaultModel?: string; + message?: string; +} + +/** + * The flow itself, free of the DI container so tests can hand it fakes. + * `CodexLoginService` is the registered wrapper around it. + */ +export class CodexLoginFlow { + private attempt: Attempt | undefined; + + constructor( + private readonly core: ICoreProcessService, + private readonly deps: CodexLoginDeps = defaultDeps, + ) {} + + async start(): Promise { + this._discard('cancelled'); + + const pkce = this.deps.createPkce(); + const callback = await this.deps.startCallbackServer(pkce.state); + const expiresAtMs = this.deps.now() + LOGIN_TTL_MS; + const attempt: Attempt = { + id: randomUUID(), + pkce, + callback, + expiresAtMs, + state: 'pending', + }; + this.attempt = attempt; + + if (callback.loopback) { + // Fire and forget: the client learns the outcome by polling `status`. + // A rejection here is the abort/timeout path, which leaves the attempt + // pending so the user can still paste the redirect URL. + void callback + .waitForCode({ timeoutMs: LOGIN_TTL_MS }) + .then(async (result) => { + if (result === null) return; + await this._complete(attempt, result.code); + }) + .catch(() => undefined); + } + + return { + login_id: attempt.id, + authorize_url: this.deps.buildAuthorizeUrl(pkce), + loopback: callback.loopback, + expires_at: new Date(expiresAtMs).toISOString(), + }; + } + + status(loginId: string): CodexLoginStatus { + const attempt = this._require(loginId); + if (attempt.state === 'pending' && this.deps.now() >= attempt.expiresAtMs) { + attempt.state = 'failed'; + attempt.message = 'OpenAI Codex login timed out. Start again.'; + attempt.callback.close(); + } + return toStatus(attempt); + } + + async submitCode(loginId: string, redirectUrl: string): Promise { + const attempt = this._require(loginId); + const parsed = parseOpenAICodexAuthorizationInput(redirectUrl); + if (parsed.state !== undefined && parsed.state !== attempt.pkce.state) { + throw new CodexLoginInvalidCodeError( + 'The pasted URL belongs to a different login. Start again.', + ); + } + if (parsed.code === undefined || parsed.code.length === 0) { + throw new CodexLoginInvalidCodeError('The pasted URL carries no authorization code.'); + } + await this._complete(attempt, parsed.code); + return toStatus(attempt); + } + + cancel(loginId: string): CodexLoginStatus { + const attempt = this._require(loginId); + if (attempt.state === 'pending') { + attempt.state = 'cancelled'; + attempt.callback.close(); + } + return toStatus(attempt); + } + + private _require(loginId: string): Attempt { + const attempt = this.attempt; + if (attempt === undefined || attempt.id !== loginId) { + throw new CodexLoginNotFoundError(loginId); + } + return attempt; + } + + /** Release a superseded attempt so it stops holding the callback port. */ + private _discard(state: CodexLoginState): void { + const attempt = this.attempt; + if (attempt === undefined) return; + if (attempt.state === 'pending') { + attempt.state = state; + attempt.callback.close(); + } + this.attempt = undefined; + } + + private async _complete(attempt: Attempt, code: string): Promise { + if (attempt.state !== 'pending') return; + attempt.callback.close(); + try { + const tokens = await this.deps.exchangeCode(code, attempt.pkce.verifier); + const models = await this.deps.fetchModels({ + accessToken: tokens.accessToken, + accountId: tokens.accountId, + }); + if (models.length === 0) { + throw new Error('No models available for OpenAI Codex.'); + } + const defaultModel = await this._writeConfig(tokens, models); + attempt.state = 'completed'; + attempt.defaultModel = defaultModel; + } catch (error) { + attempt.state = 'failed'; + attempt.message = error instanceof Error ? error.message : String(error); + } + } + + private async _writeConfig( + tokens: { accessToken: string; refreshToken: string; accountId: string }, + models: readonly PlatformModelInfo[], + ): Promise { + // `setPythinkerConfig` merges, so a re-login would keep model aliases the + // account no longer offers. Drop the provider first, exactly as the CLI + // login does, then write the fresh set. + const existing = await this.core.rpc.getPythinkerConfig({ reload: true }); + if (existing.providers?.[OPENAI_CODEX_PROVIDER_ID] !== undefined) { + await this.core.rpc.removePythinkerProvider({ providerId: OPENAI_CODEX_PROVIDER_ID }); + } + + const config = await this.core.rpc.getPythinkerConfig({ reload: true }); + const shape = config as unknown as PlatformConfigShape; + if (shape.providers === undefined) shape.providers = {}; + const result = applyOpenAICodexOAuthConfig(shape, { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + accountId: tokens.accountId, + models, + selectedModel: pickDefaultModel(models), + thinking: true, + }); + + // All five fields travel together: the patch is a deep merge, and leaving + // `thinking` out drops the effort that `applyOpenAICodexOAuthConfig` just + // picked. + await this.core.rpc.setPythinkerConfig({ + providers: shape.providers, + models: shape.models, + defaultModel: shape.defaultModel, + defaultThinking: shape.defaultThinking, + thinking: shape.thinking, + } as Parameters[0]); + + return result.defaultModel; + } +} + +/** + * The CLI asks the user which model to use. The web login skips that question + * and takes the account's Codex model, so the user lands in a working session; + * the model picker changes it afterwards. + */ +export function pickDefaultModel( + models: readonly PlatformModelInfo[], +): PlatformModelInfo { + const codex = models.find((model) => model.id.includes('codex')); + const first = models[0]; + if (first === undefined) { + throw new Error('No models available for OpenAI Codex.'); + } + return codex ?? first; +} + +function toStatus(attempt: Attempt): CodexLoginStatus { + return { + login_id: attempt.id, + state: attempt.state, + default_model: attempt.defaultModel, + message: attempt.message, + }; +} + +export class CodexLoginService extends Disposable implements ICodexLoginService { + readonly _serviceBrand: undefined; + + private readonly flow: CodexLoginFlow; + + constructor(@ICoreProcessService core: ICoreProcessService) { + super(); + this.flow = new CodexLoginFlow(core); + } + + start(): Promise { + return this.flow.start(); + } + + status(loginId: string): CodexLoginStatus { + return this.flow.status(loginId); + } + + submitCode(loginId: string, redirectUrl: string): Promise { + return this.flow.submitCode(loginId, redirectUrl); + } + + cancel(loginId: string): CodexLoginStatus { + return this.flow.cancel(loginId); + } +} + +registerSingleton(ICodexLoginService, CodexLoginService, InstantiationType.Delayed); diff --git a/packages/agent-core/src/services/index.ts b/packages/agent-core/src/services/index.ts index c411c4477..60034adb1 100644 --- a/packages/agent-core/src/services/index.ts +++ b/packages/agent-core/src/services/index.ts @@ -105,6 +105,18 @@ export { } from './authSummary/authSummary'; export { AuthSummaryService } from './authSummary/authSummaryService'; +export { + ICodexLoginService, + CodexLoginNotFoundError, + CodexLoginInvalidCodeError, +} from './codexLogin/codexLogin'; +export { + CodexLoginFlow, + CodexLoginService, + pickDefaultModel, +} from './codexLogin/codexLoginService'; +export type { CodexLoginDeps } from './codexLogin/codexLoginService'; + export { IModelCatalogService, diff --git a/packages/oauth/src/openai-codex-oauth.ts b/packages/oauth/src/openai-codex-oauth.ts index 668f7d1c6..4cb49c435 100644 --- a/packages/oauth/src/openai-codex-oauth.ts +++ b/packages/oauth/src/openai-codex-oauth.ts @@ -42,6 +42,11 @@ export interface OpenAICodexTokenBundle { export interface OpenAICodexCallbackServer { readonly redirectUri: string; + /** + * `false` when the port was already taken, so no redirect will ever arrive + * and the caller must ask the user to paste the URL back. + */ + readonly loopback: boolean; waitForCode(opts?: { readonly signal?: AbortSignal | undefined; readonly timeoutMs?: number | undefined; @@ -231,6 +236,7 @@ export async function startOpenAICodexCallbackServer( const noopServer: OpenAICodexCallbackServer = { redirectUri: OPENAI_CODEX_REDIRECT_URI, + loopback: false, waitForCode: async () => null, cancelWait: () => {}, close: () => {}, @@ -288,6 +294,7 @@ export async function startOpenAICodexCallbackServer( .listen(CALLBACK_PORT, '127.0.0.1', () => { resolve({ redirectUri: OPENAI_CODEX_REDIRECT_URI, + loopback: true, waitForCode: async (opts = {}) => { const timeoutMs = opts.timeoutMs ?? DEFAULT_CALLBACK_TIMEOUT_MS; return new Promise<{ code: string } | null>((resolveWait, rejectWait) => { diff --git a/packages/protocol/src/error-codes.ts b/packages/protocol/src/error-codes.ts index 03d033427..b3933baf5 100644 --- a/packages/protocol/src/error-codes.ts +++ b/packages/protocol/src/error-codes.ts @@ -57,6 +57,8 @@ export const ErrorCode = { TERMINAL_NOT_FOUND: 40414, /** skill_name does not exist */ SKILL_NOT_FOUND: 40415, + /** codex login_id does not exist, or its attempt was already discarded */ + CODEX_LOGIN_NOT_FOUND: 40416, /** Session has an in-flight prompt; new request rejected */ SESSION_BUSY: 40901, @@ -171,6 +173,7 @@ export const ErrorCodeReason: Readonly> = { [ErrorCode.MODEL_NOT_FOUND]: 'model.not_found', [ErrorCode.TERMINAL_NOT_FOUND]: 'terminal.not_found', [ErrorCode.SKILL_NOT_FOUND]: 'skill.not_found', + [ErrorCode.CODEX_LOGIN_NOT_FOUND]: 'codex_login.not_found', [ErrorCode.SESSION_BUSY]: 'session.busy', [ErrorCode.APPROVAL_ALREADY_RESOLVED]: 'approval.already_resolved', diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index fd9ceda88..d9deddb91 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -22,6 +22,7 @@ export * from './modelCatalog'; export * from './rest/meta'; export * from './rest/auth'; +export * from './rest/codexLogin'; export * from './rest/session'; export * from './rest/snapshot'; export * from './rest/workspace'; diff --git a/packages/protocol/src/rest/codexLogin.ts b/packages/protocol/src/rest/codexLogin.ts new file mode 100644 index 000000000..12a6ccfb6 --- /dev/null +++ b/packages/protocol/src/rest/codexLogin.ts @@ -0,0 +1,53 @@ +/** + * POST /v1/auth/codex:start + * Reply: CodexLoginStart { login_id, authorize_url, loopback, expires_at } + * GET /v1/auth/codex/{login_id} + * Reply: CodexLoginStatus { login_id, state, default_model?, message? } + * POST /v1/auth/codex/{login_id}:submit_code + * Body: CodexLoginSubmitCodeRequest { redirect_url } + * POST /v1/auth/codex/{login_id}:cancel + * + * No reply carries the access or refresh token. The server writes them to its + * own config, exactly as the CLI login does, and tells the client only which + * model alias the login selected. + */ +import { z } from 'zod'; + +export const codexLoginStartSchema = z.object({ + login_id: z.string().min(1), + authorize_url: z.string().min(1), + /** + * `true` when the local callback listener owns port 1455, so the browser + * redirect finishes the login on its own. `false` means the port was taken, + * and the user has to paste the redirect URL back. + */ + loopback: z.boolean(), + expires_at: z.string().min(1), +}); +export type CodexLoginStart = z.infer; + +export const codexLoginStateSchema = z.enum([ + 'pending', + 'completed', + 'failed', + 'cancelled', +]); +export type CodexLoginState = z.infer; + +export const codexLoginStatusSchema = z.object({ + login_id: z.string().min(1), + state: codexLoginStateSchema, + /** Set once the login wrote its config: the alias now selected by default. */ + default_model: z.string().min(1).optional(), + /** Set on `failed`. Safe to show to the user. */ + message: z.string().min(1).optional(), +}); +export type CodexLoginStatus = z.infer; + +export const codexLoginSubmitCodeRequestSchema = z.object({ + /** The full redirect URL, its query string, or the bare code. */ + redirect_url: z.string().min(1), +}); +export type CodexLoginSubmitCodeRequest = z.infer< + typeof codexLoginSubmitCodeRequestSchema +>; From fa3dc9dc22736b4deefe6fb1546d6c58c6953de6 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 17 Aug 2026 13:01:51 -0400 Subject: [PATCH 02/13] feat(server): expose the Codex login flow over REST --- .../test/services/codex-login-service.test.ts | 232 ++++++++++++++++++ packages/server/src/routes/codexLogin.ts | 175 +++++++++++++ .../server/src/routes/registerApiV1Routes.ts | 5 + packages/server/test/codex-login.e2e.test.ts | 144 +++++++++++ 4 files changed, 556 insertions(+) create mode 100644 packages/agent-core/test/services/codex-login-service.test.ts create mode 100644 packages/server/src/routes/codexLogin.ts create mode 100644 packages/server/test/codex-login.e2e.test.ts diff --git a/packages/agent-core/test/services/codex-login-service.test.ts b/packages/agent-core/test/services/codex-login-service.test.ts new file mode 100644 index 000000000..947a39e5b --- /dev/null +++ b/packages/agent-core/test/services/codex-login-service.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + CoreRPC, + GetPythinkerConfigPayload, + PythinkerConfig, + PythinkerConfigPatch, + SetPythinkerConfigPayload, +} from '../../src'; +import { + CodexLoginFlow, + CodexLoginNotFoundError, + pickDefaultModel, + type CodexLoginDeps, + type ICoreProcessService, +} from '../../src/services'; + +const MODELS = [ + { id: 'gpt-5', contextLength: 256_000, supportsReasoning: true, supportsImageIn: true, supportsVideoIn: false }, + { id: 'gpt-5-codex', contextLength: 256_000, supportsReasoning: true, supportsImageIn: true, supportsVideoIn: false }, +]; + +function makeCore(configRef: { current: PythinkerConfig }): { + core: ICoreProcessService; + setCalls: PythinkerConfigPatch[]; + removeCalls: string[]; +} { + const setCalls: PythinkerConfigPatch[] = []; + const removeCalls: string[] = []; + const rpc: Partial = { + getPythinkerConfig: vi.fn(async (_payload: GetPythinkerConfigPayload) => configRef.current), + setPythinkerConfig: vi.fn(async (payload: SetPythinkerConfigPayload) => { + setCalls.push(payload); + configRef.current = { ...configRef.current, ...(payload as Partial) }; + return configRef.current; + }), + removePythinkerProvider: vi.fn(async ({ providerId }) => { + removeCalls.push(providerId); + const providers = { ...configRef.current.providers }; + delete providers[providerId]; + configRef.current = { ...configRef.current, providers }; + return configRef.current; + }), + }; + return { + core: { rpc, ready: Promise.resolve(), dispose: () => {} } as unknown as ICoreProcessService, + setCalls, + removeCalls, + }; +} + +function makeCallback(loopback: boolean): { + server: { loopback: boolean; redirectUri: string; waitForCode: () => Promise; cancelWait: () => void; close: () => void }; + closed: () => number; +} { + let closes = 0; + return { + server: { + loopback, + redirectUri: 'http://localhost:1455/auth/callback', + waitForCode: async () => null, + cancelWait: () => {}, + close: () => { + closes += 1; + }, + }, + closed: () => closes, + }; +} + +function makeDeps( + overrides: Partial = {}, + loopback = false, +): { deps: CodexLoginDeps; callbackClosed: () => number } { + const callback = makeCallback(loopback); + const deps: CodexLoginDeps = { + createPkce: () => ({ verifier: 'v', challenge: 'c', state: 'st' }), + buildAuthorizeUrl: () => 'https://auth.openai.com/oauth/authorize?client_id=app_test&state=st', + startCallbackServer: async () => callback.server as never, + exchangeCode: async () => ({ accessToken: 'ACCESS-TOKEN-SECRET', refreshToken: 'REFRESH-TOKEN-SECRET', accountId: 'acct' }), + fetchModels: async () => MODELS, + now: () => 1_700_000_000_000, + ...overrides, + }; + return { deps, callbackClosed: callback.closed }; +} + +function emptyConfig(): PythinkerConfig { + return { providers: {} } as PythinkerConfig; +} + +describe('CodexLoginFlow', () => { + it('writes the provider, its aliases, and the thinking effort on success', async () => { + const configRef = { current: emptyConfig() }; + const { core, setCalls, removeCalls } = makeCore(configRef); + const { deps } = makeDeps(); + const flow = new CodexLoginFlow(core, deps); + + const start = await flow.start(); + expect(start.authorize_url).toContain('auth.openai.com'); + expect(start.loopback).toBe(false); + + const status = await flow.submitCode( + start.login_id, + 'http://localhost:1455/auth/callback?code=abc&state=st', + ); + expect(status.state).toBe('completed'); + expect(status.default_model).toBe('openai-codex/gpt-5-codex'); + + // Nothing existed before, so no removal was needed. + expect(removeCalls).toEqual([]); + const patch = setCalls.at(-1); + expect(patch?.providers?.['openai-codex']).toBeDefined(); + // All five fields travel together: the patch is a deep merge, and a missing + // `thinking` silently drops the effort the apply step just picked. + expect(Object.keys(patch ?? {}).toSorted()).toEqual([ + 'defaultModel', + 'defaultThinking', + 'models', + 'providers', + 'thinking', + ]); + expect(patch?.thinking).toBeDefined(); + }); + + it('drops a previous codex provider before writing the new one', async () => { + const configRef = { + current: { + providers: { 'openai-codex': { type: 'openai_responses' } }, + models: { 'openai-codex/stale': { provider: 'openai-codex', model: 'stale', maxContextSize: 1 } }, + } as unknown as PythinkerConfig, + }; + const { core, removeCalls } = makeCore(configRef); + const { deps } = makeDeps(); + const flow = new CodexLoginFlow(core, deps); + + const start = await flow.start(); + await flow.submitCode(start.login_id, 'http://localhost:1455/auth/callback?code=abc'); + expect(removeCalls).toEqual(['openai-codex']); + }); + + it('never reports a token, only the selected alias', async () => { + const configRef = { current: emptyConfig() }; + const { core } = makeCore(configRef); + const { deps } = makeDeps(); + const flow = new CodexLoginFlow(core, deps); + + const start = await flow.start(); + const status = await flow.submitCode(start.login_id, 'code-only'); + const wire = JSON.stringify({ start, status }); + expect(wire).not.toContain('ACCESS-TOKEN-SECRET'); + expect(wire).not.toContain('REFRESH-TOKEN-SECRET'); + expect(wire).not.toContain('verifier'); + }); + + it('refuses a redirect whose state belongs to another attempt', async () => { + const configRef = { current: emptyConfig() }; + const { core } = makeCore(configRef); + const { deps } = makeDeps(); + const flow = new CodexLoginFlow(core, deps); + + const start = await flow.start(); + await expect( + flow.submitCode(start.login_id, 'http://localhost:1455/auth/callback?code=abc&state=other'), + ).rejects.toThrow(/different login/); + }); + + it('fails the attempt when the token exchange fails, and keeps the message', async () => { + const configRef = { current: emptyConfig() }; + const { core, setCalls } = makeCore(configRef); + const { deps } = makeDeps({ + exchangeCode: async () => { + throw new Error('OpenAI Codex token exchange failed (HTTP 400).'); + }, + }); + const flow = new CodexLoginFlow(core, deps); + + const start = await flow.start(); + const status = await flow.submitCode(start.login_id, 'code-only'); + expect(status.state).toBe('failed'); + expect(status.message).toContain('HTTP 400'); + expect(setCalls).toEqual([]); + }); + + it('releases the callback listener when a second login starts', async () => { + const configRef = { current: emptyConfig() }; + const { core } = makeCore(configRef); + const { deps, callbackClosed } = makeDeps(); + const flow = new CodexLoginFlow(core, deps); + + const first = await flow.start(); + await flow.start(); + expect(callbackClosed()).toBeGreaterThan(0); + // The superseded attempt is gone, so its id no longer resolves. + expect(() => flow.status(first.login_id)).toThrow(CodexLoginNotFoundError); + }); + + it('expires a pending attempt once its window closes', async () => { + const configRef = { current: emptyConfig() }; + const { core } = makeCore(configRef); + let clock = 1_700_000_000_000; + const { deps } = makeDeps({ now: () => clock }); + const flow = new CodexLoginFlow(core, deps); + + const start = await flow.start(); + expect(flow.status(start.login_id).state).toBe('pending'); + clock += 11 * 60 * 1000; + const expired = flow.status(start.login_id); + expect(expired.state).toBe('failed'); + expect(expired.message).toContain('timed out'); + }); + + it('cancels a pending attempt', async () => { + const configRef = { current: emptyConfig() }; + const { core } = makeCore(configRef); + const { deps } = makeDeps(); + const flow = new CodexLoginFlow(core, deps); + + const start = await flow.start(); + expect(flow.cancel(start.login_id).state).toBe('cancelled'); + }); +}); + +describe('pickDefaultModel', () => { + it('prefers the codex model over the first entry', () => { + expect(pickDefaultModel(MODELS).id).toBe('gpt-5-codex'); + }); + + it('falls back to the first model when none is a codex model', () => { + expect(pickDefaultModel([MODELS[0]!]).id).toBe('gpt-5'); + }); +}); diff --git a/packages/server/src/routes/codexLogin.ts b/packages/server/src/routes/codexLogin.ts new file mode 100644 index 000000000..56935c690 --- /dev/null +++ b/packages/server/src/routes/codexLogin.ts @@ -0,0 +1,175 @@ +import { z } from 'zod'; + +import { + codexLoginStartSchema, + codexLoginStatusSchema, + codexLoginSubmitCodeRequestSchema, + ErrorCode, +} from '@pymodel/protocol'; +import { + CodexLoginInvalidCodeError, + CodexLoginNotFoundError, + ICodexLoginService, + type IInstantiationService, +} from '@pymodel/agent-core'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { parseActionSuffix } from './action-suffix'; + +interface CodexLoginRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const loginIdParamSchema = z.object({ + login_id: z.string().min(1), +}); + +const loginActionTailParamSchema = z.object({ + tail: z.string().min(1), +}); + +export function registerCodexLoginRoutes( + app: CodexLoginRouteHost, + ix: IInstantiationService, +): void { + const startRoute = defineRoute( + { + method: 'POST', + path: '/auth/codex:start', + success: { data: codexLoginStartSchema }, + description: 'Begin an OpenAI Codex OAuth login and return the authorize URL', + tags: ['auth'], + operationId: 'startCodexLogin', + }, + async (req, reply) => { + const start = await ix.invokeFunction((a) => a.get(ICodexLoginService).start()); + reply.send(okEnvelope(start, req.id)); + }, + ); + app.post( + startRoute.path, + startRoute.options, + startRoute.handler as Parameters[2], + ); + + const statusRoute = defineRoute( + { + method: 'GET', + path: '/auth/codex/{login_id}', + params: loginIdParamSchema, + success: { data: codexLoginStatusSchema }, + errors: { + [ErrorCode.CODEX_LOGIN_NOT_FOUND]: {}, + }, + description: 'Poll the state of an OpenAI Codex login', + tags: ['auth'], + operationId: 'getCodexLoginStatus', + }, + async (req, reply) => { + try { + const status = ix.invokeFunction((a) => + a.get(ICodexLoginService).status(req.params.login_id), + ); + reply.send(okEnvelope(status, req.id)); + } catch (error) { + sendMappedError(reply, req.id, error); + } + }, + ); + app.get( + statusRoute.path, + statusRoute.options, + statusRoute.handler as Parameters[2], + ); + + const actionRoute = defineRoute( + { + method: 'POST', + path: '/auth/codex/{tail}', + params: loginActionTailParamSchema, + body: codexLoginSubmitCodeRequestSchema.partial(), + success: { data: codexLoginStatusSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.CODEX_LOGIN_NOT_FOUND]: {}, + }, + description: 'Submit a pasted redirect URL, or cancel an OpenAI Codex login', + tags: ['auth'], + operationId: 'actOnCodexLogin', + }, + async (req, reply) => { + try { + const parsed = parseActionSuffix({ + tail: req.params.tail, + allowedActions: ['submit_code', 'cancel'] as const, + resourceLabel: 'codex login', + }); + if (parsed.kind !== 'action') { + const message = + parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${req.params.tail}`; + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); + return; + } + + if (parsed.action === 'cancel') { + const status = ix.invokeFunction((a) => + a.get(ICodexLoginService).cancel(parsed.id), + ); + reply.send(okEnvelope(status, req.id)); + return; + } + + const redirectUrl = req.body.redirect_url; + if (redirectUrl === undefined || redirectUrl.length === 0) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, 'redirect_url is required', req.id), + ); + return; + } + const status = await ix.invokeFunction((a) => + a.get(ICodexLoginService).submitCode(parsed.id, redirectUrl), + ); + reply.send(okEnvelope(status, req.id)); + } catch (error) { + sendMappedError(reply, req.id, error); + } + }, + ); + app.post( + actionRoute.path, + actionRoute.options, + actionRoute.handler as Parameters[2], + ); +} + +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (err instanceof CodexLoginNotFoundError) { + reply.send(errEnvelope(ErrorCode.CODEX_LOGIN_NOT_FOUND, err.message, requestId)); + return; + } + if (err instanceof CodexLoginInvalidCodeError) { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId)); + return; + } + throw err; +} diff --git a/packages/server/src/routes/registerApiV1Routes.ts b/packages/server/src/routes/registerApiV1Routes.ts index 2304c193e..22e6484b8 100644 --- a/packages/server/src/routes/registerApiV1Routes.ts +++ b/packages/server/src/routes/registerApiV1Routes.ts @@ -4,6 +4,7 @@ import { ulid } from 'ulid'; import { okEnvelope } from '../envelope'; import { registerApprovalsRoutes } from './approvals'; import { registerAuthRoute } from './auth'; +import { registerCodexLoginRoutes } from './codexLogin'; import { registerConfigRoutes } from './config'; import { registerConnectionsRoutes } from './connections'; import { registerDebugRoutes } from './debug'; @@ -65,6 +66,10 @@ export async function registerApiV1Routes( }); registerAuthRoute(apiV1 as unknown as Parameters[0], ix); + registerCodexLoginRoutes( + apiV1 as unknown as Parameters[0], + ix, + ); registerConfigRoutes(apiV1 as unknown as Parameters[0], ix); registerConnectionsRoutes( apiV1 as unknown as Parameters[0], diff --git a/packages/server/test/codex-login.e2e.test.ts b/packages/server/test/codex-login.e2e.test.ts new file mode 100644 index 000000000..b701c65f6 --- /dev/null +++ b/packages/server/test/codex-login.e2e.test.ts @@ -0,0 +1,144 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { CodexLoginNotFoundError, ICodexLoginService } from '@pymodel/agent-core'; +import type { CodexLoginStart, CodexLoginStatus } from '@pymodel/protocol'; + +import { IRestGateway, startServer, type RunningServer, type ServerStartOptions } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let server: RunningServer | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'pythinker-server-codex-login-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'pythinker-server-codex-login-home-')); +}); + +afterEach(async () => { + try { + await server?.close(); + } catch { + // ignore + } + server = undefined; + rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + rmSync(bridgeHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +async function bootDaemon( + serviceOverrides?: ServerStartOptions['serviceOverrides'], +): Promise { + server = await startServer({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + serviceOverrides, + }); + return server; +} + +function appOf(r: RunningServer): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); +} + +function envelopeOf(body: unknown): { code: number; data: T | null } { + return body as { code: number; data: T | null }; +} + +/** Stands in for the OAuth round trip; no browser, no network. */ +function fakeLoginService(): { service: ICodexLoginService; submitted: string[] } { + const submitted: string[] = []; + const start: CodexLoginStart = { + login_id: 'login-1', + authorize_url: 'https://auth.openai.com/oauth/authorize?client_id=app_test&state=s', + loopback: true, + expires_at: new Date(1_700_000_000_000).toISOString(), + }; + let state: CodexLoginStatus = { login_id: 'login-1', state: 'pending' }; + const service: ICodexLoginService = { + _serviceBrand: undefined, + start: async () => start, + status: (loginId: string) => { + if (loginId !== 'login-1') throw new CodexLoginNotFoundError(loginId); + return state; + }, + submitCode: async (loginId: string, redirectUrl: string) => { + if (loginId !== 'login-1') throw new CodexLoginNotFoundError(loginId); + submitted.push(redirectUrl); + state = { login_id: 'login-1', state: 'completed', default_model: 'openai-codex/gpt-5-codex' }; + return state; + }, + cancel: (loginId: string) => { + if (loginId !== 'login-1') throw new CodexLoginNotFoundError(loginId); + state = { login_id: 'login-1', state: 'cancelled' }; + return state; + }, + }; + return { service, submitted }; +} + +describe('codex login routes', () => { + it('starts a login and hands back the authorize URL', async () => { + const { service } = fakeLoginService(); + const r = await bootDaemon([[ICodexLoginService, service]]); + + const res = await appOf(r).inject({ method: 'POST', url: '/api/v1/auth/codex:start' }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + expect(env.data?.authorize_url).toContain('auth.openai.com'); + expect(env.data?.loopback).toBe(true); + // The reply must never carry a token or the PKCE verifier. + expect(JSON.stringify(env.data)).not.toContain('verifier'); + }); + + it('reports the state and completes from a pasted redirect URL', async () => { + const { service, submitted } = fakeLoginService(); + const r = await bootDaemon([[ICodexLoginService, service]]); + + const pending = await appOf(r).inject({ method: 'GET', url: '/api/v1/auth/codex/login-1' }); + expect(envelopeOf(pending.json()).data?.state).toBe('pending'); + + const done = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/auth/codex/login-1:submit_code', + payload: { redirect_url: 'http://localhost:1455/auth/callback?code=abc&state=s' }, + }); + const doneEnv = envelopeOf(done.json()); + expect(doneEnv.code).toBe(0); + expect(doneEnv.data?.state).toBe('completed'); + expect(doneEnv.data?.default_model).toBe('openai-codex/gpt-5-codex'); + expect(submitted).toEqual(['http://localhost:1455/auth/callback?code=abc&state=s']); + }); + + it('rejects a submit with no redirect URL and an unknown login id', async () => { + const { service } = fakeLoginService(); + const r = await bootDaemon([[ICodexLoginService, service]]); + + const empty = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/auth/codex/login-1:submit_code', + payload: {}, + }); + expect(envelopeOf(empty.json()).code).toBe(40001); + + const missing = await appOf(r).inject({ method: 'GET', url: '/api/v1/auth/codex/nope' }); + expect(envelopeOf(missing.json()).code).toBe(40416); + }); +}); From 4bd0361a036ee3082d64b20451e2ebf1115777fa Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 17 Aug 2026 13:05:10 -0400 Subject: [PATCH 03/13] feat(web): add OpenAI Codex sign-in to the provider dialog --- .changeset/web-codex-login.md | 5 + apps/pythinker-web/src/App.vue | 6 + apps/pythinker-web/src/api/daemon/client.ts | 40 ++++++ apps/pythinker-web/src/api/daemon/mappers.ts | 11 ++ apps/pythinker-web/src/api/daemon/wire.ts | 14 ++ apps/pythinker-web/src/api/types.ts | 20 +++ .../src/components/ProviderManager.vue | 68 ++++++++++ .../src/composables/useCodexLogin.ts | 126 ++++++++++++++++++ .../src/i18n/locales/en/codexLogin.ts | 12 ++ apps/pythinker-web/src/i18n/locales/index.ts | 2 + apps/pythinker-web/test/codex-login.test.ts | 98 ++++++++++++++ 11 files changed, 402 insertions(+) create mode 100644 .changeset/web-codex-login.md create mode 100644 apps/pythinker-web/src/composables/useCodexLogin.ts create mode 100644 apps/pythinker-web/src/i18n/locales/en/codexLogin.ts create mode 100644 apps/pythinker-web/test/codex-login.test.ts diff --git a/.changeset/web-codex-login.md b/.changeset/web-codex-login.md new file mode 100644 index 000000000..d6da292ee --- /dev/null +++ b/.changeset/web-codex-login.md @@ -0,0 +1,5 @@ +--- +'@pymodel/pythinker-code': minor +--- + +Add OpenAI Codex sign-in to the web and desktop app. The provider dialog now offers "Sign in with ChatGPT" next to the API-key form: the server runs the OAuth exchange, writes the credentials, and reports only which model it selected. When port 1455 is taken, the dialog asks for the redirect URL instead. diff --git a/apps/pythinker-web/src/App.vue b/apps/pythinker-web/src/App.vue index 7078b150e..7ec462244 100644 --- a/apps/pythinker-web/src/App.vue +++ b/apps/pythinker-web/src/App.vue @@ -728,6 +728,11 @@ async function handleRefreshProvider(id: string): Promise { await client.refreshProvider(id); } +/** A Codex sign-in wrote its own provider entry; pull the new lists. */ +async function handleProvidersChanged(): Promise { + await Promise.all([client.loadProviders(), client.loadModels()]); +} + async function handleUpdateConfig(patch: Partial): Promise { configSaving.value = true; try { @@ -1275,6 +1280,7 @@ function openPr(url: string): void { @add="handleAddProvider($event)" @refresh="handleRefreshProvider($event)" @delete="handleDeleteProvider($event)" + @refresh-all="handleProvidersChanged()" @close="showProviders = false" /> diff --git a/apps/pythinker-web/src/api/daemon/client.ts b/apps/pythinker-web/src/api/daemon/client.ts index 07a54b8a3..237b692d1 100644 --- a/apps/pythinker-web/src/api/daemon/client.ts +++ b/apps/pythinker-web/src/api/daemon/client.ts @@ -9,6 +9,8 @@ import type { AppMessageRole, AppModel, AppProvider, + CodexLoginStart, + CodexLoginStatus, ProviderRefreshResult, AppSession, AppConnector, @@ -46,6 +48,7 @@ import { toAppModel, toAppProvider, toAppQuestionRequest, + toCodexLoginStatus, toAppSession, toAppTask, toWireApprovalResponse, @@ -58,6 +61,8 @@ import { } from './mappers'; import type { WireAuthResult, + WireCodexLoginStart, + WireCodexLoginStatus, WireBackgroundTask, WireConfig, WireEvent, @@ -1149,6 +1154,41 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi { return toAppProvider(data); } + async startCodexLogin(): Promise { + const data = await this.http.post('/auth/codex:start'); + return { + loginId: data.login_id, + authorizeUrl: data.authorize_url, + loopback: data.loopback, + expiresAt: data.expires_at, + }; + } + + async getCodexLoginStatus(loginId: string): Promise { + const data = await this.http.get( + `/auth/codex/${encodeURIComponent(loginId)}`, + ); + return toCodexLoginStatus(data); + } + + async submitCodexLoginRedirect( + loginId: string, + redirectUrl: string, + ): Promise { + const data = await this.http.post( + `/auth/codex/${encodeURIComponent(loginId)}:submit_code`, + { redirect_url: redirectUrl }, + ); + return toCodexLoginStatus(data); + } + + async cancelCodexLogin(loginId: string): Promise { + const data = await this.http.post( + `/auth/codex/${encodeURIComponent(loginId)}:cancel`, + ); + return toCodexLoginStatus(data); + } + async refreshOAuthProviderModels(): Promise { const data = await this.http.post('/providers:refresh_oauth'); return { diff --git a/apps/pythinker-web/src/api/daemon/mappers.ts b/apps/pythinker-web/src/api/daemon/mappers.ts index 3e81ce9da..21ac1259b 100644 --- a/apps/pythinker-web/src/api/daemon/mappers.ts +++ b/apps/pythinker-web/src/api/daemon/mappers.ts @@ -9,6 +9,7 @@ import type { AppGoal, AppModel, AppProvider, + CodexLoginStatus, FsEntry, AppMessage, AppMessageContent, @@ -39,6 +40,7 @@ import type { WireMessageContent, WireModel, WirePromptSubmission, + WireCodexLoginStatus, WireProvider, WireQuestionAnswer, WireQuestionItem, @@ -712,6 +714,15 @@ export function toAppModel(wire: WireModel): AppModel { }; } +export function toCodexLoginStatus(wire: WireCodexLoginStatus): CodexLoginStatus { + return { + loginId: wire.login_id, + state: wire.state, + defaultModel: wire.default_model, + message: wire.message, + }; +} + export function toAppProvider(wire: WireProvider): AppProvider { return { id: wire.id, diff --git a/apps/pythinker-web/src/api/daemon/wire.ts b/apps/pythinker-web/src/api/daemon/wire.ts index 2fd49396e..1726f3e33 100644 --- a/apps/pythinker-web/src/api/daemon/wire.ts +++ b/apps/pythinker-web/src/api/daemon/wire.ts @@ -337,6 +337,20 @@ export interface WireModel { adaptive_thinking?: boolean; } +export interface WireCodexLoginStart { + login_id: string; + authorize_url: string; + loopback: boolean; + expires_at: string; +} + +export interface WireCodexLoginStatus { + login_id: string; + state: 'pending' | 'completed' | 'failed' | 'cancelled'; + default_model?: string; + message?: string; +} + export interface WireProvider { id: string; type: string; diff --git a/apps/pythinker-web/src/api/types.ts b/apps/pythinker-web/src/api/types.ts index 5f995fa09..18212f738 100644 --- a/apps/pythinker-web/src/api/types.ts +++ b/apps/pythinker-web/src/api/types.ts @@ -549,6 +549,22 @@ export interface AppProvider { models?: string[]; } +/** An OpenAI Codex sign-in in progress. Carries no token: the server keeps them. */ +export interface CodexLoginStart { + loginId: string; + authorizeUrl: string; + /** `false` when the callback port was taken, so the user must paste the redirect URL. */ + loopback: boolean; + expiresAt: string; +} + +export interface CodexLoginStatus { + loginId: string; + state: 'pending' | 'completed' | 'failed' | 'cancelled'; + defaultModel?: string; + message?: string; +} + export interface ProviderRefreshResult { changed: Array<{ providerId: string; @@ -733,6 +749,10 @@ export interface PythinkerWebApi { deleteProvider(id: string): Promise<{ deleted: true }>; refreshProvider(id: string): Promise; refreshOAuthProviderModels(): Promise; + startCodexLogin(): Promise; + getCodexLoginStatus(loginId: string): Promise; + submitCodexLoginRedirect(loginId: string, redirectUrl: string): Promise; + cancelCodexLogin(loginId: string): Promise; // File upload / download uploadFile(input: { file: Blob; name?: string }): Promise<{ id: string; name: string; mediaType: string; size: number }>; diff --git a/apps/pythinker-web/src/components/ProviderManager.vue b/apps/pythinker-web/src/components/ProviderManager.vue index 0c8ba1a45..756489cbf 100644 --- a/apps/pythinker-web/src/components/ProviderManager.vue +++ b/apps/pythinker-web/src/components/ProviderManager.vue @@ -5,6 +5,7 @@ import { onMounted, onUnmounted, reactive, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import type { AppProvider } from '../api/types'; +import { useCodexLogin } from '../composables/useCodexLogin'; import { useDialogFocus } from '../composables/useDialogFocus'; const { t } = useI18n(); @@ -24,6 +25,8 @@ const emit = defineEmits<{ add: [input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }]; refresh: [id: string]; delete: [id: string]; + /** A Codex sign-in finished; reload the provider and model lists. */ + 'refresh-all': []; /** Open the login dialog for the given platform (OAuth flow) */ close: []; }>(); @@ -88,6 +91,25 @@ function submitAdd(): void { showAddForm.value = false; } +// ------------------------------------------------------------------------- +// OpenAI Codex sign-in +// ------------------------------------------------------------------------- + +// The server holds the tokens and writes the config; this only opens the tab +// and reports progress. A finished login changes the provider list, so the +// parent refreshes through the same event the API-key form uses. +const codex = useCodexLogin(() => { + emit('refresh-all'); +}); +const codexRedirect = ref(''); + +function submitCodexRedirect(): void { + const value = codexRedirect.value.trim(); + if (value.length === 0) return; + codexRedirect.value = ''; + void codex.submitRedirect(value); +} + // ------------------------------------------------------------------------- // Keyboard — Esc closes // ------------------------------------------------------------------------- @@ -201,6 +223,48 @@ function statusLabel(status: AppProvider['status']): string { {{ t('providers.enterApiKey') }} + + +
+

+ {{ t('codexLogin.waiting') }} +

+ +

+ {{ t('codexLogin.failed', { message: codex.error.value }) }} +

+
+ + +
diff --git a/apps/pythinker-web/src/components/ProviderManager.vue b/apps/pythinker-web/src/components/ProviderManager.vue index 756489cbf..e74b2752c 100644 --- a/apps/pythinker-web/src/components/ProviderManager.vue +++ b/apps/pythinker-web/src/components/ProviderManager.vue @@ -225,7 +225,7 @@ function statusLabel(status: AppProvider['status']): string {