Handle integration credential decrypt failures (#4683)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-10 13:08:09 -07:00 committed by GitHub
parent 31f0f254eb
commit ceae167427
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 854 additions and 46 deletions

View File

@ -0,0 +1,80 @@
import { statSync } from 'fs'
import { safeStorage } from 'electron'
import {
credentialDecryptionMessage,
type IntegrationCredentialService
} from '../shared/integration-credential-errors'
// Why: connection status treats a token file as a saved credential; empty
// files read as "missing", so counting them would split-brain getStatus.
export function credentialFileHasContent(path: string): boolean {
try {
return statSync(path).size > 0
} catch {
return false
}
}
export class CredentialDecryptionError extends Error {
constructor(service: IntegrationCredentialService) {
super(credentialDecryptionMessage(service))
this.name = 'CredentialDecryptionError'
}
}
// Returns the stored token, null when the file is empty, and throws
// CredentialDecryptionError when the file holds ciphertext we cannot decrypt
// (e.g. the user denied the OS keychain prompt after an app re-sign).
export function readStoredCredentialToken(
service: IntegrationCredentialService,
raw: Buffer
): string | null {
if (raw.length === 0) {
return null
}
if (safeStorage.isEncryptionAvailable()) {
try {
return usableToken(safeStorage.decryptString(raw))
} catch {
return readPlaintextLegacyCredential(service, raw)
}
}
return readPlaintextLegacyCredential(service, raw)
}
function readPlaintextLegacyCredential(
service: IntegrationCredentialService,
raw: Buffer
): string | null {
const plaintext = decodeUtf8(raw)
// Why: legacy plaintext tokens are printable UTF-8; safeStorage ciphertext
// such as macOS v10 blobs must not be decoded into auth-header junk.
if (plaintext === null || hasControlCharacter(plaintext)) {
throw new CredentialDecryptionError(service)
}
return usableToken(plaintext)
}
function usableToken(token: string): string | null {
return token.length > 0 ? token : null
}
function decodeUtf8(raw: Buffer): string | null {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(raw)
} catch {
return null
}
}
function hasControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code < 0x20 || code === 0x7f) {
return true
}
}
return false
}

View File

@ -0,0 +1,240 @@
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import type * as Os from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const OLD_FETCH = globalThis.fetch
type SafeStorageMockOptions = {
encryptionAvailable?: boolean
decryptString?: (value: Buffer) => string
}
let tempHome = ''
let fetchMock: ReturnType<typeof vi.fn>
function mkdtempLike(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix))
}
function tokenPathForSite(siteId: string): string {
return join(tempHome, '.orca', 'jira-tokens', `${Buffer.from(siteId).toString('base64url')}.enc`)
}
function writeJiraFiles(siteId: string, token: string | Buffer): void {
const orcaDir = join(tempHome, '.orca')
mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true })
writeFileSync(
join(orcaDir, 'jira-sites.json'),
JSON.stringify(
{
version: 1,
activeSiteId: siteId,
selectedSiteId: siteId,
sites: [
{
id: siteId,
siteUrl: 'https://example.atlassian.net',
email: 'ada@example.com',
displayName: 'Ada',
accountId: 'account-alpha'
}
]
},
null,
2
),
{ encoding: 'utf-8' }
)
writeFileSync(tokenPathForSite(siteId), token)
}
async function loadClientModule(options: SafeStorageMockOptions = {}) {
vi.resetModules()
vi.doMock('electron', () => ({
safeStorage: {
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
encryptString: (value: string) => Buffer.from(value),
decryptString: options.decryptString ?? ((value: Buffer) => value.toString('utf-8'))
}
}))
vi.doMock('os', async () => {
const actual = await vi.importActual<typeof Os>('os')
return { ...actual, homedir: () => tempHome }
})
return import('./client')
}
beforeEach(() => {
tempHome = mkdtempLike('orca-jira-client-')
fetchMock = vi.fn(async () => {
throw new Error('fetch should not be called')
})
globalThis.fetch = fetchMock as typeof fetch
vi.restoreAllMocks()
})
afterEach(() => {
globalThis.fetch = OLD_FETCH
})
describe('Jira client credential storage', () => {
it('preserves plaintext fallback and reaches Jira auth header construction', async () => {
const siteId = 'site-alpha'
writeJiraFiles(siteId, 'token-alpha')
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
accountId: 'account-alpha',
displayName: 'Ada',
emailAddress: 'ada@example.com'
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
const jira = await loadClientModule({
encryptionAvailable: true,
decryptString: () => {
throw new Error('not encrypted')
}
})
await expect(jira.testConnection(siteId)).resolves.toMatchObject({
ok: true,
viewer: { displayName: 'Ada' }
})
const headers = fetchMock.mock.calls[0]?.[1]?.headers as Headers
expect(headers.get('Authorization')).toBe(
`Basic ${Buffer.from('ada@example.com:token-alpha').toString('base64')}`
)
})
it('does not pass encrypted safeStorage bytes to Jira when encryption is unavailable', async () => {
const siteId = 'site-alpha'
const tokenPath = tokenPathForSite(siteId)
writeJiraFiles(siteId, Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]))
const jira = await loadClientModule({ encryptionAvailable: false })
await expect(jira.testConnection(siteId)).resolves.toEqual({
ok: false,
error: 'Could not decrypt saved Jira credential. Approve Keychain access or reconnect Jira.'
})
expect(fetchMock).not.toHaveBeenCalled()
expect(existsSync(tokenPath)).toBe(true)
expect(jira.getStatus()).toMatchObject({
connected: true,
credentialError:
'Could not decrypt saved Jira credential. Approve Keychain access or reconnect Jira.',
sites: [{ id: siteId }]
})
})
it('does not clear the Jira token when safeStorage decryption fails', async () => {
const siteId = 'site-alpha'
const tokenPath = tokenPathForSite(siteId)
writeJiraFiles(siteId, Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]))
const jira = await loadClientModule({
encryptionAvailable: true,
decryptString: () => {
throw new Error('userCanceledErr')
}
})
await expect(jira.testConnection(siteId)).resolves.toEqual({
ok: false,
error: 'Could not decrypt saved Jira credential. Approve Keychain access or reconnect Jira.'
})
expect(fetchMock).not.toHaveBeenCalled()
expect(existsSync(tokenPath)).toBe(true)
expect(jira.getStatus()).toMatchObject({
connected: true,
credentialError:
'Could not decrypt saved Jira credential. Approve Keychain access or reconnect Jira.',
sites: [{ id: siteId }]
})
})
it('does not clear plaintext fallback credentials on Jira auth failure after decrypt failure', async () => {
const siteId = 'site-alpha'
const tokenPath = tokenPathForSite(siteId)
writeJiraFiles(siteId, 'token-revoked')
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ errorMessages: ['Jira authentication failed'] }), {
status: 401,
statusText: 'Unauthorized',
headers: { 'Content-Type': 'application/json' }
})
)
const jira = await loadClientModule({
encryptionAvailable: true,
decryptString: () => {
throw new Error('userCanceledErr')
}
})
await expect(jira.testConnection(siteId)).resolves.toEqual({
ok: false,
error: 'Jira authentication failed'
})
expect(existsSync(tokenPath)).toBe(true)
expect(jira.getStatus()).toMatchObject({
connected: true,
sites: [{ id: siteId }]
})
})
it('clears the recorded credential error after Keychain access is approved', async () => {
const siteId = 'site-alpha'
let keychainApproved = false
writeJiraFiles(siteId, Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]))
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
accountId: 'account-alpha',
displayName: 'Ada',
emailAddress: 'ada@example.com'
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
const jira = await loadClientModule({
encryptionAvailable: true,
decryptString: () => {
if (!keychainApproved) {
throw new Error('userCanceledErr')
}
return 'token-alpha'
}
})
await expect(jira.testConnection(siteId)).resolves.toMatchObject({ ok: false })
expect(jira.getStatus().credentialError).toContain('Could not decrypt')
keychainApproved = true
await expect(jira.testConnection(siteId)).resolves.toMatchObject({
ok: true,
viewer: { displayName: 'Ada' }
})
expect(jira.getStatus().credentialError).toBeUndefined()
})
it('treats empty Jira token files as missing credentials', async () => {
const siteId = 'site-alpha'
writeJiraFiles(siteId, Buffer.alloc(0))
const jira = await loadClientModule({ encryptionAvailable: false })
await expect(jira.testConnection(siteId)).resolves.toEqual({
ok: false,
error: 'Not connected to Jira.'
})
expect(fetchMock).not.toHaveBeenCalled()
expect(jira.getStatus()).toMatchObject({ connected: false })
})
})

View File

@ -6,6 +6,11 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
import { homedir } from 'os'
import { join } from 'path'
import { safeStorage } from 'electron'
import {
CredentialDecryptionError,
credentialFileHasContent,
readStoredCredentialToken
} from '../integration-credential-file'
import type {
JiraConnectArgs,
JiraConnectionStatus,
@ -63,6 +68,9 @@ export class JiraApiError extends Error {
let cachedSiteFile: JiraSiteFile | null = null
let siteFileLoaded = false
const cachedTokens = new Map<string, string>()
// Why: decrypt failures are recorded per site so getStatus can explain
// failing reads without re-touching the keychain on every status poll.
const credentialErrors = new Map<string, string>()
function getOrcaDir(): string {
return join(homedir(), '.orca')
@ -104,7 +112,7 @@ function emptySiteFile(): JiraSiteFile {
}
function hasStoredToken(siteId: string): boolean {
return cachedTokens.has(siteId) || existsSync(getTokenPath(siteId))
return cachedTokens.has(siteId) || credentialFileHasContent(getTokenPath(siteId))
}
function normalizeSite(input: unknown): JiraSite | null {
@ -206,7 +214,7 @@ function writeEncryptedToken(path: string, apiToken: string): void {
function readToken(siteId: string): string | null {
const cached = cachedTokens.get(siteId)
if (cached) {
if (cached !== undefined) {
return cached
}
const path = getTokenPath(siteId)
@ -215,12 +223,17 @@ function readToken(siteId: string): string | null {
}
try {
const raw = readFileSync(path)
const token = safeStorage.isEncryptionAvailable()
? safeStorage.decryptString(raw)
: raw.toString('utf-8')
cachedTokens.set(siteId, token)
const token = readStoredCredentialToken('Jira', raw)
if (token) {
cachedTokens.set(siteId, token)
}
credentialErrors.delete(siteId)
return token
} catch {
} catch (error) {
if (error instanceof CredentialDecryptionError) {
credentialErrors.set(siteId, error.message)
throw error
}
return null
}
}
@ -230,10 +243,12 @@ function saveToken(siteId: string, apiToken: string): void {
ensureTokenDir()
writeEncryptedToken(getTokenPath(siteId), apiToken)
cachedTokens.set(siteId, apiToken)
credentialErrors.delete(siteId)
}
function deleteToken(siteId: string): void {
cachedTokens.delete(siteId)
credentialErrors.delete(siteId)
try {
unlinkSync(getTokenPath(siteId))
} catch {
@ -373,12 +388,16 @@ export function getStatus(): JiraConnectionStatus {
const file = getSiteFile()
const sites = file.sites.filter((site) => hasStoredToken(site.id))
const activeSite = sites.find((site) => site.id === file.activeSiteId) ?? sites[0] ?? null
const credentialError = sites
.map((site) => credentialErrors.get(site.id))
.find((message) => message !== undefined)
return {
connected: sites.length > 0,
viewer: siteToViewer(activeSite),
sites,
activeSiteId: activeSite?.id ?? null,
selectedSiteId: file.selectedSiteId ?? activeSite?.id ?? null
selectedSiteId: file.selectedSiteId ?? activeSite?.id ?? null,
...(credentialError ? { credentialError } : {})
}
}
@ -461,7 +480,12 @@ export function selectSite(siteId: JiraSiteSelection): JiraConnectionStatus {
export async function testConnection(
siteId?: string
): Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> {
const client = getClients(siteId)[0]
let client: JiraClientForSite | undefined
try {
client = getClients(siteId)[0]
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' }
}
if (!client) {
return { ok: false, error: 'Not connected to Jira.' }
}

View File

@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JiraClientForSite } from './client'
import { credentialDecryptionMessage } from '../../shared/integration-credential-errors'
const { clearTokenMock, getClientsMock, jiraRequestMock } = vi.hoisted(() => ({
const { clearTokenMock, getClientsMock, isAuthErrorMock, jiraRequestMock } = vi.hoisted(() => ({
clearTokenMock: vi.fn(),
getClientsMock: vi.fn(),
isAuthErrorMock: vi.fn(),
jiraRequestMock: vi.fn()
}))
@ -12,7 +14,7 @@ vi.mock('./client', () => ({
release: vi.fn(),
clearToken: (...args: unknown[]) => clearTokenMock(...args),
getClients: (...args: unknown[]) => getClientsMock(...args),
isAuthError: vi.fn().mockReturnValue(false),
isAuthError: (...args: unknown[]) => isAuthErrorMock(...args),
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args)
}))
@ -32,9 +34,32 @@ function makeEntry(): JiraClientForSite {
describe('Jira issue operations', () => {
beforeEach(() => {
vi.clearAllMocks()
isAuthErrorMock.mockReturnValue(false)
getClientsMock.mockReturnValue([makeEntry()])
})
it('surfaces Jira credential decrypt errors on active issue, metadata, and mutation paths', async () => {
const error = new Error(credentialDecryptionMessage('Jira'))
getClientsMock.mockImplementation(() => {
throw error
})
const { createIssue, getIssue, listIssueTypes, listProjects, searchIssues } =
await import('./issues')
await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow(error.message)
await expect(getIssue('ALP-1', 'site-1')).rejects.toThrow(error.message)
await expect(listProjects('site-1')).rejects.toThrow(error.message)
await expect(listIssueTypes('10000', 'site-1')).rejects.toThrow(error.message)
await expect(
createIssue({
siteId: 'site-1',
projectId: '10000',
issueTypeId: '10001',
title: 'Fix auth'
})
).rejects.toThrow(error.message)
})
it('paginates Jira project search results before sorting them', async () => {
jiraRequestMock
.mockResolvedValueOnce({

View File

@ -16,16 +16,25 @@ let tempHome = ''
let fixtures = new Map<string, ViewerFixture>()
let linearClientMock: ReturnType<typeof vi.fn>
type SafeStorageMockOptions = {
encryptionAvailable?: boolean
decryptString?: (value: Buffer) => string
}
function writeLegacyLinearFiles(token: string, viewer: Record<string, unknown>): void {
writeLegacyLinearToken(token, viewer)
}
function writeLegacyLinearToken(token: string | Buffer, viewer: Record<string, unknown>): void {
const orcaDir = join(tempHome, '.orca')
mkdirSync(orcaDir, { recursive: true })
writeFileSync(join(orcaDir, 'linear-token.enc'), token, { encoding: 'utf-8' })
writeFileSync(join(orcaDir, 'linear-token.enc'), token)
writeFileSync(join(orcaDir, 'linear-viewer.json'), JSON.stringify(viewer), {
encoding: 'utf-8'
})
}
async function loadClientModule() {
async function loadClientModule(options: SafeStorageMockOptions = {}) {
vi.resetModules()
linearClientMock = vi.fn(function LinearClient(
this: { viewer: Promise<unknown> },
@ -47,17 +56,18 @@ async function loadClientModule() {
})
vi.doMock('electron', () => ({
safeStorage: {
isEncryptionAvailable: () => false,
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
encryptString: (value: string) => Buffer.from(value),
decryptString: (value: Buffer) => value.toString('utf-8')
decryptString: options.decryptString ?? ((value: Buffer) => value.toString('utf-8'))
}
}))
vi.doMock('os', async () => {
const actual = await vi.importActual<typeof Os>('os')
return { ...actual, homedir: () => tempHome }
})
class AuthenticationLinearError extends Error {}
vi.doMock('@linear/sdk', () => ({
AuthenticationLinearError: class AuthenticationLinearError extends Error {},
AuthenticationLinearError,
LinearClient: linearClientMock
}))
@ -167,4 +177,129 @@ describe('Linear client workspace storage', () => {
'org-alpha'
)
})
it('preserves plaintext legacy token fallback when safeStorage cannot decrypt it', async () => {
writeLegacyLinearFiles('token-alpha', {
displayName: 'Ada',
email: 'ada@example.com',
organizationName: 'Alpha'
})
const linear = await loadClientModule({
encryptionAvailable: true,
decryptString: () => {
throw new Error('not encrypted')
}
})
await expect(linear.testConnection('legacy')).resolves.toMatchObject({
ok: true,
workspace: { id: 'org-alpha', organizationName: 'Alpha' }
})
expect(linearClientMock).toHaveBeenCalledWith({ apiKey: 'token-alpha' })
})
it('does not pass encrypted safeStorage bytes to the Linear SDK when encryption is unavailable', async () => {
const tokenPath = join(tempHome, '.orca', 'linear-token.enc')
writeLegacyLinearToken(Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]), {
displayName: 'Ada',
email: 'ada@example.com',
organizationName: 'Alpha'
})
const linear = await loadClientModule({ encryptionAvailable: false })
await expect(linear.testConnection('legacy')).resolves.toEqual({
ok: false,
error:
'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.'
})
expect(linearClientMock).not.toHaveBeenCalled()
expect(existsSync(tokenPath)).toBe(true)
expect(linear.getStatus()).toMatchObject({
connected: true,
credentialError:
'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.',
workspaces: [{ id: 'legacy' }]
})
})
it('does not clear the Linear token when safeStorage decryption fails', async () => {
const tokenPath = join(tempHome, '.orca', 'linear-token.enc')
writeLegacyLinearToken(Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]), {
displayName: 'Ada',
email: 'ada@example.com',
organizationName: 'Alpha'
})
const linear = await loadClientModule({
encryptionAvailable: true,
decryptString: () => {
throw new Error('userCanceledErr')
}
})
await expect(linear.testConnection('legacy')).resolves.toEqual({
ok: false,
error:
'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.'
})
expect(linearClientMock).not.toHaveBeenCalled()
expect(existsSync(tokenPath)).toBe(true)
expect(linear.getStatus()).toMatchObject({
connected: true,
credentialError:
'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.',
workspaces: [{ id: 'legacy' }]
})
})
it('clears the recorded credential error after Keychain access is approved', async () => {
let keychainApproved = false
writeLegacyLinearToken(Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]), {
displayName: 'Ada',
email: 'ada@example.com',
organizationName: 'Alpha'
})
const linear = await loadClientModule({
encryptionAvailable: true,
decryptString: () => {
if (!keychainApproved) {
throw new Error('userCanceledErr')
}
return 'token-alpha'
}
})
await expect(linear.testConnection('legacy')).resolves.toEqual({
ok: false,
error:
'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.'
})
expect(linear.getStatus().credentialError).toContain('Could not decrypt')
keychainApproved = true
await expect(linear.testConnection('legacy')).resolves.toMatchObject({
ok: true,
workspace: { id: 'org-alpha', organizationName: 'Alpha' }
})
expect(linear.getStatus().credentialError).toBeUndefined()
})
it('treats empty Linear token files as missing credentials', async () => {
writeLegacyLinearToken(Buffer.alloc(0), {
displayName: 'Ada',
email: 'ada@example.com',
organizationName: 'Alpha'
})
const linear = await loadClientModule({ encryptionAvailable: false })
await expect(linear.testConnection('legacy')).resolves.toEqual({
ok: false,
error: 'No API key stored.'
})
expect(linearClientMock).not.toHaveBeenCalled()
expect(linear.getStatus()).toMatchObject({ connected: false })
})
})

View File

@ -6,6 +6,11 @@ import { LinearClient, AuthenticationLinearError } from '@linear/sdk'
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs'
import { homedir } from 'os'
import { join } from 'path'
import {
CredentialDecryptionError,
credentialFileHasContent,
readStoredCredentialToken
} from '../integration-credential-file'
import type {
LinearConnectionStatus,
LinearViewer,
@ -58,6 +63,9 @@ export type LinearClientForWorkspace = {
}
let cachedTokens = new Map<string, string>()
// Why: decrypt failures are recorded per workspace so getStatus can explain
// failing reads without re-touching the keychain on every status poll.
const credentialErrors = new Map<string, string>()
let cachedLegacyViewer: LinearViewer | null = null
let legacyViewerLoadedFromDisk = false
let cachedWorkspaceFile: LinearWorkspaceFile | null = null
@ -327,6 +335,7 @@ function saveWorkspaceToken(workspaceId: string, apiKey: string): void {
const tokenPath = getWorkspaceTokenPath(workspaceId)
writeEncryptedToken(tokenPath, apiKey)
cachedTokens.set(workspaceId, apiKey)
credentialErrors.delete(workspaceId)
}
// Backward-compatible export for the legacy single-workspace storage path.
@ -352,12 +361,17 @@ export function loadToken(options: { force?: boolean; workspaceId?: string } = {
}
try {
const raw = readFileSync(tokenPath)
const token = safeStorage.isEncryptionAvailable()
? safeStorage.decryptString(raw)
: raw.toString('utf-8')
cachedTokens.set(workspaceId, token)
const token = readStoredCredentialToken('Linear', raw)
if (token) {
cachedTokens.set(workspaceId, token)
}
credentialErrors.delete(workspaceId)
return token
} catch {
} catch (error) {
if (error instanceof CredentialDecryptionError) {
credentialErrors.set(workspaceId, error.message)
throw error
}
return null
}
}
@ -369,11 +383,12 @@ export function hasStoredToken(workspaceId?: string): boolean {
if (cachedTokens.has(workspaceId)) {
return true
}
return existsSync(getWorkspaceTokenPath(workspaceId))
return credentialFileHasContent(getWorkspaceTokenPath(workspaceId))
}
function clearTokenFile(workspaceId: string): void {
cachedTokens.delete(workspaceId)
credentialErrors.delete(workspaceId)
try {
unlinkSync(getWorkspaceTokenPath(workspaceId))
} catch {
@ -388,6 +403,7 @@ export function clearToken(workspaceId?: string): void {
clearTokenFile(workspace.id)
}
cachedTokens = new Map()
credentialErrors.clear()
cachedLegacyViewer = null
legacyViewerLoadedFromDisk = false
cachedWorkspaceFile = emptyWorkspaceFile()
@ -594,12 +610,17 @@ export function getStatus(): LinearConnectionStatus {
state.workspaces[0] ??
null
const credentialError = state.workspaces
.map((workspace) => credentialErrors.get(workspace.id))
.find((message) => message !== undefined)
return {
connected: state.workspaces.length > 0,
viewer: activeWorkspace,
workspaces: state.workspaces,
activeWorkspaceId: state.activeWorkspaceId,
selectedWorkspaceId: state.selectedWorkspaceId
selectedWorkspaceId: state.selectedWorkspaceId,
...(credentialError ? { credentialError } : {})
}
}
@ -612,7 +633,13 @@ export async function testConnection(
if (!resolvedWorkspaceId) {
return { ok: false, error: 'No API key stored.' }
}
const token = loadToken({ force: true, workspaceId: resolvedWorkspaceId })
let token: string | null
try {
token = loadToken({ force: true, workspaceId: resolvedWorkspaceId })
} catch (error) {
const message = error instanceof Error ? error.message : 'Test failed'
return { ok: false, error: message }
}
if (!token) {
return { ok: false, error: 'No API key stored.' }
}

View File

@ -1,15 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LinearClientForWorkspace } from './client'
import { credentialDecryptionMessage } from '../../shared/integration-credential-errors'
const rawRequest = vi.fn()
const getClients = vi.fn()
const clearToken = vi.fn()
const isAuthError = vi.fn()
vi.mock('./client', () => ({
acquire: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: vi.fn().mockReturnValue(false),
isAuthError: (...args: unknown[]) => isAuthError(...args),
clearToken: (...args: unknown[]) => clearToken(...args)
}))
@ -87,6 +89,7 @@ function datedIssues(prefix: string, count: number, startMs: number, startIndex
describe('Linear issue queries', () => {
beforeEach(() => {
vi.clearAllMocks()
isAuthError.mockReturnValue(false)
getClients.mockReturnValue([makeEntry()])
})
@ -167,6 +170,20 @@ describe('Linear issue queries', () => {
})
})
it('surfaces Linear credential decrypt errors on active issue reads and mutations', async () => {
const error = new Error(credentialDecryptionMessage('Linear'))
getClients.mockImplementation(() => {
throw error
})
const { createIssue, listIssues, searchIssues } = await import('./issues')
await expect(searchIssues('bug', 20, 'workspace-1')).rejects.toThrow(error.message)
await expect(listIssues('all', 20, 'workspace-1')).rejects.toThrow(error.message)
await expect(createIssue('team-1', 'Fix auth', undefined, 'workspace-1')).rejects.toThrow(
error.message
)
})
it('marks plain list results as having more when Linear has a next page', async () => {
rawRequest.mockResolvedValueOnce({
data: { issues: { nodes: [rawIssue('LIN-1')], pageInfo: { hasNextPage: true } } }

View File

@ -1,15 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LinearClientForWorkspace } from './client'
import { credentialDecryptionMessage } from '../../shared/integration-credential-errors'
const rawRequest = vi.fn()
const getClients = vi.fn()
const clearToken = vi.fn()
const isAuthError = vi.fn()
vi.mock('./client', () => ({
acquire: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: vi.fn().mockReturnValue(false),
isAuthError: (...args: unknown[]) => isAuthError(...args),
clearToken: (...args: unknown[]) => clearToken(...args)
}))
@ -144,9 +146,20 @@ describe('Linear project queries', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
isAuthError.mockReturnValue(false)
getClients.mockReturnValue([makeEntry()])
})
it('surfaces Linear credential decrypt errors on active project metadata reads', async () => {
const error = new Error(credentialDecryptionMessage('Linear'))
getClients.mockImplementation(() => {
throw error
})
const { listProjects } = await import('./projects')
await expect(listProjects(undefined, 20, 'workspace-1', true)).rejects.toThrow(error.message)
})
it('lets manual project issue refresh bypass older in-flight reads', async () => {
const staleRequest = deferred<ReturnType<typeof projectIssuesResponse>>()
const refreshRequest = deferred<ReturnType<typeof projectIssuesResponse>>()

View File

@ -1,14 +1,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LinearClientForWorkspace } from './client'
import { credentialDecryptionMessage } from '../../shared/integration-credential-errors'
const getClients = vi.fn()
const clearToken = vi.fn()
const isAuthError = vi.fn()
vi.mock('./client', () => ({
acquire: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: vi.fn().mockReturnValue(false),
isAuthError: (...args: unknown[]) => isAuthError(...args),
clearToken: (...args: unknown[]) => clearToken(...args)
}))
@ -65,6 +67,7 @@ function makeEntry(
describe('Linear teams', () => {
beforeEach(() => {
vi.clearAllMocks()
isAuthError.mockReturnValue(false)
})
it('fetches every page of teams for a workspace', async () => {
@ -97,4 +100,14 @@ describe('Linear teams', () => {
{ id: 'team-b', workspaceId: 'workspace-2', workspaceName: 'Beta' }
])
})
it('surfaces Linear credential decrypt errors on active team reads', async () => {
const error = new Error(credentialDecryptionMessage('Linear'))
getClients.mockImplementation(() => {
throw error
})
const { listTeams } = await import('./teams')
await expect(listTeams('workspace-1')).rejects.toThrow(error.message)
})
})

View File

@ -3735,11 +3735,12 @@ export default function TaskPage(): React.JSX.Element {
? linearCustomViewContentsLoading
: linearLoading
const activeLinearIssueError =
selectedLinearProject && linearProjectTab === 'issues'
linearStatus.credentialError ??
(selectedLinearProject && linearProjectTab === 'issues'
? linearProjectIssuesError
: selectedLinearCustomView?.model === 'issue'
? linearCustomViewContentsError
: linearError
: linearError)
const activeLinearIssueCollectionErrors =
selectedLinearProject && linearProjectTab === 'issues'
? linearProjectIssuesResult.errors
@ -8337,9 +8338,9 @@ export default function TaskPage(): React.JSX.Element {
className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek"
style={{ scrollbarGutter: 'stable' }}
>
{jiraError ? (
{(jiraStatus.credentialError ?? jiraError) ? (
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
{jiraError}
{jiraStatus.credentialError ?? jiraError}
</div>
) : null}
@ -8354,7 +8355,10 @@ export default function TaskPage(): React.JSX.Element {
</div>
) : null}
{!jiraLoading && jiraIssues.length === 0 && !jiraError ? (
{!jiraLoading &&
jiraIssues.length === 0 &&
!jiraError &&
!jiraStatus.credentialError ? (
<div className="px-4 py-10 text-center">
<p className="text-sm font-medium text-foreground">
{translate('auto.components.TaskPage.eba87f2edb', 'No Jira issues found')}

View File

@ -770,6 +770,12 @@ export function IntegrationsPane(): React.JSX.Element {
'Add Linear access to browse and link issues.'
)}
</p>
{linearStatus.credentialError ? (
<p className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="size-3.5 shrink-0" />
<span className="min-w-0">{linearStatus.credentialError}</span>
</p>
) : null}
</div>
{linearStatus.connected ? (
<div className="flex shrink-0 items-center gap-1.5">

View File

@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
import type { JiraIssue } from '../../../../shared/types'
import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors'
import { createJiraSlice } from './jira'
const jiraGetIssue = vi.fn()
const jiraListIssues = vi.fn()
const jiraSearchIssues = vi.fn()
const jiraStatus = vi.fn()
vi.mock('@/runtime/runtime-jira-client', () => ({
jiraConnect: vi.fn(),
jiraDisconnect: vi.fn(),
jiraGetIssue: (...args: unknown[]) => jiraGetIssue(...args),
jiraListIssues: (...args: unknown[]) => jiraListIssues(...args),
jiraSearchIssues: (...args: unknown[]) => jiraSearchIssues(...args),
jiraSelectSite: vi.fn(),
jiraStatus: (...args: unknown[]) => jiraStatus(...args),
jiraTestConnection: vi.fn()
}))
function createTestStore() {
return create<AppState>()(
(...a) =>
({
settings: null,
...createJiraSlice(...a)
}) as AppState
)
}
function issue(key: string): JiraIssue {
return {
id: key,
key,
title: key,
url: `https://example.atlassian.net/browse/${key}`,
siteId: 'site-1',
siteName: 'Example Jira',
project: { id: '10000', key: 'ALP', name: 'Alpha', siteId: 'site-1' },
issueType: { id: '10001', name: 'Bug' },
status: { id: '1', name: 'Todo', categoryKey: 'new', categoryName: 'To Do' },
labels: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
}
describe('createJiraSlice credential errors', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('serves fresh Jira cache without reading credentials', async () => {
const store = createTestStore()
store.setState({
jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' },
jiraSearchCache: {
'site-1::list::assigned::30': { data: [issue('ALP-1')], fetchedAt: Date.now() }
}
})
await expect(store.getState().listJiraIssues('assigned', 30)).resolves.toMatchObject([
{ key: 'ALP-1' }
])
expect(jiraListIssues).not.toHaveBeenCalled()
})
it('returns an empty list and surfaces the credential error in status on Jira decrypt errors', async () => {
const store = createTestStore()
const error = new Error(credentialDecryptionMessage('Jira'))
store.setState({
jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' }
})
jiraStatus.mockResolvedValue({
connected: true,
viewer: null,
selectedSiteId: 'site-1',
credentialError: error.message
})
jiraSearchIssues.mockRejectedValueOnce(error)
await expect(store.getState().searchJiraIssues('project = ALP', 30)).resolves.toEqual([])
await vi.waitFor(() => {
expect(store.getState().jiraStatus.credentialError).toBe(error.message)
})
})
it('returns null and refreshes status on Jira decrypt errors during detail refresh', async () => {
const store = createTestStore()
const error = new Error(credentialDecryptionMessage('Jira'))
store.setState({
jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' },
jiraIssueCache: {
'site-1::ALP-1': { data: issue('ALP-1'), fetchedAt: 1 }
}
})
jiraStatus.mockResolvedValue({
connected: true,
viewer: null,
selectedSiteId: 'site-1',
credentialError: error.message
})
jiraGetIssue.mockRejectedValueOnce(error)
await expect(store.getState().fetchJiraIssue('ALP-1', 'site-1')).resolves.toBeNull()
expect(jiraStatus).toHaveBeenCalled()
})
})

View File

@ -11,6 +11,7 @@ import type {
JiraViewer
} from '../../../../shared/types'
import type { CacheEntry } from './github'
import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors'
import {
jiraConnect,
jiraDisconnect,
@ -99,6 +100,7 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
const prev = get().jiraStatus
if (
prev.connected !== status.connected ||
prev.credentialError !== status.credentialError ||
prev.viewer?.email !== status.viewer?.email ||
getSelectedSiteId(prev) !== getSelectedSiteId(status) ||
(prev.sites?.length ?? 0) !== (status.sites?.length ?? 0)
@ -187,7 +189,9 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
})
.catch((error) => {
console.warn('[jira] fetchJiraIssue failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error)) {
void get().checkJiraConnection()
} else if (looksLikeAuthError(error)) {
set({ jiraStatus: { connected: false, viewer: null } })
}
return null
@ -222,7 +226,9 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
})
.catch((error) => {
console.warn('[jira] searchJiraIssues failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error)) {
void get().checkJiraConnection()
} else if (looksLikeAuthError(error)) {
set({ jiraStatus: { connected: false, viewer: null } })
}
return []
@ -257,7 +263,9 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set,
})
.catch((error) => {
console.warn('[jira] listJiraIssues failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error)) {
void get().checkJiraConnection()
} else if (looksLikeAuthError(error)) {
set({ jiraStatus: { connected: false, viewer: null } })
}
return []

View File

@ -12,6 +12,7 @@ import type {
LinearTeam,
LinearViewer
} from '../../../../shared/types'
import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors'
import { createLinearSlice } from './linear'
const linearStatus = vi.fn()
@ -195,6 +196,45 @@ describe('createLinearSlice caching', () => {
).resolves.toMatchObject({ items: [{ id: 'LIN-CACHED' }] })
})
it('returns an empty list and refreshes status on Linear decrypt errors during list reads', async () => {
const store = createTestStore()
const error = new Error(credentialDecryptionMessage('Linear'))
store.setState({
linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' },
linearListCache: {
'workspace-1::list::all::36': { data: { items: [issue('LIN-CACHED')] }, fetchedAt: 1 }
}
})
linearStatus.mockResolvedValue({
connected: true,
viewer: null,
credentialError: error.message
})
linearListIssues.mockRejectedValueOnce(error)
await expect(
store.getState().listLinearIssues('all', 36, { force: true })
).resolves.toMatchObject({ items: [] })
expect(linearStatus).toHaveBeenCalled()
})
it('returns an empty list and refreshes status on Linear decrypt errors during searches', async () => {
const store = createTestStore()
const error = new Error(credentialDecryptionMessage('Linear'))
store.setState({
linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' }
})
linearStatus.mockResolvedValue({
connected: true,
viewer: null,
credentialError: error.message
})
linearSearchIssues.mockRejectedValueOnce(error)
await expect(store.getState().searchLinearIssues('bug', 36)).resolves.toEqual([])
expect(linearStatus).toHaveBeenCalled()
})
it('surfaces scoped project issue failures alongside cached rows', async () => {
const store = createTestStore()
store.setState({
@ -216,6 +256,33 @@ describe('createLinearSlice caching', () => {
expect(linearListProjectIssues.mock.calls[0][4]).toEqual({ force: true })
})
it('surfaces Linear decrypt errors as workspace errors on project issue reads', async () => {
const store = createTestStore()
const error = new Error(credentialDecryptionMessage('Linear'))
store.setState({
linearProjectIssueCache: {
'workspace-1::project-issues::project-1::20': {
data: { items: [issue('LIN-CACHED')] },
fetchedAt: 1
}
}
})
linearStatus.mockResolvedValue({
connected: true,
viewer: null,
credentialError: error.message
})
linearListProjectIssues.mockRejectedValueOnce(error)
await expect(
store.getState().listLinearProjectIssues('project-1', 'workspace-1', 20, { force: true })
).resolves.toMatchObject({
items: [{ id: 'LIN-CACHED' }],
errors: [{ message: error.message }]
})
expect(linearStatus).toHaveBeenCalled()
})
it('falls back to the largest smaller cached project issue limit when expansion fails', async () => {
const store = createTestStore()
store.setState({

View File

@ -19,6 +19,7 @@ import type {
} from '../../../../shared/types'
import type { CacheEntry } from './github'
import { clampLinearIssueListLimit } from '../../../../shared/linear-issue-read-limits'
import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors'
import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata'
import {
linearConnect,
@ -198,6 +199,7 @@ function linearWorkspaceSignature(workspace: LinearWorkspace): string {
function linearStatusScopeSignature(status: LinearConnectionStatus): string {
return JSON.stringify({
connected: status.connected,
credentialError: status.credentialError ?? null,
activeWorkspaceId: status.activeWorkspaceId ?? null,
selectedWorkspaceId: getSelectedWorkspaceId(status),
viewer: status.viewer
@ -710,7 +712,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
})
.catch((error) => {
console.warn('[linear] fetchLinearIssue failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) {
void get().checkLinearConnection(true)
}
return null
@ -793,7 +795,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
})
.catch((error) => {
console.warn('[linear] searchLinearIssues failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) {
if (!shouldRefreshStatusAfterRead(workspaceId)) {
void get().checkLinearConnection(true)
}
@ -857,7 +859,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
})
.catch((error) => {
console.warn('[linear] listLinearIssues failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) {
if (!shouldRefreshStatusAfterRead(workspaceId)) {
void get().checkLinearConnection(true)
}
@ -920,7 +922,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
})
.catch((error) => {
console.warn('[linear] listLinearTeams failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) {
if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId)) {
void get().checkLinearConnection(true)
}
@ -986,7 +988,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
})
.catch((error) => {
console.warn('[linear] listLinearProjects failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error)) {
void get().checkLinearConnection(true)
} else if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
const fallback =
@ -1039,7 +1043,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
})
.catch((error) => {
console.warn('[linear] fetchLinearProject failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error)) {
void get().checkLinearConnection(true)
} else if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
if (options?.force) {
@ -1107,7 +1113,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
})
.catch((error) => {
console.warn('[linear] listLinearProjectIssues failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error)) {
void get().checkLinearConnection(true)
} else if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
const fallback =
@ -1173,7 +1181,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
})
.catch((error) => {
console.warn('[linear] listLinearCustomViews failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error)) {
void get().checkLinearConnection(true)
} else if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
const fallback =
@ -1227,7 +1237,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
})
.catch((error) => {
console.warn('[linear] fetchLinearCustomView failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error)) {
void get().checkLinearConnection(true)
} else if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
if (options?.force) {
@ -1295,7 +1307,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
})
.catch((error) => {
console.warn('[linear] listLinearCustomViewIssues failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error)) {
void get().checkLinearConnection(true)
} else if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
const fallback =
@ -1354,7 +1368,9 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
})
.catch((error) => {
console.warn('[linear] listLinearCustomViewProjects failed:', error)
if (looksLikeAuthError(error)) {
if (isIntegrationCredentialDecryptionError(error)) {
void get().checkLinearConnection(true)
} else if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
const fallback =

View File

@ -0,0 +1,15 @@
export type IntegrationCredentialService = 'Linear' | 'Jira'
export function credentialDecryptionMessage(service: IntegrationCredentialService): string {
return `Could not decrypt saved ${service} credential. Approve Keychain access or reconnect ${service}.`
}
// Why: decrypt errors cross IPC/RPC boundaries where only the message
// survives serialization, so detection matches on the canonical message.
export function isIntegrationCredentialDecryptionError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return (
message.includes(credentialDecryptionMessage('Linear')) ||
message.includes(credentialDecryptionMessage('Jira'))
)
}

View File

@ -21,6 +21,9 @@ export type JiraConnectionStatus = {
sites?: JiraSite[]
activeSiteId?: string | null
selectedSiteId?: JiraSiteSelection | null
// Set when a stored token file exists but could not be decrypted, so the
// UI can explain reads failing while the connection still looks saved.
credentialError?: string
}
export type JiraProject = {

View File

@ -1161,6 +1161,9 @@ export type LinearConnectionStatus = {
workspaces?: LinearWorkspace[]
activeWorkspaceId?: string | null
selectedWorkspaceId?: LinearWorkspaceSelection | null
// Set when a stored token file exists but could not be decrypted, so the
// UI can explain reads failing while the connection still looks saved.
credentialError?: string
}
export type LinearIssue = {