fix(auth-profiles): add file locking, type priority sort, credential filtering

- Add proper-lockfile for concurrent-safe store updates with fallback
- Add "format" to AuthProfileFailureReason
- Two-level round-robin sort: credential type priority (OAuth > API key), then lastUsed
- Filter out profiles with missing/invalid credentials from candidates
- Add preferredProfile option to resolveAuthProfileOrder
- Export coerceStore and ensureAuthStoreFile for testing
- Add store.test.ts with coerceStore, load/save round-trip, corruption handling
- Update order.test.ts mocks for resolver and registry dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
yushen 2026-02-03 17:26:09 +08:00
parent dcc336f2d0
commit a8c5042554
8 changed files with 433 additions and 65 deletions

View file

@ -31,6 +31,7 @@
"packageManager": "pnpm@10.28.2",
"devDependencies": {
"@types/node": "catalog:",
"@types/proper-lockfile": "^4.1.4",
"@types/turndown": "^5.0.6",
"@types/uuid": "^11.0.0",
"@vitest/coverage-v8": "^4.0.18",
@ -42,11 +43,11 @@
"vitest": "^4.0.18"
},
"dependencies": {
"@multica/sdk": "workspace:*",
"@mariozechner/pi-agent-core": "^0.50.3",
"@mariozechner/pi-ai": "^0.50.3",
"@mariozechner/pi-coding-agent": "^0.50.3",
"@mozilla/readability": "^0.6.0",
"@multica/sdk": "workspace:*",
"@nestjs/common": "^11.1.12",
"@nestjs/core": "^11.1.12",
"@nestjs/platform-express": "^11.1.12",
@ -61,6 +62,7 @@
"pino": "^10.3.0",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"proper-lockfile": "^4.1.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"socket.io": "^4.8.3",

142
pnpm-lock.yaml generated
View file

@ -34,13 +34,13 @@ importers:
dependencies:
'@mariozechner/pi-agent-core':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-ai':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-coding-agent':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mozilla/readability':
specifier: ^0.6.0
version: 0.6.0
@ -89,6 +89,9 @@ importers:
pino-pretty:
specifier: ^13.1.3
version: 13.1.3
proper-lockfile:
specifier: ^4.1.2
version: 4.1.2
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@ -117,6 +120,9 @@ importers:
'@types/node':
specifier: 'catalog:'
version: 25.0.10
'@types/proper-lockfile':
specifier: ^4.1.4
version: 4.1.4
'@types/turndown':
specifier: ^5.0.6
version: 5.0.6
@ -1200,89 +1206,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@ -1404,24 +1426,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@mariozechner/clipboard-linux-riscv64-gnu@0.3.0':
resolution: {integrity: sha512-4BC08CIaOXSSAGRZLEjqJmQfioED8ohAzwt0k2amZPEbH96YKoBNorq5EdwPf5VT+odS0DeyCwhwtxokRLZIvQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@mariozechner/clipboard-linux-x64-gnu@0.3.0':
resolution: {integrity: sha512-GpNY5Y9nOzr0Vt0Qi5U88qwe6piiIHk44kSMexl8ns90LluN5UTNYmyfi7Xq3/lmPZCpnB2xvBTYbsXCxnopIA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@mariozechner/clipboard-linux-x64-musl@0.3.0':
resolution: {integrity: sha512-+PnR48/x9GMY5Kh8BLjzHMx6trOegMtxAuqTM9X/bhV3QuW6sLLd7nojDHSGj/ZueK6i0tcQxvOrgNLozVtNDA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@mariozechner/clipboard-win32-arm64-msvc@0.3.0':
resolution: {integrity: sha512-+dy2vZ1Ph4EYj0cotB+bVUVk/uKl2bh9LOp/zlnFqoCCYDN6sm+L0VyIOPPo3hjoEVdGpHe1MUxp3qG/OLwXgg==}
@ -1583,24 +1609,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
@ -1700,66 +1730,79 @@ packages:
resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.57.0':
resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.57.0':
resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.57.0':
resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.57.0':
resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.57.0':
resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.57.0':
resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.57.0':
resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.57.0':
resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.57.0':
resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.57.0':
resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.57.0':
resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.57.0':
resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.57.0':
resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==}
@ -2075,24 +2118,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@ -2222,6 +2269,9 @@ packages:
'@types/plist@3.0.5':
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
'@types/proper-lockfile@4.1.4':
resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@ -2233,6 +2283,9 @@ packages:
'@types/responselike@1.0.3':
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
'@types/retry@0.12.5':
resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==}
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
@ -2417,41 +2470,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@ -4442,24 +4503,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@ -6441,11 +6506,11 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.0.2
'@anthropic-ai/sdk@0.71.2(zod@3.25.76)':
'@anthropic-ai/sdk@0.71.2(zod@4.3.6)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 3.25.76
zod: 4.3.6
'@aws-crypto/crc32@5.2.0':
dependencies:
@ -7468,12 +7533,12 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))':
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))':
dependencies:
google-auth-library: 10.5.0
ws: 8.18.3
optionalDependencies:
'@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@3.25.76)
'@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@4.3.6)
transitivePeerDependencies:
- bufferutil
- supports-color
@ -7728,9 +7793,9 @@ snapshots:
std-env: 3.10.0
yoctocolors: 2.1.2
'@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
'@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
dependencies:
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-tui': 0.50.3
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@ -7741,21 +7806,21 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@3.25.76)
'@anthropic-ai/sdk': 0.71.2(zod@4.3.6)
'@aws-sdk/client-bedrock-runtime': 3.978.0
'@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))
'@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))
'@mistralai/mistralai': 1.10.0
'@sinclair/typebox': 0.34.48
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
chalk: 5.6.2
openai: 6.10.0(ws@8.18.3)(zod@3.25.76)
openai: 6.10.0(ws@8.18.3)(zod@4.3.6)
partial-json: 0.1.7
proxy-agent: 6.5.0
undici: 7.19.2
zod-to-json-schema: 3.25.1(zod@3.25.76)
zod-to-json-schema: 3.25.1(zod@4.3.6)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
@ -7765,12 +7830,12 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
dependencies:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-tui': 0.50.3
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
@ -7828,6 +7893,29 @@ snapshots:
- hono
- supports-color
'@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.9(hono@4.11.7)
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
content-type: 1.0.5
cors: 2.8.6
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 7.5.1(express@5.2.1)
jose: 6.1.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 4.3.6
zod-to-json-schema: 3.25.1(zod@4.3.6)
transitivePeerDependencies:
- hono
- supports-color
optional: true
'@mozilla/readability@0.6.0': {}
'@mswjs/interceptors@0.40.0':
@ -8616,6 +8704,10 @@ snapshots:
xmlbuilder: 15.1.1
optional: true
'@types/proper-lockfile@4.1.4':
dependencies:
'@types/retry': 0.12.5
'@types/react-dom@19.2.3(@types/react@19.2.10)':
dependencies:
'@types/react': 19.2.10
@ -8628,6 +8720,8 @@ snapshots:
dependencies:
'@types/node': 25.0.10
'@types/retry@0.12.5': {}
'@types/statuses@2.0.6': {}
'@types/turndown@5.0.6': {}
@ -10031,7 +10125,7 @@ snapshots:
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
@ -10064,7 +10158,7 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@ -10079,7 +10173,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -12067,10 +12161,10 @@ snapshots:
powershell-utils: 0.1.0
wsl-utils: 0.3.1
openai@6.10.0(ws@8.18.3)(zod@3.25.76):
openai@6.10.0(ws@8.18.3)(zod@4.3.6):
optionalDependencies:
ws: 8.18.3
zod: 3.25.76
zod: 4.3.6
optionator@0.9.4:
dependencies:
@ -13702,6 +13796,10 @@ snapshots:
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.1(zod@4.3.6):
dependencies:
zod: 4.3.6
zod-validation-error@4.0.2(zod@4.3.6):
dependencies:
zod: 4.3.6

View file

@ -22,6 +22,8 @@ export {
export {
resolveAuthStorePath,
coerceStore,
ensureAuthStoreFile,
loadAuthProfileStore,
saveAuthProfileStore,
updateAuthProfileStore,
@ -30,6 +32,7 @@ export {
export {
listProfilesForProvider,
resolveAuthProfileOrder,
type AuthProfileOrderOptions,
} from "./order.js";
export {

View file

@ -2,11 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { resolveAuthProfileOrder, listProfilesForProvider } from "./order.js";
import type { AuthProfileStore } from "./types.js";
// Track mock profiles for credential validation
let _profiles: Record<string, { apiKey?: string }> = {};
let _order: Record<string, string[]> = {};
// Mock credentialManager
vi.mock("../credentials.js", () => {
let _profiles: Record<string, { apiKey?: string }> = {};
let _order: Record<string, string[]> = {};
return {
credentialManager: {
listProfileIdsForProvider(provider: string): string[] {
@ -17,27 +18,33 @@ vi.mock("../credentials.js", () => {
getLlmOrder(provider: string): string[] | undefined {
return _order[provider];
},
// Test helpers
__setProfiles(profiles: Record<string, { apiKey?: string }>) {
_profiles = profiles;
},
__setOrder(order: Record<string, string[]>) {
_order = order;
getLlmProviderConfig(profileId: string): { apiKey?: string } | undefined {
return _profiles[profileId];
},
},
};
});
// Import the mock to use test helpers
import { credentialManager } from "../credentials.js";
const mock = credentialManager as unknown as {
__setProfiles: (p: Record<string, { apiKey?: string }>) => void;
__setOrder: (o: Record<string, string[]>) => void;
};
// Mock providers/registry — all test profiles are API-key based
vi.mock("../providers/registry.js", () => ({
isOAuthProvider: (_provider: string) => false,
}));
// Mock providers/resolver — delegate to our mock profiles
vi.mock("../providers/resolver.js", () => ({
resolveApiKeyForProfile: (profileId: string) => _profiles[profileId]?.apiKey,
}));
function setProfiles(profiles: Record<string, { apiKey?: string }>) {
_profiles = profiles;
}
function setOrder(order: Record<string, string[]>) {
_order = order;
}
beforeEach(() => {
mock.__setProfiles({});
mock.__setOrder({});
_profiles = {};
_order = {};
});
// ============================================================
@ -46,7 +53,7 @@ beforeEach(() => {
describe("listProfilesForProvider", () => {
it("returns profiles matching the provider", () => {
mock.__setProfiles({
setProfiles({
anthropic: { apiKey: "sk-1" },
"anthropic:backup": { apiKey: "sk-2" },
openai: { apiKey: "sk-3" },
@ -58,7 +65,7 @@ describe("listProfilesForProvider", () => {
});
it("returns empty array when no profiles match", () => {
mock.__setProfiles({ openai: { apiKey: "sk-1" } });
setProfiles({ openai: { apiKey: "sk-1" } });
expect(listProfilesForProvider("anthropic")).toEqual([]);
});
});
@ -71,7 +78,7 @@ describe("resolveAuthProfileOrder", () => {
const now = 1_000_000;
it("returns round-robin order by lastUsed when no explicit order", () => {
mock.__setProfiles({
setProfiles({
"anthropic": { apiKey: "sk-1" },
"anthropic:b": { apiKey: "sk-2" },
"anthropic:c": { apiKey: "sk-3" },
@ -91,12 +98,12 @@ describe("resolveAuthProfileOrder", () => {
});
it("respects explicit order from config", () => {
mock.__setProfiles({
setProfiles({
"anthropic": { apiKey: "sk-1" },
"anthropic:b": { apiKey: "sk-2" },
"anthropic:c": { apiKey: "sk-3" },
});
mock.__setOrder({ anthropic: ["anthropic:c", "anthropic", "anthropic:b"] });
setOrder({ anthropic: ["anthropic:c", "anthropic", "anthropic:b"] });
const store: AuthProfileStore = { version: 1 };
const order = resolveAuthProfileOrder("anthropic", store, now);
@ -104,7 +111,7 @@ describe("resolveAuthProfileOrder", () => {
});
it("pushes cooldown profiles to the end", () => {
mock.__setProfiles({
setProfiles({
"anthropic": { apiKey: "sk-1" },
"anthropic:b": { apiKey: "sk-2" },
"anthropic:c": { apiKey: "sk-3" },
@ -124,7 +131,7 @@ describe("resolveAuthProfileOrder", () => {
});
it("sorts cooldown profiles by earliest recovery", () => {
mock.__setProfiles({
setProfiles({
"anthropic": { apiKey: "sk-1" },
"anthropic:b": { apiKey: "sk-2" },
"anthropic:c": { apiKey: "sk-3" },
@ -144,12 +151,12 @@ describe("resolveAuthProfileOrder", () => {
});
it("deduplicates profile IDs", () => {
mock.__setProfiles({
setProfiles({
"anthropic": { apiKey: "sk-1" },
"anthropic:b": { apiKey: "sk-2" },
});
// Explicit order has duplicate
mock.__setOrder({ anthropic: ["anthropic", "anthropic", "anthropic:b"] });
setOrder({ anthropic: ["anthropic", "anthropic", "anthropic:b"] });
const store: AuthProfileStore = { version: 1 };
const order = resolveAuthProfileOrder("anthropic", store, now);
@ -157,13 +164,13 @@ describe("resolveAuthProfileOrder", () => {
});
it("appends unlisted profiles to explicit order", () => {
mock.__setProfiles({
setProfiles({
"anthropic": { apiKey: "sk-1" },
"anthropic:b": { apiKey: "sk-2" },
"anthropic:c": { apiKey: "sk-3" },
});
// Only lists one profile in explicit order
mock.__setOrder({ anthropic: ["anthropic:b"] });
setOrder({ anthropic: ["anthropic:b"] });
const store: AuthProfileStore = { version: 1 };
const order = resolveAuthProfileOrder("anthropic", store, now);
@ -173,4 +180,29 @@ describe("resolveAuthProfileOrder", () => {
expect(order).toContain("anthropic");
expect(order).toContain("anthropic:c");
});
it("filters out profiles with no valid API key", () => {
setProfiles({
"anthropic": { apiKey: "sk-1" },
"anthropic:empty": {}, // no apiKey
"anthropic:c": { apiKey: "sk-3" },
});
const store: AuthProfileStore = { version: 1 };
const order = resolveAuthProfileOrder("anthropic", store, now);
expect(order).toEqual(["anthropic", "anthropic:c"]);
});
it("moves preferredProfile to front", () => {
setProfiles({
"anthropic": { apiKey: "sk-1" },
"anthropic:b": { apiKey: "sk-2" },
"anthropic:c": { apiKey: "sk-3" },
});
const store: AuthProfileStore = { version: 1 };
const order = resolveAuthProfileOrder("anthropic", store, now, {
preferredProfile: "anthropic:c",
});
expect(order[0]).toBe("anthropic:c");
expect(order).toHaveLength(3);
});
});

View file

@ -3,11 +3,14 @@
*
* Determines the order in which auth profiles are tried for a given provider.
* Supports explicit ordering (from credentials.json5) and automatic round-robin
* based on lastUsed time. Profiles in cooldown are pushed to the end.
* with two-level sort: credential type priority (OAuth > API key), then lastUsed.
* Profiles in cooldown are pushed to the end.
*/
import { credentialManager } from "../credentials.js";
import type { AuthProfileStore, ProfileUsageStats } from "./types.js";
import { isOAuthProvider } from "../providers/registry.js";
import { resolveApiKeyForProfile } from "../providers/resolver.js";
import type { AuthProfileStore } from "./types.js";
import { isProfileInCooldown, resolveProfileUnusableUntil } from "./usage.js";
// ============================================================
@ -22,24 +25,51 @@ export function listProfilesForProvider(provider: string): string[] {
return credentialManager.listProfileIdsForProvider(provider);
}
// ============================================================
// Type priority
// ============================================================
/**
* Get the type-based priority for a profile.
* OAuth providers (e.g. claude-code, openai-codex) get priority 0 (preferred),
* API-key providers get priority 1.
* Lower number = higher priority.
*/
function getProfileTypePriority(profileId: string): number {
// Extract the provider portion from profileId (before ":" if present)
const provider = profileId.includes(":") ? profileId.split(":")[0]! : profileId;
return isOAuthProvider(provider) ? 0 : 1;
}
// ============================================================
// Ordering
// ============================================================
export interface AuthProfileOrderOptions {
/** Preferred profile to put first (used when user or agent selects a profile) */
preferredProfile?: string | undefined;
}
/**
* Resolve the ordered list of profile IDs to try for a given provider.
*
* Strategy:
* 1. If credentials.json5 has `llm.order[provider]`, use that explicit order.
* 2. Otherwise, use round-robin ordered by `lastUsed` ascending (oldest first).
* 2. Otherwise, use round-robin with two-level sort:
* - First by credential type priority (OAuth > API key)
* - Then by `lastUsed` ascending within each type (oldest first)
*
* In both cases, profiles currently in cooldown are pushed to the end,
* sorted by earliest cooldown expiry (soonest-to-recover first).
* In both cases:
* - Profiles with invalid/missing credentials are filtered out
* - Profiles currently in cooldown are pushed to the end,
* sorted by earliest cooldown expiry (soonest-to-recover first)
* - If `preferredProfile` is set, it is moved to the front
*/
export function resolveAuthProfileOrder(
provider: string,
store: AuthProfileStore,
now?: number,
options?: AuthProfileOrderOptions,
): string[] {
const ts = now ?? Date.now();
@ -59,8 +89,11 @@ export function resolveAuthProfileOrder(
}
}
} else {
// Round-robin by lastUsed (oldest first)
// Two-level sort: type priority first, then lastUsed within same type
candidates = [...allProfiles].sort((a, b) => {
const priorityDiff = getProfileTypePriority(a) - getProfileTypePriority(b);
if (priorityDiff !== 0) return priorityDiff;
const statsA = store.usageStats?.[a];
const statsB = store.usageStats?.[b];
return (statsA?.lastUsed ?? 0) - (statsB?.lastUsed ?? 0);
@ -70,6 +103,16 @@ export function resolveAuthProfileOrder(
// Deduplicate
candidates = [...new Set(candidates)];
// Filter out profiles with invalid/missing credentials
candidates = candidates.filter((id) => {
// For OAuth providers, resolveApiKeyForProfile won't find them in credentials.json5
// but they are still valid candidates (resolved at runtime via OAuth flow)
const provider = id.includes(":") ? id.split(":")[0]! : id;
if (isOAuthProvider(provider)) return true;
return resolveApiKeyForProfile(id) !== undefined;
});
// Partition into available and in-cooldown
const available: string[] = [];
const inCooldown: string[] = [];
@ -90,5 +133,15 @@ export function resolveAuthProfileOrder(
return resolveProfileUnusableUntil(statsA) - resolveProfileUnusableUntil(statsB);
});
return [...available, ...inCooldown];
let result = [...available, ...inCooldown];
// Move preferred profile to front if specified
if (options?.preferredProfile && result.includes(options.preferredProfile)) {
result = [
options.preferredProfile,
...result.filter((id) => id !== options.preferredProfile),
];
}
return result;
}

View file

@ -0,0 +1,131 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { coerceStore, loadAuthProfileStore, saveAuthProfileStore, updateAuthProfileStore } from "./store.js";
import { AUTH_STORE_VERSION } from "./constants.js";
import type { AuthProfileStore } from "./types.js";
// Use a temp directory for tests to avoid touching real store
const TEST_DIR = join(import.meta.dirname ?? ".", "__test_store_tmp__");
const TEST_STORE_PATH = join(TEST_DIR, "auth-profiles.json");
// We need to mock resolveAuthStorePath to point to our test dir
import { vi } from "vitest";
vi.mock("../../shared/paths.js", () => ({
DATA_DIR: join(import.meta.dirname ?? ".", "__test_store_tmp__"),
}));
beforeEach(() => {
if (!existsSync(TEST_DIR)) {
mkdirSync(TEST_DIR, { recursive: true });
}
});
afterEach(() => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true });
}
});
// ============================================================
// coerceStore
// ============================================================
describe("coerceStore", () => {
it("returns empty store for null", () => {
const store = coerceStore(null);
expect(store.version).toBe(AUTH_STORE_VERSION);
expect(store.lastGood).toBeUndefined();
expect(store.usageStats).toBeUndefined();
});
it("returns empty store for non-object", () => {
expect(coerceStore("hello").version).toBe(AUTH_STORE_VERSION);
expect(coerceStore(42).version).toBe(AUTH_STORE_VERSION);
expect(coerceStore(undefined).version).toBe(AUTH_STORE_VERSION);
});
it("preserves valid store data", () => {
const raw = {
version: 1,
lastGood: { anthropic: "anthropic:backup" },
usageStats: {
"anthropic": { lastUsed: 1000, errorCount: 0 },
},
};
const store = coerceStore(raw);
expect(store.version).toBe(1);
expect(store.lastGood?.anthropic).toBe("anthropic:backup");
expect(store.usageStats?.anthropic?.lastUsed).toBe(1000);
});
it("defaults version when missing", () => {
const store = coerceStore({ lastGood: {} });
expect(store.version).toBe(AUTH_STORE_VERSION);
});
});
// ============================================================
// loadAuthProfileStore / saveAuthProfileStore
// ============================================================
describe("loadAuthProfileStore / saveAuthProfileStore", () => {
it("returns empty store when file does not exist", () => {
const store = loadAuthProfileStore();
expect(store.version).toBe(AUTH_STORE_VERSION);
});
it("round-trips save and load", () => {
const original: AuthProfileStore = {
version: 1,
lastGood: { anthropic: "anthropic:main" },
usageStats: {
"anthropic:main": { lastUsed: 5000, errorCount: 1 },
},
};
saveAuthProfileStore(original);
const loaded = loadAuthProfileStore();
expect(loaded).toEqual(original);
});
it("handles corrupted JSON gracefully", () => {
writeFileSync(TEST_STORE_PATH, "not valid json{{{", "utf8");
const store = loadAuthProfileStore();
expect(store.version).toBe(AUTH_STORE_VERSION);
});
});
// ============================================================
// updateAuthProfileStore
// ============================================================
describe("updateAuthProfileStore", () => {
it("creates file and applies update when file does not exist", () => {
const result = updateAuthProfileStore((store) => {
if (!store.lastGood) store.lastGood = {};
store.lastGood.openai = "openai:primary";
});
expect(result.lastGood?.openai).toBe("openai:primary");
// Verify persisted
const loaded = loadAuthProfileStore();
expect(loaded.lastGood?.openai).toBe("openai:primary");
});
it("preserves existing data across updates", () => {
saveAuthProfileStore({
version: 1,
lastGood: { anthropic: "anthropic" },
});
updateAuthProfileStore((store) => {
if (!store.usageStats) store.usageStats = {};
store.usageStats["anthropic"] = { lastUsed: 9999 };
});
const loaded = loadAuthProfileStore();
expect(loaded.lastGood?.anthropic).toBe("anthropic");
expect(loaded.usageStats?.anthropic?.lastUsed).toBe(9999);
});
});

View file

@ -3,14 +3,31 @@
*
* Persistence layer for auth profile runtime state.
* Stores usage stats, cooldowns, and last-good info in ~/.super-multica/auth-profiles.json.
* Uses proper-lockfile for safe concurrent access across multiple agent processes.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import lockfile from "proper-lockfile";
import { DATA_DIR } from "../../shared/paths.js";
import { AUTH_STORE_VERSION, AUTH_PROFILE_STORE_FILENAME } from "./constants.js";
import type { AuthProfileStore } from "./types.js";
// ============================================================
// Lock options (matches OpenClaw's AUTH_STORE_LOCK_OPTIONS)
// ============================================================
const LOCK_OPTIONS = {
retries: {
retries: 10,
factor: 2,
minTimeout: 100,
maxTimeout: 10_000,
randomize: true,
},
stale: 30_000,
} as const;
// ============================================================
// Paths
// ============================================================
@ -28,7 +45,8 @@ function createEmptyStore(): AuthProfileStore {
return { version: AUTH_STORE_VERSION };
}
function coerceStore(raw: unknown): AuthProfileStore {
/** Coerce raw JSON into a valid AuthProfileStore, defensive against malformed data */
export function coerceStore(raw: unknown): AuthProfileStore {
if (!raw || typeof raw !== "object") return createEmptyStore();
const obj = raw as Record<string, unknown>;
@ -46,6 +64,19 @@ function coerceStore(raw: unknown): AuthProfileStore {
return store;
}
/** Ensure the store file exists on disk (creates it if missing) */
export function ensureAuthStoreFile(): string {
const storePath = resolveAuthStorePath();
const dir = dirname(storePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
if (!existsSync(storePath)) {
writeFileSync(storePath, JSON.stringify(createEmptyStore(), null, 2), "utf8");
}
return storePath;
}
/** Load auth profile store from disk. Returns empty store if file doesn't exist. */
export function loadAuthProfileStore(): AuthProfileStore {
const storePath = resolveAuthStorePath();
@ -70,15 +101,32 @@ export function saveAuthProfileStore(store: AuthProfileStore): void {
}
/**
* Atomic load-update-save cycle.
* The updater receives the current store and should mutate it in place.
* Atomic load-update-save cycle with file locking.
* Acquires a lock on the store file, loads current state, runs the updater,
* and saves. Falls back to unlocked update if the lock cannot be acquired.
* Returns the updated store.
*/
export function updateAuthProfileStore(
updater: (store: AuthProfileStore) => void,
): AuthProfileStore {
const store = loadAuthProfileStore();
updater(store);
saveAuthProfileStore(store);
return store;
const storePath = ensureAuthStoreFile();
try {
// Acquire file lock
const release = lockfile.lockSync(storePath, LOCK_OPTIONS);
try {
const store = loadAuthProfileStore();
updater(store);
saveAuthProfileStore(store);
return store;
} finally {
release();
}
} catch {
// Fallback: unlocked update (better than losing the write entirely)
const store = loadAuthProfileStore();
updater(store);
saveAuthProfileStore(store);
return store;
}
}

View file

@ -7,6 +7,7 @@
/** Reason for an auth profile failure, determines cooldown behavior */
export type AuthProfileFailureReason =
| "auth"
| "format"
| "rate_limit"
| "billing"
| "timeout"