From 98d1e3904db69b8118083883e22353f1873fc018 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Tue, 8 Sep 2026 14:58:41 -0400 Subject: [PATCH 01/13] feat: add getDrivingAgent agent-detection helper Pure env-reading helper for EX-3040 that identifies which AI agent is driving a CLI invocation, via an ordered signal table and an announced-name parser. Downstream tickets (EX-3042, EX-3044, EX-3038, EX-3037) will wire it in; nothing calls it yet. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y8HbdxER5EJPxNKqcorkTp --- src/utils/agent-detection.ts | 179 +++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/utils/agent-detection.ts diff --git a/src/utils/agent-detection.ts b/src/utils/agent-detection.ts new file mode 100644 index 00000000000..e6b69a0b609 --- /dev/null +++ b/src/utils/agent-detection.ts @@ -0,0 +1,179 @@ +// EX-3040: signal choices and evidence are tracked in the Linear issue, not here. + +export type DrivingAgent = { + name: string + source: string + version?: string + markers?: string[] + otherValue?: string +} + +export const CANONICAL_AGENT_NAMES = [ + 'claude', + 'codex', + 'copilot', + 'gemini', + 'cursor', + 'opencode', + 'kiro', + 'cline', + 'amp', + 'claudeai', + 'chatgpt', + 'other', +] as const + +export type CanonicalAgentName = (typeof CANONICAL_AGENT_NAMES)[number] + +const ANNOUNCED_NAME_TABLE: Partial> = { + ...Object.fromEntries(CANONICAL_AGENT_NAMES.map((name) => [name, name] as const)), + 'claude-code': 'claude', + 'claude-ai': 'claudeai', + github_copilot_vscode_agent: 'copilot', +} + +type ParsedAnnouncedName = { + name: CanonicalAgentName + version?: string + otherValue?: string +} + +const sanitizeAnnouncedValue = (raw: string): string => raw.replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 64) + +const parseAnnouncedName = (raw: string): ParsedAnnouncedName => { + const sanitized = sanitizeAnnouncedValue(raw) + + const exact = ANNOUNCED_NAME_TABLE[sanitized] + if (exact) { + return { name: exact } + } + + const withoutAgentSuffix = sanitized.replace(/_agent$/, '') + const suffixMatch = ANNOUNCED_NAME_TABLE[withoutAgentSuffix] + if (suffixMatch) { + return { name: suffixMatch } + } + + const lastUnderscore = withoutAgentSuffix.lastIndexOf('_') + if (lastUnderscore !== -1) { + const head = withoutAgentSuffix.slice(0, lastUnderscore) + const headMatch = ANNOUNCED_NAME_TABLE[head] + if (headMatch) { + const tail = withoutAgentSuffix.slice(lastUnderscore + 1) + return { name: headMatch, version: tail.replace(/-/g, '.') } + } + } + + return { name: 'other', otherValue: sanitized } +} + +const nonEmpty = (value: string | undefined): string | undefined => (value ? value : undefined) + +const COPILOT_SESSION_ENV_KEY = ['COPILOT_AGENT', 'SESSION_ID'].join('_') + +type Signal = { + source: string + detect: (env: NodeJS.ProcessEnv) => ParsedAnnouncedName | undefined +} + +const SIGNALS: Signal[] = [ + { + source: 'NETLIFY_AGENT', + detect: (env) => { + const value = nonEmpty(env.NETLIFY_AGENT) + return value === undefined ? undefined : parseAnnouncedName(value) + }, + }, + { + 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_SESSION_ENV_KEY, + detect: (env) => (nonEmpty(env[COPILOT_SESSION_ENV_KEY]) === 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 ? undefined : { name: 'kiro' }), + }, + { + source: 'AGENT_CONTEXT_OUT', + detect: (env) => (nonEmpty(env.AGENT_CONTEXT_OUT) === undefined ? undefined : { name: 'kiro' }), + }, + { + source: 'AI_AGENT', + detect: (env) => { + const value = nonEmpty(env.AI_AGENT) + return value === undefined ? undefined : parseAnnouncedName(value) + }, + }, + { + 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), + }, +] + +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.find((match) => match.name !== 'other') ?? matches[0] + + const version = + winner.source === 'AI_AGENT' + ? winner.version + : winner.source === 'CODEX_CI' + ? nonEmpty(env.CODEX_VERSION) + : undefined + + 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 ?? '' } : {}), + } +} From 84c9b48390914d0431de089e09a7a2d8deb30147 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Tue, 8 Sep 2026 15:03:56 -0400 Subject: [PATCH 02/13] test: cover getDrivingAgent signals, nesting, and ignored markers Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y8HbdxER5EJPxNKqcorkTp --- src/utils/agent-detection.ts | 6 +- tests/unit/utils/agent-detection.test.ts | 231 +++++++++++++++++++++++ 2 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 tests/unit/utils/agent-detection.test.ts diff --git a/src/utils/agent-detection.ts b/src/utils/agent-detection.ts index e6b69a0b609..d075b89d0e5 100644 --- a/src/utils/agent-detection.ts +++ b/src/utils/agent-detection.ts @@ -69,8 +69,6 @@ const parseAnnouncedName = (raw: string): ParsedAnnouncedName => { const nonEmpty = (value: string | undefined): string | undefined => (value ? value : undefined) -const COPILOT_SESSION_ENV_KEY = ['COPILOT_AGENT', 'SESSION_ID'].join('_') - type Signal = { source: string detect: (env: NodeJS.ProcessEnv) => ParsedAnnouncedName | undefined @@ -97,8 +95,8 @@ const SIGNALS: Signal[] = [ detect: (env) => (env.COPILOT_CLI === '1' ? { name: 'copilot' } : undefined), }, { - source: COPILOT_SESSION_ENV_KEY, - detect: (env) => (nonEmpty(env[COPILOT_SESSION_ENV_KEY]) === undefined ? undefined : { name: 'copilot' }), + source: 'COPILOT_AGENT_SESSION_ID', + detect: (env) => (nonEmpty(env.COPILOT_AGENT_SESSION_ID) === undefined ? undefined : { name: 'copilot' }), }, { source: 'OPENCODE', diff --git a/tests/unit/utils/agent-detection.test.ts b/tests/unit/utils/agent-detection.test.ts new file mode 100644 index 00000000000..bc7344efb04 --- /dev/null +++ b/tests/unit/utils/agent-detection.test.ts @@ -0,0 +1,231 @@ +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('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 to kiro without surfacing its value', () => { + expect(getDrivingAgent({ AGENT_DISPLAY_OUT: '/tmp/agent-display-output.json' })).toEqual({ + name: 'kiro', + source: 'AGENT_DISPLAY_OUT', + }) +}) + +test('resolves AGENT_CONTEXT_OUT to kiro', () => { + expect(getDrivingAgent({ AGENT_CONTEXT_OUT: '/tmp/agent-context-output.json' })).toEqual({ + name: 'kiro', + source: 'AGENT_CONTEXT_OUT', + }) +}) + +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('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({ + NETLIFY_AGENT: 'gemini', + CODEX_CI: '1', + AI_AGENT: 'claude-code_2-1-263_agent', + }), + ).toEqual({ + name: 'gemini', + source: 'NETLIFY_AGENT', + markers: ['gemini', 'codex', 'claude'], + }) +}) + +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('a recognized name beats an other match but still lists it in markers', () => { + expect( + getDrivingAgent({ + AI_AGENT: 'some-new-tool_1-0_agent', + CURSOR_AGENT: '1', + }), + ).toEqual({ + name: 'cursor', + source: 'CURSOR_AGENT', + markers: ['other', 'cursor'], + }) +}) + +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('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', + '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]+$/) + }) +}) From 38c708f864133df3ed4f16832428ab6ed9887ac2 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Tue, 8 Sep 2026 15:05:35 -0400 Subject: [PATCH 03/13] docs: document NETLIFY_AGENT for agent detection Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y8HbdxER5EJPxNKqcorkTp --- docs/index.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/index.md b/docs/index.md index e681df42f73..b5206601090 100644 --- a/docs/index.md +++ b/docs/index.md @@ -207,3 +207,15 @@ Watch for project deploy to finish + +## Agent detection + +The CLI reads the `NETLIFY_AGENT` environment variable to learn which AI agent or tool is running it. Agents, MCP servers, and wrappers that invoke the CLI should set it to their name. Known names resolve to a canonical agent name; anything else is recorded as `other`. The value is limited to letters, digits, `_`, `.`, and `-`, and is truncated to 64 characters. + +Recognized values: `claude`, `codex`, `copilot`, `gemini`, `cursor`, `opencode`, `kiro`, `cline`, `amp`, `claudeai`, `chatgpt`, `claude-code`, `claude-ai`, `github_copilot_vscode_agent`. + +``` +NETLIFY_AGENT=claude-code netlify deploy +``` + +The CLI also recognizes markers that agent products set on their own (for example `AI_AGENT`, `CODEX_CI`, `GEMINI_CLI`); `NETLIFY_AGENT` takes precedence over all of them. From ba67e0913a6b1a3556de9c9712b5e0ded7df2a36 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Tue, 8 Sep 2026 15:15:34 -0400 Subject: [PATCH 04/13] fix: guard agent-name lookup against prototype keys Replace the plain-object ANNOUNCED_NAME_TABLE with a Map so lookups can't resolve inherited properties like constructor, __proto__, or toString. Also broadens the override-priority test to set all thirteen lower-priority signals at once and assert the full markers order, and drops the ticket-only header comment for a self-contained constraint statement. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y8HbdxER5EJPxNKqcorkTp --- src/utils/agent-detection.ts | 20 ++++----- tests/unit/utils/agent-detection.test.ts | 57 ++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/utils/agent-detection.ts b/src/utils/agent-detection.ts index d075b89d0e5..778845e249f 100644 --- a/src/utils/agent-detection.ts +++ b/src/utils/agent-detection.ts @@ -1,4 +1,4 @@ -// EX-3040: signal choices and evidence are tracked in the Linear issue, not here. +// Only markers an agent product sets on its own count as a signal; never infer from process names, terminals, or the process tree. export type DrivingAgent = { name: string @@ -25,12 +25,12 @@ export const CANONICAL_AGENT_NAMES = [ export type CanonicalAgentName = (typeof CANONICAL_AGENT_NAMES)[number] -const ANNOUNCED_NAME_TABLE: Partial> = { - ...Object.fromEntries(CANONICAL_AGENT_NAMES.map((name) => [name, name] as const)), - 'claude-code': 'claude', - 'claude-ai': 'claudeai', - github_copilot_vscode_agent: 'copilot', -} +const ANNOUNCED_NAME_TABLE = new Map([ + ...CANONICAL_AGENT_NAMES.map((name) => [name, name] as const), + ['claude-code', 'claude'], + ['claude-ai', 'claudeai'], + ['github_copilot_vscode_agent', 'copilot'], +]) type ParsedAnnouncedName = { name: CanonicalAgentName @@ -43,13 +43,13 @@ const sanitizeAnnouncedValue = (raw: string): string => raw.replace(/[^A-Za-z0-9 const parseAnnouncedName = (raw: string): ParsedAnnouncedName => { const sanitized = sanitizeAnnouncedValue(raw) - const exact = ANNOUNCED_NAME_TABLE[sanitized] + const exact = ANNOUNCED_NAME_TABLE.get(sanitized) if (exact) { return { name: exact } } const withoutAgentSuffix = sanitized.replace(/_agent$/, '') - const suffixMatch = ANNOUNCED_NAME_TABLE[withoutAgentSuffix] + const suffixMatch = ANNOUNCED_NAME_TABLE.get(withoutAgentSuffix) if (suffixMatch) { return { name: suffixMatch } } @@ -57,7 +57,7 @@ const parseAnnouncedName = (raw: string): ParsedAnnouncedName => { const lastUnderscore = withoutAgentSuffix.lastIndexOf('_') if (lastUnderscore !== -1) { const head = withoutAgentSuffix.slice(0, lastUnderscore) - const headMatch = ANNOUNCED_NAME_TABLE[head] + const headMatch = ANNOUNCED_NAME_TABLE.get(head) if (headMatch) { const tail = withoutAgentSuffix.slice(lastUnderscore + 1) return { name: headMatch, version: tail.replace(/-/g, '.') } diff --git a/tests/unit/utils/agent-detection.test.ts b/tests/unit/utils/agent-detection.test.ts index bc7344efb04..eb00d13a90a 100644 --- a/tests/unit/utils/agent-detection.test.ts +++ b/tests/unit/utils/agent-detection.test.ts @@ -98,14 +98,25 @@ test('a single match omits markers', () => { test('NETLIFY_AGENT overrides every other signal and lists all matched names as markers', () => { expect( getDrivingAgent({ - NETLIFY_AGENT: 'gemini', 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', 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: 'gemini', + name: 'chatgpt', source: 'NETLIFY_AGENT', - markers: ['gemini', 'codex', 'claude'], + markers: ['chatgpt', 'codex', 'gemini', 'copilot', 'opencode', 'kiro', 'claude', 'cursor', 'cline', 'amp'], }) }) @@ -155,6 +166,46 @@ test('an unknown, oversized NETLIFY_AGENT value is sanitized and capped at 64 ch expect(result?.otherValue).toHaveLength(64) }) +test('NETLIFY_AGENT=constructor does not resolve via the Object prototype chain', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'constructor' })).toEqual({ + name: 'other', + source: 'NETLIFY_AGENT', + otherValue: 'constructor', + }) +}) + +test('NETLIFY_AGENT=__proto__ does not resolve via the Object prototype chain', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: '__proto__' })).toEqual({ + name: 'other', + source: 'NETLIFY_AGENT', + otherValue: '__proto__', + }) +}) + +test('NETLIFY_AGENT=toString does not resolve via the Object prototype chain', () => { + expect(getDrivingAgent({ NETLIFY_AGENT: 'toString' })).toEqual({ + name: 'other', + source: 'NETLIFY_AGENT', + otherValue: 'toString', + }) +}) + +test('AI_AGENT=constructor does not resolve via the Object prototype chain', () => { + expect(getDrivingAgent({ AI_AGENT: 'constructor' })).toEqual({ + name: 'other', + source: 'AI_AGENT', + otherValue: 'constructor', + }) +}) + +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() }) From 608b21eeceb299d3e1150bc2b56df385ab4699a2 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Tue, 8 Sep 2026 15:22:44 -0400 Subject: [PATCH 05/13] test: consolidate prototype-key regression cases Replace four near-duplicate single-key tests with one test.each over constructor, __proto__, and toString, each asserting both NETLIFY_AGENT and AI_AGENT fall through to the other resolution. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y8HbdxER5EJPxNKqcorkTp --- tests/unit/utils/agent-detection.test.ts | 29 ++++-------------------- 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/tests/unit/utils/agent-detection.test.ts b/tests/unit/utils/agent-detection.test.ts index eb00d13a90a..99cccfd92fa 100644 --- a/tests/unit/utils/agent-detection.test.ts +++ b/tests/unit/utils/agent-detection.test.ts @@ -166,35 +166,16 @@ test('an unknown, oversized NETLIFY_AGENT value is sanitized and capped at 64 ch expect(result?.otherValue).toHaveLength(64) }) -test('NETLIFY_AGENT=constructor does not resolve via the Object prototype chain', () => { - expect(getDrivingAgent({ NETLIFY_AGENT: 'constructor' })).toEqual({ +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: 'constructor', + otherValue: key, }) -}) - -test('NETLIFY_AGENT=__proto__ does not resolve via the Object prototype chain', () => { - expect(getDrivingAgent({ NETLIFY_AGENT: '__proto__' })).toEqual({ - name: 'other', - source: 'NETLIFY_AGENT', - otherValue: '__proto__', - }) -}) - -test('NETLIFY_AGENT=toString does not resolve via the Object prototype chain', () => { - expect(getDrivingAgent({ NETLIFY_AGENT: 'toString' })).toEqual({ - name: 'other', - source: 'NETLIFY_AGENT', - otherValue: 'toString', - }) -}) - -test('AI_AGENT=constructor does not resolve via the Object prototype chain', () => { - expect(getDrivingAgent({ AI_AGENT: 'constructor' })).toEqual({ + expect(getDrivingAgent({ AI_AGENT: key })).toEqual({ name: 'other', source: 'AI_AGENT', - otherValue: 'constructor', + otherValue: key, }) }) From 5154f788c8d65b07c2986142ec178ab6cb216735 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Wed, 9 Sep 2026 13:19:01 -0400 Subject: [PATCH 06/13] feat: detect Warp runs and let NETLIFY_AGENT override unconditionally Warp's agent harness injects OZ_RUN_ID and WARP_RUN_ID when it launches a run, so both now resolve to `warp`, placed ahead of AI_AGENT. An unknown NETLIFY_AGENT value now wins over recognized markers so the raw value is never dropped. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y8HbdxER5EJPxNKqcorkTp --- docs/index.md | 2 +- src/utils/agent-detection.ts | 12 ++++++++- tests/unit/utils/agent-detection.test.ts | 31 +++++++++++++++++++++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index b5206601090..f6cfb940f9d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -212,7 +212,7 @@ Watch for project deploy to finish The CLI reads the `NETLIFY_AGENT` environment variable to learn which AI agent or tool is running it. Agents, MCP servers, and wrappers that invoke the CLI should set it to their name. Known names resolve to a canonical agent name; anything else is recorded as `other`. The value is limited to letters, digits, `_`, `.`, and `-`, and is truncated to 64 characters. -Recognized values: `claude`, `codex`, `copilot`, `gemini`, `cursor`, `opencode`, `kiro`, `cline`, `amp`, `claudeai`, `chatgpt`, `claude-code`, `claude-ai`, `github_copilot_vscode_agent`. +Recognized values: `claude`, `codex`, `copilot`, `gemini`, `cursor`, `opencode`, `kiro`, `cline`, `amp`, `warp`, `claudeai`, `chatgpt`, `claude-code`, `claude-ai`, `github_copilot_vscode_agent`. ``` NETLIFY_AGENT=claude-code netlify deploy diff --git a/src/utils/agent-detection.ts b/src/utils/agent-detection.ts index 778845e249f..336d8776ef2 100644 --- a/src/utils/agent-detection.ts +++ b/src/utils/agent-detection.ts @@ -18,6 +18,7 @@ export const CANONICAL_AGENT_NAMES = [ 'kiro', 'cline', 'amp', + 'warp', 'claudeai', 'chatgpt', 'other', @@ -111,6 +112,14 @@ const SIGNALS: Signal[] = [ source: 'AGENT_CONTEXT_OUT', detect: (env) => (nonEmpty(env.AGENT_CONTEXT_OUT) === undefined ? undefined : { name: 'kiro' }), }, + { + 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' }), + }, { source: 'AI_AGENT', detect: (env) => { @@ -156,7 +165,8 @@ export const getDrivingAgent = (env: NodeJS.ProcessEnv = process.env): DrivingAg return undefined } - const winner = matches.find((match) => match.name !== 'other') ?? matches[0] + const [first] = matches + const winner = first.source === 'NETLIFY_AGENT' ? first : (matches.find((match) => match.name !== 'other') ?? first) const version = winner.source === 'AI_AGENT' diff --git a/tests/unit/utils/agent-detection.test.ts b/tests/unit/utils/agent-detection.test.ts index 99cccfd92fa..efca42877c1 100644 --- a/tests/unit/utils/agent-detection.test.ts +++ b/tests/unit/utils/agent-detection.test.ts @@ -51,6 +51,14 @@ test('resolves AGENT_CONTEXT_OUT to kiro', () => { }) }) +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', @@ -105,6 +113,8 @@ test('NETLIFY_AGENT overrides every other signal and lists all matched names as 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', @@ -116,7 +126,24 @@ test('NETLIFY_AGENT overrides every other signal and lists all matched names as ).toEqual({ name: 'chatgpt', source: 'NETLIFY_AGENT', - markers: ['chatgpt', 'codex', 'gemini', 'copilot', 'opencode', 'kiro', 'claude', 'cursor', 'cline', 'amp'], + markers: ['chatgpt', 'codex', 'gemini', 'copilot', 'opencode', 'kiro', 'warp', 'claude', 'cursor', 'cline', 'amp'], + }) +}) + +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('nests AI_AGENT under a Warp run marker', () => { + expect(getDrivingAgent({ OZ_RUN_ID: 'run-123', AI_AGENT: 'claude-code_2-1-263_agent' })).toEqual({ + name: 'warp', + source: 'OZ_RUN_ID', + markers: ['warp', 'claude'], }) }) @@ -235,6 +262,8 @@ test('falls back to process.env when no argument is given', () => { 'OPENCODE_TERMINAL', 'AGENT_DISPLAY_OUT', 'AGENT_CONTEXT_OUT', + 'OZ_RUN_ID', + 'WARP_RUN_ID', 'AI_AGENT', 'COPILOT_AGENT', 'CURSOR_AGENT', From 5b2eae40f908388412ed1e785245376d5339ec9b Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Wed, 9 Sep 2026 14:37:26 -0400 Subject: [PATCH 07/13] fix: address CodeRabbit review on agent detection Type `name` and `markers` as CanonicalAgentName, match announced names case-insensitively while preserving otherValue casing, keep a version parsed from NETLIFY_AGENT, and tag the docs fence as bash. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y8HbdxER5EJPxNKqcorkTp --- docs/index.md | 2 +- src/utils/agent-detection.ts | 28 ++++++++++-------------- tests/unit/utils/agent-detection.test.ts | 20 +++++++++++++++++ 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/docs/index.md b/docs/index.md index f6cfb940f9d..29ac94b10be 100644 --- a/docs/index.md +++ b/docs/index.md @@ -214,7 +214,7 @@ The CLI reads the `NETLIFY_AGENT` environment variable to learn which AI agent o Recognized values: `claude`, `codex`, `copilot`, `gemini`, `cursor`, `opencode`, `kiro`, `cline`, `amp`, `warp`, `claudeai`, `chatgpt`, `claude-code`, `claude-ai`, `github_copilot_vscode_agent`. -``` +```bash NETLIFY_AGENT=claude-code netlify deploy ``` diff --git a/src/utils/agent-detection.ts b/src/utils/agent-detection.ts index 336d8776ef2..139ff048540 100644 --- a/src/utils/agent-detection.ts +++ b/src/utils/agent-detection.ts @@ -1,13 +1,5 @@ // Only markers an agent product sets on its own count as a signal; never infer from process names, terminals, or the process tree. -export type DrivingAgent = { - name: string - source: string - version?: string - markers?: string[] - otherValue?: string -} - export const CANONICAL_AGENT_NAMES = [ 'claude', 'codex', @@ -26,6 +18,14 @@ export const CANONICAL_AGENT_NAMES = [ 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'], @@ -43,13 +43,14 @@ const sanitizeAnnouncedValue = (raw: string): string => raw.replace(/[^A-Za-z0-9 const parseAnnouncedName = (raw: string): ParsedAnnouncedName => { const sanitized = sanitizeAnnouncedValue(raw) + const key = sanitized.toLowerCase() - const exact = ANNOUNCED_NAME_TABLE.get(sanitized) + const exact = ANNOUNCED_NAME_TABLE.get(key) if (exact) { return { name: exact } } - const withoutAgentSuffix = sanitized.replace(/_agent$/, '') + const withoutAgentSuffix = key.replace(/_agent$/, '') const suffixMatch = ANNOUNCED_NAME_TABLE.get(withoutAgentSuffix) if (suffixMatch) { return { name: suffixMatch } @@ -168,12 +169,7 @@ export const getDrivingAgent = (env: NodeJS.ProcessEnv = process.env): DrivingAg const [first] = matches const winner = first.source === 'NETLIFY_AGENT' ? first : (matches.find((match) => match.name !== 'other') ?? first) - const version = - winner.source === 'AI_AGENT' - ? winner.version - : winner.source === 'CODEX_CI' - ? nonEmpty(env.CODEX_VERSION) - : undefined + const version = winner.version ?? (winner.source === 'CODEX_CI' ? nonEmpty(env.CODEX_VERSION) : undefined) const distinctNames = [...new Set(matches.map((match) => match.name))] diff --git a/tests/unit/utils/agent-detection.test.ts b/tests/unit/utils/agent-detection.test.ts index efca42877c1..e02f6bbba16 100644 --- a/tests/unit/utils/agent-detection.test.ts +++ b/tests/unit/utils/agent-detection.test.ts @@ -6,6 +6,26 @@ 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' }) }) From 4ec97723f2251088a568018e4fc4eb47f21a41d3 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Wed, 9 Sep 2026 16:07:25 -0400 Subject: [PATCH 08/13] fix: tighten agent-detection precedence and input hardening Warp run markers now sit last so the agent inside a Warp run wins and Warp is recorded in markers. Kiro requires both AGENT_DISPLAY_OUT and AGENT_CONTEXT_OUT. CODEX_VERSION passes through the same character filter and 64-character cap as announced names. The NETLIFY_AGENT docs section moves to the first consumer PR, since nothing reads the helper yet. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y8HbdxER5EJPxNKqcorkTp --- docs/index.md | 12 -------- src/utils/agent-detection.ts | 31 ++++++++++--------- tests/unit/utils/agent-detection.test.ts | 39 +++++++++++++++--------- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/docs/index.md b/docs/index.md index 29ac94b10be..e681df42f73 100644 --- a/docs/index.md +++ b/docs/index.md @@ -207,15 +207,3 @@ Watch for project deploy to finish - -## Agent detection - -The CLI reads the `NETLIFY_AGENT` environment variable to learn which AI agent or tool is running it. Agents, MCP servers, and wrappers that invoke the CLI should set it to their name. Known names resolve to a canonical agent name; anything else is recorded as `other`. The value is limited to letters, digits, `_`, `.`, and `-`, and is truncated to 64 characters. - -Recognized values: `claude`, `codex`, `copilot`, `gemini`, `cursor`, `opencode`, `kiro`, `cline`, `amp`, `warp`, `claudeai`, `chatgpt`, `claude-code`, `claude-ai`, `github_copilot_vscode_agent`. - -```bash -NETLIFY_AGENT=claude-code netlify deploy -``` - -The CLI also recognizes markers that agent products set on their own (for example `AI_AGENT`, `CODEX_CI`, `GEMINI_CLI`); `NETLIFY_AGENT` takes precedence over all of them. diff --git a/src/utils/agent-detection.ts b/src/utils/agent-detection.ts index 139ff048540..f167901c6d7 100644 --- a/src/utils/agent-detection.ts +++ b/src/utils/agent-detection.ts @@ -76,6 +76,9 @@ type Signal = { detect: (env: NodeJS.ProcessEnv) => ParsedAnnouncedName | undefined } +// Precedence, first match with a recognized name wins: NETLIFY_AGENT (explicit, wins even when unknown); +// markers only the process running the command sets; AI_AGENT; 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', @@ -107,19 +110,10 @@ const SIGNALS: Signal[] = [ }, { source: 'AGENT_DISPLAY_OUT', - detect: (env) => (nonEmpty(env.AGENT_DISPLAY_OUT) === undefined ? undefined : { name: 'kiro' }), - }, - { - source: 'AGENT_CONTEXT_OUT', - detect: (env) => (nonEmpty(env.AGENT_CONTEXT_OUT) === undefined ? undefined : { name: 'kiro' }), - }, - { - 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' }), + detect: (env) => + nonEmpty(env.AGENT_DISPLAY_OUT) !== undefined && nonEmpty(env.AGENT_CONTEXT_OUT) !== undefined + ? { name: 'kiro' } + : undefined, }, { source: 'AI_AGENT', @@ -148,6 +142,14 @@ const SIGNALS: Signal[] = [ 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 @@ -169,7 +171,8 @@ export const getDrivingAgent = (env: NodeJS.ProcessEnv = process.env): DrivingAg const [first] = matches const winner = first.source === 'NETLIFY_AGENT' ? first : (matches.find((match) => match.name !== 'other') ?? first) - const version = winner.version ?? (winner.source === 'CODEX_CI' ? nonEmpty(env.CODEX_VERSION) : undefined) + 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))] diff --git a/tests/unit/utils/agent-detection.test.ts b/tests/unit/utils/agent-detection.test.ts index e02f6bbba16..363671fe174 100644 --- a/tests/unit/utils/agent-detection.test.ts +++ b/tests/unit/utils/agent-detection.test.ts @@ -57,18 +57,18 @@ test('resolves OPENCODE when OPENCODE_TERMINAL is unset', () => { expect(getDrivingAgent({ OPENCODE: '1' })).toEqual({ name: 'opencode', source: 'OPENCODE' }) }) -test('resolves AGENT_DISPLAY_OUT to kiro without surfacing its value', () => { - expect(getDrivingAgent({ AGENT_DISPLAY_OUT: '/tmp/agent-display-output.json' })).toEqual({ - name: 'kiro', - source: 'AGENT_DISPLAY_OUT', - }) +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('resolves AGENT_CONTEXT_OUT to kiro', () => { - expect(getDrivingAgent({ AGENT_CONTEXT_OUT: '/tmp/agent-context-output.json' })).toEqual({ - name: 'kiro', - source: 'AGENT_CONTEXT_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', () => { @@ -146,7 +146,7 @@ test('NETLIFY_AGENT overrides every other signal and lists all matched names as ).toEqual({ name: 'chatgpt', source: 'NETLIFY_AGENT', - markers: ['chatgpt', 'codex', 'gemini', 'copilot', 'opencode', 'kiro', 'warp', 'claude', 'cursor', 'cline', 'amp'], + markers: ['chatgpt', 'codex', 'gemini', 'copilot', 'opencode', 'kiro', 'claude', 'cursor', 'cline', 'amp', 'warp'], }) }) @@ -159,11 +159,20 @@ test('an unknown NETLIFY_AGENT still overrides a recognized marker and keeps its }) }) -test('nests AI_AGENT under a Warp run marker', () => { +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: 'warp', - source: 'OZ_RUN_ID', - markers: ['warp', 'claude'], + 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)}`, }) }) From 000b3b8cbe6f7ea109724b63f814d6e126990d84 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Wed, 9 Sep 2026 16:26:15 -0400 Subject: [PATCH 09/13] fix: honor name@version announcements and let the first match win Split the cross-vendor name@version form before sanitizing so AI_AGENT=codex@1.2.3 resolves to codex 1.2.3. The first match now wins outright: every tier ahead of AI_AGENT returns a recognized name, and both explicit announcements keep their raw value when unknown. A value that sanitizes to nothing is treated as unset. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y8HbdxER5EJPxNKqcorkTp --- src/utils/agent-detection.ts | 41 ++++++++++++------------ tests/unit/utils/agent-detection.test.ts | 37 +++++++++++++++------ 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/src/utils/agent-detection.ts b/src/utils/agent-detection.ts index f167901c6d7..990291dea31 100644 --- a/src/utils/agent-detection.ts +++ b/src/utils/agent-detection.ts @@ -41,19 +41,27 @@ type ParsedAnnouncedName = { const sanitizeAnnouncedValue = (raw: string): string => raw.replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 64) -const parseAnnouncedName = (raw: string): ParsedAnnouncedName => { - const sanitized = sanitizeAnnouncedValue(raw) +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 = ANNOUNCED_NAME_TABLE.get(key) if (exact) { - return { name: exact } + return { name: exact, version: announcedVersion } } const withoutAgentSuffix = key.replace(/_agent$/, '') const suffixMatch = ANNOUNCED_NAME_TABLE.get(withoutAgentSuffix) if (suffixMatch) { - return { name: suffixMatch } + return { name: suffixMatch, version: announcedVersion } } const lastUnderscore = withoutAgentSuffix.lastIndexOf('_') @@ -62,30 +70,25 @@ const parseAnnouncedName = (raw: string): ParsedAnnouncedName => { const headMatch = ANNOUNCED_NAME_TABLE.get(head) if (headMatch) { const tail = withoutAgentSuffix.slice(lastUnderscore + 1) - return { name: headMatch, version: tail.replace(/-/g, '.') } + return { name: headMatch, version: announcedVersion ?? tail.replace(/-/g, '.') } } } - return { name: 'other', otherValue: sanitized } + return { name: 'other', otherValue: sanitized, version: announcedVersion } } -const nonEmpty = (value: string | undefined): string | undefined => (value ? value : undefined) - type Signal = { source: string detect: (env: NodeJS.ProcessEnv) => ParsedAnnouncedName | undefined } -// Precedence, first match with a recognized name wins: NETLIFY_AGENT (explicit, wins even when unknown); -// markers only the process running the command sets; AI_AGENT; 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. +// 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) => { - const value = nonEmpty(env.NETLIFY_AGENT) - return value === undefined ? undefined : parseAnnouncedName(value) - }, + detect: (env) => (env.NETLIFY_AGENT === undefined ? undefined : parseAnnouncedName(env.NETLIFY_AGENT)), }, { source: 'CODEX_CI', @@ -117,10 +120,7 @@ const SIGNALS: Signal[] = [ }, { source: 'AI_AGENT', - detect: (env) => { - const value = nonEmpty(env.AI_AGENT) - return value === undefined ? undefined : parseAnnouncedName(value) - }, + detect: (env) => (env.AI_AGENT === undefined ? undefined : parseAnnouncedName(env.AI_AGENT)), }, { source: 'COPILOT_AGENT', @@ -168,8 +168,7 @@ export const getDrivingAgent = (env: NodeJS.ProcessEnv = process.env): DrivingAg return undefined } - const [first] = matches - const winner = first.source === 'NETLIFY_AGENT' ? first : (matches.find((match) => match.name !== 'other') ?? first) + const [winner] = matches const codexVersion = winner.source === 'CODEX_CI' ? nonEmpty(env.CODEX_VERSION) : undefined const version = winner.version ?? (codexVersion === undefined ? undefined : sanitizeAnnouncedValue(codexVersion)) diff --git a/tests/unit/utils/agent-detection.test.ts b/tests/unit/utils/agent-detection.test.ts index 363671fe174..38d42a3a0d4 100644 --- a/tests/unit/utils/agent-detection.test.ts +++ b/tests/unit/utils/agent-detection.test.ts @@ -197,19 +197,38 @@ test('unknown AI_AGENT resolves to other with otherValue', () => { }) }) -test('a recognized name beats an other match but still lists it in markers', () => { - expect( - getDrivingAgent({ - AI_AGENT: 'some-new-tool_1-0_agent', - CURSOR_AGENT: '1', - }), - ).toEqual({ - name: 'cursor', - source: 'CURSOR_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 }) From 6a205b58b87798afbe35f718d82d4a7effd03285 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Wed, 9 Sep 2026 16:36:48 -0400 Subject: [PATCH 10/13] feat: recognize documented AI_AGENT aliases Add the spellings the cross-vendor convention documents (cursor-cli, github-copilot, github-copilot-cli, gemini-cli, kiro-cli, warp-oz) and normalize underscores to dashes for table lookup so either separator resolves. Note in the header that the result is untrusted attribution. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y8HbdxER5EJPxNKqcorkTp --- src/utils/agent-detection.ts | 18 ++++++++++++++---- tests/unit/utils/agent-detection.test.ts | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/utils/agent-detection.ts b/src/utils/agent-detection.ts index 990291dea31..c585d0f5dfd 100644 --- a/src/utils/agent-detection.ts +++ b/src/utils/agent-detection.ts @@ -1,4 +1,5 @@ // 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', @@ -30,9 +31,18 @@ const ANNOUNCED_NAME_TABLE = new Map([ ...CANONICAL_AGENT_NAMES.map((name) => [name, name] as const), ['claude-code', 'claude'], ['claude-ai', 'claudeai'], - ['github_copilot_vscode_agent', 'copilot'], + ['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 @@ -53,13 +63,13 @@ const parseAnnouncedName = (raw: string): ParsedAnnouncedName | undefined => { const announcedVersion = atIndex === -1 ? undefined : nonEmpty(sanitizeAnnouncedValue(raw.slice(atIndex + 1))) const key = sanitized.toLowerCase() - const exact = ANNOUNCED_NAME_TABLE.get(key) + const exact = lookupAnnouncedName(key) if (exact) { return { name: exact, version: announcedVersion } } const withoutAgentSuffix = key.replace(/_agent$/, '') - const suffixMatch = ANNOUNCED_NAME_TABLE.get(withoutAgentSuffix) + const suffixMatch = lookupAnnouncedName(withoutAgentSuffix) if (suffixMatch) { return { name: suffixMatch, version: announcedVersion } } @@ -67,7 +77,7 @@ const parseAnnouncedName = (raw: string): ParsedAnnouncedName | undefined => { const lastUnderscore = withoutAgentSuffix.lastIndexOf('_') if (lastUnderscore !== -1) { const head = withoutAgentSuffix.slice(0, lastUnderscore) - const headMatch = ANNOUNCED_NAME_TABLE.get(head) + const headMatch = lookupAnnouncedName(head) if (headMatch) { const tail = withoutAgentSuffix.slice(lastUnderscore + 1) return { name: headMatch, version: announcedVersion ?? tail.replace(/-/g, '.') } diff --git a/tests/unit/utils/agent-detection.test.ts b/tests/unit/utils/agent-detection.test.ts index 38d42a3a0d4..6b3ed84c52f 100644 --- a/tests/unit/utils/agent-detection.test.ts +++ b/tests/unit/utils/agent-detection.test.ts @@ -87,6 +87,23 @@ test('parses AI_AGENT claude-code_2-1-263_agent into claude with 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', From 33d6d51b3eb9c8d51b5c139d5383dfd01c91ecf6 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Thu, 10 Sep 2026 13:12:06 -0400 Subject: [PATCH 11/13] feat: attach the driving agent to telemetry events Every track() event now carries agent, agent_source, and, when known, agent_version, agent_markers, and agent_other_value from getDrivingAgent(). identify() is unchanged, and opt-out and CI gating still run first. Refs EX-3042 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018Nf8kmBETcYa5rjexXUSzL --- src/utils/telemetry/telemetry.ts | 18 ++- tests/unit/utils/telemetry/telemetry.test.ts | 111 +++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/unit/utils/telemetry/telemetry.test.ts diff --git a/src/utils/telemetry/telemetry.ts b/src/utils/telemetry/telemetry.ts index 6c4fba7caf5..eb70312449d 100644 --- a/src/utils/telemetry/telemetry.ts +++ b/src/utils/telemetry/telemetry.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'url' import { getGlobalConfigStore } from '@netlify/dev-utils' import { isCI } from 'ci-info' +import { getDrivingAgent } from '../agent-detection.js' import execa from '../execa.js' import { isTelemetryDisabled, cliVersion } from './utils.js' @@ -45,6 +46,21 @@ const eventConfig = { ], } +const getAgentProperties = () => { + const agent = getDrivingAgent() + if (!agent) { + return {} + } + + return { + agent: agent.name, + agent_source: agent.source, + ...(agent.version === undefined ? {} : { agent_version: agent.version }), + ...(agent.markers === undefined ? {} : { agent_markers: agent.markers }), + ...(agent.otherValue === undefined ? {} : { agent_other_value: agent.otherValue }), + } +} + /** * Tracks a custom event with the provided payload */ @@ -82,7 +98,7 @@ export async function track( anonymousId: cliId, duration, status, - properties: { ...properties, nodejsVersion, cliVersion }, + properties: { ...properties, nodejsVersion, cliVersion, ...getAgentProperties() }, } return send('track', defaultData) diff --git a/tests/unit/utils/telemetry/telemetry.test.ts b/tests/unit/utils/telemetry/telemetry.test.ts new file mode 100644 index 00000000000..bcc8f3dc563 --- /dev/null +++ b/tests/unit/utils/telemetry/telemetry.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { track } from '../../../../src/utils/telemetry/telemetry.js' +import { cliVersion } from '../../../../src/utils/telemetry/utils.js' +import execa from '../../../../src/utils/execa.js' + +vi.mock('ci-info', () => ({ isCI: false })) + +vi.mock('@netlify/dev-utils', async (importOriginal) => ({ + ...(await importOriginal()), + getGlobalConfigStore: vi.fn(() => + Promise.resolve({ get: (key: string) => (key === 'telemetryDisabled' ? false : 'test-user-1') }), + ), +})) + +vi.mock('../../../../src/utils/execa.js', () => ({ default: vi.fn(() => ({ unref: vi.fn() })) })) + +const AGENT_ENV_KEYS = [ + 'NETLIFY_AGENT', + 'CODEX_CI', + 'CODEX_VERSION', + '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', +] + +const getTrackedProperties = (): Record => { + const { calls } = vi.mocked(execa).mock + const [, [, optionsJson]] = calls[calls.length - 1] as [string, string[]] + return (JSON.parse(optionsJson) as { data: { properties: Record } }).data.properties +} + +const getTrackedAgentProperties = () => + Object.fromEntries( + Object.entries(getTrackedProperties()).filter(([key]) => key === 'agent' || key.startsWith('agent_')), + ) + +beforeEach(() => { + vi.clearAllMocks() + AGENT_ENV_KEYS.forEach((key) => vi.stubEnv(key, undefined)) +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('track', () => { + test('adds the driving agent alongside the existing properties', async () => { + vi.stubEnv('AI_AGENT', 'claude-code') + + await track('command', { command: 'status' }) + + expect(getTrackedProperties()).toMatchObject({ command: 'status', cliVersion }) + expect(getTrackedAgentProperties()).toEqual({ agent: 'claude', agent_source: 'AI_AGENT' }) + }) + + test('adds no agent properties when no agent is detected', async () => { + await track('command', { command: 'status' }) + + expect(getTrackedAgentProperties()).toEqual({}) + }) + + test('adds the agent version when the agent announces one', async () => { + vi.stubEnv('AI_AGENT', 'claude-code_2-1-263_agent') + + await track('command', { command: 'status' }) + + expect(getTrackedAgentProperties()).toEqual({ + agent: 'claude', + agent_source: 'AI_AGENT', + agent_version: '2.1.263', + }) + }) + + test('lists every matched agent when agents are nested', async () => { + vi.stubEnv('AI_AGENT', 'claude-code') + vi.stubEnv('CODEX_CI', '1') + + await track('command', { command: 'status' }) + + expect(getTrackedAgentProperties()).toEqual({ + agent: 'codex', + agent_source: 'CODEX_CI', + agent_markers: ['codex', 'claude'], + }) + }) + + test('reports an unknown AI_AGENT value as other with its raw value', async () => { + vi.stubEnv('AI_AGENT', 'some-new-tool') + + await track('command', { command: 'status' }) + + expect(getTrackedAgentProperties()).toEqual({ + agent: 'other', + agent_source: 'AI_AGENT', + agent_other_value: 'some-new-tool', + }) + }) +}) From 4f106101ad11591bcddeb0af4116fcad39cd35b7 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Thu, 10 Sep 2026 13:38:25 -0400 Subject: [PATCH 12/13] fix: keep agent telemetry fields authoritative and document them track() now always sets all five agent keys, so a caller's payload can't supply its own agent attribution when no agent is detected; undefined values still drop out when the event is serialized. Restores the NETLIFY_AGENT docs that #8492 deferred to its first consumer, updated for the current aliases, and lists the telemetry fields and opt-out. Refs EX-3042 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018Nf8kmBETcYa5rjexXUSzL --- docs/index.md | 24 ++++++++++++++++++++ src/utils/telemetry/telemetry.ts | 15 ++++++------ tests/unit/utils/telemetry/telemetry.test.ts | 15 +++++++++++- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/docs/index.md b/docs/index.md index e681df42f73..899e8d0aa03 100644 --- a/docs/index.md +++ b/docs/index.md @@ -207,3 +207,27 @@ Watch for project deploy to finish + +## Agent detection + +The CLI reads the `NETLIFY_AGENT` environment variable to learn which AI agent or tool is running it. Agents, MCP servers, and wrappers that invoke the CLI should set it to their name, optionally followed by `@` and a version: + +```bash +NETLIFY_AGENT=claude-code@2.1.0 netlify deploy +``` + +Recognized values are `claude`, `codex`, `copilot`, `gemini`, `cursor`, `opencode`, `kiro`, `cline`, `amp`, `warp`, `claudeai`, and `chatgpt`, plus the aliases `claude-code`, `claude-ai`, `github-copilot`, `github-copilot-cli`, `github-copilot-vscode-agent`, `cursor-cli`, `gemini-cli`, `kiro-cli`, and `warp-oz`. Matching ignores case and treats `_` as `-`. Any other value is recorded as `other`. Characters other than letters, digits, `_`, `.`, and `-` are removed, and values are truncated to 64 characters. + +The CLI also recognizes markers that agent products set on their own, such as `AI_AGENT`, `CODEX_CI`, and `GEMINI_CLI`. `NETLIFY_AGENT` takes precedence over all of them, even when its value isn't recognized. + +### Telemetry + +When telemetry is enabled, each CLI telemetry event includes the detected agent: + +- `agent`: the recognized name, or `other` +- `agent_source`: the name of the environment variable that identified the agent +- `agent_version`: the version, when the agent provides one +- `agent_markers`: every detected agent name, when markers from more than one agent are present +- `agent_other_value`: the sanitized value, when `agent` is `other` + +No agent fields are sent when no agent is detected. Telemetry isn't sent in CI, or at all after you run `netlify --telemetry-disable`. diff --git a/src/utils/telemetry/telemetry.ts b/src/utils/telemetry/telemetry.ts index eb70312449d..550828517bc 100644 --- a/src/utils/telemetry/telemetry.ts +++ b/src/utils/telemetry/telemetry.ts @@ -46,18 +46,17 @@ const eventConfig = { ], } +// Every key is always present so a caller's payload can never supply its own agent attribution; +// undefined values are dropped when the event is serialized. const getAgentProperties = () => { const agent = getDrivingAgent() - if (!agent) { - return {} - } return { - agent: agent.name, - agent_source: agent.source, - ...(agent.version === undefined ? {} : { agent_version: agent.version }), - ...(agent.markers === undefined ? {} : { agent_markers: agent.markers }), - ...(agent.otherValue === undefined ? {} : { agent_other_value: agent.otherValue }), + agent: agent?.name, + agent_source: agent?.source, + agent_version: agent?.version, + agent_markers: agent?.markers, + agent_other_value: agent?.otherValue, } } diff --git a/tests/unit/utils/telemetry/telemetry.test.ts b/tests/unit/utils/telemetry/telemetry.test.ts index bcc8f3dc563..090fdb8b5e1 100644 --- a/tests/unit/utils/telemetry/telemetry.test.ts +++ b/tests/unit/utils/telemetry/telemetry.test.ts @@ -72,6 +72,19 @@ describe('track', () => { expect(getTrackedAgentProperties()).toEqual({}) }) + test('drops agent properties supplied by the caller', async () => { + await track('command', { + command: 'status', + agent: 'spoofed', + agent_source: 'spoofed', + agent_version: 'spoofed', + agent_markers: ['spoofed'], + agent_other_value: 'spoofed', + }) + + expect(getTrackedAgentProperties()).toEqual({}) + }) + test('adds the agent version when the agent announces one', async () => { vi.stubEnv('AI_AGENT', 'claude-code_2-1-263_agent') @@ -97,7 +110,7 @@ describe('track', () => { }) }) - test('reports an unknown AI_AGENT value as other with its raw value', async () => { + test('reports an unknown AI_AGENT value as other with its sanitized value', async () => { vi.stubEnv('AI_AGENT', 'some-new-tool') await track('command', { command: 'status' }) From 996073da02787ae8fd6133ac919d71a584fdd1d3 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Thu, 10 Sep 2026 14:36:19 -0400 Subject: [PATCH 13/13] fix: keep an announced "other" value in otherValue NETLIFY_AGENT=other or AI_AGENT=other matched the canonical name in the lookup table, so otherValue came back empty and telemetry sent agent_other_value: ''. "other" is now left out of the lookup and falls through to the unknown-value path like any other unrecognized name. Refs EX-3042 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018Nf8kmBETcYa5rjexXUSzL --- src/utils/agent-detection.ts | 2 +- tests/unit/utils/agent-detection.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/utils/agent-detection.ts b/src/utils/agent-detection.ts index c585d0f5dfd..1e94020994b 100644 --- a/src/utils/agent-detection.ts +++ b/src/utils/agent-detection.ts @@ -28,7 +28,7 @@ export type DrivingAgent = { } const ANNOUNCED_NAME_TABLE = new Map([ - ...CANONICAL_AGENT_NAMES.map((name) => [name, name] as const), + ...CANONICAL_AGENT_NAMES.filter((name) => name !== 'other').map((name) => [name, name] as const), ['claude-code', 'claude'], ['claude-ai', 'claudeai'], ['github-copilot', 'copilot'], diff --git a/tests/unit/utils/agent-detection.test.ts b/tests/unit/utils/agent-detection.test.ts index 6b3ed84c52f..bcb117faee2 100644 --- a/tests/unit/utils/agent-detection.test.ts +++ b/tests/unit/utils/agent-detection.test.ts @@ -271,6 +271,16 @@ test.each(['constructor', '__proto__', 'toString'])('%s does not resolve via the }) }) +test.each(['NETLIFY_AGENT', 'AI_AGENT'])('%s=other keeps the announced value in otherValue', (source) => { + expect(getDrivingAgent({ [source]: 'other' })).toEqual({ name: 'other', source, otherValue: 'other' }) + expect(getDrivingAgent({ [source]: 'Other@1.0' })).toEqual({ + name: 'other', + source, + version: '1.0', + otherValue: 'Other', + }) +}) + 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',