diff --git a/.changeset/cli-attribution.md b/.changeset/cli-attribution.md new file mode 100644 index 0000000000..11649e7b7f --- /dev/null +++ b/.changeset/cli-attribution.md @@ -0,0 +1,5 @@ +--- +'@e2b/cli': patch +--- + +Attribute CLI traffic by tagging the `User-Agent` header of every request with `e2b-cli/` and the invoked command as `e2b-cli-command/` (e.g. `e2b-cli-command/sandbox.list`) via `ConnectionConfig.setIntegration` diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 5ed5238e13..90b9b86a80 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -1,9 +1,16 @@ import * as boxen from 'boxen' import * as e2b from 'e2b' +import * as packageJSON from '../package.json' import { getUserConfig, UserConfig } from './user' import { asBold, asPrimary } from './utils/format' +const cliIntegration = `e2b-cli/${packageJSON.version}` + +// Must run before any ConnectionConfig is constructed (including the +// module-level one below) — configs read the integration at construction time. +e2b.ConnectionConfig.setIntegration(cliIntegration) + export type Teams = e2b.paths['/teams']['get']['responses'][200]['content']['application/json'] @@ -101,14 +108,34 @@ const userConfig = getUserConfig() const resolvedAccessToken = process.env.E2B_ACCESS_TOKEN || userConfig?.tokens.access_token -export const connectionConfig = new e2b.ConnectionConfig({ - apiKey: process.env.E2B_API_KEY || userConfig?.teamApiKey, - apiHeaders: resolvedAccessToken - ? { Authorization: `Bearer ${resolvedAccessToken}` } - : undefined, -}) +function buildConnectionConfig() { + return new e2b.ConnectionConfig({ + apiKey: process.env.E2B_API_KEY || userConfig?.teamApiKey, + apiHeaders: resolvedAccessToken + ? { Authorization: `Bearer ${resolvedAccessToken}` } + : undefined, + }) +} + // The CLI authenticates team-scoped endpoints (e.g. `/teams`) with the access // token instead of an API key, so don't require an API key here. -export const client = new e2b.ApiClient(connectionConfig, { - requireApiKey: false, -}) +function buildClient(config: e2b.ConnectionConfig) { + return new e2b.ApiClient(config, { requireApiKey: false }) +} + +export let connectionConfig = buildConnectionConfig() +export let client = buildClient(connectionConfig) + +/** + * Extend the integration tag with the command being run, e.g. + * `e2b-cli-command/sandbox.list`. Configs capture the User-Agent when + * constructed, and the shared config and client above are built at import + * time — before the command is known — so they are rebuilt here. + */ +export function setCommandAttribution(commandPath: string) { + e2b.ConnectionConfig.setIntegration( + `${cliIntegration} e2b-cli-command/${commandPath}` + ) + connectionConfig = buildConnectionConfig() + client = buildClient(connectionConfig) +} diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index aeee06baa9..ed48b70d0d 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,11 +1,29 @@ import * as commander from 'commander' +import { setCommandAttribution } from 'src/api' import { asPrimary } from 'src/utils/format' import { templateCommand } from './template' import { sandboxCommand } from './sandbox' import { authCommand } from './auth' +// Canonical dot-joined path of the invoked command (aliases resolved), +// without the program name, e.g. `sandbox.list`. +function commandPath(command: commander.Command): string { + const names: string[] = [] + for ( + let cmd: commander.Command | null = command; + cmd?.parent; + cmd = cmd.parent + ) { + names.unshift(cmd.name()) + } + return names.join('.') +} + export const program = new commander.Command() + .hook('preAction', (_, actionCommand) => { + setCommandAttribution(commandPath(actionCommand)) + }) .description( `Create sandbox templates from Dockerfiles by running ${asPrimary( 'e2b template create' diff --git a/packages/cli/tests/attribution.test.ts b/packages/cli/tests/attribution.test.ts new file mode 100644 index 0000000000..1e596b157c --- /dev/null +++ b/packages/cli/tests/attribution.test.ts @@ -0,0 +1,59 @@ +import { createServer, Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, expect, test } from 'vitest' + +import * as packageJSON from '../package.json' +import { runCliWithPipedStdin } from './setup' + +let server: Server +let apiUrl: string +const userAgents: (string | undefined)[] = [] + +beforeAll(async () => { + server = createServer((req, res) => { + userAgents.push(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 + apiUrl = `http://127.0.0.1:${port}` +}) + +afterAll(() => { + server.close() +}) + +async function captureUserAgent(args: string[]): Promise { + const env: NodeJS.ProcessEnv = { + ...process.env, + E2B_API_URL: apiUrl, + E2B_API_KEY: `e2b_${'0'.repeat(40)}`, + } + delete env.E2B_DEBUG + delete env.E2B_ACCESS_TOKEN + + userAgents.length = 0 + const result = await runCliWithPipedStdin(args, Buffer.alloc(0), { + timeoutMs: 30_000, + env, + }) + expect(result.error).toBeUndefined() + return userAgents.find(Boolean) +} + +test('CLI requests carry SDK, CLI, and command attribution in the User-Agent', async () => { + const userAgent = await captureUserAgent(['sandbox', 'list']) + + expect(userAgent).toMatch(/^e2b-js-sdk\/\d/) + expect(userAgent).toContain(`e2b-cli/${packageJSON.version}`) + expect(userAgent).toContain('e2b-cli-command/sandbox.list') +}) + +test('command attribution uses the canonical name for aliases', async () => { + const userAgent = await captureUserAgent(['sandbox', 'ls']) + + expect(userAgent).toContain('e2b-cli-command/sandbox.list') +})