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
11 changes: 11 additions & 0 deletions .changeset/cli-access-token-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@e2b/cli': minor
---

The CLI now authenticates every request with your login's access token instead of a stored API key.

- `e2b auth login` opens `https://e2b.dev/auth/cli` (or `https://$E2B_DOMAIN/auth/cli`). The `E2B_DOCS_BASE` override is removed.
- Access tokens are refreshed automatically before they expire, including during long-running commands such as `sandbox logs --follow`, `sandbox metrics --follow` and an attached `sandbox create` session. Refreshes are serialized across concurrent `e2b` processes, so running several commands at once no longer risks signing you out.
- `--project`, `E2B_PROJECT_ID` and the project chosen with `e2b auth configure` select the project for each request.
- The project API key in `~/.e2b/config.json` plays no role any more: it is neither used for requests nor written by `e2b auth login`. After downgrading to a CLI version that authenticates with it, run `e2b auth login` again.
- `E2B_API_KEY` still works and takes precedence, for CI/CD and other non-interactive use.
2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"devDependencies": {
"@types/node": "catalog:",
"@types/npmcli__package-json": "^4.0.4",
"@types/proper-lockfile": "^4.1.4",
"@types/statuses": "^2.0.6",
"@typescript/native": "catalog:",
"@vitest/coverage-v8": "catalog:",
Expand Down Expand Up @@ -82,6 +83,7 @@
"e2b": "workspace:^",
"handlebars": "^4.7.10",
"inquirer": "^12.11.1",
"proper-lockfile": "^4.1.2",
"simple-update-notifier": "^2.0.0",
"simple-wcswidth": "^1.1.2",
"statuses": "^2.0.2",
Expand Down
61 changes: 40 additions & 21 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as e2b from 'e2b'
import * as packageJSON from '../package.json'
import { getUserConfig, UserConfig } from './user'
import { asBold, asPrimary } from './utils/format'
import { ensureValidAccessToken } from './utils/token-refresh'

// Must run before any ConnectionConfig is constructed (including the
// module-level one below) — configs read the integration at construction time.
Expand All @@ -12,7 +13,6 @@ e2b.ConnectionConfig.setIntegration(`e2b-cli/${packageJSON.version}`)
export type Teams =
e2b.paths['/teams']['get']['responses'][200]['content']['application/json']

export let apiKey = process.env.E2B_API_KEY
export const projectId = process.env.E2B_PROJECT_ID || process.env.E2B_TEAM_ID

const authErrorBox = () => {
Expand All @@ -34,19 +34,47 @@ Visit ${asPrimary('https://e2b.dev/dashboard?tab=keys')} to get the API key.`
})
}

export function ensureAPIKey() {
// If apiKey is not already set (either from env var or from user config), try to get it from config file
if (!apiKey) {
const userConfig = getUserConfig()
apiKey = userConfig?.projectApiKey
}
/**
* Credentials to spread into SDK call options, e.g. `Sandbox.list({ ...auth })`.
* Derived from ConnectionOpts so a renamed SDK option fails to compile here
* instead of being silently dropped by the spreads.
*/
export type Auth =
| Required<Pick<e2b.ConnectionOpts, 'apiKey'>>
| Required<Pick<e2b.ConnectionOpts, 'apiHeaders'>>

/**
* Resolve credentials for an E2B API call.
*
* `E2B_API_KEY` wins, for CI/CD and other non-interactive use. Otherwise the
* logged-in user's OAuth access token is used, refreshed first when it is
* about to expire, with `X-Team-ID` selecting the project. Call this right
* before each request (not once per command) so long-running commands keep
* working after the hour-long access token expires.
*/
export async function ensureAuth(cliProjectId?: string): Promise<Auth> {
const apiKey = process.env.E2B_API_KEY
if (apiKey) return { apiKey }

if (!apiKey) {
if (!getUserConfig()) {
console.error(authErrorBox())
process.exit(1)
} else {
return apiKey
}

const { accessToken, config } = await ensureValidAccessToken()
return {
apiHeaders: {
Authorization: `Bearer ${accessToken}`,
'X-Team-ID': resolveProjectId(cliProjectId) ?? config.projectId,
},
}
}

/** An E2B API client authenticated with {@link ensureAuth}. */
export async function apiClient(cliProjectId?: string) {
return new e2b.ApiClient(
new e2b.ConnectionConfig(await ensureAuth(cliProjectId))
)
}

export function ensureUserConfig(): UserConfig {
Expand Down Expand Up @@ -75,14 +103,5 @@ export function resolveProjectId(cliProjectId?: string): string | undefined {
return undefined
}

const userConfig = getUserConfig()

export const connectionConfig = new e2b.ConnectionConfig({
apiKey: process.env.E2B_API_KEY || userConfig?.projectApiKey,
})

// `e2b auth login` runs before any API key exists, and this client is built at
// import time, so don't require an API key here.
export const client = new e2b.ApiClient(connectionConfig, {
requireApiKey: false,
})
// Carries no credentials: used for the API domain and request signals only.
export const connectionConfig = new e2b.ConnectionConfig()
20 changes: 10 additions & 10 deletions packages/cli/src/commands/auth/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,7 @@ export const configureCommand = new commander.Command('configure')
return
}

// ensureValidAccessToken may refresh tokens and write them to disk.
// Re-read the config afterwards so we persist the refreshed tokens
// instead of overwriting them with stale in-memory copies.
const accessToken = await ensureValidAccessToken()
const userConfig = ensureUserConfig()
const { accessToken, config: userConfig } = await ensureValidAccessToken()

const config = new e2b.ConnectionConfig({
apiHeaders: { Authorization: `Bearer ${accessToken}` },
Expand Down Expand Up @@ -60,11 +56,15 @@ export const configureCommand = new commander.Command('configure')
])
)['project']

userConfig.projectName = team.name
userConfig.projectId = team.teamID
userConfig.projectApiKey = team.apiKey
userConfig.last_refresh = getConfigRefreshTimestamp()
writeUserConfig(USER_CONFIG_PATH, userConfig)
// Re-read the config: another e2b process may have refreshed (and so
// rotated) the tokens while the prompt was open, and writing back the
// copy read before it would sign the user out on the next refresh.
writeUserConfig(USER_CONFIG_PATH, {
...ensureUserConfig(),
projectName: team.name,
projectId: team.teamID,
last_refresh: getConfigRefreshTimestamp(),
})
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
mishushakov marked this conversation as resolved.
Resolved
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved

console.log(`Project ${asBold(team.name)} (${team.teamID}) selected.\n`)
})
7 changes: 3 additions & 4 deletions packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as e2b from 'e2b'

import * as pkg from '../../../package.json'
import {
DOCS_BASE,
CLI_AUTH_URL,
getConfigRefreshTimestamp,
getUserConfig,
writeUserConfig,
Expand Down Expand Up @@ -81,7 +81,6 @@ export const loginCommand = new commander.Command('login')
last_refresh: getConfigRefreshTimestamp(),
projectName: defaultTeam.name,
projectId: defaultTeam.teamID,
projectApiKey: defaultTeam.apiKey,
}

writeUserConfig(USER_CONFIG_PATH, userConfig)
Expand All @@ -107,15 +106,15 @@ interface SignInWithBrowserResponse {
async function signInWithBrowser(): Promise<SignInWithBrowserResponse> {
const server = http.createServer()
const { port } = await listen.default(server, 0, '127.0.0.1')
const loginUrl = new URL(`${DOCS_BASE}/api/cli`)
const loginUrl = new URL(CLI_AUTH_URL)
loginUrl.searchParams.set('next', `http://localhost:${port}`)
loginUrl.searchParams.set('cliVersion', pkg.version)

return new Promise((resolve, reject) => {
server.once('request', (req, res) => {
// Close the HTTP connection to prevent `server.close()` from hanging
res.setHeader('connection', 'close')
const followUpUrl = new URL(`${DOCS_BASE}/api/cli`)
const followUpUrl = new URL(CLI_AUTH_URL)
const searchParams = new URL(req.url || '/', 'http://localhost')
.searchParams
const searchParamsObj = Object.fromEntries(
Expand Down
12 changes: 5 additions & 7 deletions packages/cli/src/commands/sandbox/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as commander from '@commander-js/extra-typings'

import { spawnConnectedTerminal, TerminalOpts } from 'src/terminal'
import { asBold, asPrimary } from 'src/utils/format'
import { ensureAPIKey } from '../../api'
import { Auth, ensureAuth } from '../../api'
import { parseEnv } from 'src/utils/env'
import { printDashboardSandboxInspectUrl } from 'src/utils/urls'

Expand All @@ -20,15 +20,13 @@ export const connectCommand = new commander.Command('connect')
.alias('cn')
.action(async (sandboxID, opts) => {
try {
const apiKey = ensureAPIKey()

if (!sandboxID) {
console.error('You need to specify sandbox ID')
process.exit(1)
}

await connectToSandbox({
apiKey,
auth: await ensureAuth(),
sandboxID,
terminal: {
user: opts.user,
Expand All @@ -47,15 +45,15 @@ export const connectCommand = new commander.Command('connect')
})

async function connectToSandbox({
apiKey,
auth,
sandboxID,
terminal,
}: {
apiKey: string
auth: Auth
sandboxID: string
terminal?: TerminalOpts
}) {
const sandbox = await e2b.Sandbox.connect(sandboxID, { apiKey })
const sandbox = await e2b.Sandbox.connect(sandboxID, auth)

printDashboardSandboxInspectUrl(sandbox.sandboxId)

Expand Down
20 changes: 15 additions & 5 deletions packages/cli/src/commands/sandbox/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as e2b from 'e2b'
import * as commander from '@commander-js/extra-typings'
import { z } from 'zod'

import { ensureAPIKey } from 'src/api'
import { ensureAuth } from 'src/api'
import { spawnConnectedTerminal, TerminalOpts } from 'src/terminal'
import { asBold, asFormattedSandboxTemplate } from 'src/utils/format'
import { deprecatedConfigOption, warnIgnoredConfigOption } from '../../options'
Expand Down Expand Up @@ -60,7 +60,7 @@ export function createCommand(
try {
warnIgnoredConfigOption(opts)

const apiKey = ensureAPIKey()
const auth = await ensureAuth()

const templateID = template ?? DEFAULT_TEMPLATE

Expand All @@ -69,7 +69,7 @@ export function createCommand(
opts['lifecycle.autoresume']
)
const sandboxOpts = {
apiKey,
...auth,
...(lifecycle ? { lifecycle } : {}),
...(opts.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
}
Expand Down Expand Up @@ -157,11 +157,21 @@ async function connectSandbox({
timeoutMs?: number
terminal?: TerminalOpts
}) {
// Resolve credentials on every call rather than reusing the ones the
// sandbox was created with: a session can outlive the hour-long access token.
const setSandboxTimeout = async (ms: number) =>
e2b.Sandbox.setTimeout(sandbox.sandboxId, ms, await ensureAuth())
Comment thread
mishushakov marked this conversation as resolved.
Resolved

// keep-alive loop — track the in-flight promise so we can await it on shutdown
let pendingKeepAlive: Promise<void> = Promise.resolve()
const keepAliveTimeoutMs = timeoutMs ?? 30_000
const intervalId = setInterval(() => {
pendingKeepAlive = sandbox.setTimeout(keepAliveTimeoutMs)
// Handled here: an unhandled rejection would take the session down.
pendingKeepAlive = setSandboxTimeout(keepAliveTimeoutMs).catch((err) => {
console.error(
`Failed to keep the sandbox alive: ${err instanceof Error ? err.message : err}`
)
})
}, 5_000)

console.log(
Expand All @@ -174,7 +184,7 @@ async function connectSandbox({
} finally {
clearInterval(intervalId)
await pendingKeepAlive.catch(() => {})
await sandbox.setTimeout(timeoutMs ?? 1_000)
await setSandboxTimeout(timeoutMs ?? 1_000)
console.log(
`Closing terminal connection to template ${asFormattedSandboxTemplate(
template
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/sandbox/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { Sandbox, CommandExitError, NotFoundError } from 'e2b'
import * as commander from '@commander-js/extra-typings'

import { ensureAPIKey } from '../../api'
import { ensureAuth } from '../../api'
import { setupSignalHandlers } from 'src/utils/signal'
import { parseEnv } from 'src/utils/env'
import { buildCommand, isPipedStdin, streamStdinChunks } from './exec_helpers'
Expand Down Expand Up @@ -52,8 +52,8 @@ export const execCommand = new commander.Command('exec')
}

try {
const apiKey = ensureAPIKey()
const sandbox = await Sandbox.connect(sandboxID, { apiKey })
const auth = await ensureAuth()
const sandbox = await Sandbox.connect(sandboxID, auth)

if (hasPipedStdin && !sandbox.commands.supportsStdinClose) {
console.error(
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/sandbox/fork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as commander from '@commander-js/extra-typings'
import * as e2b from 'e2b'
import { NotFoundError } from 'e2b'

import { ensureAPIKey } from 'src/api'
import { ensureAuth } from 'src/api'
import { asBold } from 'src/utils/format'
import { integer, zodParser } from 'src/validation'
import { parseTimeout } from './create'
Expand Down Expand Up @@ -30,10 +30,10 @@ export const forkCommand = new commander.Command('fork')
)
.action(async (sandboxID, opts) => {
try {
const apiKey = ensureAPIKey()
const auth = await ensureAuth()

const forks = await e2b.Sandbox.fork(sandboxID, {
apiKey,
...auth,
...(opts.count !== undefined ? { count: opts.count } : {}),
...(opts.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
})
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/sandbox/info.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as commander from '@commander-js/extra-typings'
import { NotFoundError, Sandbox } from 'e2b'

import { ensureAPIKey } from 'src/api'
import { ensureAuth } from 'src/api'
import { Format, parseFormat } from 'src/options'
import { asBold } from 'src/utils/format'

Expand Down Expand Up @@ -54,8 +54,8 @@ export const infoCommand = new commander.Command('info')
)
.action(async (sandboxID, options) => {
try {
const apiKey = ensureAPIKey()
const info = await Sandbox.getInfo(sandboxID, { apiKey })
const auth = await ensureAuth()
const info = await Sandbox.getInfo(sandboxID, auth)

if (options.format === Format.PRETTY) {
renderPrettyInfo(info as unknown as Record<string, unknown>)
Expand Down
Loading
Loading