diff --git a/.changeset/cli-access-token-auth.md b/.changeset/cli-access-token-auth.md new file mode 100644 index 0000000000..1788093bff --- /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 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/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/api.ts b/packages/cli/src/api.ts index d5bee56bf2..dc6d9ec69d 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,47 @@ Visit ${asPrimary('https://e2b.dev/dashboard?tab=keys')} to get the API key.` }) } -export function ensureAPIKey() { - // If apiKey is not already set (either from env var or from user config), try to get it from config file - if (!apiKey) { - const userConfig = getUserConfig() - apiKey = userConfig?.projectApiKey - } +/** + * Credentials to spread into SDK call options, e.g. `Sandbox.list({ ...auth })`. + * Derived from ConnectionOpts so a renamed SDK option fails to compile here + * instead of being silently dropped by the spreads. + */ +export type Auth = + | Required> + | Required> + +/** + * 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 (!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 +103,5 @@ export function resolveProjectId(cliProjectId?: string): string | undefined { return undefined } -const userConfig = getUserConfig() - -export const connectionConfig = new e2b.ConnectionConfig({ - apiKey: process.env.E2B_API_KEY || userConfig?.projectApiKey, -}) - -// `e2b auth login` runs before any API key exists, and this client is built at -// import time, so don't require an API key here. -export const client = new e2b.ApiClient(connectionConfig, { - requireApiKey: false, -}) +// Carries no credentials: used for the API domain and request signals only. +export const connectionConfig = new e2b.ConnectionConfig() 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..a411d1f367 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,21 @@ async function connectSandbox({ timeoutMs?: number terminal?: TerminalOpts }) { + // Resolve credentials on every call rather than reusing the ones the + // sandbox was created with: a session can outlive the hour-long access token. + const setSandboxTimeout = async (ms: number) => + e2b.Sandbox.setTimeout(sandbox.sandboxId, ms, await ensureAuth()) + // 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) + // 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( @@ -174,7 +184,7 @@ async function connectSandbox({ } finally { clearInterval(intervalId) await pendingKeepAlive.catch(() => {}) - await sandbox.setTimeout(timeoutMs ?? 1_000) + await setSandboxTimeout(timeoutMs ?? 1_000) console.log( `Closing terminal connection to template ${asFormattedSandboxTemplate( template 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..4e1e3196f8 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' @@ -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 apiKey = ensureAPIKey() - const info = await Sandbox.getInfo(sandboxID, { - apiKey, - }) + const info = await Sandbox.getInfo(sandboxID, auth) return info.state === 'running' } catch (err) { console.error(`Failed to check sandbox status: ${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..4c768ef1a8 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,7 +89,7 @@ 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 > return { @@ -100,7 +97,6 @@ function migrateV1UserConfig(config: unknown): UserConfig | null { version: 2, projectName: teamName, projectId: teamId, - projectApiKey: teamApiKey, } } @@ -117,6 +113,13 @@ export function writeUserConfig(configPath: string, config: UserConfig): void { 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(config, null, 2), { + mode: 0o600, + }) + fs.chmodSync(tmpPath, 0o600) + fs.renameSync(tmpPath, configPath) } diff --git a/packages/cli/src/utils/lock.ts b/packages/cli/src/utils/lock.ts new file mode 100644 index 0000000000..08b362dcbb --- /dev/null +++ b/packages/cli/src/utils/lock.ts @@ -0,0 +1,68 @@ +import * as fs from 'fs' +import * as path from 'path' +import { lock } from 'proper-lockfile' + +export type FileLockOptions = { + /** + * 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 RETRY_INTERVAL_MS = 100 + +/** + * 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 `${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( + filePath: string, + fn: () => T | Promise, + { staleMs, timeoutMs }: FileLockOptions +): Promise { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }) + + 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, + }, + // 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') { + throw new Error( + `Timed out waiting for another e2b process to release ${filePath}.lock. If none is running, delete it and try again.` + ) + } + throw err + } + + try { + return await fn() + } finally { + await release() + } +} diff --git a/packages/cli/src/utils/token-refresh.ts b/packages/cli/src/utils/token-refresh.ts index 31a3fed075..bb8e6314a8 100644 --- a/packages/cli/src/utils/token-refresh.ts +++ b/packages/cli/src/utils/token-refresh.ts @@ -1,9 +1,25 @@ import * as fs from 'fs' -import { getUserConfig, writeUserConfig, USER_CONFIG_PATH } from 'src/user' +import { + getUserConfig, + writeUserConfig, + USER_CONFIG_PATH, + UserConfig, +} from 'src/user' +import { withFileLock } from './lock' const REFRESH_SKEW_SECONDS = 60 +const REFRESH_TIMEOUT_MS = 15_000 + +/** Serializes writes to the user config across e2b processes. */ +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 @@ -46,6 +62,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,40 +92,64 @@ async function refreshOAuthToken( } } -export async function ensureValidAccessToken(): Promise { - const config = getUserConfig() - if (!config) { +/** + * 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(config.tokens.access_token)) { - return config.tokens.access_token + if (!isTokenExpired(current.tokens.access_token)) { + return { accessToken: current.tokens.access_token, config: current } } - try { - const refreshed = await refreshOAuthToken( - config.tokens.refresh_token, - config.oauth.client_id, - config.oauth.token_endpoint - ) + return withFileLock( + USER_CONFIG_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 } + } - // 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 - } 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 - } + 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/auth.test.ts b/packages/cli/tests/auth.test.ts new file mode 100644 index 0000000000..709506f0a4 --- /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', + futureField: 'kept', + }) + ) +} + +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', + }) + // Fields this version doesn't know about survive the write. + expect(stored.futureField).toBe('kept') + 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.mkdirSync(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..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('returns a valid v2 config as-is', 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, @@ -95,9 +96,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..1c6eaf18dc 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,28 @@ describe('writeUserConfig', () => { } expect(JSON.parse(fs.readFileSync(configPath, 'utf8'))).toEqual(config) }) + + 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') + 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 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']) + }) }) diff --git a/packages/cli/tests/utils/lock.test.ts b/packages/cli/tests/utils/lock.test.ts new file mode 100644 index 0000000000..fc1904d7f4 --- /dev/null +++ b/packages/cli/tests/utils/lock.test.ts @@ -0,0 +1,79 @@ +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 filePath: string +let lockPath: string + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2b-lock-')) + filePath = path.join(dir, 'nested', 'config.json') + lockPath = `${filePath}.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( + filePath, + 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( + filePath, + () => { + 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(lockPath, { recursive: true }) + const old = new Date(Date.now() - 60_000) + fs.utimesSync(lockPath, old, old) + + 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(lockPath, { recursive: true }) + + await expect( + withFileLock(filePath, () => '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) + }) +}) 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: