Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/link-cli-agent-attribution.md
Original file line number Diff line number Diff line change
@@ -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.
186 changes: 167 additions & 19 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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`,
Expand All @@ -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 /);
});

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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');
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/src/utils/__tests__/ai-agent.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
27 changes: 27 additions & 0 deletions packages/cli/src/utils/ai-agent.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | undefined>>,
): string {
for (const [variable, agent] of agentSignals) {
if (env[variable]) return agent;
}
return '';
}
Loading