From 5bbd2a9280e688e2a7df161647460c0a8b46036c Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:03:33 +0100 Subject: [PATCH 1/6] feat(cli): authenticate with the refreshed access token, drop the API key - Login opens https://e2b.dev/auth/cli (https://$E2B_DOMAIN/auth/cli); DOCS_BASE and the E2B_DOCS_BASE override are removed. - ensureAuth() replaces ensureAPIKey(): E2B_API_KEY (CI) wins, otherwise `Authorization: Bearer ` + `X-Team-ID: `, with the project from --project, E2B_PROJECT_ID or the config. - Access tokens are refreshed before they expire. Refresh tokens rotate, so refreshes are serialized across processes with a lock file and the config is re-read under the lock; previously two commands refreshing at once could get invalid_grant and delete the config. - Credentials are resolved per request, so `logs --follow`, `metrics --follow` and the attached `sandbox create` keepalive keep working past the one-hour token lifetime. - `auth configure` re-reads the config before writing, so it no longer overwrites tokens another process refreshed while its prompt was open. - projectApiKey is no longer stored; an old one is dropped on the next write. Config writes are atomic (temp file + rename). Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/cli-access-token-auth.md | 11 + packages/cli/src/api.ts | 55 +++-- packages/cli/src/commands/auth/configure.ts | 20 +- packages/cli/src/commands/auth/login.ts | 7 +- packages/cli/src/commands/sandbox/connect.ts | 12 +- packages/cli/src/commands/sandbox/create.ts | 15 +- packages/cli/src/commands/sandbox/exec.ts | 6 +- packages/cli/src/commands/sandbox/fork.ts | 6 +- packages/cli/src/commands/sandbox/info.ts | 6 +- packages/cli/src/commands/sandbox/kill.ts | 14 +- packages/cli/src/commands/sandbox/list.ts | 6 +- packages/cli/src/commands/sandbox/logs.ts | 4 +- packages/cli/src/commands/sandbox/metrics.ts | 9 +- packages/cli/src/commands/sandbox/pause.ts | 10 +- packages/cli/src/commands/sandbox/resume.ts | 10 +- packages/cli/src/commands/sandbox/snapshot.ts | 14 +- packages/cli/src/commands/sandbox/utils.ts | 6 +- packages/cli/src/commands/template/create.ts | 6 +- packages/cli/src/commands/template/delete.ts | 3 +- packages/cli/src/commands/template/list.ts | 7 +- packages/cli/src/commands/template/publish.ts | 3 +- packages/cli/src/user.ts | 33 ++- packages/cli/src/utils/token-refresh.ts | 96 ++++++-- packages/cli/tests/auth.test.ts | 216 ++++++++++++++++++ .../sandbox/backend_integration.test.ts | 2 +- .../commands/sandbox/create_lifecycle.test.ts | 19 +- .../commands/sandbox/exec_close_stdin.test.ts | 6 +- .../commands/sandbox/exec_passthrough.test.ts | 6 +- .../tests/commands/sandbox/exec_pipe.test.ts | 2 +- .../commands/sandbox/exec_stdin_flag.test.ts | 6 +- .../cli/tests/commands/sandbox/fork.test.ts | 4 +- .../cli/tests/commands/template/list.test.ts | 4 +- .../cli/tests/user_config_migration.test.ts | 8 +- .../cli/tests/user_config_permissions.test.ts | 28 ++- 34 files changed, 510 insertions(+), 150 deletions(-) create mode 100644 .changeset/cli-access-token-auth.md create mode 100644 packages/cli/tests/auth.test.ts diff --git a/.changeset/cli-access-token-auth.md b/.changeset/cli-access-token-auth.md new file mode 100644 index 0000000000..edb3ff8f35 --- /dev/null +++ b/.changeset/cli-access-token-auth.md @@ -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 is no longer stored in `~/.e2b/config.json`. A key saved by an earlier version is removed the next time the config is written. CLI versions released before this one treat a config without it as signed out, so after downgrading, run `e2b auth login` again. +- `E2B_API_KEY` still works and takes precedence, for CI/CD and other non-interactive use. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index d5bee56bf2..5b27a102e0 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -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. @@ -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 = () => { @@ -34,19 +34,41 @@ 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 })`. */ +export type Auth = { apiKey: string } | { apiHeaders: Record } - if (!apiKey) { +/** + * 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 { + const apiKey = process.env.E2B_API_KEY + if (apiKey) return { 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 { @@ -75,14 +97,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() diff --git a/packages/cli/src/commands/auth/configure.ts b/packages/cli/src/commands/auth/configure.ts index 12ac72b43d..b627212c9d 100644 --- a/packages/cli/src/commands/auth/configure.ts +++ b/packages/cli/src/commands/auth/configure.ts @@ -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}` }, @@ -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(), + }) console.log(`Project ${asBold(team.name)} (${team.teamID}) selected.\n`) }) diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 9650ccc1ce..8935376c5b 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -5,7 +5,7 @@ import * as e2b from 'e2b' import * as pkg from '../../../package.json' import { - DOCS_BASE, + CLI_AUTH_URL, getConfigRefreshTimestamp, getUserConfig, writeUserConfig, @@ -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) @@ -107,7 +106,7 @@ interface SignInWithBrowserResponse { async function signInWithBrowser(): Promise { 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) @@ -115,7 +114,7 @@ async function signInWithBrowser(): Promise { 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( diff --git a/packages/cli/src/commands/sandbox/connect.ts b/packages/cli/src/commands/sandbox/connect.ts index 52ce1ee4c4..a06dcb2df3 100644 --- a/packages/cli/src/commands/sandbox/connect.ts +++ b/packages/cli/src/commands/sandbox/connect.ts @@ -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' @@ -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, @@ -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) diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts index 62ddb7cc8a..38967009b8 100644 --- a/packages/cli/src/commands/sandbox/create.ts +++ b/packages/cli/src/commands/sandbox/create.ts @@ -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' @@ -60,7 +60,7 @@ export function createCommand( try { warnIgnoredConfigOption(opts) - const apiKey = ensureAPIKey() + const auth = await ensureAuth() const templateID = template ?? DEFAULT_TEMPLATE @@ -69,7 +69,7 @@ export function createCommand( opts['lifecycle.autoresume'] ) const sandboxOpts = { - apiKey, + ...auth, ...(lifecycle ? { lifecycle } : {}), ...(opts.timeout !== undefined ? { timeoutMs: opts.timeout } : {}), } @@ -157,11 +157,16 @@ 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()) + // keep-alive loop — track the in-flight promise so we can await it on shutdown let pendingKeepAlive: Promise = Promise.resolve() const keepAliveTimeoutMs = timeoutMs ?? 30_000 const intervalId = setInterval(() => { - pendingKeepAlive = sandbox.setTimeout(keepAliveTimeoutMs) + pendingKeepAlive = setSandboxTimeout(keepAliveTimeoutMs) }, 5_000) console.log( @@ -174,7 +179,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 diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts index 60e4bac1c7..65de40104f 100644 --- a/packages/cli/src/commands/sandbox/exec.ts +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -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' @@ -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( diff --git a/packages/cli/src/commands/sandbox/fork.ts b/packages/cli/src/commands/sandbox/fork.ts index 1dce04f83d..f2da08cc7f 100644 --- a/packages/cli/src/commands/sandbox/fork.ts +++ b/packages/cli/src/commands/sandbox/fork.ts @@ -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' @@ -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 } : {}), }) diff --git a/packages/cli/src/commands/sandbox/info.ts b/packages/cli/src/commands/sandbox/info.ts index be39891d21..9fe10eec49 100644 --- a/packages/cli/src/commands/sandbox/info.ts +++ b/packages/cli/src/commands/sandbox/info.ts @@ -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' @@ -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) diff --git a/packages/cli/src/commands/sandbox/kill.ts b/packages/cli/src/commands/sandbox/kill.ts index 87df6a1284..48ff696aa0 100644 --- a/packages/cli/src/commands/sandbox/kill.ts +++ b/packages/cli/src/commands/sandbox/kill.ts @@ -1,13 +1,13 @@ import * as commander from '@commander-js/extra-typings' -import { ensureAPIKey } from 'src/api' +import { Auth, ensureAuth } from 'src/api' import { asBold } from 'src/utils/format' import * as e2b from 'e2b' import { Sandbox } from 'e2b' import { parseMetadata, parseSandboxStates } from './utils' -async function killSandbox(sandboxID: string, apiKey: string) { - const killed = await e2b.Sandbox.kill(sandboxID, { apiKey }) +async function killSandbox(sandboxID: string, auth: Auth) { + const killed = await e2b.Sandbox.kill(sandboxID, auth) if (killed) { console.log(`Sandbox ${asBold(sandboxID)} has been killed`) } else { @@ -35,7 +35,7 @@ export const killCommand = new commander.Command('kill') ) .action(async (sandboxIDs, { all, state, metadata }) => { try { - const apiKey = ensureAPIKey() + const auth = await ensureAuth() const sandboxesState = state || ['running'] if ((!sandboxIDs || sandboxIDs.length === 0) && !all) { @@ -59,7 +59,7 @@ export const killCommand = new commander.Command('kill') if (all) { let total = 0 const iterator = Sandbox.list({ - apiKey, + ...auth, query: { state: sandboxesState, metadata, @@ -71,7 +71,7 @@ export const killCommand = new commander.Command('kill') total += sandboxes.length await Promise.all( - sandboxes.map((sandbox) => killSandbox(sandbox.sandboxId, apiKey)) + sandboxes.map((sandbox) => killSandbox(sandbox.sandboxId, auth)) ) } @@ -84,7 +84,7 @@ export const killCommand = new commander.Command('kill') process.exit(0) } else { await Promise.all( - sandboxIDs.map((sandboxID) => killSandbox(sandboxID, apiKey)) + sandboxIDs.map((sandboxID) => killSandbox(sandboxID, auth)) ) } } catch (err: any) { diff --git a/packages/cli/src/commands/sandbox/list.ts b/packages/cli/src/commands/sandbox/list.ts index c3dc4bee67..c26efd149e 100644 --- a/packages/cli/src/commands/sandbox/list.ts +++ b/packages/cli/src/commands/sandbox/list.ts @@ -3,7 +3,7 @@ import { Sandbox, SandboxInfo, SandboxListOrder, SandboxState } from 'e2b' import { z } from 'zod' -import { ensureAPIKey } from 'src/api' +import { ensureAuth } from 'src/api' import { Format, parseFormat } from 'src/options' import { renderTable } from 'src/utils/table' import { integer, zodParser } from 'src/validation' @@ -169,7 +169,7 @@ async function listSandboxes({ startedAfter, order, }: ListSandboxesOptions = {}): Promise { - const apiKey = ensureAPIKey() + const auth = await ensureAuth() let pageLimit = limit if (!limit || limit > PAGE_LIMIT) { @@ -178,7 +178,7 @@ async function listSandboxes({ const sandboxes: SandboxInfo[] = [] const iterator = Sandbox.list({ - apiKey: apiKey, + ...auth, limit: pageLimit, query: { state, metadata, template, startedAfter }, order, diff --git a/packages/cli/src/commands/sandbox/logs.ts b/packages/cli/src/commands/sandbox/logs.ts index 578258f1d1..eb67e18882 100644 --- a/packages/cli/src/commands/sandbox/logs.ts +++ b/packages/cli/src/commands/sandbox/logs.ts @@ -3,7 +3,7 @@ import * as e2b from 'e2b' import * as util from 'util' import * as chalk from 'chalk' -import { client, connectionConfig } from 'src/api' +import { apiClient, connectionConfig } from 'src/api' import { asBold, asTimestamp, withUnderline } from 'src/utils/format' import { wait } from 'src/utils/wait' import { handleE2BRequestError } from '../../utils/errors' @@ -234,6 +234,8 @@ async function listSandboxLogs({ sandboxID: string start?: number }): Promise { + // A fresh client per call: `--follow` can outlive the hour-long access token. + const client = await apiClient() const signal = connectionConfig.getSignal() const res = await client.api.GET('/sandboxes/{sandboxID}/logs', { signal, diff --git a/packages/cli/src/commands/sandbox/metrics.ts b/packages/cli/src/commands/sandbox/metrics.ts index 150d2a0689..6122c75fe3 100644 --- a/packages/cli/src/commands/sandbox/metrics.ts +++ b/packages/cli/src/commands/sandbox/metrics.ts @@ -6,7 +6,7 @@ import { wait } from 'src/utils/wait' import { Format, parseFormat } from 'src/options' import { formatEnum, isRunning } from './utils' import { Sandbox } from 'e2b' -import { ensureAPIKey } from '../../api' +import { ensureAuth } from '../../api' export const metricsCommand = new commander.Command('metrics') .description('show metrics for sandbox') @@ -34,11 +34,14 @@ export const metricsCommand = new commander.Command('metrics') console.log(`\nMetrics for sandbox ${asBold(sandboxID)}:`) } - const apiKey = ensureAPIKey() const isRunningPromise = isRunning(sandboxID) do { - const metrics = await Sandbox.getMetrics(sandboxID, { start, apiKey }) + // Per iteration: `--follow` can outlive the hour-long access token. + const metrics = await Sandbox.getMetrics(sandboxID, { + start, + ...(await ensureAuth()), + }) if (metrics.length !== 0 && !firstMetricsPrinted) { firstMetricsPrinted = true diff --git a/packages/cli/src/commands/sandbox/pause.ts b/packages/cli/src/commands/sandbox/pause.ts index ad3aadbef1..6e6097e99c 100644 --- a/packages/cli/src/commands/sandbox/pause.ts +++ b/packages/cli/src/commands/sandbox/pause.ts @@ -1,13 +1,13 @@ import * as commander from '@commander-js/extra-typings' -import { ensureAPIKey } from 'src/api' +import { Auth, ensureAuth } from 'src/api' import { asBold } from 'src/utils/format' import * as e2b from 'e2b' import { NotFoundError } from 'e2b' -async function pauseSandbox(sandboxID: string, apiKey: string) { +async function pauseSandbox(sandboxID: string, auth: Auth) { try { - const paused = await e2b.Sandbox.pause(sandboxID, { apiKey }) + const paused = await e2b.Sandbox.pause(sandboxID, auth) if (paused) { console.log(`Sandbox ${asBold(sandboxID)} has been paused`) } else { @@ -31,8 +31,8 @@ export const pauseCommand = new commander.Command('pause') .alias('ps') .action(async (sandboxID) => { try { - const apiKey = ensureAPIKey() - await pauseSandbox(sandboxID, apiKey) + const auth = await ensureAuth() + await pauseSandbox(sandboxID, auth) } catch (err: unknown) { console.error(err) process.exit(1) diff --git a/packages/cli/src/commands/sandbox/resume.ts b/packages/cli/src/commands/sandbox/resume.ts index a90b20fb75..cdbdcd02f8 100644 --- a/packages/cli/src/commands/sandbox/resume.ts +++ b/packages/cli/src/commands/sandbox/resume.ts @@ -1,13 +1,13 @@ import * as commander from '@commander-js/extra-typings' -import { ensureAPIKey } from 'src/api' +import { Auth, ensureAuth } from 'src/api' import { asBold } from 'src/utils/format' import * as e2b from 'e2b' import { NotFoundError } from 'e2b' -async function resumeSandbox(sandboxID: string, apiKey: string) { +async function resumeSandbox(sandboxID: string, auth: Auth) { try { - await e2b.Sandbox.connect(sandboxID, { apiKey }) + await e2b.Sandbox.connect(sandboxID, auth) console.log(`Sandbox ${asBold(sandboxID)} has been resumed`) } catch (err: unknown) { if (err instanceof NotFoundError) { @@ -27,8 +27,8 @@ export const resumeCommand = new commander.Command('resume') .alias('rs') .action(async (sandboxID) => { try { - const apiKey = ensureAPIKey() - await resumeSandbox(sandboxID, apiKey) + const auth = await ensureAuth() + await resumeSandbox(sandboxID, auth) } catch (err: unknown) { console.error(err) process.exit(1) diff --git a/packages/cli/src/commands/sandbox/snapshot.ts b/packages/cli/src/commands/sandbox/snapshot.ts index 27d79181ba..7a2b24e19d 100644 --- a/packages/cli/src/commands/sandbox/snapshot.ts +++ b/packages/cli/src/commands/sandbox/snapshot.ts @@ -1,7 +1,7 @@ import * as commander from '@commander-js/extra-typings' import { NotFoundError, Sandbox, SnapshotInfo } from 'e2b' -import { ensureAPIKey } from 'src/api' +import { ensureAuth } from 'src/api' import { Format, parseFormat } from 'src/options' import { integer, zodParser } from 'src/validation' import { asBold } from 'src/utils/format' @@ -20,10 +20,10 @@ const createSnapshotCommand = new commander.Command('create') ) .action(async (sandboxID, options) => { try { - const apiKey = ensureAPIKey() + const auth = await ensureAuth() const snapshot = await Sandbox.createSnapshot(sandboxID, { - apiKey, + ...auth, name: options.name, }) @@ -66,7 +66,7 @@ const listSnapshotsCommand = new commander.Command('list') ) .action(async (sandboxID, options) => { try { - const apiKey = ensureAPIKey() + const auth = await ensureAuth() const limit = options.limit === 0 ? undefined : (options.limit ?? DEFAULT_LIMIT) @@ -77,7 +77,7 @@ const listSnapshotsCommand = new commander.Command('list') const allSnapshots: SnapshotInfo[] = [] const paginator = Sandbox.listSnapshots({ - apiKey, + ...auth, sandboxId: sandboxID, name: options.name, limit: pageLimit, @@ -117,11 +117,11 @@ const deleteSnapshotCommand = new commander.Command('delete') .alias('dl') .action(async (snapshotIDs) => { try { - const apiKey = ensureAPIKey() + const auth = await ensureAuth() const results = await Promise.allSettled( snapshotIDs.map(async (snapshotID) => { - const deleted = await Sandbox.deleteSnapshot(snapshotID, { apiKey }) + const deleted = await Sandbox.deleteSnapshot(snapshotID, auth) if (deleted) { console.log(`Snapshot ${asBold(snapshotID)} has been deleted`) } else { diff --git a/packages/cli/src/commands/sandbox/utils.ts b/packages/cli/src/commands/sandbox/utils.ts index 24bfadc3f8..fc19813d85 100644 --- a/packages/cli/src/commands/sandbox/utils.ts +++ b/packages/cli/src/commands/sandbox/utils.ts @@ -1,7 +1,7 @@ import { Sandbox, SandboxState } from 'e2b' import { z } from 'zod' -import { ensureAPIKey } from 'src/api' +import { ensureAuth } from 'src/api' import { keyValuePairs, zodParser } from 'src/validation' import { wait } from '../../utils/wait' import { asBold } from '../../utils/format' @@ -72,9 +72,9 @@ export function waitForSandboxEnd(sandboxID: string) { export async function isRunning(sandboxID: string) { try { - const apiKey = ensureAPIKey() + const auth = await ensureAuth() const info = await Sandbox.getInfo(sandboxID, { - apiKey, + ...auth, }) return info.state === 'running' } catch (err) { diff --git a/packages/cli/src/commands/template/create.ts b/packages/cli/src/commands/template/create.ts index 1249219e73..c96d24a844 100644 --- a/packages/cli/src/commands/template/create.ts +++ b/packages/cli/src/commands/template/create.ts @@ -1,7 +1,7 @@ import * as boxen from 'boxen' import * as commander from '@commander-js/extra-typings' import { defaultBuildLogger, Template, TemplateClass } from 'e2b' -import { connectionConfig, ensureAPIKey } from 'src/api' +import { connectionConfig, ensureAuth } from 'src/api' import { defaultDockerfileName, fallbackDockerfileName, @@ -108,7 +108,7 @@ export const createCommand = new commander.Command('create') console.log('\nBuilding sandbox template...\n') // Prepare API credentials for SDK - const apiKey = ensureAPIKey() + const auth = await ensureAuth() const domain = connectionConfig.domain // Build the template using SDK @@ -119,7 +119,7 @@ export const createCommand = new commander.Command('create') memoryMB: memoryMB, minFreeDiskMb, skipCache: opts.cache === false, - apiKey: apiKey, + ...auth, domain: domain, onBuildLogs: defaultBuildLogger(), }) diff --git a/packages/cli/src/commands/template/delete.ts b/packages/cli/src/commands/template/delete.ts index cb8def65e8..1e2fe58658 100644 --- a/packages/cli/src/commands/template/delete.ts +++ b/packages/cli/src/commands/template/delete.ts @@ -9,10 +9,11 @@ import { } from 'src/utils/format' import { deprecatedConfigOption, warnIgnoredConfigOption } from 'src/options' import { confirm } from 'src/utils/confirm' -import { client } from 'src/api' +import { apiClient } from 'src/api' import { handleE2BRequestError } from '../../utils/errors' async function deleteTemplate(templateID: string) { + const client = await apiClient() const res = await client.api.DELETE('/templates/{templateID}', { params: { path: { diff --git a/packages/cli/src/commands/template/list.ts b/packages/cli/src/commands/template/list.ts index 2b456ff2c2..5a4966f71e 100644 --- a/packages/cli/src/commands/template/list.ts +++ b/packages/cli/src/commands/template/list.ts @@ -4,7 +4,7 @@ import * as e2b from 'e2b' import { listAliases } from '../../utils/format' import { renderTable } from 'src/utils/table' import { sortTemplatesAliases } from 'src/utils/templateSort' -import { client, ensureAPIKey, resolveProjectId } from 'src/api' +import { apiClient, resolveProjectId } from 'src/api' import { deprecatedTeamOption, Format, @@ -39,7 +39,6 @@ export const listCommand = new commander.Command('list') const defaultLimit = opts.format === Format.JSON ? undefined : DEFAULT_LIMIT const limit = opts.limit === 0 ? undefined : (opts.limit ?? defaultLimit) - ensureAPIKey() process.stdout.write('\n') const { templates, hasMore } = await listSandboxTemplates({ @@ -120,6 +119,10 @@ async function listSandboxTemplates({ pageLimit = PAGE_LIMIT } + // With an access token, X-Team-ID (from the same project ID) selects the + // project; with an API key, the key does and the teamID query param is + // ignored. + const client = await apiClient(projectId) const templates: e2b.components['schemas']['Template'][] = [] let nextToken: string | undefined diff --git a/packages/cli/src/commands/template/publish.ts b/packages/cli/src/commands/template/publish.ts index 7f49ef2336..d2d85b6ba2 100644 --- a/packages/cli/src/commands/template/publish.ts +++ b/packages/cli/src/commands/template/publish.ts @@ -9,10 +9,11 @@ import { } from 'src/utils/format' import { deprecatedConfigOption, warnIgnoredConfigOption } from 'src/options' import { confirm } from 'src/utils/confirm' -import { client } from 'src/api' +import { apiClient } from 'src/api' import { handleE2BRequestError } from '../../utils/errors' async function publishTemplate(templateID: string, publish: boolean) { + const client = await apiClient() const res = await client.api.PATCH('/v2/templates/{templateID}', { params: { path: { diff --git a/packages/cli/src/user.ts b/packages/cli/src/user.ts index 5cbf56be53..77fb9820c3 100644 --- a/packages/cli/src/user.ts +++ b/packages/cli/src/user.ts @@ -24,7 +24,6 @@ const userConfigSchema = z.object({ ...authFields, projectName: z.string(), projectId: z.string(), - projectApiKey: z.string(), }) export type UserConfig = z.infer @@ -35,7 +34,6 @@ const v1UserConfigSchema = z.object({ ...authFields, teamName: z.string(), teamId: z.string(), - teamApiKey: z.string(), }) export const USER_CONFIG_PATH = path.join(os.homedir(), '.e2b', 'config.json') // TODO: Keep in Keychain @@ -46,9 +44,8 @@ export const DEPRECATED_USER_CONFIG_MESSAGE = // getUserConfig can be called several times per command; warn only once. let deprecationWarned = false -export const DOCS_BASE = - process.env.E2B_DOCS_BASE || - `https://${process.env.E2B_DOMAIN || 'e2b.dev'}/docs` +/** Browser sign-in page for `e2b auth login`. */ +export const CLI_AUTH_URL = `https://${process.env.E2B_DOMAIN || 'e2b.dev'}/auth/cli` const DASHBOARD_BASE = process.env.E2B_DASHBOARD_BASE || @@ -92,15 +89,16 @@ function migrateV1UserConfig(config: unknown): UserConfig | null { // Spread the original object, not the parsed one, so fields this CLI // version doesn't know about are kept when the config is written back. - const { teamName, teamId, teamApiKey, ...rest } = config as z.infer< + const { teamName, teamId, ...rest } = config as z.infer< typeof v1UserConfigSchema - > + > & { teamApiKey?: string } + // Requests use the access token; the stored API key is not carried over. + delete rest.teamApiKey return { ...rest, version: 2, projectName: teamName, projectId: teamId, - projectApiKey: teamApiKey, } } @@ -114,9 +112,24 @@ export function getConfigRefreshTimestamp(): string { * written as owner-readable/writable only because it contains credentials. */ export function writeUserConfig(configPath: string, config: UserConfig): void { + // Earlier CLI versions stored a long-lived project API key (`projectApiKey`, + // or `teamApiKey` in v1). Requests now use the access token, so the key is + // dropped from disk the next time the config is written (login, configure + // or a token refresh). + const persisted: Record = { ...config } + delete persisted.projectApiKey + delete persisted.teamApiKey + const dir = path.dirname(configPath) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) fs.chmodSync(dir, 0o700) - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }) - fs.chmodSync(configPath, 0o600) + // Write to a temporary file and rename it over the config, so other e2b + // processes reading it (token refreshes happen in the background of any + // command) never see a partially written file. + const tmpPath = `${configPath}.${process.pid}.tmp` + fs.writeFileSync(tmpPath, JSON.stringify(persisted, null, 2), { + mode: 0o600, + }) + fs.chmodSync(tmpPath, 0o600) + fs.renameSync(tmpPath, configPath) } diff --git a/packages/cli/src/utils/token-refresh.ts b/packages/cli/src/utils/token-refresh.ts index 31a3fed075..f7c1e89525 100644 --- a/packages/cli/src/utils/token-refresh.ts +++ b/packages/cli/src/utils/token-refresh.ts @@ -1,9 +1,23 @@ import * as fs from 'fs' +import * as path from 'path' -import { getUserConfig, writeUserConfig, USER_CONFIG_PATH } from 'src/user' +import { + getUserConfig, + writeUserConfig, + USER_CONFIG_PATH, + UserConfig, +} from 'src/user' +import { wait } from './wait' const REFRESH_SKEW_SECONDS = 60 +const LOCK_PATH = `${USER_CONFIG_PATH}.lock` +// Longer than a refresh can take (REFRESH_TIMEOUT_MS), so a live holder's lock +// is never treated as stale; a crashed holder's is cleared after this long. +const LOCK_STALE_MS = 30_000 +const LOCK_WAIT_MS = 45_000 +const REFRESH_TIMEOUT_MS = 15_000 + function decodeJwtExp(token: string): number | undefined { const payload = token.split('.')[1] if (!payload) return undefined @@ -46,6 +60,7 @@ async function refreshOAuthToken( ): Promise { const res = await fetch(tokenEndpoint, { method: 'POST', + signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS), headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', @@ -75,17 +90,69 @@ async function refreshOAuthToken( } } -export async function ensureValidAccessToken(): Promise { - const config = getUserConfig() - if (!config) { - throw new Error('No user config found, run `e2b auth login` first.') +/** + * Take the cross-process refresh lock, waiting for another holder to finish. + * Returns a function that releases it. + */ +async function acquireRefreshLock(): Promise<() => void> { + fs.mkdirSync(path.dirname(LOCK_PATH), { recursive: true, mode: 0o700 }) + const deadline = Date.now() + LOCK_WAIT_MS + + for (;;) { + try { + fs.closeSync(fs.openSync(LOCK_PATH, 'wx', 0o600)) + return () => fs.rmSync(LOCK_PATH, { force: true }) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + } + + const lockAgeMs = + Date.now() - + (fs.statSync(LOCK_PATH, { throwIfNoEntry: false })?.mtimeMs ?? Date.now()) + if (lockAgeMs > LOCK_STALE_MS) { + fs.rmSync(LOCK_PATH, { force: true }) + continue + } + if (Date.now() > deadline) { + throw new Error( + `Timed out waiting for another e2b process to refresh your credentials. If none is running, delete ${LOCK_PATH} and try again.` + ) + } + await wait(100) } +} - if (!isTokenExpired(config.tokens.access_token)) { - return config.tokens.access_token +/** + * Return a valid access token for the logged-in user, refreshing it first if + * it expires within a minute, along with the config it belongs to. + * + * Refresh tokens rotate: once used, the old one is rejected. Refreshing is + * therefore serialized across CLI processes with a lock file, and the config + * is re-read under the lock so a process that waited picks up the tokens the + * other one just wrote instead of spending the rotated refresh token. + */ +export async function ensureValidAccessToken(): Promise<{ + accessToken: string + config: UserConfig +}> { + const current = getUserConfig() + if (!current) { + throw new Error('No user config found, run `e2b auth login` first.') + } + if (!isTokenExpired(current.tokens.access_token)) { + return { accessToken: current.tokens.access_token, config: current } } + const release = await acquireRefreshLock() try { + const config = getUserConfig() + if (!config) { + throw new Error('No user config found, run `e2b auth login` first.') + } + if (!isTokenExpired(config.tokens.access_token)) { + return { accessToken: config.tokens.access_token, config } + } + const refreshed = await refreshOAuthToken( config.tokens.refresh_token, config.oauth.client_id, @@ -93,15 +160,10 @@ export async function ensureValidAccessToken(): Promise { ) // Token refresh updates only tokens.* — it does NOT update last_refresh. - writeUserConfig(USER_CONFIG_PATH, { - ...config, - tokens: { - access_token: refreshed.access_token, - refresh_token: refreshed.refresh_token, - }, - }) - - return refreshed.access_token + const updated = { ...config, tokens: refreshed } + writeUserConfig(USER_CONFIG_PATH, updated) + + return { accessToken: refreshed.access_token, config: updated } } catch (err) { if (err instanceof TokenRefreshError && err.errorCode === 'invalid_grant') { fs.rmSync(USER_CONFIG_PATH, { force: true }) @@ -110,5 +172,7 @@ export async function ensureValidAccessToken(): Promise { ) } throw err + } finally { + release() } } diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts new file mode 100644 index 0000000000..4c0f482002 --- /dev/null +++ b/packages/cli/tests/auth.test.ts @@ -0,0 +1,216 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +// Point USER_CONFIG_PATH (computed from os.homedir() at module load) at a +// temp directory. Modules are imported dynamically after mockHome is set. +let mockHome = '' + +vi.mock('os', async (importOriginal: () => Promise) => { + const actual = await importOriginal() + return { ...actual, homedir: () => mockHome } +}) + +/** An unsigned JWT that expires `expiresInSeconds` from now. */ +function jwt(expiresInSeconds: number, id = 'token') { + const payload = Buffer.from( + JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + expiresInSeconds, + jti: id, + }) + ).toString('base64url') + return `header.${payload}.signature` +} + +function configPath() { + return path.join(mockHome, '.e2b', 'config.json') +} + +function writeConfig(accessToken: string, refreshToken = 'refresh-1') { + fs.mkdirSync(path.dirname(configPath()), { recursive: true }) + fs.writeFileSync( + configPath(), + JSON.stringify({ + version: 2, + identity: { email: 'user@example.com' }, + oauth: { + token_endpoint: 'https://auth.example.com/oauth2/token', + revoke_endpoint: 'https://auth.example.com/oauth2/revoke', + client_id: 'cli', + }, + tokens: { access_token: accessToken, refresh_token: refreshToken }, + projectName: 'default', + projectId: 'config-project', + projectApiKey: 'e2b_stale_key', + }) + ) +} + +function readConfig() { + return JSON.parse(fs.readFileSync(configPath(), 'utf8')) +} + +/** Token endpoint that rotates refresh tokens, like the real one. */ +function mockTokenEndpoint() { + let issued = 0 + const fetchMock = vi.fn(async (_url: string, init: RequestInit) => { + const body = new URLSearchParams(init.body as string) + if (body.get('refresh_token') !== `refresh-${issued + 1}`) { + return Response.json({ error: 'invalid_grant' }, { status: 400 }) + } + issued += 1 + // Let concurrent callers pile up on the lock while this one refreshes. + await new Promise((resolve) => setTimeout(resolve, 50)) + return Response.json({ + access_token: jwt(3600, `access-${issued + 1}`), + refresh_token: `refresh-${issued + 1}`, + expires_in: 3600, + }) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +beforeEach(() => { + vi.resetModules() + vi.unstubAllEnvs() + vi.stubEnv('E2B_API_KEY', '') + vi.stubEnv('E2B_PROJECT_ID', '') + vi.stubEnv('E2B_TEAM_ID', '') + mockHome = fs.mkdtempSync(path.join(os.tmpdir(), 'e2b-auth-')) +}) + +afterEach(() => { + fs.rmSync(mockHome, { recursive: true, force: true }) + vi.unstubAllGlobals() + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +describe('ensureAuth', () => { + test('uses E2B_API_KEY when set', async () => { + vi.stubEnv('E2B_API_KEY', 'e2b_env_key') + writeConfig(jwt(3600)) + const { ensureAuth } = await import('../src/api') + + expect(await ensureAuth()).toEqual({ apiKey: 'e2b_env_key' }) + }) + + test('uses the access token and the configured project otherwise', async () => { + const token = jwt(3600) + writeConfig(token) + const { ensureAuth } = await import('../src/api') + + expect(await ensureAuth()).toEqual({ + apiHeaders: { + Authorization: `Bearer ${token}`, + 'X-Team-ID': 'config-project', + }, + }) + }) + + test('selects the project from --project, then E2B_PROJECT_ID', async () => { + writeConfig(jwt(3600)) + vi.stubEnv('E2B_PROJECT_ID', 'env-project') + const { ensureAuth } = await import('../src/api') + + const headers = async (project?: string) => + ((await ensureAuth(project)) as { apiHeaders: Record }) + .apiHeaders['X-Team-ID'] + expect(await headers()).toBe('env-project') + expect(await headers('flag-project')).toBe('flag-project') + }) + + test('exits with the login hint when signed out', async () => { + const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`) + }) as never) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { ensureAuth } = await import('../src/api') + + await expect(ensureAuth()).rejects.toThrow('exit 1') + expect(exit).toHaveBeenCalledWith(1) + expect(String(error.mock.calls[0][0])).toContain('e2b auth login') + }) +}) + +describe('ensureValidAccessToken', () => { + test('refreshes an expiring token and stores the rotated tokens', async () => { + writeConfig(jwt(30)) + const fetchMock = mockTokenEndpoint() + const { ensureValidAccessToken } = + await import('../src/utils/token-refresh') + + const { accessToken } = await ensureValidAccessToken() + + expect(fetchMock).toHaveBeenCalledTimes(1) + const stored = readConfig() + expect(stored.tokens).toEqual({ + access_token: accessToken, + refresh_token: 'refresh-2', + }) + // The API key earlier versions stored is dropped on write. + expect(stored.projectApiKey).toBeUndefined() + expect(fs.existsSync(`${configPath()}.lock`)).toBe(false) + }) + + test('refreshes once when several commands need a token at the same time', async () => { + writeConfig(jwt(-10)) + const fetchMock = mockTokenEndpoint() + const { ensureValidAccessToken } = + await import('../src/utils/token-refresh') + + const results = await Promise.all( + Array.from({ length: 5 }, () => ensureValidAccessToken()) + ) + + // Without the lock, the losers would spend the rotated refresh token, + // get invalid_grant and sign the user out. + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(new Set(results.map((r) => r.accessToken)).size).toBe(1) + expect(readConfig().tokens.refresh_token).toBe('refresh-2') + }) + + test('signs out when the refresh token is rejected', async () => { + writeConfig(jwt(-10), 'revoked') + mockTokenEndpoint() + const { ensureValidAccessToken } = + await import('../src/utils/token-refresh') + + await expect(ensureValidAccessToken()).rejects.toThrow( + 'Your session has expired' + ) + expect(fs.existsSync(configPath())).toBe(false) + }) + + test('clears a lock left behind by a crashed process', async () => { + writeConfig(jwt(-10)) + const lockPath = `${configPath()}.lock` + fs.writeFileSync(lockPath, '') + const old = new Date(Date.now() - 60_000) + fs.utimesSync(lockPath, old, old) + const fetchMock = mockTokenEndpoint() + const { ensureValidAccessToken } = + await import('../src/utils/token-refresh') + + await ensureValidAccessToken() + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fs.existsSync(lockPath)).toBe(false) + }) +}) + +describe('CLI_AUTH_URL', () => { + test('defaults to e2b.dev/auth/cli', async () => { + vi.stubEnv('E2B_DOMAIN', '') + const { CLI_AUTH_URL } = await import('../src/user') + expect(CLI_AUTH_URL).toBe('https://e2b.dev/auth/cli') + }) + + test('follows E2B_DOMAIN', async () => { + vi.stubEnv('E2B_DOMAIN', 'e2b.example.com') + const { CLI_AUTH_URL } = await import('../src/user') + expect(CLI_AUTH_URL).toBe('https://e2b.example.com/auth/cli') + }) +}) diff --git a/packages/cli/tests/commands/sandbox/backend_integration.test.ts b/packages/cli/tests/commands/sandbox/backend_integration.test.ts index 4af72a4312..b17a24f183 100644 --- a/packages/cli/tests/commands/sandbox/backend_integration.test.ts +++ b/packages/cli/tests/commands/sandbox/backend_integration.test.ts @@ -20,7 +20,7 @@ const domain = userConfig?.E2B_DOMAIN || userConfig?.domain || 'e2b.app' -const apiKey = process.env.E2B_API_KEY || userConfig?.projectApiKey +const apiKey = process.env.E2B_API_KEY const shouldSkip = !apiKey || isDebug const integrationTest = test.skipIf(shouldSkip) const templateId = diff --git a/packages/cli/tests/commands/sandbox/create_lifecycle.test.ts b/packages/cli/tests/commands/sandbox/create_lifecycle.test.ts index e19ee32fe2..57c5a8d920 100644 --- a/packages/cli/tests/commands/sandbox/create_lifecycle.test.ts +++ b/packages/cli/tests/commands/sandbox/create_lifecycle.test.ts @@ -2,13 +2,15 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' const mocks = vi.hoisted(() => { const create = vi.fn() - const ensureAPIKey = vi.fn(() => 'test-api-key') + const setTimeout = vi.fn() + const ensureAuth = vi.fn(async () => ({ apiKey: 'test-api-key' })) const spawnConnectedTerminal = vi.fn() const printDashboardSandboxInspectUrl = vi.fn() return { create, - ensureAPIKey, + setTimeout, + ensureAuth, spawnConnectedTerminal, printDashboardSandboxInspectUrl, } @@ -17,11 +19,12 @@ const mocks = vi.hoisted(() => { vi.mock('e2b', () => ({ Sandbox: { create: mocks.create, + setTimeout: mocks.setTimeout, }, })) vi.mock('../../../src/api', () => ({ - ensureAPIKey: mocks.ensureAPIKey, + ensureAuth: mocks.ensureAuth, })) vi.mock('src/utils/urls', () => ({ @@ -144,8 +147,14 @@ describe('sandbox create lifecycle options', () => { cwd: undefined, envs: undefined, }) - expect(sandbox.setTimeout).toHaveBeenCalledWith(120_000) - expect(sandbox.setTimeout).not.toHaveBeenCalledWith(1_000) + expect(mocks.setTimeout).toHaveBeenCalledWith('sandbox-id', 120_000, { + apiKey: 'test-api-key', + }) + expect(mocks.setTimeout).not.toHaveBeenCalledWith( + 'sandbox-id', + 1_000, + expect.anything() + ) expect(exitSpy).toHaveBeenCalledWith(0) vi.useRealTimers() }) diff --git a/packages/cli/tests/commands/sandbox/exec_close_stdin.test.ts b/packages/cli/tests/commands/sandbox/exec_close_stdin.test.ts index 17712e10b5..161d809fdc 100644 --- a/packages/cli/tests/commands/sandbox/exec_close_stdin.test.ts +++ b/packages/cli/tests/commands/sandbox/exec_close_stdin.test.ts @@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => { const sendStdin = vi.fn() const closeStdin = vi.fn() const kill = vi.fn() - const ensureAPIKey = vi.fn(() => 'test-api-key') + const ensureAuth = vi.fn(async () => ({ apiKey: 'test-api-key' })) const isPipedStdin = vi.fn() const streamStdinChunks = vi.fn() const setupSignalHandlers = vi.fn(() => () => {}) @@ -19,7 +19,7 @@ const mocks = vi.hoisted(() => { sendStdin, closeStdin, kill, - ensureAPIKey, + ensureAuth, isPipedStdin, streamStdinChunks, setupSignalHandlers, @@ -47,7 +47,7 @@ vi.mock('e2b', () => { }) vi.mock('../../../src/api', () => ({ - ensureAPIKey: mocks.ensureAPIKey, + ensureAuth: mocks.ensureAuth, })) vi.mock('src/utils/signal', () => ({ diff --git a/packages/cli/tests/commands/sandbox/exec_passthrough.test.ts b/packages/cli/tests/commands/sandbox/exec_passthrough.test.ts index 8cbbfbbe3c..c9519198f9 100644 --- a/packages/cli/tests/commands/sandbox/exec_passthrough.test.ts +++ b/packages/cli/tests/commands/sandbox/exec_passthrough.test.ts @@ -3,14 +3,14 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' const mocks = vi.hoisted(() => { const connect = vi.fn() const run = vi.fn() - const ensureAPIKey = vi.fn(() => 'test-api-key') + const ensureAuth = vi.fn(async () => ({ apiKey: 'test-api-key' })) const isPipedStdin = vi.fn() const setupSignalHandlers = vi.fn(() => () => {}) return { connect, run, - ensureAPIKey, + ensureAuth, isPipedStdin, setupSignalHandlers, } @@ -37,7 +37,7 @@ vi.mock('e2b', () => { }) vi.mock('../../../src/api', () => ({ - ensureAPIKey: mocks.ensureAPIKey, + ensureAuth: mocks.ensureAuth, })) vi.mock('src/utils/signal', () => ({ diff --git a/packages/cli/tests/commands/sandbox/exec_pipe.test.ts b/packages/cli/tests/commands/sandbox/exec_pipe.test.ts index e57e3ce9e6..c4c09f8729 100644 --- a/packages/cli/tests/commands/sandbox/exec_pipe.test.ts +++ b/packages/cli/tests/commands/sandbox/exec_pipe.test.ts @@ -28,7 +28,7 @@ const domain = userConfig?.E2B_DOMAIN || userConfig?.domain || 'e2b.app' -const apiKey = process.env.E2B_API_KEY || userConfig?.projectApiKey +const apiKey = process.env.E2B_API_KEY const shouldSkip = !apiKey || isDebug const integrationTest = test.skipIf(shouldSkip) const templateId = diff --git a/packages/cli/tests/commands/sandbox/exec_stdin_flag.test.ts b/packages/cli/tests/commands/sandbox/exec_stdin_flag.test.ts index 89b4b575be..de4c5bfad8 100644 --- a/packages/cli/tests/commands/sandbox/exec_stdin_flag.test.ts +++ b/packages/cli/tests/commands/sandbox/exec_stdin_flag.test.ts @@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => { const run = vi.fn() const sendStdin = vi.fn() const closeStdin = vi.fn() - const ensureAPIKey = vi.fn(() => 'test-api-key') + const ensureAuth = vi.fn(async () => ({ apiKey: 'test-api-key' })) const isPipedStdin = vi.fn() const streamStdinChunks = vi.fn() const setupSignalHandlers = vi.fn(() => () => {}) @@ -15,7 +15,7 @@ const mocks = vi.hoisted(() => { run, sendStdin, closeStdin, - ensureAPIKey, + ensureAuth, isPipedStdin, streamStdinChunks, setupSignalHandlers, @@ -43,7 +43,7 @@ vi.mock('e2b', () => { }) vi.mock('../../../src/api', () => ({ - ensureAPIKey: mocks.ensureAPIKey, + ensureAuth: mocks.ensureAuth, })) vi.mock('src/utils/signal', () => ({ diff --git a/packages/cli/tests/commands/sandbox/fork.test.ts b/packages/cli/tests/commands/sandbox/fork.test.ts index a20a941b43..28526f010b 100644 --- a/packages/cli/tests/commands/sandbox/fork.test.ts +++ b/packages/cli/tests/commands/sandbox/fork.test.ts @@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => { return { fork: vi.fn(), - ensureAPIKey: vi.fn(() => 'test-api-key'), + ensureAuth: vi.fn(async () => ({ apiKey: 'test-api-key' })), NotFoundError, } }) @@ -18,7 +18,7 @@ vi.mock('e2b', () => ({ })) vi.mock('../../../src/api', () => ({ - ensureAPIKey: mocks.ensureAPIKey, + ensureAuth: mocks.ensureAuth, })) async function runFork(args: string[]) { diff --git a/packages/cli/tests/commands/template/list.test.ts b/packages/cli/tests/commands/template/list.test.ts index baf5b856b2..ebb7a477e1 100644 --- a/packages/cli/tests/commands/template/list.test.ts +++ b/packages/cli/tests/commands/template/list.test.ts @@ -1,10 +1,8 @@ import { beforeAll, describe, expect, test } from 'vitest' import { ApiClient, ConnectionConfig } from 'e2b' -import { getUserConfig } from 'src/user' import { isDebug, runCli } from '../../setup' -const userConfig = getUserConfig() -const apiKey = process.env.E2B_API_KEY || userConfig?.projectApiKey +const apiKey = process.env.E2B_API_KEY const domain = process.env.E2B_DOMAIN || 'e2b.app' const shouldSkip = !apiKey || isDebug const integrationTest = test.skipIf(shouldSkip) diff --git a/packages/cli/tests/user_config_migration.test.ts b/packages/cli/tests/user_config_migration.test.ts index a06fd8fb88..d077febc4e 100644 --- a/packages/cli/tests/user_config_migration.test.ts +++ b/packages/cli/tests/user_config_migration.test.ts @@ -66,12 +66,11 @@ describe('getUserConfig', () => { ...v1AuthFields, projectName: 'default', projectId: 'team-id', - projectApiKey: 'team-api-key-secret', dockerProxySet: true, }) }) - it('returns a valid v2 config as-is', async () => { + it('still loads a v2 config that has the API key earlier versions stored', async () => { const v2Config = { version: 2, ...v1AuthFields, @@ -95,9 +94,8 @@ describe('getUserConfig', () => { // Missing team* fields — cannot be migrated. }) - const { getUserConfig, DEPRECATED_USER_CONFIG_MESSAGE } = await import( - '../src/user' - ) + const { getUserConfig, DEPRECATED_USER_CONFIG_MESSAGE } = + await import('../src/user') expect(getUserConfig()).toBeNull() expect(fs.existsSync(configPath)).toBe(true) expect(consoleError).toHaveBeenCalledWith(DEPRECATED_USER_CONFIG_MESSAGE) diff --git a/packages/cli/tests/user_config_permissions.test.ts b/packages/cli/tests/user_config_permissions.test.ts index 639fbdd53d..a83e6d031c 100644 --- a/packages/cli/tests/user_config_permissions.test.ts +++ b/packages/cli/tests/user_config_permissions.test.ts @@ -35,7 +35,6 @@ describe('writeUserConfig', () => { last_refresh: '2024-06-24T12:00:00.000Z', projectName: 'default', projectId: 'project-id', - projectApiKey: 'project-api-key-secret', } writeUserConfig(configPath, config) @@ -49,4 +48,31 @@ describe('writeUserConfig', () => { } expect(JSON.parse(fs.readFileSync(configPath, 'utf8'))).toEqual(config) }) + + it('drops the API key stored by earlier CLI versions', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2b-config-key-')) + tmpDirs.push(tmpDir) + const configPath = path.join(tmpDir, 'config.json') + const config = { + version: 2, + identity: { email: 'user@example.com' }, + oauth: { + token_endpoint: 'https://hydra.example.com/oauth2/token', + revoke_endpoint: 'https://hydra.example.com/oauth2/revoke', + client_id: 'cli-client-id', + }, + tokens: { access_token: 'access', refresh_token: 'refresh' }, + projectName: 'default', + projectId: 'project-id', + futureField: 'kept', + } as const + writeUserConfig(configPath, { + ...config, + projectApiKey: 'e2b_secret', + } as UserConfig) + + expect(JSON.parse(fs.readFileSync(configPath, 'utf8'))).toEqual(config) + // The temporary file used for the atomic write is renamed away. + expect(fs.readdirSync(tmpDir)).toEqual(['config.json']) + }) }) From 53bea5c135afe9189bcdacc4b1395a8884ec677e Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:49:57 +0100 Subject: [PATCH 2/6] fix(cli): keep auth failures out of sandbox-stopped checks and the keepalive - Auth is derived from e2b.ConnectionOpts so a renamed SDK option fails to compile instead of being dropped by the spreads. - isRunning lets credential errors propagate; logs --follow no longer reports a running sandbox as closed when the token refresh fails. - The attached-create keepalive logs a failed setTimeout instead of leaving an unhandled rejection that terminates the session. Co-Authored-By: Claude Fable 5.1 --- packages/cli/src/api.ts | 10 ++++++++-- packages/cli/src/commands/sandbox/create.ts | 7 ++++++- packages/cli/src/commands/sandbox/utils.ts | 13 ++++++++----- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 5b27a102e0..dc6d9ec69d 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -34,8 +34,14 @@ Visit ${asPrimary('https://e2b.dev/dashboard?tab=keys')} to get the API key.` }) } -/** Credentials to spread into SDK call options, e.g. `Sandbox.list({ ...auth })`. */ -export type Auth = { apiKey: string } | { apiHeaders: Record } +/** + * 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> + | Required> /** * Resolve credentials for an E2B API call. diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts index 38967009b8..a411d1f367 100644 --- a/packages/cli/src/commands/sandbox/create.ts +++ b/packages/cli/src/commands/sandbox/create.ts @@ -166,7 +166,12 @@ async function connectSandbox({ let pendingKeepAlive: Promise = Promise.resolve() const keepAliveTimeoutMs = timeoutMs ?? 30_000 const intervalId = setInterval(() => { - pendingKeepAlive = setSandboxTimeout(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( diff --git a/packages/cli/src/commands/sandbox/utils.ts b/packages/cli/src/commands/sandbox/utils.ts index fc19813d85..4e1e3196f8 100644 --- a/packages/cli/src/commands/sandbox/utils.ts +++ b/packages/cli/src/commands/sandbox/utils.ts @@ -65,17 +65,20 @@ export function waitForSandboxEnd(sandboxID: string) { } } - monitor() + monitor().catch((err) => { + // Credentials could not be refreshed: stop, as a missing login would. + console.error(err instanceof Error ? err.message : err) + process.exit(1) + }) return () => running } export async function isRunning(sandboxID: string) { + // Outside the try: a failed token refresh is not a stopped sandbox. + const auth = await ensureAuth() try { - const auth = await ensureAuth() - const info = await Sandbox.getInfo(sandboxID, { - ...auth, - }) + const info = await Sandbox.getInfo(sandboxID, auth) return info.state === 'running' } catch (err) { console.error(`Failed to check sandbox status: ${err}`) From 28515b2063cc55d5906b92c7c29de37cd3e9ee2c Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:23:03 +0100 Subject: [PATCH 3/6] refactor(cli): move the config lock into a generic withFileLock The cross-process lock that serializes token refreshes now lives in src/utils/lock.ts as withFileLock(path, fn, { staleMs, timeoutMs }): it takes an O_EXCL lock file, runs fn, and always releases it, taking over locks older than staleMs left by crashed processes. Both timings are required, since the stale time must outlast whatever runs under the lock. Token refresh uses it with ~/.e2b/config.json.lock. The invalid_grant sign-out still happens under the lock. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cli/src/utils/lock.ts | 62 ++++++++++++ packages/cli/src/utils/token-refresh.ts | 120 ++++++++++-------------- packages/cli/tests/utils/lock.test.ts | 78 +++++++++++++++ 3 files changed, 189 insertions(+), 71 deletions(-) create mode 100644 packages/cli/src/utils/lock.ts create mode 100644 packages/cli/tests/utils/lock.test.ts diff --git a/packages/cli/src/utils/lock.ts b/packages/cli/src/utils/lock.ts new file mode 100644 index 0000000000..c3486c0801 --- /dev/null +++ b/packages/cli/src/utils/lock.ts @@ -0,0 +1,62 @@ +import * as fs from 'fs' +import * as path from 'path' + +import { wait } from './wait' + +export type FileLockOptions = { + /** + * A lock file older than this is taken to be left behind by a process that + * crashed while holding it, and is removed. Must be longer than `fn` can + * take, or a live holder's lock could be taken over. + */ + staleMs: number + /** How long to wait for another holder before giving up. */ + timeoutMs: number +} + +const POLL_INTERVAL_MS = 100 + +/** + * Run `fn` while holding an exclusive lock that all e2b processes on this + * machine share, and release it afterwards, whether `fn` returns or throws. + * + * The lock is a file created with `O_EXCL` at `lockPath`: whoever creates it + * holds the lock, and the others poll until it's gone. + */ +export async function withFileLock( + lockPath: string, + fn: () => T | Promise, + { staleMs, timeoutMs }: FileLockOptions +): Promise { + fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 }) + const deadline = Date.now() + timeoutMs + + for (;;) { + try { + fs.closeSync(fs.openSync(lockPath, 'wx', 0o600)) + break + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + } + + const lockAgeMs = + Date.now() - + (fs.statSync(lockPath, { throwIfNoEntry: false })?.mtimeMs ?? Date.now()) + if (lockAgeMs > staleMs) { + fs.rmSync(lockPath, { force: true }) + continue + } + if (Date.now() > deadline) { + throw new Error( + `Timed out waiting for another e2b process to release ${lockPath}. If none is running, delete it and try again.` + ) + } + await wait(POLL_INTERVAL_MS) + } + + try { + return await fn() + } finally { + fs.rmSync(lockPath, { force: true }) + } +} diff --git a/packages/cli/src/utils/token-refresh.ts b/packages/cli/src/utils/token-refresh.ts index f7c1e89525..97ba21fd39 100644 --- a/packages/cli/src/utils/token-refresh.ts +++ b/packages/cli/src/utils/token-refresh.ts @@ -1,5 +1,4 @@ import * as fs from 'fs' -import * as path from 'path' import { getUserConfig, @@ -7,17 +6,21 @@ import { USER_CONFIG_PATH, UserConfig, } from 'src/user' -import { wait } from './wait' +import { withFileLock } from './lock' const REFRESH_SKEW_SECONDS = 60 -const LOCK_PATH = `${USER_CONFIG_PATH}.lock` -// Longer than a refresh can take (REFRESH_TIMEOUT_MS), so a live holder's lock -// is never treated as stale; a crashed holder's is cleared after this long. -const LOCK_STALE_MS = 30_000 -const LOCK_WAIT_MS = 45_000 const REFRESH_TIMEOUT_MS = 15_000 +/** Serializes writes to the user config across e2b processes. */ +const CONFIG_LOCK_PATH = `${USER_CONFIG_PATH}.lock` +const CONFIG_LOCK = { + // Longer than a refresh can take (REFRESH_TIMEOUT_MS), so a live holder's + // lock is never treated as stale. + staleMs: 30_000, + timeoutMs: 45_000, +} + function decodeJwtExp(token: string): number | undefined { const payload = token.split('.')[1] if (!payload) return undefined @@ -90,38 +93,6 @@ async function refreshOAuthToken( } } -/** - * Take the cross-process refresh lock, waiting for another holder to finish. - * Returns a function that releases it. - */ -async function acquireRefreshLock(): Promise<() => void> { - fs.mkdirSync(path.dirname(LOCK_PATH), { recursive: true, mode: 0o700 }) - const deadline = Date.now() + LOCK_WAIT_MS - - for (;;) { - try { - fs.closeSync(fs.openSync(LOCK_PATH, 'wx', 0o600)) - return () => fs.rmSync(LOCK_PATH, { force: true }) - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err - } - - const lockAgeMs = - Date.now() - - (fs.statSync(LOCK_PATH, { throwIfNoEntry: false })?.mtimeMs ?? Date.now()) - if (lockAgeMs > LOCK_STALE_MS) { - fs.rmSync(LOCK_PATH, { force: true }) - continue - } - if (Date.now() > deadline) { - throw new Error( - `Timed out waiting for another e2b process to refresh your credentials. If none is running, delete ${LOCK_PATH} and try again.` - ) - } - await wait(100) - } -} - /** * Return a valid access token for the logged-in user, refreshing it first if * it expires within a minute, along with the config it belongs to. @@ -143,36 +114,43 @@ export async function ensureValidAccessToken(): Promise<{ return { accessToken: current.tokens.access_token, config: current } } - const release = await acquireRefreshLock() - try { - const config = getUserConfig() - if (!config) { - throw new Error('No user config found, run `e2b auth login` first.') - } - if (!isTokenExpired(config.tokens.access_token)) { - return { accessToken: config.tokens.access_token, config } - } - - const refreshed = await refreshOAuthToken( - config.tokens.refresh_token, - config.oauth.client_id, - config.oauth.token_endpoint - ) - - // Token refresh updates only tokens.* — it does NOT update last_refresh. - const updated = { ...config, tokens: refreshed } - writeUserConfig(USER_CONFIG_PATH, updated) - - return { accessToken: refreshed.access_token, config: updated } - } catch (err) { - if (err instanceof TokenRefreshError && err.errorCode === 'invalid_grant') { - fs.rmSync(USER_CONFIG_PATH, { force: true }) - throw new Error( - 'Your session has expired. Please run `e2b auth login` again.' - ) - } - throw err - } finally { - release() - } + return withFileLock( + CONFIG_LOCK_PATH, + async () => { + const config = getUserConfig() + if (!config) { + throw new Error('No user config found, run `e2b auth login` first.') + } + if (!isTokenExpired(config.tokens.access_token)) { + return { accessToken: config.tokens.access_token, config } + } + + let refreshed: RefreshedTokens + try { + refreshed = await refreshOAuthToken( + config.tokens.refresh_token, + config.oauth.client_id, + config.oauth.token_endpoint + ) + } catch (err) { + if ( + err instanceof TokenRefreshError && + err.errorCode === 'invalid_grant' + ) { + fs.rmSync(USER_CONFIG_PATH, { force: true }) + throw new Error( + 'Your session has expired. Please run `e2b auth login` again.' + ) + } + throw err + } + + // Token refresh updates only tokens.* — it does NOT update last_refresh. + const updated = { ...config, tokens: refreshed } + writeUserConfig(USER_CONFIG_PATH, updated) + + return { accessToken: refreshed.access_token, config: updated } + }, + CONFIG_LOCK + ) } diff --git a/packages/cli/tests/utils/lock.test.ts b/packages/cli/tests/utils/lock.test.ts new file mode 100644 index 0000000000..dd819559f2 --- /dev/null +++ b/packages/cli/tests/utils/lock.test.ts @@ -0,0 +1,78 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' + +import { withFileLock } from '../../src/utils/lock' + +const options = { staleMs: 30_000, timeoutMs: 2_000 } + +let dir: string +let lockPath: string + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2b-lock-')) + lockPath = path.join(dir, 'nested', 'config.json.lock') +}) + +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }) +}) + +describe('withFileLock', () => { + test('runs one holder at a time and returns its result', async () => { + let active = 0 + let maxActive = 0 + const run = (id: number) => + withFileLock( + lockPath, + async () => { + active += 1 + maxActive = Math.max(maxActive, active) + await new Promise((resolve) => setTimeout(resolve, 20)) + active -= 1 + return id + }, + options + ) + + expect(await Promise.all([1, 2, 3].map(run))).toEqual([1, 2, 3]) + expect(maxActive).toBe(1) + expect(fs.existsSync(lockPath)).toBe(false) + }) + + test('releases the lock when the holder throws', async () => { + await expect( + withFileLock( + lockPath, + () => { + throw new Error('boom') + }, + options + ) + ).rejects.toThrow('boom') + expect(fs.existsSync(lockPath)).toBe(false) + }) + + test('takes over a lock left behind by a crashed process', async () => { + fs.mkdirSync(path.dirname(lockPath), { recursive: true }) + fs.writeFileSync(lockPath, '') + const old = new Date(Date.now() - 60_000) + fs.utimesSync(lockPath, old, old) + + expect(await withFileLock(lockPath, () => 'ran', options)).toBe('ran') + }) + + test('gives up while a live holder keeps the lock', async () => { + fs.mkdirSync(path.dirname(lockPath), { recursive: true }) + fs.writeFileSync(lockPath, '') + + await expect( + withFileLock(lockPath, () => 'ran', { staleMs: 30_000, timeoutMs: 200 }) + ).rejects.toThrow( + `Timed out waiting for another e2b process to release ${lockPath}` + ) + // A live holder's lock is left alone. + expect(fs.existsSync(lockPath)).toBe(true) + }) +}) From 5326b143c012f354399a16ccbc6c0a43179aa091 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:35:38 +0100 Subject: [PATCH 4/6] refactor(cli): manage the config lock with proper-lockfile `withFileLock` now locks the config file through proper-lockfile instead of a hand-rolled O_EXCL file: the lock is kept fresh while held so a live holder is never taken over, it is released when the process exits, and waiting uses bounded retries. Callers pass the config path; the lock is `config.json.lock` as before, now a directory. Co-Authored-By: Claude Fable 5.1 --- packages/cli/package.json | 2 + packages/cli/src/utils/lock.ts | 62 ++++++++++++------------- packages/cli/src/utils/token-refresh.ts | 3 +- packages/cli/tests/auth.test.ts | 2 +- packages/cli/tests/utils/lock.test.ts | 19 ++++---- pnpm-lock.yaml | 38 +++++++++++++++ 6 files changed, 82 insertions(+), 44 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 6442e3567b..30cca23a1b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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:", @@ -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", diff --git a/packages/cli/src/utils/lock.ts b/packages/cli/src/utils/lock.ts index c3486c0801..9f0309f2df 100644 --- a/packages/cli/src/utils/lock.ts +++ b/packages/cli/src/utils/lock.ts @@ -1,62 +1,60 @@ import * as fs from 'fs' import * as path from 'path' - -import { wait } from './wait' +import { lock } from 'proper-lockfile' export type FileLockOptions = { /** - * A lock file older than this is taken to be left behind by a process that - * crashed while holding it, and is removed. Must be longer than `fn` can - * take, or a live holder's lock could be taken over. + * A lock whose holder hasn't touched it for this long is taken to be left + * behind by a process that crashed, and is removed. The holder refreshes + * the lock every `staleMs / 2`, so a live holder is never taken over. */ staleMs: number /** How long to wait for another holder before giving up. */ timeoutMs: number } -const POLL_INTERVAL_MS = 100 +const RETRY_INTERVAL_MS = 100 /** - * Run `fn` while holding an exclusive lock that all e2b processes on this - * machine share, and release it afterwards, whether `fn` returns or throws. + * Run `fn` while holding an exclusive lock on `filePath` that all e2b + * processes on this machine share, and release it afterwards, whether `fn` + * returns or throws. * - * The lock is a file created with `O_EXCL` at `lockPath`: whoever creates it - * holds the lock, and the others poll until it's gone. + * The lock is `${filePath}.lock`, managed by proper-lockfile: it's created + * atomically, kept fresh while held, released when the process exits, and + * taken over once stale. `filePath` itself doesn't have to exist. */ export async function withFileLock( - lockPath: string, + filePath: string, fn: () => T | Promise, { staleMs, timeoutMs }: FileLockOptions ): Promise { - fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 }) - const deadline = Date.now() + timeoutMs - - for (;;) { - try { - fs.closeSync(fs.openSync(lockPath, 'wx', 0o600)) - break - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err - } + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }) - const lockAgeMs = - Date.now() - - (fs.statSync(lockPath, { throwIfNoEntry: false })?.mtimeMs ?? Date.now()) - if (lockAgeMs > staleMs) { - fs.rmSync(lockPath, { force: true }) - continue - } - if (Date.now() > deadline) { + let release: () => Promise + try { + release = await lock(filePath, { + realpath: false, + stale: staleMs, + retries: { + retries: Math.ceil(timeoutMs / RETRY_INTERVAL_MS), + minTimeout: RETRY_INTERVAL_MS, + maxTimeout: RETRY_INTERVAL_MS, + maxRetryTime: timeoutMs, + }, + }) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ELOCKED') { throw new Error( - `Timed out waiting for another e2b process to release ${lockPath}. If none is running, delete it and try again.` + `Timed out waiting for another e2b process to release ${filePath}.lock. If none is running, delete it and try again.` ) } - await wait(POLL_INTERVAL_MS) + throw err } try { return await fn() } finally { - fs.rmSync(lockPath, { force: true }) + await release() } } diff --git a/packages/cli/src/utils/token-refresh.ts b/packages/cli/src/utils/token-refresh.ts index 97ba21fd39..bb8e6314a8 100644 --- a/packages/cli/src/utils/token-refresh.ts +++ b/packages/cli/src/utils/token-refresh.ts @@ -13,7 +13,6 @@ const REFRESH_SKEW_SECONDS = 60 const REFRESH_TIMEOUT_MS = 15_000 /** Serializes writes to the user config across e2b processes. */ -const CONFIG_LOCK_PATH = `${USER_CONFIG_PATH}.lock` const CONFIG_LOCK = { // Longer than a refresh can take (REFRESH_TIMEOUT_MS), so a live holder's // lock is never treated as stale. @@ -115,7 +114,7 @@ export async function ensureValidAccessToken(): Promise<{ } return withFileLock( - CONFIG_LOCK_PATH, + USER_CONFIG_PATH, async () => { const config = getUserConfig() if (!config) { diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 4c0f482002..0d75f47e60 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -187,7 +187,7 @@ describe('ensureValidAccessToken', () => { test('clears a lock left behind by a crashed process', async () => { writeConfig(jwt(-10)) const lockPath = `${configPath()}.lock` - fs.writeFileSync(lockPath, '') + fs.mkdirSync(lockPath) const old = new Date(Date.now() - 60_000) fs.utimesSync(lockPath, old, old) const fetchMock = mockTokenEndpoint() diff --git a/packages/cli/tests/utils/lock.test.ts b/packages/cli/tests/utils/lock.test.ts index dd819559f2..fc1904d7f4 100644 --- a/packages/cli/tests/utils/lock.test.ts +++ b/packages/cli/tests/utils/lock.test.ts @@ -8,11 +8,13 @@ import { withFileLock } from '../../src/utils/lock' const options = { staleMs: 30_000, timeoutMs: 2_000 } let dir: string +let filePath: string let lockPath: string beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2b-lock-')) - lockPath = path.join(dir, 'nested', 'config.json.lock') + filePath = path.join(dir, 'nested', 'config.json') + lockPath = `${filePath}.lock` }) afterEach(() => { @@ -25,7 +27,7 @@ describe('withFileLock', () => { let maxActive = 0 const run = (id: number) => withFileLock( - lockPath, + filePath, async () => { active += 1 maxActive = Math.max(maxActive, active) @@ -44,7 +46,7 @@ describe('withFileLock', () => { test('releases the lock when the holder throws', async () => { await expect( withFileLock( - lockPath, + filePath, () => { throw new Error('boom') }, @@ -55,20 +57,19 @@ describe('withFileLock', () => { }) test('takes over a lock left behind by a crashed process', async () => { - fs.mkdirSync(path.dirname(lockPath), { recursive: true }) - fs.writeFileSync(lockPath, '') + fs.mkdirSync(lockPath, { recursive: true }) const old = new Date(Date.now() - 60_000) fs.utimesSync(lockPath, old, old) - expect(await withFileLock(lockPath, () => 'ran', options)).toBe('ran') + expect(await withFileLock(filePath, () => 'ran', options)).toBe('ran') + expect(fs.existsSync(lockPath)).toBe(false) }) test('gives up while a live holder keeps the lock', async () => { - fs.mkdirSync(path.dirname(lockPath), { recursive: true }) - fs.writeFileSync(lockPath, '') + fs.mkdirSync(lockPath, { recursive: true }) await expect( - withFileLock(lockPath, () => 'ran', { staleMs: 30_000, timeoutMs: 200 }) + withFileLock(filePath, () => 'ran', { staleMs: 30_000, timeoutMs: 200 }) ).rejects.toThrow( `Timed out waiting for another e2b process to release ${lockPath}` ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea578d1f5d..6e328110ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: inquirer: specifier: ^12.11.1 version: 12.11.1(@types/node@22.20.5) + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 simple-update-notifier: specifier: ^2.0.0 version: 2.0.0 @@ -150,6 +153,9 @@ importers: '@types/npmcli__package-json': specifier: ^4.0.4 version: 4.0.4 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 '@types/statuses': specifier: ^2.0.6 version: 2.0.6 @@ -1835,6 +1841,12 @@ packages: '@types/platform@1.3.6': resolution: {integrity: sha512-ZmSaqHuvzv+jC232cFoz2QqPUkaj6EvMmCrWcx3WRr7xTPVFCMUOTcOq8m2d+Zw1iKRc1kDiaA+jtNrV0hkVew==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/set-cookie-parser@2.4.10': resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} @@ -2989,6 +3001,9 @@ packages: resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} engines: {node: ^20.17.0 || >=22.9.0} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -3021,6 +3036,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + rettime@0.11.12: resolution: {integrity: sha512-5ExtR1ppiDBQkVoPw5NAqdbfH4Iq2VOmQKHMtUoaFxA9bo2krzFYBtyq6K40xxbYp+2w/YOr6D+1ToF6tTN53A==} @@ -3102,6 +3121,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -4738,6 +4760,12 @@ snapshots: '@types/platform@1.3.6': {} + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + + '@types/retry@0.12.5': {} + '@types/set-cookie-parser@2.4.10': dependencies: '@types/node': 22.20.5 @@ -5790,6 +5818,12 @@ snapshots: proc-log@6.1.0: {} + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + quansync@0.2.11: {} quansync@1.0.0: {} @@ -5816,6 +5850,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + retry@0.12.0: {} + rettime@0.11.12: {} reusify@1.1.0: {} @@ -5947,6 +5983,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} simple-update-notifier@2.0.0: From 587158efebe701b33334699ab713e1ebbb014176 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:26:57 +0100 Subject: [PATCH 5/6] fix(cli): keep the stored project API key, warn instead of crashing on a lost lock The project API key is no longer used for requests, but a key saved by an earlier CLI version is kept on disk (and a v1 `teamApiKey` is carried over as `projectApiKey`) so those versions stay signed in next to this one. `withFileLock` replaces proper-lockfile's default `onCompromised`, which throws from a timer and would take the command down, with a warning. Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-access-token-auth.md | 2 +- packages/cli/src/user.ts | 19 ++++++++++--------- packages/cli/src/utils/lock.ts | 8 ++++++++ packages/cli/tests/auth.test.ts | 4 ++-- .../cli/tests/user_config_migration.test.ts | 1 + .../cli/tests/user_config_permissions.test.ts | 8 +++----- 6 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.changeset/cli-access-token-auth.md b/.changeset/cli-access-token-auth.md index edb3ff8f35..385a499b58 100644 --- a/.changeset/cli-access-token-auth.md +++ b/.changeset/cli-access-token-auth.md @@ -7,5 +7,5 @@ The CLI now authenticates every request with your login's access token instead o - `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 is no longer stored in `~/.e2b/config.json`. A key saved by an earlier version is removed the next time the config is written. CLI versions released before this one treat a config without it as signed out, so after downgrading, run `e2b auth login` again. +- The project API key in `~/.e2b/config.json` is no longer used for requests. A key saved by an earlier version is kept, so CLI versions released before this one stay signed in. Logging in with this version does not store a key, so after downgrading from a fresh login, run `e2b auth login` again. - `E2B_API_KEY` still works and takes precedence, for CI/CD and other non-interactive use. diff --git a/packages/cli/src/user.ts b/packages/cli/src/user.ts index 77fb9820c3..21a0282d93 100644 --- a/packages/cli/src/user.ts +++ b/packages/cli/src/user.ts @@ -24,6 +24,9 @@ const userConfigSchema = z.object({ ...authFields, projectName: z.string(), projectId: z.string(), + // Stored by earlier CLI versions, which authenticate with it. Not used for + // requests any more, but kept on disk so those versions stay signed in. + projectApiKey: z.string().optional(), }) export type UserConfig = z.infer @@ -89,16 +92,17 @@ function migrateV1UserConfig(config: unknown): UserConfig | null { // Spread the original object, not the parsed one, so fields this CLI // version doesn't know about are kept when the config is written back. - const { teamName, teamId, ...rest } = config as z.infer< + const { teamName, teamId, teamApiKey, ...rest } = config as z.infer< typeof v1UserConfigSchema > & { teamApiKey?: string } - // Requests use the access token; the stored API key is not carried over. - delete rest.teamApiKey return { ...rest, version: 2, projectName: teamName, projectId: teamId, + // Requests use the access token, but the key is kept so CLI versions that + // still authenticate with it stay signed in. + ...(teamApiKey ? { projectApiKey: teamApiKey } : {}), } } @@ -112,13 +116,10 @@ export function getConfigRefreshTimestamp(): string { * written as owner-readable/writable only because it contains credentials. */ export function writeUserConfig(configPath: string, config: UserConfig): void { - // Earlier CLI versions stored a long-lived project API key (`projectApiKey`, - // or `teamApiKey` in v1). Requests now use the access token, so the key is - // dropped from disk the next time the config is written (login, configure - // or a token refresh). + // Requests use the access token, but a stored `projectApiKey` is written + // back unchanged so CLI versions that still authenticate with it stay + // signed in. const persisted: Record = { ...config } - delete persisted.projectApiKey - delete persisted.teamApiKey const dir = path.dirname(configPath) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) diff --git a/packages/cli/src/utils/lock.ts b/packages/cli/src/utils/lock.ts index 9f0309f2df..08b362dcbb 100644 --- a/packages/cli/src/utils/lock.ts +++ b/packages/cli/src/utils/lock.ts @@ -42,6 +42,14 @@ export async function withFileLock( maxTimeout: RETRY_INTERVAL_MS, maxRetryTime: timeoutMs, }, + // proper-lockfile's default handler throws from a timer, which would + // take the whole command down. Losing the lock only means another e2b + // process took it over, so warn and let `fn` finish. + onCompromised: (err) => { + console.error( + `Warning: lost the lock on ${filePath} (${err.message}); another e2b process may have taken it over.` + ) + }, }) } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ELOCKED') { diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 0d75f47e60..af8b35ac7e 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -150,8 +150,8 @@ describe('ensureValidAccessToken', () => { access_token: accessToken, refresh_token: 'refresh-2', }) - // The API key earlier versions stored is dropped on write. - expect(stored.projectApiKey).toBeUndefined() + // The API key earlier versions stored is kept, so they stay signed in. + expect(stored.projectApiKey).toBe('e2b_stale_key') expect(fs.existsSync(`${configPath()}.lock`)).toBe(false) }) diff --git a/packages/cli/tests/user_config_migration.test.ts b/packages/cli/tests/user_config_migration.test.ts index d077febc4e..bcfd18e9cf 100644 --- a/packages/cli/tests/user_config_migration.test.ts +++ b/packages/cli/tests/user_config_migration.test.ts @@ -66,6 +66,7 @@ describe('getUserConfig', () => { ...v1AuthFields, projectName: 'default', projectId: 'team-id', + projectApiKey: 'team-api-key-secret', dockerProxySet: true, }) }) diff --git a/packages/cli/tests/user_config_permissions.test.ts b/packages/cli/tests/user_config_permissions.test.ts index a83e6d031c..0cf5028ae2 100644 --- a/packages/cli/tests/user_config_permissions.test.ts +++ b/packages/cli/tests/user_config_permissions.test.ts @@ -49,7 +49,7 @@ describe('writeUserConfig', () => { expect(JSON.parse(fs.readFileSync(configPath, 'utf8'))).toEqual(config) }) - it('drops the API key stored by earlier CLI versions', () => { + it('keeps the API key stored by earlier CLI versions', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2b-config-key-')) tmpDirs.push(tmpDir) const configPath = path.join(tmpDir, 'config.json') @@ -65,11 +65,9 @@ describe('writeUserConfig', () => { projectName: 'default', projectId: 'project-id', futureField: 'kept', - } as const - writeUserConfig(configPath, { - ...config, projectApiKey: 'e2b_secret', - } as UserConfig) + } as const + writeUserConfig(configPath, config as UserConfig) expect(JSON.parse(fs.readFileSync(configPath, 'utf8'))).toEqual(config) // The temporary file used for the atomic write is renamed away. From da82a361e3a1e14467eb5b927bb100a0ba9a4e1a Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:17:10 +0100 Subject: [PATCH 6/6] refactor(cli): give the stored project API key no role at all This version neither reads, migrates nor strips `projectApiKey`: it is one more field earlier versions wrote, carried through writes like any field this version doesn't know about. The changeset says what that means for a downgrade. Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-access-token-auth.md | 2 +- packages/cli/src/user.ts | 17 +++-------------- packages/cli/tests/auth.test.ts | 6 +++--- .../cli/tests/user_config_migration.test.ts | 5 +++-- .../cli/tests/user_config_permissions.test.ts | 1 - 5 files changed, 10 insertions(+), 21 deletions(-) diff --git a/.changeset/cli-access-token-auth.md b/.changeset/cli-access-token-auth.md index 385a499b58..1788093bff 100644 --- a/.changeset/cli-access-token-auth.md +++ b/.changeset/cli-access-token-auth.md @@ -7,5 +7,5 @@ The CLI now authenticates every request with your login's access token instead o - `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` is no longer used for requests. A key saved by an earlier version is kept, so CLI versions released before this one stay signed in. Logging in with this version does not store a key, so after downgrading from a fresh login, run `e2b auth login` again. +- 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. diff --git a/packages/cli/src/user.ts b/packages/cli/src/user.ts index 21a0282d93..4c768ef1a8 100644 --- a/packages/cli/src/user.ts +++ b/packages/cli/src/user.ts @@ -24,9 +24,6 @@ const userConfigSchema = z.object({ ...authFields, projectName: z.string(), projectId: z.string(), - // Stored by earlier CLI versions, which authenticate with it. Not used for - // requests any more, but kept on disk so those versions stay signed in. - projectApiKey: z.string().optional(), }) export type UserConfig = z.infer @@ -92,17 +89,14 @@ function migrateV1UserConfig(config: unknown): UserConfig | null { // Spread the original object, not the parsed one, so fields this CLI // version doesn't know about are kept when the config is written back. - const { teamName, teamId, teamApiKey, ...rest } = config as z.infer< + const { teamName, teamId, ...rest } = config as z.infer< typeof v1UserConfigSchema - > & { teamApiKey?: string } + > return { ...rest, version: 2, projectName: teamName, projectId: teamId, - // Requests use the access token, but the key is kept so CLI versions that - // still authenticate with it stay signed in. - ...(teamApiKey ? { projectApiKey: teamApiKey } : {}), } } @@ -116,11 +110,6 @@ export function getConfigRefreshTimestamp(): string { * written as owner-readable/writable only because it contains credentials. */ export function writeUserConfig(configPath: string, config: UserConfig): void { - // Requests use the access token, but a stored `projectApiKey` is written - // back unchanged so CLI versions that still authenticate with it stay - // signed in. - const persisted: Record = { ...config } - const dir = path.dirname(configPath) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) fs.chmodSync(dir, 0o700) @@ -128,7 +117,7 @@ export function writeUserConfig(configPath: string, config: UserConfig): void { // processes reading it (token refreshes happen in the background of any // command) never see a partially written file. const tmpPath = `${configPath}.${process.pid}.tmp` - fs.writeFileSync(tmpPath, JSON.stringify(persisted, null, 2), { + fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600, }) fs.chmodSync(tmpPath, 0o600) diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index af8b35ac7e..709506f0a4 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -42,7 +42,7 @@ function writeConfig(accessToken: string, refreshToken = 'refresh-1') { tokens: { access_token: accessToken, refresh_token: refreshToken }, projectName: 'default', projectId: 'config-project', - projectApiKey: 'e2b_stale_key', + futureField: 'kept', }) ) } @@ -150,8 +150,8 @@ describe('ensureValidAccessToken', () => { access_token: accessToken, refresh_token: 'refresh-2', }) - // The API key earlier versions stored is kept, so they stay signed in. - expect(stored.projectApiKey).toBe('e2b_stale_key') + // Fields this version doesn't know about survive the write. + expect(stored.futureField).toBe('kept') expect(fs.existsSync(`${configPath()}.lock`)).toBe(false) }) diff --git a/packages/cli/tests/user_config_migration.test.ts b/packages/cli/tests/user_config_migration.test.ts index bcfd18e9cf..0d42d920a7 100644 --- a/packages/cli/tests/user_config_migration.test.ts +++ b/packages/cli/tests/user_config_migration.test.ts @@ -66,12 +66,13 @@ describe('getUserConfig', () => { ...v1AuthFields, projectName: 'default', projectId: 'team-id', - projectApiKey: 'team-api-key-secret', + // Not used by this version: carried over like any unknown field. + teamApiKey: 'team-api-key-secret', dockerProxySet: true, }) }) - it('still loads a v2 config that has the API key earlier versions stored', async () => { + it('keeps fields of a v2 config that it does not use, like the API key earlier versions stored', async () => { const v2Config = { version: 2, ...v1AuthFields, diff --git a/packages/cli/tests/user_config_permissions.test.ts b/packages/cli/tests/user_config_permissions.test.ts index 0cf5028ae2..1c6eaf18dc 100644 --- a/packages/cli/tests/user_config_permissions.test.ts +++ b/packages/cli/tests/user_config_permissions.test.ts @@ -65,7 +65,6 @@ describe('writeUserConfig', () => { projectName: 'default', projectId: 'project-id', futureField: 'kept', - projectApiKey: 'e2b_secret', } as const writeUserConfig(configPath, config as UserConfig)