diff --git a/src/utils/agent-detection.ts b/src/utils/agent-detection.ts new file mode 100644 index 00000000000..c585d0f5dfd --- /dev/null +++ b/src/utils/agent-detection.ts @@ -0,0 +1,195 @@ +// Only markers an agent product sets on its own count as a signal; never infer from process names, terminals, or the process tree. +// The result is untrusted attribution from the environment, for telemetry and labeling only, never for authorization. + +export const CANONICAL_AGENT_NAMES = [ + 'claude', + 'codex', + 'copilot', + 'gemini', + 'cursor', + 'opencode', + 'kiro', + 'cline', + 'amp', + 'warp', + 'claudeai', + 'chatgpt', + 'other', +] as const + +export type CanonicalAgentName = (typeof CANONICAL_AGENT_NAMES)[number] + +export type DrivingAgent = { + name: CanonicalAgentName + source: string + version?: string + markers?: CanonicalAgentName[] + otherValue?: string +} + +const ANNOUNCED_NAME_TABLE = new Map([ + ...CANONICAL_AGENT_NAMES.map((name) => [name, name] as const), + ['claude-code', 'claude'], + ['claude-ai', 'claudeai'], + ['github-copilot', 'copilot'], + ['github-copilot-cli', 'copilot'], + ['github-copilot-vscode-agent', 'copilot'], + ['cursor-cli', 'cursor'], + ['gemini-cli', 'gemini'], + ['kiro-cli', 'kiro'], + ['warp-oz', 'warp'], +]) + +const lookupAnnouncedName = (key: string): CanonicalAgentName | undefined => + ANNOUNCED_NAME_TABLE.get(key.replace(/_/g, '-')) + +type ParsedAnnouncedName = { + name: CanonicalAgentName + version?: string + otherValue?: string +} + +const sanitizeAnnouncedValue = (raw: string): string => raw.replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 64) + +const nonEmpty = (value: string | undefined): string | undefined => (value ? value : undefined) + +const parseAnnouncedName = (raw: string): ParsedAnnouncedName | undefined => { + const atIndex = raw.indexOf('@') + const sanitized = sanitizeAnnouncedValue(atIndex === -1 ? raw : raw.slice(0, atIndex)) + if (sanitized === '') { + return undefined + } + + const announcedVersion = atIndex === -1 ? undefined : nonEmpty(sanitizeAnnouncedValue(raw.slice(atIndex + 1))) + const key = sanitized.toLowerCase() + + const exact = lookupAnnouncedName(key) + if (exact) { + return { name: exact, version: announcedVersion } + } + + const withoutAgentSuffix = key.replace(/_agent$/, '') + const suffixMatch = lookupAnnouncedName(withoutAgentSuffix) + if (suffixMatch) { + return { name: suffixMatch, version: announcedVersion } + } + + const lastUnderscore = withoutAgentSuffix.lastIndexOf('_') + if (lastUnderscore !== -1) { + const head = withoutAgentSuffix.slice(0, lastUnderscore) + const headMatch = lookupAnnouncedName(head) + if (headMatch) { + const tail = withoutAgentSuffix.slice(lastUnderscore + 1) + return { name: headMatch, version: announcedVersion ?? tail.replace(/-/g, '.') } + } + } + + return { name: 'other', otherValue: sanitized, version: announcedVersion } +} + +type Signal = { + source: string + detect: (env: NodeJS.ProcessEnv) => ParsedAnnouncedName | undefined +} + +// Precedence, first match wins: NETLIFY_AGENT (explicit, even when unknown); markers only the process +// running the command sets; AI_AGENT (explicit, even when unknown); markers inherited from an agent +// session; runner/task markers such as Warp's last, since the agent inside the run is the more specific answer. +const SIGNALS: Signal[] = [ + { + source: 'NETLIFY_AGENT', + detect: (env) => (env.NETLIFY_AGENT === undefined ? undefined : parseAnnouncedName(env.NETLIFY_AGENT)), + }, + { + source: 'CODEX_CI', + detect: (env) => (env.CODEX_CI === '1' ? { name: 'codex' } : undefined), + }, + { + source: 'GEMINI_CLI', + detect: (env) => (env.GEMINI_CLI === '1' ? { name: 'gemini' } : undefined), + }, + { + source: 'COPILOT_CLI', + detect: (env) => (env.COPILOT_CLI === '1' ? { name: 'copilot' } : undefined), + }, + { + source: 'COPILOT_AGENT_SESSION_ID', + detect: (env) => (nonEmpty(env.COPILOT_AGENT_SESSION_ID) === undefined ? undefined : { name: 'copilot' }), + }, + { + source: 'OPENCODE', + detect: (env) => + env.OPENCODE === '1' && nonEmpty(env.OPENCODE_TERMINAL) === undefined ? { name: 'opencode' } : undefined, + }, + { + source: 'AGENT_DISPLAY_OUT', + detect: (env) => + nonEmpty(env.AGENT_DISPLAY_OUT) !== undefined && nonEmpty(env.AGENT_CONTEXT_OUT) !== undefined + ? { name: 'kiro' } + : undefined, + }, + { + source: 'AI_AGENT', + detect: (env) => (env.AI_AGENT === undefined ? undefined : parseAnnouncedName(env.AI_AGENT)), + }, + { + source: 'COPILOT_AGENT', + detect: (env) => (env.COPILOT_AGENT === '1' ? { name: 'copilot' } : undefined), + }, + { + source: 'CURSOR_AGENT', + detect: (env) => (env.CURSOR_AGENT === '1' ? { name: 'cursor' } : undefined), + }, + { + source: 'CLINE_ACTIVE', + detect: (env) => (env.CLINE_ACTIVE === 'true' ? { name: 'cline' } : undefined), + }, + { + source: 'AGENT', + detect: (env) => (env.AGENT === 'amp' ? { name: 'amp' } : undefined), + }, + { + source: 'CLAUDE_CODE_CHILD_SESSION', + detect: (env) => (env.CLAUDE_CODE_CHILD_SESSION === '1' ? { name: 'claude' } : undefined), + }, + { + source: 'OZ_RUN_ID', + detect: (env) => (nonEmpty(env.OZ_RUN_ID) === undefined ? undefined : { name: 'warp' }), + }, + { + source: 'WARP_RUN_ID', + detect: (env) => (nonEmpty(env.WARP_RUN_ID) === undefined ? undefined : { name: 'warp' }), + }, +] + +type SignalMatch = { source: string } & ParsedAnnouncedName + +export const getDrivingAgent = (env: NodeJS.ProcessEnv = process.env): DrivingAgent | undefined => { + const matches: SignalMatch[] = [] + + for (const signal of SIGNALS) { + const result = signal.detect(env) + if (result) { + matches.push({ source: signal.source, ...result }) + } + } + + if (matches.length === 0) { + return undefined + } + + const [winner] = matches + + const codexVersion = winner.source === 'CODEX_CI' ? nonEmpty(env.CODEX_VERSION) : undefined + const version = winner.version ?? (codexVersion === undefined ? undefined : sanitizeAnnouncedValue(codexVersion)) + + const distinctNames = [...new Set(matches.map((match) => match.name))] + + return { + name: winner.name, + source: winner.source, + ...(version ? { version } : {}), + ...(distinctNames.length >= 2 ? { markers: distinctNames } : {}), + ...(winner.name === 'other' ? { otherValue: winner.otherValue ?? '' } : {}), + } +} diff --git a/tests/unit/utils/agent-detection.test.ts b/tests/unit/utils/agent-detection.test.ts new file mode 100644 index 00000000000..6b3ed84c52f --- /dev/null +++ b/tests/unit/utils/agent-detection.test.ts @@ -0,0 +1,357 @@ +import { expect, test, vi } from 'vitest' + +import { CANONICAL_AGENT_NAMES, getDrivingAgent } from '../../../src/utils/agent-detection.js' + +test('resolves NETLIFY_AGENT to the matching canonical name', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'codex' })).toEqual({ name: 'codex', source: 'NETLIFY_AGENT' }) +}) + +test('matches NETLIFY_AGENT case-insensitively', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'Claude-Code' })).toEqual({ name: 'claude', source: 'NETLIFY_AGENT' }) +}) + +test('keeps a version parsed from NETLIFY_AGENT', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'claude-code_2-1-263_agent' })).toEqual({ + name: 'claude', + source: 'NETLIFY_AGENT', + version: '2.1.263', + }) +}) + +test('preserves the original casing of an unknown value in otherValue', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'MyWrapper' })).toEqual({ + name: 'other', + source: 'NETLIFY_AGENT', + otherValue: 'MyWrapper', + }) +}) + +test('resolves CODEX_CI without CODEX_VERSION and omits version', () => { + expect(getDrivingAgent({ CODEX_CI: '1' })).toEqual({ name: 'codex', source: 'CODEX_CI' }) +}) + +test('adds version from CODEX_VERSION when CODEX_CI matches', () => { + expect(getDrivingAgent({ CODEX_CI: '1', CODEX_VERSION: '1.2.3' })).toEqual({ + name: 'codex', + source: 'CODEX_CI', + version: '1.2.3', + }) +}) + +test('resolves GEMINI_CLI', () => { + expect(getDrivingAgent({ GEMINI_CLI: '1' })).toEqual({ name: 'gemini', source: 'GEMINI_CLI' }) +}) + +test('resolves COPILOT_CLI', () => { + expect(getDrivingAgent({ COPILOT_CLI: '1' })).toEqual({ name: 'copilot', source: 'COPILOT_CLI' }) +}) + +test('resolves COPILOT_AGENT_SESSION_ID', () => { + expect(getDrivingAgent({ COPILOT_AGENT_SESSION_ID: 'session-123' })).toEqual({ + name: 'copilot', + source: 'COPILOT_AGENT_SESSION_ID', + }) +}) + +test('resolves OPENCODE when OPENCODE_TERMINAL is unset', () => { + expect(getDrivingAgent({ OPENCODE: '1' })).toEqual({ name: 'opencode', source: 'OPENCODE' }) +}) + +test('resolves AGENT_DISPLAY_OUT plus AGENT_CONTEXT_OUT to kiro without surfacing their values', () => { + expect( + getDrivingAgent({ + AGENT_DISPLAY_OUT: '/tmp/agent-display-output.json', + AGENT_CONTEXT_OUT: '/tmp/agent-context-output.json', + }), + ).toEqual({ name: 'kiro', source: 'AGENT_DISPLAY_OUT' }) +}) + +test('either Kiro variable alone matches nothing', () => { + expect(getDrivingAgent({ AGENT_DISPLAY_OUT: '/tmp/agent-display-output.json' })).toBeUndefined() + expect(getDrivingAgent({ AGENT_CONTEXT_OUT: '/tmp/agent-context-output.json' })).toBeUndefined() +}) + +test('resolves OZ_RUN_ID to warp without surfacing its value', () => { + expect(getDrivingAgent({ OZ_RUN_ID: 'run-123' })).toEqual({ name: 'warp', source: 'OZ_RUN_ID' }) +}) + +test('resolves WARP_RUN_ID to warp', () => { + expect(getDrivingAgent({ WARP_RUN_ID: 'run-123' })).toEqual({ name: 'warp', source: 'WARP_RUN_ID' }) +}) + +test('parses AI_AGENT claude-code_2-1-263_agent into claude with version 2.1.263', () => { + expect(getDrivingAgent({ AI_AGENT: 'claude-code_2-1-263_agent' })).toEqual({ + name: 'claude', + source: 'AI_AGENT', + version: '2.1.263', + }) +}) + +test.each([ + ['claude-code', 'claude'], + ['claude-ai', 'claudeai'], + ['github-copilot', 'copilot'], + ['github-copilot-cli', 'copilot'], + ['github_copilot_vscode_agent', 'copilot'], + ['cursor-cli', 'cursor'], + ['gemini-cli', 'gemini'], + ['gemini_cli', 'gemini'], + ['kiro-cli', 'kiro'], + ['warp-oz', 'warp'], + ['Claude_Code', 'claude'], +])('resolves the announced alias %s to %s', (alias, name) => { + expect(getDrivingAgent({ AI_AGENT: alias })).toEqual({ name, source: 'AI_AGENT' }) + expect(getDrivingAgent({ AI_AGENT: `${alias}@1.0` })).toEqual({ name, source: 'AI_AGENT', version: '1.0' }) +}) + +test('parses AI_AGENT github_copilot_vscode_agent into copilot without version', () => { + expect(getDrivingAgent({ AI_AGENT: 'github_copilot_vscode_agent' })).toEqual({ + name: 'copilot', + source: 'AI_AGENT', + }) +}) + +test('resolves COPILOT_AGENT', () => { + expect(getDrivingAgent({ COPILOT_AGENT: '1' })).toEqual({ name: 'copilot', source: 'COPILOT_AGENT' }) +}) + +test('resolves CURSOR_AGENT', () => { + expect(getDrivingAgent({ CURSOR_AGENT: '1' })).toEqual({ name: 'cursor', source: 'CURSOR_AGENT' }) +}) + +test('resolves CLINE_ACTIVE', () => { + expect(getDrivingAgent({ CLINE_ACTIVE: 'true' })).toEqual({ name: 'cline', source: 'CLINE_ACTIVE' }) +}) + +test('resolves AGENT=amp exactly', () => { + expect(getDrivingAgent({ AGENT: 'amp' })).toEqual({ name: 'amp', source: 'AGENT' }) +}) + +test('resolves CLAUDE_CODE_CHILD_SESSION', () => { + expect(getDrivingAgent({ CLAUDE_CODE_CHILD_SESSION: '1' })).toEqual({ + name: 'claude', + source: 'CLAUDE_CODE_CHILD_SESSION', + }) +}) + +test('a single match omits markers', () => { + const result = getDrivingAgent({ CURSOR_AGENT: '1' }) + expect(result).toEqual({ name: 'cursor', source: 'CURSOR_AGENT' }) + expect(result?.markers).toBeUndefined() +}) + +test('NETLIFY_AGENT overrides every other signal and lists all matched names as markers', () => { + expect( + getDrivingAgent({ + CODEX_CI: '1', + GEMINI_CLI: '1', + COPILOT_CLI: '1', + COPILOT_AGENT_SESSION_ID: 'session-123', + OPENCODE: '1', + AGENT_DISPLAY_OUT: '/tmp/agent-display-output.json', + AGENT_CONTEXT_OUT: '/tmp/agent-context-output.json', + OZ_RUN_ID: 'run-123', + WARP_RUN_ID: 'run-123', + AI_AGENT: 'claude-code_2-1-263_agent', + COPILOT_AGENT: '1', + CURSOR_AGENT: '1', + CLINE_ACTIVE: 'true', + AGENT: 'amp', + CLAUDE_CODE_CHILD_SESSION: '1', + NETLIFY_AGENT: 'chatgpt', + }), + ).toEqual({ + name: 'chatgpt', + source: 'NETLIFY_AGENT', + markers: ['chatgpt', 'codex', 'gemini', 'copilot', 'opencode', 'kiro', 'claude', 'cursor', 'cline', 'amp', 'warp'], + }) +}) + +test('an unknown NETLIFY_AGENT still overrides a recognized marker and keeps its raw value', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'windsurf', CODEX_CI: '1' })).toEqual({ + name: 'other', + source: 'NETLIFY_AGENT', + otherValue: 'windsurf', + markers: ['other', 'codex'], + }) +}) + +test('the agent inside a Warp run beats the Warp run marker', () => { + expect(getDrivingAgent({ OZ_RUN_ID: 'run-123', AI_AGENT: 'claude-code_2-1-263_agent' })).toEqual({ + name: 'claude', + source: 'AI_AGENT', + version: '2.1.263', + markers: ['claude', 'warp'], + }) +}) + +test('sanitizes and caps CODEX_VERSION', () => { + expect(getDrivingAgent({ CODEX_CI: '1', CODEX_VERSION: `1.2.3\r\nX-Injected: ${'9'.repeat(80)}` })).toEqual({ + name: 'codex', + source: 'CODEX_CI', + version: `1.2.3X-Injected${'9'.repeat(64 - '1.2.3X-Injected'.length)}`, + }) +}) + +test('nests AI_AGENT under a higher-priority CODEX_CI match', () => { + expect( + getDrivingAgent({ + AI_AGENT: 'claude-code_2-1-263_agent', + CODEX_CI: '1', + }), + ).toEqual({ + name: 'codex', + source: 'CODEX_CI', + markers: ['codex', 'claude'], + }) +}) + +test('unknown AI_AGENT resolves to other with otherValue', () => { + expect(getDrivingAgent({ AI_AGENT: 'some-new-tool_1-0_agent' })).toEqual({ + name: 'other', + source: 'AI_AGENT', + otherValue: 'some-new-tool_1-0_agent', + }) +}) + +test('an unknown AI_AGENT beats an inherited session marker and keeps its raw value', () => { + expect(getDrivingAgent({ AI_AGENT: 'windsurf@1.0', CURSOR_AGENT: '1' })).toEqual({ + name: 'other', + source: 'AI_AGENT', + version: '1.0', + otherValue: 'windsurf', + markers: ['other', 'cursor'], + }) +}) + +test('parses the name@version AI_AGENT convention', () => { + expect(getDrivingAgent({ AI_AGENT: 'codex@1.2.3' })).toEqual({ name: 'codex', source: 'AI_AGENT', version: '1.2.3' }) +}) + +test('an announced @version wins over an underscore-encoded one', () => { + expect(getDrivingAgent({ AI_AGENT: 'claude-code_2-1-263_agent@3.0.0' })).toEqual({ + name: 'claude', + source: 'AI_AGENT', + version: '3.0.0', + }) +}) + +test('a name with an empty @version omits version', () => { + expect(getDrivingAgent({ AI_AGENT: 'codex@' })).toEqual({ name: 'codex', source: 'AI_AGENT' }) +}) + +test('an override that sanitizes to nothing is treated as unset', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: ' ', CODEX_CI: '1' })).toEqual({ name: 'codex', source: 'CODEX_CI' }) + expect(getDrivingAgent({ NETLIFY_AGENT: '!!!' })).toBeUndefined() + expect(getDrivingAgent({ AI_AGENT: '@1.0' })).toBeUndefined() +}) + +test('an unknown, oversized NETLIFY_AGENT value is sanitized and capped at 64 characters', () => { + const raw = `${'x'.repeat(70)} disallowed/chars!!!` + const result = getDrivingAgent({ NETLIFY_AGENT: raw }) + + expect(result).toEqual({ + name: 'other', + source: 'NETLIFY_AGENT', + otherValue: 'x'.repeat(64), + }) + expect(result?.otherValue).toHaveLength(64) +}) + +test.each(['constructor', '__proto__', 'toString'])('%s does not resolve via the Object prototype chain', (key) => { + expect(getDrivingAgent({ NETLIFY_AGENT: key })).toEqual({ + name: 'other', + source: 'NETLIFY_AGENT', + otherValue: key, + }) + expect(getDrivingAgent({ AI_AGENT: key })).toEqual({ + name: 'other', + source: 'AI_AGENT', + otherValue: key, + }) +}) + +test('NETLIFY_AGENT=constructor_1-0_agent does not resolve constructor via the split-at-last-underscore path', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'constructor_1-0_agent' })).toEqual({ + name: 'other', + source: 'NETLIFY_AGENT', + otherValue: 'constructor_1-0_agent', + }) +}) + +test('AGENT=1 alone matches nothing', () => { + expect(getDrivingAgent({ AGENT: '1' })).toBeUndefined() +}) + +test('AGENT=true alone matches nothing', () => { + expect(getDrivingAgent({ AGENT: 'true' })).toBeUndefined() +}) + +test('OPENCODE with OPENCODE_TERMINAL set matches nothing', () => { + expect(getDrivingAgent({ OPENCODE: '1', OPENCODE_TERMINAL: '1' })).toBeUndefined() +}) + +test('an empty string value is treated as unset', () => { + expect(getDrivingAgent({ CODEX_CI: '' })).toBeUndefined() +}) + +test('an env of only ignored variables matches nothing', () => { + expect( + getDrivingAgent({ + CLAUDECODE: '1', + CURSOR_TRACE_ID: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + CURSOR_CLI: '1', + TERM_PROGRAM: 'iTerm.app', + ZED_TERM: 'true', + CODEX_SESSION_ID: 'sess_abc123', + CODEX_THREAD_ID: 'thread_xyz789', + AGENT_SESSION_ID: 'agent-session-001', + OR_APP_NAME: 'OpenRouter', + REPLIT_AGENT: '1', + }), + ).toBeUndefined() +}) + +test('an empty env matches nothing', () => { + expect(getDrivingAgent({})).toBeUndefined() +}) + +test('falls back to process.env when no argument is given', () => { + const signalKeys = [ + 'NETLIFY_AGENT', + 'CODEX_CI', + 'GEMINI_CLI', + 'COPILOT_CLI', + 'COPILOT_AGENT_SESSION_ID', + 'OPENCODE', + 'OPENCODE_TERMINAL', + 'AGENT_DISPLAY_OUT', + 'AGENT_CONTEXT_OUT', + 'OZ_RUN_ID', + 'WARP_RUN_ID', + 'AI_AGENT', + 'COPILOT_AGENT', + 'CURSOR_AGENT', + 'CLINE_ACTIVE', + 'AGENT', + 'CLAUDE_CODE_CHILD_SESSION', + ] + + try { + signalKeys.forEach((key) => vi.stubEnv(key, undefined)) + vi.stubEnv('GEMINI_CLI', '1') + + expect(getDrivingAgent()).toEqual({ name: 'gemini', source: 'GEMINI_CLI' }) + } finally { + vi.unstubAllEnvs() + } +}) + +test('CANONICAL_AGENT_NAMES has no duplicates and only lowercase letters', () => { + const uniqueNames = new Set(CANONICAL_AGENT_NAMES) + expect(uniqueNames.size).toBe(CANONICAL_AGENT_NAMES.length) + + CANONICAL_AGENT_NAMES.forEach((name) => { + expect(name).toMatch(/^[a-z]+$/) + }) +})