diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 8b4e9abfede..a2ecd70fcea 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -41,6 +41,7 @@ import { getSiteByName } from '../utils/get-site.js' import openBrowser from '../utils/open-browser.js' import { isInteractive } from '../utils/scripted-commands.js' import { identify, reportError, setCommandForErrorReporting, track } from '../utils/telemetry/index.js' +import { getRequestUserAgent } from '../utils/user-agent.js' import type { NetlifyOptions } from './types.js' import type { CachedConfig } from '../lib/build.js' import type { MinimalAccount } from '../utils/types.js' @@ -666,7 +667,7 @@ export default class BaseCommand extends Command { host?: string pathPrefix?: string } = { - userAgent: USER_AGENT, + userAgent: getRequestUserAgent(), } if (process.env.NETLIFY_API_URL) { diff --git a/src/commands/blobs/blobs-delete.ts b/src/commands/blobs/blobs-delete.ts index 8b877670b7a..1d68070d691 100644 --- a/src/commands/blobs/blobs-delete.ts +++ b/src/commands/blobs/blobs-delete.ts @@ -1,6 +1,7 @@ import { getStore } from '@netlify/blobs' import { chalk, logAndThrowError, log } from '../../utils/command-helpers.js' +import { netlifyFetchForOrigin } from '../../utils/netlify-fetch.js' import { promptBlobDelete } from '../../utils/prompts/blob-delete-prompts.js' /** @@ -10,8 +11,10 @@ export const blobsDelete = async (storeName: string, key: string, _options: Reco const { api, siteInfo } = command.netlify const { force } = _options + const apiURL = `${api.scheme}://${api.host}` const store = getStore({ - apiURL: `${api.scheme}://${api.host}`, + apiURL, + fetch: netlifyFetchForOrigin(apiURL), name: storeName, siteID: siteInfo.id ?? '', token: api.accessToken ?? '', diff --git a/src/commands/blobs/blobs-get.ts b/src/commands/blobs/blobs-get.ts index 0b105e97d05..1a8622fbc8e 100644 --- a/src/commands/blobs/blobs-get.ts +++ b/src/commands/blobs/blobs-get.ts @@ -5,6 +5,7 @@ import { getStore } from '@netlify/blobs' import { OptionValues } from 'commander' import { chalk, logAndThrowError } from '../../utils/command-helpers.js' +import { netlifyFetchForOrigin } from '../../utils/netlify-fetch.js' import BaseCommand from '../base-command.js' interface Options extends OptionValues { @@ -14,8 +15,10 @@ interface Options extends OptionValues { export const blobsGet = async (storeName: string, key: string, options: Options, command: BaseCommand) => { const { api, siteInfo } = command.netlify const { output } = options + const apiURL = `${api.scheme}://${api.host}` const store = getStore({ - apiURL: `${api.scheme}://${api.host}`, + apiURL, + fetch: netlifyFetchForOrigin(apiURL), name: storeName, siteID: siteInfo?.id ?? '', token: api.accessToken ?? '', diff --git a/src/commands/blobs/blobs-list.ts b/src/commands/blobs/blobs-list.ts index 55844976261..5ab1cb79f9d 100644 --- a/src/commands/blobs/blobs-list.ts +++ b/src/commands/blobs/blobs-list.ts @@ -3,6 +3,7 @@ import AsciiTable from 'ascii-table' import { OptionValues } from 'commander' import { chalk, logAndThrowError, log, logJson } from '../../utils/command-helpers.js' +import { netlifyFetchForOrigin } from '../../utils/netlify-fetch.js' import BaseCommand from '../base-command.js' interface Options extends OptionValues { @@ -13,8 +14,10 @@ interface Options extends OptionValues { export const blobsList = async (storeName: string, options: Options, command: BaseCommand) => { const { api, siteInfo } = command.netlify + const apiURL = `${api.scheme}://${api.host}` const store = getStore({ - apiURL: `${api.scheme}://${api.host}`, + apiURL, + fetch: netlifyFetchForOrigin(apiURL), name: storeName, siteID: siteInfo.id, token: api.accessToken ?? '', diff --git a/src/commands/blobs/blobs-set.ts b/src/commands/blobs/blobs-set.ts index 66b34d21d6c..2a0c3082b6f 100644 --- a/src/commands/blobs/blobs-set.ts +++ b/src/commands/blobs/blobs-set.ts @@ -5,6 +5,7 @@ import { getStore } from '@netlify/blobs' import { OptionValues } from 'commander' import { chalk, logAndThrowError, isNodeError, log } from '../../utils/command-helpers.js' +import { netlifyFetchForOrigin } from '../../utils/netlify-fetch.js' import { promptBlobSetOverwrite } from '../../utils/prompts/blob-set-prompt.js' import BaseCommand from '../base-command.js' @@ -22,8 +23,10 @@ export const blobsSet = async ( ) => { const { api, siteInfo } = command.netlify const { force, input } = options + const apiURL = `${api.scheme}://${api.host}` const store = getStore({ - apiURL: `${api.scheme}://${api.host}`, + apiURL, + fetch: netlifyFetchForOrigin(apiURL), name: storeName, siteID: siteInfo.id, token: api.accessToken ?? '', diff --git a/src/commands/claim/claim.ts b/src/commands/claim/claim.ts index 5783ff78bf8..ae064584931 100644 --- a/src/commands/claim/claim.ts +++ b/src/commands/claim/claim.ts @@ -5,11 +5,7 @@ import type BaseCommand from '../base-command.js' export const claim = async (siteId: string, dropToken: string, command: BaseCommand) => { await command.authenticate() - const apiBase = command.netlify.api.basePath - const dropApiOptions = { - apiBase, - userAgent: command.netlify.api.defaultHeaders['User-agent'] || 'netlify-cli', - } + const dropApiOptions = { apiBase: command.netlify.api.basePath } const authToken = command.netlify.api.accessToken if (!authToken) { diff --git a/src/commands/database/db-migration-pull.ts b/src/commands/database/db-migration-pull.ts index d0f904bc8f2..421baa81f70 100644 --- a/src/commands/database/db-migration-pull.ts +++ b/src/commands/database/db-migration-pull.ts @@ -4,6 +4,7 @@ import { dirname, resolve, isAbsolute } from 'path' import inquirer from 'inquirer' import { log, logJson } from '../../utils/command-helpers.js' +import { netlifyFetch } from '../../utils/netlify-fetch.js' import execa from '../../utils/execa.js' import BaseCommand from '../base-command.js' import { readApiErrorMessage } from './util/api-errors.js' @@ -81,7 +82,7 @@ const fetchMigrations = async (ctx: ApiContext, branch: string): Promise {} : deployProgressCb() diff --git a/src/commands/logs/log-api.ts b/src/commands/logs/log-api.ts index 2246ef67717..a55d6c6795c 100644 --- a/src/commands/logs/log-api.ts +++ b/src/commands/logs/log-api.ts @@ -2,6 +2,7 @@ import type { NetlifyAPI } from '@netlify/api' import parseDuration from 'parse-duration' import { chalk } from '../../utils/command-helpers.js' +import { netlifyFetch } from '../../utils/netlify-fetch.js' import { LOG_LEVELS } from './log-levels.js' @@ -95,7 +96,7 @@ const debugLog = (message: string) => { export const debugFetch = async (url: string, init?: RequestInit): Promise => { debugLog(`→ ${init?.method ?? 'GET'} ${url}`) const start = performance.now() - const response = await fetch(url, init) + const response = await netlifyFetch(url, init) const elapsed = (performance.now() - start).toFixed(0) debugLog(`← ${response.status.toString()} ${response.statusText} (${elapsed}ms)`) return response diff --git a/src/lib/geo-location.ts b/src/lib/geo-location.ts index 875925ec544..16529b12b37 100644 --- a/src/lib/geo-location.ts +++ b/src/lib/geo-location.ts @@ -1,6 +1,7 @@ -import fetch from 'node-fetch' import { type Geolocation, mockLocation } from '@netlify/dev-utils' +import { netlifyFetch } from '../utils/netlify-fetch.js' + const API_URL = 'https://netlifind.netlify.app' const STATE_GEO_PROPERTY = 'geolocation' // 24 hours @@ -93,7 +94,7 @@ export const getGeoLocation = async ({ * Returns geolocation data from a remote API. */ const getGeoLocationFromAPI = async (): Promise => { - const res = await fetch(API_URL, { + const res = await netlifyFetch(API_URL, { method: 'GET', signal: AbortSignal.timeout(REQUEST_TIMEOUT), }) diff --git a/src/recipes/ai-context/context.ts b/src/recipes/ai-context/context.ts index 5e85eb9c08e..86911324617 100644 --- a/src/recipes/ai-context/context.ts +++ b/src/recipes/ai-context/context.ts @@ -1,7 +1,8 @@ import { promises as fs } from 'node:fs' import { dirname, resolve } from 'node:path' import semver from 'semver' -import { chalk, logAndThrowError, log, version } from '../../utils/command-helpers.js' +import { chalk, log, version } from '../../utils/command-helpers.js' +import { netlifyFetch } from '../../utils/netlify-fetch.js' import type { RunRecipeOptions } from '../../commands/recipes/recipes.js' const ATTRIBUTES_REGEX = /(\S*)="([^\s"]*)"/gim @@ -41,16 +42,12 @@ export interface ConsumerConfig { } let contextConsumers: ConsumerConfig[] = [] -export const getContextConsumers = async (cliVersion: string) => { +export const getContextConsumers = async () => { if (contextConsumers.length > 0) { return contextConsumers } try { - const res = await fetch(`${BASE_URL}/context-consumers`, { - headers: { - 'user-agent': `NetlifyCLI ${cliVersion}`, - }, - }) + const res = await netlifyFetch(`${BASE_URL}/context-consumers`) if (!res.ok) { return [] @@ -63,7 +60,7 @@ export const getContextConsumers = async (cliVersion: string) => { return contextConsumers } -export const downloadFile = async (cliVersion: string, contextConfig: ContextConfig, consumer: ConsumerConfig) => { +export const downloadFile = async (contextConfig: ContextConfig, consumer: ConsumerConfig) => { try { if (!contextConfig.endpoint) { return null @@ -79,11 +76,7 @@ export const downloadFile = async (cliVersion: string, contextConfig: ContextCon url.protocol = overridingUrl.protocol } - const res = await fetch(url, { - headers: { - 'user-agent': `NetlifyCLI ${cliVersion}`, - }, - }) + const res = await netlifyFetch(url) if (!res.ok) { return null @@ -221,21 +214,24 @@ export const deleteFile = async (path: string) => { } } -export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { command }: RunRecipeOptions) => { - await Promise.allSettled( +export const downloadAndWriteContextFiles = async ( + consumer: ConsumerConfig, + { command }: RunRecipeOptions, +): Promise => { + const results = await Promise.allSettled( Object.keys(consumer.contextScopes).map(async (contextKey) => { const contextConfig = consumer.contextScopes[contextKey] const { contents: downloadedFile, minimumCLIVersion } = - (await downloadFile(version, contextConfig, consumer).catch(() => null)) ?? {} + (await downloadFile(contextConfig, consumer).catch(() => null)) ?? {} if (!downloadedFile) { - return logAndThrowError( + throw new Error( `An error occurred when pulling the latest context file for scope ${contextConfig.scope}. Please try again.`, ) } if (minimumCLIVersion && semver.lt(version, minimumCLIVersion)) { - return logAndThrowError( + throw new Error( `This command requires version ${minimumCLIVersion} or above of the Netlify CLI. Refer to ${chalk.underline( 'https://ntl.fyi/update-cli', )} for information on how to update.`, @@ -264,7 +260,7 @@ export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { c absoluteFilePath, )} contains the latest version of the context files.`, ) - return + return false } // We must preserve any overrides found in the existing file. @@ -289,6 +285,14 @@ export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { c await writeFile(absoluteFilePath, contents) log(`${existing ? 'Updated' : 'Created'} context files at ${chalk.underline(absoluteFilePath)}`) + return true }), ) + + const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure) { + throw failure.reason + } + + return results.some((result) => result.status === 'fulfilled' && result.value) } diff --git a/src/recipes/ai-context/index.ts b/src/recipes/ai-context/index.ts index 8063062075d..ffdde99d349 100644 --- a/src/recipes/ai-context/index.ts +++ b/src/recipes/ai-context/index.ts @@ -4,7 +4,8 @@ import inquirer from 'inquirer' import execa from 'execa' import type { RunRecipeOptions } from '../../commands/recipes/recipes.js' -import { logAndThrowError, log, version } from '../../utils/command-helpers.js' +import { logAndThrowError, log } from '../../utils/command-helpers.js' +import { track } from '../../utils/telemetry/index.js' import { getExistingContext, @@ -20,7 +21,7 @@ export const description = 'Manage context files for AI tools' // context consumers endpoints returns all supported IDE and other consumers // that can be used to pull context files. It also includes a catchall consumer // for outlining all context that an unspecified consumer would handle. -const allContextConsumers = await getContextConsumers(version) +const allContextConsumers = await getContextConsumers() const cliContextConsumers = allContextConsumers.filter((consumer) => !consumer.hideFromCLI) const rulesForDefaultConsumer = allContextConsumers.find((consumer) => consumer.key === 'catchall-consumer') ?? { @@ -156,8 +157,9 @@ export const run = async (runOptions: RunRecipeOptions) => { return } + let wroteFiles = false try { - await downloadAndWriteContextFiles(consumer, runOptions) + wroteFiles = await downloadAndWriteContextFiles(consumer, runOptions) // the deprecated MCP file path // let's remove that file if it exists. @@ -171,4 +173,8 @@ export const run = async (runOptions: RunRecipeOptions) => { } catch (error) { logAndThrowError(error) } + + if (wroteFiles) { + await track('sites_aiContextInstalled', { consumer: consumer.key }) + } } diff --git a/src/recipes/blobs-migrate/index.ts b/src/recipes/blobs-migrate/index.ts index 0b7c6243bc5..e54c1bbdad5 100644 --- a/src/recipes/blobs-migrate/index.ts +++ b/src/recipes/blobs-migrate/index.ts @@ -4,6 +4,7 @@ import pMap from 'p-map' import BaseCommand from '../../commands/base-command.js' import { logAndThrowError, log } from '../../utils/command-helpers.js' +import { netlifyFetchForOrigin } from '../../utils/netlify-fetch.js' export const description = 'Migrate legacy Netlify Blobs stores' @@ -21,8 +22,10 @@ export const run = async ({ args, command }: Options) => { const [storeName] = args const { api, siteInfo } = command.netlify + const apiURL = `${api.scheme}://${api.host}` const clientOptions = { - apiURL: `${api.scheme}://${api.host}`, + apiURL, + fetch: netlifyFetchForOrigin(apiURL), siteID: siteInfo.id, token: api.accessToken ?? '', } diff --git a/src/utils/agent-detection.ts b/src/utils/agent-detection.ts new file mode 100644 index 00000000000..c585d0f5dfd --- /dev/null +++ b/src/utils/agent-detection.ts @@ -0,0 +1,195 @@ +// Only markers an agent product sets on its own count as a signal; never infer from process names, terminals, or the process tree. +// The result is untrusted attribution from the environment, for telemetry and labeling only, never for authorization. + +export const CANONICAL_AGENT_NAMES = [ + 'claude', + 'codex', + 'copilot', + 'gemini', + 'cursor', + 'opencode', + 'kiro', + 'cline', + 'amp', + 'warp', + 'claudeai', + 'chatgpt', + 'other', +] as const + +export type CanonicalAgentName = (typeof CANONICAL_AGENT_NAMES)[number] + +export type DrivingAgent = { + name: CanonicalAgentName + source: string + version?: string + markers?: CanonicalAgentName[] + otherValue?: string +} + +const ANNOUNCED_NAME_TABLE = new Map([ + ...CANONICAL_AGENT_NAMES.map((name) => [name, name] as const), + ['claude-code', 'claude'], + ['claude-ai', 'claudeai'], + ['github-copilot', 'copilot'], + ['github-copilot-cli', 'copilot'], + ['github-copilot-vscode-agent', 'copilot'], + ['cursor-cli', 'cursor'], + ['gemini-cli', 'gemini'], + ['kiro-cli', 'kiro'], + ['warp-oz', 'warp'], +]) + +const lookupAnnouncedName = (key: string): CanonicalAgentName | undefined => + ANNOUNCED_NAME_TABLE.get(key.replace(/_/g, '-')) + +type ParsedAnnouncedName = { + name: CanonicalAgentName + version?: string + otherValue?: string +} + +const sanitizeAnnouncedValue = (raw: string): string => raw.replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 64) + +const nonEmpty = (value: string | undefined): string | undefined => (value ? value : undefined) + +const parseAnnouncedName = (raw: string): ParsedAnnouncedName | undefined => { + const atIndex = raw.indexOf('@') + const sanitized = sanitizeAnnouncedValue(atIndex === -1 ? raw : raw.slice(0, atIndex)) + if (sanitized === '') { + return undefined + } + + const announcedVersion = atIndex === -1 ? undefined : nonEmpty(sanitizeAnnouncedValue(raw.slice(atIndex + 1))) + const key = sanitized.toLowerCase() + + const exact = lookupAnnouncedName(key) + if (exact) { + return { name: exact, version: announcedVersion } + } + + const withoutAgentSuffix = key.replace(/_agent$/, '') + const suffixMatch = lookupAnnouncedName(withoutAgentSuffix) + if (suffixMatch) { + return { name: suffixMatch, version: announcedVersion } + } + + const lastUnderscore = withoutAgentSuffix.lastIndexOf('_') + if (lastUnderscore !== -1) { + const head = withoutAgentSuffix.slice(0, lastUnderscore) + const headMatch = lookupAnnouncedName(head) + if (headMatch) { + const tail = withoutAgentSuffix.slice(lastUnderscore + 1) + return { name: headMatch, version: announcedVersion ?? tail.replace(/-/g, '.') } + } + } + + return { name: 'other', otherValue: sanitized, version: announcedVersion } +} + +type Signal = { + source: string + detect: (env: NodeJS.ProcessEnv) => ParsedAnnouncedName | undefined +} + +// Precedence, first match wins: NETLIFY_AGENT (explicit, even when unknown); markers only the process +// running the command sets; AI_AGENT (explicit, even when unknown); markers inherited from an agent +// session; runner/task markers such as Warp's last, since the agent inside the run is the more specific answer. +const SIGNALS: Signal[] = [ + { + source: 'NETLIFY_AGENT', + detect: (env) => (env.NETLIFY_AGENT === undefined ? undefined : parseAnnouncedName(env.NETLIFY_AGENT)), + }, + { + source: 'CODEX_CI', + detect: (env) => (env.CODEX_CI === '1' ? { name: 'codex' } : undefined), + }, + { + source: 'GEMINI_CLI', + detect: (env) => (env.GEMINI_CLI === '1' ? { name: 'gemini' } : undefined), + }, + { + source: 'COPILOT_CLI', + detect: (env) => (env.COPILOT_CLI === '1' ? { name: 'copilot' } : undefined), + }, + { + source: 'COPILOT_AGENT_SESSION_ID', + detect: (env) => (nonEmpty(env.COPILOT_AGENT_SESSION_ID) === undefined ? undefined : { name: 'copilot' }), + }, + { + source: 'OPENCODE', + detect: (env) => + env.OPENCODE === '1' && nonEmpty(env.OPENCODE_TERMINAL) === undefined ? { name: 'opencode' } : undefined, + }, + { + source: 'AGENT_DISPLAY_OUT', + detect: (env) => + nonEmpty(env.AGENT_DISPLAY_OUT) !== undefined && nonEmpty(env.AGENT_CONTEXT_OUT) !== undefined + ? { name: 'kiro' } + : undefined, + }, + { + source: 'AI_AGENT', + detect: (env) => (env.AI_AGENT === undefined ? undefined : parseAnnouncedName(env.AI_AGENT)), + }, + { + source: 'COPILOT_AGENT', + detect: (env) => (env.COPILOT_AGENT === '1' ? { name: 'copilot' } : undefined), + }, + { + source: 'CURSOR_AGENT', + detect: (env) => (env.CURSOR_AGENT === '1' ? { name: 'cursor' } : undefined), + }, + { + source: 'CLINE_ACTIVE', + detect: (env) => (env.CLINE_ACTIVE === 'true' ? { name: 'cline' } : undefined), + }, + { + source: 'AGENT', + detect: (env) => (env.AGENT === 'amp' ? { name: 'amp' } : undefined), + }, + { + source: 'CLAUDE_CODE_CHILD_SESSION', + detect: (env) => (env.CLAUDE_CODE_CHILD_SESSION === '1' ? { name: 'claude' } : undefined), + }, + { + source: 'OZ_RUN_ID', + detect: (env) => (nonEmpty(env.OZ_RUN_ID) === undefined ? undefined : { name: 'warp' }), + }, + { + source: 'WARP_RUN_ID', + detect: (env) => (nonEmpty(env.WARP_RUN_ID) === undefined ? undefined : { name: 'warp' }), + }, +] + +type SignalMatch = { source: string } & ParsedAnnouncedName + +export const getDrivingAgent = (env: NodeJS.ProcessEnv = process.env): DrivingAgent | undefined => { + const matches: SignalMatch[] = [] + + for (const signal of SIGNALS) { + const result = signal.detect(env) + if (result) { + matches.push({ source: signal.source, ...result }) + } + } + + if (matches.length === 0) { + return undefined + } + + const [winner] = matches + + const codexVersion = winner.source === 'CODEX_CI' ? nonEmpty(env.CODEX_VERSION) : undefined + const version = winner.version ?? (codexVersion === undefined ? undefined : sanitizeAnnouncedValue(codexVersion)) + + const distinctNames = [...new Set(matches.map((match) => match.name))] + + return { + name: winner.name, + source: winner.source, + ...(version ? { version } : {}), + ...(distinctNames.length >= 2 ? { markers: distinctNames } : {}), + ...(winner.name === 'other' ? { otherValue: winner.otherValue ?? '' } : {}), + } +} diff --git a/src/utils/command-helpers.ts b/src/utils/command-helpers.ts index 0775c79f230..73d1b83d685 100644 --- a/src/utils/command-helpers.ts +++ b/src/utils/command-helpers.ts @@ -1,4 +1,3 @@ -import os from 'os' import fs from 'fs' import process from 'process' import { format, inspect } from 'util' @@ -7,7 +6,6 @@ import type { NetlifyAPI } from '@netlify/api' import { getAPIToken } from '@netlify/dev-utils' import { Chalk, type ChalkInstance as ChalkInstancePrimitiveType } from 'chalk' import type { Option } from 'commander' -import WSL from 'is-wsl' import terminalLink from 'terminal-link' import { startSpinner } from '../lib/spinner.js' @@ -46,13 +44,10 @@ export type ChalkInstance = ChalkInstancePrimitiveType */ export const padLeft = (str: string, count: number, filler = ' ') => str.padStart(str.length + count, filler) -const platform = WSL ? 'wsl' : os.platform() -const arch = os.arch() === 'ia32' ? 'x86' : os.arch() - -const { name, version: packageVersion } = await getCLIPackageJson() +const { version: packageVersion } = await getCLIPackageJson() export const version = packageVersion -export const USER_AGENT = `${name}/${version} ${platform}-${arch} node-${process.version}` +export { USER_AGENT } from './user-agent.js' /** A list of base command flags that needs to be sorted down on documentation and on help pages */ const BASE_FLAGS = new Set(['--debug', '--http-proxy', '--http-proxy-certificate-filename']) diff --git a/src/utils/deploy/drop-api.ts b/src/utils/deploy/drop-api.ts index 1a8ae9dc8e6..2e5b59becd4 100644 --- a/src/utils/deploy/drop-api.ts +++ b/src/utils/deploy/drop-api.ts @@ -5,6 +5,8 @@ import fs from 'fs' import pWaitFor from 'p-wait-for' +import { netlifyFetch } from '../netlify-fetch.js' + import { DEPLOY_POLL, DEFAULT_DEPLOY_TIMEOUT, DEFAULT_CONCURRENT_UPLOAD, DEFAULT_MAX_RETRY } from './constants.js' import type { StatusCallback } from './status-cb.js' @@ -21,24 +23,22 @@ interface DropDeployInfo { interface DropApiOptions { apiBase: string - userAgent: string } export interface DropApiError extends Error { status?: number } -const makeHeaders = (userAgent: string, extra: Record = {}): Record => ({ - 'User-Agent': userAgent, +const makeHeaders = (extra: Record = {}): Record => ({ Referer: APP_NETLIFY_REFERRER, ...extra, }) // TODO: Migrate to @netlify/api when Drop endpoints are in the OpenAPI spec. -export const getDropToken = async ({ apiBase, userAgent }: DropApiOptions): Promise => { - const response = await fetch(`${apiBase}/drop/token`, { +export const getDropToken = async ({ apiBase }: DropApiOptions): Promise => { + const response = await netlifyFetch(`${apiBase}/drop/token`, { method: 'POST', - headers: makeHeaders(userAgent, { 'Content-Type': 'application/json' }), + headers: makeHeaders({ 'Content-Type': 'application/json' }), }) if (!response.ok) { @@ -53,7 +53,7 @@ export const getDropToken = async ({ apiBase, userAgent }: DropApiOptions): Prom // TODO: Migrate to @netlify/api when Drop endpoints are in the OpenAPI spec. export const createDropDeploy = async ( - { apiBase, userAgent }: DropApiOptions, + { apiBase }: DropApiOptions, files: Record, token: string, createdVia?: string, @@ -63,9 +63,9 @@ export const createDropDeploy = async ( body.created_via = createdVia } - const response = await fetch(`${apiBase}/drop`, { + const response = await netlifyFetch(`${apiBase}/drop`, { method: 'POST', - headers: makeHeaders(userAgent, { 'Content-Type': 'application/json' }), + headers: makeHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(body), }) @@ -85,7 +85,7 @@ interface UploadError extends Error { // TODO: Migrate to @netlify/api when Drop endpoints are in the OpenAPI spec. export const uploadDropFile = async ( - { apiBase, userAgent }: DropApiOptions, + { apiBase }: DropApiOptions, deployId: string, filePath: string, body: fs.ReadStream | Buffer, @@ -94,15 +94,18 @@ export const uploadDropFile = async ( // Node.js fetch needs `duplex: 'half'` for streaming bodies which isn't in standard RequestInit /* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}` - const response: Response = await fetch(`${apiBase}/deploys/${deployId}/files${encodeURI(normalizedFilePath)}`, { - method: 'PUT', - headers: makeHeaders(userAgent, { - 'Content-Type': 'application/octet-stream', - Authorization: `Bearer ${token}`, - }), - body: body as any, - duplex: 'half', - } as any) + const response: Response = await netlifyFetch( + `${apiBase}/deploys/${deployId}/files${encodeURI(normalizedFilePath)}`, + { + method: 'PUT', + headers: makeHeaders({ + 'Content-Type': 'application/octet-stream', + Authorization: `Bearer ${token}`, + }), + body: body as any, + duplex: 'half', + } as any, + ) /* eslint-enable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ if (!response.ok) { @@ -116,7 +119,7 @@ export const uploadDropFile = async ( // TODO: Migrate to @netlify/api when Drop endpoints are in the OpenAPI spec. export const waitForDropDeploy = async ( - { apiBase, userAgent }: DropApiOptions, + { apiBase }: DropApiOptions, siteId: string, deployId: string, timeout: number = DEFAULT_DEPLOY_TIMEOUT, @@ -124,8 +127,8 @@ export const waitForDropDeploy = async ( let deploy: Record | undefined const checkDeploy = async (): Promise => { - const response = await fetch(`${apiBase}/sites/${siteId}/deploys/${deployId}`, { - headers: makeHeaders(userAgent), + const response = await netlifyFetch(`${apiBase}/sites/${siteId}/deploys/${deployId}`, { + headers: makeHeaders(), }) if (!response.ok) { @@ -158,14 +161,14 @@ export const waitForDropDeploy = async ( // TODO: Migrate to @netlify/api when Drop endpoints are in the OpenAPI spec. export const claimDropSite = async ( - { apiBase, userAgent }: DropApiOptions, + { apiBase }: DropApiOptions, siteId: string, dropToken: string, authToken: string, ): Promise => { - const response = await fetch(`${apiBase}/drop/claim`, { + const response = await netlifyFetch(`${apiBase}/drop/claim`, { method: 'POST', - headers: makeHeaders(userAgent, { + headers: makeHeaders({ 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }), diff --git a/src/utils/live-tunnel.ts b/src/utils/live-tunnel.ts index e0139d5ebc8..18ddf86f19d 100644 --- a/src/utils/live-tunnel.ts +++ b/src/utils/live-tunnel.ts @@ -8,6 +8,7 @@ import { getPathInHome } from '../lib/settings.js' import { NETLIFYDEVERR, NETLIFYDEVLOG, chalk, exit, log } from './command-helpers.js' import execa from './execa.js' +import { netlifyFetch } from './netlify-fetch.js' import type { LocalState } from './types.js' const PACKAGE_NAME = 'live-tunnel-client' @@ -37,7 +38,7 @@ const createTunnel = async function ({ await installTunnelClient() const url = `https://api.netlify.com/api/v1/live_sessions?site_id=${siteId}&slug=${slug}` - const response = await fetch(url, { + const response = await netlifyFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -141,7 +142,7 @@ export const startLiveTunnel = async ({ const isLiveTunnelReady = async (): Promise => { const url = `https://api.netlify.com/api/v1/live_sessions/${session.id}` - const response = await fetch(url, { + const response = await netlifyFetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', diff --git a/src/utils/netlify-fetch.ts b/src/utils/netlify-fetch.ts new file mode 100644 index 00000000000..6890ccf6b88 --- /dev/null +++ b/src/utils/netlify-fetch.ts @@ -0,0 +1,19 @@ +import { getRequestUserAgent } from './user-agent.js' + +type FetchInput = Parameters[0] + +const withUserAgent = (input: FetchInput, init?: RequestInit): RequestInit => { + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)) + headers.set('User-Agent', getRequestUserAgent()) + return { ...init, headers } +} + +export const netlifyFetch: typeof fetch = (input, init) => fetch(input, withUserAgent(input, init)) + +export const netlifyFetchForOrigin = (origin: string): typeof fetch => { + const netlifyOrigin = new URL(origin).origin + return (input, init) => { + const { origin: requestOrigin } = new URL(input instanceof Request ? input.url : input) + return requestOrigin === netlifyOrigin ? netlifyFetch(input, init) : fetch(input, init) + } +} diff --git a/src/utils/telemetry/request.ts b/src/utils/telemetry/request.ts index e3c2bfb2b83..fdd8568470e 100644 --- a/src/utils/telemetry/request.ts +++ b/src/utils/telemetry/request.ts @@ -4,9 +4,7 @@ import process from 'process' import fetch from 'node-fetch' -import getPackageJson from '../get-cli-package-json.js' - -const { name, version } = await getPackageJson() +import { getRequestUserAgent } from '../user-agent.js' const options = JSON.parse(process.argv[2]) @@ -34,7 +32,7 @@ const makeRequest = async function () { headers: { 'Content-Type': 'application/json', 'X-Netlify-Client': CLIENT_ID, - 'User-Agent': `${name}/${version}`, + 'User-Agent': getRequestUserAgent(), }, body: JSON.stringify(options.data), }) diff --git a/src/utils/user-agent.ts b/src/utils/user-agent.ts new file mode 100644 index 00000000000..a2da5078de8 --- /dev/null +++ b/src/utils/user-agent.ts @@ -0,0 +1,19 @@ +import os from 'os' +import process from 'process' + +import WSL from 'is-wsl' + +import { getDrivingAgent } from './agent-detection.js' +import getCLIPackageJson from './get-cli-package-json.js' + +const platform = WSL ? 'wsl' : os.platform() +const arch = os.arch() === 'ia32' ? 'x86' : os.arch() + +const { name, version } = await getCLIPackageJson() + +export const USER_AGENT = `${name}/${version} ${platform}-${arch} node-${process.version}` + +export const getRequestUserAgent = (env: NodeJS.ProcessEnv = process.env): string => { + const agent = getDrivingAgent(env) + return agent ? `${USER_AGENT} agent/${agent.name}` : USER_AGENT +} diff --git a/src/utils/websockets/index.ts b/src/utils/websockets/index.ts index 7d9837538c7..7950b201beb 100644 --- a/src/utils/websockets/index.ts +++ b/src/utils/websockets/index.ts @@ -1,3 +1,5 @@ import WebSocket from 'ws' -export const getWebSocket = (url: string) => new WebSocket(url) +import { getRequestUserAgent } from '../user-agent.js' + +export const getWebSocket = (url: string) => new WebSocket(url, { headers: { 'User-Agent': getRequestUserAgent() } }) diff --git a/tests/integration/telemetry.test.ts b/tests/integration/telemetry.test.ts index 6e6ab698781..ea0bc9a1d5b 100644 --- a/tests/integration/telemetry.test.ts +++ b/tests/integration/telemetry.test.ts @@ -5,6 +5,8 @@ import type { Options } from 'execa' import execa from 'execa' import { expect, test } from 'vitest' +import { USER_AGENT } from '../../src/utils/user-agent.js' + import { callCli } from './utils/call-cli.js' import { cliPath } from './utils/cli-path.js' import { MockApiTestContext, withMockApi } from './utils/mock-api-vitest.js' @@ -42,7 +44,7 @@ await withMockApi(routes, () => { expect(requests.length).toBe(1) expect(requests[0].method).toBe('POST') expect(requests[0].path).toBe('/api/v1/track') - expect(requests[0].headers['user-agent']).toBe(`${pkg.name}/${pkg.version}`) + expect(requests[0].headers['user-agent']).toBe(USER_AGENT) expect(requests[0].body).toHaveProperty('event', 'cli:user_telemetryEnabled') expect(requests[0].body).toHaveProperty('anonymousId', expect.any(String)) expect(requests[0].body).toHaveProperty('properties', { cliVersion: pkg.version, nodejsVersion }) diff --git a/tests/unit/commands/database/db-migration-pull.test.ts b/tests/unit/commands/database/db-migration-pull.test.ts index 2966b2f4f77..6ef28b8cfe5 100644 --- a/tests/unit/commands/database/db-migration-pull.test.ts +++ b/tests/unit/commands/database/db-migration-pull.test.ts @@ -50,6 +50,8 @@ import { resolve } from 'path' import inquirer from 'inquirer' import { migrationPull } from '../../../../src/commands/database/db-migration-pull.js' +const authorizationHeaderOf = (call: unknown[]) => new Headers((call[1] as RequestInit).headers).get('Authorization') + interface SampleMigration { version: number name: string @@ -156,7 +158,7 @@ describe('migrationPull', () => { expect(calledUrl.toString()).toBe( 'https://api.netlify.com/api/v1/sites/site-123/database/migrations?branch=production', ) - expect(mockFetch.mock.calls[0][1]).toEqual({ headers: { Authorization: 'Bearer test-token' } }) + expect(authorizationHeaderOf(mockFetch.mock.calls[0])).toBe('Bearer test-token') }) test('fetches content for each migration from the detail endpoint', async () => { @@ -174,7 +176,7 @@ describe('migrationPull', () => { 'https://api.netlify.com/api/v1/sites/site-123/database/migrations/0002_add-posts?branch=production', ]) for (const call of mockFetch.mock.calls) { - expect(call[1]).toEqual({ headers: { Authorization: 'Bearer test-token' } }) + expect(authorizationHeaderOf(call)).toBe('Bearer test-token') } }) diff --git a/tests/unit/recipes/ai-context/download-context-files.test.ts b/tests/unit/recipes/ai-context/download-context-files.test.ts index af1bd102e7c..4a1a01a9f34 100644 --- a/tests/unit/recipes/ai-context/download-context-files.test.ts +++ b/tests/unit/recipes/ai-context/download-context-files.test.ts @@ -98,7 +98,7 @@ describe('downloadAndWriteContextFiles', () => { test('downloads and writes context files for all scopes', async () => { // Execute the actual function - await downloadAndWriteContextFiles(mockConsumer, mockRunOptions) + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBe(true) // Verify expected calls expect(mockFetch).toHaveBeenCalledTimes(2) // Once for each scope @@ -124,12 +124,19 @@ describe('downloadAndWriteContextFiles', () => { fs.readFile.mockResolvedValue(mockProviderContent) // Execute the actual function - await downloadAndWriteContextFiles(mockConsumer, mockRunOptions) + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBe(false) // Verify expected behavior - no writes when versions match expect(fs.writeFile).not.toHaveBeenCalled() }) + test('reports no writes when the consumer has no context scopes', async () => { + await expect(downloadAndWriteContextFiles({ ...mockConsumer, contextScopes: {} }, mockRunOptions)).resolves.toBe( + false, + ) + expect(fs.writeFile).not.toHaveBeenCalled() + }) + test('applies overrides when updating existing Netlify files', async () => { // Mock existing file with different version const existingContent = @@ -199,22 +206,25 @@ describe('downloadAndWriteContextFiles', () => { ) }) - test('handles download errors gracefully', async () => { + test('rejects when a context file cannot be downloaded', async () => { // Mock fetch to return not ok // @ts-expect-error mocking is not 100% consistent with full API and types for fetch.mockResolvedValue({ ok: false, }) - // Execute the actual function and expect error - await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined() + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).rejects.toThrow( + 'An error occurred when pulling the latest context file', + ) + expect(fs.writeFile).not.toHaveBeenCalled() }) - test('checks CLI version compatibility', async () => { + test('rejects when the CLI is older than the minimum version', async () => { // Set higher minimum CLI version // @ts-expect-error mocking is not 100% consistent with full API and types for fetch.mockResolvedValue({ ok: true, + text: () => Promise.resolve(mockProviderContent), headers: { get: (header: string) => { if (header === 'x-cli-min-ver') return '2.0.0' // Higher than the mocked current version @@ -223,7 +233,9 @@ describe('downloadAndWriteContextFiles', () => { }, }) - // Execute the actual function and expect error - await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined() + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).rejects.toThrow( + 'This command requires version 2.0.0', + ) + expect(fs.writeFile).not.toHaveBeenCalled() }) }) diff --git a/tests/unit/recipes/ai-context/index.test.ts b/tests/unit/recipes/ai-context/index.test.ts new file mode 100644 index 00000000000..2c629978b12 --- /dev/null +++ b/tests/unit/recipes/ai-context/index.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import type { RunRecipeOptions } from '../../../../src/commands/recipes/recipes.js' + +const { cursorConsumer } = vi.hoisted(() => ({ + cursorConsumer: { + key: 'cursor', + presentedName: 'Cursor', + consumerProcessCmd: 'cursor', + path: './.cursor/rules', + ext: 'mdc', + contextScopes: { serverless: { scope: 'Serverless functions' } }, + }, +})) + +vi.mock('../../../../src/recipes/ai-context/context.js', () => ({ + NTL_DEV_MCP_FILE_NAME: 'netlify-development.mdc', + getContextConsumers: vi.fn().mockResolvedValue([cursorConsumer]), + downloadAndWriteContextFiles: vi.fn().mockResolvedValue(true), + getExistingContext: vi.fn().mockResolvedValue(null), + deleteFile: vi.fn(), +})) + +vi.mock('../../../../src/utils/command-helpers.js', () => ({ + log: vi.fn(), + logAndThrowError: vi.fn((error: unknown) => { + throw error + }), + version: '1.0.0', +})) + +vi.mock('../../../../src/utils/telemetry/index.js', () => ({ + track: vi.fn(), +})) + +vi.mock('inquirer', () => ({ + default: { prompt: vi.fn().mockResolvedValue({ consumerKey: 'cursor' }) }, +})) + +import { downloadAndWriteContextFiles } from '../../../../src/recipes/ai-context/context.js' +import { run } from '../../../../src/recipes/ai-context/index.js' +import { track } from '../../../../src/utils/telemetry/index.js' + +const runRecipe = () => run({ args: [], command: { workingDir: '/project' } } as unknown as RunRecipeOptions) + +beforeEach(() => { + vi.mocked(track).mockClear() + vi.stubEnv('AI_CONTEXT_SKIP_DETECTION', 'true') +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +test('tracks sites_aiContextInstalled with the consumer the context was installed for', async () => { + await runRecipe() + + expect(track).toHaveBeenCalledWith('sites_aiContextInstalled', { consumer: 'cursor' }) +}) + +test('does not track an install when every context file was already current', async () => { + vi.mocked(downloadAndWriteContextFiles).mockResolvedValueOnce(false) + + await runRecipe() + + expect(track).not.toHaveBeenCalled() +}) + +test('does not track an install when writing the context files fails', async () => { + vi.mocked(downloadAndWriteContextFiles).mockRejectedValueOnce(new Error('download failed')) + + await expect(runRecipe()).rejects.toThrow('download failed') + expect(track).not.toHaveBeenCalled() +}) diff --git a/tests/unit/utils/agent-detection.test.ts b/tests/unit/utils/agent-detection.test.ts new file mode 100644 index 00000000000..6b3ed84c52f --- /dev/null +++ b/tests/unit/utils/agent-detection.test.ts @@ -0,0 +1,357 @@ +import { expect, test, vi } from 'vitest' + +import { CANONICAL_AGENT_NAMES, getDrivingAgent } from '../../../src/utils/agent-detection.js' + +test('resolves NETLIFY_AGENT to the matching canonical name', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'codex' })).toEqual({ name: 'codex', source: 'NETLIFY_AGENT' }) +}) + +test('matches NETLIFY_AGENT case-insensitively', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'Claude-Code' })).toEqual({ name: 'claude', source: 'NETLIFY_AGENT' }) +}) + +test('keeps a version parsed from NETLIFY_AGENT', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'claude-code_2-1-263_agent' })).toEqual({ + name: 'claude', + source: 'NETLIFY_AGENT', + version: '2.1.263', + }) +}) + +test('preserves the original casing of an unknown value in otherValue', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'MyWrapper' })).toEqual({ + name: 'other', + source: 'NETLIFY_AGENT', + otherValue: 'MyWrapper', + }) +}) + +test('resolves CODEX_CI without CODEX_VERSION and omits version', () => { + expect(getDrivingAgent({ CODEX_CI: '1' })).toEqual({ name: 'codex', source: 'CODEX_CI' }) +}) + +test('adds version from CODEX_VERSION when CODEX_CI matches', () => { + expect(getDrivingAgent({ CODEX_CI: '1', CODEX_VERSION: '1.2.3' })).toEqual({ + name: 'codex', + source: 'CODEX_CI', + version: '1.2.3', + }) +}) + +test('resolves GEMINI_CLI', () => { + expect(getDrivingAgent({ GEMINI_CLI: '1' })).toEqual({ name: 'gemini', source: 'GEMINI_CLI' }) +}) + +test('resolves COPILOT_CLI', () => { + expect(getDrivingAgent({ COPILOT_CLI: '1' })).toEqual({ name: 'copilot', source: 'COPILOT_CLI' }) +}) + +test('resolves COPILOT_AGENT_SESSION_ID', () => { + expect(getDrivingAgent({ COPILOT_AGENT_SESSION_ID: 'session-123' })).toEqual({ + name: 'copilot', + source: 'COPILOT_AGENT_SESSION_ID', + }) +}) + +test('resolves OPENCODE when OPENCODE_TERMINAL is unset', () => { + expect(getDrivingAgent({ OPENCODE: '1' })).toEqual({ name: 'opencode', source: 'OPENCODE' }) +}) + +test('resolves AGENT_DISPLAY_OUT plus AGENT_CONTEXT_OUT to kiro without surfacing their values', () => { + expect( + getDrivingAgent({ + AGENT_DISPLAY_OUT: '/tmp/agent-display-output.json', + AGENT_CONTEXT_OUT: '/tmp/agent-context-output.json', + }), + ).toEqual({ name: 'kiro', source: 'AGENT_DISPLAY_OUT' }) +}) + +test('either Kiro variable alone matches nothing', () => { + expect(getDrivingAgent({ AGENT_DISPLAY_OUT: '/tmp/agent-display-output.json' })).toBeUndefined() + expect(getDrivingAgent({ AGENT_CONTEXT_OUT: '/tmp/agent-context-output.json' })).toBeUndefined() +}) + +test('resolves OZ_RUN_ID to warp without surfacing its value', () => { + expect(getDrivingAgent({ OZ_RUN_ID: 'run-123' })).toEqual({ name: 'warp', source: 'OZ_RUN_ID' }) +}) + +test('resolves WARP_RUN_ID to warp', () => { + expect(getDrivingAgent({ WARP_RUN_ID: 'run-123' })).toEqual({ name: 'warp', source: 'WARP_RUN_ID' }) +}) + +test('parses AI_AGENT claude-code_2-1-263_agent into claude with version 2.1.263', () => { + expect(getDrivingAgent({ AI_AGENT: 'claude-code_2-1-263_agent' })).toEqual({ + name: 'claude', + source: 'AI_AGENT', + version: '2.1.263', + }) +}) + +test.each([ + ['claude-code', 'claude'], + ['claude-ai', 'claudeai'], + ['github-copilot', 'copilot'], + ['github-copilot-cli', 'copilot'], + ['github_copilot_vscode_agent', 'copilot'], + ['cursor-cli', 'cursor'], + ['gemini-cli', 'gemini'], + ['gemini_cli', 'gemini'], + ['kiro-cli', 'kiro'], + ['warp-oz', 'warp'], + ['Claude_Code', 'claude'], +])('resolves the announced alias %s to %s', (alias, name) => { + expect(getDrivingAgent({ AI_AGENT: alias })).toEqual({ name, source: 'AI_AGENT' }) + expect(getDrivingAgent({ AI_AGENT: `${alias}@1.0` })).toEqual({ name, source: 'AI_AGENT', version: '1.0' }) +}) + +test('parses AI_AGENT github_copilot_vscode_agent into copilot without version', () => { + expect(getDrivingAgent({ AI_AGENT: 'github_copilot_vscode_agent' })).toEqual({ + name: 'copilot', + source: 'AI_AGENT', + }) +}) + +test('resolves COPILOT_AGENT', () => { + expect(getDrivingAgent({ COPILOT_AGENT: '1' })).toEqual({ name: 'copilot', source: 'COPILOT_AGENT' }) +}) + +test('resolves CURSOR_AGENT', () => { + expect(getDrivingAgent({ CURSOR_AGENT: '1' })).toEqual({ name: 'cursor', source: 'CURSOR_AGENT' }) +}) + +test('resolves CLINE_ACTIVE', () => { + expect(getDrivingAgent({ CLINE_ACTIVE: 'true' })).toEqual({ name: 'cline', source: 'CLINE_ACTIVE' }) +}) + +test('resolves AGENT=amp exactly', () => { + expect(getDrivingAgent({ AGENT: 'amp' })).toEqual({ name: 'amp', source: 'AGENT' }) +}) + +test('resolves CLAUDE_CODE_CHILD_SESSION', () => { + expect(getDrivingAgent({ CLAUDE_CODE_CHILD_SESSION: '1' })).toEqual({ + name: 'claude', + source: 'CLAUDE_CODE_CHILD_SESSION', + }) +}) + +test('a single match omits markers', () => { + const result = getDrivingAgent({ CURSOR_AGENT: '1' }) + expect(result).toEqual({ name: 'cursor', source: 'CURSOR_AGENT' }) + expect(result?.markers).toBeUndefined() +}) + +test('NETLIFY_AGENT overrides every other signal and lists all matched names as markers', () => { + expect( + getDrivingAgent({ + CODEX_CI: '1', + GEMINI_CLI: '1', + COPILOT_CLI: '1', + COPILOT_AGENT_SESSION_ID: 'session-123', + OPENCODE: '1', + AGENT_DISPLAY_OUT: '/tmp/agent-display-output.json', + AGENT_CONTEXT_OUT: '/tmp/agent-context-output.json', + OZ_RUN_ID: 'run-123', + WARP_RUN_ID: 'run-123', + AI_AGENT: 'claude-code_2-1-263_agent', + COPILOT_AGENT: '1', + CURSOR_AGENT: '1', + CLINE_ACTIVE: 'true', + AGENT: 'amp', + CLAUDE_CODE_CHILD_SESSION: '1', + NETLIFY_AGENT: 'chatgpt', + }), + ).toEqual({ + name: 'chatgpt', + source: 'NETLIFY_AGENT', + markers: ['chatgpt', 'codex', 'gemini', 'copilot', 'opencode', 'kiro', 'claude', 'cursor', 'cline', 'amp', 'warp'], + }) +}) + +test('an unknown NETLIFY_AGENT still overrides a recognized marker and keeps its raw value', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'windsurf', CODEX_CI: '1' })).toEqual({ + name: 'other', + source: 'NETLIFY_AGENT', + otherValue: 'windsurf', + markers: ['other', 'codex'], + }) +}) + +test('the agent inside a Warp run beats the Warp run marker', () => { + expect(getDrivingAgent({ OZ_RUN_ID: 'run-123', AI_AGENT: 'claude-code_2-1-263_agent' })).toEqual({ + name: 'claude', + source: 'AI_AGENT', + version: '2.1.263', + markers: ['claude', 'warp'], + }) +}) + +test('sanitizes and caps CODEX_VERSION', () => { + expect(getDrivingAgent({ CODEX_CI: '1', CODEX_VERSION: `1.2.3\r\nX-Injected: ${'9'.repeat(80)}` })).toEqual({ + name: 'codex', + source: 'CODEX_CI', + version: `1.2.3X-Injected${'9'.repeat(64 - '1.2.3X-Injected'.length)}`, + }) +}) + +test('nests AI_AGENT under a higher-priority CODEX_CI match', () => { + expect( + getDrivingAgent({ + AI_AGENT: 'claude-code_2-1-263_agent', + CODEX_CI: '1', + }), + ).toEqual({ + name: 'codex', + source: 'CODEX_CI', + markers: ['codex', 'claude'], + }) +}) + +test('unknown AI_AGENT resolves to other with otherValue', () => { + expect(getDrivingAgent({ AI_AGENT: 'some-new-tool_1-0_agent' })).toEqual({ + name: 'other', + source: 'AI_AGENT', + otherValue: 'some-new-tool_1-0_agent', + }) +}) + +test('an unknown AI_AGENT beats an inherited session marker and keeps its raw value', () => { + expect(getDrivingAgent({ AI_AGENT: 'windsurf@1.0', CURSOR_AGENT: '1' })).toEqual({ + name: 'other', + source: 'AI_AGENT', + version: '1.0', + otherValue: 'windsurf', + markers: ['other', 'cursor'], + }) +}) + +test('parses the name@version AI_AGENT convention', () => { + expect(getDrivingAgent({ AI_AGENT: 'codex@1.2.3' })).toEqual({ name: 'codex', source: 'AI_AGENT', version: '1.2.3' }) +}) + +test('an announced @version wins over an underscore-encoded one', () => { + expect(getDrivingAgent({ AI_AGENT: 'claude-code_2-1-263_agent@3.0.0' })).toEqual({ + name: 'claude', + source: 'AI_AGENT', + version: '3.0.0', + }) +}) + +test('a name with an empty @version omits version', () => { + expect(getDrivingAgent({ AI_AGENT: 'codex@' })).toEqual({ name: 'codex', source: 'AI_AGENT' }) +}) + +test('an override that sanitizes to nothing is treated as unset', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: ' ', CODEX_CI: '1' })).toEqual({ name: 'codex', source: 'CODEX_CI' }) + expect(getDrivingAgent({ NETLIFY_AGENT: '!!!' })).toBeUndefined() + expect(getDrivingAgent({ AI_AGENT: '@1.0' })).toBeUndefined() +}) + +test('an unknown, oversized NETLIFY_AGENT value is sanitized and capped at 64 characters', () => { + const raw = `${'x'.repeat(70)} disallowed/chars!!!` + const result = getDrivingAgent({ NETLIFY_AGENT: raw }) + + expect(result).toEqual({ + name: 'other', + source: 'NETLIFY_AGENT', + otherValue: 'x'.repeat(64), + }) + expect(result?.otherValue).toHaveLength(64) +}) + +test.each(['constructor', '__proto__', 'toString'])('%s does not resolve via the Object prototype chain', (key) => { + expect(getDrivingAgent({ NETLIFY_AGENT: key })).toEqual({ + name: 'other', + source: 'NETLIFY_AGENT', + otherValue: key, + }) + expect(getDrivingAgent({ AI_AGENT: key })).toEqual({ + name: 'other', + source: 'AI_AGENT', + otherValue: key, + }) +}) + +test('NETLIFY_AGENT=constructor_1-0_agent does not resolve constructor via the split-at-last-underscore path', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'constructor_1-0_agent' })).toEqual({ + name: 'other', + source: 'NETLIFY_AGENT', + otherValue: 'constructor_1-0_agent', + }) +}) + +test('AGENT=1 alone matches nothing', () => { + expect(getDrivingAgent({ AGENT: '1' })).toBeUndefined() +}) + +test('AGENT=true alone matches nothing', () => { + expect(getDrivingAgent({ AGENT: 'true' })).toBeUndefined() +}) + +test('OPENCODE with OPENCODE_TERMINAL set matches nothing', () => { + expect(getDrivingAgent({ OPENCODE: '1', OPENCODE_TERMINAL: '1' })).toBeUndefined() +}) + +test('an empty string value is treated as unset', () => { + expect(getDrivingAgent({ CODEX_CI: '' })).toBeUndefined() +}) + +test('an env of only ignored variables matches nothing', () => { + expect( + getDrivingAgent({ + CLAUDECODE: '1', + CURSOR_TRACE_ID: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + CURSOR_CLI: '1', + TERM_PROGRAM: 'iTerm.app', + ZED_TERM: 'true', + CODEX_SESSION_ID: 'sess_abc123', + CODEX_THREAD_ID: 'thread_xyz789', + AGENT_SESSION_ID: 'agent-session-001', + OR_APP_NAME: 'OpenRouter', + REPLIT_AGENT: '1', + }), + ).toBeUndefined() +}) + +test('an empty env matches nothing', () => { + expect(getDrivingAgent({})).toBeUndefined() +}) + +test('falls back to process.env when no argument is given', () => { + const signalKeys = [ + 'NETLIFY_AGENT', + 'CODEX_CI', + 'GEMINI_CLI', + 'COPILOT_CLI', + 'COPILOT_AGENT_SESSION_ID', + 'OPENCODE', + 'OPENCODE_TERMINAL', + 'AGENT_DISPLAY_OUT', + 'AGENT_CONTEXT_OUT', + 'OZ_RUN_ID', + 'WARP_RUN_ID', + 'AI_AGENT', + 'COPILOT_AGENT', + 'CURSOR_AGENT', + 'CLINE_ACTIVE', + 'AGENT', + 'CLAUDE_CODE_CHILD_SESSION', + ] + + try { + signalKeys.forEach((key) => vi.stubEnv(key, undefined)) + vi.stubEnv('GEMINI_CLI', '1') + + expect(getDrivingAgent()).toEqual({ name: 'gemini', source: 'GEMINI_CLI' }) + } finally { + vi.unstubAllEnvs() + } +}) + +test('CANONICAL_AGENT_NAMES has no duplicates and only lowercase letters', () => { + const uniqueNames = new Set(CANONICAL_AGENT_NAMES) + expect(uniqueNames.size).toBe(CANONICAL_AGENT_NAMES.length) + + CANONICAL_AGENT_NAMES.forEach((name) => { + expect(name).toMatch(/^[a-z]+$/) + }) +}) diff --git a/tests/unit/utils/live-tunnel.test.ts b/tests/unit/utils/live-tunnel.test.ts index 8c29a9bece4..161e1c29dc9 100644 --- a/tests/unit/utils/live-tunnel.test.ts +++ b/tests/unit/utils/live-tunnel.test.ts @@ -76,13 +76,10 @@ describe('startLiveTunnel', () => { await startLiveTunnel(TUNNEL_ARGS) - expect(vi.mocked(fetch)).toHaveBeenCalledWith( - 'https://api.netlify.com/api/v1/live_sessions?site_id=site-456&slug=test', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ Authorization: 'Bearer fake-token' }) as unknown, - }), - ) + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect(url).toBe('https://api.netlify.com/api/v1/live_sessions?site_id=site-456&slug=test') + expect(init?.method).toBe('POST') + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer fake-token') }) test('polls the session until it is online', async () => { diff --git a/tests/unit/utils/netlify-fetch.test.ts b/tests/unit/utils/netlify-fetch.test.ts new file mode 100644 index 00000000000..3a31214e80a --- /dev/null +++ b/tests/unit/utils/netlify-fetch.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import { netlifyFetch, netlifyFetchForOrigin } from '../../../src/utils/netlify-fetch.js' +import { USER_AGENT } from '../../../src/utils/user-agent.js' + +const fetchMock = vi.fn(() => Promise.resolve(new Response())) + +const sentHeaders = (callIndex: number) => new Headers(fetchMock.mock.calls[callIndex][1]?.headers) + +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + vi.stubEnv('NETLIFY_AGENT', 'claude') +}) + +afterEach(() => { + fetchMock.mockClear() + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +test("sets the agent User-Agent and keeps the caller's other headers", async () => { + await netlifyFetch('https://api.netlify.com/api/v1/sites', { + headers: { Authorization: 'Bearer token', 'user-agent': 'caller' }, + }) + + expect(sentHeaders(0).get('User-Agent')).toBe(`${USER_AGENT} agent/claude`) + expect(sentHeaders(0).get('Authorization')).toBe('Bearer token') +}) + +test('adds the User-Agent only to requests for the given origin', async () => { + const fetchForApi = netlifyFetchForOrigin('https://api.netlify.com') + const presignedUrl = 'https://bucket.s3.amazonaws.com/blob?X-Amz-Signature=abc' + + await fetchForApi('https://api.netlify.com/api/v1/blobs/site-id/store') + await fetchForApi(presignedUrl, { headers: { 'x-custom': '1' } }) + + expect(sentHeaders(0).get('User-Agent')).toBe(`${USER_AGENT} agent/claude`) + expect(fetchMock.mock.calls[1]).toEqual([presignedUrl, { headers: { 'x-custom': '1' } }]) +}) diff --git a/tests/unit/utils/user-agent.test.ts b/tests/unit/utils/user-agent.test.ts new file mode 100644 index 00000000000..e6ab15e0c2a --- /dev/null +++ b/tests/unit/utils/user-agent.test.ts @@ -0,0 +1,102 @@ +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import process from 'node:process' + +import { NetlifyAPI } from '@netlify/api' +import { afterEach, expect, test, vi } from 'vitest' + +import { getDropToken } from '../../../src/utils/deploy/drop-api.js' +import { netlifyFetch } from '../../../src/utils/netlify-fetch.js' +import { USER_AGENT, getRequestUserAgent } from '../../../src/utils/user-agent.js' +import { getWebSocket } from '../../../src/utils/websockets/index.js' + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +const captureUserAgent = async (sendRequest: (origin: string) => Promise) => { + let resolveUserAgent: (userAgent: string | undefined) => void = () => {} + const received = new Promise((resolve) => { + resolveUserAgent = resolve + }) + const server = createServer((req, res) => { + resolveUserAgent(req.headers['user-agent']) + res.setHeader('Content-Type', 'application/json') + res.end('{}') + }) + server.on('upgrade', (req, socket) => { + resolveUserAgent(req.headers['user-agent']) + socket.destroy() + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + + try { + await sendRequest(`http://127.0.0.1:${String(port)}`) + return await received + } finally { + server.closeAllConnections() + server.close() + } +} + +const sendViaApiClient = (origin: string) => + new NetlifyAPI('', { + userAgent: getRequestUserAgent(), + scheme: 'http', + host: new URL(origin).host, + pathPrefix: '/api/v1', + }).listSites() + +const sendViaDropApi = (origin: string) => getDropToken({ apiBase: origin }) + +const sendViaNetlifyFetch = (origin: string) => netlifyFetch(`${origin}/api/v1/sites`) + +const sendViaWebSocket = (origin: string) => + new Promise((resolve) => { + getWebSocket(origin.replace('http', 'ws')).on('error', () => { + resolve() + }) + }) + +const sendViaTelemetryRequest = async (origin: string) => { + const exited = new Promise((resolve) => { + vi.spyOn(process, 'exit').mockImplementation(() => { + resolve() + return undefined as never + }) + }) + vi.stubEnv('NETLIFY_TEST_TRACK_URL', `${origin}/track`) + const { argv } = process + process.argv = [...argv.slice(0, 2), JSON.stringify({ type: 'track', data: {} })] + + try { + await import('../../../src/utils/telemetry/request.js') + } finally { + process.argv = argv + } + await exited +} + +test('every Netlify request path sends the same User-Agent', async () => { + vi.stubEnv('NETLIFY_AGENT', 'claude') + + const userAgents = [ + await captureUserAgent(sendViaApiClient), + await captureUserAgent(sendViaDropApi), + await captureUserAgent(sendViaTelemetryRequest), + await captureUserAgent(sendViaNetlifyFetch), + await captureUserAgent(sendViaWebSocket), + ] + + expect(userAgents).toEqual(Array(5).fill(`${USER_AGENT} agent/claude`)) +}) + +test('appends only the agent name, without its version or source', () => { + expect(getRequestUserAgent({ AI_AGENT: 'claude-code@2.1.0' })).toBe(`${USER_AGENT} agent/claude`) +}) + +test('leaves the User-Agent unchanged when no agent is detected', () => { + expect(getRequestUserAgent({})).toBe(USER_AGENT) +})