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
199 changes: 199 additions & 0 deletions lib/helpers/__tests__/agent-detect.test.js
Original file line number Diff line number Diff line change
@@ -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;
}
});
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