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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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',