/ utils / secureStorage / fallbackStorage.ts
fallbackStorage.ts
 1  import type { SecureStorage, SecureStorageData } from './types.js'
 2  
 3  /**
 4   * Creates a fallback storage that tries to use the primary storage first,
 5   * and if that fails, falls back to the secondary storage
 6   */
 7  export function createFallbackStorage(
 8    primary: SecureStorage,
 9    secondary: SecureStorage,
10  ): SecureStorage {
11    return {
12      name: `${primary.name}-with-${secondary.name}-fallback`,
13      read(): SecureStorageData {
14        const result = primary.read()
15        if (result !== null && result !== undefined) {
16          return result
17        }
18        return secondary.read() || {}
19      },
20      async readAsync(): Promise<SecureStorageData | null> {
21        const result = await primary.readAsync()
22        if (result !== null && result !== undefined) {
23          return result
24        }
25        return (await secondary.readAsync()) || {}
26      },
27      update(data: SecureStorageData): { success: boolean; warning?: string } {
28        // Capture state before update
29        const primaryDataBefore = primary.read()
30  
31        const result = primary.update(data)
32  
33        if (result.success) {
34          // Delete secondary when migrating to primary for the first time
35          // This preserves credentials when sharing .claude between host and containers
36          // See: https://github.com/anthropics/claude-code/issues/1414
37          if (primaryDataBefore === null) {
38            secondary.delete()
39          }
40          return result
41        }
42  
43        const fallbackResult = secondary.update(data)
44  
45        if (fallbackResult.success) {
46          // Primary write failed but primary may still hold an *older* valid
47          // entry. read() prefers primary whenever it returns non-null, so that
48          // stale entry would shadow the fresh data we just wrote to secondary —
49          // e.g. a refresh token the server has already rotated away, causing a
50          // /login loop (#30337). Best-effort delete; if this also fails the
51          // user's keychain is in a bad state we can't fix from here.
52          if (primaryDataBefore !== null) {
53            primary.delete()
54          }
55          return {
56            success: true,
57            warning: fallbackResult.warning,
58          }
59        }
60  
61        return { success: false }
62      },
63      delete(): boolean {
64        const primarySuccess = primary.delete()
65        const secondarySuccess = secondary.delete()
66  
67        return primarySuccess || secondarySuccess
68      },
69    }
70  }