diff --git a/lib/utils/agentDetector.ts b/lib/utils/agentDetector.ts new file mode 100644 index 00000000..1c4fff04 --- /dev/null +++ b/lib/utils/agentDetector.ts @@ -0,0 +1,38 @@ +/** + * Detects whether the Node.js SQL driver is being invoked by an AI coding agent + * by checking for well-known environment variables that agents set in their + * spawned shell processes. + * + * Detection only succeeds when exactly one agent environment variable is present, + * to avoid ambiguous attribution when multiple agent environments overlap. + * + * Adding a new agent requires only a new entry in `knownAgents`. + * + * References for each environment variable: + * - ANTIGRAVITY_AGENT: Closed source. Google Antigravity sets this variable. + * - CLAUDECODE: https://github.com/anthropics/claude-code (sets CLAUDECODE=1) + * - CLINE_ACTIVE: https://github.com/cline/cline (shipped in v3.24.0) + * - CODEX_CI: https://github.com/openai/codex (part of UNIFIED_EXEC_ENV array in codex-rs) + * - CURSOR_AGENT: Closed source. Referenced in a gist by johnlindquist. + * - GEMINI_CLI: https://google-gemini.github.io/gemini-cli/docs/tools/shell.html (sets GEMINI_CLI=1) + * - OPENCODE: https://github.com/opencode-ai/opencode (sets OPENCODE=1) + */ + +const knownAgents: Array<{ envVar: string; product: string }> = [ + { envVar: 'ANTIGRAVITY_AGENT', product: 'antigravity' }, + { envVar: 'CLAUDECODE', product: 'claude-code' }, + { envVar: 'CLINE_ACTIVE', product: 'cline' }, + { envVar: 'CODEX_CI', product: 'codex' }, + { envVar: 'CURSOR_AGENT', product: 'cursor' }, + { envVar: 'GEMINI_CLI', product: 'gemini-cli' }, + { envVar: 'OPENCODE', product: 'opencode' }, +]; + +export default function detectAgent(env: Record = process.env): string { + const detected = knownAgents.filter((a) => env[a.envVar]).map((a) => a.product); + + if (detected.length === 1) { + return detected[0]; + } + return ''; +} diff --git a/lib/utils/buildUserAgentString.ts b/lib/utils/buildUserAgentString.ts index cd021e93..5d9db66e 100644 --- a/lib/utils/buildUserAgentString.ts +++ b/lib/utils/buildUserAgentString.ts @@ -1,5 +1,6 @@ import os from 'os'; import packageVersion from '../version'; +import detectAgent from './agentDetector'; const productName = 'NodejsDatabricksSqlConnector'; @@ -27,5 +28,12 @@ export default function buildUserAgentString(userAgentEntry?: string): string { } const extra = [userAgentEntry, getNodeVersion(), getOperatingSystemVersion()].filter(Boolean); - return `${productName}/${packageVersion} (${extra.join('; ')})`; + let ua = `${productName}/${packageVersion} (${extra.join('; ')})`; + + const agentProduct = detectAgent(); + if (agentProduct) { + ua += ` agent/${agentProduct}`; + } + + return ua; } diff --git a/tests/unit/utils/agentDetector.test.ts b/tests/unit/utils/agentDetector.test.ts new file mode 100644 index 00000000..66686e13 --- /dev/null +++ b/tests/unit/utils/agentDetector.test.ts @@ -0,0 +1,36 @@ +import { expect } from 'chai'; +import detectAgent from '../../../lib/utils/agentDetector'; + +describe('detectAgent', () => { + const allAgents = [ + { envVar: 'ANTIGRAVITY_AGENT', product: 'antigravity' }, + { envVar: 'CLAUDECODE', product: 'claude-code' }, + { envVar: 'CLINE_ACTIVE', product: 'cline' }, + { envVar: 'CODEX_CI', product: 'codex' }, + { envVar: 'CURSOR_AGENT', product: 'cursor' }, + { envVar: 'GEMINI_CLI', product: 'gemini-cli' }, + { envVar: 'OPENCODE', product: 'opencode' }, + ]; + + for (const { envVar, product } of allAgents) { + it(`detects ${product} when ${envVar} is set`, () => { + expect(detectAgent({ [envVar]: '1' })).to.equal(product); + }); + } + + it('returns empty string when no agent is detected', () => { + expect(detectAgent({})).to.equal(''); + }); + + it('returns empty string when multiple agents are detected', () => { + expect(detectAgent({ CLAUDECODE: '1', CURSOR_AGENT: '1' })).to.equal(''); + }); + + it('ignores empty env var values', () => { + expect(detectAgent({ CLAUDECODE: '' })).to.equal(''); + }); + + it('ignores undefined env var values', () => { + expect(detectAgent({ CLAUDECODE: undefined })).to.equal(''); + }); +}); diff --git a/tests/unit/utils/utils.test.ts b/tests/unit/utils/utils.test.ts index ed96326e..13a535cc 100644 --- a/tests/unit/utils/utils.test.ts +++ b/tests/unit/utils/utils.test.ts @@ -32,7 +32,7 @@ describe('buildUserAgentString', () => { // Prefix: 'NodejsDatabricksSqlConnector/' // Version: three period-separated digits and optional suffix const re = - /^(?NodejsDatabricksSqlConnector)\/(?\d+\.\d+\.\d+(-[^(]+)?)\s*\((?[^)]+)\)$/i; + /^(?NodejsDatabricksSqlConnector)\/(?\d+\.\d+\.\d+(-[^(]+)?)\s*\((?[^)]+)\)(\s+agent\/[a-z-]+)?$/i; const match = re.exec(ua); expect(match).to.not.be.eq(null); @@ -62,6 +62,21 @@ describe('buildUserAgentString', () => { const userAgentString = buildUserAgentString(userAgentEntry); expect(userAgentString).to.include(''); }); + + it('appends agent suffix when agent env var is set', () => { + const orig = process.env.CLAUDECODE; + try { + process.env.CLAUDECODE = '1'; + const ua = buildUserAgentString(); + expect(ua).to.include('agent/claude-code'); + } finally { + if (orig === undefined) { + delete process.env.CLAUDECODE; + } else { + process.env.CLAUDECODE = orig; + } + } + }); }); describe('formatProgress', () => {