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! :)

Move session metadata from keychain to plain file to prevent credential loss

The current-session-metadata keychain entry was getting wiped by transient
errors (e.g. network failure on wake) in the resumeSession() catch block,
forcing users to re-authenticate unnecessarily.

Changes:
- Store session metadata in ~/.config/tangled/session.json (not keychain)
so it survives sleep/wake cycles and keychain lock events
- Don't clear metadata on transient agent.resumeSession() failures — only
clear when loadSession() definitively returns null (no stored credentials)
- Update session.test.ts to mock node:fs/promises with in-memory storage
- Update api-client.test.ts to reflect new non-clearing behavior

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

authored by markbennett.ca

Claude Sonnet 4.5 and committed by tangled.org 42af5942 265ca911

+57 -19
+3 -2
src/lib/api-client.ts
··· 111 111 // Don't clear credentials — keychain may just be temporarily locked 112 112 throw error; 113 113 } 114 - // Session data invalid or agent resume failed — clear stale state 115 - await clearCurrentSessionMetadata(); 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. 116 117 return false; 117 118 } 118 119 }
+21 -15
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'); 5 9 6 10 export class KeychainAccessError extends Error { 7 11 constructor(message: string) { ··· 73 77 } 74 78 75 79 /** 76 - * Store metadata about current session for CLI to track active user 77 - * 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). 78 83 */ 79 84 export async function saveCurrentSessionMetadata(metadata: SessionMetadata): Promise<void> { 80 - const serialized = JSON.stringify(metadata); 81 - const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); 82 - 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'); 83 87 } 84 88 85 89 /** ··· 87 91 */ 88 92 export async function getCurrentSessionMetadata(): Promise<SessionMetadata | null> { 89 93 try { 90 - const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); 91 - const serialized = await entry.getPassword(); 92 - if (!serialized) { 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') { 93 98 return null; 94 99 } 95 - return JSON.parse(serialized) as SessionMetadata; 96 - } catch (error) { 97 - throw new KeychainAccessError( 98 - `Cannot access keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 99 - ); 100 + throw error; 100 101 } 101 102 } 102 103 ··· 104 105 * Clear current session metadata 105 106 */ 106 107 export async function clearCurrentSessionMetadata(): Promise<void> { 107 - const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); 108 - 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 + } 109 115 }
+2 -2
tests/lib/api-client.test.ts
··· 143 143 expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); 144 144 }); 145 145 146 - it('should return false and cleanup on resume error', async () => { 146 + it('should return false without clearing metadata on transient resume error', async () => { 147 147 vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 148 148 vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); 149 149 ··· 153 153 const resumed = await client.resumeSession(); 154 154 155 155 expect(resumed).toBe(false); 156 - expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); 156 + expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).not.toHaveBeenCalled(); 157 157 }); 158 158 159 159 it('should rethrow KeychainAccessError without clearing metadata', async () => {
+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', () => {