diff --git a/docs/release/provider-branding-validation.md b/docs/release/provider-branding-validation.md new file mode 100644 index 000000000..eb5d95ede --- /dev/null +++ b/docs/release/provider-branding-validation.md @@ -0,0 +1,60 @@ +# Provider branding validation + +Diffex controls the local callback presentation and default application attribution. +Registered OAuth client IDs, provider consent pages, authentication protocols, stored credentials, inference endpoints, and payloads remain unchanged. +The separate Diffex account feature and Radius implementation are excluded. + +## Implemented defaults + +| Integration | Branding | +| --- | --- | +| OpenAI Codex | Authorization and inference `originator=diffex`; native `diffex ( ; )` and browser `diffex (browser)` User-Agent | +| xAI | Device authorization `referrer=diffex` | +| OpenRouter | `HTTP-Referer: https://diffex.ai`, `X-OpenRouter-Title: Diffex` | +| NVIDIA NIM | `X-BILLING-INVOKE-ORIGIN: Diffex` | +| Cloudflare Workers AI / AI Gateway | `User-Agent: diffex-coding-agent` | +| OpenCode / OpenCode Go | `x-opencode-client: diffex` with the existing session header | + +Optional attribution still defaults off and honors `DIFFEX_PROVIDER_ATTRIBUTION`. +OpenCode session attribution remains independent of that setting and requires a session ID. +Explicit header overrides and `null` removal take precedence over branding defaults, including Codex originator and User-Agent. + +Codex, Anthropic, and OpenRouter callbacks use the Diffex four-loop mark and an inline pixel-art background based on the entrance animation's boat. +Success pages ask users to close the tab and return to Diffex without claiming credential persistence. +Handled errors use safe explanations instead of displaying raw provider responses. +Callback routes, validation, HTTP status codes, and response timing are preserved. + +## Offline verification + +A controlled Node capture used the actual login implementations and loopback callback servers with synthetic authorization codes and mocked token endpoints before changing presentation. +The baseline exposed provider-specific success claims on all three callbacks. +The updated pages were inspected in a browser at 375px and 1280px widths for success and error states, including spacing, contrast, wrapping, and horizontal overflow. +No real credentials or provider calls were used. + +Focused Vitest checks pass for callback presentation, Codex authorization and SSE/WebSocket streaming, Anthropic and OpenRouter OAuth, xAI polling and refresh, shared device polling, Copilot and Kimi OAuth, attribution merging, SDK attribution dispatch, and the provider selector. +The callback tests verify unchanged client IDs, scopes, redirect URIs, endpoints, PKCE, validation, and exchange timing. +Attribution tests cover enabled and disabled settings, environment overrides, configured and caller headers, explicit `null` removal, custom providers, and requests with and without session IDs. +A browser-bundled Codex stream also completed against a mock with `process` absent and the expected `diffex (browser)` User-Agent. + +`npm run check` passes, including the runtime identity inventory and browser bundle smoke check. +The inventory retains four occurrences across three documented exceptions: the Radius client ID, the Radius gateway, and two occurrences in the original DOOM integration credit. +The audit found no inherited Pi application labels in other built-in provider login implementations or shared login UI. +Provider protocol labels such as GitHub Copilot and Claude CLI headers remain unchanged. + +## Pending live compatibility checks + +No authorized provider accounts were used for live validation in this implementation session. +Mock results do not establish provider compatibility. +No exact provider rejection was observed; the following checks remain pending before release. + +| Integration | Required live checks | Status | +| --- | --- | --- | +| OpenAI Codex | Browser and device login, stored credential reuse and refresh, SSE and WebSocket inference | Pending | +| xAI | Device authorization with the new referrer, polling, stored credential reuse and refresh, inference | Pending | +| OpenRouter | Login, stored API-key reuse, inference with optional attribution enabled | Pending | +| NVIDIA NIM | Authenticated inference with `X-BILLING-INVOKE-ORIGIN: Diffex` and existing credentials | Pending; acceptance of the new value is unresolved | +| Cloudflare Workers AI / AI Gateway | Inference with each integration's existing credentials and optional attribution enabled | Pending | +| OpenCode / OpenCode Go | Inference with existing credentials and session/client attribution | Pending | + +Any rejection must be recorded with its exact incompatibility before claiming provider compatibility. +Do not resolve a branding incompatibility by changing OAuth registration, authentication protocols, or inference routing. diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index f46544099..5ba4abb13 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -1595,7 +1595,11 @@ function buildBaseCodexHeaders( accountId: string, token: string, ): Headers { - const headers = new Headers(initHeaders); + const userAgent = _os ? `diffex (${_os.platform()} ${_os.release()}; ${_os.arch()})` : "diffex (browser)"; + const headers = new Headers({ originator: "diffex", "User-Agent": userAgent }); + new Headers(initHeaders).forEach((value, key) => { + headers.set(key, value); + }); for (const [key, value] of Object.entries(additionalHeaders || {})) { if (value === null) { headers.delete(key); @@ -1605,9 +1609,6 @@ function buildBaseCodexHeaders( } headers.set("Authorization", `Bearer ${token}`); headers.set("chatgpt-account-id", accountId); - headers.set("originator", "pi"); - const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : "pi (browser)"; - headers.set("User-Agent", userAgent); return headers; } diff --git a/packages/ai/src/auth/oauth/anthropic.ts b/packages/ai/src/auth/oauth/anthropic.ts index 980ee7d0a..f6bf007bd 100644 --- a/packages/ai/src/auth/oauth/anthropic.ts +++ b/packages/ai/src/auth/oauth/anthropic.ts @@ -125,7 +125,7 @@ async function startCallbackServer(expectedState: string): Promise`; diff --git a/packages/ai/src/auth/oauth/oauth-page.ts b/packages/ai/src/auth/oauth/oauth-page.ts index a2a1c4d8a..47d29ad84 100644 --- a/packages/ai/src/auth/oauth/oauth-page.ts +++ b/packages/ai/src/auth/oauth/oauth-page.ts @@ -1,4 +1,18 @@ -const LOGO_SVG = ``; +import { DIFFEX_LOGO_SVG } from "./diffex-logo.ts"; + +// Pixel rendition of the entrance animation's rotor, cabin, portholes, hull, and wake. +const BOAT_SVG = ``; function escapeHtml(value: string): string { return value @@ -37,13 +51,29 @@ function renderPage(options: { title: string; heading: string; message: string; display: flex; align-items: center; justify-content: center; + overflow-wrap: anywhere; padding: 24px; background: var(--page-bg); color: var(--text); font-family: var(--font-sans); text-align: center; } + .scene { + position: fixed; + inset: 0; + overflow: hidden; + pointer-events: none; + } + .boat { + position: absolute; + left: 50%; + bottom: 5vh; + width: min(520px, 90vw); + transform: translateX(-50%); + opacity: .2; + } main { + position: relative; width: 100%; max-width: 560px; display: flex; @@ -63,6 +93,7 @@ function renderPage(options: { title: string; heading: string; message: string; line-height: 1.15; font-weight: 650; color: var(--text); + text-wrap: balance; } p { margin: 0; @@ -78,11 +109,15 @@ function renderPage(options: { title: string; heading: string; message: string; white-space: pre-wrap; word-break: break-word; } + @media (max-height: 600px) { + .boat { bottom: -48px; opacity: .07; } + } +
- +

${heading}

${message}

${details ? `
${details}
` : ""} @@ -91,19 +126,19 @@ function renderPage(options: { title: string; heading: string; message: string; `; } -export function oauthSuccessHtml(message: string): string { +export function oauthSuccessHtml(message = "You can close this tab and return to Diffex."): string { return renderPage({ - title: "Authentication successful", - heading: "Authentication successful", + title: "Continue in Diffex", + heading: "Continue in Diffex", message, }); } export function oauthErrorHtml(message: string, details?: string): string { return renderPage({ - title: "Authentication failed", - heading: "Authentication failed", - message, - details, + title: "Diffex sign-in error", + heading: "Could not complete sign-in", + message: "Return to Diffex and run /login to try again.", + details: [message, details].filter(Boolean).join("\n"), }); } diff --git a/packages/ai/src/auth/oauth/openai-codex.ts b/packages/ai/src/auth/oauth/openai-codex.ts index af529c55f..fc003eaeb 100644 --- a/packages/ai/src/auth/oauth/openai-codex.ts +++ b/packages/ai/src/auth/oauth/openai-codex.ts @@ -291,7 +291,7 @@ async function pollOpenAICodexDeviceAuth(device: DeviceAuthInfo, signal: AbortSi } async function createAuthorizationFlow( - originator: string = "pi", + originator: string = "diffex", ): Promise<{ verifier: string; state: string; url: string }> { const { verifier, challenge } = await generatePKCE(); const state = createState(); @@ -356,7 +356,7 @@ function startLocalOAuthServer(state: string): Promise { } res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window.")); + res.end(oauthSuccessHtml()); settleWait?.({ code }); } catch { res.statusCode = 500; diff --git a/packages/ai/src/auth/oauth/openrouter.ts b/packages/ai/src/auth/oauth/openrouter.ts index 0054ebf1f..44d61e555 100644 --- a/packages/ai/src/auth/oauth/openrouter.ts +++ b/packages/ai/src/auth/oauth/openrouter.ts @@ -181,7 +181,7 @@ async function startCallbackServer( const oauthError = requestUrl.searchParams.get("error"); if (oauthError) { const description = requestUrl.searchParams.get("error_description") ?? oauthError; - sendHtml(response, 400, oauthErrorHtml("OpenRouter authorization was denied.", description)); + sendHtml(response, 400, oauthErrorHtml("OpenRouter authorization was denied.")); finish({ error: new Error(`OpenRouter authorization failed: ${description}`) }); return; } @@ -195,11 +195,11 @@ async function startCallbackServer( try { const result = await exchangeAuthorizationCode(code, verifier, signal); - sendHtml(response, 200, oauthSuccessHtml("Signed in to OpenRouter. You may now close this page.")); + sendHtml(response, 200, oauthSuccessHtml()); finish({ credential: result }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown token exchange error"; - sendHtml(response, 502, oauthErrorHtml("OpenRouter key exchange failed.", message)); + sendHtml(response, 502, oauthErrorHtml("OpenRouter key exchange failed. Check the terminal for details.")); finish({ error: error instanceof Error ? error : new Error(message) }); } })(); diff --git a/packages/ai/src/auth/oauth/xai.ts b/packages/ai/src/auth/oauth/xai.ts index bf999c34c..fa74ffa49 100644 --- a/packages/ai/src/auth/oauth/xai.ts +++ b/packages/ai/src/auth/oauth/xai.ts @@ -148,7 +148,7 @@ async function requestDeviceCode(signal: AbortSignal): Promise { { client_id: XAI_CLIENT_ID, scope: XAI_SCOPE, - referrer: "pi", + referrer: "diffex", }, signal, ); diff --git a/packages/ai/test/anthropic-oauth.test.ts b/packages/ai/test/anthropic-oauth.test.ts index 9dc227b93..d0ccddeb1 100644 --- a/packages/ai/test/anthropic-oauth.test.ts +++ b/packages/ai/test/anthropic-oauth.test.ts @@ -1,6 +1,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts"; import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts"; +import { defineOAuthCallbackTests } from "./oauth-callback-test-utils.ts"; + +defineOAuthCallbackTests(anthropicOAuth, { + name: "Anthropic", + authorizeUrl: "https://claude.ai/oauth/authorize", + tokenUrl: "https://platform.claude.com/v1/oauth/token", + clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + scope: "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload", + redirectUri: "http://localhost:53692/callback", +}); const neverAbortedSignal = new AbortController().signal; diff --git a/packages/ai/test/oauth-callback-test-utils.ts b/packages/ai/test/oauth-callback-test-utils.ts new file mode 100644 index 000000000..5c63e11e4 --- /dev/null +++ b/packages/ai/test/oauth-callback-test-utils.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OAuthAuth } from "../src/auth/types.ts"; + +const nativeFetch = globalThis.fetch; +const privateDetail = ''; + +export function defineOAuthCallbackTests( + provider: OAuthAuth, + options: { + name: string; + authorizeUrl: string; + tokenUrl: string; + clientId?: string; + scope?: string; + redirectUri?: string; + originator?: string; + }, +): void { + describe.sequential(`${options.name} browser callback`, () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps callback validation, PKCE, and exchange timing while returning Diffex pages", async () => { + let finishExchange: (response: Response) => void = () => { + throw new Error("No pending exchange"); + }; + let exchangeBody: Record | undefined; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe(options.tokenUrl); + expect(init?.method).toBe("POST"); + exchangeBody = + init?.body instanceof URLSearchParams + ? Object.fromEntries(init.body) + : (JSON.parse(String(init?.body)) as Record); + return new Promise((resolve, reject) => { + finishExchange = resolve; + init?.signal?.addEventListener("abort", () => reject(new Error("Login cancelled")), { once: true }); + }); + }); + vi.stubGlobal("fetch", fetchMock); + let authorize: URL | undefined; + let callback: URL | undefined; + let manualSignal: AbortSignal | undefined; + let settled = false; + const controller = new AbortController(); + const login = provider.login({ + signal: controller.signal, + notify: (event) => { + if (event.type !== "auth_url") return; + authorize = new URL(event.url); + callback = new URL( + authorize.searchParams.get("redirect_uri") ?? authorize.searchParams.get("callback_url") ?? "", + ); + callback.hostname = "127.0.0.1"; + }, + prompt: async (prompt) => { + if (prompt.type === "select") return "browser"; + manualSignal = prompt.signal; + return waitForCancellation(controller.signal); + }, + }); + const outcome = login.then( + (credential) => { + settled = true; + return credential; + }, + (error: unknown) => { + settled = true; + throw error; + }, + ); + try { + await vi.waitFor(() => expect(callback).toBeDefined()); + if (!callback || !authorize) throw new Error("No browser callback"); + expect(`${authorize.origin}${authorize.pathname}`).toBe(options.authorizeUrl); + if (options.clientId) expect(authorize.searchParams.get("client_id")).toBe(options.clientId); + if (options.scope) expect(authorize.searchParams.get("scope")).toBe(options.scope); + if (options.redirectUri) expect(authorize.searchParams.get("redirect_uri")).toBe(options.redirectUri); + expect(authorize.searchParams.get("code_challenge_method")).toBe("S256"); + if (options.originator) { + expect(Object.fromEntries(authorize.searchParams)).toEqual({ + response_type: "code", + client_id: options.clientId, + redirect_uri: options.redirectUri, + scope: options.scope, + code_challenge: expect.stringMatching(/^[\w-]{43}$/), + code_challenge_method: "S256", + state: expect.stringMatching(/^[a-f0-9]{32}$/), + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + originator: options.originator, + }); + } + const state = authorize.searchParams.get("state"); + if (state) callback.searchParams.set("state", state); + + const invalid = new URL(callback); + invalid.pathname = "/invalid-route"; + let response = await nativeFetch(invalid); + expect(response.status).toBe(404); + assertErrorPage(await response.text()); + + response = await nativeFetch(callback); + expect(response.status).toBe(400); + assertErrorPage(await response.text()); + if (state) { + invalid.pathname = callback.pathname; + invalid.searchParams.set("code", "mock-code"); + invalid.searchParams.set("state", "wrong-state"); + response = await nativeFetch(invalid); + expect(response.status).toBe(400); + expect(await response.text()).toContain("State mismatch."); + } + expect(fetchMock).not.toHaveBeenCalled(); + callback.searchParams.set("code", "mock-code"); + let callbackArrived = false; + const callbackResponse = nativeFetch(callback).then((result) => { + callbackArrived = true; + return result; + }); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + // OpenRouter replies after exchange; Codex and Anthropic reply before it completes. + if (options.name === "OpenRouter") expect(callbackArrived).toBe(false); + else assertSuccessPage(await (await callbackResponse).text()); + expect(settled).toBe(false); + expect(exchangeBody?.code).toBe("mock-code"); + const verifier = exchangeBody?.code_verifier; + expect(verifier).toBeTruthy(); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + expect(Buffer.from(digest).toString("base64url")).toBe(authorize.searchParams.get("code_challenge")); + if (options.clientId) expect(exchangeBody?.client_id).toBe(options.clientId); + if (options.redirectUri) expect(exchangeBody?.redirect_uri).toBe(options.redirectUri); + const payload = Buffer.from( + JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "mock-account" } }), + ).toString("base64"); + finishExchange( + Response.json({ + key: "mock-key", + access_token: `e30.${payload}.signature`, + refresh_token: "mock-refresh", + expires_in: 3600, + }), + ); + await expect(outcome).resolves.toMatchObject({ type: "oauth" }); + const success = await callbackResponse; + expect(success.status).toBe(200); + if (options.name === "OpenRouter") assertSuccessPage(await success.text()); + expect(manualSignal?.aborted).toBe(true); + } finally { + controller.abort(); + await outcome.catch(() => {}); + } + }); + + if (options.name !== "OpenAI Codex") { + it("omits raw provider error content from the browser", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + let callback: URL | undefined; + const controller = new AbortController(); + const login = provider.login({ + signal: controller.signal, + prompt: async () => waitForCancellation(controller.signal), + notify: (event) => { + if (event.type !== "auth_url") return; + const auth = new URL(event.url); + callback = new URL( + auth.searchParams.get("redirect_uri") ?? auth.searchParams.get("callback_url") ?? "", + ); + callback.hostname = "127.0.0.1"; + callback.searchParams.set("error", privateDetail); + callback.searchParams.set("error_description", privateDetail); + }, + }); + const outcome = login.catch((error: unknown) => error); + try { + await vi.waitFor(() => expect(callback).toBeDefined()); + if (!callback) throw new Error("No callback"); + const response = await nativeFetch(callback); + expect(response.status).toBe(400); + const html = await response.text(); + assertErrorPage(html); + expect(html).not.toContain("private-token"); + expect(fetchMock).not.toHaveBeenCalled(); + if (options.name === "OpenRouter") expect(await outcome).toBeInstanceOf(Error); + } finally { + controller.abort(); + await outcome; + } + }); + } + }); +} + +function waitForCancellation(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(new Error("Login cancelled")), { once: true }); + }); +} + +function assertSuccessPage(html: string): void { + expect(html).toContain("Continue in Diffex"); + expect(html).toContain("

Continue in Diffex

"); + expect(html).toContain("You can close this tab and return to Diffex."); + expect(html).not.toContain("Check the terminal for sign-in status."); + expect(html).not.toMatch(/authentication (successful|completed)|signed in|window\.close|