Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/release/provider-branding-validation.md
Original file line number Diff line number Diff line change
@@ -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 (<platform> <release>; <arch>)` 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.
9 changes: 5 additions & 4 deletions packages/ai/src/api/openai-codex-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down
8 changes: 4 additions & 4 deletions packages/ai/src/auth/oauth/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ async function startCallbackServer(expectedState: string): Promise<CallbackServe

if (error) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(oauthErrorHtml("Anthropic authentication did not complete.", `Error: ${error}`));
res.end(oauthErrorHtml("Anthropic authorization did not complete."));
return;
}

Expand All @@ -142,11 +142,11 @@ async function startCallbackServer(expectedState: string): Promise<CallbackServe
}

res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(oauthSuccessHtml("Anthropic authentication completed. You can close this window."));
res.end(oauthSuccessHtml());
settleWait?.({ code, state });
} catch {
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Internal error");
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
res.end(oauthErrorHtml("Internal error while processing OAuth callback."));
}
});

Expand Down
2 changes: 2 additions & 0 deletions packages/ai/src/auth/oauth/diffex-logo.ts

Large diffs are not rendered by default.

53 changes: 44 additions & 9 deletions packages/ai/src/auth/oauth/oauth-page.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" aria-hidden="true"><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"/><path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/></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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 48" shape-rendering="crispEdges" aria-hidden="true">
<g fill="#82aaff">
<path d="M46 2h2v20h-2z M38 2h2v2h-2z M40 4h2v2h-2z M42 6h2v2h-2z M44 8h2v2h-2z M54 2h2v2h-2z M52 4h2v2h-2z M50 6h2v2h-2z M48 8h2v2h-2z M36 10h10v2H36z M48 10h10v2H48z"/>
<path d="M34 20h26v2H34z M32 22h2v2h-2z M60 22h2v2h-2z M30 24h2v4h-2z M62 24h2v4h-2z M26 28h4v2h-4z M64 28h6v2h-6z M20 30h6v2h-6z M70 30h8v2h-8z"/>
</g>
<path fill="#a9c4ff" d="M18 32h62v2h-2v2h-2v2h-4v2H28v-2h-4v-2h-4v-2h-2z"/>
<path fill="#5d7eaf" d="M28 38h44v2H28z M34 22h26v2H34z"/>
<path fill="#ccd9f5" d="M39 25h4v4h-4z M53 25h4v4h-4z"/>
<g fill="#82aaff" opacity=".45">
<path d="M2 36h10v1H2z M82 36h10v1H82z M12 42h12v1H12z M30 44h14v1H30z M51 43h12v1H51z M70 42h14v1H70z M4 47h18v1H4z M40 48h18v1H40z M78 47h18v1H78z"/>
</g>
</svg>`;

function escapeHtml(value: string): string {
return value
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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; }
}
</style>
</head>
<body>
<div class="scene" aria-hidden="true"><div class="boat">${BOAT_SVG}</div></div>
<main>
<div class="logo">${LOGO_SVG}</div>
<div class="logo">${DIFFEX_LOGO_SVG}</div>
<h1>${heading}</h1>
<p>${message}</p>
${details ? `<div class="details">${details}</div>` : ""}
Expand All @@ -91,19 +126,19 @@ function renderPage(options: { title: string; heading: string; message: string;
</html>`;
}

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"),
});
}
4 changes: 2 additions & 2 deletions packages/ai/src/auth/oauth/openai-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -356,7 +356,7 @@ function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
}
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;
Expand Down
6 changes: 3 additions & 3 deletions packages/ai/src/auth/oauth/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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) });
}
})();
Expand Down
2 changes: 1 addition & 1 deletion packages/ai/src/auth/oauth/xai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ async function requestDeviceCode(signal: AbortSignal): Promise<XaiDeviceCode> {
{
client_id: XAI_CLIENT_ID,
scope: XAI_SCOPE,
referrer: "pi",
referrer: "diffex",
},
signal,
);
Expand Down
10 changes: 10 additions & 0 deletions packages/ai/test/anthropic-oauth.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
Loading
Loading