From 0c445279e22d0b378b8c8eceb1407fb4dabfb13b Mon Sep 17 00:00:00 2001 From: Daniel Shapiro Date: Wed, 22 Jul 2026 10:19:17 -0400 Subject: [PATCH 1/2] fix(cli): stop sanity logout from sending env tokens to the session endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An env-sourced SANITY_AUTH_TOKEN (often a robot token from `sanity new`'s .env) is not a login session: logging out cannot end it, and the session endpoint rejects robot tokens with a raw internal error. Logout now names the variable and instructs removal, targets only the stored login session, and surfaces API failures by status alone — response bodies can name internal services. Co-Authored-By: Claude Fable 5 --- .../cli/src/commands/__tests__/logout.test.ts | 67 +++++++++++++++---- packages/@sanity/cli/src/commands/logout.ts | 31 +++++++-- 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/packages/@sanity/cli/src/commands/__tests__/logout.test.ts b/packages/@sanity/cli/src/commands/__tests__/logout.test.ts index 0295e0d857..f505f8265d 100644 --- a/packages/@sanity/cli/src/commands/__tests__/logout.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/logout.test.ts @@ -1,7 +1,7 @@ -import {getCliToken, setCliUserConfig} from '@sanity/cli-core' +import {getCliUserConfig, setCliUserConfig} from '@sanity/cli-core' import {mockApi, testCommand} from '@sanity/cli-test' import {cleanAll, pendingMocks} from 'nock' -import {afterEach, describe, expect, test, vi} from 'vitest' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {AUTH_API_VERSION} from '../../services/auth.js' import {LogoutCommand} from '../logout.js' @@ -12,7 +12,7 @@ vi.mock('@sanity/cli-core', async () => { const actual = await vi.importActual('@sanity/cli-core') return { ...actual, - getCliToken: vi.fn(), + getCliUserConfig: vi.fn(), getUserConfig: vi.fn().mockReturnValue({ delete: mockConfigStoreDelete, }), @@ -20,10 +20,18 @@ vi.mock('@sanity/cli-core', async () => { } }) -const mockedGetCliToken = vi.mocked(getCliToken) +const mockedGetCliUserConfig = vi.mocked(getCliUserConfig) const mockedSetConfig = vi.mocked(setCliUserConfig) +beforeEach(() => { + // The command reads SANITY_AUTH_TOKEN directly — a token inherited from the developer's shell + // (e.g. running the suite from a minted directory) must not leak into the no-env-token cases. + // An empty stub reads as absent; env-token tests stub their own value over it. + vi.stubEnv('SANITY_AUTH_TOKEN', '') +}) + afterEach(() => { + vi.unstubAllEnvs() vi.clearAllMocks() const pending = pendingMocks() cleanAll() @@ -31,8 +39,8 @@ afterEach(() => { }) describe('#logout', () => { - test('logs out successfully if a token exists', async () => { - mockedGetCliToken.mockResolvedValueOnce('test-token') + test('logs out successfully if a stored session exists', async () => { + mockedGetCliUserConfig.mockReturnValueOnce('test-token') mockApi({ apiVersion: AUTH_API_VERSION, @@ -48,7 +56,7 @@ describe('#logout', () => { }) test('logs out successfully when session is expired (401)', async () => { - mockedGetCliToken.mockResolvedValueOnce('test-token') + mockedGetCliUserConfig.mockReturnValueOnce('test-token') mockApi({ apiVersion: AUTH_API_VERSION, @@ -66,8 +74,8 @@ describe('#logout', () => { expect(mockConfigStoreDelete).toHaveBeenCalledWith('telemetryConsent') }) - test('shows an error if no token exists', async () => { - mockedGetCliToken.mockResolvedValueOnce('') + test('shows an error if no credentials exist', async () => { + mockedGetCliUserConfig.mockReturnValueOnce(undefined) const {stdout} = await testCommand(LogoutCommand) @@ -76,19 +84,52 @@ describe('#logout', () => { expect(mockConfigStoreDelete).not.toHaveBeenCalled() }) - test('throws error on API failure (non-401)', async () => { - mockedGetCliToken.mockResolvedValueOnce('test-token') + test('env token only: explains it cannot be logged out, calls no API', async () => { + vi.stubEnv('SANITY_AUTH_TOKEN', 'sk-robot-token') + mockedGetCliUserConfig.mockReturnValueOnce(undefined) + + const {error, stderr, stdout} = await testCommand(LogoutCommand) + + expect(error).toBeUndefined() + expect(stderr).toContain('SANITY_AUTH_TOKEN is set in the environment') + // oclif wraps warnings, so assert a fragment that fits on one wrapped line. + expect(stderr).toContain('Remove that variable') + expect(stdout).not.toContain('No login credentials found') + expect(mockedSetConfig).not.toHaveBeenCalled() + expect(mockConfigStoreDelete).not.toHaveBeenCalled() + }) + + test('env token plus stored session: warns about the env token and ends the session', async () => { + vi.stubEnv('SANITY_AUTH_TOKEN', 'sk-robot-token') + mockedGetCliUserConfig.mockReturnValueOnce('session-token') + + mockApi({ + apiVersion: AUTH_API_VERSION, + method: 'post', + uri: '/auth/logout', + }).reply(200) + + const {stderr, stdout} = await testCommand(LogoutCommand) + + expect(stderr).toContain('SANITY_AUTH_TOKEN is set in the environment') + expect(stdout).toContain('Logged out successfully') + expect(mockedSetConfig).toHaveBeenCalledWith('authToken', undefined) + }) + + test('surfaces only the status on API failure, never the response body', async () => { + mockedGetCliUserConfig.mockReturnValueOnce('test-token') mockApi({ apiVersion: AUTH_API_VERSION, method: 'post', uri: '/auth/logout', - }).reply(500, {message: 'Internal Server Error'}) + }).reply(500, {message: 'Populus error: something internal'}) const {error} = await testCommand(LogoutCommand) expect(error).toBeDefined() - expect(error?.message).toContain('Failed to logout') + expect(error?.message).toContain('Failed to logout (HTTP 500)') + expect(error?.message).not.toContain('Populus') expect(mockedSetConfig).not.toHaveBeenCalled() expect(mockConfigStoreDelete).not.toHaveBeenCalled() }) diff --git a/packages/@sanity/cli/src/commands/logout.ts b/packages/@sanity/cli/src/commands/logout.ts index 20b5374f9e..80bc26e8f5 100644 --- a/packages/@sanity/cli/src/commands/logout.ts +++ b/packages/@sanity/cli/src/commands/logout.ts @@ -1,6 +1,6 @@ import { exitCodes, - getCliToken, + getCliUserConfig, getUserConfig, SanityCommand, setCliUserConfig, @@ -15,14 +15,27 @@ export class LogoutCommand extends SanityCommand { public async run(): Promise { await this.parse(LogoutCommand) - const previousToken = await getCliToken() - if (!previousToken) { - this.log('No login credentials found') + // An env-sourced token (SANITY_AUTH_TOKEN, often loaded from ./.env) is not a login + // session: there is nothing server-side to end, and clearing local config would not stop + // the next command from picking the variable up again. Robot tokens from `sanity new` make + // the session endpoint reject outright — so say what to do instead of calling it. + const envToken = process.env.SANITY_AUTH_TOKEN?.trim() + if (envToken) { + this.warn( + 'SANITY_AUTH_TOKEN is set in the environment (often via ./.env) — logging out cannot end it. Remove that variable to stop acting as its identity.', + ) + } + + // Target the stored login session directly: `getCliToken()` prefers the env token, which is + // exactly the credential `sanity logout` must not send to the session endpoint. + const sessionToken = getCliUserConfig('authToken') + if (!sessionToken) { + if (!envToken) this.log('No login credentials found') return } try { - await logout() + await logout(sessionToken) this.clearConfig() } catch (error) { @@ -33,6 +46,14 @@ export class LogoutCommand extends SanityCommand { this.clearConfig() return } + // API failure bodies can name internal services — surface only the status, keep the + // local credentials so a retry is possible. + if (isHttpError(error)) { + this.error( + `Failed to logout (HTTP ${error.response.statusCode}). Your local session was kept — try again shortly.`, + {exit: exitCodes.RUNTIME_ERROR}, + ) + } const err = error instanceof Error ? error : new Error(`${error}`) this.error(`Failed to logout: ${err.message}`, {exit: exitCodes.RUNTIME_ERROR}) } From a0bad1e3502b80f8f239a461df7b1c1bb40d34f7 Mon Sep 17 00:00:00 2001 From: "squiggler-app[bot]" <265501495+squiggler-app[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:56:36 +0000 Subject: [PATCH 2/2] chore: update auto-generated changeset for PR #1584 --- .changeset/pr-1584.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/pr-1584.md diff --git a/.changeset/pr-1584.md b/.changeset/pr-1584.md new file mode 100644 index 0000000000..afb4f8dbe8 --- /dev/null +++ b/.changeset/pr-1584.md @@ -0,0 +1,6 @@ + +--- +'@sanity/cli': patch +--- + +fix(cli): stop sanity logout from sending env tokens to the session endpoint \ No newline at end of file