WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)

Fix #17: detect locked keychain and preserve credentials on access failure #3

merged opened by markbennett.ca targeting main from fix/keychain-locked-detection
  • Export KeychainAccessError from session.ts; thrown when getPassword() fails (platform error like locked keychain), not when an entry is missing (undefined)
  • resumeSession() now rethrows KeychainAccessError without clearing metadata, so temporarily locked keychains no longer wipe stored credentials
  • Add ensureAuthenticated(client) to auth-helpers.ts: on KeychainAccessError, attempts to unlock the keychain via security unlock-keychain (macOS only), retries once, then falls back to a clear error message with manual instructions
  • Replace 7 repeated inline auth-check blocks in issue.ts with ensureAuthenticated()
  • Add/update tests for KeychainAccessError propagation and ensureAuthenticated behavior

Co-Authored-By: Claude Sonnet 4.5 noreply@anthropic.com

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:b2mcbcamkwyznc5fkplwlxbf/sh.tangled.repo.pull/3mekb442mr322
+295 -75
Diff #3
+3 -2
.claude/settings.json
··· 7 7 "Bash(npm run typecheck:*)", 8 8 "Bash(npm run lint:*)", 9 9 "Bash(npm run lint:fix:*)", 10 - "Bash(git checkout:*)", 11 - "Bash(npm run format:*)" 10 + "Bash(npm run format:*)", 11 + "Bash(git fetch:*)", 12 + "Bash(git checkout:*)" 12 13 ] 13 14 } 14 15 }
+5 -1
.tangled/workflows/ci.yml
··· 9 9 engine: nixery 10 10 11 11 # Clone repository to get all source files 12 - clone: true 12 + # using the default values 13 + clone: 14 + skip: false 15 + depth: 1 16 + submodules: false 13 17 14 18 dependencies: 15 19 nixpkgs:
+8 -29
src/commands/issue.ts
··· 16 16 } from '../lib/issues-api.js'; 17 17 import type { IssueData } from '../lib/issues-api.js'; 18 18 import { buildRepoAtUri } from '../utils/at-uri.js'; 19 - import { requireAuth } from '../utils/auth-helpers.js'; 19 + import { ensureAuthenticated, requireAuth } from '../utils/auth-helpers.js'; 20 20 import { readBodyInput } from '../utils/body-input.js'; 21 21 import { formatDate, formatIssueState, outputJson } from '../utils/formatting.js'; 22 22 import { validateIssueBody, validateIssueTitle } from '../utils/validation.js'; ··· 112 112 try { 113 113 // 1. Validate auth 114 114 const client = createApiClient(); 115 - if (!(await client.resumeSession())) { 116 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 117 - process.exit(1); 118 - } 115 + await ensureAuthenticated(client); 119 116 120 117 // 2. Get repo context 121 118 const context = await getCurrentRepoContext(); ··· 188 185 189 186 // 2. Validate auth 190 187 const client = createApiClient(); 191 - if (!(await client.resumeSession())) { 192 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 193 - process.exit(1); 194 - } 188 + await ensureAuthenticated(client); 195 189 196 190 // 3. Get repo context 197 191 const context = await getCurrentRepoContext(); ··· 271 265 try { 272 266 // 1. Validate auth 273 267 const client = createApiClient(); 274 - if (!(await client.resumeSession())) { 275 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 276 - process.exit(1); 277 - } 268 + await ensureAuthenticated(client); 278 269 279 270 // 2. Get repo context 280 271 const context = await getCurrentRepoContext(); ··· 331 322 try { 332 323 // 1. Validate auth 333 324 const client = createApiClient(); 334 - if (!(await client.resumeSession())) { 335 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 336 - process.exit(1); 337 - } 325 + await ensureAuthenticated(client); 338 326 339 327 // 2. Get repo context 340 328 const context = await getCurrentRepoContext(); ··· 391 379 .action(async (issueId: string, options: { force?: boolean; json?: string | true }) => { 392 380 // 1. Validate auth 393 381 const client = createApiClient(); 394 - if (!(await client.resumeSession())) { 395 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 396 - process.exit(1); 397 - } 382 + await ensureAuthenticated(client); 398 383 399 384 // 2. Get repo context 400 385 const context = await getCurrentRepoContext(); ··· 487 472 try { 488 473 // 1. Validate auth 489 474 const client = createApiClient(); 490 - if (!(await client.resumeSession())) { 491 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 492 - process.exit(1); 493 - } 475 + await ensureAuthenticated(client); 494 476 495 477 // 2. Get repo context 496 478 const context = await getCurrentRepoContext(); ··· 574 556 try { 575 557 // 1. Validate auth 576 558 const client = createApiClient(); 577 - if (!(await client.resumeSession())) { 578 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 579 - process.exit(1); 580 - } 559 + await ensureAuthenticated(client); 581 560 582 561 // 2. Get repo context 583 562 const context = await getCurrentRepoContext();
+8 -2
src/lib/api-client.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 2 import type { AtpSessionData } from '@atproto/api'; 3 3 import { 4 + KeychainAccessError, 4 5 clearCurrentSessionMetadata, 5 6 deleteSession, 6 7 getCurrentSessionMetadata, ··· 106 107 107 108 return true; 108 109 } catch (error) { 109 - // If resume fails, clear invalid session 110 - await clearCurrentSessionMetadata(); 110 + if (error instanceof KeychainAccessError) { 111 + // Don't clear credentials โ€” keychain may just be temporarily locked 112 + throw error; 113 + } 114 + // Session resume failed (network error, expired refresh token, etc.) 115 + // Don't clear credentials โ€” the error may be transient. The user can 116 + // run "auth login" explicitly if they need to re-authenticate. 111 117 return false; 112 118 } 113 119 }
+33 -14
src/lib/session.ts
··· 1 + import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; 2 + import { homedir } from 'node:os'; 3 + import { join } from 'node:path'; 1 4 import type { AtpSessionData } from '@atproto/api'; 2 5 import { AsyncEntry } from '@napi-rs/keyring'; 3 6 4 7 const SERVICE_NAME = 'tangled-cli'; 8 + const SESSION_METADATA_PATH = join(homedir(), '.config', 'tangled', 'session.json'); 9 + 10 + export class KeychainAccessError extends Error { 11 + constructor(message: string) { 12 + super(message); 13 + this.name = 'KeychainAccessError'; 14 + } 15 + } 5 16 6 17 export interface SessionMetadata { 7 18 handle: string; ··· 44 55 } 45 56 return JSON.parse(serialized) as AtpSessionData; 46 57 } catch (error) { 47 - throw new Error( 48 - `Failed to load session from keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 58 + throw new KeychainAccessError( 59 + `Cannot access keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 49 60 ); 50 61 } 51 62 } ··· 66 77 } 67 78 68 79 /** 69 - * Store metadata about current session for CLI to track active user 70 - * Uses a special "current" account in keychain 80 + * Store metadata about current session for CLI to track active user. 81 + * Written to a plain file โ€” metadata is not secret and must be readable 82 + * even when the keychain is locked (e.g. after sleep/wake). 71 83 */ 72 84 export async function saveCurrentSessionMetadata(metadata: SessionMetadata): Promise<void> { 73 - const serialized = JSON.stringify(metadata); 74 - const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); 75 - await entry.setPassword(serialized); 85 + await mkdir(join(homedir(), '.config', 'tangled'), { recursive: true }); 86 + await writeFile(SESSION_METADATA_PATH, JSON.stringify(metadata, null, 2), 'utf-8'); 76 87 } 77 88 78 89 /** 79 90 * Get metadata about current active session 80 91 */ 81 92 export async function getCurrentSessionMetadata(): Promise<SessionMetadata | null> { 82 - const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); 83 - const serialized = await entry.getPassword(); 84 - if (!serialized) { 85 - return null; 93 + try { 94 + const content = await readFile(SESSION_METADATA_PATH, 'utf-8'); 95 + return JSON.parse(content) as SessionMetadata; 96 + } catch (error) { 97 + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 98 + return null; 99 + } 100 + throw error; 86 101 } 87 - return JSON.parse(serialized) as SessionMetadata; 88 102 } 89 103 90 104 /** 91 105 * Clear current session metadata 92 106 */ 93 107 export async function clearCurrentSessionMetadata(): Promise<void> { 94 - const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); 95 - await entry.deleteCredential(); 108 + try { 109 + await unlink(SESSION_METADATA_PATH); 110 + } catch (error) { 111 + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { 112 + throw error; 113 + } 114 + } 96 115 }
+45 -1
src/utils/auth-helpers.ts
··· 1 + import { execSync } from 'node:child_process'; 1 2 import type { TangledApiClient } from '../lib/api-client.js'; 3 + import { KeychainAccessError } from '../lib/session.js'; 2 4 3 5 /** 4 6 * Validate that the client is authenticated and has an active session ··· 9 11 did: string; 10 12 handle: string; 11 13 }> { 12 - if (!(await client.isAuthenticated())) { 14 + if (!client.isAuthenticated()) { 13 15 throw new Error('Must be authenticated. Run "tangled auth login" first.'); 14 16 } 15 17 ··· 20 22 21 23 return session; 22 24 } 25 + 26 + function tryUnlockKeychain(): boolean { 27 + if (process.platform !== 'darwin') return false; 28 + try { 29 + execSync('security unlock-keychain', { stdio: 'inherit' }); 30 + return true; 31 + } catch { 32 + return false; 33 + } 34 + } 35 + 36 + /** 37 + * Resume session and ensure the client is authenticated. 38 + * On macOS, if the keychain is locked, attempts to unlock it interactively 39 + * via `security unlock-keychain` before falling back to an error message. 40 + * Exits the process with a clear error message if authentication fails. 41 + */ 42 + export async function ensureAuthenticated(client: TangledApiClient): Promise<void> { 43 + try { 44 + const authenticated = await client.resumeSession(); 45 + if (!authenticated) { 46 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 47 + process.exit(1); 48 + } 49 + } catch (error) { 50 + if (error instanceof KeychainAccessError) { 51 + const unlocked = tryUnlockKeychain(); 52 + if (unlocked) { 53 + try { 54 + const retried = await client.resumeSession(); 55 + if (retried) return; 56 + } catch { 57 + // fall through to error message 58 + } 59 + } 60 + console.error('โœ— Cannot access keychain. Please unlock your Mac keychain and try again.'); 61 + console.error(' You can unlock it manually with: security unlock-keychain'); 62 + process.exit(1); 63 + } 64 + throw error; 65 + } 66 + }
+1 -4
src/utils/formatting.ts
··· 45 45 * @param data - The data to output (object or array of objects) 46 46 * @param fields - Comma-separated field names to include; omit for all fields 47 47 */ 48 - export function outputJson<T extends object>( 49 - data: T | T[], 50 - fields?: string 51 - ): void { 48 + export function outputJson<T extends object>(data: T | T[], fields?: string): void { 52 49 if (fields) { 53 50 const fieldList = fields 54 51 .split(',')
+28 -7
tests/commands/issue.test.ts
··· 163 163 164 164 describe('authentication required', () => { 165 165 it('should fail when not authenticated', async () => { 166 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 166 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 167 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 168 + process.exit(1); 169 + }); 167 170 168 171 const command = createIssueCommand(); 169 172 ··· 419 422 420 423 describe('authentication required', () => { 421 424 it('should fail when not authenticated', async () => { 422 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 425 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 426 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 427 + process.exit(1); 428 + }); 423 429 424 430 const command = createIssueCommand(); 425 431 ··· 689 695 }); 690 696 691 697 it('should fail when not authenticated', async () => { 692 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 698 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 699 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 700 + process.exit(1); 701 + }); 693 702 694 703 const command = createIssueCommand(); 695 704 await expect(command.parseAsync(['node', 'test', 'view', '1'])).rejects.toThrow( ··· 886 895 }); 887 896 888 897 it('should fail when not authenticated', async () => { 889 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 898 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 899 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 900 + process.exit(1); 901 + }); 890 902 891 903 const command = createIssueCommand(); 892 904 await expect( ··· 1028 1040 }); 1029 1041 1030 1042 it('should fail when not authenticated', async () => { 1031 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 1043 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 1044 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 1045 + process.exit(1); 1046 + }); 1032 1047 1033 1048 const command = createIssueCommand(); 1034 1049 await expect(command.parseAsync(['node', 'test', 'close', '1'])).rejects.toThrow( ··· 1142 1157 }); 1143 1158 1144 1159 it('should fail when not authenticated', async () => { 1145 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 1160 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 1161 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 1162 + process.exit(1); 1163 + }); 1146 1164 1147 1165 const command = createIssueCommand(); 1148 1166 await expect(command.parseAsync(['node', 'test', 'reopen', '1'])).rejects.toThrow( ··· 1294 1312 }); 1295 1313 1296 1314 it('should fail when not authenticated', async () => { 1297 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 1315 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 1316 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 1317 + process.exit(1); 1318 + }); 1298 1319 1299 1320 const command = createIssueCommand(); 1300 1321 await expect(command.parseAsync(['node', 'test', 'delete', '1', '--force'])).rejects.toThrow(
+25 -11
tests/lib/api-client.test.ts
··· 1 1 import type { AtpSessionData } from '@atproto/api'; 2 2 import { beforeEach, describe, expect, it, vi } from 'vitest'; 3 3 import { TangledApiClient } from '../../src/lib/api-client.js'; 4 + import { KeychainAccessError } from '../../src/lib/session.js'; 4 5 import * as sessionModule from '../../src/lib/session.js'; 5 6 import { mockSessionData, mockSessionMetadata } from '../helpers/mock-data.js'; 6 7 ··· 30 31 }; 31 32 }); 32 33 33 - // Mock session management 34 - vi.mock('../../src/lib/session.js', () => ({ 35 - saveSession: vi.fn(), 36 - loadSession: vi.fn(), 37 - deleteSession: vi.fn(), 38 - saveCurrentSessionMetadata: vi.fn(), 39 - getCurrentSessionMetadata: vi.fn(), 40 - clearCurrentSessionMetadata: vi.fn(), 41 - })); 34 + // Mock session management (use importOriginal to preserve KeychainAccessError class) 35 + vi.mock('../../src/lib/session.js', async (importOriginal) => { 36 + const actual = await importOriginal<typeof import('../../src/lib/session.js')>(); 37 + return { 38 + ...actual, 39 + saveSession: vi.fn(), 40 + loadSession: vi.fn(), 41 + deleteSession: vi.fn(), 42 + saveCurrentSessionMetadata: vi.fn(), 43 + getCurrentSessionMetadata: vi.fn(), 44 + clearCurrentSessionMetadata: vi.fn(), 45 + }; 46 + }); 42 47 43 48 describe('TangledApiClient', () => { 44 49 let client: TangledApiClient; ··· 138 143 expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); 139 144 }); 140 145 141 - it('should return false and cleanup on resume error', async () => { 146 + it('should return false without clearing metadata on transient resume error', async () => { 142 147 vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 143 148 vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); 144 149 ··· 148 153 const resumed = await client.resumeSession(); 149 154 150 155 expect(resumed).toBe(false); 151 - expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); 156 + expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).not.toHaveBeenCalled(); 157 + }); 158 + 159 + it('should rethrow KeychainAccessError without clearing metadata', async () => { 160 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockRejectedValueOnce( 161 + new KeychainAccessError('Cannot access keychain: locked') 162 + ); 163 + 164 + await expect(client.resumeSession()).rejects.toThrow(KeychainAccessError); 165 + expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).not.toHaveBeenCalled(); 152 166 }); 153 167 }); 154 168
+1 -1
tests/lib/issues-api.test.ts
··· 30 30 }; 31 31 32 32 return { 33 - isAuthenticated: vi.fn(async () => authenticated), 33 + isAuthenticated: vi.fn(() => authenticated), 34 34 getSession: vi.fn(() => 35 35 authenticated ? { did: 'did:plc:test123', handle: 'test.bsky.social' } : null 36 36 ),
+31
tests/lib/session.test.ts
··· 33 33 }; 34 34 }); 35 35 36 + // Mock node:fs/promises for metadata file storage 37 + const mockFileStorage = new Map<string, string>(); 38 + 39 + vi.mock('node:fs/promises', () => ({ 40 + mkdir: vi.fn().mockResolvedValue(undefined), 41 + writeFile: vi.fn().mockImplementation(async (path: string, content: string) => { 42 + mockFileStorage.set(path as string, content); 43 + }), 44 + readFile: vi.fn().mockImplementation(async (path: string) => { 45 + const content = mockFileStorage.get(path as string); 46 + if (content === undefined) { 47 + const err = Object.assign(new Error(`ENOENT: no such file or directory, open '${path}'`), { 48 + code: 'ENOENT', 49 + }); 50 + throw err; 51 + } 52 + return content; 53 + }), 54 + unlink: vi.fn().mockImplementation(async (path: string) => { 55 + if (!mockFileStorage.has(path as string)) { 56 + const err = Object.assign(new Error(`ENOENT: no such file or directory, unlink '${path}'`), { 57 + code: 'ENOENT', 58 + }); 59 + throw err; 60 + } 61 + mockFileStorage.delete(path as string); 62 + }), 63 + })); 64 + 36 65 describe('Session Management', () => { 37 66 beforeEach(() => { 38 67 // Clear mock storage before each test 39 68 mockKeyringStorage.clear(); 69 + mockFileStorage.clear(); 40 70 vi.clearAllMocks(); 41 71 }); 42 72 43 73 afterEach(() => { 44 74 // Clean up after each test 45 75 mockKeyringStorage.clear(); 76 + mockFileStorage.clear(); 46 77 }); 47 78 48 79 describe('saveSession', () => {
+107 -3
tests/utils/auth-helpers.test.ts
··· 1 - import { describe, expect, it, vi } from 'vitest'; 1 + import { execSync } from 'node:child_process'; 2 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 3 import type { TangledApiClient } from '../../src/lib/api-client.js'; 3 - import { requireAuth } from '../../src/utils/auth-helpers.js'; 4 + import { KeychainAccessError } from '../../src/lib/session.js'; 5 + import { ensureAuthenticated, requireAuth } from '../../src/utils/auth-helpers.js'; 6 + 7 + vi.mock('node:child_process', () => ({ 8 + execSync: vi.fn(), 9 + })); 4 10 5 11 // Mock API client factory 6 12 const createMockClient = ( ··· 8 14 session: { did: string; handle: string } | null 9 15 ): TangledApiClient => { 10 16 return { 11 - isAuthenticated: vi.fn(async () => authenticated), 17 + isAuthenticated: vi.fn(() => authenticated), 12 18 getSession: vi.fn(() => session), 13 19 } as unknown as TangledApiClient; 14 20 }; ··· 37 43 await expect(requireAuth(mockClient)).rejects.toThrow('No active session found'); 38 44 }); 39 45 }); 46 + 47 + describe('ensureAuthenticated', () => { 48 + // biome-ignore lint/suspicious/noExplicitAny: spy instance types vary by platform signature 49 + let mockExit: any; 50 + // biome-ignore lint/suspicious/noExplicitAny: spy instance types vary by platform signature 51 + let mockConsoleError: any; 52 + 53 + beforeEach(() => { 54 + mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { 55 + throw new Error('process.exit called'); 56 + }); 57 + mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); 58 + vi.mocked(execSync).mockReset(); 59 + }); 60 + 61 + afterEach(() => { 62 + mockExit.mockRestore(); 63 + mockConsoleError.mockRestore(); 64 + }); 65 + 66 + it('should return normally when resumeSession succeeds', async () => { 67 + const mockClient = { 68 + resumeSession: vi.fn().mockResolvedValue(true), 69 + } as unknown as TangledApiClient; 70 + 71 + await expect(ensureAuthenticated(mockClient)).resolves.toBeUndefined(); 72 + expect(mockExit).not.toHaveBeenCalled(); 73 + }); 74 + 75 + it('should exit with error when not authenticated', async () => { 76 + const mockClient = { 77 + resumeSession: vi.fn().mockResolvedValue(false), 78 + } as unknown as TangledApiClient; 79 + 80 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 81 + expect(mockConsoleError).toHaveBeenCalledWith( 82 + 'โœ— Not authenticated. Run "tangled auth login" first.' 83 + ); 84 + expect(mockExit).toHaveBeenCalledWith(1); 85 + }); 86 + 87 + it('should unlock keychain and retry when KeychainAccessError is thrown', async () => { 88 + const mockClient = { 89 + resumeSession: vi 90 + .fn() 91 + .mockRejectedValueOnce(new KeychainAccessError('locked')) 92 + .mockResolvedValueOnce(true), 93 + } as unknown as TangledApiClient; 94 + 95 + vi.mocked(execSync).mockReturnValue(Buffer.from('')); 96 + 97 + await expect(ensureAuthenticated(mockClient)).resolves.toBeUndefined(); 98 + expect(execSync).toHaveBeenCalledWith('security unlock-keychain', { stdio: 'inherit' }); 99 + expect(mockExit).not.toHaveBeenCalled(); 100 + }); 101 + 102 + it('should exit with keychain error when unlock fails', async () => { 103 + const mockClient = { 104 + resumeSession: vi.fn().mockRejectedValue(new KeychainAccessError('locked')), 105 + } as unknown as TangledApiClient; 106 + 107 + vi.mocked(execSync).mockImplementation(() => { 108 + throw new Error('unlock failed'); 109 + }); 110 + 111 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 112 + expect(mockConsoleError).toHaveBeenCalledWith( 113 + 'โœ— Cannot access keychain. Please unlock your Mac keychain and try again.' 114 + ); 115 + expect(mockExit).toHaveBeenCalledWith(1); 116 + }); 117 + 118 + it('should exit with keychain error when unlock succeeds but retry fails', async () => { 119 + const mockClient = { 120 + resumeSession: vi 121 + .fn() 122 + .mockRejectedValueOnce(new KeychainAccessError('locked')) 123 + .mockRejectedValueOnce(new KeychainAccessError('still locked')), 124 + } as unknown as TangledApiClient; 125 + 126 + vi.mocked(execSync).mockReturnValue(Buffer.from('')); 127 + 128 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 129 + expect(mockConsoleError).toHaveBeenCalledWith( 130 + 'โœ— Cannot access keychain. Please unlock your Mac keychain and try again.' 131 + ); 132 + expect(mockExit).toHaveBeenCalledWith(1); 133 + }); 134 + 135 + it('should rethrow unexpected errors', async () => { 136 + const mockClient = { 137 + resumeSession: vi.fn().mockRejectedValue(new Error('unexpected network error')), 138 + } as unknown as TangledApiClient; 139 + 140 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('unexpected network error'); 141 + expect(mockExit).not.toHaveBeenCalled(); 142 + }); 143 + });

History

4 rounds 1 comment
sign up or login to add to the discussion
5 commits
expand
Fix #17: detect locked keychain and preserve credentials on access failure
Move session metadata from keychain to plain file to prevent credential loss
chore: allow git fetch in Claude permissions
Formatting
Update pipeline cloning
expand 1 comment

Alright, I haven't seen the error for a while now. Will try this and create a new issue if it comes up again!

pull request successfully merged
4 commits
expand
Fix #17: detect locked keychain and preserve credentials on access failure
Move session metadata from keychain to plain file to prevent credential loss
chore: allow git fetch in Claude permissions
Formatting
expand 0 comments
2 commits
expand
Fix #17: detect locked keychain and preserve credentials on access failure
Move session metadata from keychain to plain file to prevent credential loss
expand 0 comments
1 commit
expand
Fix #17: detect locked keychain and preserve credentials on access failure
expand 0 comments