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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 25b3edb90c185b70fe008b5e3b93093b63832085 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Thu, 10 Sep 2026 13:19:54 -0400 Subject: [PATCH 11/14] feat: add the driving agent to outgoing User-Agent headers Build the request User-Agent in one place (src/utils/user-agent.ts) and use it for the API client, the Drop API, and telemetry requests. When an agent is detected it appends agent/; otherwise the header is unchanged. The printed USER_AGENT (--version, help) keeps its original text. Track sites_aiContextInstalled with the consumer when the ai-context recipe installs context files. The ticket's ai_context_installed name fails the CLI's event-name validation, so it uses the existing sites object instead. Refs EX-3044 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nq1RjpFnzzsfZf5qHLCH36 --- src/commands/base-command.ts | 3 +- src/commands/claim/claim.ts | 6 +- src/commands/deploy/deploy.ts | 7 +- src/recipes/ai-context/index.ts | 3 + src/utils/command-helpers.ts | 9 +-- src/utils/deploy/drop-api.ts | 27 +++---- src/utils/telemetry/request.ts | 6 +- src/utils/user-agent.ts | 19 +++++ tests/unit/recipes/ai-context/index.test.ts | 53 +++++++++++++ tests/unit/utils/user-agent.test.ts | 85 +++++++++++++++++++++ 10 files changed, 182 insertions(+), 36 deletions(-) create mode 100644 src/utils/user-agent.ts create mode 100644 tests/unit/recipes/ai-context/index.test.ts create mode 100644 tests/unit/utils/user-agent.test.ts diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 8b4e9abfede..a2ecd70fcea 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -41,6 +41,7 @@ import { getSiteByName } from '../utils/get-site.js' import openBrowser from '../utils/open-browser.js' import { isInteractive } from '../utils/scripted-commands.js' import { identify, reportError, setCommandForErrorReporting, track } from '../utils/telemetry/index.js' +import { getRequestUserAgent } from '../utils/user-agent.js' import type { NetlifyOptions } from './types.js' import type { CachedConfig } from '../lib/build.js' import type { MinimalAccount } from '../utils/types.js' @@ -666,7 +667,7 @@ export default class BaseCommand extends Command { host?: string pathPrefix?: string } = { - userAgent: USER_AGENT, + userAgent: getRequestUserAgent(), } if (process.env.NETLIFY_API_URL) { diff --git a/src/commands/claim/claim.ts b/src/commands/claim/claim.ts index 5783ff78bf8..ae064584931 100644 --- a/src/commands/claim/claim.ts +++ b/src/commands/claim/claim.ts @@ -5,11 +5,7 @@ import type BaseCommand from '../base-command.js' export const claim = async (siteId: string, dropToken: string, command: BaseCommand) => { await command.authenticate() - const apiBase = command.netlify.api.basePath - const dropApiOptions = { - apiBase, - userAgent: command.netlify.api.defaultHeaders['User-agent'] || 'netlify-cli', - } + const dropApiOptions = { apiBase: command.netlify.api.basePath } const authToken = command.netlify.api.accessToken if (!authToken) { diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 7828979231d..fa5ba57f762 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -1237,12 +1237,7 @@ const anonymousDeploy = async (options: DeployOptionValues, command: BaseCommand log(`\n${NETLIFYDEVLOG} Deploying ${filesCount} files anonymously...`) - const apiBase = command.netlify.api.basePath - - const dropApiOptions = { - apiBase, - userAgent: command.netlify.api.defaultHeaders['User-agent'] || 'netlify-cli', - } + const dropApiOptions = { apiBase: command.netlify.api.basePath } const statusCb = options.json ? () => {} : deployProgressCb() diff --git a/src/recipes/ai-context/index.ts b/src/recipes/ai-context/index.ts index 8063062075d..d7b27a768a0 100644 --- a/src/recipes/ai-context/index.ts +++ b/src/recipes/ai-context/index.ts @@ -5,6 +5,7 @@ import execa from 'execa' import type { RunRecipeOptions } from '../../commands/recipes/recipes.js' import { logAndThrowError, log, version } from '../../utils/command-helpers.js' +import { track } from '../../utils/telemetry/index.js' import { getExistingContext, @@ -171,4 +172,6 @@ export const run = async (runOptions: RunRecipeOptions) => { } catch (error) { logAndThrowError(error) } + + await track('sites_aiContextInstalled', { consumer: consumer.key }) } diff --git a/src/utils/command-helpers.ts b/src/utils/command-helpers.ts index 0775c79f230..73d1b83d685 100644 --- a/src/utils/command-helpers.ts +++ b/src/utils/command-helpers.ts @@ -1,4 +1,3 @@ -import os from 'os' import fs from 'fs' import process from 'process' import { format, inspect } from 'util' @@ -7,7 +6,6 @@ import type { NetlifyAPI } from '@netlify/api' import { getAPIToken } from '@netlify/dev-utils' import { Chalk, type ChalkInstance as ChalkInstancePrimitiveType } from 'chalk' import type { Option } from 'commander' -import WSL from 'is-wsl' import terminalLink from 'terminal-link' import { startSpinner } from '../lib/spinner.js' @@ -46,13 +44,10 @@ export type ChalkInstance = ChalkInstancePrimitiveType */ export const padLeft = (str: string, count: number, filler = ' ') => str.padStart(str.length + count, filler) -const platform = WSL ? 'wsl' : os.platform() -const arch = os.arch() === 'ia32' ? 'x86' : os.arch() - -const { name, version: packageVersion } = await getCLIPackageJson() +const { version: packageVersion } = await getCLIPackageJson() export const version = packageVersion -export const USER_AGENT = `${name}/${version} ${platform}-${arch} node-${process.version}` +export { USER_AGENT } from './user-agent.js' /** A list of base command flags that needs to be sorted down on documentation and on help pages */ const BASE_FLAGS = new Set(['--debug', '--http-proxy', '--http-proxy-certificate-filename']) diff --git a/src/utils/deploy/drop-api.ts b/src/utils/deploy/drop-api.ts index 1a8ae9dc8e6..3653f443361 100644 --- a/src/utils/deploy/drop-api.ts +++ b/src/utils/deploy/drop-api.ts @@ -5,6 +5,8 @@ import fs from 'fs' import pWaitFor from 'p-wait-for' +import { getRequestUserAgent } from '../user-agent.js' + import { DEPLOY_POLL, DEFAULT_DEPLOY_TIMEOUT, DEFAULT_CONCURRENT_UPLOAD, DEFAULT_MAX_RETRY } from './constants.js' import type { StatusCallback } from './status-cb.js' @@ -21,24 +23,23 @@ interface DropDeployInfo { interface DropApiOptions { apiBase: string - userAgent: string } export interface DropApiError extends Error { status?: number } -const makeHeaders = (userAgent: string, extra: Record = {}): Record => ({ - 'User-Agent': userAgent, +const makeHeaders = (extra: Record = {}): Record => ({ + 'User-Agent': getRequestUserAgent(), Referer: APP_NETLIFY_REFERRER, ...extra, }) // TODO: Migrate to @netlify/api when Drop endpoints are in the OpenAPI spec. -export const getDropToken = async ({ apiBase, userAgent }: DropApiOptions): Promise => { +export const getDropToken = async ({ apiBase }: DropApiOptions): Promise => { const response = await fetch(`${apiBase}/drop/token`, { method: 'POST', - headers: makeHeaders(userAgent, { 'Content-Type': 'application/json' }), + headers: makeHeaders({ 'Content-Type': 'application/json' }), }) if (!response.ok) { @@ -53,7 +54,7 @@ export const getDropToken = async ({ apiBase, userAgent }: DropApiOptions): Prom // TODO: Migrate to @netlify/api when Drop endpoints are in the OpenAPI spec. export const createDropDeploy = async ( - { apiBase, userAgent }: DropApiOptions, + { apiBase }: DropApiOptions, files: Record, token: string, createdVia?: string, @@ -65,7 +66,7 @@ export const createDropDeploy = async ( const response = await fetch(`${apiBase}/drop`, { method: 'POST', - headers: makeHeaders(userAgent, { 'Content-Type': 'application/json' }), + headers: makeHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(body), }) @@ -85,7 +86,7 @@ interface UploadError extends Error { // TODO: Migrate to @netlify/api when Drop endpoints are in the OpenAPI spec. export const uploadDropFile = async ( - { apiBase, userAgent }: DropApiOptions, + { apiBase }: DropApiOptions, deployId: string, filePath: string, body: fs.ReadStream | Buffer, @@ -96,7 +97,7 @@ export const uploadDropFile = async ( const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}` const response: Response = await fetch(`${apiBase}/deploys/${deployId}/files${encodeURI(normalizedFilePath)}`, { method: 'PUT', - headers: makeHeaders(userAgent, { + headers: makeHeaders({ 'Content-Type': 'application/octet-stream', Authorization: `Bearer ${token}`, }), @@ -116,7 +117,7 @@ export const uploadDropFile = async ( // TODO: Migrate to @netlify/api when Drop endpoints are in the OpenAPI spec. export const waitForDropDeploy = async ( - { apiBase, userAgent }: DropApiOptions, + { apiBase }: DropApiOptions, siteId: string, deployId: string, timeout: number = DEFAULT_DEPLOY_TIMEOUT, @@ -125,7 +126,7 @@ export const waitForDropDeploy = async ( const checkDeploy = async (): Promise => { const response = await fetch(`${apiBase}/sites/${siteId}/deploys/${deployId}`, { - headers: makeHeaders(userAgent), + headers: makeHeaders(), }) if (!response.ok) { @@ -158,14 +159,14 @@ export const waitForDropDeploy = async ( // TODO: Migrate to @netlify/api when Drop endpoints are in the OpenAPI spec. export const claimDropSite = async ( - { apiBase, userAgent }: DropApiOptions, + { apiBase }: DropApiOptions, siteId: string, dropToken: string, authToken: string, ): Promise => { const response = await fetch(`${apiBase}/drop/claim`, { method: 'POST', - headers: makeHeaders(userAgent, { + headers: makeHeaders({ 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }), diff --git a/src/utils/telemetry/request.ts b/src/utils/telemetry/request.ts index e3c2bfb2b83..fdd8568470e 100644 --- a/src/utils/telemetry/request.ts +++ b/src/utils/telemetry/request.ts @@ -4,9 +4,7 @@ import process from 'process' import fetch from 'node-fetch' -import getPackageJson from '../get-cli-package-json.js' - -const { name, version } = await getPackageJson() +import { getRequestUserAgent } from '../user-agent.js' const options = JSON.parse(process.argv[2]) @@ -34,7 +32,7 @@ const makeRequest = async function () { headers: { 'Content-Type': 'application/json', 'X-Netlify-Client': CLIENT_ID, - 'User-Agent': `${name}/${version}`, + 'User-Agent': getRequestUserAgent(), }, body: JSON.stringify(options.data), }) diff --git a/src/utils/user-agent.ts b/src/utils/user-agent.ts new file mode 100644 index 00000000000..a2da5078de8 --- /dev/null +++ b/src/utils/user-agent.ts @@ -0,0 +1,19 @@ +import os from 'os' +import process from 'process' + +import WSL from 'is-wsl' + +import { getDrivingAgent } from './agent-detection.js' +import getCLIPackageJson from './get-cli-package-json.js' + +const platform = WSL ? 'wsl' : os.platform() +const arch = os.arch() === 'ia32' ? 'x86' : os.arch() + +const { name, version } = await getCLIPackageJson() + +export const USER_AGENT = `${name}/${version} ${platform}-${arch} node-${process.version}` + +export const getRequestUserAgent = (env: NodeJS.ProcessEnv = process.env): string => { + const agent = getDrivingAgent(env) + return agent ? `${USER_AGENT} agent/${agent.name}` : USER_AGENT +} diff --git a/tests/unit/recipes/ai-context/index.test.ts b/tests/unit/recipes/ai-context/index.test.ts new file mode 100644 index 00000000000..c39672c8071 --- /dev/null +++ b/tests/unit/recipes/ai-context/index.test.ts @@ -0,0 +1,53 @@ +import { afterEach, expect, test, vi } from 'vitest' + +import type { RunRecipeOptions } from '../../../../src/commands/recipes/recipes.js' + +const { cursorConsumer } = vi.hoisted(() => ({ + cursorConsumer: { + key: 'cursor', + presentedName: 'Cursor', + consumerProcessCmd: 'cursor', + path: './.cursor/rules', + ext: 'mdc', + contextScopes: { serverless: { scope: 'Serverless functions' } }, + }, +})) + +vi.mock('../../../../src/recipes/ai-context/context.js', () => ({ + NTL_DEV_MCP_FILE_NAME: 'netlify-development.mdc', + getContextConsumers: vi.fn().mockResolvedValue([cursorConsumer]), + downloadAndWriteContextFiles: vi.fn().mockResolvedValue(undefined), + getExistingContext: vi.fn().mockResolvedValue(null), + deleteFile: vi.fn(), +})) + +vi.mock('../../../../src/utils/command-helpers.js', () => ({ + log: vi.fn(), + logAndThrowError: vi.fn((error: unknown) => { + throw error + }), + version: '1.0.0', +})) + +vi.mock('../../../../src/utils/telemetry/index.js', () => ({ + track: vi.fn(), +})) + +vi.mock('inquirer', () => ({ + default: { prompt: vi.fn().mockResolvedValue({ consumerKey: 'cursor' }) }, +})) + +import { run } from '../../../../src/recipes/ai-context/index.js' +import { track } from '../../../../src/utils/telemetry/index.js' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +test('tracks sites_aiContextInstalled with the consumer the context was installed for', async () => { + vi.stubEnv('AI_CONTEXT_SKIP_DETECTION', 'true') + + await run({ args: [], command: { workingDir: '/project' } } as unknown as RunRecipeOptions) + + expect(track).toHaveBeenCalledWith('sites_aiContextInstalled', { consumer: 'cursor' }) +}) diff --git a/tests/unit/utils/user-agent.test.ts b/tests/unit/utils/user-agent.test.ts new file mode 100644 index 00000000000..059781ee2d2 --- /dev/null +++ b/tests/unit/utils/user-agent.test.ts @@ -0,0 +1,85 @@ +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import process from 'node:process' + +import { NetlifyAPI } from '@netlify/api' +import { afterEach, expect, test, vi } from 'vitest' + +import { getDropToken } from '../../../src/utils/deploy/drop-api.js' +import { USER_AGENT, getRequestUserAgent } from '../../../src/utils/user-agent.js' + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +const captureUserAgent = async (sendRequest: (origin: string) => Promise) => { + let resolveUserAgent: (userAgent: string | undefined) => void = () => {} + const received = new Promise((resolve) => { + resolveUserAgent = resolve + }) + const server = createServer((req, res) => { + resolveUserAgent(req.headers['user-agent']) + res.setHeader('Content-Type', 'application/json') + res.end('{}') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + + try { + await sendRequest(`http://127.0.0.1:${String(port)}`) + return await received + } finally { + server.closeAllConnections() + server.close() + } +} + +const sendViaApiClient = (origin: string) => + new NetlifyAPI('', { + userAgent: getRequestUserAgent(), + scheme: 'http', + host: new URL(origin).host, + pathPrefix: '/api/v1', + }).listSites() + +const sendViaDropApi = (origin: string) => getDropToken({ apiBase: origin }) + +const sendViaTelemetryRequest = async (origin: string) => { + const exited = new Promise((resolve) => { + vi.spyOn(process, 'exit').mockImplementation(() => { + resolve() + return undefined as never + }) + }) + vi.stubEnv('NETLIFY_TEST_TRACK_URL', `${origin}/track`) + const { argv } = process + process.argv = [...argv.slice(0, 2), JSON.stringify({ type: 'track', data: {} })] + + try { + await import('../../../src/utils/telemetry/request.js') + } finally { + process.argv = argv + } + await exited +} + +test('the API client, Drop API, and telemetry requests send the same User-Agent', async () => { + vi.stubEnv('NETLIFY_AGENT', 'claude') + + const userAgents = [ + await captureUserAgent(sendViaApiClient), + await captureUserAgent(sendViaDropApi), + await captureUserAgent(sendViaTelemetryRequest), + ] + + expect(userAgents).toEqual(Array(3).fill(`${USER_AGENT} agent/claude`)) +}) + +test('appends only the agent name, without its version or source', () => { + expect(getRequestUserAgent({ AI_AGENT: 'claude-code@2.1.0' })).toBe(`${USER_AGENT} agent/claude`) +}) + +test('leaves the User-Agent unchanged when no agent is detected', () => { + expect(getRequestUserAgent({})).toBe(USER_AGENT) +}) From 2635468c25e0f8355ed2c6fe46ea19d01e6396d0 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Thu, 10 Sep 2026 13:37:55 -0400 Subject: [PATCH 12/14] test: expect the full User-Agent on telemetry requests Telemetry requests now send the same User-Agent as API requests, so the integration test compares against USER_AGENT instead of name/version. Refs EX-3044 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nq1RjpFnzzsfZf5qHLCH36 --- tests/integration/telemetry.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integration/telemetry.test.ts b/tests/integration/telemetry.test.ts index 6e6ab698781..ea0bc9a1d5b 100644 --- a/tests/integration/telemetry.test.ts +++ b/tests/integration/telemetry.test.ts @@ -5,6 +5,8 @@ import type { Options } from 'execa' import execa from 'execa' import { expect, test } from 'vitest' +import { USER_AGENT } from '../../src/utils/user-agent.js' + import { callCli } from './utils/call-cli.js' import { cliPath } from './utils/cli-path.js' import { MockApiTestContext, withMockApi } from './utils/mock-api-vitest.js' @@ -42,7 +44,7 @@ await withMockApi(routes, () => { expect(requests.length).toBe(1) expect(requests[0].method).toBe('POST') expect(requests[0].path).toBe('/api/v1/track') - expect(requests[0].headers['user-agent']).toBe(`${pkg.name}/${pkg.version}`) + expect(requests[0].headers['user-agent']).toBe(USER_AGENT) expect(requests[0].body).toHaveProperty('event', 'cli:user_telemetryEnabled') expect(requests[0].body).toHaveProperty('anonymousId', expect.any(String)) expect(requests[0].body).toHaveProperty('properties', { cliVersion: pkg.version, nodejsVersion }) From 38626299ce7267de027c8d31d20cb31beb16480b Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Thu, 10 Sep 2026 13:52:11 -0400 Subject: [PATCH 13/14] fix: send the agent User-Agent on every Netlify request Add netlifyFetch, which sets the request User-Agent, and use it for every raw request to a Netlify-controlled endpoint: Drop API, database, logs, live tunnels, AI context downloads, and geolocation. Log-stream websockets send the same header. Blobs commands pass netlifyFetchForOrigin, which adds the header only for the API origin, so presigned storage URLs are untouched. Third-party, presigned, and local requests keep plain fetch. downloadAndWriteContextFiles now rejects when a download or write fails and returns whether it wrote anything, so sites_aiContextInstalled fires only when context files were actually created or updated. Refs EX-3044 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nq1RjpFnzzsfZf5qHLCH36 --- src/commands/blobs/blobs-delete.ts | 5 ++- src/commands/blobs/blobs-get.ts | 5 ++- src/commands/blobs/blobs-list.ts | 5 ++- src/commands/blobs/blobs-set.ts | 5 ++- src/commands/database/db-migration-pull.ts | 5 ++- src/commands/database/db-status.ts | 5 ++- .../database/util/applied-migrations.ts | 4 +- src/commands/logs/log-api.ts | 3 +- src/lib/geo-location.ts | 5 ++- src/recipes/ai-context/context.ts | 42 ++++++++++--------- src/recipes/ai-context/index.ts | 11 +++-- src/recipes/blobs-migrate/index.ts | 5 ++- src/utils/deploy/drop-api.ts | 32 +++++++------- src/utils/live-tunnel.ts | 5 ++- src/utils/netlify-fetch.ts | 21 ++++++++++ src/utils/websockets/index.ts | 4 +- .../database/db-migration-pull.test.ts | 6 ++- .../ai-context/download-context-files.test.ts | 28 +++++++++---- tests/unit/recipes/ai-context/index.test.ts | 31 +++++++++++--- tests/unit/utils/live-tunnel.test.ts | 11 ++--- tests/unit/utils/netlify-fetch.test.ts | 39 +++++++++++++++++ tests/unit/utils/user-agent.test.ts | 21 +++++++++- 22 files changed, 220 insertions(+), 78 deletions(-) create mode 100644 src/utils/netlify-fetch.ts create mode 100644 tests/unit/utils/netlify-fetch.test.ts diff --git a/src/commands/blobs/blobs-delete.ts b/src/commands/blobs/blobs-delete.ts index 8b877670b7a..1d68070d691 100644 --- a/src/commands/blobs/blobs-delete.ts +++ b/src/commands/blobs/blobs-delete.ts @@ -1,6 +1,7 @@ import { getStore } from '@netlify/blobs' import { chalk, logAndThrowError, log } from '../../utils/command-helpers.js' +import { netlifyFetchForOrigin } from '../../utils/netlify-fetch.js' import { promptBlobDelete } from '../../utils/prompts/blob-delete-prompts.js' /** @@ -10,8 +11,10 @@ export const blobsDelete = async (storeName: string, key: string, _options: Reco const { api, siteInfo } = command.netlify const { force } = _options + const apiURL = `${api.scheme}://${api.host}` const store = getStore({ - apiURL: `${api.scheme}://${api.host}`, + apiURL, + fetch: netlifyFetchForOrigin(apiURL), name: storeName, siteID: siteInfo.id ?? '', token: api.accessToken ?? '', diff --git a/src/commands/blobs/blobs-get.ts b/src/commands/blobs/blobs-get.ts index 0b105e97d05..1a8622fbc8e 100644 --- a/src/commands/blobs/blobs-get.ts +++ b/src/commands/blobs/blobs-get.ts @@ -5,6 +5,7 @@ import { getStore } from '@netlify/blobs' import { OptionValues } from 'commander' import { chalk, logAndThrowError } from '../../utils/command-helpers.js' +import { netlifyFetchForOrigin } from '../../utils/netlify-fetch.js' import BaseCommand from '../base-command.js' interface Options extends OptionValues { @@ -14,8 +15,10 @@ interface Options extends OptionValues { export const blobsGet = async (storeName: string, key: string, options: Options, command: BaseCommand) => { const { api, siteInfo } = command.netlify const { output } = options + const apiURL = `${api.scheme}://${api.host}` const store = getStore({ - apiURL: `${api.scheme}://${api.host}`, + apiURL, + fetch: netlifyFetchForOrigin(apiURL), name: storeName, siteID: siteInfo?.id ?? '', token: api.accessToken ?? '', diff --git a/src/commands/blobs/blobs-list.ts b/src/commands/blobs/blobs-list.ts index 55844976261..5ab1cb79f9d 100644 --- a/src/commands/blobs/blobs-list.ts +++ b/src/commands/blobs/blobs-list.ts @@ -3,6 +3,7 @@ import AsciiTable from 'ascii-table' import { OptionValues } from 'commander' import { chalk, logAndThrowError, log, logJson } from '../../utils/command-helpers.js' +import { netlifyFetchForOrigin } from '../../utils/netlify-fetch.js' import BaseCommand from '../base-command.js' interface Options extends OptionValues { @@ -13,8 +14,10 @@ interface Options extends OptionValues { export const blobsList = async (storeName: string, options: Options, command: BaseCommand) => { const { api, siteInfo } = command.netlify + const apiURL = `${api.scheme}://${api.host}` const store = getStore({ - apiURL: `${api.scheme}://${api.host}`, + apiURL, + fetch: netlifyFetchForOrigin(apiURL), name: storeName, siteID: siteInfo.id, token: api.accessToken ?? '', diff --git a/src/commands/blobs/blobs-set.ts b/src/commands/blobs/blobs-set.ts index 66b34d21d6c..2a0c3082b6f 100644 --- a/src/commands/blobs/blobs-set.ts +++ b/src/commands/blobs/blobs-set.ts @@ -5,6 +5,7 @@ import { getStore } from '@netlify/blobs' import { OptionValues } from 'commander' import { chalk, logAndThrowError, isNodeError, log } from '../../utils/command-helpers.js' +import { netlifyFetchForOrigin } from '../../utils/netlify-fetch.js' import { promptBlobSetOverwrite } from '../../utils/prompts/blob-set-prompt.js' import BaseCommand from '../base-command.js' @@ -22,8 +23,10 @@ export const blobsSet = async ( ) => { const { api, siteInfo } = command.netlify const { force, input } = options + const apiURL = `${api.scheme}://${api.host}` const store = getStore({ - apiURL: `${api.scheme}://${api.host}`, + apiURL, + fetch: netlifyFetchForOrigin(apiURL), name: storeName, siteID: siteInfo.id, token: api.accessToken ?? '', diff --git a/src/commands/database/db-migration-pull.ts b/src/commands/database/db-migration-pull.ts index d0f904bc8f2..421baa81f70 100644 --- a/src/commands/database/db-migration-pull.ts +++ b/src/commands/database/db-migration-pull.ts @@ -4,6 +4,7 @@ import { dirname, resolve, isAbsolute } from 'path' import inquirer from 'inquirer' import { log, logJson } from '../../utils/command-helpers.js' +import { netlifyFetch } from '../../utils/netlify-fetch.js' import execa from '../../utils/execa.js' import BaseCommand from '../base-command.js' import { readApiErrorMessage } from './util/api-errors.js' @@ -81,7 +82,7 @@ const fetchMigrations = async (ctx: ApiContext, branch: string): Promise { export const debugFetch = async (url: string, init?: RequestInit): Promise => { debugLog(`→ ${init?.method ?? 'GET'} ${url}`) const start = performance.now() - const response = await fetch(url, init) + const response = await netlifyFetch(url, init) const elapsed = (performance.now() - start).toFixed(0) debugLog(`← ${response.status.toString()} ${response.statusText} (${elapsed}ms)`) return response diff --git a/src/lib/geo-location.ts b/src/lib/geo-location.ts index 875925ec544..16529b12b37 100644 --- a/src/lib/geo-location.ts +++ b/src/lib/geo-location.ts @@ -1,6 +1,7 @@ -import fetch from 'node-fetch' import { type Geolocation, mockLocation } from '@netlify/dev-utils' +import { netlifyFetch } from '../utils/netlify-fetch.js' + const API_URL = 'https://netlifind.netlify.app' const STATE_GEO_PROPERTY = 'geolocation' // 24 hours @@ -93,7 +94,7 @@ export const getGeoLocation = async ({ * Returns geolocation data from a remote API. */ const getGeoLocationFromAPI = async (): Promise => { - const res = await fetch(API_URL, { + const res = await netlifyFetch(API_URL, { method: 'GET', signal: AbortSignal.timeout(REQUEST_TIMEOUT), }) diff --git a/src/recipes/ai-context/context.ts b/src/recipes/ai-context/context.ts index 5e85eb9c08e..86911324617 100644 --- a/src/recipes/ai-context/context.ts +++ b/src/recipes/ai-context/context.ts @@ -1,7 +1,8 @@ import { promises as fs } from 'node:fs' import { dirname, resolve } from 'node:path' import semver from 'semver' -import { chalk, logAndThrowError, log, version } from '../../utils/command-helpers.js' +import { chalk, log, version } from '../../utils/command-helpers.js' +import { netlifyFetch } from '../../utils/netlify-fetch.js' import type { RunRecipeOptions } from '../../commands/recipes/recipes.js' const ATTRIBUTES_REGEX = /(\S*)="([^\s"]*)"/gim @@ -41,16 +42,12 @@ export interface ConsumerConfig { } let contextConsumers: ConsumerConfig[] = [] -export const getContextConsumers = async (cliVersion: string) => { +export const getContextConsumers = async () => { if (contextConsumers.length > 0) { return contextConsumers } try { - const res = await fetch(`${BASE_URL}/context-consumers`, { - headers: { - 'user-agent': `NetlifyCLI ${cliVersion}`, - }, - }) + const res = await netlifyFetch(`${BASE_URL}/context-consumers`) if (!res.ok) { return [] @@ -63,7 +60,7 @@ export const getContextConsumers = async (cliVersion: string) => { return contextConsumers } -export const downloadFile = async (cliVersion: string, contextConfig: ContextConfig, consumer: ConsumerConfig) => { +export const downloadFile = async (contextConfig: ContextConfig, consumer: ConsumerConfig) => { try { if (!contextConfig.endpoint) { return null @@ -79,11 +76,7 @@ export const downloadFile = async (cliVersion: string, contextConfig: ContextCon url.protocol = overridingUrl.protocol } - const res = await fetch(url, { - headers: { - 'user-agent': `NetlifyCLI ${cliVersion}`, - }, - }) + const res = await netlifyFetch(url) if (!res.ok) { return null @@ -221,21 +214,24 @@ export const deleteFile = async (path: string) => { } } -export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { command }: RunRecipeOptions) => { - await Promise.allSettled( +export const downloadAndWriteContextFiles = async ( + consumer: ConsumerConfig, + { command }: RunRecipeOptions, +): Promise => { + const results = await Promise.allSettled( Object.keys(consumer.contextScopes).map(async (contextKey) => { const contextConfig = consumer.contextScopes[contextKey] const { contents: downloadedFile, minimumCLIVersion } = - (await downloadFile(version, contextConfig, consumer).catch(() => null)) ?? {} + (await downloadFile(contextConfig, consumer).catch(() => null)) ?? {} if (!downloadedFile) { - return logAndThrowError( + throw new Error( `An error occurred when pulling the latest context file for scope ${contextConfig.scope}. Please try again.`, ) } if (minimumCLIVersion && semver.lt(version, minimumCLIVersion)) { - return logAndThrowError( + throw new Error( `This command requires version ${minimumCLIVersion} or above of the Netlify CLI. Refer to ${chalk.underline( 'https://ntl.fyi/update-cli', )} for information on how to update.`, @@ -264,7 +260,7 @@ export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { c absoluteFilePath, )} contains the latest version of the context files.`, ) - return + return false } // We must preserve any overrides found in the existing file. @@ -289,6 +285,14 @@ export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { c await writeFile(absoluteFilePath, contents) log(`${existing ? 'Updated' : 'Created'} context files at ${chalk.underline(absoluteFilePath)}`) + return true }), ) + + const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure) { + throw failure.reason + } + + return results.some((result) => result.status === 'fulfilled' && result.value) } diff --git a/src/recipes/ai-context/index.ts b/src/recipes/ai-context/index.ts index d7b27a768a0..ffdde99d349 100644 --- a/src/recipes/ai-context/index.ts +++ b/src/recipes/ai-context/index.ts @@ -4,7 +4,7 @@ import inquirer from 'inquirer' import execa from 'execa' import type { RunRecipeOptions } from '../../commands/recipes/recipes.js' -import { logAndThrowError, log, version } from '../../utils/command-helpers.js' +import { logAndThrowError, log } from '../../utils/command-helpers.js' import { track } from '../../utils/telemetry/index.js' import { @@ -21,7 +21,7 @@ export const description = 'Manage context files for AI tools' // context consumers endpoints returns all supported IDE and other consumers // that can be used to pull context files. It also includes a catchall consumer // for outlining all context that an unspecified consumer would handle. -const allContextConsumers = await getContextConsumers(version) +const allContextConsumers = await getContextConsumers() const cliContextConsumers = allContextConsumers.filter((consumer) => !consumer.hideFromCLI) const rulesForDefaultConsumer = allContextConsumers.find((consumer) => consumer.key === 'catchall-consumer') ?? { @@ -157,8 +157,9 @@ export const run = async (runOptions: RunRecipeOptions) => { return } + let wroteFiles = false try { - await downloadAndWriteContextFiles(consumer, runOptions) + wroteFiles = await downloadAndWriteContextFiles(consumer, runOptions) // the deprecated MCP file path // let's remove that file if it exists. @@ -173,5 +174,7 @@ export const run = async (runOptions: RunRecipeOptions) => { logAndThrowError(error) } - await track('sites_aiContextInstalled', { consumer: consumer.key }) + if (wroteFiles) { + await track('sites_aiContextInstalled', { consumer: consumer.key }) + } } diff --git a/src/recipes/blobs-migrate/index.ts b/src/recipes/blobs-migrate/index.ts index 0b7c6243bc5..e54c1bbdad5 100644 --- a/src/recipes/blobs-migrate/index.ts +++ b/src/recipes/blobs-migrate/index.ts @@ -4,6 +4,7 @@ import pMap from 'p-map' import BaseCommand from '../../commands/base-command.js' import { logAndThrowError, log } from '../../utils/command-helpers.js' +import { netlifyFetchForOrigin } from '../../utils/netlify-fetch.js' export const description = 'Migrate legacy Netlify Blobs stores' @@ -21,8 +22,10 @@ export const run = async ({ args, command }: Options) => { const [storeName] = args const { api, siteInfo } = command.netlify + const apiURL = `${api.scheme}://${api.host}` const clientOptions = { - apiURL: `${api.scheme}://${api.host}`, + apiURL, + fetch: netlifyFetchForOrigin(apiURL), siteID: siteInfo.id, token: api.accessToken ?? '', } diff --git a/src/utils/deploy/drop-api.ts b/src/utils/deploy/drop-api.ts index 3653f443361..2e5b59becd4 100644 --- a/src/utils/deploy/drop-api.ts +++ b/src/utils/deploy/drop-api.ts @@ -5,7 +5,7 @@ import fs from 'fs' import pWaitFor from 'p-wait-for' -import { getRequestUserAgent } from '../user-agent.js' +import { netlifyFetch } from '../netlify-fetch.js' import { DEPLOY_POLL, DEFAULT_DEPLOY_TIMEOUT, DEFAULT_CONCURRENT_UPLOAD, DEFAULT_MAX_RETRY } from './constants.js' import type { StatusCallback } from './status-cb.js' @@ -30,14 +30,13 @@ export interface DropApiError extends Error { } const makeHeaders = (extra: Record = {}): Record => ({ - 'User-Agent': getRequestUserAgent(), Referer: APP_NETLIFY_REFERRER, ...extra, }) // TODO: Migrate to @netlify/api when Drop endpoints are in the OpenAPI spec. export const getDropToken = async ({ apiBase }: DropApiOptions): Promise => { - const response = await fetch(`${apiBase}/drop/token`, { + const response = await netlifyFetch(`${apiBase}/drop/token`, { method: 'POST', headers: makeHeaders({ 'Content-Type': 'application/json' }), }) @@ -64,7 +63,7 @@ export const createDropDeploy = async ( body.created_via = createdVia } - const response = await fetch(`${apiBase}/drop`, { + const response = await netlifyFetch(`${apiBase}/drop`, { method: 'POST', headers: makeHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(body), @@ -95,15 +94,18 @@ export const uploadDropFile = async ( // Node.js fetch needs `duplex: 'half'` for streaming bodies which isn't in standard RequestInit /* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}` - const response: Response = await fetch(`${apiBase}/deploys/${deployId}/files${encodeURI(normalizedFilePath)}`, { - method: 'PUT', - headers: makeHeaders({ - 'Content-Type': 'application/octet-stream', - Authorization: `Bearer ${token}`, - }), - body: body as any, - duplex: 'half', - } as any) + const response: Response = await netlifyFetch( + `${apiBase}/deploys/${deployId}/files${encodeURI(normalizedFilePath)}`, + { + method: 'PUT', + headers: makeHeaders({ + 'Content-Type': 'application/octet-stream', + Authorization: `Bearer ${token}`, + }), + body: body as any, + duplex: 'half', + } as any, + ) /* eslint-enable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ if (!response.ok) { @@ -125,7 +127,7 @@ export const waitForDropDeploy = async ( let deploy: Record | undefined const checkDeploy = async (): Promise => { - const response = await fetch(`${apiBase}/sites/${siteId}/deploys/${deployId}`, { + const response = await netlifyFetch(`${apiBase}/sites/${siteId}/deploys/${deployId}`, { headers: makeHeaders(), }) @@ -164,7 +166,7 @@ export const claimDropSite = async ( dropToken: string, authToken: string, ): Promise => { - const response = await fetch(`${apiBase}/drop/claim`, { + const response = await netlifyFetch(`${apiBase}/drop/claim`, { method: 'POST', headers: makeHeaders({ 'Content-Type': 'application/json', diff --git a/src/utils/live-tunnel.ts b/src/utils/live-tunnel.ts index e0139d5ebc8..18ddf86f19d 100644 --- a/src/utils/live-tunnel.ts +++ b/src/utils/live-tunnel.ts @@ -8,6 +8,7 @@ import { getPathInHome } from '../lib/settings.js' import { NETLIFYDEVERR, NETLIFYDEVLOG, chalk, exit, log } from './command-helpers.js' import execa from './execa.js' +import { netlifyFetch } from './netlify-fetch.js' import type { LocalState } from './types.js' const PACKAGE_NAME = 'live-tunnel-client' @@ -37,7 +38,7 @@ const createTunnel = async function ({ await installTunnelClient() const url = `https://api.netlify.com/api/v1/live_sessions?site_id=${siteId}&slug=${slug}` - const response = await fetch(url, { + const response = await netlifyFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -141,7 +142,7 @@ export const startLiveTunnel = async ({ const isLiveTunnelReady = async (): Promise => { const url = `https://api.netlify.com/api/v1/live_sessions/${session.id}` - const response = await fetch(url, { + const response = await netlifyFetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', diff --git a/src/utils/netlify-fetch.ts b/src/utils/netlify-fetch.ts new file mode 100644 index 00000000000..4253123047e --- /dev/null +++ b/src/utils/netlify-fetch.ts @@ -0,0 +1,21 @@ +import { getRequestUserAgent } from './user-agent.js' + +type FetchInput = Parameters[0] + +const withUserAgent = (input: FetchInput, init?: RequestInit): RequestInit => { + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)) + headers.set('User-Agent', getRequestUserAgent()) + return { ...init, headers } +} + +// Only for Netlify-controlled endpoints. Third-party, presigned, and user-provided URLs keep plain `fetch`. +export const netlifyFetch: typeof fetch = (input, init) => fetch(input, withUserAgent(input, init)) + +// For clients that send Netlify API requests and presigned-URL requests through the same `fetch`. +export const netlifyFetchForOrigin = (origin: string): typeof fetch => { + const netlifyOrigin = new URL(origin).origin + return (input, init) => { + const { origin: requestOrigin } = new URL(input instanceof Request ? input.url : input) + return requestOrigin === netlifyOrigin ? netlifyFetch(input, init) : fetch(input, init) + } +} diff --git a/src/utils/websockets/index.ts b/src/utils/websockets/index.ts index 7d9837538c7..7950b201beb 100644 --- a/src/utils/websockets/index.ts +++ b/src/utils/websockets/index.ts @@ -1,3 +1,5 @@ import WebSocket from 'ws' -export const getWebSocket = (url: string) => new WebSocket(url) +import { getRequestUserAgent } from '../user-agent.js' + +export const getWebSocket = (url: string) => new WebSocket(url, { headers: { 'User-Agent': getRequestUserAgent() } }) diff --git a/tests/unit/commands/database/db-migration-pull.test.ts b/tests/unit/commands/database/db-migration-pull.test.ts index 2966b2f4f77..6ef28b8cfe5 100644 --- a/tests/unit/commands/database/db-migration-pull.test.ts +++ b/tests/unit/commands/database/db-migration-pull.test.ts @@ -50,6 +50,8 @@ import { resolve } from 'path' import inquirer from 'inquirer' import { migrationPull } from '../../../../src/commands/database/db-migration-pull.js' +const authorizationHeaderOf = (call: unknown[]) => new Headers((call[1] as RequestInit).headers).get('Authorization') + interface SampleMigration { version: number name: string @@ -156,7 +158,7 @@ describe('migrationPull', () => { expect(calledUrl.toString()).toBe( 'https://api.netlify.com/api/v1/sites/site-123/database/migrations?branch=production', ) - expect(mockFetch.mock.calls[0][1]).toEqual({ headers: { Authorization: 'Bearer test-token' } }) + expect(authorizationHeaderOf(mockFetch.mock.calls[0])).toBe('Bearer test-token') }) test('fetches content for each migration from the detail endpoint', async () => { @@ -174,7 +176,7 @@ describe('migrationPull', () => { 'https://api.netlify.com/api/v1/sites/site-123/database/migrations/0002_add-posts?branch=production', ]) for (const call of mockFetch.mock.calls) { - expect(call[1]).toEqual({ headers: { Authorization: 'Bearer test-token' } }) + expect(authorizationHeaderOf(call)).toBe('Bearer test-token') } }) diff --git a/tests/unit/recipes/ai-context/download-context-files.test.ts b/tests/unit/recipes/ai-context/download-context-files.test.ts index af1bd102e7c..4a1a01a9f34 100644 --- a/tests/unit/recipes/ai-context/download-context-files.test.ts +++ b/tests/unit/recipes/ai-context/download-context-files.test.ts @@ -98,7 +98,7 @@ describe('downloadAndWriteContextFiles', () => { test('downloads and writes context files for all scopes', async () => { // Execute the actual function - await downloadAndWriteContextFiles(mockConsumer, mockRunOptions) + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBe(true) // Verify expected calls expect(mockFetch).toHaveBeenCalledTimes(2) // Once for each scope @@ -124,12 +124,19 @@ describe('downloadAndWriteContextFiles', () => { fs.readFile.mockResolvedValue(mockProviderContent) // Execute the actual function - await downloadAndWriteContextFiles(mockConsumer, mockRunOptions) + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBe(false) // Verify expected behavior - no writes when versions match expect(fs.writeFile).not.toHaveBeenCalled() }) + test('reports no writes when the consumer has no context scopes', async () => { + await expect(downloadAndWriteContextFiles({ ...mockConsumer, contextScopes: {} }, mockRunOptions)).resolves.toBe( + false, + ) + expect(fs.writeFile).not.toHaveBeenCalled() + }) + test('applies overrides when updating existing Netlify files', async () => { // Mock existing file with different version const existingContent = @@ -199,22 +206,25 @@ describe('downloadAndWriteContextFiles', () => { ) }) - test('handles download errors gracefully', async () => { + test('rejects when a context file cannot be downloaded', async () => { // Mock fetch to return not ok // @ts-expect-error mocking is not 100% consistent with full API and types for fetch.mockResolvedValue({ ok: false, }) - // Execute the actual function and expect error - await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined() + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).rejects.toThrow( + 'An error occurred when pulling the latest context file', + ) + expect(fs.writeFile).not.toHaveBeenCalled() }) - test('checks CLI version compatibility', async () => { + test('rejects when the CLI is older than the minimum version', async () => { // Set higher minimum CLI version // @ts-expect-error mocking is not 100% consistent with full API and types for fetch.mockResolvedValue({ ok: true, + text: () => Promise.resolve(mockProviderContent), headers: { get: (header: string) => { if (header === 'x-cli-min-ver') return '2.0.0' // Higher than the mocked current version @@ -223,7 +233,9 @@ describe('downloadAndWriteContextFiles', () => { }, }) - // Execute the actual function and expect error - await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined() + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).rejects.toThrow( + 'This command requires version 2.0.0', + ) + expect(fs.writeFile).not.toHaveBeenCalled() }) }) diff --git a/tests/unit/recipes/ai-context/index.test.ts b/tests/unit/recipes/ai-context/index.test.ts index c39672c8071..2c629978b12 100644 --- a/tests/unit/recipes/ai-context/index.test.ts +++ b/tests/unit/recipes/ai-context/index.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' import type { RunRecipeOptions } from '../../../../src/commands/recipes/recipes.js' @@ -16,7 +16,7 @@ const { cursorConsumer } = vi.hoisted(() => ({ vi.mock('../../../../src/recipes/ai-context/context.js', () => ({ NTL_DEV_MCP_FILE_NAME: 'netlify-development.mdc', getContextConsumers: vi.fn().mockResolvedValue([cursorConsumer]), - downloadAndWriteContextFiles: vi.fn().mockResolvedValue(undefined), + downloadAndWriteContextFiles: vi.fn().mockResolvedValue(true), getExistingContext: vi.fn().mockResolvedValue(null), deleteFile: vi.fn(), })) @@ -37,17 +37,38 @@ vi.mock('inquirer', () => ({ default: { prompt: vi.fn().mockResolvedValue({ consumerKey: 'cursor' }) }, })) +import { downloadAndWriteContextFiles } from '../../../../src/recipes/ai-context/context.js' import { run } from '../../../../src/recipes/ai-context/index.js' import { track } from '../../../../src/utils/telemetry/index.js' +const runRecipe = () => run({ args: [], command: { workingDir: '/project' } } as unknown as RunRecipeOptions) + +beforeEach(() => { + vi.mocked(track).mockClear() + vi.stubEnv('AI_CONTEXT_SKIP_DETECTION', 'true') +}) + afterEach(() => { vi.unstubAllEnvs() }) test('tracks sites_aiContextInstalled with the consumer the context was installed for', async () => { - vi.stubEnv('AI_CONTEXT_SKIP_DETECTION', 'true') - - await run({ args: [], command: { workingDir: '/project' } } as unknown as RunRecipeOptions) + await runRecipe() expect(track).toHaveBeenCalledWith('sites_aiContextInstalled', { consumer: 'cursor' }) }) + +test('does not track an install when every context file was already current', async () => { + vi.mocked(downloadAndWriteContextFiles).mockResolvedValueOnce(false) + + await runRecipe() + + expect(track).not.toHaveBeenCalled() +}) + +test('does not track an install when writing the context files fails', async () => { + vi.mocked(downloadAndWriteContextFiles).mockRejectedValueOnce(new Error('download failed')) + + await expect(runRecipe()).rejects.toThrow('download failed') + expect(track).not.toHaveBeenCalled() +}) diff --git a/tests/unit/utils/live-tunnel.test.ts b/tests/unit/utils/live-tunnel.test.ts index 8c29a9bece4..161e1c29dc9 100644 --- a/tests/unit/utils/live-tunnel.test.ts +++ b/tests/unit/utils/live-tunnel.test.ts @@ -76,13 +76,10 @@ describe('startLiveTunnel', () => { await startLiveTunnel(TUNNEL_ARGS) - expect(vi.mocked(fetch)).toHaveBeenCalledWith( - 'https://api.netlify.com/api/v1/live_sessions?site_id=site-456&slug=test', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ Authorization: 'Bearer fake-token' }) as unknown, - }), - ) + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect(url).toBe('https://api.netlify.com/api/v1/live_sessions?site_id=site-456&slug=test') + expect(init?.method).toBe('POST') + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer fake-token') }) test('polls the session until it is online', async () => { diff --git a/tests/unit/utils/netlify-fetch.test.ts b/tests/unit/utils/netlify-fetch.test.ts new file mode 100644 index 00000000000..3a31214e80a --- /dev/null +++ b/tests/unit/utils/netlify-fetch.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import { netlifyFetch, netlifyFetchForOrigin } from '../../../src/utils/netlify-fetch.js' +import { USER_AGENT } from '../../../src/utils/user-agent.js' + +const fetchMock = vi.fn(() => Promise.resolve(new Response())) + +const sentHeaders = (callIndex: number) => new Headers(fetchMock.mock.calls[callIndex][1]?.headers) + +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + vi.stubEnv('NETLIFY_AGENT', 'claude') +}) + +afterEach(() => { + fetchMock.mockClear() + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +test("sets the agent User-Agent and keeps the caller's other headers", async () => { + await netlifyFetch('https://api.netlify.com/api/v1/sites', { + headers: { Authorization: 'Bearer token', 'user-agent': 'caller' }, + }) + + expect(sentHeaders(0).get('User-Agent')).toBe(`${USER_AGENT} agent/claude`) + expect(sentHeaders(0).get('Authorization')).toBe('Bearer token') +}) + +test('adds the User-Agent only to requests for the given origin', async () => { + const fetchForApi = netlifyFetchForOrigin('https://api.netlify.com') + const presignedUrl = 'https://bucket.s3.amazonaws.com/blob?X-Amz-Signature=abc' + + await fetchForApi('https://api.netlify.com/api/v1/blobs/site-id/store') + await fetchForApi(presignedUrl, { headers: { 'x-custom': '1' } }) + + expect(sentHeaders(0).get('User-Agent')).toBe(`${USER_AGENT} agent/claude`) + expect(fetchMock.mock.calls[1]).toEqual([presignedUrl, { headers: { 'x-custom': '1' } }]) +}) diff --git a/tests/unit/utils/user-agent.test.ts b/tests/unit/utils/user-agent.test.ts index 059781ee2d2..e6ab15e0c2a 100644 --- a/tests/unit/utils/user-agent.test.ts +++ b/tests/unit/utils/user-agent.test.ts @@ -6,7 +6,9 @@ import { NetlifyAPI } from '@netlify/api' import { afterEach, expect, test, vi } from 'vitest' import { getDropToken } from '../../../src/utils/deploy/drop-api.js' +import { netlifyFetch } from '../../../src/utils/netlify-fetch.js' import { USER_AGENT, getRequestUserAgent } from '../../../src/utils/user-agent.js' +import { getWebSocket } from '../../../src/utils/websockets/index.js' afterEach(() => { vi.unstubAllEnvs() @@ -23,6 +25,10 @@ const captureUserAgent = async (sendRequest: (origin: string) => Promise { + resolveUserAgent(req.headers['user-agent']) + socket.destroy() + }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const { port } = server.address() as AddressInfo @@ -45,6 +51,15 @@ const sendViaApiClient = (origin: string) => const sendViaDropApi = (origin: string) => getDropToken({ apiBase: origin }) +const sendViaNetlifyFetch = (origin: string) => netlifyFetch(`${origin}/api/v1/sites`) + +const sendViaWebSocket = (origin: string) => + new Promise((resolve) => { + getWebSocket(origin.replace('http', 'ws')).on('error', () => { + resolve() + }) + }) + const sendViaTelemetryRequest = async (origin: string) => { const exited = new Promise((resolve) => { vi.spyOn(process, 'exit').mockImplementation(() => { @@ -64,16 +79,18 @@ const sendViaTelemetryRequest = async (origin: string) => { await exited } -test('the API client, Drop API, and telemetry requests send the same User-Agent', async () => { +test('every Netlify request path sends the same User-Agent', async () => { vi.stubEnv('NETLIFY_AGENT', 'claude') const userAgents = [ await captureUserAgent(sendViaApiClient), await captureUserAgent(sendViaDropApi), await captureUserAgent(sendViaTelemetryRequest), + await captureUserAgent(sendViaNetlifyFetch), + await captureUserAgent(sendViaWebSocket), ] - expect(userAgents).toEqual(Array(3).fill(`${USER_AGENT} agent/claude`)) + expect(userAgents).toEqual(Array(5).fill(`${USER_AGENT} agent/claude`)) }) test('appends only the agent name, without its version or source', () => { From 00d059cc77c732ed2a3fa0b97f1082d951317d69 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Thu, 10 Sep 2026 14:43:56 -0400 Subject: [PATCH 14/14] refactor: drop comments from netlifyFetch helpers Refs EX-3044 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nq1RjpFnzzsfZf5qHLCH36 --- src/utils/netlify-fetch.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/utils/netlify-fetch.ts b/src/utils/netlify-fetch.ts index 4253123047e..6890ccf6b88 100644 --- a/src/utils/netlify-fetch.ts +++ b/src/utils/netlify-fetch.ts @@ -8,10 +8,8 @@ const withUserAgent = (input: FetchInput, init?: RequestInit): RequestInit => { return { ...init, headers } } -// Only for Netlify-controlled endpoints. Third-party, presigned, and user-provided URLs keep plain `fetch`. export const netlifyFetch: typeof fetch = (input, init) => fetch(input, withUserAgent(input, init)) -// For clients that send Netlify API requests and presigned-URL requests through the same `fetch`. export const netlifyFetchForOrigin = (origin: string): typeof fetch => { const netlifyOrigin = new URL(origin).origin return (input, init) => {