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/cli-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@e2b/cli': patch
---

Attribute CLI traffic by tagging the `User-Agent` header of every request with `e2b-cli/<version>` and the invoked command as `e2b-cli-command/<command>` (e.g. `e2b-cli-command/sandbox.list`) via `ConnectionConfig.setIntegration`
45 changes: 36 additions & 9 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
@@ -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']

Expand Down Expand Up @@ -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)
}
18 changes: 18 additions & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
59 changes: 59 additions & 0 deletions packages/cli/tests/attribution.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<string | undefined> {
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')
})
Loading