diff --git a/.changeset/link-cli-agent-attribution.md b/.changeset/link-cli-agent-attribution.md new file mode 100644 index 00000000..efee9455 --- /dev/null +++ b/.changeset/link-cli-agent-attribution.md @@ -0,0 +1,5 @@ +--- +'@stripe/link-cli': patch +--- + +Report the invoking AI agent in the User-Agent header on Link API and authentication requests when detectable. Merchant requests made by `mpp pay` retain their existing User-Agent. diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 002a58a8..916a1185 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -9,6 +9,60 @@ import { storage } from '../auth/storage'; const execFileAsync = promisify(execFile); const CLI_PATH = new URL('../../dist/cli.js', import.meta.url).pathname; +const CLI_VERSION = JSON.parse( + fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +).version; +const CLI_USER_AGENT = `link-cli/${CLI_VERSION}`; + +// Do not inherit agent attribution from the developer's shell or CI runner. +const EMPTY_AGENT_ENV = { + ANTIGRAVITY_CLI_ALIAS: '', + CLAUDECODE: '', + CLINE_ACTIVE: '', + CODEX_SANDBOX: '', + CODEX_THREAD_ID: '', + CODEX_SANDBOX_NETWORK_DISABLED: '', + CODEX_CI: '', + CURSOR_AGENT: '', + GEMINI_CLI: '', + OPENCODE: '', + OPENCLAW_SHELL: '', + CLAUDE_CODE_ENTRYPOINT: '', + CODEX_INTERNAL_ORIGINATOR_OVERRIDE: '', +}; + +const AGENT_CASES: { + name: string; + env: Record; + userAgent: string; +}[] = [ + { name: 'unknown agent', env: {}, userAgent: CLI_USER_AGENT }, + { + name: 'Claude Code', + env: { CLAUDECODE: '1' }, + userAgent: `${CLI_USER_AGENT} AIAgent/claude_code`, + }, + { + name: 'Cursor', + env: { CURSOR_AGENT: '1' }, + userAgent: `${CLI_USER_AGENT} AIAgent/cursor`, + }, + { + name: 'Codex CLI', + env: { CODEX_THREAD_ID: 'test-thread-id' }, + userAgent: `${CLI_USER_AGENT} AIAgent/codex_cli`, + }, + { + name: 'Claude host fallback', + env: { CLAUDE_CODE_ENTRYPOINT: 'sdk-ts' }, + userAgent: `${CLI_USER_AGENT} AIAgent/claude_code`, + }, + { + name: 'Codex host fallback', + env: { CODEX_INTERNAL_ORIGINATOR_OVERRIDE: 'codex-desktop' }, + userAgent: `${CLI_USER_AGENT} AIAgent/codex_cli`, + }, +]; const AUTH_TOKENS = { access_token: 'test_access_token_1234567890', @@ -143,6 +197,7 @@ async function runProdCliWithEnv( { env: { ...process.env, + ...EMPTY_AGENT_ENV, LINK_API_BASE_URL: `http://127.0.0.1:${serverPort}`, LINK_AUTH_BASE_URL: `http://127.0.0.1:${serverPort}`, XDG_DATA_HOME: '/tmp/link-cli-test-empty', @@ -245,6 +300,81 @@ describe('production mode', () => { setNextResponse(200, BASE_REQUEST); }); + describe('request agent attribution', () => { + it.each(AGENT_CASES)( + 'uses the expected User-Agent for $name on Link API requests', + async ({ env, userAgent }) => { + setNextResponse(200, { data: [] }); + + const result = await runProdCliWithEnv( + env, + 'spend-request', + 'list', + '--json', + ); + + expect(result.exitCode).toBe(0); + const request = requests.find((r) => r.url === '/spend_requests'); + expect(request).toBeDefined(); + expect(request?.headers['user-agent']).toBe(userAgent); + }, + ); + + it.each(AGENT_CASES)( + 'uses the expected User-Agent for $name on device authentication', + async ({ env, userAgent }) => { + storage.clearTokens(); + setResponseForUrl('/device/code', 200, { + device_code: 'test_device_code', + user_code: 'apple-grape', + verification_uri: 'https://app.link.com/device/setup', + verification_uri_complete: + 'https://app.link.com/device/setup?code=apple-grape', + expires_in: 300, + interval: 1, + }); + + const result = await runProdCliWithEnv( + env, + 'auth', + 'login', + '--client-name', + 'Test Agent', + '--json', + ); + + expect(result.exitCode).toBe(0); + const request = requests.find((r) => r.url === '/device/code'); + expect(request).toBeDefined(); + expect(request?.headers['user-agent']).toBe(userAgent); + }, + ); + + it.each(AGENT_CASES)( + 'uses the expected User-Agent for $name on token refresh', + async ({ env, userAgent }) => { + setResponseForUrl('/device/token', 200, PROD_AUTH_TOKENS); + + const result = await runProdCliWithEnv( + env, + 'auth', + 'login', + '--client-name', + 'Test Agent', + '--json', + ); + + expect(result.exitCode).toBe(0); + const request = requests.find((r) => r.url === '/device/token'); + expect(request).toBeDefined(); + expect(new URLSearchParams(request?.body).get('grant_type')).toBe( + 'refresh_token', + ); + expect(request?.headers['user-agent']).toBe(userAgent); + }, + ); + }); + describe('spend-request create', () => { it('sends POST to /spend_requests with correct headers and body', async () => { const result = await runProdCli( @@ -2994,7 +3124,8 @@ describe('production mode', () => { }); setMerchantResponse(200, '{"success":true}'); - const result = await runProdCli( + const result = await runProdCliWithEnv( + { CODEX_THREAD_ID: 'test-thread-id' }, 'mpp', 'pay', `http://127.0.0.1:${merchantPort}/api/charge`, @@ -3012,8 +3143,15 @@ describe('production mode', () => { expect(parsed.status).toBe(200); expect(parsed.body).toContain('success'); expect(merchantRequests).toHaveLength(2); - expect(merchantRequests[0].headers['user-agent']).toMatch(/^link-cli\//); - expect(merchantRequests[1].headers['user-agent']).toMatch(/^link-cli\//); + const linkRequest = requests.find((r) => + r.url.startsWith('/spend_requests/lsrq_spt_001'), + ); + expect(linkRequest).toBeDefined(); + expect(linkRequest?.headers['user-agent']).toBe( + `${CLI_USER_AGENT} AIAgent/codex_cli`, + ); + expect(merchantRequests[0].headers['user-agent']).toBe(CLI_USER_AGENT); + expect(merchantRequests[1].headers['user-agent']).toBe(CLI_USER_AGENT); expect(merchantRequests[1].headers.authorization).toMatch(/^Payment /); }); @@ -3196,26 +3334,36 @@ describe('production mode', () => { expect(merchantRequests[0].headers['x-custom-header']).toBe('hello'); expect(merchantRequests[0].headers['x-another']).toBe('world'); - expect(merchantRequests[0].headers['user-agent']).toMatch(/^link-cli\//); + expect(merchantRequests[0].headers['user-agent']).toBe(CLI_USER_AGENT); }); - it('lets --header override the default User-Agent', async () => { - setNextResponse(200, APPROVED_SPT_REQUEST); - setMerchantResponse(200, '{"ok":true}'); + it.each(['User-Agent', 'user-agent'])( + 'lets --header override the default %s under an agent environment', + async (headerName) => { + setNextResponse(200, APPROVED_SPT_REQUEST); + setMerchantResponse(402, '{"error":"payment required"}', { + 'www-authenticate': WWW_AUTHENTICATE_STRIPE, + }); + setMerchantResponse(200, '{"ok":true}'); - await runProdCli( - 'mpp', - 'pay', - `http://127.0.0.1:${merchantPort}/api/endpoint`, - '--spend-request-id', - 'lsrq_spt_001', - '--header', - 'User-Agent: CustomBot/1.0', - '--json', - ); + const result = await runProdCliWithEnv( + { CLAUDECODE: '1' }, + 'mpp', + 'pay', + `http://127.0.0.1:${merchantPort}/api/endpoint`, + '--spend-request-id', + 'lsrq_spt_001', + '--header', + `${headerName}: CustomBot/1.0`, + '--json', + ); - expect(merchantRequests[0].headers['user-agent']).toBe('CustomBot/1.0'); - }); + expect(result.exitCode).toBe(0); + expect(merchantRequests).toHaveLength(2); + expect(merchantRequests[0].headers['user-agent']).toBe('CustomBot/1.0'); + expect(merchantRequests[1].headers['user-agent']).toBe('CustomBot/1.0'); + }, + ); it('auto-applies Content-Type application/json when --data is provided', async () => { setNextResponse(200, APPROVED_SPT_REQUEST); diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 338ee853..400b5c7a 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -16,6 +16,7 @@ import { createSpendRequestCli } from './commands/spend-request'; import { createTransactionsCli } from './commands/transactions'; import { createUcpCli } from './commands/ucp'; import { createUserInfoCli } from './commands/user-info'; +import { detectAIAgent } from './utils/ai-agent'; import { buildMcpCommand } from './utils/package-runner'; import { ResourceFactory } from './utils/resource-factory'; import { @@ -29,8 +30,9 @@ declare const __CLI_NAME__: string; const cliVersion = __CLI_VERSION__; const cliName = __CLI_NAME__; +const agent = detectAIAgent(process.env); const defaultHeaders = { - 'User-Agent': `link-cli/${cliVersion}`, + 'User-Agent': `link-cli/${cliVersion}${agent ? ` AIAgent/${agent}` : ''}`, }; const verboseIndex = process.argv.indexOf('--verbose'); diff --git a/packages/cli/src/utils/__tests__/ai-agent.test.ts b/packages/cli/src/utils/__tests__/ai-agent.test.ts new file mode 100644 index 00000000..b99e5765 --- /dev/null +++ b/packages/cli/src/utils/__tests__/ai-agent.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { detectAIAgent } from '../ai-agent'; + +const signals = [ + ['ANTIGRAVITY_CLI_ALIAS', 'antigravity'], + ['CLAUDECODE', 'claude_code'], + ['CLINE_ACTIVE', 'cline'], + ['CODEX_SANDBOX', 'codex_cli'], + ['CODEX_THREAD_ID', 'codex_cli'], + ['CODEX_SANDBOX_NETWORK_DISABLED', 'codex_cli'], + ['CODEX_CI', 'codex_cli'], + ['CURSOR_AGENT', 'cursor'], + ['GEMINI_CLI', 'gemini_cli'], + ['OPENCODE', 'open_code'], + ['OPENCLAW_SHELL', 'openclaw'], + ['CLAUDE_CODE_ENTRYPOINT', 'claude_code'], + ['CODEX_INTERNAL_ORIGINATOR_OVERRIDE', 'codex_cli'], +] as const; + +const emptyEnv = Object.fromEntries(signals.map(([key]) => [key, ''])); + +describe('detectAIAgent', () => { + it('returns an empty string without a recognized signal', () => { + expect(detectAIAgent({})).toBe(''); + expect(detectAIAgent({ UNKNOWN_AGENT: '1' })).toBe(''); + expect(detectAIAgent(emptyEnv)).toBe(''); + }); + + it.each(signals)('detects %s as %s', (variable, agent) => { + expect(detectAIAgent({ ...emptyEnv, [variable]: '1' })).toBe(agent); + }); + + it.each(signals)('ignores empty or missing %s', (variable) => { + expect(detectAIAgent({ [variable]: '' })).toBe(''); + expect(detectAIAgent({ [variable]: undefined })).toBe(''); + }); + + it.each(['0', 'false', ' ', 'private-thread-id'])( + 'treats %j as a presence signal and emits only the slug', + (value) => { + expect(detectAIAgent({ CODEX_THREAD_ID: value })).toBe('codex_cli'); + }, + ); + + it.each( + signals.map(([variable, agent], index) => ({ variable, agent, index })), + )('prioritizes $variable over all later signals', ({ agent, index }) => { + const env = Object.fromEntries( + signals.slice(index).map(([variable]) => [variable, '1']), + ); + expect(detectAIAgent(env)).toBe(agent); + }); +}); diff --git a/packages/cli/src/utils/ai-agent.ts b/packages/cli/src/utils/ai-agent.ts new file mode 100644 index 00000000..62cd10f2 --- /dev/null +++ b/packages/cli/src/utils/ai-agent.ts @@ -0,0 +1,27 @@ +// Keep signals, slugs, and priority aligned with Stripe CLI's DetectAIAgent: +// https://github.com/stripe/stripe-cli/blob/master/pkg/useragent/useragent.go +const agentSignals = [ + ['ANTIGRAVITY_CLI_ALIAS', 'antigravity'], + ['CLAUDECODE', 'claude_code'], + ['CLINE_ACTIVE', 'cline'], + ['CODEX_SANDBOX', 'codex_cli'], + ['CODEX_THREAD_ID', 'codex_cli'], + ['CODEX_SANDBOX_NETWORK_DISABLED', 'codex_cli'], + ['CODEX_CI', 'codex_cli'], + ['CURSOR_AGENT', 'cursor'], + ['GEMINI_CLI', 'gemini_cli'], + ['OPENCODE', 'open_code'], + ['OPENCLAW_SHELL', 'openclaw'], + // Host signals are fallbacks after all agent-specific signals. + ['CLAUDE_CODE_ENTRYPOINT', 'claude_code'], + ['CODEX_INTERNAL_ORIGINATOR_OVERRIDE', 'codex_cli'], +] as const; + +export function detectAIAgent( + env: Readonly>, +): string { + for (const [variable, agent] of agentSignals) { + if (env[variable]) return agent; + } + return ''; +}