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..9447ebdf --- /dev/null +++ b/lib/helpers/__tests__/agent-detect.test.js @@ -0,0 +1,199 @@ +'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 requires an exact value match', () => { + process.env = { TERM_PROGRAM: 'WarpTerminal' }; + expect(detectAgent()).toBe('warp'); + + process.env = { TERM_PROGRAM: 'iTerm.app' }; + expect(detectAgent()).toBeNull(); +}); + +test('vtcode requires an exact value match', () => { + process.env = { VTCODE: '1' }; + expect(detectAgent()).toBe('vtcode'); + + process.env = { VTCODE: '0' }; + expect(detectAgent()).toBeNull(); + + process.env = { VTCODE: 'true' }; + expect(detectAgent()).toBeNull(); +}); + +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 AI_AGENT when no harness var matches', () => { + process.env = { AI_AGENT: 'custom-agent' }; + expect(detectAgent()).toBe('custom-agent'); +}); + +test('falls back to AGENT when no harness var or AI_AGENT matches', () => { + process.env = { AGENT: 'custom-agent' }; + expect(detectAgent()).toBe('custom-agent'); +}); + +test('AI_AGENT takes precedence over AGENT in the fallback', () => { + process.env = { AI_AGENT: 'first', AGENT: 'second' }; + expect(detectAgent()).toBe('first'); +}); + +test('empty or whitespace-only fallback values are skipped', () => { + process.env = { AI_AGENT: '' }; + expect(detectAgent()).toBeNull(); + + process.env = { AI_AGENT: ' ' }; + expect(detectAgent()).toBeNull(); + + process.env = { AI_AGENT: '', AGENT: 'still-empty-check' }; + expect(detectAgent()).toBe('still-empty-check'); +}); + +test('a harness var set to an empty or whitespace value is treated as unset', () => { + process.env = { CLAUDECODE: '' }; + expect(detectAgent()).toBeNull(); + + process.env = { CLAUDECODE: ' ' }; + expect(detectAgent()).toBeNull(); +}); + +test('fallback rejects values containing header-unsafe characters', () => { + process.env = { AI_AGENT: 'foo\nbar: injected' }; + expect(detectAgent()).toBeNull(); + + process.env = { AI_AGENT: 'has spaces' }; + expect(detectAgent()).toBeNull(); +}); + +test('an unsafe fallback value falls through to the next fallback var', () => { + process.env = { AI_AGENT: 'foo\nbar', AGENT: 'safe-id' }; + expect(detectAgent()).toBe('safe-id'); +}); + +test('fallback rejects an overlong value', () => { + process.env = { AI_AGENT: 'a'.repeat(65) }; + expect(detectAgent()).toBeNull(); + + process.env = { AI_AGENT: 'a'.repeat(64) }; + expect(detectAgent()).toBe('a'.repeat(64)); +}); + +test('fallback rejects a unicode value outside the ASCII word-character charset', () => { + process.env = { AI_AGENT: 'café' }; + expect(detectAgent()).toBeNull(); +}); + +test.each([['\r'], ['\t'], ['@']])( + 'fallback rejects a value containing the unsafe character %j in isolation', + char => { + process.env = { AI_AGENT: `foo${char}bar` }; + expect(detectAgent()).toBeNull(); + } +); + +// Single-var, presence-check allowlist entries not already covered above by +// a more targeted test (precedence, multi-var-OR, or exact-value-match). +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..f8621dc5 --- /dev/null +++ b/lib/helpers/agent-detect.js @@ -0,0 +1,120 @@ +/* eslint-env node */ +'use strict'; + +// A safe charset for an agent id placed into a User-Agent header: env vars +// are not validated by whoever sets them, so a value like "foo\nbar: injected" +// must be rejected here rather than reaching `got` as an invalid header value +// (which would throw and break every request). +var SAFE_FALLBACK_ID = /^[\w.-]{1,64}$/; + +// (agentId, [[envVar, expectedValueOrNull], ...]) - table order is precedence +// order; the first entry with any matching condition wins. expectedValue null +// means a presence check (the key exists in process.env with a non-empty, +// non-whitespace value); otherwise an exact-equality check. +// +// 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. +var ALLOWLIST = [ + ['antigravity', [['ANTIGRAVITY_AGENT', null]]], + ['augment-cli', [['AUGMENT_AGENT', null]]], + ['cline', [['CLINE_ACTIVE', null]]], + ['cowork', [['CLAUDE_CODE_IS_COWORK', null]]], + ['claude-code', [['CLAUDECODE', null], ['CLAUDE_CODE', null]]], + [ + 'codex', + [['CODEX_SANDBOX', null], ['CODEX_CI', null], ['CODEX_THREAD_ID', null]] + ], + ['crush', [['CRUSH', null]]], + ['gemini-cli', [['GEMINI_CLI', null]]], + [ + 'github-copilot', + [ + ['COPILOT_MODEL', null], + ['COPILOT_ALLOW_ALL', null], + ['COPILOT_GITHUB_TOKEN', null] + ] + ], + ['goose', [['GOOSE_TERMINAL', null]]], + ['hermes-agent', [['HERMES_SESSION_ID', null]]], + ['kilo-code', [['KILOCODE_FEATURE', null]]], + ['kiro', [['AGENT_CONTEXT_OUT', null]]], + ['openclaw', [['OPENCLAW_SHELL', null]]], + ['opencode', [['OPENCODE_CLIENT', null]]], + ['pi', [['PI_CODING_AGENT', null]]], + ['replit', [['REPL_ID', null]]], + ['trae', [['TRAE_AI_SHELL_ID', null]]], + ['vtcode', [['VTCODE', '1']]], + ['warp', [['TERM_PROGRAM', 'WarpTerminal']]], + ['zed', [['ZED_TERM', null]]], + ['cursor-cli', [['CURSOR_AGENT', null]]], + ['cursor', [['CURSOR_TRACE_ID', null]]] +]; + +// Checked only if nothing in ALLOWLIST matched. First one with a non-empty +// (after trimming) value matching SAFE_FALLBACK_ID wins; an unsafe or empty +// value falls through to the next var rather than being returned as-is. +var FALLBACK_VARS = ['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 conditions = ALLOWLIST[i][1]; + for (var j = 0; j < conditions.length; j++) { + var envVar = conditions[j][0]; + var expected = conditions[j][1]; + if (expected === null) { + if ((env[envVar] || '').trim()) { + return agentId; + } + } else if (env[envVar] === expected) { + return agentId; + } + } + } + + for (var k = 0; k < FALLBACK_VARS.length; k++) { + var value = (env[FALLBACK_VARS[k]] || '').trim(); + if (value && SAFE_FALLBACK_ID.test(value)) { + return value; + } + } + + 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() ] };