diff --git a/lib/browser/__tests__/browser-layer.test.js b/lib/browser/__tests__/browser-layer.test.js index e64b9c07..022a20a9 100644 --- a/lib/browser/__tests__/browser-layer.test.js +++ b/lib/browser/__tests__/browser-layer.test.js @@ -106,3 +106,36 @@ describe('sendRequestXhr', () => { expect(send).rejects.toThrow(mockError); }); }); + +describe('createRequestXhr', () => { + afterEach(() => { + delete global.window; + }); + + test('skips the user-agent header, since browsers forbid script-set UA', () => { + const setRequestHeader = jest.fn(); + function FakeXhr() { + this.open = jest.fn(); + this.setRequestHeader = setRequestHeader; + } + global.window = { XMLHttpRequest: FakeXhr }; + + const request = { + method: 'GET', + url: () => 'mockOrigin/mockPath', + headers: { + 'user-agent': 'mapbox-sdk-js/0.16.3 agent/claude-code', + accept: 'application/json' + } + }; + + browserLayer.createRequestXhr(request); + + expect(setRequestHeader).not.toHaveBeenCalledWith( + 'user-agent', + expect.anything() + ); + expect(setRequestHeader).toHaveBeenCalledWith('accept', 'application/json'); + expect(setRequestHeader).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/browser/browser-layer.js b/lib/browser/browser-layer.js index d13123ae..2d6d5751 100644 --- a/lib/browser/browser-layer.js +++ b/lib/browser/browser-layer.js @@ -105,6 +105,15 @@ function createRequestXhr(request, accessToken) { var xhr = new window.XMLHttpRequest(); xhr.open(request.method, url); Object.keys(request.headers).forEach(function(key) { + // Browsers forbid script from setting User-Agent via XHR, so this SDK's + // default `user-agent` header (added in mapi-request.js) is a no-op here + // and skipped rather than risking an error in stricter XHR + // implementations. Browser agent tagging is deferred to a separate, + // not-yet-implemented mechanism (a dedicated X-Mapbox-Agent header, per + // the parent Agent Telemetry epic) rather than this SDK's User-Agent. + if (key === 'user-agent') { + return; + } xhr.setRequestHeader(key, request.headers[key]); }); return xhr; diff --git a/lib/classes/__tests__/mapi-request.test.js b/lib/classes/__tests__/mapi-request.test.js index 0e7a3f83..ee74e6f7 100644 --- a/lib/classes/__tests__/mapi-request.test.js +++ b/lib/classes/__tests__/mapi-request.test.js @@ -2,6 +2,17 @@ const MapiRequest = require('../mapi-request'); const tu = require('../../../test/test-utils'); +const getUserAgent = require('../../helpers/sdk-version'); + +const ORIGINAL_ENV = process.env; + +beforeEach(() => { + process.env = {}; +}); + +afterEach(() => { + process.env = ORIGINAL_ENV; +}); function createMockClient() { return { @@ -80,6 +91,60 @@ test('sets instance fields, all options', () => { }); }); +describe('MapiRequest user-agent', () => { + test('sets a base user-agent with no agent detected (negative control)', () => { + const client = createMockClient(); + const request = new MapiRequest(client, { + path: 'mockUrl', + method: 'MOCK_METHOD' + }); + expect(request.headers['user-agent']).toBe(getUserAgent()); + expect(request.headers['user-agent']).not.toMatch(/agent\//); + }); + + test('appends agent/ when a coding agent is detected', () => { + process.env.CLAUDECODE = '1'; + const client = createMockClient(); + const request = new MapiRequest(client, { + path: 'mockUrl', + method: 'MOCK_METHOD' + }); + expect(request.headers['user-agent']).toBe( + `${getUserAgent()} agent/claude-code` + ); + }); + + test('a caller-supplied User-Agent overrides the default with no duplicate key', () => { + const client = createMockClient(); + const request = new MapiRequest(client, { + path: 'mockUrl', + method: 'MOCK_METHOD', + headers: { 'User-Agent': 'custom-agent/1.0' } + }); + expect(request.headers['user-agent']).toBe('custom-agent/1.0'); + expect(request.headers).not.toHaveProperty('User-Agent'); + expect(Object.keys(request.headers)).toEqual(['user-agent']); + }); + + test('still sets a base user-agent, with no agent/ suffix, when process is unavailable (as in a browser bundle)', () => { + process.env.CLAUDECODE = '1'; + const originalProcess = global.process; + let request; + try { + global.process = undefined; + const client = createMockClient(); + request = new MapiRequest(client, { + path: 'mockUrl', + method: 'MOCK_METHOD' + }); + } finally { + global.process = originalProcess; + } + expect(request.headers['user-agent']).toBe(getUserAgent()); + expect(request.headers['user-agent']).not.toMatch(/agent\//); + }); +}); + describe('MapiRequest#send', () => { test('success', () => { const client = createMockClient(); diff --git a/lib/classes/mapi-request.js b/lib/classes/mapi-request.js index da2dd19a..597fa243 100644 --- a/lib/classes/mapi-request.js +++ b/lib/classes/mapi-request.js @@ -5,6 +5,8 @@ var xtend = require('xtend'); var EventEmitter = require('eventemitter3'); var urlUtils = require('../helpers/url-utils'); var constants = require('../constants'); +var getUserAgent = require('../helpers/sdk-version'); +var detectAgent = require('../helpers/agent-detect'); var requestId = 1; @@ -84,6 +86,13 @@ function MapiRequest(client, options) { defaultHeaders['content-type'] = 'application/json'; } + var userAgent = getUserAgent(); + var agent = detectAgent(); + if (agent) { + userAgent += ' agent/' + agent; + } + defaultHeaders['user-agent'] = userAgent; + var headersWithDefaults = xtend(defaultHeaders, options.headers); // Disallows duplicate header names of mixed case, diff --git a/lib/helpers/__tests__/agent-detect.test.js b/lib/helpers/__tests__/agent-detect.test.js new file mode 100644 index 00000000..b492a0cc --- /dev/null +++ b/lib/helpers/__tests__/agent-detect.test.js @@ -0,0 +1,167 @@ +'use strict'; + +const detectAgent = require('../agent-detect'); + +const ORIGINAL_ENV = process.env; + +beforeEach(() => { + process.env = {}; +}); + +afterEach(() => { + process.env = ORIGINAL_ENV; +}); + +test('no indicators returns null', () => { + expect(detectAgent()).toBeNull(); +}); + +test('harness var wins over AI_AGENT fallback, even when both are present', () => { + process.env.CLAUDECODE = '1'; + process.env.AI_AGENT = 'something-else'; + expect(detectAgent()).toBe('claude-code'); +}); + +test('codex and claude-code are distinct', () => { + process.env = { CODEX_THREAD_ID: 'abc' }; + expect(detectAgent()).toBe('codex'); + + process.env = { CLAUDECODE: '1' }; + expect(detectAgent()).toBe('claude-code'); + + process.env = { CLAUDE_CODE: '1' }; + expect(detectAgent()).toBe('claude-code'); +}); + +test('codex matches on any of its vars', () => { + process.env = { CODEX_SANDBOX: '1' }; + expect(detectAgent()).toBe('codex'); + + process.env = { CODEX_CI: '1' }; + expect(detectAgent()).toBe('codex'); +}); + +test('warp was dropped: TERM_PROGRAM is not a safe existence-only signal', () => { + // TERM_PROGRAM is set by most terminal emulators, not just Warp, so it's + // not on the allowlist at all now that presence is the only check. + process.env = { TERM_PROGRAM: 'WarpTerminal' }; + expect(detectAgent()).toBeNull(); + + process.env = { TERM_PROGRAM: 'iTerm.app' }; + expect(detectAgent()).toBeNull(); +}); + +test('vtcode matches on presence alone, regardless of value', () => { + process.env = { VTCODE: '1' }; + expect(detectAgent()).toBe('vtcode'); + + process.env = { VTCODE: '0' }; + expect(detectAgent()).toBe('vtcode'); + + process.env = { VTCODE: 'true' }; + expect(detectAgent()).toBe('vtcode'); +}); + +test('table order determines precedence among harness vars', () => { + process.env = { CURSOR_AGENT: '1', ANTIGRAVITY_AGENT: '1' }; + expect(detectAgent()).toBe('antigravity'); +}); + +test('github-copilot matches on any of its vars', () => { + process.env = { COPILOT_MODEL: 'gpt' }; + expect(detectAgent()).toBe('github-copilot'); + + process.env = { COPILOT_ALLOW_ALL: '1' }; + expect(detectAgent()).toBe('github-copilot'); + + process.env = { COPILOT_GITHUB_TOKEN: 'abc' }; + expect(detectAgent()).toBe('github-copilot'); +}); + +test('falls back to custom-agent when AI_AGENT is present, regardless of its value', () => { + process.env = { AI_AGENT: 'my-cool-tool' }; + expect(detectAgent()).toBe('custom-agent'); +}); + +test('falls back to custom-agent when AGENT is present and AI_AGENT does not match', () => { + process.env = { AGENT: 'my-cool-tool' }; + expect(detectAgent()).toBe('custom-agent'); +}); + +test('AI_AGENT takes precedence over AGENT in the fallback (table order), same result either way', () => { + process.env = { AI_AGENT: 'first', AGENT: 'second' }; + expect(detectAgent()).toBe('custom-agent'); +}); + +test('an env var set to an empty or whitespace value still counts as present - existence is all that matters', () => { + process.env = { AI_AGENT: '' }; + expect(detectAgent()).toBe('custom-agent'); + + process.env = { AI_AGENT: ' ' }; + expect(detectAgent()).toBe('custom-agent'); + + process.env = { CLAUDECODE: '' }; + expect(detectAgent()).toBe('claude-code'); + + process.env = { CLAUDECODE: ' ' }; + expect(detectAgent()).toBe('claude-code'); +}); + +test('the fallback value itself is never forwarded, even when it looks header-unsafe', () => { + process.env = { AI_AGENT: 'foo\nbar: injected' }; + expect(detectAgent()).toBe('custom-agent'); +}); + +// Single-var allowlist entries not already covered above by a more targeted +// test (precedence or multi-var-OR). +test.each([ + ['augment-cli', 'AUGMENT_AGENT'], + ['cline', 'CLINE_ACTIVE'], + ['cowork', 'CLAUDE_CODE_IS_COWORK'], + ['crush', 'CRUSH'], + ['gemini-cli', 'GEMINI_CLI'], + ['goose', 'GOOSE_TERMINAL'], + ['hermes-agent', 'HERMES_SESSION_ID'], + ['kilo-code', 'KILOCODE_FEATURE'], + ['kiro', 'AGENT_CONTEXT_OUT'], + ['openclaw', 'OPENCLAW_SHELL'], + ['opencode', 'OPENCODE_CLIENT'], + ['pi', 'PI_CODING_AGENT'], + ['replit', 'REPL_ID'], + ['trae', 'TRAE_AI_SHELL_ID'], + ['zed', 'ZED_TERM'], + ['cursor-cli', 'CURSOR_AGENT'], + ['cursor', 'CURSOR_TRACE_ID'] +])('detects %j from its env var %j in isolation', (agentId, envVar) => { + process.env = { [envVar]: '1' }; + expect(detectAgent()).toBe(agentId); +}); + +test('a throwing process.env (e.g. a permission-gated Proxy) is treated as no agent detected', () => { + Object.defineProperty(process, 'env', { + configurable: true, + get() { + throw new Error('permission denied'); + } + }); + try { + expect(detectAgent()).toBeNull(); + } finally { + Object.defineProperty(process, 'env', { + configurable: true, + writable: true, + value: ORIGINAL_ENV + }); + } +}); + +test('returns null outside Node, where process.env is unavailable', () => { + process.env = { CLAUDECODE: '1' }; + const originalProcess = global.process; + try { + global.process = undefined; + expect(detectAgent()).toBeNull(); + } finally { + global.process = originalProcess; + } +}); diff --git a/lib/helpers/__tests__/sdk-version.test.js b/lib/helpers/__tests__/sdk-version.test.js new file mode 100644 index 00000000..d84f861e --- /dev/null +++ b/lib/helpers/__tests__/sdk-version.test.js @@ -0,0 +1,8 @@ +'use strict'; + +const getUserAgent = require('../sdk-version'); +const pkg = require('../../../package.json'); + +test('returns the mapbox-sdk-js product token with the package.json version', () => { + expect(getUserAgent()).toBe(`mapbox-sdk-js/${pkg.version}`); +}); diff --git a/lib/helpers/agent-detect.js b/lib/helpers/agent-detect.js new file mode 100644 index 00000000..0a2f6713 --- /dev/null +++ b/lib/helpers/agent-detect.js @@ -0,0 +1,100 @@ +/* eslint-env node */ +'use strict'; + +// (agentId, [envVar, ...]) - table order is precedence order; the first +// entry with any of its env vars present wins. Presence is the only thing +// ever tested - a var's value is never read or compared against anything, +// for any entry. Even a var explicitly set to "" or whitespace counts as +// present. +// +// Ported from mapbox/tilesets-cli's `agent_detect.py` (this repo's sibling +// implementation of the same allowlist - keep the two in sync). Canonical +// origin: HuggingFace's public `agent-harnesses.ts` registry. +// +// `vtcode` and `warp` used to require a specific value (`VTCODE === '1'`, +// `TERM_PROGRAM === 'WarpTerminal'`) rather than mere presence. Since values +// are never checked, `warp` was dropped entirely: `TERM_PROGRAM` is set by +// most terminal emulators (iTerm2, Apple Terminal, VS Code, Hyper, ...), not +// just Warp, so an existence check on it would misidentify most terminal +// sessions as "warp". `VTCODE` has no such collision risk and stays as a +// plain presence check. +// +// The final entry, `custom-agent`, is a catch-all for AI_AGENT/AGENT: these +// exist so an agent not on this list can still flag its presence, but we only +// ever check for them, never read their value - an arbitrary, unvalidated +// string must never be forwarded into telemetry as an "agent id". +var ALLOWLIST = [ + ['antigravity', ['ANTIGRAVITY_AGENT']], + ['augment-cli', ['AUGMENT_AGENT']], + ['cline', ['CLINE_ACTIVE']], + ['cowork', ['CLAUDE_CODE_IS_COWORK']], + ['claude-code', ['CLAUDECODE', 'CLAUDE_CODE']], + ['codex', ['CODEX_SANDBOX', 'CODEX_CI', 'CODEX_THREAD_ID']], + ['crush', ['CRUSH']], + ['gemini-cli', ['GEMINI_CLI']], + ['github-copilot', ['COPILOT_MODEL', 'COPILOT_ALLOW_ALL', 'COPILOT_GITHUB_TOKEN']], + ['goose', ['GOOSE_TERMINAL']], + ['hermes-agent', ['HERMES_SESSION_ID']], + ['kilo-code', ['KILOCODE_FEATURE']], + ['kiro', ['AGENT_CONTEXT_OUT']], + ['openclaw', ['OPENCLAW_SHELL']], + ['opencode', ['OPENCODE_CLIENT']], + ['pi', ['PI_CODING_AGENT']], + ['replit', ['REPL_ID']], + ['trae', ['TRAE_AI_SHELL_ID']], + ['vtcode', ['VTCODE']], + ['zed', ['ZED_TERM']], + ['cursor-cli', ['CURSOR_AGENT']], + ['cursor', ['CURSOR_TRACE_ID']], + ['custom-agent', ['AI_AGENT', 'AGENT']] +]; + +// Scans `env` for a matching agent indicator. Split out from `detectAgent` +// so the individual key reads below (which could throw in an environment +// where `process.env` is a permission-gated Proxy, e.g. Deno without +// --allow-env) are covered by a single try/catch there, rather than one +// bare `typeof process` guard that only checks the top-level object. +function scanEnv(env) { + for (var i = 0; i < ALLOWLIST.length; i++) { + var agentId = ALLOWLIST[i][0]; + var envVars = ALLOWLIST[i][1]; + for (var j = 0; j < envVars.length; j++) { + if (Object.prototype.hasOwnProperty.call(env, envVars[j])) { + return agentId; + } + } + } + + return null; +} + +/** + * Detect the AI coding agent (if any) driving this process, from + * `process.env`. Node-only: returns `null` immediately outside Node (e.g. + * bundled for the browser), where there is no `process.env` to read. + * + * Never reads or logs the full environment - only the matched id is used. + * Never throws: any error while reading `process.env` (e.g. a + * permission-gated environment) is treated as "no agent detected" rather + * than propagating out of `MapiRequest`'s constructor and breaking every + * request. + * + * @returns {string|null} The detected agent id, or `null` when no agent + * indicator is present. + */ +function detectAgent() { + // The `process.env` accesses below (including the guard itself) must all + // be inside this try: in an environment where `process.env` is a + // permission-gated Proxy (e.g. Deno without --allow-env), even reading + // `process.env` to check it can throw, not just reading an individual key. + try { + if (typeof process === 'undefined' || !process.env) { + return null; + } + return scanEnv(process.env); + } catch (error) { + return null; + } +} + +module.exports = detectAgent; diff --git a/lib/helpers/sdk-version.js b/lib/helpers/sdk-version.js new file mode 100644 index 00000000..9d401c5a --- /dev/null +++ b/lib/helpers/sdk-version.js @@ -0,0 +1,20 @@ +'use strict'; + +var pkg = require('../../package.json'); + +// The UA product token is the repo/UA name (`mapbox-sdk-js`), which differs +// from the published package name (`@mapbox/mapbox-sdk`). +var PRODUCT_NAME = 'mapbox-sdk-js'; + +/** + * Get this SDK's User-Agent product token, e.g. `mapbox-sdk-js/0.16.3`. + * The version is read from package.json, so it can never drift from the + * published package version. + * + * @returns {string} + */ +function getUserAgent() { + return PRODUCT_NAME + '/' + pkg.version; +} + +module.exports = getUserAgent; diff --git a/package-lock.json b/package-lock.json index d852f1d8..b561c075 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mapbox/mapbox-sdk", - "version": "0.16.2", + "version": "0.16.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mapbox/mapbox-sdk", - "version": "0.16.2", + "version": "0.16.3", "license": "BSD-2-Clause", "dependencies": { "@mapbox/fusspot": "^0.4.0", @@ -37,6 +37,7 @@ "remark-preset-davidtheclark": "^0.12.0", "rollup": "^0.62.0", "rollup-plugin-commonjs": "^9.1.3", + "rollup-plugin-json": "^3.1.0", "rollup-plugin-node-resolve": "^3.3.0", "uglify-js": "^3.4.4", "xhr-mock": "^2.4.1" @@ -16296,6 +16297,17 @@ "rollup": ">=0.56.0" } }, + "node_modules/rollup-plugin-json": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-json/-/rollup-plugin-json-3.1.0.tgz", + "integrity": "sha512-BlYk5VspvGpjz7lAwArVzBXR60JK+4EKtPkCHouAWg39obk9S61hZYJDBfMK+oitPdoe11i69TlxKlMQNFC/Uw==", + "deprecated": "This module has been deprecated and is no longer maintained. Please use @rollup/plugin-json.", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-pluginutils": "^2.3.1" + } + }, "node_modules/rollup-plugin-node-resolve": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.3.0.tgz", diff --git a/package.json b/package.json index 33802b45..4f4abd61 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "remark-preset-davidtheclark": "^0.12.0", "rollup": "^0.62.0", "rollup-plugin-commonjs": "^9.1.3", + "rollup-plugin-json": "^3.1.0", "rollup-plugin-node-resolve": "^3.3.0", "uglify-js": "^3.4.4", "xhr-mock": "^2.4.1" diff --git a/rollup.config.js b/rollup.config.js index 7c5f837c..d7fc317a 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -4,6 +4,7 @@ var path = require('path'); var commonjs = require('rollup-plugin-commonjs'); var nodeResolve = require('rollup-plugin-node-resolve'); +var json = require('rollup-plugin-json'); module.exports = { input: path.join(__dirname, './bundle.js'), @@ -16,6 +17,7 @@ module.exports = { nodeResolve({ browser: true }), + json(), commonjs() ] };