diff --git a/package.json b/package.json index 68fa350c..d0952bbf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a71a43c2..5d0fc218 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/agent/auth-profiles/index.ts b/src/agent/auth-profiles/index.ts index bd87cbb7..9458ea55 100644 --- a/src/agent/auth-profiles/index.ts +++ b/src/agent/auth-profiles/index.ts @@ -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 { diff --git a/src/agent/auth-profiles/order.test.ts b/src/agent/auth-profiles/order.test.ts index dd729719..905724d8 100644 --- a/src/agent/auth-profiles/order.test.ts +++ b/src/agent/auth-profiles/order.test.ts @@ -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 = {}; +let _order: Record = {}; + // Mock credentialManager vi.mock("../credentials.js", () => { - let _profiles: Record = {}; - let _order: Record = {}; - 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) { - _profiles = profiles; - }, - __setOrder(order: Record) { - _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) => void; - __setOrder: (o: Record) => 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) { + _profiles = profiles; +} +function setOrder(order: Record) { + _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); + }); }); diff --git a/src/agent/auth-profiles/order.ts b/src/agent/auth-profiles/order.ts index 16055e81..09c99a32 100644 --- a/src/agent/auth-profiles/order.ts +++ b/src/agent/auth-profiles/order.ts @@ -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; } diff --git a/src/agent/auth-profiles/store.test.ts b/src/agent/auth-profiles/store.test.ts new file mode 100644 index 00000000..4e2d78f5 --- /dev/null +++ b/src/agent/auth-profiles/store.test.ts @@ -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); + }); +}); diff --git a/src/agent/auth-profiles/store.ts b/src/agent/auth-profiles/store.ts index 3bb0480f..1d0ab0ca 100644 --- a/src/agent/auth-profiles/store.ts +++ b/src/agent/auth-profiles/store.ts @@ -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; @@ -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; + } } diff --git a/src/agent/auth-profiles/types.ts b/src/agent/auth-profiles/types.ts index e4e34eb2..036341be 100644 --- a/src/agent/auth-profiles/types.ts +++ b/src/agent/auth-profiles/types.ts @@ -7,6 +7,7 @@ /** Reason for an auth profile failure, determines cooldown behavior */ export type AuthProfileFailureReason = | "auth" + | "format" | "rate_limit" | "billing" | "timeout"