From 99f85a626be9686f668a56b631593d4018b136b2 Mon Sep 17 00:00:00 2001 From: ctufts Date: Fri, 17 Jul 2026 17:09:36 -0400 Subject: [PATCH 1/4] Set a base User-Agent and append agent/ when a coding agent is detected The Node SDK sends no User-Agent today, so requests fall back to got's default. Establish mapbox-sdk-js/ as the base UA on every request, and append " agent/" when a coding-agent env indicator is present (Node only; the allowlist + precedence mirrors mapbox/tilesets-cli's agent_detect.py). The browser client explicitly filters the user-agent header back out, since browsers forbid script-set UA via XHR. --- lib/browser/__tests__/browser-layer.test.js | 33 ++++ lib/browser/browser-layer.js | 9 + lib/classes/__tests__/mapi-request.test.js | 65 +++++++ lib/classes/mapi-request.js | 9 + lib/helpers/__tests__/agent-detect.test.js | 199 ++++++++++++++++++++ lib/helpers/__tests__/sdk-version.test.js | 8 + lib/helpers/agent-detect.js | 120 ++++++++++++ lib/helpers/sdk-version.js | 20 ++ package-lock.json | 16 +- package.json | 1 + rollup.config.js | 2 + 11 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 lib/helpers/__tests__/agent-detect.test.js create mode 100644 lib/helpers/__tests__/sdk-version.test.js create mode 100644 lib/helpers/agent-detect.js create mode 100644 lib/helpers/sdk-version.js 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() ] }; From 1861910a627bd1b0bb865433e3653d11e48594a0 Mon Sep 17 00:00:00 2001 From: ctufts Date: Thu, 10 Sep 2026 14:37:33 -0400 Subject: [PATCH 2/4] Stop forwarding AI_AGENT/AGENT env var values into telemetry Per review feedback, the fallback detector should only check whether AI_AGENT/AGENT is present, never read and forward its value - an arbitrary, unvalidated string should not become an "agent id" in production telemetry. Fold the fallback into the allowlist itself as its lowest-precedence entry, returning a fixed `custom-agent` id on presence instead of the variable's value. This also removes the charset/length validation that existed only to sanitize that value, since every returned id is now a fixed literal. Co-Authored-By: Claude Sonnet 5 --- lib/helpers/__tests__/agent-detect.test.js | 47 +++++----------------- lib/helpers/agent-detect.js | 26 ++++-------- 2 files changed, 16 insertions(+), 57 deletions(-) diff --git a/lib/helpers/__tests__/agent-detect.test.js b/lib/helpers/__tests__/agent-detect.test.js index 9447ebdf..402044f1 100644 --- a/lib/helpers/__tests__/agent-detect.test.js +++ b/lib/helpers/__tests__/agent-detect.test.js @@ -76,19 +76,19 @@ test('github-copilot matches on any of its vars', () => { expect(detectAgent()).toBe('github-copilot'); }); -test('falls back to AI_AGENT when no harness var matches', () => { - process.env = { AI_AGENT: 'custom-agent' }; +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 AGENT when no harness var or AI_AGENT matches', () => { - process.env = { AGENT: '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', () => { +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('first'); + expect(detectAgent()).toBe('custom-agent'); }); test('empty or whitespace-only fallback values are skipped', () => { @@ -99,7 +99,7 @@ test('empty or whitespace-only fallback values are skipped', () => { expect(detectAgent()).toBeNull(); process.env = { AI_AGENT: '', AGENT: 'still-empty-check' }; - expect(detectAgent()).toBe('still-empty-check'); + expect(detectAgent()).toBe('custom-agent'); }); test('a harness var set to an empty or whitespace value is treated as unset', () => { @@ -110,40 +110,11 @@ test('a harness var set to an empty or whitespace value is treated as unset', () expect(detectAgent()).toBeNull(); }); -test('fallback rejects values containing header-unsafe characters', () => { +test('the fallback value itself is never forwarded, even when it looks header-unsafe', () => { 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(); + expect(detectAgent()).toBe('custom-agent'); }); -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([ diff --git a/lib/helpers/agent-detect.js b/lib/helpers/agent-detect.js index f8621dc5..5977471f 100644 --- a/lib/helpers/agent-detect.js +++ b/lib/helpers/agent-detect.js @@ -1,12 +1,6 @@ /* 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, @@ -15,6 +9,11 @@ var SAFE_FALLBACK_ID = /^[\w.-]{1,64}$/; // 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. +// +// 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', null]]], ['augment-cli', [['AUGMENT_AGENT', null]]], @@ -48,14 +47,10 @@ var ALLOWLIST = [ ['warp', [['TERM_PROGRAM', 'WarpTerminal']]], ['zed', [['ZED_TERM', null]]], ['cursor-cli', [['CURSOR_AGENT', null]]], - ['cursor', [['CURSOR_TRACE_ID', null]]] + ['cursor', [['CURSOR_TRACE_ID', null]]], + ['custom-agent', [['AI_AGENT', null], ['AGENT', 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 @@ -78,13 +73,6 @@ function scanEnv(env) { } } - 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; } From 16224d04f55a737e310b61dd7a2c724130795fd7 Mon Sep 17 00:00:00 2001 From: ctufts Date: Thu, 10 Sep 2026 14:47:48 -0400 Subject: [PATCH 3/4] Treat presence check as existence-only, ignoring blank values The allowlist's presence-check branch (expectedValue === null) required a non-empty, non-whitespace value, so an env var explicitly set to "" or whitespace was treated as unset. Per feedback, existence is all that should matter - whether the key is present at all, not what it's set to. Check membership directly instead of trimming and testing truthiness. Co-Authored-By: Claude Sonnet 5 --- lib/helpers/__tests__/agent-detect.test.js | 13 ++++--------- lib/helpers/agent-detect.js | 7 ++++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/lib/helpers/__tests__/agent-detect.test.js b/lib/helpers/__tests__/agent-detect.test.js index 402044f1..ec1ef98f 100644 --- a/lib/helpers/__tests__/agent-detect.test.js +++ b/lib/helpers/__tests__/agent-detect.test.js @@ -91,23 +91,18 @@ test('AI_AGENT takes precedence over AGENT in the fallback (table order), same r expect(detectAgent()).toBe('custom-agent'); }); -test('empty or whitespace-only fallback values are skipped', () => { +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()).toBeNull(); + expect(detectAgent()).toBe('custom-agent'); process.env = { AI_AGENT: ' ' }; - expect(detectAgent()).toBeNull(); - - process.env = { AI_AGENT: '', AGENT: 'still-empty-check' }; expect(detectAgent()).toBe('custom-agent'); -}); -test('a harness var set to an empty or whitespace value is treated as unset', () => { process.env = { CLAUDECODE: '' }; - expect(detectAgent()).toBeNull(); + expect(detectAgent()).toBe('claude-code'); process.env = { CLAUDECODE: ' ' }; - expect(detectAgent()).toBeNull(); + expect(detectAgent()).toBe('claude-code'); }); test('the fallback value itself is never forwarded, even when it looks header-unsafe', () => { diff --git a/lib/helpers/agent-detect.js b/lib/helpers/agent-detect.js index 5977471f..62f9d223 100644 --- a/lib/helpers/agent-detect.js +++ b/lib/helpers/agent-detect.js @@ -3,8 +3,9 @@ // (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. +// means a presence check (the key exists in process.env at all - even set to +// "" or whitespace still counts, we only care whether it exists, not what +// it's set to); 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 @@ -64,7 +65,7 @@ function scanEnv(env) { var envVar = conditions[j][0]; var expected = conditions[j][1]; if (expected === null) { - if ((env[envVar] || '').trim()) { + if (Object.prototype.hasOwnProperty.call(env, envVar)) { return agentId; } } else if (env[envVar] === expected) { From 1ed85cfe4f1ae22b338019eac8c90069575c6bae Mon Sep 17 00:00:00 2001 From: ctufts Date: Thu, 10 Sep 2026 16:11:01 -0400 Subject: [PATCH 4/4] Remove all value comparisons from agent detection - existence only Every allowlist entry now tests presence only, never a value: collapse the table from (agentId, [[envVar, expectedValueOrNull], ...]) to a flat (agentId, [envVar, ...]), and drop the equality-check branch in scanEnv entirely. The warp entry compared TERM_PROGRAM against "WarpTerminal" - dropped outright, since TERM_PROGRAM is set by most terminal emulators (iTerm2, Apple Terminal, VS Code, Hyper, ...), not just Warp, and an existence-only check on it would misidentify most terminal sessions. vtcode's VTCODE has no such collision risk and stays as a plain presence check. Co-Authored-By: Claude Sonnet 5 --- lib/helpers/__tests__/agent-detect.test.js | 16 ++-- lib/helpers/agent-detect.js | 87 ++++++++++------------ 2 files changed, 48 insertions(+), 55 deletions(-) diff --git a/lib/helpers/__tests__/agent-detect.test.js b/lib/helpers/__tests__/agent-detect.test.js index ec1ef98f..b492a0cc 100644 --- a/lib/helpers/__tests__/agent-detect.test.js +++ b/lib/helpers/__tests__/agent-detect.test.js @@ -41,23 +41,25 @@ test('codex matches on any of its vars', () => { expect(detectAgent()).toBe('codex'); }); -test('warp requires an exact value match', () => { +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()).toBe('warp'); + expect(detectAgent()).toBeNull(); process.env = { TERM_PROGRAM: 'iTerm.app' }; expect(detectAgent()).toBeNull(); }); -test('vtcode requires an exact value match', () => { +test('vtcode matches on presence alone, regardless of value', () => { process.env = { VTCODE: '1' }; expect(detectAgent()).toBe('vtcode'); process.env = { VTCODE: '0' }; - expect(detectAgent()).toBeNull(); + expect(detectAgent()).toBe('vtcode'); process.env = { VTCODE: 'true' }; - expect(detectAgent()).toBeNull(); + expect(detectAgent()).toBe('vtcode'); }); test('table order determines precedence among harness vars', () => { @@ -110,8 +112,8 @@ test('the fallback value itself is never forwarded, even when it looks header-un expect(detectAgent()).toBe('custom-agent'); }); -// Single-var, presence-check allowlist entries not already covered above by -// a more targeted test (precedence, multi-var-OR, or exact-value-match). +// 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'], diff --git a/lib/helpers/agent-detect.js b/lib/helpers/agent-detect.js index 62f9d223..0a2f6713 100644 --- a/lib/helpers/agent-detect.js +++ b/lib/helpers/agent-detect.js @@ -1,55 +1,52 @@ /* eslint-env node */ 'use strict'; -// (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 at all - even set to -// "" or whitespace still counts, we only care whether it exists, not what -// it's set to); otherwise an exact-equality check. +// (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', 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]]], - ['custom-agent', [['AI_AGENT', null], ['AGENT', null]]] + ['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` @@ -60,15 +57,9 @@ var ALLOWLIST = [ 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 (Object.prototype.hasOwnProperty.call(env, envVar)) { - return agentId; - } - } else if (env[envVar] === expected) { + var envVars = ALLOWLIST[i][1]; + for (var j = 0; j < envVars.length; j++) { + if (Object.prototype.hasOwnProperty.call(env, envVars[j])) { return agentId; } }