From 9ec2bbdbd8395edfed136c527935f50f8f836830 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:12:44 +0100 Subject: [PATCH 1/9] feat(cli): authenticate with E2B_ACCESS_TOKEN An OAuth access token in E2B_ACCESS_TOKEN authenticates like a login, after E2B_API_KEY and ignoring the stored login. The project comes from E2B_PROJECT_ID, else the token's user's personal project or their only active one. Workspace and project commands work with it; `project use` refuses and points at E2B_PROJECT_ID. The token is used as is and an expired one is rejected up front. Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-access-token-env.md | 5 + packages/cli/src/api.ts | 98 +++++++++++++++---- packages/cli/src/commands/auth/info.ts | 8 +- packages/cli/src/commands/project/index.ts | 34 +++++-- packages/cli/src/utils/token-refresh.ts | 3 +- packages/cli/src/workspaces.ts | 28 +++++- packages/cli/tests/auth.test.ts | 101 ++++++++++++++++++++ packages/cli/tests/commands/project.test.ts | 30 ++++++ 8 files changed, 274 insertions(+), 33 deletions(-) create mode 100644 .changeset/cli-access-token-env.md diff --git a/.changeset/cli-access-token-env.md b/.changeset/cli-access-token-env.md new file mode 100644 index 0000000000..f08fdd06b0 --- /dev/null +++ b/.changeset/cli-access-token-env.md @@ -0,0 +1,5 @@ +--- +'@e2b/cli': minor +--- + +`E2B_ACCESS_TOKEN` authenticates the CLI with an OAuth access token, for CI and other places without a login. `E2B_API_KEY` still takes precedence; a stored login is ignored while the token is set. The project comes from `E2B_PROJECT_ID`, else the token's user's personal project (or their only active one). Workspace and project commands work with it, `e2b project use` doesn't (set `E2B_PROJECT_ID` instead). The token is used as is: once it expires, set a new one. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index c179d7ed34..00d956811d 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -4,8 +4,12 @@ import * as e2b from 'e2b' import * as packageJSON from '../package.json' import { getUserConfig, UserConfig } from './user' import { asBold, asPrimary } from './utils/format' -import { ensureValidAccessToken, updateUserConfig } from './utils/token-refresh' -import { fetchMe, findProject, Me } from './workspaces' +import { + decodeJwtExp, + ensureValidAccessToken, + updateUserConfig, +} from './utils/token-refresh' +import { defaultProject, fetchMe, findProject, Me } from './workspaces' // Must run before any ConnectionConfig is constructed (including the // module-level one below) — configs read the integration at construction time. @@ -13,6 +17,12 @@ e2b.ConnectionConfig.setIntegration(`e2b-cli/${packageJSON.version}`) export const projectId = process.env.E2B_PROJECT_ID || process.env.E2B_TEAM_ID +/** + * An OAuth access token given instead of logging in. It is used as is (there + * is no refresh token to renew it with), and the stored login is ignored. + */ +export const envAccessToken = process.env.E2B_ACCESS_TOKEN || undefined + const authErrorBox = () => { const body = `You must be logged in to use this command. Run ${asBold( 'e2b auth login' @@ -20,7 +30,7 @@ const authErrorBox = () => { If you are seeing this message in CI/CD you may need to set the ${asBold( 'E2B_API_KEY' - )} environment variable. + )} (or ${asBold('E2B_ACCESS_TOKEN')}) environment variable. Visit ${asPrimary('https://e2b.dev/dashboard?tab=keys')} to get the API key.` return boxen.default(body, { width: 70, @@ -45,16 +55,33 @@ let me: Promise | undefined /** * The project commands run in: `E2B_PROJECT_ID`, else the one selected with - * `e2b project use`; `undefined` when signed out without `E2B_PROJECT_ID`. + * `e2b project use`. `undefined` when signed out, or with `E2B_ACCESS_TOKEN` + * (which ignores the stored login), without `E2B_PROJECT_ID`. */ export function currentProject(): string | undefined { - return projectId || getUserConfig()?.projectId + if (projectId) return projectId + return envAccessToken ? undefined : getUserConfig()?.projectId +} + +/** `E2B_ACCESS_TOKEN`, checked for expiry: it can't be refreshed. */ +function checkedEnvAccessToken(): string { + const exp = decodeJwtExp(envAccessToken!) + if (exp && Math.floor(Date.now() / 1000) >= exp) { + throw new Error( + `${asBold('E2B_ACCESS_TOKEN')} has expired. Set a current one, or unset it and run ${asBold( + 'e2b auth login' + )}.` + ) + } + return envAccessToken! } -/** The logged-in user's workspaces and projects, fetched once per process. */ +/** The user's workspaces and projects, fetched once per process. */ export function getMe(): Promise { me ??= (async () => { - const { accessToken } = await ensureValidAccessToken() + const accessToken = envAccessToken + ? checkedEnvAccessToken() + : (await ensureValidAccessToken()).accessToken const result = await fetchMe(accessToken) if (!result) { throw new Error( @@ -68,19 +95,37 @@ export function getMe(): Promise { /** * {@link getMe} for commands about workspaces and projects. workspace-api - * only accepts the login's access token, so these need a login even when - * `E2B_API_KEY` is set. + * only accepts an access token, so these need a login or `E2B_ACCESS_TOKEN` + * even when `E2B_API_KEY` is set. */ export async function requireMe(): Promise { - if (!getUserConfig()) { + if (!envAccessToken && !getUserConfig()) { console.error( `You must be logged in to see your workspaces and projects. Run ${asBold( 'e2b auth login' - )}.` + )} or set ${asBold('E2B_ACCESS_TOKEN')}.` ) process.exit(1) } - return getMe() + try { + return await getMe() + } catch (err) { + console.error(err instanceof Error ? err.message : err) + process.exit(1) + } +} + +/** + * The project requests with `E2B_ACCESS_TOKEN` go to: `cliProjectId`, then + * `E2B_PROJECT_ID`, else the default project the token's user has. + */ +async function resolveEnvProject( + cliProjectId?: string +): Promise<{ id: string; domain: string }> { + const me = await getMe() + const ref = cliProjectId || projectId + const { project } = ref ? findProject(me, ref) : defaultProject(me) + return { id: project.id, domain: project.control_plane_domain } } /** @@ -115,17 +160,34 @@ async function resolveProject( /** * 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 and requests sent - * to that project's control plane (`domain`). Call this right before each - * request (not once per command) so long-running commands keep working after - * the hour-long access token expires. + * `E2B_API_KEY` wins, for CI/CD and other non-interactive use, then + * `E2B_ACCESS_TOKEN`. Otherwise the logged-in user's OAuth access token is + * used, refreshed first when it is about to expire. With a token, `X-Team-ID` + * selects the project and requests go to that project's control plane + * (`domain`). 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 (envAccessToken) { + try { + const project = await resolveEnvProject(cliProjectId) + return { + apiHeaders: { + Authorization: `Bearer ${checkedEnvAccessToken()}`, + 'X-Team-ID': project.id, + }, + domain: project.domain, + } + } catch (err) { + console.error(err instanceof Error ? err.message : err) + process.exit(1) + } + } + if (!getUserConfig()) { console.error(authErrorBox()) process.exit(1) diff --git a/packages/cli/src/commands/auth/info.ts b/packages/cli/src/commands/auth/info.ts index 5381bf379e..1f19ab496d 100644 --- a/packages/cli/src/commands/auth/info.ts +++ b/packages/cli/src/commands/auth/info.ts @@ -1,5 +1,6 @@ import * as commander from '@commander-js/extra-typings' +import { envAccessToken } from 'src/api' import { getUserConfig } from 'src/user' import { asFormattedConfig, asFormattedError } from 'src/utils/format' @@ -13,8 +14,13 @@ export const infoCommand = new commander.Command('info') console.error(asFormattedError('Failed to read user config', err)) } + if (envAccessToken) { + console.log( + `Authenticating with E2B_ACCESS_TOKEN${userConfig ? '; the login below is not used' : ''}.` + ) + } if (!userConfig) { - console.log('Not logged in') + if (!envAccessToken) console.log('Not logged in') return } diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts index 824bae7632..6f002b8f39 100644 --- a/packages/cli/src/commands/project/index.ts +++ b/packages/cli/src/commands/project/index.ts @@ -1,6 +1,6 @@ import * as commander from '@commander-js/extra-typings' -import { currentProject, projectId, requireMe } from 'src/api' +import { currentProject, envAccessToken, projectId, requireMe } from 'src/api' import { Format, parseFormat } from 'src/options' import { asBold, asFormattedError } from 'src/utils/format' import { renderTable } from 'src/utils/table' @@ -8,6 +8,7 @@ import { updateUserConfig } from 'src/utils/token-refresh' import { allProjects, blockedWarning, + defaultProject, findProject, findWorkspace, Me, @@ -45,12 +46,22 @@ function projectStatus({ status, blocked, block_reasons }: Project) { : 'blocked' } -/** The current project, if it is one `me` lists. */ -function findCurrent(me: Me): ProjectEntry | undefined { +/** + * The project commands run in: the current one if `me` lists it; with + * `E2B_ACCESS_TOKEN` and no `E2B_PROJECT_ID`, the token's default project. + */ +function resolveCurrent(me: Me): ProjectEntry { const current = currentProject() - if (!current) return undefined + if (current) return findProject(me, current) + if (envAccessToken) return defaultProject(me) + throw new Error( + `No project selected. Run ${asBold('e2b project use ')}.` + ) +} + +function findCurrent(me: Me): ProjectEntry | undefined { try { - return findProject(me, current) + return resolveCurrent(me) } catch { return undefined } @@ -115,6 +126,13 @@ const useCommand = new commander.Command('use') .addOption(workspaceOption('the workspace to pick from or look in')) .action(async (ref, opts) => { try { + if (envAccessToken) { + fail( + `${asBold('E2B_ACCESS_TOKEN')} is set, so the stored project is not used. Choose the project with ${asBold( + 'E2B_PROJECT_ID' + )} instead.` + ) + } const me = await requireMe() const workspace = opts.workspace ? findWorkspace(me, opts.workspace) @@ -174,11 +192,7 @@ const infoCommand = new commander.Command('info') .addOption(formatOption) .action(async (opts) => { try { - const current = currentProject() - if (!current) { - fail(`No project selected. Run ${asBold('e2b project use ')}.`) - } - const { project, workspace } = findProject(await requireMe(), current) + const { project, workspace } = resolveCurrent(await requireMe()) if (opts.format === Format.JSON) { console.log( diff --git a/packages/cli/src/utils/token-refresh.ts b/packages/cli/src/utils/token-refresh.ts index b53e4a4912..91ac2f0247 100644 --- a/packages/cli/src/utils/token-refresh.ts +++ b/packages/cli/src/utils/token-refresh.ts @@ -20,7 +20,8 @@ const CONFIG_LOCK = { timeoutMs: 45_000, } -function decodeJwtExp(token: string): number | undefined { +/** The `exp` claim of a JWT, or `undefined` for anything else. */ +export function decodeJwtExp(token: string): number | undefined { const payload = token.split('.')[1] if (!payload) return undefined try { diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts index 286a8a1d2e..e6b71f1bcf 100644 --- a/packages/cli/src/workspaces.ts +++ b/packages/cli/src/workspaces.ts @@ -162,6 +162,30 @@ export function personalProject(me: Me): ProjectEntry | undefined { ) } +const NO_ACTIVE_PROJECTS = `You don't have any active projects. Create one at ${asPrimary( + 'https://e2b.dev/dashboard' +)}.` + +/** + * The project to use when none is chosen and nobody can be asked: the + * personal one, else the only active one. Otherwise the caller has to name + * one with `E2B_PROJECT_ID`. + */ +export function defaultProject(me: Me): ProjectEntry { + const personal = personalProject(me) + if (personal) return personal + const active = activeProjects(me) + if (active.length === 1) return active[0] + if (active.length === 0) throw new Error(NO_ACTIVE_PROJECTS) + throw new Error( + `Set ${asBold('E2B_PROJECT_ID')} to one of your projects: ${active + .map( + ({ project, workspace }) => `${project.public_id} (${workspace.name})` + ) + .join(', ')}.` + ) +} + /** Find a workspace by ID (UUID or `wrk_` public ID) or unambiguous name. */ export function findWorkspace(me: Me, ref: string): Workspace { const exact = me.workspaces.find((w) => w.id === ref || w.public_id === ref) @@ -283,9 +307,7 @@ export async function pickProject( throw new Error( workspace ? `Workspace ${workspace.name} has no active projects.` - : `You don't have any active projects. Create one at ${asPrimary( - 'https://e2b.dev/dashboard' - )}.` + : NO_ACTIVE_PROJECTS ) } const current = entries.find(({ project }) => project.id === selectedId) diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 0ae22393a5..ab20546016 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -123,6 +123,7 @@ beforeEach(() => { vi.resetModules() vi.unstubAllEnvs() vi.stubEnv('E2B_API_KEY', '') + vi.stubEnv('E2B_ACCESS_TOKEN', '') vi.stubEnv('E2B_PROJECT_ID', '') vi.stubEnv('E2B_TEAM_ID', '') mockHome = fs.mkdtempSync(path.join(os.tmpdir(), 'e2b-auth-')) @@ -231,6 +232,106 @@ describe('ensureAuth', () => { ) }) + describe('with E2B_ACCESS_TOKEN', () => { + const headersOf = async (ref?: string) => { + const { ensureAuth } = await import('../src/api') + return (await ensureAuth(ref)) as { + apiHeaders: Record + domain: string + } + } + const failing = () => { + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`) + }) as never) + return vi.spyOn(console, 'error').mockImplementation(() => {}) + } + + test('uses the token and E2B_PROJECT_ID, ignoring the stored login', async () => { + const envToken = jwt(3600, 'env') + writeConfig(jwt(3600, 'login')) + vi.stubEnv('E2B_ACCESS_TOKEN', envToken) + vi.stubEnv('E2B_PROJECT_ID', 'prj_other') + const fetchMock = mockWorkspaceApi() + + expect(await headersOf()).toEqual({ + apiHeaders: { + Authorization: `Bearer ${envToken}`, + 'X-Team-ID': 'other', + }, + domain: 'other.e2b.dev', + }) + // /v1/me was asked with the env token, not the login's. + const req = fetchMock.mock.calls[0][0] as Request + expect(req.headers.get('Authorization')).toBe(`Bearer ${envToken}`) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + test('falls back to the personal project without E2B_PROJECT_ID', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) + mockWorkspaceApi([project('mine'), project('second')]) + + expect((await headersOf()).apiHeaders['X-Team-ID']).toBe('mine') + }) + + test('needs E2B_PROJECT_ID without a personal project', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + workspaces: [ + { + id: 'ws', + public_id: 'wrk_ws', + name: 'Acme', + kind: 'standard', + blocked: false, + block_reasons: [], + capabilities: [], + projects: [project('a'), project('b')], + }, + ], + }) + ) + ) + const error = failing() + + await expect(headersOf()).rejects.toThrow('exit 1') + expect(String(error.mock.calls[0][0])).toContain( + 'Set E2B_PROJECT_ID to one of your projects: prj_a (Acme), prj_b (Acme)' + ) + }) + + test('loses to E2B_API_KEY', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) + vi.stubEnv('E2B_API_KEY', 'e2b_env_key') + const { ensureAuth } = await import('../src/api') + + expect(await ensureAuth()).toEqual({ apiKey: 'e2b_env_key' }) + }) + + test('rejects an expired token', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(-10)) + const fetchMock = mockWorkspaceApi() + const error = failing() + + await expect(headersOf()).rejects.toThrow('exit 1') + expect(String(error.mock.calls[0][0])).toContain( + 'E2B_ACCESS_TOKEN has expired' + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + + test('lets requireMe list workspaces without a login', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) + mockWorkspaceApi() + const { requireMe } = await import('../src/api') + + expect((await requireMe()).workspaces).toHaveLength(1) + }) + }) + test('exits with the login hint when signed out', async () => { const exit = vi.spyOn(process, 'exit').mockImplementation((( code?: number diff --git a/packages/cli/tests/commands/project.test.ts b/packages/cli/tests/commands/project.test.ts index 7f7e7904b7..47e0fbf3c7 100644 --- a/packages/cli/tests/commands/project.test.ts +++ b/packages/cli/tests/commands/project.test.ts @@ -53,6 +53,7 @@ const me: Me = { const mocks = vi.hoisted(() => ({ updateUserConfig: vi.fn(), current: undefined as string | undefined, + envToken: undefined as string | undefined, select: vi.fn(), })) @@ -64,6 +65,10 @@ vi.mock( ...(await importOriginal()), requireMe: async () => me, currentProject: () => mocks.current, + // The real value is read from the environment once, at import. + get envAccessToken() { + return mocks.envToken + }, }) ) @@ -108,6 +113,7 @@ beforeEach(() => { vi.resetModules() vi.clearAllMocks() mocks.current = 'mine' + mocks.envToken = undefined }) afterEach(() => { @@ -270,4 +276,28 @@ describe('e2b project info', () => { const { err } = await run(['project', 'info']) expect(err).toContain('No project selected') }) + + test('shows the personal project with E2B_ACCESS_TOKEN and no E2B_PROJECT_ID', async () => { + mocks.current = undefined + mocks.envToken = 'token' + const { out } = await run(['project', 'info', '-f', 'json']) + expect(JSON.parse(out)).toMatchObject({ id: 'mine' }) + }) +}) + +describe('with E2B_ACCESS_TOKEN', () => { + test('project use refuses, pointing at E2B_PROJECT_ID', async () => { + mocks.envToken = 'token' + const { err } = await run(['project', 'use', 'prj_shared']) + expect(err).toContain('Choose the project with E2B_PROJECT_ID') + expect(mocks.updateUserConfig).not.toHaveBeenCalled() + }) + + test('project list marks the personal project current', async () => { + mocks.current = undefined + mocks.envToken = 'token' + const { out } = await run(['project', 'list', '-f', 'json']) + const rows = JSON.parse(out) as { id: string; current: boolean }[] + expect(rows.find((r) => r.current)?.id).toBe('mine') + }) }) From 35598339cebd57cca5bb75f745b798406003e7d8 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:26:45 +0100 Subject: [PATCH 2/9] fix(cli): keep E2B_ACCESS_TOKEN out of the stored login, report precedence - `auth configure` refuses with the token set, like `project use`: the picker would list the token user's projects and store one in the login. - `auth info` reports the credential ensureAuth actually uses when both E2B_API_KEY and E2B_ACCESS_TOKEN are set. - A token workspace-api rejects is named as such instead of surfacing the bootstrap failure or a login hint. Co-Authored-By: Claude Fable 5.1 --- packages/cli/src/api.ts | 4 +++- packages/cli/src/commands/auth/configure.ts | 15 ++++++++++++++- packages/cli/src/commands/auth/info.ts | 16 ++++++++++++++-- packages/cli/tests/auth.test.ts | 14 ++++++++++++++ packages/cli/tests/commands/project.test.ts | 7 +++++++ 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 00d956811d..59e2120d81 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -85,7 +85,9 @@ export function getMe(): Promise { const result = await fetchMe(accessToken) if (!result) { throw new Error( - 'Your session was not accepted. Run `e2b auth login` again.' + envAccessToken + ? `${asBold('E2B_ACCESS_TOKEN')} was rejected. Set a current access token.` + : 'Your session was not accepted. Run `e2b auth login` again.' ) } return result diff --git a/packages/cli/src/commands/auth/configure.ts b/packages/cli/src/commands/auth/configure.ts index 261d5bbd2b..05fdcda290 100644 --- a/packages/cli/src/commands/auth/configure.ts +++ b/packages/cli/src/commands/auth/configure.ts @@ -1,6 +1,6 @@ import * as commander from '@commander-js/extra-typings' -import { getMe } from 'src/api' +import { envAccessToken, getMe } from 'src/api' import { getConfigRefreshTimestamp, getUserConfig } from 'src/user' import { updateUserConfig } from 'src/utils/token-refresh' import { blockedWarning, pickProject } from 'src/workspaces' @@ -11,6 +11,19 @@ export const configureCommand = new commander.Command('configure') .action(async () => { console.log('Configuring user...\n') + // The token's projects are not the login's, and the token ignores the + // stored selection anyway. + if (envAccessToken) { + console.error( + asFormattedError( + `${asBold('E2B_ACCESS_TOKEN')} is set, so the stored project is not used. Choose the project with ${asBold( + 'E2B_PROJECT_ID' + )} instead.` + ) + ) + process.exit(1) + } + // A file that exists but fails validation counts as signed out too — // getUserConfig returns null for it (and prints the deprecation message). const userConfig = getUserConfig() diff --git a/packages/cli/src/commands/auth/info.ts b/packages/cli/src/commands/auth/info.ts index 1f19ab496d..f8aef3c7fd 100644 --- a/packages/cli/src/commands/auth/info.ts +++ b/packages/cli/src/commands/auth/info.ts @@ -14,13 +14,25 @@ export const infoCommand = new commander.Command('info') console.error(asFormattedError('Failed to read user config', err)) } - if (envAccessToken) { + // The same precedence as ensureAuth: the key wins for API calls, the + // token is still what workspace-api is asked with. + if (process.env.E2B_API_KEY) { + console.log( + `Authenticating with E2B_API_KEY${ + envAccessToken + ? ', and with E2B_ACCESS_TOKEN for workspace and project commands' + : '' + }${userConfig ? '; the login below is not used' : ''}.` + ) + } else if (envAccessToken) { console.log( `Authenticating with E2B_ACCESS_TOKEN${userConfig ? '; the login below is not used' : ''}.` ) } if (!userConfig) { - if (!envAccessToken) console.log('Not logged in') + if (!envAccessToken && !process.env.E2B_API_KEY) { + console.log('Not logged in') + } return } diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index ab20546016..07198bfb8a 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -323,6 +323,20 @@ describe('ensureAuth', () => { expect(fetchMock).not.toHaveBeenCalled() }) + test('names the token when workspace-api rejects it', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 401 })) + ) + const error = failing() + + await expect(headersOf()).rejects.toThrow('exit 1') + expect(String(error.mock.calls[0][0])).toContain( + 'E2B_ACCESS_TOKEN was rejected' + ) + }) + test('lets requireMe list workspaces without a login', async () => { vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) mockWorkspaceApi() diff --git a/packages/cli/tests/commands/project.test.ts b/packages/cli/tests/commands/project.test.ts index 47e0fbf3c7..8ce9ef4038 100644 --- a/packages/cli/tests/commands/project.test.ts +++ b/packages/cli/tests/commands/project.test.ts @@ -293,6 +293,13 @@ describe('with E2B_ACCESS_TOKEN', () => { expect(mocks.updateUserConfig).not.toHaveBeenCalled() }) + test('auth configure refuses, pointing at E2B_PROJECT_ID', async () => { + mocks.envToken = 'token' + const { err } = await run(['auth', 'configure']) + expect(err).toContain('Choose the project with E2B_PROJECT_ID') + expect(mocks.updateUserConfig).not.toHaveBeenCalled() + }) + test('project list marks the personal project current', async () => { mocks.current = undefined mocks.envToken = 'token' From 4698778aaf6242a7ccad00fbd60f79b2a0937f47 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:34:51 +0100 Subject: [PATCH 3/9] fix(cli): reject E2B_ACCESS_TOKEN values that aren't OAuth tokens The variable once held the dashboard's access tokens, which some shells and CI jobs still export. A value that doesn't decode as a JWT is refused with a message to unset it, before any request, instead of outranking a working login. Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-access-token-env.md | 2 +- packages/cli/src/api.ts | 15 +++++++++++++-- packages/cli/tests/auth.test.ts | 12 ++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.changeset/cli-access-token-env.md b/.changeset/cli-access-token-env.md index f08fdd06b0..2aebab7aa3 100644 --- a/.changeset/cli-access-token-env.md +++ b/.changeset/cli-access-token-env.md @@ -2,4 +2,4 @@ '@e2b/cli': minor --- -`E2B_ACCESS_TOKEN` authenticates the CLI with an OAuth access token, for CI and other places without a login. `E2B_API_KEY` still takes precedence; a stored login is ignored while the token is set. The project comes from `E2B_PROJECT_ID`, else the token's user's personal project (or their only active one). Workspace and project commands work with it, `e2b project use` doesn't (set `E2B_PROJECT_ID` instead). The token is used as is: once it expires, set a new one. +`E2B_ACCESS_TOKEN` authenticates the CLI with an OAuth access token, for CI and other places without a login. `E2B_API_KEY` still takes precedence; a stored login is ignored while the token is set. The project comes from `E2B_PROJECT_ID`, else the token's user's personal project (or their only active one). Workspace and project commands work with it, `e2b project use` doesn't (set `E2B_PROJECT_ID` instead). The token is used as is: once it expires, set a new one. The variable once held the dashboard's access tokens, which no longer work: such a value is rejected with a message to unset it. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 59e2120d81..a40d8e5a61 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -63,10 +63,21 @@ export function currentProject(): string | undefined { return envAccessToken ? undefined : getUserConfig()?.projectId } -/** `E2B_ACCESS_TOKEN`, checked for expiry: it can't be refreshed. */ +/** + * `E2B_ACCESS_TOKEN`, checked to be an OAuth access token (a JWT: the same + * variable used to hold the dashboard's access tokens, which no longer + * work) that hasn't expired, since it can't be refreshed. + */ function checkedEnvAccessToken(): string { const exp = decodeJwtExp(envAccessToken!) - if (exp && Math.floor(Date.now() / 1000) >= exp) { + if (!exp) { + throw new Error( + `${asBold('E2B_ACCESS_TOKEN')} is not an OAuth access token. Access tokens from the dashboard are no longer supported: unset it and run ${asBold( + 'e2b auth login' + )}, or use ${asBold('E2B_API_KEY')}.` + ) + } + if (Math.floor(Date.now() / 1000) >= exp) { throw new Error( `${asBold('E2B_ACCESS_TOKEN')} has expired. Set a current one, or unset it and run ${asBold( 'e2b auth login' diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 07198bfb8a..60c27181a9 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -323,6 +323,18 @@ describe('ensureAuth', () => { expect(fetchMock).not.toHaveBeenCalled() }) + test('rejects a value that is not an OAuth token', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', 'legacy-dashboard-token') + const fetchMock = mockWorkspaceApi() + const error = failing() + + await expect(headersOf()).rejects.toThrow('exit 1') + expect(String(error.mock.calls[0][0])).toContain( + 'E2B_ACCESS_TOKEN is not an OAuth access token' + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + test('names the token when workspace-api rejects it', async () => { vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) vi.stubGlobal( From faba2b17340e382748d4c7785c1f0c8fee14843f Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:36:21 +0100 Subject: [PATCH 4/9] fix(cli): refuse E2B_API_KEY and E2B_ACCESS_TOKEN set together The two select different projects, so there is no sensible precedence: ensureAuth, requireMe and `auth info` exit with "Both E2B_API_KEY and E2B_ACCESS_TOKEN are set. Unset one of them." Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-access-token-env.md | 2 +- packages/cli/src/api.ts | 27 +++++++++++++++++++++++--- packages/cli/src/commands/auth/info.ts | 18 +++++++++-------- packages/cli/tests/auth.test.ts | 15 +++++++++++--- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/.changeset/cli-access-token-env.md b/.changeset/cli-access-token-env.md index 2aebab7aa3..0a4c236ee6 100644 --- a/.changeset/cli-access-token-env.md +++ b/.changeset/cli-access-token-env.md @@ -2,4 +2,4 @@ '@e2b/cli': minor --- -`E2B_ACCESS_TOKEN` authenticates the CLI with an OAuth access token, for CI and other places without a login. `E2B_API_KEY` still takes precedence; a stored login is ignored while the token is set. The project comes from `E2B_PROJECT_ID`, else the token's user's personal project (or their only active one). Workspace and project commands work with it, `e2b project use` doesn't (set `E2B_PROJECT_ID` instead). The token is used as is: once it expires, set a new one. The variable once held the dashboard's access tokens, which no longer work: such a value is rejected with a message to unset it. +`E2B_ACCESS_TOKEN` authenticates the CLI with an OAuth access token, for CI and other places without a login. Setting it together with `E2B_API_KEY` is refused; a stored login is ignored while the token is set. The project comes from `E2B_PROJECT_ID`, else the token's user's personal project (or their only active one). Workspace and project commands work with it, `e2b project use` doesn't (set `E2B_PROJECT_ID` instead). The token is used as is: once it expires, set a new one. The variable once held the dashboard's access tokens, which no longer work: such a value is rejected with a message to unset it. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index a40d8e5a61..8260ecf0fb 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -63,6 +63,18 @@ export function currentProject(): string | undefined { return envAccessToken ? undefined : getUserConfig()?.projectId } +/** + * `E2B_API_KEY` and `E2B_ACCESS_TOKEN` select different projects (the key's + * own, the token's user's), so both at once is a mistake, not a precedence. + */ +export const CONFLICTING_CREDENTIALS = `Both ${asBold('E2B_API_KEY')} and ${asBold( + 'E2B_ACCESS_TOKEN' +)} are set. Unset one of them.` + +export function envCredentialsConflict(): boolean { + return Boolean(process.env.E2B_API_KEY && envAccessToken) +} + /** * `E2B_ACCESS_TOKEN`, checked to be an OAuth access token (a JWT: the same * variable used to hold the dashboard's access tokens, which no longer @@ -112,6 +124,10 @@ export function getMe(): Promise { * even when `E2B_API_KEY` is set. */ export async function requireMe(): Promise { + if (envCredentialsConflict()) { + console.error(CONFLICTING_CREDENTIALS) + process.exit(1) + } if (!envAccessToken && !getUserConfig()) { console.error( `You must be logged in to see your workspaces and projects. Run ${asBold( @@ -173,15 +189,20 @@ async function resolveProject( /** * Resolve credentials for an E2B API call. * - * `E2B_API_KEY` wins, for CI/CD and other non-interactive use, then - * `E2B_ACCESS_TOKEN`. Otherwise the logged-in user's OAuth access token is - * used, refreshed first when it is about to expire. With a token, `X-Team-ID` + * `E2B_API_KEY` or `E2B_ACCESS_TOKEN`, for CI/CD and other non-interactive + * use (setting both is refused), each ignoring the stored login. Otherwise + * the logged-in user's OAuth access token is used, refreshed first when it + * is about to expire. With a token, `X-Team-ID` * selects the project and requests go to that project's control plane * (`domain`). 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 { + if (envCredentialsConflict()) { + console.error(CONFLICTING_CREDENTIALS) + process.exit(1) + } const apiKey = process.env.E2B_API_KEY if (apiKey) return { apiKey } diff --git a/packages/cli/src/commands/auth/info.ts b/packages/cli/src/commands/auth/info.ts index f8aef3c7fd..48251c3da4 100644 --- a/packages/cli/src/commands/auth/info.ts +++ b/packages/cli/src/commands/auth/info.ts @@ -1,6 +1,10 @@ import * as commander from '@commander-js/extra-typings' -import { envAccessToken } from 'src/api' +import { + CONFLICTING_CREDENTIALS, + envAccessToken, + envCredentialsConflict, +} from 'src/api' import { getUserConfig } from 'src/user' import { asFormattedConfig, asFormattedError } from 'src/utils/format' @@ -14,15 +18,13 @@ export const infoCommand = new commander.Command('info') console.error(asFormattedError('Failed to read user config', err)) } - // The same precedence as ensureAuth: the key wins for API calls, the - // token is still what workspace-api is asked with. + if (envCredentialsConflict()) { + console.error(asFormattedError(CONFLICTING_CREDENTIALS)) + process.exit(1) + } if (process.env.E2B_API_KEY) { console.log( - `Authenticating with E2B_API_KEY${ - envAccessToken - ? ', and with E2B_ACCESS_TOKEN for workspace and project commands' - : '' - }${userConfig ? '; the login below is not used' : ''}.` + `Authenticating with E2B_API_KEY${userConfig ? '; the login below is not used' : ''}.` ) } else if (envAccessToken) { console.log( diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 60c27181a9..0b499feefc 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -303,12 +303,21 @@ describe('ensureAuth', () => { ) }) - test('loses to E2B_API_KEY', async () => { + test('is refused together with E2B_API_KEY', async () => { vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) vi.stubEnv('E2B_API_KEY', 'e2b_env_key') - const { ensureAuth } = await import('../src/api') + const fetchMock = mockWorkspaceApi() + const error = failing() + const { ensureAuth, requireMe } = await import('../src/api') - expect(await ensureAuth()).toEqual({ apiKey: 'e2b_env_key' }) + await expect(ensureAuth()).rejects.toThrow('exit 1') + await expect(requireMe()).rejects.toThrow('exit 1') + for (const call of error.mock.calls) { + expect(String(call[0])).toContain( + 'Both E2B_API_KEY and E2B_ACCESS_TOKEN are set. Unset one of them.' + ) + } + expect(fetchMock).not.toHaveBeenCalled() }) test('rejects an expired token', async () => { From 84bd20b9dcd1981a4f06146448ffb51316b9420a Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:46:26 +0100 Subject: [PATCH 5/9] refactor(cli): read JWT claims with jose Replaces the hand-rolled base64url/JSON decoding of the access token's exp claim; malformed tokens decode to no expiry, as before. Co-Authored-By: Claude Fable 5.1 --- packages/cli/package.json | 1 + packages/cli/src/utils/token-refresh.ts | 8 ++------ pnpm-lock.yaml | 8 ++++++++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index c42d218e2a..5ffec5404c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -85,6 +85,7 @@ "commander": "~15.0.0", "e2b": "workspace:^", "handlebars": "^4.7.10", + "jose": "^6.2.16", "openapi-fetch": "^0.17.0", "proper-lockfile": "^4.1.2", "simple-update-notifier": "^2.0.0", diff --git a/packages/cli/src/utils/token-refresh.ts b/packages/cli/src/utils/token-refresh.ts index 91ac2f0247..a856cadbf4 100644 --- a/packages/cli/src/utils/token-refresh.ts +++ b/packages/cli/src/utils/token-refresh.ts @@ -1,4 +1,5 @@ import * as fs from 'fs' +import { decodeJwt } from 'jose' import { getUserConfig, @@ -22,13 +23,8 @@ const CONFIG_LOCK = { /** The `exp` claim of a JWT, or `undefined` for anything else. */ export function decodeJwtExp(token: string): number | undefined { - const payload = token.split('.')[1] - if (!payload) return undefined try { - const decoded = JSON.parse( - Buffer.from(payload, 'base64url').toString('utf8') - ) as { exp?: number } - return decoded.exp + return decodeJwt(token).exp } catch { return undefined } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2a894daf3..a374b97a06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: handlebars: specifier: ^4.7.10 version: 4.7.10 + jose: + specifier: ^6.2.16 + version: 6.2.16 openapi-fetch: specifier: ^0.17.0 version: 0.17.0 @@ -2541,6 +2544,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.16: + resolution: {integrity: sha512-4LSJpbuBuPdGrONCdZUy0xnxtLf8eEz+EY0SMXdHQEBU7bgUHDVblU/JAjryv3wID9/LK7/YuwFwq+AQNfMscQ==} + js-levenshtein@1.1.6: resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} engines: {node: '>=0.10.0'} @@ -5201,6 +5207,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.16: {} + js-levenshtein@1.1.6: {} js-tokens@10.0.0: {} From 22fc36b81fabdf0f452356b5063af9bf854c3ce6 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:34:53 +0100 Subject: [PATCH 6/9] feat(cli): E2B_ACCESS_TOKEN requires E2B_PROJECT_ID, which decides every request Like E2B_API_KEY decides the project on its own, the token's project is E2B_PROJECT_ID: required up front, resolved through /v1/me for its UUID and control plane, and not overridable with --project. The default- project fallback (personal, else the only active project) is gone. Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-access-token-env.md | 2 +- packages/cli/src/api.ts | 59 +++++++++++--------- packages/cli/src/commands/auth/info.ts | 11 ++-- packages/cli/src/commands/project/index.ts | 7 +-- packages/cli/src/workspaces.ts | 28 +--------- packages/cli/tests/auth.test.ts | 61 +++++++++------------ packages/cli/tests/commands/project.test.ts | 13 +---- 7 files changed, 71 insertions(+), 110 deletions(-) diff --git a/.changeset/cli-access-token-env.md b/.changeset/cli-access-token-env.md index 0a4c236ee6..f3b362c98f 100644 --- a/.changeset/cli-access-token-env.md +++ b/.changeset/cli-access-token-env.md @@ -2,4 +2,4 @@ '@e2b/cli': minor --- -`E2B_ACCESS_TOKEN` authenticates the CLI with an OAuth access token, for CI and other places without a login. Setting it together with `E2B_API_KEY` is refused; a stored login is ignored while the token is set. The project comes from `E2B_PROJECT_ID`, else the token's user's personal project (or their only active one). Workspace and project commands work with it, `e2b project use` doesn't (set `E2B_PROJECT_ID` instead). The token is used as is: once it expires, set a new one. The variable once held the dashboard's access tokens, which no longer work: such a value is rejected with a message to unset it. +`E2B_ACCESS_TOKEN` authenticates the CLI with an OAuth access token, for CI and other places without a login. It needs `E2B_PROJECT_ID`, which is the project for every request, like `E2B_API_KEY` decides the project on its own; setting both credentials is refused, and a stored login is ignored while the token is set. Workspace and project commands work with it, `e2b project use` doesn't. The token is used as is: once it expires, set a new one. The variable once held the dashboard's access tokens, which no longer work: such a value is rejected with a message to unset it. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 8260ecf0fb..fd4c4bd239 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -9,7 +9,7 @@ import { ensureValidAccessToken, updateUserConfig, } from './utils/token-refresh' -import { defaultProject, fetchMe, findProject, Me } from './workspaces' +import { fetchMe, findProject, Me } from './workspaces' // Must run before any ConnectionConfig is constructed (including the // module-level one below) — configs read the integration at construction time. @@ -64,15 +64,23 @@ export function currentProject(): string | undefined { } /** + * What is wrong with the credentials in the environment, if anything. * `E2B_API_KEY` and `E2B_ACCESS_TOKEN` select different projects (the key's - * own, the token's user's), so both at once is a mistake, not a precedence. + * own, `E2B_PROJECT_ID`), so both at once is a mistake, not a precedence; + * and the token needs the project named, like the key names its own. */ -export const CONFLICTING_CREDENTIALS = `Both ${asBold('E2B_API_KEY')} and ${asBold( - 'E2B_ACCESS_TOKEN' -)} are set. Unset one of them.` - -export function envCredentialsConflict(): boolean { - return Boolean(process.env.E2B_API_KEY && envAccessToken) +export function envCredentialsError(): string | undefined { + if (process.env.E2B_API_KEY && envAccessToken) { + return `Both ${asBold('E2B_API_KEY')} and ${asBold( + 'E2B_ACCESS_TOKEN' + )} are set. Unset one of them.` + } + if (envAccessToken && !projectId) { + return `${asBold('E2B_ACCESS_TOKEN')} needs ${asBold( + 'E2B_PROJECT_ID' + )}: set it to the project requests go to (its ID, prj_ ID, slug or name).` + } + return undefined } /** @@ -124,8 +132,9 @@ export function getMe(): Promise { * even when `E2B_API_KEY` is set. */ export async function requireMe(): Promise { - if (envCredentialsConflict()) { - console.error(CONFLICTING_CREDENTIALS) + const envError = envCredentialsError() + if (envError) { + console.error(envError) process.exit(1) } if (!envAccessToken && !getUserConfig()) { @@ -145,15 +154,13 @@ export async function requireMe(): Promise { } /** - * The project requests with `E2B_ACCESS_TOKEN` go to: `cliProjectId`, then - * `E2B_PROJECT_ID`, else the default project the token's user has. + * The project every request with `E2B_ACCESS_TOKEN` goes to: `E2B_PROJECT_ID` + * (required, checked by {@link envCredentialsError}), resolved through + * `/v1/me` for its UUID and control plane. `--project` plays no part, as + * with `E2B_API_KEY`. */ -async function resolveEnvProject( - cliProjectId?: string -): Promise<{ id: string; domain: string }> { - const me = await getMe() - const ref = cliProjectId || projectId - const { project } = ref ? findProject(me, ref) : defaultProject(me) +async function resolveEnvProject(): Promise<{ id: string; domain: string }> { + const { project } = findProject(await getMe(), projectId!) return { id: project.id, domain: project.control_plane_domain } } @@ -189,18 +196,20 @@ async function resolveProject( /** * Resolve credentials for an E2B API call. * - * `E2B_API_KEY` or `E2B_ACCESS_TOKEN`, for CI/CD and other non-interactive - * use (setting both is refused), each ignoring the stored login. Otherwise - * the logged-in user's OAuth access token is used, refreshed first when it - * is about to expire. With a token, `X-Team-ID` + * `E2B_API_KEY` or `E2B_ACCESS_TOKEN` with `E2B_PROJECT_ID`, for CI/CD and + * other non-interactive use (setting both credentials is refused), each + * ignoring the stored login and `cliProjectId`. Otherwise the logged-in + * user's OAuth access token is used, refreshed first when it is about to + * expire. With a token, `X-Team-ID` * selects the project and requests go to that project's control plane * (`domain`). 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 { - if (envCredentialsConflict()) { - console.error(CONFLICTING_CREDENTIALS) + const envError = envCredentialsError() + if (envError) { + console.error(envError) process.exit(1) } const apiKey = process.env.E2B_API_KEY @@ -208,7 +217,7 @@ export async function ensureAuth(cliProjectId?: string): Promise { if (envAccessToken) { try { - const project = await resolveEnvProject(cliProjectId) + const project = await resolveEnvProject() return { apiHeaders: { Authorization: `Bearer ${checkedEnvAccessToken()}`, diff --git a/packages/cli/src/commands/auth/info.ts b/packages/cli/src/commands/auth/info.ts index 48251c3da4..f00b0bc8ff 100644 --- a/packages/cli/src/commands/auth/info.ts +++ b/packages/cli/src/commands/auth/info.ts @@ -1,10 +1,6 @@ import * as commander from '@commander-js/extra-typings' -import { - CONFLICTING_CREDENTIALS, - envAccessToken, - envCredentialsConflict, -} from 'src/api' +import { envAccessToken, envCredentialsError } from 'src/api' import { getUserConfig } from 'src/user' import { asFormattedConfig, asFormattedError } from 'src/utils/format' @@ -18,8 +14,9 @@ export const infoCommand = new commander.Command('info') console.error(asFormattedError('Failed to read user config', err)) } - if (envCredentialsConflict()) { - console.error(asFormattedError(CONFLICTING_CREDENTIALS)) + const envError = envCredentialsError() + if (envError) { + console.error(asFormattedError(envError)) process.exit(1) } if (process.env.E2B_API_KEY) { diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts index 6f002b8f39..de4b47d497 100644 --- a/packages/cli/src/commands/project/index.ts +++ b/packages/cli/src/commands/project/index.ts @@ -8,7 +8,6 @@ import { updateUserConfig } from 'src/utils/token-refresh' import { allProjects, blockedWarning, - defaultProject, findProject, findWorkspace, Me, @@ -46,14 +45,10 @@ function projectStatus({ status, blocked, block_reasons }: Project) { : 'blocked' } -/** - * The project commands run in: the current one if `me` lists it; with - * `E2B_ACCESS_TOKEN` and no `E2B_PROJECT_ID`, the token's default project. - */ +/** The project commands run in, which `me` has to list. */ function resolveCurrent(me: Me): ProjectEntry { const current = currentProject() if (current) return findProject(me, current) - if (envAccessToken) return defaultProject(me) throw new Error( `No project selected. Run ${asBold('e2b project use ')}.` ) diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts index e6b71f1bcf..286a8a1d2e 100644 --- a/packages/cli/src/workspaces.ts +++ b/packages/cli/src/workspaces.ts @@ -162,30 +162,6 @@ export function personalProject(me: Me): ProjectEntry | undefined { ) } -const NO_ACTIVE_PROJECTS = `You don't have any active projects. Create one at ${asPrimary( - 'https://e2b.dev/dashboard' -)}.` - -/** - * The project to use when none is chosen and nobody can be asked: the - * personal one, else the only active one. Otherwise the caller has to name - * one with `E2B_PROJECT_ID`. - */ -export function defaultProject(me: Me): ProjectEntry { - const personal = personalProject(me) - if (personal) return personal - const active = activeProjects(me) - if (active.length === 1) return active[0] - if (active.length === 0) throw new Error(NO_ACTIVE_PROJECTS) - throw new Error( - `Set ${asBold('E2B_PROJECT_ID')} to one of your projects: ${active - .map( - ({ project, workspace }) => `${project.public_id} (${workspace.name})` - ) - .join(', ')}.` - ) -} - /** Find a workspace by ID (UUID or `wrk_` public ID) or unambiguous name. */ export function findWorkspace(me: Me, ref: string): Workspace { const exact = me.workspaces.find((w) => w.id === ref || w.public_id === ref) @@ -307,7 +283,9 @@ export async function pickProject( throw new Error( workspace ? `Workspace ${workspace.name} has no active projects.` - : NO_ACTIVE_PROJECTS + : `You don't have any active projects. Create one at ${asPrimary( + 'https://e2b.dev/dashboard' + )}.` ) } const current = entries.find(({ project }) => project.id === selectedId) diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 0b499feefc..308a410b4a 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -233,6 +233,8 @@ describe('ensureAuth', () => { }) describe('with E2B_ACCESS_TOKEN', () => { + beforeEach(() => vi.stubEnv('E2B_PROJECT_ID', 'prj_other')) + const headersOf = async (ref?: string) => { const { ensureAuth } = await import('../src/api') return (await ensureAuth(ref)) as { @@ -247,60 +249,47 @@ describe('ensureAuth', () => { return vi.spyOn(console, 'error').mockImplementation(() => {}) } - test('uses the token and E2B_PROJECT_ID, ignoring the stored login', async () => { + test('uses the token and E2B_PROJECT_ID, ignoring the stored login and --project', async () => { const envToken = jwt(3600, 'env') writeConfig(jwt(3600, 'login')) vi.stubEnv('E2B_ACCESS_TOKEN', envToken) - vi.stubEnv('E2B_PROJECT_ID', 'prj_other') - const fetchMock = mockWorkspaceApi() + const fetchMock = mockWorkspaceApi([ + project('config-project'), + project('other'), + project('flag'), + ]) - expect(await headersOf()).toEqual({ + const expected = { apiHeaders: { Authorization: `Bearer ${envToken}`, 'X-Team-ID': 'other', }, domain: 'other.e2b.dev', - }) + } + expect(await headersOf()).toEqual(expected) + // Like with E2B_API_KEY, the environment decides the project. + expect(await headersOf('slug-flag')).toEqual(expected) // /v1/me was asked with the env token, not the login's. const req = fetchMock.mock.calls[0][0] as Request expect(req.headers.get('Authorization')).toBe(`Bearer ${envToken}`) expect(fetchMock).toHaveBeenCalledTimes(1) }) - test('falls back to the personal project without E2B_PROJECT_ID', async () => { - vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) - mockWorkspaceApi([project('mine'), project('second')]) - - expect((await headersOf()).apiHeaders['X-Team-ID']).toBe('mine') - }) - - test('needs E2B_PROJECT_ID without a personal project', async () => { + test('needs E2B_PROJECT_ID', async () => { vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) - vi.stubGlobal( - 'fetch', - vi.fn(async () => - Response.json({ - workspaces: [ - { - id: 'ws', - public_id: 'wrk_ws', - name: 'Acme', - kind: 'standard', - blocked: false, - block_reasons: [], - capabilities: [], - projects: [project('a'), project('b')], - }, - ], - }) - ) - ) + vi.stubEnv('E2B_PROJECT_ID', '') + const fetchMock = mockWorkspaceApi() const error = failing() + const { ensureAuth, requireMe } = await import('../src/api') - await expect(headersOf()).rejects.toThrow('exit 1') - expect(String(error.mock.calls[0][0])).toContain( - 'Set E2B_PROJECT_ID to one of your projects: prj_a (Acme), prj_b (Acme)' - ) + await expect(ensureAuth()).rejects.toThrow('exit 1') + await expect(requireMe()).rejects.toThrow('exit 1') + for (const call of error.mock.calls) { + expect(String(call[0])).toContain( + 'E2B_ACCESS_TOKEN needs E2B_PROJECT_ID' + ) + } + expect(fetchMock).not.toHaveBeenCalled() }) test('is refused together with E2B_API_KEY', async () => { diff --git a/packages/cli/tests/commands/project.test.ts b/packages/cli/tests/commands/project.test.ts index 8ce9ef4038..ceac9253fd 100644 --- a/packages/cli/tests/commands/project.test.ts +++ b/packages/cli/tests/commands/project.test.ts @@ -276,13 +276,6 @@ describe('e2b project info', () => { const { err } = await run(['project', 'info']) expect(err).toContain('No project selected') }) - - test('shows the personal project with E2B_ACCESS_TOKEN and no E2B_PROJECT_ID', async () => { - mocks.current = undefined - mocks.envToken = 'token' - const { out } = await run(['project', 'info', '-f', 'json']) - expect(JSON.parse(out)).toMatchObject({ id: 'mine' }) - }) }) describe('with E2B_ACCESS_TOKEN', () => { @@ -300,11 +293,11 @@ describe('with E2B_ACCESS_TOKEN', () => { expect(mocks.updateUserConfig).not.toHaveBeenCalled() }) - test('project list marks the personal project current', async () => { - mocks.current = undefined + test('project list marks the E2B_PROJECT_ID project current', async () => { + mocks.current = 'prj_shared' mocks.envToken = 'token' const { out } = await run(['project', 'list', '-f', 'json']) const rows = JSON.parse(out) as { id: string; current: boolean }[] - expect(rows.find((r) => r.current)?.id).toBe('mine') + expect(rows.find((r) => r.current)?.id).toBe('shared') }) }) From 1c56bff5d97bfc4b5701055b3bc50f7db977716e Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:21:16 +0100 Subject: [PATCH 7/9] feat(cli): list workspaces and projects with E2B_ACCESS_TOKEN alone E2B_PROJECT_ID is still required for every request, but workspace list and project list no longer need it, so they can be used to find it. The missing E2B_PROJECT_ID error points at e2b project list, and project info asks for E2B_PROJECT_ID instead of suggesting project use, which refuses with the token. Co-Authored-By: Claude Opus 5.5 --- .changeset/cli-access-token-env.md | 2 +- packages/cli/src/api.ts | 38 +++++++++++++++------ packages/cli/src/commands/auth/info.ts | 4 +-- packages/cli/src/commands/project/index.ts | 10 +++++- packages/cli/tests/auth.test.ts | 23 ++++++++----- packages/cli/tests/commands/project.test.ts | 17 +++++++++ 6 files changed, 72 insertions(+), 22 deletions(-) diff --git a/.changeset/cli-access-token-env.md b/.changeset/cli-access-token-env.md index f3b362c98f..5526dd857c 100644 --- a/.changeset/cli-access-token-env.md +++ b/.changeset/cli-access-token-env.md @@ -2,4 +2,4 @@ '@e2b/cli': minor --- -`E2B_ACCESS_TOKEN` authenticates the CLI with an OAuth access token, for CI and other places without a login. It needs `E2B_PROJECT_ID`, which is the project for every request, like `E2B_API_KEY` decides the project on its own; setting both credentials is refused, and a stored login is ignored while the token is set. Workspace and project commands work with it, `e2b project use` doesn't. The token is used as is: once it expires, set a new one. The variable once held the dashboard's access tokens, which no longer work: such a value is rejected with a message to unset it. +`E2B_ACCESS_TOKEN` authenticates the CLI with an OAuth access token, for CI and other places without a login. It needs `E2B_PROJECT_ID`, which is the project for every request, like `E2B_API_KEY` decides the project on its own; setting both credentials is refused, and a stored login is ignored while the token is set. `e2b workspace list` and `e2b project list` work with the token alone, to find the project to set; `e2b project use` doesn't work with it. The token is used as is: once it expires, set a new one. The variable once held the dashboard's access tokens, which no longer work: such a value is rejected with a message to unset it. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index fd4c4bd239..6dc8811bbe 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -66,8 +66,7 @@ export function currentProject(): string | undefined { /** * What is wrong with the credentials in the environment, if anything. * `E2B_API_KEY` and `E2B_ACCESS_TOKEN` select different projects (the key's - * own, `E2B_PROJECT_ID`), so both at once is a mistake, not a precedence; - * and the token needs the project named, like the key names its own. + * own, `E2B_PROJECT_ID`), so both at once is a mistake, not a precedence. */ export function envCredentialsError(): string | undefined { if (process.env.E2B_API_KEY && envAccessToken) { @@ -75,14 +74,32 @@ export function envCredentialsError(): string | undefined { 'E2B_ACCESS_TOKEN' )} are set. Unset one of them.` } - if (envAccessToken && !projectId) { - return `${asBold('E2B_ACCESS_TOKEN')} needs ${asBold( - 'E2B_PROJECT_ID' - )}: set it to the project requests go to (its ID, prj_ ID, slug or name).` - } return undefined } +/** + * `E2B_ACCESS_TOKEN` needs the project named, like the key names its own, + * before any request goes to a project. Listing workspaces and projects + * doesn't, so `e2b project list` can find it. + */ +export const accessTokenNeedsProject = () => + `${asBold('E2B_ACCESS_TOKEN')} needs ${asBold( + 'E2B_PROJECT_ID' + )}: set it to the project requests go to (its ID, prj_ ID, slug or name). Run ${asBold( + 'e2b project list' + )} to see your projects.` + +/** + * {@link envCredentialsError}, plus a missing `E2B_PROJECT_ID` with + * `E2B_ACCESS_TOKEN`: for commands that need a project. + */ +export function envProjectCredentialsError(): string | undefined { + return ( + envCredentialsError() ?? + (envAccessToken && !projectId ? accessTokenNeedsProject() : undefined) + ) +} + /** * `E2B_ACCESS_TOKEN`, checked to be an OAuth access token (a JWT: the same * variable used to hold the dashboard's access tokens, which no longer @@ -129,7 +146,8 @@ export function getMe(): Promise { /** * {@link getMe} for commands about workspaces and projects. workspace-api * only accepts an access token, so these need a login or `E2B_ACCESS_TOKEN` - * even when `E2B_API_KEY` is set. + * even when `E2B_API_KEY` is set. `E2B_PROJECT_ID` is not needed with the + * token: listing projects is how to find one. */ export async function requireMe(): Promise { const envError = envCredentialsError() @@ -155,7 +173,7 @@ export async function requireMe(): Promise { /** * The project every request with `E2B_ACCESS_TOKEN` goes to: `E2B_PROJECT_ID` - * (required, checked by {@link envCredentialsError}), resolved through + * (required, checked by {@link envProjectCredentialsError}), resolved through * `/v1/me` for its UUID and control plane. `--project` plays no part, as * with `E2B_API_KEY`. */ @@ -207,7 +225,7 @@ async function resolveProject( * expires. */ export async function ensureAuth(cliProjectId?: string): Promise { - const envError = envCredentialsError() + const envError = envProjectCredentialsError() if (envError) { console.error(envError) process.exit(1) diff --git a/packages/cli/src/commands/auth/info.ts b/packages/cli/src/commands/auth/info.ts index f00b0bc8ff..4fad790c78 100644 --- a/packages/cli/src/commands/auth/info.ts +++ b/packages/cli/src/commands/auth/info.ts @@ -1,6 +1,6 @@ import * as commander from '@commander-js/extra-typings' -import { envAccessToken, envCredentialsError } from 'src/api' +import { envAccessToken, envProjectCredentialsError } from 'src/api' import { getUserConfig } from 'src/user' import { asFormattedConfig, asFormattedError } from 'src/utils/format' @@ -14,7 +14,7 @@ export const infoCommand = new commander.Command('info') console.error(asFormattedError('Failed to read user config', err)) } - const envError = envCredentialsError() + const envError = envProjectCredentialsError() if (envError) { console.error(asFormattedError(envError)) process.exit(1) diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts index de4b47d497..c6f7fc8d33 100644 --- a/packages/cli/src/commands/project/index.ts +++ b/packages/cli/src/commands/project/index.ts @@ -1,6 +1,12 @@ import * as commander from '@commander-js/extra-typings' -import { currentProject, envAccessToken, projectId, requireMe } from 'src/api' +import { + accessTokenNeedsProject, + currentProject, + envAccessToken, + projectId, + requireMe, +} from 'src/api' import { Format, parseFormat } from 'src/options' import { asBold, asFormattedError } from 'src/utils/format' import { renderTable } from 'src/utils/table' @@ -49,6 +55,8 @@ function projectStatus({ status, blocked, block_reasons }: Project) { function resolveCurrent(me: Me): ProjectEntry { const current = currentProject() if (current) return findProject(me, current) + // `e2b project use` refuses with the token, which ignores the stored login. + if (envAccessToken) throw new Error(accessTokenNeedsProject()) throw new Error( `No project selected. Run ${asBold('e2b project use ')}.` ) diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 308a410b4a..c908c0ce42 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -275,23 +275,30 @@ describe('ensureAuth', () => { expect(fetchMock).toHaveBeenCalledTimes(1) }) - test('needs E2B_PROJECT_ID', async () => { + test('needs E2B_PROJECT_ID for requests, pointing at project list', async () => { vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) vi.stubEnv('E2B_PROJECT_ID', '') const fetchMock = mockWorkspaceApi() const error = failing() - const { ensureAuth, requireMe } = await import('../src/api') + const { ensureAuth } = await import('../src/api') await expect(ensureAuth()).rejects.toThrow('exit 1') - await expect(requireMe()).rejects.toThrow('exit 1') - for (const call of error.mock.calls) { - expect(String(call[0])).toContain( - 'E2B_ACCESS_TOKEN needs E2B_PROJECT_ID' - ) - } + const message = String(error.mock.calls[0][0]) + expect(message).toContain('E2B_ACCESS_TOKEN needs E2B_PROJECT_ID') + expect(message).toContain('e2b project list') expect(fetchMock).not.toHaveBeenCalled() }) + test('lets requireMe list projects without E2B_PROJECT_ID', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) + vi.stubEnv('E2B_PROJECT_ID', '') + mockWorkspaceApi() + const { currentProject, requireMe } = await import('../src/api') + + expect((await requireMe()).workspaces).toHaveLength(1) + expect(currentProject()).toBeUndefined() + }) + test('is refused together with E2B_API_KEY', async () => { vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) vi.stubEnv('E2B_API_KEY', 'e2b_env_key') diff --git a/packages/cli/tests/commands/project.test.ts b/packages/cli/tests/commands/project.test.ts index ceac9253fd..ecc97cd61e 100644 --- a/packages/cli/tests/commands/project.test.ts +++ b/packages/cli/tests/commands/project.test.ts @@ -293,6 +293,23 @@ describe('with E2B_ACCESS_TOKEN', () => { expect(mocks.updateUserConfig).not.toHaveBeenCalled() }) + test('project list works without E2B_PROJECT_ID, marking none current', async () => { + mocks.current = undefined + mocks.envToken = 'token' + const { out } = await run(['project', 'list', '-f', 'json']) + const rows = JSON.parse(out) as { current: boolean }[] + expect(rows.length).toBeGreaterThan(0) + expect(rows.some((r) => r.current)).toBe(false) + }) + + test('project info without E2B_PROJECT_ID asks for it', async () => { + mocks.current = undefined + mocks.envToken = 'token' + const { err } = await run(['project', 'info']) + expect(err).toContain('E2B_ACCESS_TOKEN needs E2B_PROJECT_ID') + expect(err).toContain('e2b project list') + }) + test('project list marks the E2B_PROJECT_ID project current', async () => { mocks.current = 'prj_shared' mocks.envToken = 'token' From edd9ef7b89878e54c6fcb5b7b9ad6f171beab3b6 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:47:38 +0100 Subject: [PATCH 8/9] fix(cli): ignore non-JWT E2B_ACCESS_TOKEN values with a warning A legacy dashboard token in E2B_ACCESS_TOKEN no longer stops the CLI: it is treated as unset, so the stored login or E2B_API_KEY is used, and a warning says to unset it. It no longer counts as setting both credentials either. Co-Authored-By: Claude Opus 5.5 --- .changeset/cli-access-token-env.md | 2 +- packages/cli/src/api.ts | 28 +++++++++++++++----------- packages/cli/tests/auth.test.ts | 32 +++++++++++++++++++++++------- 3 files changed, 42 insertions(+), 20 deletions(-) diff --git a/.changeset/cli-access-token-env.md b/.changeset/cli-access-token-env.md index 5526dd857c..aff4793ced 100644 --- a/.changeset/cli-access-token-env.md +++ b/.changeset/cli-access-token-env.md @@ -2,4 +2,4 @@ '@e2b/cli': minor --- -`E2B_ACCESS_TOKEN` authenticates the CLI with an OAuth access token, for CI and other places without a login. It needs `E2B_PROJECT_ID`, which is the project for every request, like `E2B_API_KEY` decides the project on its own; setting both credentials is refused, and a stored login is ignored while the token is set. `e2b workspace list` and `e2b project list` work with the token alone, to find the project to set; `e2b project use` doesn't work with it. The token is used as is: once it expires, set a new one. The variable once held the dashboard's access tokens, which no longer work: such a value is rejected with a message to unset it. +`E2B_ACCESS_TOKEN` authenticates the CLI with an OAuth access token, for CI and other places without a login. It needs `E2B_PROJECT_ID`, which is the project for every request, like `E2B_API_KEY` decides the project on its own; setting both credentials is refused, and a stored login is ignored while the token is set. `e2b workspace list` and `e2b project list` work with the token alone, to find the project to set; `e2b project use` doesn't work with it. The token is used as is: once it expires, set a new one. The variable once held the dashboard's access tokens, which no longer work: such a value is ignored with a warning, as if the variable were unset. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 6dc8811bbe..baab7a3131 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -20,8 +20,20 @@ export const projectId = process.env.E2B_PROJECT_ID || process.env.E2B_TEAM_ID /** * An OAuth access token given instead of logging in. It is used as is (there * is no refresh token to renew it with), and the stored login is ignored. + * + * The variable used to hold the dashboard's access tokens, which no longer + * work and which some shells and CI jobs still export. A value that isn't an + * OAuth access token (a JWT) is ignored with a warning, as if it were unset. */ -export const envAccessToken = process.env.E2B_ACCESS_TOKEN || undefined +export const envAccessToken = (() => { + const token = process.env.E2B_ACCESS_TOKEN + if (!token) return undefined + if (decodeJwtExp(token)) return token + console.warn( + `${asBold('E2B_ACCESS_TOKEN')} is not an OAuth access token and is ignored: access tokens from the dashboard are no longer supported. Unset it to remove this warning.` + ) + return undefined +})() const authErrorBox = () => { const body = `You must be logged in to use this command. Run ${asBold( @@ -101,19 +113,11 @@ export function envProjectCredentialsError(): string | undefined { } /** - * `E2B_ACCESS_TOKEN`, checked to be an OAuth access token (a JWT: the same - * variable used to hold the dashboard's access tokens, which no longer - * work) that hasn't expired, since it can't be refreshed. + * `E2B_ACCESS_TOKEN`, checked not to have expired, since it can't be + * refreshed. */ function checkedEnvAccessToken(): string { - const exp = decodeJwtExp(envAccessToken!) - if (!exp) { - throw new Error( - `${asBold('E2B_ACCESS_TOKEN')} is not an OAuth access token. Access tokens from the dashboard are no longer supported: unset it and run ${asBold( - 'e2b auth login' - )}, or use ${asBold('E2B_API_KEY')}.` - ) - } + const exp = decodeJwtExp(envAccessToken!)! if (Math.floor(Date.now() / 1000) >= exp) { throw new Error( `${asBold('E2B_ACCESS_TOKEN')} has expired. Set a current one, or unset it and run ${asBold( diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index c908c0ce42..91841f1a4f 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -328,16 +328,34 @@ describe('ensureAuth', () => { expect(fetchMock).not.toHaveBeenCalled() }) - test('rejects a value that is not an OAuth token', async () => { + test('ignores a value that is not an OAuth token, with a warning', async () => { + const loginToken = jwt(3600, 'login') + writeConfig(loginToken) vi.stubEnv('E2B_ACCESS_TOKEN', 'legacy-dashboard-token') - const fetchMock = mockWorkspaceApi() - const error = failing() + vi.stubEnv('E2B_PROJECT_ID', '') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await expect(headersOf()).rejects.toThrow('exit 1') - expect(String(error.mock.calls[0][0])).toContain( - 'E2B_ACCESS_TOKEN is not an OAuth access token' + // The stored login is used, as if the variable were unset. + expect(await headersOf()).toEqual({ + apiHeaders: { + Authorization: `Bearer ${loginToken}`, + 'X-Team-ID': 'config-project', + }, + domain: 'config.e2b.dev', + }) + expect(warn).toHaveBeenCalledTimes(1) + expect(String(warn.mock.calls[0][0])).toContain( + 'E2B_ACCESS_TOKEN is not an OAuth access token and is ignored' ) - expect(fetchMock).not.toHaveBeenCalled() + }) + + test('ignores a value that is not an OAuth token next to E2B_API_KEY', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', 'legacy-dashboard-token') + vi.stubEnv('E2B_API_KEY', 'e2b_env_key') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Not refused as both credentials set: the key is used. + expect(await headersOf()).toEqual({ apiKey: 'e2b_env_key' }) }) test('names the token when workspace-api rejects it', async () => { From 9332a3e7d530cb155be8bda8fca2e666d13ba19d Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:16:04 +0100 Subject: [PATCH 9/9] fix(cli): auth info reports E2B_ACCESS_TOKEN without E2B_PROJECT_ID It said nothing but the missing-E2B_PROJECT_ID error and exited 1, while workspace and project listing work in that environment. It now reports the token in use and warns that requests need E2B_PROJECT_ID. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/api.ts | 17 ++++------------- packages/cli/src/commands/auth/info.ts | 11 +++++++++-- packages/cli/tests/auth.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index baab7a3131..c4179ca84a 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -101,17 +101,6 @@ export const accessTokenNeedsProject = () => 'e2b project list' )} to see your projects.` -/** - * {@link envCredentialsError}, plus a missing `E2B_PROJECT_ID` with - * `E2B_ACCESS_TOKEN`: for commands that need a project. - */ -export function envProjectCredentialsError(): string | undefined { - return ( - envCredentialsError() ?? - (envAccessToken && !projectId ? accessTokenNeedsProject() : undefined) - ) -} - /** * `E2B_ACCESS_TOKEN`, checked not to have expired, since it can't be * refreshed. @@ -177,7 +166,7 @@ export async function requireMe(): Promise { /** * The project every request with `E2B_ACCESS_TOKEN` goes to: `E2B_PROJECT_ID` - * (required, checked by {@link envProjectCredentialsError}), resolved through + * (required, checked in {@link ensureAuth}), resolved through * `/v1/me` for its UUID and control plane. `--project` plays no part, as * with `E2B_API_KEY`. */ @@ -229,7 +218,9 @@ async function resolveProject( * expires. */ export async function ensureAuth(cliProjectId?: string): Promise { - const envError = envProjectCredentialsError() + const envError = + envCredentialsError() ?? + (envAccessToken && !projectId ? accessTokenNeedsProject() : undefined) if (envError) { console.error(envError) process.exit(1) diff --git a/packages/cli/src/commands/auth/info.ts b/packages/cli/src/commands/auth/info.ts index 4fad790c78..b9bc84b57a 100644 --- a/packages/cli/src/commands/auth/info.ts +++ b/packages/cli/src/commands/auth/info.ts @@ -1,6 +1,11 @@ import * as commander from '@commander-js/extra-typings' -import { envAccessToken, envProjectCredentialsError } from 'src/api' +import { + accessTokenNeedsProject, + envAccessToken, + envCredentialsError, + projectId, +} from 'src/api' import { getUserConfig } from 'src/user' import { asFormattedConfig, asFormattedError } from 'src/utils/format' @@ -14,7 +19,7 @@ export const infoCommand = new commander.Command('info') console.error(asFormattedError('Failed to read user config', err)) } - const envError = envProjectCredentialsError() + const envError = envCredentialsError() if (envError) { console.error(asFormattedError(envError)) process.exit(1) @@ -27,6 +32,8 @@ export const infoCommand = new commander.Command('info') console.log( `Authenticating with E2B_ACCESS_TOKEN${userConfig ? '; the login below is not used' : ''}.` ) + // Reported, not fatal: listing workspaces and projects works without it. + if (!projectId) console.warn(accessTokenNeedsProject()) } if (!userConfig) { if (!envAccessToken && !process.env.E2B_API_KEY) { diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 91841f1a4f..433d9a9e64 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -372,6 +372,28 @@ describe('ensureAuth', () => { ) }) + test('auth info reports the token without E2B_PROJECT_ID, warning it is needed', async () => { + vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) + vi.stubEnv('E2B_PROJECT_ID', '') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const exit = vi.spyOn(process, 'exit').mockImplementation((( + code?: number + ) => { + throw new Error(`exit ${code}`) + }) as never) + const { program } = await import('../src/commands') + + await program.parseAsync(['auth', 'info'], { from: 'user' }) + expect(String(log.mock.calls[0][0])).toBe( + 'Authenticating with E2B_ACCESS_TOKEN.' + ) + expect(String(warn.mock.calls[0][0])).toContain( + 'E2B_ACCESS_TOKEN needs E2B_PROJECT_ID' + ) + expect(exit).not.toHaveBeenCalled() + }) + test('lets requireMe list workspaces without a login', async () => { vi.stubEnv('E2B_ACCESS_TOKEN', jwt(3600)) mockWorkspaceApi()