diff --git a/package-lock.json b/package-lock.json index a8b41f063..5878e391a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6086,6 +6086,7 @@ "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", + "jose": "6.2.13", "openai": "7.43.0", "partial-json": "0.1.7", "typebox": "1.4.3" diff --git a/packages/ai/package.json b/packages/ai/package.json index f9cffc09c..2cba0f94a 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -75,6 +75,7 @@ "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", + "jose": "6.2.13", "openai": "7.43.0", "partial-json": "0.1.7", "typebox": "1.4.3" diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index a26d4ab47..f90b13dfd 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -141,6 +141,11 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions> if (nextParams !== undefined) { params = nextParams as ResponseCreateParamsStreaming; } + if (model.provider === "openai-codex") { + params.model = model.id; + params.store = false; + params.stream = true; + } const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), diff --git a/packages/ai/src/auth/errors.ts b/packages/ai/src/auth/errors.ts new file mode 100644 index 000000000..5d2a75b0a --- /dev/null +++ b/packages/ai/src/auth/errors.ts @@ -0,0 +1,7 @@ +/** A provider confirmed that this renewable session cannot be used again. */ +export class OAuthSessionExpiredError extends Error { + constructor(message: string) { + super(message); + this.name = "OAuthSessionExpiredError"; + } +} diff --git a/packages/ai/src/auth/helpers.ts b/packages/ai/src/auth/helpers.ts index 7265cc59e..c8443b5bb 100644 --- a/packages/ai/src/auth/helpers.ts +++ b/packages/ai/src/auth/helpers.ts @@ -1,5 +1,12 @@ import type { ApiKeyAuth, OAuthAuth } from "./types.ts"; +/** Keep retained identity tokens out of terminal output and diagnostic URLs. */ +export function getOAuthDisplayUrl(value: string): string { + const url = new URL(value); + url.searchParams.delete("id_token_hint"); + return url.toString(); +} + /** * Standard api-key auth: a stored credential key wins, otherwise the first * set env var resolves. Includes a `login` that prompts for the key. @@ -54,6 +61,7 @@ export function lazyOAuth(input: { loginLabel: input.loginLabel, login: async (interaction) => (await loaded()).login(interaction), refresh: async (credential, signal) => (await loaded()).refresh(credential, signal), + logout: async (credential, signal) => (await loaded()).logout?.(credential, signal), toAuth: async (credential) => (await loaded()).toAuth(credential), }; } diff --git a/packages/ai/src/auth/oauth/chatgpt-registration.ts b/packages/ai/src/auth/oauth/chatgpt-registration.ts new file mode 100644 index 000000000..fd29c3612 --- /dev/null +++ b/packages/ai/src/auth/oauth/chatgpt-registration.ts @@ -0,0 +1,73 @@ +import { randomUUID } from "node:crypto"; +import { linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface ChatGPTRegistration { + clientId: string; + subject?: string; + email?: string; + idToken?: string; + scopes?: string[]; +} + +/** Registration metadata survives logout; renewable tokens live in the credential store. */ +export class ChatGPTRegistrationStore { + private readonly directory: string; + + constructor() { + const configured = process.env.DIFFEX_CODING_AGENT_DIR; + const directory = configured?.startsWith("~/") ? join(homedir(), configured.slice(2)) : configured; + this.directory = join(directory || join(homedir(), ".diffex", "agent"), "chatgpt"); + mkdirSync(this.directory, { recursive: true, mode: 0o700 }); + } + + hostId(): string { + const path = join(this.directory, "host"); + const temporary = join(this.directory, `${randomUUID()}.tmp`); + // Publish a complete file exclusively when processes first sign in concurrently. + writeFileSync(temporary, `urn:uuid:${randomUUID()}`, { mode: 0o600, flag: "wx" }); + try { + try { + linkSync(temporary, path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + const host = readFileSync(path, "utf8").trim(); + if (!/^urn:uuid:[0-9a-f-]{36}$/.test(host)) throw new Error("Invalid saved ChatGPT host identity"); + return host; + } finally { + unlinkSync(temporary); + } + } + + list(): ChatGPTRegistration[] { + return readdirSync(this.directory) + .filter((name) => name.endsWith(".json")) + .map((name) => { + const value = JSON.parse(readFileSync(join(this.directory, name), "utf8")) as ChatGPTRegistration; + if ( + !/^oaiapp_[\w-]+$/.test(value.clientId) || + (value.subject !== undefined && (typeof value.subject !== "string" || !value.subject)) || + (value.email !== undefined && typeof value.email !== "string") || + (value.idToken !== undefined && typeof value.idToken !== "string") || + (value.scopes !== undefined && + (!Array.isArray(value.scopes) || !value.scopes.every((scope) => typeof scope === "string"))) + ) { + throw new Error("Invalid saved ChatGPT registration"); + } + return value; + }); + } + + save(registration: ChatGPTRegistration): void { + if (!/^oaiapp_[\w-]+$/.test(registration.clientId)) throw new Error("Invalid issued ChatGPT client ID"); + const temporary = join(this.directory, `${randomUUID()}.tmp`); + try { + writeFileSync(temporary, JSON.stringify(registration), { mode: 0o600, flag: "wx" }); + renameSync(temporary, join(this.directory, `${registration.clientId}.json`)); + } finally { + rmSync(temporary, { force: true }); + } + } +} diff --git a/packages/ai/src/auth/oauth/openai-codex.ts b/packages/ai/src/auth/oauth/openai-codex.ts index fc003eaeb..64c76ffd2 100644 --- a/packages/ai/src/auth/oauth/openai-codex.ts +++ b/packages/ai/src/auth/oauth/openai-codex.ts @@ -1,544 +1,370 @@ -/** - * OpenAI Codex (ChatGPT OAuth) flow - * - * NOTE: This module uses Node.js crypto and http for the OAuth callback. - * It is only intended for CLI use, not browser environments. - */ - -// NEVER convert to top-level imports - breaks browser/Vite builds -let _randomBytes: typeof import("node:crypto").randomBytes | null = null; -let _http: typeof import("node:http") | null = null; -if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { - import("node:crypto").then((m) => { - _randomBytes = m.randomBytes; - }); - import("node:http").then((m) => { - _http = m; - }); -} - -import { getProviderEnvValue } from "../../utils/provider-env.ts"; +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import { setTimeout as sleep } from "node:timers/promises"; +import { createRemoteJWKSet, customFetch, jwtVerify } from "jose"; +import { OAuthSessionExpiredError } from "../errors.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; -import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import { type ChatGPTRegistration, ChatGPTRegistrationStore } from "./chatgpt-registration.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; -const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; -const AUTH_BASE_URL = "https://auth.openai.com"; -const AUTHORIZE_URL = `${AUTH_BASE_URL}/oauth/authorize`; -const TOKEN_URL = `${AUTH_BASE_URL}/oauth/token`; -const REDIRECT_URI = "http://localhost:1455/auth/callback"; -const DEVICE_USER_CODE_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/usercode`; -const DEVICE_TOKEN_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/token`; -const DEVICE_VERIFICATION_URI = `${AUTH_BASE_URL}/codex/device`; -const DEVICE_REDIRECT_URI = `${AUTH_BASE_URL}/deviceauth/callback`; -const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60; -const OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser"; -const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code"; -const SCOPE = "openid profile email offline_access"; -const JWT_CLAIM_PATH = "https://api.openai.com/auth"; - -type OAuthToken = { access: string; refresh: string; expires: number }; -type TokenOperation = "exchange" | "refresh"; - -function getCallbackHost(): string { - return getProviderEnvValue("DIFFEX_OAUTH_CALLBACK_HOST") || "127.0.0.1"; -} - -type DeviceAuthInfo = { - deviceAuthId: string; - userCode: string; - intervalSeconds: number; -}; - -type DeviceTokenSuccess = { - authorizationCode: string; - codeVerifier: string; -}; - -type JwtPayload = { - [JWT_CLAIM_PATH]?: { - chatgpt_account_id?: string; - }; - [key: string]: unknown; -}; - -function createState(): string { - if (!_randomBytes) { - throw new Error("OpenAI Codex OAuth is only available in Node.js environments"); - } - return _randomBytes(16).toString("hex"); -} - -function parseAuthorizationInput(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 - } - - if (value.includes("#")) { - const [code, state] = value.split("#", 2); - return { code, state }; - } - - if (value.includes("code=")) { - const params = new URLSearchParams(value); - return { - code: params.get("code") ?? undefined, - state: params.get("state") ?? undefined, - }; - } - - return { code: value }; -} - -function decodeJwt(token: string): JwtPayload | null { - try { - const parts = token.split("."); - if (parts.length !== 3) return null; - const payload = parts[1] ?? ""; - const decoded = atob(payload); - return JSON.parse(decoded) as JwtPayload; - } catch { - return null; - } -} - -async function fetchWithLoginCancellation(input: string, init: RequestInit): Promise { - try { - return await fetch(input, init); - } catch (error) { - if (init.signal?.aborted) { - throw new Error("Login cancelled"); - } - throw error; - } -} - -async function readTokenResponse(response: Response, operation: TokenOperation): Promise { - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw new Error(`OpenAI Codex token ${operation} failed (${response.status}): ${text || response.statusText}`); - } - - const rawJson = await response.json(); - const json = rawJson as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - } | null; - if (!json?.access_token || !json.refresh_token || typeof json.expires_in !== "number") { - throw new Error(`OpenAI Codex token ${operation} response missing fields: ${JSON.stringify(json)}`); - } - - return { - access: json.access_token, - refresh: json.refresh_token, - expires: Date.now() + json.expires_in * 1000, - }; -} - -async function exchangeAuthorizationCode( - code: string, - verifier: string, - redirectUri: string, +const ISSUER = "https://auth.openai.com"; +const TOKEN_URL = `${ISSUER}/api/accounts/oauth/token`; +const RESOURCE = "https://api.openai.com/v1"; +const SCOPE = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"; +const PLAN_SCOPE = "chatgpt.tokens.use.direct"; +const TERMINAL_REFRESH_ERRORS = new Set([ + "invalid_grant", + "invalid_refresh_token", + "token_expired", + "refresh_token_expired", + "refresh_token_invalidated", + "refresh_token_reused", +]); + +async function tokenCredentials( + response: Response, + clientId: string, + hostId: string, signal: AbortSignal, -): Promise { - const response = await fetchWithLoginCancellation(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: CLIENT_ID, - code, - code_verifier: verifier, - redirect_uri: redirectUri, - }), - signal, - }); - - return readTokenResponse(response, "exchange"); -} - -async function refreshAccessToken(refreshToken: string, signal: AbortSignal): Promise { - let response: Response; - try { - response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: CLIENT_ID, - }), - signal, - }); - } catch (error) { - throw new Error(`OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`); - } - - return readTokenResponse(response, "refresh"); -} - -async function startOpenAICodexDeviceAuth(signal: AbortSignal): Promise { - const response = await fetchWithLoginCancellation(DEVICE_USER_CODE_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ client_id: CLIENT_ID }), - signal, - }); - + options: { nonce?: string; previous?: OAuthCredential; registration?: ChatGPTRegistration }, +): Promise { if (!response.ok) { - if (response.status === 404) { - throw new Error( - "OpenAI Codex device code login is not enabled for this server. Use browser login or verify the server URL.", - ); + const body: unknown = await response.json().catch(() => undefined); + const error = body && typeof body === "object" && "error" in body ? body.error : undefined; + const code = + typeof error === "string" + ? error + : error && typeof error === "object" && "code" in error + ? error.code + : undefined; + if (options.previous && typeof code === "string" && TERMINAL_REFRESH_ERRORS.has(code)) { + throw new OAuthSessionExpiredError(`ChatGPT session expired (${code}). Run /login.`); } - const responseBody = await response.text().catch(() => ""); throw new Error( - `OpenAI Codex device code request failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`, + `ChatGPT token request failed (${response.status}). ${response.status >= 500 ? "Try again later." : "Sign in again with /login."}`, ); } - - const rawJson = await response.json(); - const json = rawJson as { - device_auth_id?: string; - user_code?: string; - interval?: number | string; - } | null; - const intervalSeconds = typeof json?.interval === "string" ? Number(json.interval.trim()) : json?.interval; + const json = (await response.json()) as Record; if ( - !json?.device_auth_id || - !json.user_code || - typeof intervalSeconds !== "number" || - !Number.isFinite(intervalSeconds) || - intervalSeconds < 0 + typeof json.access_token !== "string" || + !json.access_token || + typeof json.refresh_token !== "string" || + !json.refresh_token || + typeof json.expires_in !== "number" || + !Number.isFinite(json.expires_in) || + json.expires_in <= 0 || + !Number.isSafeInteger(Date.now() + json.expires_in * 1000) || + typeof json.token_type !== "string" || + json.token_type.toLowerCase() !== "bearer" ) { - throw new Error(`Invalid OpenAI Codex device code response: ${JSON.stringify(json)}`); + throw new Error("Invalid ChatGPT token response"); } - + const scopes = typeof json.scope === "string" ? json.scope.split(/\s+/).filter(Boolean) : options.previous?.scopes; + if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string")) + throw new Error("Missing ChatGPT granted scopes"); + let subject = options.previous?.subject; + let email = options.previous?.email; + let idToken = options.previous?.idToken; + if (typeof json.id_token === "string") { + const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`), { + [customFetch]: (url, init) => fetch(url, { ...init, signal: AbortSignal.any([signal, init.signal]) }), + }); + const { payload } = await jwtVerify(json.id_token, jwks, { + issuer: ISSUER, + audience: clientId, + algorithms: ["RS256", "ES256"], + requiredClaims: ["sub", "exp", "iat", ...(options.nonce ? ["nonce"] : [])], + clockTolerance: 5, + }); + if (options.nonce && payload.nonce !== options.nonce) throw new Error("ChatGPT ID token nonce mismatch"); + if ( + !payload.sub || + (payload.azp !== undefined && payload.azp !== clientId) || + (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== clientId) + ) + throw new Error("Invalid ChatGPT ID token identity"); + const expectedSubject = options.registration?.subject ?? options.previous?.subject; + if (expectedSubject && payload.sub !== expectedSubject) throw new Error("ChatGPT account identity changed"); + subject = payload.sub; + email = typeof payload.email === "string" ? payload.email : undefined; + idToken = json.id_token; + } else if (options.nonce) throw new Error("ChatGPT sign-in response is missing an ID token"); + signal.throwIfAborted(); + if (typeof subject !== "string" || !subject) throw new Error("Missing validated ChatGPT identity. Run /login."); return { - deviceAuthId: json.device_auth_id, - userCode: json.user_code, - intervalSeconds, + type: "oauth", + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + clientId, + extAgentHostId: hostId, + issuer: ISSUER, + subject, + email, + idToken, + scopes, }; } -async function pollOpenAICodexDeviceAuth(device: DeviceAuthInfo, signal: AbortSignal): Promise { - return pollOAuthDeviceCodeFlow({ - intervalSeconds: device.intervalSeconds, - expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS, - signal, - poll: async () => { - const response = await fetchWithLoginCancellation(DEVICE_TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - device_auth_id: device.deviceAuthId, - user_code: device.userCode, - }), - signal, - }); - - if (response.ok) { - const rawJson = await response.json(); - const json = rawJson as { authorization_code?: string; code_verifier?: string } | null; - if (!json?.authorization_code || !json.code_verifier) { - return { - status: "failed", - message: `Invalid OpenAI Codex device auth token response: ${JSON.stringify(json)}`, - }; - } - return { - status: "complete", - value: { authorizationCode: json.authorization_code, codeVerifier: json.code_verifier }, - }; - } - - if (response.status === 403 || response.status === 404) { - return { status: "pending" }; - } - - const responseBody = await response.text().catch(() => ""); - let errorCode: unknown; - try { - const json = JSON.parse(responseBody) as { error?: string | { code?: string } } | null; - const error = json?.error; - errorCode = typeof error === "object" ? error?.code : error; - } catch {} - - if (errorCode === "deviceauth_authorization_pending") { - return { status: "pending" }; - } - if (errorCode === "slow_down") { - return { status: "slow_down" }; - } - - return { - status: "failed", - message: `OpenAI Codex device auth failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`, - }; - }, - }); -} - -async function createAuthorizationFlow( - originator: string = "diffex", -): Promise<{ verifier: string; state: string; url: string }> { - const { verifier, challenge } = await generatePKCE(); - const state = createState(); - - const url = new URL(AUTHORIZE_URL); - url.searchParams.set("response_type", "code"); - url.searchParams.set("client_id", CLIENT_ID); - url.searchParams.set("redirect_uri", REDIRECT_URI); - url.searchParams.set("scope", SCOPE); - url.searchParams.set("code_challenge", challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", state); - url.searchParams.set("id_token_add_organizations", "true"); - url.searchParams.set("codex_cli_simplified_flow", "true"); - url.searchParams.set("originator", originator); - - return { verifier, state, url: url.toString() }; -} - -type OAuthServerInfo = { - close: () => void; - cancelWait: () => void; - waitForCode: () => Promise<{ code: string } | null>; -}; - -function startLocalOAuthServer(state: string): Promise { - if (!_http) { - throw new Error("OpenAI Codex OAuth is only available in Node.js environments"); +function requireRegistration(credential: OAuthCredential): { clientId: string; hostId: string } { + if ( + typeof credential.clientId !== "string" || + !/^oaiapp_[\w-]+$/.test(credential.clientId) || + typeof credential.extAgentHostId !== "string" || + typeof credential.subject !== "string" + ) { + throw new Error("ChatGPT registration is missing. Run /login to register Diffex."); } - - let settleWait: ((value: { code: string } | null) => void) | undefined; - const waitForCodePromise = new Promise<{ code: string } | null>((resolve) => { - let settled = false; - settleWait = (value) => { - if (settled) return; - settled = true; - resolve(value); - }; - }); - - const server = _http.createServer((req, res) => { - try { - const url = new URL(req.url || "", "http://localhost"); - if (url.pathname !== "/auth/callback") { - res.statusCode = 404; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthErrorHtml("Callback route not found.")); - return; - } - if (url.searchParams.get("state") !== state) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthErrorHtml("State mismatch.")); - return; - } - const code = url.searchParams.get("code"); - if (!code) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthErrorHtml("Missing authorization code.")); - return; - } - res.statusCode = 200; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthSuccessHtml()); - settleWait?.({ code }); - } catch { - res.statusCode = 500; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthErrorHtml("Internal error while processing OAuth callback.")); - } - }); - - return new Promise((resolve) => { - server - .listen(1455, getCallbackHost(), () => { - resolve({ - close: () => server.close(), - cancelWait: () => { - settleWait?.(null); - }, - waitForCode: () => waitForCodePromise, - }); - }) - .on("error", (_err: NodeJS.ErrnoException) => { - settleWait?.(null); - resolve({ - close: () => { - try { - server.close(); - } catch { - // ignore - } - }, - cancelWait: () => {}, - waitForCode: async () => null, - }); - }); - }); + return { clientId: credential.clientId, hostId: credential.extAgentHostId }; } -function getAccountId(accessToken: string): string | null { - const payload = decodeJwt(accessToken); - const auth = payload?.[JWT_CLAIM_PATH]; - const accountId = auth?.chatgpt_account_id; - return typeof accountId === "string" && accountId.length > 0 ? accountId : null; -} - -function credentialsFromToken(token: OAuthToken): OAuthCredential { - const accountId = getAccountId(token.access); - if (!accountId) { - throw new Error("Failed to extract accountId from token"); +async function login(interaction: ProviderAuthInteraction): Promise { + interaction.signal.throwIfAborted(); + const store = new ChatGPTRegistrationStore(); + const hostId = store.hostId(); + const registrations = store.list(); + let registration: ChatGPTRegistration | undefined; + if (registrations.length) { + const selected = await interaction.prompt({ + type: "select", + message: "Continue with ChatGPT", + options: [ + ...registrations.map((entry) => ({ + id: entry.clientId, + label: `${entry.scopes && !entry.scopes.includes(PLAN_SCOPE) ? "Enable ChatGPT plan usage: " : ""}${entry.email ?? entry.subject ?? "Complete registration"} (${entry.clientId})`, + })), + { id: "new", label: "Add a ChatGPT account or workspace" }, + ], + }); + registration = registrations.find((entry) => entry.clientId === selected); + if (!registration && selected !== "new") throw new Error("Unknown ChatGPT registration"); } - - return { - type: "oauth", - access: token.access, - refresh: token.refresh, - expires: token.expires, - accountId, - }; -} - -async function exchangeAuthorizationCodeForCredentials( - code: string, - verifier: string, - redirectUri: string, - signal: AbortSignal, -): Promise { - return credentialsFromToken(await exchangeAuthorizationCode(code, verifier, redirectUri, signal)); -} - -async function loginOpenAICodexDeviceCode(interaction: ProviderAuthInteraction): Promise { - const device = await startOpenAICodexDeviceAuth(interaction.signal); - interaction.notify({ - type: "device_code", - userCode: device.userCode, - verificationUri: DEVICE_VERIFICATION_URI, - intervalSeconds: device.intervalSeconds, - expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS, + const signal = AbortSignal.any([interaction.signal, AbortSignal.timeout(10 * 60 * 1000)]); + signal.throwIfAborted(); + const state = randomBytes(32).toString("base64url"); + const nonce = randomBytes(32).toString("base64url"); + const { verifier, challenge } = await generatePKCE(); + let accept: (url: URL) => void = () => {}; + const callback = new Promise((resolve) => { + accept = resolve; }); - const code = await pollOpenAICodexDeviceAuth(device, interaction.signal); - return exchangeAuthorizationCodeForCredentials( - code.authorizationCode, - code.codeVerifier, - DEVICE_REDIRECT_URI, - interaction.signal, - ); -} - -async function loginOpenAICodex(interaction: ProviderAuthInteraction): Promise { - const { verifier, state, url } = await createAuthorizationFlow(); - const server = await startLocalOAuthServer(state); - const manualAbort = new AbortController(); - const onAbort = () => server.cancelWait(); - interaction.signal.addEventListener("abort", onAbort, { once: true }); - if (interaction.signal.aborted) onAbort(); - let code: string | undefined; - let manualCode: string | undefined; - let manualError: Error | undefined; - - interaction.notify({ - type: "auth_url", - url, - instructions: "A browser window should open. Complete login to finish.", + let consumed = false; + const server = createServer((req, res) => { + const url = new URL(req.url ?? "", "http://127.0.0.1"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + if (req.method !== "GET" || url.pathname !== "/auth/callback") { + res.writeHead(404).end(oauthErrorHtml("Callback route not found.")); + } else if (consumed || url.searchParams.get("state") !== state) { + res.writeHead(400).end(oauthErrorHtml("State mismatch or sign-in already completed.")); + } else if (!url.searchParams.has("code") && !url.searchParams.has("error")) { + res.writeHead(400).end(oauthErrorHtml("Missing authorization code.")); + } else { + consumed = true; + res.end(url.searchParams.has("error") ? oauthErrorHtml("Sign-in was declined.") : oauthSuccessHtml()); + accept(url); + } }); - + const promptController = new AbortController(); + let onAbort: (() => void) | undefined; try { - const manualPromise = interaction + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.removeListener("error", reject); + resolve(); + }); + }); + const address = server.address(); + signal.throwIfAborted(); + if (!address || typeof address === "string") throw new Error("ChatGPT callback listener unavailable"); + const redirectUri = `http://127.0.0.1:${address.port}/auth/callback`; + const authorize = new URL(`${ISSUER}/api/accounts/authorize`); + authorize.search = new URLSearchParams({ + client_id: registration?.clientId ?? "dynamic_agent_client", + ext_agent_host_id: hostId, + response_type: "code", + redirect_uri: redirectUri, + scope: SCOPE, + resource: RESOURCE, + state, + nonce, + code_challenge_method: "S256", + code_challenge: challenge, + }).toString(); + if (!registration) authorize.searchParams.set("agent_name_hint", "Diffex"); + if (registration?.idToken) authorize.searchParams.set("id_token_hint", registration.idToken); + if (registration?.email) authorize.searchParams.set("login_hint", registration.email); + if (registration?.scopes && !registration.scopes.includes(PLAN_SCOPE)) + authorize.searchParams.set("prompt", "consent"); + interaction.notify({ + type: "auth_url", + url: authorize.toString(), + instructions: "Continue with ChatGPT in your browser.", + }); + const manual = interaction .prompt({ type: "manual_code", - message: "Complete login in your browser, or paste the authorization code / redirect URL here:", - placeholder: REDIRECT_URI, - signal: manualAbort.signal, + message: "Complete sign-in in your browser, or paste the full redirect URL:", + placeholder: redirectUri, + signal: promptController.signal, }) .then((input) => { - manualCode = input; - server.cancelWait(); - }) - .catch((error) => { - manualError = error instanceof Error ? error : new Error(String(error)); - server.cancelWait(); + const url = new URL(input.trim()); + if (`${url.origin}${url.pathname}` !== redirectUri) throw new Error("ChatGPT redirect URI mismatch"); + return url; }); - - const result = await server.waitForCode(); - if (manualError) throw manualError; - if (result?.code) { - code = result.code; - } else if (manualCode) { - const parsed = parseAuthorizationInput(manualCode); - if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); - code = parsed.code; + const cancelled = new Promise((_, reject) => { + onAbort = () => reject(new Error("ChatGPT sign-in cancelled or timed out")); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); + const url = await Promise.race([callback, manual, cancelled]); + consumed = true; + if (url.searchParams.getAll("state").length !== 1 || url.searchParams.get("state") !== state) + throw new Error("ChatGPT state mismatch"); + if (url.searchParams.has("error")) throw new Error("ChatGPT sign-in was declined. Run /login to try again."); + const code = url.searchParams.get("code"); + const clientId = url.searchParams.get("client_id") ?? registration?.clientId; + if ( + !code || + url.searchParams.getAll("code").length !== 1 || + !clientId || + !/^oaiapp_[\w-]+$/.test(clientId) || + url.searchParams.getAll("client_id").length > 1 || + (registration && registration.clientId !== clientId) + ) { + throw new Error("Incomplete or mismatched ChatGPT registration callback"); } - - if (!code) { - await manualPromise; - if (manualError) throw manualError; - if (manualCode) { - const parsed = parseAuthorizationInput(manualCode); - if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); - code = parsed.code; - } + // A code may expire before exchange. Keep the issued ID for a fresh authorization attempt. + const issuedRegistration = registration ?? store.list().find((entry) => entry.clientId === clientId); + if (!issuedRegistration) store.save({ clientId }); + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + signal, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + resource: RESOURCE, + }), + }); + const credential = await tokenCredentials(response, clientId, hostId, signal, { + nonce, + registration: issuedRegistration, + }); + store.save({ + clientId, + subject: credential.subject as string, + email: credential.email as string | undefined, + idToken: credential.idToken as string, + scopes: credential.scopes as string[], + }); + if (!(credential.scopes as string[]).includes(PLAN_SCOPE)) { + interaction.notify({ + type: "info", + message: + "Signed in to ChatGPT. Plan usage is disabled. Run /login and select Enable ChatGPT plan usage, or configure an OpenAI API key.", + }); } - - if (!code) throw new Error("Missing authorization code"); - return exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI, interaction.signal); + return credential; } finally { - interaction.signal.removeEventListener("abort", onAbort); - manualAbort.abort(); + if (onAbort) signal.removeEventListener("abort", onAbort); + promptController.abort(); server.close(); + server.closeAllConnections(); } } -/** - * Refresh OpenAI Codex OAuth token - */ -async function refreshOpenAICodexToken(refreshToken: string, signal: AbortSignal): Promise { - return credentialsFromToken(await refreshAccessToken(refreshToken, signal)); -} - export const openaiCodexOAuth: OAuthAuth = { - name: "OpenAI (ChatGPT Plus/Pro)", + name: "ChatGPT", isSubscription: true, - - async login(interaction) { - const method = await interaction.prompt({ - type: "select", - message: "Select OpenAI Codex login method:", - options: [ - { id: OPENAI_CODEX_BROWSER_LOGIN_METHOD, label: "Browser login (default)" }, - { id: OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, label: "Device code login (headless)" }, - ], + loginLabel: "Continue with ChatGPT", + login, + async refresh(credential, signal) { + const { clientId, hostId } = requireRegistration(credential); + signal.throwIfAborted(); + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + signal, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: credential.refresh, + resource: RESOURCE, + }), }); - - if (method === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) { - return loginOpenAICodexDeviceCode(interaction); - } - if (method !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) { - throw new Error(`Unknown OpenAI Codex login method: ${method}`); + try { + return await tokenCredentials(response, clientId, hostId, signal, { previous: credential }); + } catch (error) { + if (error instanceof OAuthSessionExpiredError) { + new ChatGPTRegistrationStore().save({ + clientId, + subject: credential.subject as string, + email: credential.email as string | undefined, + }); + } + throw error; } - - return loginOpenAICodex(interaction); }, - - refresh: (credential, signal) => refreshOpenAICodexToken(credential.refresh, signal), - + async logout(credential, signal) { + const { clientId } = requireRegistration(credential); + const store = new ChatGPTRegistrationStore(); + const registration = store.list().find((entry) => entry.clientId === clientId); + if (registration) store.save({ ...registration, idToken: undefined }); + try { + let endpoint: URL | undefined; + for (let attempt = 0; attempt < 3; attempt++) { + try { + if (!endpoint) { + const discovery = await fetch(`${ISSUER}/.well-known/openid-configuration`, { signal }); + if (!discovery.ok) { + if (discovery.status < 500) break; + throw new Error("Discovery temporarily unavailable"); + } + const metadata = (await discovery.json()) as { issuer?: string; revocation_endpoint?: string }; + const discovered = new URL(metadata.revocation_endpoint ?? ""); + if (metadata.issuer !== ISSUER || discovered.origin !== ISSUER) break; + endpoint = discovered; + } + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + signal, + body: new URLSearchParams({ + token: credential.refresh, + token_type_hint: "refresh_token", + client_id: clientId, + }), + }); + if (response.status === 200) return; + if (response.status < 500) break; + } catch { + if (signal.aborted) break; + } + if (attempt < 2) await sleep(250 * 2 ** attempt, undefined, { signal }); + } + } catch {} + throw new Error( + "Signed out locally. Remote revocation was not confirmed; disconnect Diffex in ChatGPT Settings.", + ); + }, async toAuth(credential) { - return { apiKey: credential.access }; + requireRegistration(credential); + if (!credential.access || credential.reauthorizationRequired) + throw new Error("ChatGPT session expired. Run /login."); + if ( + !Array.isArray(credential.scopes) || + !credential.scopes.includes(PLAN_SCOPE) || + !credential.scopes.includes("resource.invoke") + ) { + throw new Error("ChatGPT plan usage is not authorized. Run /login."); + } + return { apiKey: credential.access, baseUrl: RESOURCE }; }, }; diff --git a/packages/ai/src/auth/resolve.ts b/packages/ai/src/auth/resolve.ts index 6425913ec..c6e4907df 100644 --- a/packages/ai/src/auth/resolve.ts +++ b/packages/ai/src/auth/resolve.ts @@ -1,6 +1,7 @@ import type { ProviderEnv } from "../types.ts"; import { operationSignal, raceWithAbortSignal } from "../utils/abort.ts"; import { formatThrownValue } from "../utils/diagnostics.ts"; +import { OAuthSessionExpiredError } from "./errors.ts"; import type { ApiKeyAuth, ApiKeyCredential, @@ -135,15 +136,18 @@ async function resolveStoredOAuth( const minimumValidityMs = Math.max(DEFAULT_OAUTH_MINIMUM_VALIDITY_MS, minOAuthValidityMs ?? 0); const expiresSoon = (credential: OAuthCredential) => Date.now() + minimumValidityMs >= credential.expires; let credential = stored; + if (credential.reauthorizationRequired) throw new ModelsError("oauth", `Sign in again to ${providerId} with /login`); if (expiresSoon(credential)) { // Optimistic check said expired; the authoritative check runs under the lock. let post: Credential | undefined; + let expiredSession: OAuthSessionExpiredError | undefined; try { post = await credentials.modify( providerId, async (current) => { if (current?.type !== "oauth") return undefined; // logged out meanwhile + if (current.reauthorizationRequired) return undefined; if (!expiresSoon(current)) return undefined; // another process/request refreshed try { const refreshSignal = AbortSignal.any([ @@ -152,6 +156,17 @@ async function resolveStoredOAuth( ]); return await oauth.refresh(current, refreshSignal); } catch (error) { + if (error instanceof OAuthSessionExpiredError) { + expiredSession = error; + return { + ...current, + access: "", + refresh: "", + idToken: undefined, + expires: 0, + reauthorizationRequired: true, + }; + } throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error }); } }, @@ -162,6 +177,9 @@ async function resolveStoredOAuth( throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error }); } if (post?.type !== "oauth") return undefined; // logged out meanwhile + if (expiredSession || post.reauthorizationRequired) { + throw new ModelsError("oauth", `Sign in again to ${providerId} with /login`, { cause: expiredSession }); + } credential = post; // The normal five-minute window triggers a refresh but does not impose a // provider contract. Explicit callers (such as bearer-token export) do diff --git a/packages/ai/src/auth/types.ts b/packages/ai/src/auth/types.ts index 54c4ed344..ef22eeaf5 100644 --- a/packages/ai/src/auth/types.ts +++ b/packages/ai/src/auth/types.ts @@ -31,6 +31,8 @@ export interface OAuthCredentials { /** Stored canonical OAuth credential. */ export interface OAuthCredential extends OAuthCredentials { type: "oauth"; + /** Terminal session failure: tokens cleared, registration retained for a new sign-in. */ + reauthorizationRequired?: boolean; } /** One type-tagged credential per provider — the shape of today's auth.json. */ @@ -221,6 +223,9 @@ export interface OAuthAuth { */ refresh(credential: OAuthCredential, signal: AbortSignal): Promise; + /** End the renewable session before the app clears its local credential. */ + logout?(credential: OAuthCredential, signal: AbortSignal): Promise; + /** * Side-effect-free derivation of request auth from a valid credential. * Covers per-credential baseUrl (GitHub Copilot). Async so lazy wrappers diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 23b0cc149..769c24864 100644 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { createInterface } from "node:readline"; +import { getOAuthDisplayUrl } from "./auth/helpers.ts"; import type { AuthPrompt, OAuthCredential, Provider } from "./index.ts"; import { builtinProviders } from "./providers/all.ts"; @@ -53,7 +54,7 @@ async function login(providerId: string): Promise { notify: (event) => { switch (event.type) { case "auth_url": - console.log(`\nOpen this URL in your browser:\n${event.url}`); + console.log(`\nOpen this URL in your browser:\n${getOAuthDisplayUrl(event.url)}`); if (event.instructions) console.log(event.instructions); break; case "device_code": diff --git a/packages/ai/src/models-store.ts b/packages/ai/src/models-store.ts index 674184d1c..c669eb0f9 100644 --- a/packages/ai/src/models-store.ts +++ b/packages/ai/src/models-store.ts @@ -2,6 +2,8 @@ import type { Api, Model } from "./types.ts"; export interface ModelsStoreEntry { models: readonly Model[]; + /** Validated account registration that owns an account-specific catalog. */ + credentialId?: string; /** Exact provider catalog URL that supplied this entry. */ catalogUrl?: string; /** Diffex client version used to select its compatibility tier. */ diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 21932b276..1f06228f5 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -451,19 +451,9 @@ class ModelsImpl implements MutableModels { signal: AbortSignal, ): Promise { if (stored?.type === "oauth") { - const oauth = provider.auth.oauth; - if (!oauth) return undefined; - if (Date.now() < stored.expires) return stored; - if (signal.aborted) return undefined; - const post = await this.credentials.modify( - provider.id, - async (current) => { - if (current?.type !== "oauth" || Date.now() < current.expires) return undefined; - return oauth.refresh(current, signal); - }, - { signal }, - ); - return post?.type === "oauth" ? post : undefined; + if (!stored.reauthorizationRequired && Date.now() + 5 * 60_000 < stored.expires) return stored; + const resolved = await resolveProviderAuth(provider, this.credentials, this.authContext, { signal }); + return resolved ? this.readCredential(provider.id, signal) : undefined; } const apiKey = provider.auth.apiKey; @@ -488,6 +478,7 @@ class ModelsImpl implements MutableModels { signal: AbortSignal, ): Promise { if (credential?.type === "oauth") { + if (credential.reauthorizationRequired) return undefined; return provider.auth.oauth ? { source: "OAuth", type: "oauth" } : undefined; } const apiKey = provider.auth.apiKey; @@ -617,12 +608,26 @@ class ModelsImpl implements MutableModels { async logout(providerId: string, options?: AuthOperationOptions): Promise { const signal = operationSignal(options?.signal); signal.throwIfAborted(); + this.supersedeProviderRefresh(providerId); + let revocationError: unknown; + const provider = this.providers.get(providerId); + if (provider?.auth.oauth?.logout) { + await this.credentials.modify(providerId, async (current) => { + if (current?.type !== "oauth") return undefined; + try { + await provider.auth.oauth?.logout?.(current, AbortSignal.any([signal, AbortSignal.timeout(15_000)])); + } catch (error) { + revocationError = error; + } + return undefined; + }); + } try { - await this.credentials.delete(providerId, { signal }); + await this.credentials.delete(providerId); } catch (error) { - signal.throwIfAborted(); throw new ModelsError("auth", `Credential store delete failed for ${providerId}`, { cause: error }); } + if (revocationError) throw revocationError; } private requireProvider(model: Model): Provider { diff --git a/packages/ai/src/providers/openai-codex.ts b/packages/ai/src/providers/openai-codex.ts index 75ec9e6dd..e2be0d80f 100644 --- a/packages/ai/src/providers/openai-codex.ts +++ b/packages/ai/src/providers/openai-codex.ts @@ -1,22 +1,197 @@ -import { openAICodexResponsesApi } from "../api/openai-codex-responses.lazy.ts"; +import { lazyStream } from "../api/lazy.ts"; +import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; import { lazyOAuth } from "../auth/helpers.ts"; import { loadOpenAICodexOAuth } from "../auth/oauth/load.ts"; +import type { Credential } from "../auth/types.ts"; import { createProvider, type Provider } from "../models.ts"; +import type { Model, SimpleStreamOptions, StreamOptions } from "../types.ts"; +import { OPENAI_MODELS } from "./openai.models.ts"; import { OPENAI_CODEX_MODELS } from "./openai-codex.models.ts"; -export function openaiCodexProvider(): Provider<"openai-codex-responses"> { - return createProvider({ +const BASE_URL = "https://api.openai.com/v1"; +const CATALOG_URL = `${BASE_URL}/models`; + +function credentialId(credential: Credential | undefined): string | undefined { + return credential?.type === "oauth" && + credential.access && + !credential.reauthorizationRequired && + Array.isArray(credential.scopes) && + credential.scopes.includes("chatgpt.tokens.use.direct") && + credential.scopes.includes("resource.invoke") && + typeof credential.clientId === "string" && + typeof credential.subject === "string" + ? JSON.stringify([credential.clientId, credential.subject]) + : undefined; +} + +function parseCatalog(value: unknown): Model<"openai-responses">[] { + if (!value || typeof value !== "object" || !("models" in value) || !Array.isArray(value.models)) { + throw new Error("Invalid ChatGPT model catalog"); + } + const models: Model<"openai-responses">[] = []; + const ids = new Set(); + for (const entry of value.models as unknown[]) { + if (!entry || typeof entry !== "object") throw new Error("Invalid ChatGPT model entry"); + const item = entry as Record; + if (item.visibility !== "list") continue; + if ( + typeof item.slug !== "string" || + !item.slug || + typeof item.display_name !== "string" || + !item.display_name || + ids.has(item.slug) + ) { + throw new Error("Invalid or duplicate ChatGPT model identity"); + } + ids.add(item.slug); + const known = [...Object.values(OPENAI_MODELS), ...Object.values(OPENAI_CODEX_MODELS)].find( + (model) => model.id === item.slug, + ); + const contextWindow = + typeof item.context_window === "number" && Number.isSafeInteger(item.context_window) && item.context_window > 0 + ? item.context_window + : (known?.contextWindow ?? 128_000); + const maxTokens = + typeof item.max_output_tokens === "number" && + Number.isSafeInteger(item.max_output_tokens) && + item.max_output_tokens > 0 + ? item.max_output_tokens + : (known?.maxTokens ?? 16_384); + models.push({ + ...known, + id: item.slug, + name: item.display_name, + api: "openai-responses", + provider: "openai-codex", + baseUrl: BASE_URL, + reasoning: known?.reasoning ?? false, + input: known?.input ?? ["text"], + contextWindow, + maxTokens: Math.min(maxTokens, contextWindow), + // ChatGPT plan usage is not billed at API-key token prices. + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }); + } + return models; +} + +export function openaiCodexProvider(): Provider<"openai-responses"> { + let catalog: readonly Model<"openai-responses">[] = []; + let catalogOwner: string | undefined; + let activeOwner: string | undefined; + let session = new AbortController(); + const oauth = lazyOAuth({ + name: "ChatGPT", + isSubscription: true, + loginLabel: "Continue with ChatGPT", + load: loadOpenAICodexOAuth, + }); + const api = openAIResponsesApi(); + const activate = (owner: string | undefined) => { + if (activeOwner === owner) return; + session.abort(); + session = new AbortController(); + activeOwner = owner; + if (catalogOwner !== owner) { + catalog = []; + catalogOwner = undefined; + } + }; + const streamWith = ( + model: Model<"openai-responses">, + context: Parameters[1], + options: StreamOptions | SimpleStreamOptions | undefined, + simple: boolean, + ) => { + const signal = options?.signal ? AbortSignal.any([options.signal, session.signal]) : session.signal; + return lazyStream( + model, + async () => { + signal.throwIfAborted(); + if (!activeOwner || catalogOwner !== activeOwner || !catalog.some((entry) => entry.id === model.id)) { + throw new Error( + "Model is unavailable for the active ChatGPT account. Refresh models and select an available model.", + ); + } + const requestModel = { ...model, api: "openai-responses" as const, baseUrl: BASE_URL }; + const requestOptions = { ...options, signal }; + return simple + ? api.streamSimple(requestModel, context, requestOptions) + : api.stream(requestModel, context, requestOptions); + }, + signal, + ); + }; + const provider = createProvider<"openai-responses">({ id: "openai-codex", - name: "OpenAI Codex", - baseUrl: "https://chatgpt.com/backend-api", + name: "ChatGPT", + baseUrl: BASE_URL, auth: { - oauth: lazyOAuth({ - name: "OpenAI (ChatGPT Plus/Pro)", - isSubscription: true, - load: loadOpenAICodexOAuth, - }), + oauth: { + ...oauth, + toAuth: async (credential) => { + const auth = await oauth.toAuth(credential); + activate(credentialId(credential)); + return auth; + }, + logout: async (credential, signal) => { + activate(undefined); + await oauth.logout?.(credential, signal); + }, + }, + }, + models: [], + filterModels: (models, credential) => + credentialId(credential) === catalogOwner + ? models.filter((model) => catalog.some((entry) => entry.id === model.id)) + : [], + api: { + stream: (model, context, options) => streamWith(model as Model<"openai-responses">, context, options, false), + streamSimple: (model, context, options) => + streamWith(model as Model<"openai-responses">, context, options, true), }, - models: Object.values(OPENAI_CODEX_MODELS), - api: openAICodexResponsesApi(), }); + return { + ...provider, + getModels: () => catalog, + refreshModels: async (context) => { + const owner = credentialId(context.credential); + const stored = + context.stored?.catalogUrl === CATALOG_URL && context.stored.credentialId === owner && owner + ? context.stored + : undefined; + if ( + !(await context.publish({ + update: () => { + activate(owner); + if (stored) { + catalog = stored.models.filter( + (model): model is Model<"openai-responses"> => + model.provider === "openai-codex" && + model.api === "openai-responses" && + model.baseUrl === BASE_URL, + ); + catalogOwner = owner; + } + }, + })) + ) + return; + if (!context.allowNetwork || !owner || context.credential?.type !== "oauth") return; + await oauth.toAuth(context.credential); + const response = await fetch(CATALOG_URL, { + headers: { Authorization: `Bearer ${context.credential.access}`, Accept: "application/json" }, + signal: AbortSignal.any([context.signal, AbortSignal.timeout(15_000)]), + }); + if (!response.ok) throw new Error(`ChatGPT model catalog request failed (${response.status})`); + const models = parseCatalog(await response.json()); + await context.publish({ + persist: { models, catalogUrl: CATALOG_URL, credentialId: owner, checkedAt: Date.now() }, + update: () => { + catalog = models; + catalogOwner = owner; + }, + }); + }, + }; } diff --git a/packages/ai/test/chatgpt-inference.test.ts b/packages/ai/test/chatgpt-inference.test.ts new file mode 100644 index 000000000..9650568a0 --- /dev/null +++ b/packages/ai/test/chatgpt-inference.test.ts @@ -0,0 +1,375 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Type } from "typebox"; +import { afterEach, expect, it, vi } from "vitest"; +import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; +import type { OAuthCredential } from "../src/auth/types.ts"; +import { createModels } from "../src/models.ts"; +import { InMemoryModelsStore } from "../src/models-store.ts"; +import { openaiCodexProvider } from "../src/providers/openai-codex.ts"; +import type { Context } from "../src/types.ts"; + +const directories: string[] = []; +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +const credential: OAuthCredential = { + type: "oauth", + access: "opaque-access", + refresh: "refresh", + expires: Date.now() + 3600_000, + clientId: "oaiapp_diffex", + subject: "account", + extAgentHostId: "urn:uuid:host", + scopes: ["chatgpt.tokens.use.direct", "resource.invoke"], +}; +const catalog = { models: [{ slug: "gpt-6.1-sol", display_name: "Account model", visibility: "list" }] }; +const context: Context = { + messages: [{ role: "user", content: "Use a tool", timestamp: 1 }], + tools: [{ name: "lookup", description: "Look up a value", parameters: Type.Object({ key: Type.String() }) }], +}; + +function sse(events: unknown[]) { + return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }); +} + +function textEvents() { + return [ + { type: "response.created", response: { id: "resp_test" } }, + { + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "msg_test", role: "assistant", content: [] }, + }, + { type: "response.output_text.delta", output_index: 0, delta: "Hello" }, + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", + id: "msg_test", + role: "assistant", + content: [{ type: "output_text", text: "Hello" }], + }, + }, + ]; +} + +function completed() { + return { + type: "response.completed", + response: { + id: "resp_test", + status: "completed", + usage: { + input_tokens: 100, + input_tokens_details: { cached_tokens: 40 }, + output_tokens: 30, + output_tokens_details: { reasoning_tokens: 20 }, + total_tokens: 130, + }, + }, + }; +} + +async function runtime() { + const credentials = new InMemoryCredentialStore(); + await credentials.modify("openai-codex", async () => credential); + const store = new InMemoryModelsStore(); + const models = createModels({ credentials, modelsStore: store }); + models.setProvider(openaiCodexProvider()); + expect((await models.refresh()).errors.size).toBe(0); + const model = models.getModel("openai-codex", "gpt-6.1-sol"); + if (!model) throw new Error("Model not discovered"); + return { models, model, credentials, store }; +} + +it("discovers only the signed-in account's listed models at the public endpoint", async () => { + const credentials = new InMemoryCredentialStore(); + await credentials.modify("openai-codex", async () => ({ + type: "oauth", + access: "opaque-access", + refresh: "refresh", + expires: Date.now() + 3600_000, + clientId: "oaiapp_diffex", + subject: "account", + extAgentHostId: "urn:uuid:host", + scopes: ["chatgpt.tokens.use.direct", "resource.invoke"], + })); + const fetch = vi.fn(async () => + Response.json({ + models: [ + { slug: "account-first", display_name: "First model", visibility: "list" }, + { slug: "hidden", display_name: "Hidden model", visibility: "hide" }, + { slug: "account-second", display_name: "Second model", visibility: "list" }, + ], + }), + ); + vi.stubGlobal("fetch", fetch); + const models = createModels({ credentials }); + models.setProvider(openaiCodexProvider()); + expect((await models.refresh()).errors.size).toBe(0); + expect(fetch).toHaveBeenCalledWith( + "https://api.openai.com/v1/models", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer opaque-access" }), + }), + ); + expect((await models.getAvailable()).map((model) => [model.id, model.name, model.api, model.baseUrl])).toEqual([ + ["account-first", "First model", "openai-responses", "https://api.openai.com/v1"], + ["account-second", "Second model", "openai-responses", "https://api.openai.com/v1"], + ]); +}); + +it("streams text and usage over public Responses with subscription billing", async () => { + let body: Record | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + if (String(input).endsWith("/models")) return Response.json(catalog); + expect(String(input)).toBe("https://api.openai.com/v1/responses"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer opaque-access"); + expect(new Headers(init?.headers).has("chatgpt-account-id")).toBe(false); + body = JSON.parse(String(init?.body)) as Record; + return sse([...textEvents(), completed()]); + }), + ); + const { models, model } = await runtime(); + const events: string[] = []; + const stream = models.streamSimple(model, context, { + reasoning: "high", + samplingParams: { store: true, stream: false }, + onPayload: (payload) => ({ ...(payload as object), store: true, stream: false }), + }); + for await (const event of stream) events.push(event.type); + const result = await stream.result(); + expect(events).toContain("text_delta"); + expect(result.stopReason).toBe("stop"); + expect(result.usage).toMatchObject({ + input: 60, + output: 30, + cacheRead: 40, + reasoning: 20, + totalTokens: 130, + cost: { total: 0 }, + }); + expect(body).toMatchObject({ + store: false, + stream: true, + reasoning: { effort: "high", summary: "auto" }, + include: ["reasoning.encrypted_content"], + }); +}); + +it("streams tool arguments and replays tool results and encrypted reasoning", async () => { + const bodies: Record[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + if (String(input).endsWith("/models")) return Response.json(catalog); + bodies.push(JSON.parse(String(init?.body)) as Record); + if (bodies.length > 1) return sse([...textEvents(), completed()]); + return sse([ + { + type: "response.output_item.added", + output_index: 0, + item: { type: "reasoning", id: "rs_1", summary: [] }, + }, + { type: "response.reasoning_summary_text.delta", output_index: 0, delta: "Check the value" }, + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "reasoning", + id: "rs_1", + summary: [{ type: "summary_text", text: "Check the value" }], + encrypted_content: "encrypted", + }, + }, + { + type: "response.output_item.added", + output_index: 1, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" }, + }, + { type: "response.function_call_arguments.delta", output_index: 1, delta: '{"key":' }, + { type: "response.function_call_arguments.delta", output_index: 1, delta: '"value"}' }, + { + type: "response.output_item.done", + output_index: 1, + item: { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "lookup", + arguments: '{"key":"value"}', + }, + }, + completed(), + ]); + }), + ); + const { models, model } = await runtime(); + const events: string[] = []; + const stream = models.streamSimple(model, context, { reasoning: "high" }); + for await (const event of stream) events.push(event.type); + const first = await stream.result(); + expect(events).toContain("thinking_delta"); + expect(events).toContain("toolcall_delta"); + expect(first.stopReason).toBe("toolUse"); + const tool = first.content.find((block) => block.type === "toolCall"); + expect(tool).toMatchObject({ name: "lookup", arguments: { key: "value" } }); + const result = await models.completeSimple(model, { + ...context, + messages: [ + ...context.messages, + first, + { + role: "toolResult", + toolName: "lookup", + toolCallId: tool!.id, + isError: false, + content: [{ type: "text", text: "found" }], + timestamp: 2, + }, + ], + }); + expect(result.stopReason).toBe("stop"); + expect(bodies[1].input).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "reasoning", encrypted_content: "encrypted" }), + expect.objectContaining({ type: "function_call_output", call_id: "call_1", output: "found" }), + ]), + ); +}); + +it.each(["subscription_sharing_usage_limit_exceeded", "subscription_sharing_usage_unavailable"])( + "surfaces late %s failures", + async (code) => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => + String(input).endsWith("/models") + ? Response.json(catalog) + : sse([ + ...textEvents(), + { + type: "response.failed", + response: { status: "failed", error: { code, message: "Usage unavailable" } }, + }, + ]), + ), + ); + const { models, model, credentials } = await runtime(); + const result = await models.completeSimple(model, context); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain(code); + expect(await credentials.read("openai-codex")).toEqual(credential); + }, +); + +it.each(["incomplete", "interrupted"])("distinguishes %s output from completed inference", async (status) => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => + String(input).endsWith("/models") + ? Response.json(catalog) + : sse([ + ...textEvents(), + ...(status === "incomplete" + ? [ + { + type: "response.incomplete", + response: { status: "incomplete", incomplete_details: { reason: "max_output_tokens" } }, + }, + ] + : []), + ]), + ), + ); + const { models, model } = await runtime(); + expect((await models.completeSimple(model, context)).stopReason).toBe(status === "incomplete" ? "length" : "error"); +}); + +it.each(["cancellation", "logout"])("stops an active HTTP stream on %s", async (action) => { + const directory = mkdtempSync(join(tmpdir(), "diffex-chatgpt-inference-")); + directories.push(directory); + vi.stubEnv("DIFFEX_CODING_AGENT_DIR", directory); + let requestSignal: AbortSignal | null | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + if (String(input).endsWith("/models")) return Response.json(catalog); + if (String(input).endsWith("/.well-known/openid-configuration")) + return Response.json({ + issuer: "https://auth.openai.com", + revocation_endpoint: "https://auth.openai.com/oauth/revoke", + }); + if (String(input).endsWith("/oauth/revoke")) return new Response(null, { status: 200 }); + requestSignal = init?.signal; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + textEvents() + .slice(0, 3) + .map((event) => `data: ${JSON.stringify(event)}\n\n`) + .join(""), + ), + ); + init?.signal?.addEventListener("abort", () => controller.error(new Error("aborted")), { once: true }); + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ); + }), + ); + const { models, model } = await runtime(); + const controller = new AbortController(); + const stream = models.streamSimple(model, context, { signal: controller.signal }); + for await (const event of stream) { + if (event.type !== "text_delta") continue; + if (action === "logout") await models.logout("openai-codex"); + else controller.abort(); + } + expect((await stream.result()).stopReason).toBe("aborted"); + expect(requestSignal?.aborted).toBe(true); +}); + +it("isolates cached catalogs across accounts, failures, and restart", async () => { + const fetch = vi.fn(async () => Response.json(catalog)); + vi.stubGlobal("fetch", fetch); + const { models, credentials, store, model } = await runtime(); + const restarted = createModels({ credentials, modelsStore: store }); + restarted.setProvider(openaiCodexProvider()); + await restarted.refresh({ allowNetwork: false }); + expect((await restarted.getAvailable()).map((entry) => entry.id)).toEqual([model.id]); + expect(fetch).toHaveBeenCalledOnce(); + await credentials.modify("openai-codex", async () => ({ + ...credential, + clientId: "oaiapp_other", + subject: "other", + })); + expect(await models.getAvailable()).toEqual([]); + fetch.mockRejectedValue(new Error("network unavailable")); + expect((await models.refresh()).errors.size).toBe(1); + expect(models.getModels("openai-codex")).toEqual([]); + expect((await models.completeSimple(model, context)).stopReason).toBe("error"); +}); + +it("keeps the last valid same-account catalog after a malformed response", async () => { + const fetch = vi.fn(async () => Response.json(catalog)); + vi.stubGlobal("fetch", fetch); + const { models, store } = await runtime(); + const before = await store.read("openai-codex"); + fetch.mockResolvedValue(Response.json({ models: [{ slug: "broken", visibility: "list" }] })); + expect((await models.refresh()).errors.size).toBe(1); + expect(await store.read("openai-codex")).toEqual(before); + expect((await models.getAvailable()).map((entry) => entry.id)).toEqual(["gpt-6.1-sol"]); +}); diff --git a/packages/ai/test/chatgpt-registration-e2e.test.ts b/packages/ai/test/chatgpt-registration-e2e.test.ts new file mode 100644 index 000000000..e12fa820e --- /dev/null +++ b/packages/ai/test/chatgpt-registration-e2e.test.ts @@ -0,0 +1,42 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts"; + +const dirs: string[] = []; +afterEach(() => { + vi.unstubAllEnvs(); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +it("starts Diffex registration through a loopback callback and cancels without hanging", async () => { + const dir = mkdtempSync(join(tmpdir(), "diffex-chatgpt-")); + dirs.push(dir); + vi.stubEnv("DIFFEX_CODING_AGENT_DIR", dir); + const controller = new AbortController(); + let authorization: URL | undefined; + const login = openaiCodexOAuth.login({ + signal: controller.signal, + prompt: async (prompt) => { + if (prompt.type === "select") return "browser"; + return new Promise(() => {}); + }, + notify: (event) => { + if (event.type === "auth_url") authorization = new URL(event.url); + }, + }); + const outcome = login.catch((error: unknown) => error); + try { + await vi.waitFor(() => expect(authorization).toBeDefined()); + expect(authorization?.pathname).toBe("/api/accounts/authorize"); + expect(authorization?.searchParams.get("client_id")).toBe("dynamic_agent_client"); + expect(authorization?.searchParams.get("agent_name_hint")).toBe("Diffex"); + expect(authorization?.searchParams.get("ext_agent_host_id")).toBeTruthy(); + expect(authorization?.searchParams.get("nonce")).toBeTruthy(); + expect(new URL(authorization?.searchParams.get("redirect_uri") ?? "").hostname).toBe("127.0.0.1"); + } finally { + controller.abort(); + await outcome; + } +}); diff --git a/packages/ai/test/chatgpt-registration.test.ts b/packages/ai/test/chatgpt-registration.test.ts new file mode 100644 index 000000000..56058aaaa --- /dev/null +++ b/packages/ai/test/chatgpt-registration.test.ts @@ -0,0 +1,34 @@ +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { ChatGPTRegistrationStore } from "../src/auth/oauth/chatgpt-registration.ts"; + +let directory: string; + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "diffex-registration-store-")); + vi.stubEnv("DIFFEX_CODING_AGENT_DIR", directory); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + rmSync(directory, { recursive: true, force: true }); +}); + +it("keeps registration metadata in the configured storage directory", () => { + const store = new ChatGPTRegistrationStore(); + const registration = { clientId: "oaiapp_diffex_test", subject: "test-account" }; + store.save(registration); + expect(store.list()).toEqual([registration]); + expect(readdirSync(join(directory, "chatgpt"))).toEqual(["oaiapp_diffex_test.json"]); +}); + +it.each(["../outside", "oaiapp_../../outside", "oaiapp_test/outside", "oaiapp_test\\outside"])( + "rejects a client ID containing a path: %s", + (clientId) => { + const store = new ChatGPTRegistrationStore(); + expect(() => store.save({ clientId })).toThrow("Invalid issued ChatGPT client ID"); + expect(readdirSync(join(directory, "chatgpt"))).toEqual([]); + }, +); diff --git a/packages/ai/test/oauth-auth.test.ts b/packages/ai/test/oauth-auth.test.ts index ceaac57f9..186999dc5 100644 --- a/packages/ai/test/oauth-auth.test.ts +++ b/packages/ai/test/oauth-auth.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; +import { getOAuthDisplayUrl } from "../src/auth/helpers.ts"; import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts"; import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts"; import { kimiCodingOAuth } from "../src/auth/oauth/kimi-coding.ts"; @@ -18,6 +19,14 @@ function jsonResponse(body: unknown, status = 200): Response { } describe.sequential("OAuthAuth adapters", () => { + it("redacts retained ID tokens from displayed authorization URLs", () => { + const display = getOAuthDisplayUrl( + "https://auth.openai.com/api/accounts/authorize?client_id=oaiapp_diffex&id_token_hint=private-token&state=state", + ); + expect(display).not.toContain("private-token"); + expect(display).not.toContain("id_token_hint"); + expect(new URL(display).searchParams.get("client_id")).toBe("oaiapp_diffex"); + }); it("keeps the extension OAuth barrel free of built-in flow implementations", () => { expect(extensionOAuthCompatibility).not.toHaveProperty("loginAnthropic"); expect(extensionOAuthCompatibility).not.toHaveProperty("anthropicOAuth"); @@ -40,8 +49,17 @@ describe.sequential("OAuthAuth adapters", () => { }); it("openai-codex toAuth derives the api key from the access token", async () => { - const auth = await openaiCodexOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 }); - expect(auth).toEqual({ apiKey: "token" }); + const auth = await openaiCodexOAuth.toAuth({ + type: "oauth", + access: "token", + refresh: "r", + expires: 0, + clientId: "oaiapp_diffex", + extAgentHostId: "urn:uuid:host", + subject: "subject", + scopes: ["chatgpt.tokens.use.direct", "resource.invoke"], + }); + expect(auth).toEqual({ apiKey: "token", baseUrl: "https://api.openai.com/v1" }); }); it("openrouter derives the api key and keeps the permanent credential on refresh", async () => { diff --git a/packages/ai/test/openai-codex-oauth.test.ts b/packages/ai/test/openai-codex-oauth.test.ts index ce8a70819..39a91c650 100644 --- a/packages/ai/test/openai-codex-oauth.test.ts +++ b/packages/ai/test/openai-codex-oauth.test.ts @@ -1,497 +1,314 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; +import { ChatGPTRegistrationStore } from "../src/auth/oauth/chatgpt-registration.ts"; import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts"; -import { defineOAuthCallbackTests } from "./oauth-callback-test-utils.ts"; - -defineOAuthCallbackTests(openaiCodexOAuth, { - name: "OpenAI Codex", - authorizeUrl: "https://auth.openai.com/oauth/authorize", - tokenUrl: "https://auth.openai.com/oauth/token", - clientId: "app_EMoamEEZ73f0CkXaXp7hrann", - scope: "openid profile email offline_access", - redirectUri: "http://localhost:1455/auth/callback", - originator: "diffex", +import type { OAuthCredential } from "../src/auth/types.ts"; +import { createModels, createProvider } from "../src/models.ts"; + +const nativeFetch = globalThis.fetch; +const issuer = "https://auth.openai.com"; +const clientId = "oaiapp_diffex_test"; +const scopes = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"; +const key = await generateKeyPair("RS256", { extractable: true }); +const jwk = { ...(await exportJWK(key.publicKey)), kid: "test-key", alg: "RS256" }; +let directory: string; +let authorization: URL; +let tokenOverrides: Record; +let claims: Record; +let requests: Array<{ url: string; init?: RequestInit }>; + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "diffex-chatgpt-oauth-")); + vi.stubEnv("DIFFEX_CODING_AGENT_DIR", directory); + tokenOverrides = {}; + claims = {}; + requests = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + requests.push({ url, init }); + if (url.endsWith("/.well-known/jwks.json")) return Response.json({ keys: [jwk] }); + if (url.endsWith("/.well-known/openid-configuration")) + return Response.json({ issuer, revocation_endpoint: `${issuer}/oauth/revoke` }); + if (url.endsWith("/oauth/revoke")) return new Response(null, { status: 200 }); + if (url !== `${issuer}/api/accounts/oauth/token`) throw new Error(`Unexpected request: ${url}`); + const idToken = await new SignJWT({ + iss: issuer, + aud: clientId, + sub: "account-subject", + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + nonce: authorization.searchParams.get("nonce"), + email: "person@example.com", + ...claims, + }) + .setProtectedHeader({ alg: "RS256", kid: "test-key" }) + .sign(key.privateKey); + return Response.json({ + access_token: "opaque-access", + refresh_token: "refresh-one", + expires_in: 3600, + token_type: "Bearer", + scope: scopes, + id_token: idToken, + ...tokenOverrides, + }); + }), + ); }); -const neverAbortedSignal = new AbortController().signal; +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + rmSync(directory, { recursive: true, force: true }); +}); -function jsonResponse(body: unknown, status: number = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, +async function startLogin(selection = clientId, signal = new AbortController().signal) { + let ready: () => void = () => {}; + const started = new Promise((resolve) => { + ready = resolve; }); -} - -function getUrl(input: unknown): string { - if (typeof input === "string") return input; - if (input instanceof URL) return input.toString(); - if (input instanceof Request) return input.url; - throw new Error(`Unsupported fetch input: ${String(input)}`); -} - -function createAccessToken(accountId: string): string { - const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64"); - const payload = Buffer.from( - JSON.stringify({ - "https://api.openai.com/auth": { - chatgpt_account_id: accountId, - }, - }), - ).toString("base64"); - return `${header}.${payload}.signature`; -} - -function deviceAuthPendingResponse(): Response { - return jsonResponse( - { - error: { - message: "Device authorization is pending. Please try again.", - type: "invalid_request_error", - param: null, - code: "deviceauth_authorization_pending", - }, - }, - 403, - ); -} - -function loginOpenAICodexDeviceCodeForTest(options: { - onDeviceCode(info: { - userCode: string; - verificationUri: string; - intervalSeconds?: number; - expiresInSeconds?: number; - }): void; - signal?: AbortSignal; -}) { - return openaiCodexOAuth.login({ - signal: options.signal ?? neverAbortedSignal, - prompt: async (prompt) => { - if (prompt.type !== "select") throw new Error(`Unexpected prompt: ${prompt.type}`); - return "device_code"; - }, + const login = openaiCodexOAuth.login({ + signal, notify: (event) => { - if (event.type === "device_code") { - const { type: _, ...info } = event; - options.onDeviceCode(info); + if (event.type === "auth_url") { + authorization = new URL(event.url); + ready(); } }, + prompt: async (prompt) => (prompt.type === "select" ? selection : new Promise(() => {})), }); + const outcome = login.catch((error: unknown) => error); + await Promise.race([started, login]); + return { login, outcome }; } -describe("OpenAI Codex OAuth", () => { - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); - - it("logs in with the OpenAI Codex device code flow", async () => { - vi.useFakeTimers(); - const startTime = new Date("2026-05-20T00:00:00Z"); - vi.setSystemTime(startTime); - - const accessToken = createAccessToken("account-123"); - const deviceInfos: Array<{ - userCode: string; - verificationUri: string; - instructions?: string; - intervalSeconds?: number; - expiresInSeconds?: number; - }> = []; - const pollTimes: number[] = []; - const pollResponses = [ - deviceAuthPendingResponse(), - jsonResponse({ - authorization_code: "oauth-code", - code_challenge: "device-code-challenge", - code_verifier: "device-code-verifier", - }), - ]; - - const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { - const url = getUrl(input); - - if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { - expect(init?.method).toBe("POST"); - expect(init?.headers).toMatchObject({ "Content-Type": "application/json" }); - expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); - return jsonResponse({ - device_auth_id: "device-auth-id", - user_code: "ABCD-1234", - interval: "5", - }); - } - - if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { - pollTimes.push(Date.now()); - expect(init?.method).toBe("POST"); - expect(init?.headers).toMatchObject({ "Content-Type": "application/json" }); - expect(JSON.parse(String(init?.body))).toEqual({ - device_auth_id: "device-auth-id", - user_code: "ABCD-1234", - }); - const response = pollResponses.shift(); - if (!response) { - throw new Error("Unexpected extra device auth poll"); - } - return response; - } - - if (url === "https://auth.openai.com/oauth/token") { - expect(init?.method).toBe("POST"); - expect(init?.headers).toMatchObject({ "Content-Type": "application/x-www-form-urlencoded" }); - const params = new URLSearchParams(String(init?.body)); - expect(params.get("grant_type")).toBe("authorization_code"); - expect(params.get("client_id")).toBe("app_EMoamEEZ73f0CkXaXp7hrann"); - expect(params.get("code")).toBe("oauth-code"); - expect(params.get("redirect_uri")).toBe("https://auth.openai.com/deviceauth/callback"); - expect(params.get("code_verifier")).toBe("device-code-verifier"); - return jsonResponse({ - access_token: accessToken, - refresh_token: "refresh-token", - expires_in: 3600, - }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }); - - vi.stubGlobal("fetch", fetchMock); - - const credentialsPromise = loginOpenAICodexDeviceCodeForTest({ - onDeviceCode: (info) => deviceInfos.push(info), - }); - - for (let i = 0; i < 5 && pollTimes.length === 0; i++) { - await vi.advanceTimersByTimeAsync(0); - } - expect(deviceInfos).toEqual([ - { - userCode: "ABCD-1234", - verificationUri: "https://auth.openai.com/codex/device", - intervalSeconds: 5, - expiresInSeconds: 900, - }, - ]); - expect(pollTimes).toEqual([startTime.getTime()]); +function callback(params: Record = {}) { + const url = new URL(authorization.searchParams.get("redirect_uri") ?? ""); + url.search = new URLSearchParams({ + state: authorization.searchParams.get("state") ?? "", + code: "code", + client_id: clientId, + ...params, + }).toString(); + return url; +} - await vi.advanceTimersByTimeAsync(4999); - expect(pollTimes).toEqual([startTime.getTime()]); +async function signIn() { + const attempt = await startLogin(); + await nativeFetch(callback()); + return attempt.login; +} - await vi.advanceTimersByTimeAsync(1); - await expect(credentialsPromise).resolves.toMatchObject({ - access: accessToken, - refresh: "refresh-token", - expires: startTime.getTime() + 5000 + 3600 * 1000, - accountId: "account-123", +describe.sequential("ChatGPT registration and session lifecycle", () => { + it("persists the host before authorization and exchanges with the issued client and exact PKCE redirect", async () => { + const attempt = await startLogin(); + const host = authorization.searchParams.get("ext_agent_host_id"); + expect(readFileSync(join(directory, "chatgpt", "host"), "utf8")).toBe(host); + expect(authorization.searchParams.get("scope")).toBe(scopes); + expect(authorization.searchParams.get("resource")).toBe("https://api.openai.com/v1"); + expect((await nativeFetch(callback({ state: "wrong" }))).status).toBe(400); + expect(requests).toHaveLength(0); + const response = await nativeFetch(callback()); + expect(await response.text()).toContain("Continue in Diffex"); + const credential = await attempt.login; + expect(credential).toMatchObject({ + clientId, + subject: "account-subject", + extAgentHostId: host, + access: "opaque-access", }); - expect(pollTimes).toEqual([startTime.getTime(), startTime.getTime() + 5000]); + const body = new URLSearchParams(String(requests[0].init?.body)); + expect(body.get("client_id")).toBe(clientId); + expect(body.get("redirect_uri")).toBe(authorization.searchParams.get("redirect_uri")); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body.get("code_verifier") ?? "")); + expect(Buffer.from(digest).toString("base64url")).toBe(authorization.searchParams.get("code_challenge")); + expect(statSync(join(directory, "chatgpt", `${clientId}.json`)).mode & 0o777).toBe(0o600); }); - it("offers browser login first and uses the selected OpenAI Codex device code flow", async () => { - const accessToken = createAccessToken("account-456"); - const selectPrompts: Array<{ - message: string; - options: readonly { id: string; label: string }[]; - }> = []; - const deviceInfos: Array<{ - userCode: string; - verificationUri: string; - intervalSeconds?: number; - expiresInSeconds?: number; - }> = []; - - vi.stubGlobal( - "fetch", - vi.fn(async (input: unknown, init?: RequestInit): Promise => { - const url = getUrl(input); - if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { - expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); - return jsonResponse({ - device_auth_id: "device-auth-id", - user_code: "WXYZ-7890", - interval: "5", - }); - } - if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { - return jsonResponse({ - authorization_code: "oauth-code", - code_challenge: "device-code-challenge", - code_verifier: "device-code-verifier", - }); - } - if (url === "https://auth.openai.com/oauth/token") { - return jsonResponse({ - access_token: accessToken, - refresh_token: "refresh-token", - expires_in: 3600, - }); - } - throw new Error(`Unexpected fetch URL: ${url}`); - }), - ); - - await expect( - openaiCodexOAuth.login({ - signal: neverAbortedSignal, - prompt: async (prompt) => { - if (prompt.type !== "select") throw new Error("Text prompt should not be used"); - selectPrompts.push(prompt); - return "device_code"; - }, - notify: (event) => { - if (event.type === "auth_url") throw new Error("Browser login should not start"); - if (event.type === "device_code") { - const { type: _, ...info } = event; - deviceInfos.push(info); - } - }, - }), - ).resolves.toMatchObject({ - type: "oauth", - access: accessToken, - refresh: "refresh-token", - accountId: "account-456", - }); + it.each([ + ["wrong nonce", { nonce: "wrong" }, {}], + ["wrong audience", { aud: "oaiapp_other" }, {}], + ["wrong issuer", { iss: "https://other.example" }, {}], + ["expired ID token", { exp: 1 }, {}], + ["missing subject", { sub: "" }, {}], + ["invalid signature", {}, { id_token: "unsigned.token.value" }], + ["missing ID token", {}, { id_token: null }], + ["invalid expiry", {}, { expires_in: -1 }], + ["overflowing expiry", {}, { expires_in: 1e308 }], + ["missing refresh", {}, { refresh_token: "" }], + ] as const)("rejects %s without saving an account identity", async (_name, nextClaims, overrides) => { + claims = nextClaims; + tokenOverrides = overrides; + const attempt = await startLogin(); + await nativeFetch(callback()); + await expect(attempt.login).rejects.toThrow(); + expect(new ChatGPTRegistrationStore().list()[0].subject).toBeUndefined(); + }); - expect(selectPrompts).toEqual([ - { - type: "select", - message: "Select OpenAI Codex login method:", - options: [ - { id: "browser", label: "Browser login (default)" }, - { id: "device_code", label: "Device code login (headless)" }, - ], - }, - ]); - expect(deviceInfos).toEqual([ - { - userCode: "WXYZ-7890", - verificationUri: "https://auth.openai.com/codex/device", - intervalSeconds: 5, - expiresInSeconds: 900, - }, - ]); + it("retains a validated sign-in without plan permission and supports explicit consent later", async () => { + tokenOverrides = { scope: "openid profile email offline_access" }; + const credential = await signIn(); + expect(credential.subject).toBe("account-subject"); + await expect(openaiCodexOAuth.toAuth(credential)).rejects.toThrow("plan usage is not authorized"); + const attempt = await startLogin(); + expect(authorization.searchParams.get("client_id")).toBe(clientId); + expect(authorization.searchParams.get("prompt")).toBe("consent"); + tokenOverrides = {}; + await nativeFetch(callback()); + await expect(openaiCodexOAuth.toAuth(await attempt.login)).resolves.toMatchObject({ apiKey: "opaque-access" }); }); - it("cancels when OpenAI Codex login method selection is cancelled", async () => { - await expect( - openaiCodexOAuth.login({ - signal: neverAbortedSignal, - prompt: async () => { - throw new Error("Login cancelled"); - }, - notify: () => {}, - }), - ).rejects.toThrow("Login cancelled"); + it("rejects a different verified subject during reauthorization", async () => { + await signIn(); + claims = { sub: "other-account" }; + const attempt = await startLogin(); + await nativeFetch(callback()); + await expect(attempt.login).rejects.toThrow("identity changed"); + expect(new ChatGPTRegistrationStore().list()[0].subject).toBe("account-subject"); }); - it("cancels the OpenAI Codex device code flow while waiting", async () => { - vi.useFakeTimers(); - const controller = new AbortController(); - const pollTimes: number[] = []; + it.each(["invalid_grant", "refresh_token_reused"])( + "clears unusable tokens after %s while retaining the registration", + async (code) => { + const credential = await signIn(); + const credentials = new InMemoryCredentialStore(); + await credentials.modify("openai-codex", async () => ({ ...credential, expires: 0 })); + const models = createModels({ credentials }); + models.setProvider( + createProvider({ id: "openai-codex", auth: { oauth: openaiCodexOAuth }, models: [], api: {} }), + ); + const fetch = vi.fn(async () => Response.json({ error: code }, { status: 400 })); + vi.stubGlobal("fetch", fetch); + await expect(models.getAuth("openai-codex")).rejects.toThrow("Run /login"); + expect(await credentials.read("openai-codex")).toMatchObject({ + access: "", + refresh: "", + reauthorizationRequired: true, + clientId, + }); + const cleared = await credentials.read("openai-codex"); + expect(cleared?.type === "oauth" ? cleared.idToken : undefined).toBeUndefined(); + await expect(models.getAuth("openai-codex")).rejects.toThrow("/login"); + expect(fetch).toHaveBeenCalledOnce(); + expect(new ChatGPTRegistrationStore().list()[0]).toMatchObject({ clientId, subject: credential.subject }); + expect(new ChatGPTRegistrationStore().list()[0].idToken).toBeUndefined(); + }, + ); + it("preserves usable credentials after a temporary refresh failure", async () => { + const credential = { ...(await signIn()), expires: 0 }; + const credentials = new InMemoryCredentialStore(); + await credentials.modify("openai-codex", async () => credential); + const models = createModels({ credentials }); + models.setProvider( + createProvider({ id: "openai-codex", auth: { oauth: openaiCodexOAuth }, models: [], api: {} }), + ); vi.stubGlobal( "fetch", - vi.fn(async (input: unknown, init?: RequestInit): Promise => { - const url = getUrl(input); - if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { - expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); - return jsonResponse({ - device_auth_id: "device-auth-id", - user_code: "ABCD-1234", - interval: "5", - }); - } - if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { - pollTimes.push(Date.now()); - return deviceAuthPendingResponse(); - } - throw new Error(`Unexpected fetch URL: ${url}`); - }), - ); - - const credentialsPromise = loginOpenAICodexDeviceCodeForTest({ - onDeviceCode: () => {}, - signal: controller.signal, - }); - const rejectionPromise = credentialsPromise.then( - () => new Error("Expected login to fail"), - (error: unknown) => error, + vi.fn(async () => new Response(null, { status: 503 })), ); + await expect(models.getAuth("openai-codex")).rejects.toThrow("Try again later"); + expect(await credentials.read("openai-codex")).toEqual(credential); + }); - for (let i = 0; i < 5 && pollTimes.length === 0; i++) { - await vi.advanceTimersByTimeAsync(0); + it("rejects missing issued IDs and authorization denial before exchange", async () => { + const callbacks: Record[] = [{ client_id: "dynamic_agent_client" }, { error: "access_denied" }]; + for (const params of callbacks) { + const attempt = await startLogin(); + await nativeFetch(callback(params)); + await expect(attempt.login).rejects.toThrow(); } - expect(pollTimes).toHaveLength(1); - - controller.abort(); - const rejection = await rejectionPromise; - expect(rejection).toBeInstanceOf(Error); - expect((rejection as Error).message).toBe("Login cancelled"); + expect(requests).toHaveLength(0); }); - it("times out the OpenAI Codex device code flow after 15 minutes", async () => { - vi.useFakeTimers(); - const pollTimes: number[] = []; + it("reauthorizes the saved identity using its issued ID and stable host", async () => { + const first = await signIn(); + const attempt = await startLogin(); + expect(authorization.searchParams.get("client_id")).toBe(clientId); + expect(authorization.searchParams.get("ext_agent_host_id")).toBe(first.extAgentHostId); + expect(authorization.searchParams.has("agent_name_hint")).toBe(false); + expect(authorization.searchParams.get("id_token_hint")).toBe(first.idToken); + const url = callback(); + url.searchParams.delete("client_id"); + await nativeFetch(url); + await expect(attempt.login).resolves.toMatchObject({ clientId, subject: first.subject }); + }); - vi.stubGlobal( - "fetch", - vi.fn(async (input: unknown, init?: RequestInit): Promise => { - const url = getUrl(input); - if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { - expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); - return jsonResponse({ - device_auth_id: "device-auth-id", - user_code: "ABCD-1234", - interval: "60", - }); - } - if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { - pollTimes.push(Date.now()); - return deviceAuthPendingResponse(); - } - throw new Error(`Unexpected fetch URL: ${url}`); - }), - ); + it("rejects a changed client during reauthorization", async () => { + await signIn(); + const count = requests.length; + const attempt = await startLogin(); + await nativeFetch(callback({ client_id: "oaiapp_other" })); + await expect(attempt.login).rejects.toThrow("mismatched"); + expect(requests).toHaveLength(count); + }); - const credentialsPromise = loginOpenAICodexDeviceCodeForTest({ - onDeviceCode: () => {}, + it("refreshes with the saved issued ID, rotates tokens, and retains omitted scopes", async () => { + const credential = await signIn(); + tokenOverrides = { id_token: undefined, scope: undefined, refresh_token: "refresh-two" }; + const refreshed = await openaiCodexOAuth.refresh(credential, new AbortController().signal); + expect(refreshed).toMatchObject({ + refresh: "refresh-two", + clientId, + subject: credential.subject, + scopes: credential.scopes, + }); + const body = new URLSearchParams(String(requests.at(-1)?.init?.body)); + expect(Object.fromEntries(body)).toEqual({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: "refresh-one", + resource: "https://api.openai.com/v1", }); - const rejectionPromise = credentialsPromise.then( - () => new Error("Expected login to fail"), - (error: unknown) => error, - ); - - for (let i = 0; i < 5 && pollTimes.length === 0; i++) { - await vi.advanceTimersByTimeAsync(0); - } - expect(pollTimes).toHaveLength(1); - - await vi.advanceTimersByTimeAsync(15 * 60 * 1000); - const rejection = await rejectionPromise; - expect(rejection).toBeInstanceOf(Error); - expect((rejection as Error).message).toBe("Device flow timed out"); }); - it("treats OpenAI Codex device auth 403 and 404 responses as pending", async () => { - vi.useFakeTimers(); - const accessToken = createAccessToken("account-403-404"); - const pollTimes: number[] = []; - const pollResponses = [ - jsonResponse({ error: "access_denied", error_description: "denied" }, 403), - new Response("not ready", { status: 404, headers: { "Content-Type": "text/plain" } }), - jsonResponse({ - authorization_code: "oauth-code", - code_challenge: "device-code-challenge", - code_verifier: "device-code-verifier", - }), - ]; - - vi.stubGlobal( - "fetch", - vi.fn(async (input: unknown): Promise => { - const url = getUrl(input); - if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { - return jsonResponse({ - device_auth_id: "device-auth-id", - user_code: "ABCD-1234", - interval: "1", - }); - } - if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { - pollTimes.push(Date.now()); - const response = pollResponses.shift(); - if (!response) { - throw new Error("Unexpected extra device auth poll"); - } - return response; - } - if (url === "https://auth.openai.com/oauth/token") { - return jsonResponse({ - access_token: accessToken, - refresh_token: "refresh-token", - expires_in: 3600, - }); - } - throw new Error(`Unexpected fetch URL: ${url}`); - }), + it("revokes on logout, clears local tokens, and retains registration without ID-token hints", async () => { + const credential = await signIn(); + const credentials = new InMemoryCredentialStore(); + await credentials.modify("openai-codex", async () => credential); + const models = createModels({ credentials }); + models.setProvider( + createProvider({ id: "openai-codex", auth: { oauth: openaiCodexOAuth }, models: [], api: {} }), ); - - const credentialsPromise = loginOpenAICodexDeviceCodeForTest({ - onDeviceCode: () => {}, - }); - - for (let i = 0; i < 5 && pollTimes.length === 0; i++) { - await vi.advanceTimersByTimeAsync(0); - } - await vi.advanceTimersByTimeAsync(1000); - await vi.advanceTimersByTimeAsync(1000); - - await expect(credentialsPromise).resolves.toMatchObject({ - access: accessToken, - refresh: "refresh-token", - accountId: "account-403-404", + await models.logout("openai-codex"); + expect(await credentials.read("openai-codex")).toBeUndefined(); + expect(new ChatGPTRegistrationStore().list()).toEqual([ + { clientId, subject: credential.subject, email: credential.email, scopes: credential.scopes }, + ]); + const request = requests.find((entry) => entry.url.endsWith("/oauth/revoke")); + expect(Object.fromEntries(new URLSearchParams(String(request?.init?.body)))).toEqual({ + token: credential.refresh, + token_type_hint: "refresh_token", + client_id: clientId, }); - expect(pollTimes).toHaveLength(3); }); - it("includes the response body in OpenAI Codex device auth poll failures", async () => { + it("clears local credentials even when remote revocation fails", async () => { + const credential = await signIn(); vi.stubGlobal( "fetch", - vi.fn(async (input: unknown): Promise => { - const url = getUrl(input); - if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { - return jsonResponse({ - device_auth_id: "device-auth-id", - user_code: "ABCD-1234", - interval: "5", - }); - } - if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { - return jsonResponse({ error: "server_error", error_description: "try again later" }, 500); - } - throw new Error(`Unexpected fetch URL: ${url}`); - }), + vi.fn(async () => new Response(null, { status: 503 })), ); - - await expect( - loginOpenAICodexDeviceCodeForTest({ - onDeviceCode: () => {}, - }), - ).rejects.toThrow( - 'OpenAI Codex device auth failed with status 500: {"error":"server_error","error_description":"try again later"}', + const credentials = new InMemoryCredentialStore(); + await credentials.modify("openai-codex", async () => credential); + const models = createModels({ credentials }); + models.setProvider( + createProvider({ id: "openai-codex", auth: { oauth: openaiCodexOAuth }, models: [], api: {} }), ); + await expect(models.logout("openai-codex")).rejects.toThrow("Remote revocation was not confirmed"); + expect(await credentials.read("openai-codex")).toBeUndefined(); }); - it("does not write token refresh failures to stderr", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - vi.stubGlobal( - "fetch", - vi.fn(async (): Promise => { - return new Response( - JSON.stringify({ - error: { - message: "Could not validate your token. Please try signing in again.", - type: "invalid_request_error", - }, - }), - { status: 401, statusText: "Unauthorized", headers: { "Content-Type": "application/json" } }, - ); - }), - ); - - await expect( - openaiCodexOAuth.refresh( - { - type: "oauth", - access: "invalid-access-token", - refresh: "invalid-refresh-token", - expires: 0, - }, - neverAbortedSignal, - ), - ).rejects.toThrow(/OpenAI Codex token refresh failed \(401\).*Could not validate your token/); - expect(consoleError).not.toHaveBeenCalled(); + it("requires reauthorization for inherited credentials", async () => { + const credential: OAuthCredential = { type: "oauth", access: "legacy", refresh: "legacy", expires: 0 }; + await expect(openaiCodexOAuth.refresh(credential, new AbortController().signal)).rejects.toThrow("Run /login"); + await expect(openaiCodexOAuth.toAuth(credential)).rejects.toThrow("Run /login"); + expect(requests).toHaveLength(0); }); }); diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index c47e086b2..d1d21f360 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -36,7 +36,7 @@ const neverAbortedSignal = new AbortController().signal; const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }; describe("builtin providers", () => { - it("builtinModels registers every builtin provider with models", async () => { + it("builtinModels registers static and account-specific builtin catalogs", async () => { const models = builtinModels(); const providers = models.getProviders(); expect(providers.length).toBe(builtinProviders().length); @@ -50,9 +50,11 @@ describe("builtin providers", () => { for (const provider of providers) { const list = models.getModels(provider.id); - expect(list.length).toBeGreaterThan(0); + expect(list.length > 0 || provider.refreshModels !== undefined).toBe(true); expect(list.every((m) => m.provider === provider.id)).toBe(true); } + expect(models.getModels("openai-codex")).toEqual([]); + expect(models.getProvider("openai-codex")?.refreshModels).toBeTypeOf("function"); }); it("does not expose Radius or resolve its former environment key", async () => { diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 01c15a4e2..e2a22dc2d 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -16,7 +16,7 @@ Diffex supports subscription-based providers via OAuth and API key providers via Use `/login` in interactive mode, then select a provider: -- ChatGPT Plus/Pro (Codex) +- ChatGPT - Claude Pro/Max - GitHub Copilot - xAI (Grok/X subscription) @@ -24,10 +24,30 @@ Use `/login` in interactive mode, then select a provider: Use `/logout` to clear credentials. Tokens are stored in `~/.diffex/agent/auth.json` and auto-refresh when expired. OpenRouter instead mints a user-controlled API key that does not expire automatically. -### OpenAI Codex +### ChatGPT -- Requires ChatGPT Plus or Pro subscription -- Officially endorsed by OpenAI: [Codex for OSS](https://developers.openai.com/community/codex-for-oss) +Run `/login`, select ChatGPT, and continue in your browser to register Diffex and authorize ChatGPT plan usage. +Existing installations using the inherited client registration must sign in again. +The provider ID remains `openai-codex` for configuration and CLI selection. + +Diffex stores the stable host identity and issued account registrations in `~/.diffex/agent/chatgpt/`, alongside the active credentials in `auth.json`. +`DIFFEX_CODING_AGENT_DIR` relocates both directories. +Later sign-ins let you select a saved registration or add another ChatGPT account or workspace. +If you decline plan usage, Diffex retains your validated sign-in and offers **Enable ChatGPT plan usage** on the next login. +Remote machines can complete sign-in by pasting the full loopback redirect URL into Diffex. + +Models come from the signed-in account's public model catalog, with an account-specific cache for offline startup. +Inference uses the public Responses API with streaming and storage disabled. +Availability depends on the selected account, workspace, and granted permissions. +Token usage is reported without applying API-key token prices to subscription usage. +Review app limits and credits in [ChatGPT Settings - Usage](https://chatgpt.com/settings/usage). + +`/logout` stops the account's active requests, attempts remote session revocation, and clears the active tokens while retaining the host and registration for later sign-in. +If remote revocation cannot be confirmed, Diffex reports that local sign-out completed and directs you to disconnect the app in ChatGPT Settings. +An unusable refresh token clears the local token set and requires a new sign-in with the saved registration. +Temporary network failures preserve credentials for retry. + +See OpenAI's [registration guide](https://developers.openai.com/siwc/token-sharing-open-source/sign-in) and [models and inference guide](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference). ### Claude Pro/Max diff --git a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts index 969124910..dea0aa6db 100644 --- a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts +++ b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts @@ -4,6 +4,8 @@ * Configure provider auth through ModelRuntime. */ +import { homedir } from "node:os"; +import { join } from "node:path"; import { createAgentSession, ModelRuntime, SessionManager } from "@diffexai/diffex"; const modelRuntime = await ModelRuntime.create(); @@ -14,9 +16,10 @@ const { session: defaultAuthSession } = await createAgentSession({ console.log("Session with default model runtime"); await defaultAuthSession.dispose(); +const customDirectory = join(homedir(), ".diffex", "examples", "custom-auth"); const customRuntime = await ModelRuntime.create({ - authPath: "/tmp/my-app/auth.json", - modelsPath: "/tmp/my-app/models.json", + authPath: join(customDirectory, "auth.json"), + modelsPath: join(customDirectory, "models.json"), }); const { session: customAuthSession } = await createAgentSession({ sessionManager: SessionManager.inMemory(), diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index e9716d050..d6bfc66f3 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -4,6 +4,8 @@ * Replace everything - no discovery, explicit configuration. */ +import { homedir } from "node:os"; +import { join } from "node:path"; import { createAgentSession, createExtensionRuntime, @@ -14,9 +16,10 @@ import { } from "@diffexai/diffex"; import { getModel } from "@diffexai/diffex-ai/compat"; +const agentDir = join(homedir(), ".diffex", "examples", "full-control"); const modelRuntime = await ModelRuntime.create({ - authPath: "/tmp/my-agent/auth.json", - modelsPath: "/tmp/my-agent/models.json", + authPath: join(agentDir, "auth.json"), + modelsPath: join(agentDir, "models.json"), }); if (process.env.MY_ANTHROPIC_KEY) { await modelRuntime.setRuntimeApiKey("anthropic", process.env.MY_ANTHROPIC_KEY); @@ -51,7 +54,7 @@ Available: read, bash. Be concise.`, const { session } = await createAgentSession({ cwd, - agentDir: "/tmp/my-agent", + agentDir, model, thinkingLevel: "off", modelRuntime, diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 9107c25bc..7b8ce2b95 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -425,6 +425,7 @@ "https-proxy-agent": "9.1.0", "ignore": "7.0.14", "jiti": "2.7.0", + "jose": "6.2.13", "marked": "18.1.1", "minimatch": "10.2.6", "openai": "7.43.0", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 5654b31db..769b9cd88 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -29,6 +29,7 @@ "https-proxy-agent": "9.1.0", "ignore": "7.0.14", "jiti": "2.7.0", + "jose": "6.2.13", "marked": "18.1.1", "minimatch": "10.2.6", "openai": "7.43.0", diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 8c0d362e4..b50d3c42b 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -3,8 +3,9 @@ * Provider auth orchestration belongs to ModelRuntime and Diffex AI Models. */ +import { randomUUID } from "node:crypto"; import type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore } from "@diffexai/diffex-ai"; -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { setTimeout as sleep } from "timers/promises"; @@ -34,7 +35,10 @@ type AuthFileReadState = { reload?: AuthFileReload; }; -let sharedAuthFileReadState: { authPath: string; readState: AuthFileReadState } | undefined; +const sharedAuthFileReadStates = new Map>(); +const authFileReadStateCleanup = new FinalizationRegistry((authPath) => { + if (!sharedAuthFileReadStates.get(authPath)?.deref()) sharedAuthFileReadStates.delete(authPath); +}); export interface AuthStorageBackend { withLock(fn: (current: string | undefined) => LockResult): T; @@ -65,6 +69,18 @@ export class FileAuthStorageBackend implements AuthStorageBackend { } } + private writeAtomically(content: string): void { + // Resolve existing symlinks so a configured credential-store link stays intact. + const target = realpathSync(this.authPath); + const temporary = join(dirname(target), `.auth-${randomUUID()}.tmp`); + try { + writeFileSync(temporary, content, { encoding: "utf-8", mode: 0o600, flag: "wx" }); + renameSync(temporary, target); + } finally { + rmSync(temporary, { force: true }); + } + } + private acquireLockSyncWithRetry(path: string): () => void { const maxAttempts = 10; const delayMs = 20; @@ -102,8 +118,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend { const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined; const { result, next } = fn(current); if (next !== undefined) { - writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); - chmodSync(this.authPath, 0o600); + this.writeAtomically(next); } return result; } finally { @@ -184,8 +199,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend { throwIfCompromised(); options?.signal?.throwIfAborted(); if (next !== undefined) { - writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); - chmodSync(this.authPath, 0o600); + this.writeAtomically(next); } throwIfCompromised(); return result; @@ -333,10 +347,11 @@ export class AuthStorage implements CredentialStore { private constructor(storage: AuthStorageBackend, authPath?: string) { this.storage = storage; this.authPath = authPath; - this.readState = - authPath && sharedAuthFileReadState?.authPath === authPath ? sharedAuthFileReadState.readState : { data: {} }; - if (authPath && !sharedAuthFileReadState) { - sharedAuthFileReadState = { authPath, readState: this.readState }; + const sharedReadState = authPath ? sharedAuthFileReadStates.get(authPath)?.deref() : undefined; + this.readState = sharedReadState ?? { data: {} }; + if (authPath && !sharedReadState) { + sharedAuthFileReadStates.set(authPath, new WeakRef(this.readState)); + authFileReadStateCleanup.register(this.readState, authPath); } if (authPath) { const revision = getFileRevision(authPath); diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index e395b7627..7b2ed4f85 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -497,7 +497,11 @@ export class ModelRuntime implements Models { this.recomposeProvider(providerId); const compositionError = this.compositionErrors.get(providerId); if (compositionError) throw new Error(compositionError); - const result = await this.models.refresh({ allowNetwork: false, providers: [providerId], signal }); + const result = await this.models.refresh({ + allowNetwork: operation === "login" && providerId === "openai-codex" && this.modelNetworkEnabled, + providers: [providerId], + signal, + }); if (result.aborted) signal.throwIfAborted(); const refreshError = result.errors.get(providerId); if (refreshError) throw refreshError; @@ -669,8 +673,11 @@ export class ModelRuntime implements Models { logout(providerId: string, options: AuthOperationOptions = {}): Promise { const signal = operationSignal(options.signal); return this.enqueueCredentialOperation(providerId, signal, async () => { - await this.models.logout(providerId, { signal }); - await this.synchronizeCredentialState(providerId, "logout", undefined, signal); + try { + await this.models.logout(providerId, { signal }); + } finally { + await this.synchronizeCredentialState(providerId, "logout", undefined, operationSignal()); + } }); } diff --git a/packages/coding-agent/src/core/remote-catalog-provider.ts b/packages/coding-agent/src/core/remote-catalog-provider.ts index ef0f43737..4379bef50 100644 --- a/packages/coding-agent/src/core/remote-catalog-provider.ts +++ b/packages/coding-agent/src/core/remote-catalog-provider.ts @@ -33,6 +33,7 @@ export function withRemoteCatalog( catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL, localGeneratedAt?: number, ): Provider { + if (provider.refreshModels) return provider; let dynamicModels: readonly Model[] = []; const catalogUrl = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl).href; const provenance = { catalogUrl, catalogClientVersion: VERSION }; diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 5cb96d4da..ca9a8c6af 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -1,4 +1,4 @@ -import type { AuthInfoLink, OAuthDeviceCodeInfo } from "@diffexai/diffex-ai"; +import { type AuthInfoLink, getOAuthDisplayUrl, type OAuthDeviceCodeInfo } from "@diffexai/diffex-ai"; import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@diffexai/diffex-tui"; import { openBrowser } from "../../../utils/open-browser.ts"; import { theme } from "../theme/theme.ts"; @@ -74,7 +74,7 @@ export class LoginDialogComponent extends Container implements Focusable { } private cancel(): void { - this.abortController.abort(); + this.abortController.abort(new Error("Login cancelled")); if (this.inputRejecter) { this.inputRejecter(new Error("Login cancelled")); this.inputResolver = undefined; @@ -87,13 +87,14 @@ export class LoginDialogComponent extends Container implements Focusable { * Called by onAuth callback - show URL and optional instructions */ showAuth(url: string, instructions?: string): void { + const displayUrl = getOAuthDisplayUrl(url); this.contentContainer.clear(); this.contentContainer.addChild(new Spacer(1)); - const linkedUrl = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`; + const linkedUrl = `\x1b]8;;${displayUrl}\x07${displayUrl}\x1b]8;;\x07`; this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 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`; + const hyperlink = `\x1b]8;;${displayUrl}\x07${clickHint}\x1b]8;;\x07`; this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0)); if (instructions) { diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index 8c6b09b4e..64b81afd5 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@diffexai/diffex-agent-core"; @@ -19,8 +19,7 @@ describe("AgentSession auto-compaction queue resume", () => { let tempDir: string; beforeEach(async () => { - tempDir = join(tmpdir(), `pi-auto-compaction-queue-${Date.now()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = mkdtempSync(join(tmpdir(), "diffex-auto-compaction-queue-")); const model = getModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index f26ce43e6..a352af041 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -7,7 +7,7 @@ * - getUserMessagesForForking returns correct entries */ -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getModel } from "@diffexai/diffex-ai/compat"; @@ -31,8 +31,7 @@ describe.skipIf(!API_KEY)("AgentSession forking", () => { let sessionManager: SessionManager; beforeEach(() => { - tempDir = join(tmpdir(), `pi-branching-test-${Date.now()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = mkdtempSync(join(tmpdir(), "diffex-branching-test-")); }); afterEach(async () => { diff --git a/packages/coding-agent/test/auth-check.test.ts b/packages/coding-agent/test/auth-check.test.ts index f47afaad6..d6656b323 100644 --- a/packages/coding-agent/test/auth-check.test.ts +++ b/packages/coding-agent/test/auth-check.test.ts @@ -85,6 +85,10 @@ describe("auth check command", () => { if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered"); const refresh = vi.fn(async () => ({ type: "oauth" as const, + clientId: "oaiapp_diffex_test", + subject: "account", + extAgentHostId: "urn:uuid:host", + scopes: ["chatgpt.tokens.use.direct", "resource.invoke"], access: "fresh-token", refresh: "refresh-token", expires: Date.now() + 60 * 60 * 1000, diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index 965d9176a..1641cd7c1 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -1,4 +1,15 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + closeSync, + existsSync, + fstatSync, + lstatSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { type CredentialStore, createModels, type Provider } from "@diffexai/diffex-ai"; @@ -7,12 +18,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AuthStorage, FileAuthStorageBackend } from "../src/core/auth-storage.ts"; describe("AuthStorage", () => { - const tempDir = join(tmpdir(), `pi-test-auth-storage-${Date.now()}-${Math.random().toString(36).slice(2)}`); - const authJsonPath = join(tempDir, "auth.json"); + let tempDir: string; + let authJsonPath: string; beforeEach(() => { - if (existsSync(tempDir)) rmSync(tempDir, { recursive: true }); - mkdirSync(tempDir, { recursive: true }); + tempDir = mkdtempSync(join(tmpdir(), "diffex-test-auth-storage-")); + authJsonPath = join(tempDir, "auth.json"); }); afterEach(() => { @@ -155,6 +166,43 @@ describe("AuthStorage", () => { }); }); + test.skipIf(process.platform === "win32")( + "atomically replaces credentials with owner-only permissions while preserving symlinks", + async () => { + const target = join(tempDir, "protected.json"); + writeFileSync(target, "{}"); + symlinkSync(target, authJsonPath); + const original = openSync(target, "r"); + let before: number; + try { + before = fstatSync(original).ino; + } finally { + closeSync(original); + } + const storage = AuthStorage.create(authJsonPath); + await storage.modify("openai-codex", async () => ({ + type: "oauth", + access: "access", + refresh: "refresh", + expires: 123, + })); + const descriptor = openSync(target, "r"); + try { + const metadata = fstatSync(descriptor); + expect(metadata.ino).not.toBe(before); + expect(metadata.mode & 0o777).toBe(0o600); + expect(JSON.parse(readFileSync(descriptor, "utf8"))["openai-codex"]).toMatchObject({ + access: "access", + refresh: "refresh", + expires: 123, + }); + } finally { + closeSync(descriptor); + } + expect(lstatSync(authJsonPath).isSymbolicLink()).toBe(true); + }, + ); + test("modify with undefined leaves the current credential unchanged", async () => { writeAuthJson({ anthropic: { type: "api_key", key: "stored" } }); const storage = AuthStorage.create(authJsonPath); diff --git a/packages/coding-agent/test/codex-gpt6-model-catalog.test.ts b/packages/coding-agent/test/codex-gpt6-model-catalog.test.ts index a051bd845..f3a03d7d6 100644 --- a/packages/coding-agent/test/codex-gpt6-model-catalog.test.ts +++ b/packages/coding-agent/test/codex-gpt6-model-catalog.test.ts @@ -3,13 +3,29 @@ import { describe, expect, it, vi } from "vitest"; import { listModels } from "../src/cli/list-models.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { ModelRuntime } from "../src/core/model-runtime.ts"; +import { allowNetwork } from "./test-network-env.ts"; describe("Codex GPT-6 model catalog", () => { it("lists GPT-6 models for a ChatGPT login", async () => { + allowNetwork(); + const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json({ + models: [ + { slug: "gpt-6-astra", display_name: "GPT-6 Astra", visibility: "list" }, + { slug: "gpt-6-luna", display_name: "GPT-6 Luna", visibility: "list" }, + { slug: "gpt-6-sol", display_name: "GPT-6 Sol", visibility: "list" }, + { slug: "gpt-6.1-sol", display_name: "GPT-6.1 Sol", visibility: "list" }, + ], + }), + ); const modelRuntime = await ModelRuntime.create({ credentials: AuthStorage.inMemory({ "openai-codex": { type: "oauth", + clientId: "oaiapp_diffex_test", + subject: "account", + extAgentHostId: "urn:uuid:host", + scopes: ["chatgpt.tokens.use.direct", "resource.invoke"], access: "fixture-token", refresh: "fixture-refresh", expires: Date.now() + 60 * 60 * 1000, @@ -26,9 +42,11 @@ describe("Codex GPT-6 model catalog", () => { }); try { + await modelRuntime.refresh({ providers: ["openai-codex"], allowNetwork: true }); await listModels(modelRuntime, "gpt-6"); } finally { log.mockRestore(); + fetch.mockRestore(); } const listedIds = output diff --git a/packages/coding-agent/test/credential-print.test.ts b/packages/coding-agent/test/credential-print.test.ts index e783d6961..002b4da53 100644 --- a/packages/coding-agent/test/credential-print.test.ts +++ b/packages/coding-agent/test/credential-print.test.ts @@ -52,6 +52,10 @@ describe("credential print commands", () => { const runtime = await createRuntime(storage); const refresh = vi.fn(async () => ({ type: "oauth" as const, + clientId: "oaiapp_diffex_test", + subject: "account", + extAgentHostId: "urn:uuid:host", + scopes: ["chatgpt.tokens.use.direct", "resource.invoke"], access: "fresh-test-token", refresh: "test-refresh-token", expires: Date.now() + 60 * 60 * 1000, diff --git a/packages/coding-agent/test/login-dialog-auth.test.ts b/packages/coding-agent/test/login-dialog-auth.test.ts new file mode 100644 index 000000000..485feeff7 --- /dev/null +++ b/packages/coding-agent/test/login-dialog-auth.test.ts @@ -0,0 +1,36 @@ +import { setKeybindings, type TUI } from "@diffexai/diffex-tui"; +import { beforeAll, expect, it, vi } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import { LoginDialogComponent } from "../src/modes/interactive/components/login-dialog.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { openBrowser } from "../src/utils/open-browser.ts"; + +vi.mock("../src/utils/open-browser.ts", () => ({ openBrowser: vi.fn() })); + +beforeAll(() => initTheme("dark")); + +it("opens the complete sign-in URL while redacting the ID token from terminal output and hyperlinks", () => { + const dialog = new LoginDialogComponent( + { requestRender: vi.fn() } as unknown as TUI, + "openai-codex", + () => {}, + "ChatGPT", + ); + const url = + "https://auth.openai.com/api/accounts/authorize?client_id=oaiapp_diffex&id_token_hint=private-identity-token&state=state"; + dialog.showAuth(url, "Continue with ChatGPT"); + expect(openBrowser).toHaveBeenCalledWith(url); + const rendered = dialog.render(100).join("\n"); + expect(rendered).not.toContain("private-identity-token"); + expect(rendered).not.toContain("id_token_hint"); + expect(rendered).toContain("Continue with ChatGPT"); + expect(rendered).toContain("oaiapp_diffex"); +}); + +it("cancels with the login cancellation reason used by the UI", () => { + setKeybindings(new KeybindingsManager()); + const dialog = new LoginDialogComponent({ requestRender: vi.fn() } as unknown as TUI, "openai-codex", () => {}); + dialog.handleInput("\x1b"); + expect(dialog.signal.aborted).toBe(true); + expect(dialog.signal.reason).toMatchObject({ message: "Login cancelled" }); +}); diff --git a/packages/coding-agent/test/model-runtime-credential-sync.test.ts b/packages/coding-agent/test/model-runtime-credential-sync.test.ts index c79bd3d46..8e794e131 100644 --- a/packages/coding-agent/test/model-runtime-credential-sync.test.ts +++ b/packages/coding-agent/test/model-runtime-credential-sync.test.ts @@ -2,6 +2,7 @@ import type { ApiKeyCredential, Credential, CredentialStore, Model, Provider } f import { describe, expect, it, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { CredentialSynchronizationError, ModelRuntime } from "../src/core/model-runtime.ts"; +import { allowNetwork } from "./test-network-env.ts"; function model(provider: string): Model<"openai-completions"> { return { @@ -60,6 +61,44 @@ async function runtimeWithProvider( } describe("ModelRuntime credential synchronization", () => { + it("discovers ChatGPT account models before completing login", async () => { + allowNetwork(); + const registered = provider("openai-codex"); + const networkRefresh = vi.fn(); + registered.refreshModels = async (context) => { + if (context.allowNetwork) networkRefresh(); + }; + const runtime = await runtimeWithProvider(registered); + await runtime.login("openai-codex", "api_key", { prompt: async () => "unused", notify: () => {} }); + expect(networkRefresh).toHaveBeenCalledOnce(); + }); + + it("synchronizes local sign-out when remote revocation fails", async () => { + const credentials = AuthStorage.inMemory({ + renewable: { type: "oauth", access: "access", refresh: "refresh", expires: Date.now() + 3600_000 }, + }); + const registered = provider("renewable"); + const renewable: Provider = { + ...registered, + auth: { + oauth: { + name: "Renewable", + login: async () => { + throw new Error("unused"); + }, + refresh: async (credential) => credential, + toAuth: async (credential) => ({ apiKey: credential.access }), + logout: async () => { + throw new Error("Remote revocation was not confirmed"); + }, + }, + }, + }; + const runtime = await runtimeWithProvider(renewable, credentials); + await expect(runtime.logout("renewable")).rejects.toThrow("Remote revocation was not confirmed"); + expect(await credentials.read("renewable")).toBeUndefined(); + expect(runtime.hasConfiguredAuth("renewable")).toBe(false); + }); it("publishes locally consistent availability before login and logout resolve", async () => { const credentials = AuthStorage.inMemory(); const runtime = await runtimeWithProvider(provider("dynamic"), credentials); diff --git a/packages/coding-agent/test/remote-catalog-provider.test.ts b/packages/coding-agent/test/remote-catalog-provider.test.ts index 83e9174a3..1ceaac22c 100644 --- a/packages/coding-agent/test/remote-catalog-provider.test.ts +++ b/packages/coding-agent/test/remote-catalog-provider.test.ts @@ -77,6 +77,16 @@ async function refreshProvider( afterEach(() => vi.restoreAllMocks()); describe("remote catalog provider", () => { + it("preserves a provider's account-specific discovery policy", () => { + const provider = createProvider({ + id: "account-catalog", + models: [], + api: {}, + auth: { apiKey: { name: "Test", resolve: async () => undefined } }, + fetchModels: async () => [], + }); + expect(withRemoteCatalog(provider)).toBe(provider); + }); it("parses keyed catalogs, sends version headers, observes the refresh TTL, and supports forced refreshes", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( async () => diff --git a/scripts/verify-candidate-artifacts.py b/scripts/verify-candidate-artifacts.py index 118810e3e..10415eaf0 100644 --- a/scripts/verify-candidate-artifacts.py +++ b/scripts/verify-candidate-artifacts.py @@ -123,7 +123,7 @@ os.write(fd, b"\r") stage = "accounts" elif stage == "accounts" and all( - name in text for name in ["OpenAI Codex", "OpenRouter", "xAI"] + name in text for name in ["ChatGPT", "OpenRouter", "xAI"] ): assert not re.search(r"\bRadius\b", text), text mark = len(raw) @@ -162,7 +162,7 @@ "command": "interactive prompt, login selector, exit", "exitCode": 0, "events": events, - "accounts": ["OpenAI Codex", "OpenRouter", "xAI"], + "accounts": ["ChatGPT", "OpenRouter", "xAI"], "cleanup": "process exited and isolated directories removed", } )