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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions lib/browser/__tests__/browser-layer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
9 changes: 9 additions & 0 deletions lib/browser/browser-layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
65 changes: 65 additions & 0 deletions lib/classes/__tests__/mapi-request.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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/<id> 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();
Expand Down
9 changes: 9 additions & 0 deletions lib/classes/mapi-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
167 changes: 167 additions & 0 deletions lib/helpers/__tests__/agent-detect.test.js
Original file line number Diff line number Diff line change
@@ -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;
}
});
8 changes: 8 additions & 0 deletions lib/helpers/__tests__/sdk-version.test.js
Original file line number Diff line number Diff line change
@@ -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}`);
});
Loading