From f5721d7baa1f25c75ff1d177ef90b9376c018c5a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 12 May 2026 13:22:07 -0700 Subject: [PATCH] Set Freebuff ad request user agent --- cli/src/hooks/use-gravity-ad.ts | 14 ++++- web/src/app/api/v1/ads/_post.ts | 4 +- web/src/app/api/v1/ads/impression/_post.ts | 7 ++- .../lib/ad-providers/__tests__/carbon.test.ts | 62 +++++++++++++++++++ web/src/lib/ad-providers/carbon.ts | 14 +++-- web/src/lib/ad-providers/types.ts | 4 +- 6 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 web/src/lib/ad-providers/__tests__/carbon.test.ts diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index d012817860..2d527c6f9e 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -7,6 +7,7 @@ import { useChatStore } from '../state/chat-store' import { isUserActive, subscribeToActivity } from '../utils/activity-tracker' import { getAuthToken } from '../utils/auth' import { IS_FREEBUFF } from '../utils/constants' +import { getCliEnv } from '../utils/env' import { logger } from '../utils/logger' import type { Message } from '@codebuff/sdk' @@ -165,8 +166,12 @@ export const useGravityAd = (options?: { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, + 'User-Agent': getCliAdRequestUserAgent(), }, - body: JSON.stringify({ impUrl, mode: agentMode }), + body: JSON.stringify({ + impUrl, + mode: agentMode, + }), }) if (!res.ok) { @@ -282,6 +287,7 @@ export const useGravityAd = (options?: { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, + 'User-Agent': getCliAdRequestUserAgent(), }, body: JSON.stringify({ provider: providerToTry, @@ -482,3 +488,9 @@ function getAdUserAgent(): string { } return osUA[process.platform] ?? osUA.linux } + +function getCliAdRequestUserAgent(): string { + const product = IS_FREEBUFF ? 'Freebuff-CLI' : 'Codebuff-CLI' + const version = getCliEnv().CODEBUFF_CLI_VERSION ?? 'dev' + return `${product}/${version}` +} diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index 51419d8fb5..7762d151c1 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -46,7 +46,7 @@ const bodySchema = z.object({ sessionId: z.string().optional(), device: deviceSchema.optional(), surface: surfaceSchema.optional(), - /** Browser/CLI useragent passed through to providers that require it. */ + /** Browser-like useragent passed through to providers that require it. */ userAgent: z.string().optional(), }) @@ -120,6 +120,7 @@ export async function postAds(params: { const providerId: AdProviderId = parsedBody.provider ?? 'gravity' const userAgent = parsedBody.userAgent ?? req.headers.get('user-agent') ?? undefined + const requestUserAgent = req.headers.get('user-agent') ?? undefined // Pick a provider. If the requested one isn't configured, return no ad // rather than failing — the client falls back to its cache / fallback UI. @@ -151,6 +152,7 @@ export async function postAds(params: { sessionId: parsedBody.sessionId, clientIp, userAgent, + requestUserAgent, device: parsedBody.device, surface: parsedBody.surface, messages: parsedBody.messages, diff --git a/web/src/app/api/v1/ads/impression/_post.ts b/web/src/app/api/v1/ads/impression/_post.ts index a1f3e04a3d..673e376082 100644 --- a/web/src/app/api/v1/ads/impression/_post.ts +++ b/web/src/app/api/v1/ads/impression/_post.ts @@ -183,11 +183,16 @@ export async function postAdImpression(params: { p.replaceAll('[timestamp]', now), ) const pixelUrls = [impUrl, ...extraPixels] + const requestUserAgent = req.headers.get('user-agent') ?? undefined await Promise.all( pixelUrls.map(async (pixelUrl) => { try { - await fetch(pixelUrl) + await fetch(pixelUrl, { + ...(requestUserAgent + ? { headers: { 'User-Agent': requestUserAgent } } + : {}), + }) } catch (error) { logger.warn( { diff --git a/web/src/lib/ad-providers/__tests__/carbon.test.ts b/web/src/lib/ad-providers/__tests__/carbon.test.ts new file mode 100644 index 0000000000..88363426d0 --- /dev/null +++ b/web/src/lib/ad-providers/__tests__/carbon.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' + +import { createCarbonProvider } from '../carbon' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +const logger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +} + +describe('Carbon ad provider', () => { + test('sends the CLI User-Agent as the HTTP header', async () => { + const provider = createCarbonProvider({ zoneKey: 'CVADC53U' }) + const requests: Array<{ url: string; init?: RequestInit }> = [] + const fetch = Object.assign( + async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(url), init }) + return new Response( + JSON.stringify({ + ads: [ + { + statlink: '//srv.buysellads.com/click', + statimp: '//srv.buysellads.com/imp', + description: 'Ad copy', + company: 'Acme', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) + }, + { preconnect: () => {} }, + ) as typeof globalThis.fetch + + const result = await provider.fetchAd({ + userId: 'user-1', + userEmail: 'user@example.com', + clientIp: '203.0.113.1', + userAgent: 'Mozilla/5.0 Test Browser', + requestUserAgent: 'Freebuff-CLI/0.0.88', + messages: [], + testMode: false, + logger, + fetch, + }) + + expect(result?.ads).toHaveLength(1) + expect(requests).toHaveLength(4) + for (const request of requests) { + expect(request.url).toContain('useragent=Mozilla%2F5.0+Test+Browser') + expect(request.init?.headers).toEqual({ + 'User-Agent': 'Freebuff-CLI/0.0.88', + }) + } + }) +}) diff --git a/web/src/lib/ad-providers/carbon.ts b/web/src/lib/ad-providers/carbon.ts index f4775a00ac..7ff789bf4f 100644 --- a/web/src/lib/ad-providers/carbon.ts +++ b/web/src/lib/ad-providers/carbon.ts @@ -95,13 +95,12 @@ function normalizeCarbonAd(raw: CarbonAd): NormalizedAd | null { } } -export function createCarbonProvider(config: { - zoneKey: string -}): AdProvider { +export function createCarbonProvider(config: { zoneKey: string }): AdProvider { return { id: 'carbon', fetchAd: async (input: FetchAdInput): Promise => { - const { clientIp, userAgent, testMode, logger, fetch } = input + const { clientIp, userAgent, requestUserAgent, testMode, logger, fetch } = + input if (!clientIp || !userAgent) { logger.debug( @@ -122,7 +121,12 @@ export function createCarbonProvider(config: { const url = `${CARBON_URL_BASE}/${config.zoneKey}.json?${params.toString()}` const fetchOne = async (): Promise => { - const response = await fetch(url, { method: 'GET' }) + const response = await fetch(url, { + method: 'GET', + headers: { + 'User-Agent': requestUserAgent ?? userAgent, + }, + }) if (!response.ok) { let body: unknown try { diff --git a/web/src/lib/ad-providers/types.ts b/web/src/lib/ad-providers/types.ts index 8f6558d31f..b485a62f5d 100644 --- a/web/src/lib/ad-providers/types.ts +++ b/web/src/lib/ad-providers/types.ts @@ -53,8 +53,10 @@ export type FetchAdInput = { sessionId?: string /** Client IP, parsed from X-Forwarded-For upstream. */ clientIp?: string - /** Browser/CLI useragent string, passed through to upstream. */ + /** Browser-like useragent string, passed through to upstream. */ userAgent?: string + /** Product User-Agent header sent on provider HTTP requests. */ + requestUserAgent?: string device?: AdDeviceInfo /** Product surface requesting the ad. Providers may map this to placements. */ surface?: AdSurface