diff --git a/.changeset/calm-runtimes-align.md b/.changeset/calm-runtimes-align.md new file mode 100644 index 00000000..9c7db19f --- /dev/null +++ b/.changeset/calm-runtimes-align.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Migrate session daemon authentication and model discovery to Pi's public ModelRuntime API. Pi 0.80.10 is the minimum supported version and the exact development/CI baseline; newer Pi releases are permitted by the peer dependency range but may not yet be verified. diff --git a/package-lock.json b/package-lock.json index 446d3e81..7b0a7234 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,9 +42,9 @@ }, "devDependencies": { "@changesets/cli": "^2.31.0", - "@earendil-works/pi-agent-core": "^0.80.6", - "@earendil-works/pi-ai": "^0.80.6", - "@earendil-works/pi-coding-agent": "^0.80.6", + "@earendil-works/pi-agent-core": "0.80.10", + "@earendil-works/pi-ai": "0.80.10", + "@earendil-works/pi-coding-agent": "0.80.10", "@eslint/js": "^10.0.1", "@types/node": "^24.13.3", "@types/ws": "^8.18.1", @@ -58,12 +58,12 @@ "vitest": "^4.1.10" }, "engines": { - "node": ">=22" + "node": ">=22.19.0" }, "peerDependencies": { - "@earendil-works/pi-agent-core": ">=0.80.0 <1", - "@earendil-works/pi-ai": ">=0.80.0 <1", - "@earendil-works/pi-coding-agent": ">=0.80.0 <1" + "@earendil-works/pi-agent-core": ">=0.80.10", + "@earendil-works/pi-ai": ">=0.80.10", + "@earendil-works/pi-coding-agent": ">=0.80.10" } }, "node_modules/@anthropic-ai/sdk": { @@ -167,18 +167,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.975.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.1.tgz", - "integrity": "sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==", + "version": "3.975.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz", + "integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.0", - "@aws-sdk/xml-builder": "^3.972.34", + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.36", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.29.2", - "@smithy/signature-v4": "^5.6.3", - "@smithy/types": "^4.16.0", + "@smithy/core": "^3.29.4", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -187,16 +187,16 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.57", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.57.tgz", - "integrity": "sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==", + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.59.tgz", + "integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -204,18 +204,18 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.59", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.59.tgz", - "integrity": "sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==", + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.61.tgz", + "integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/fetch-http-handler": "^5.6.4", - "@smithy/node-http-handler": "^4.9.4", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/node-http-handler": "^4.9.6", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -223,14 +223,14 @@ } }, "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { - "version": "4.9.4", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.4.tgz", - "integrity": "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==", + "version": "4.9.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", + "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -238,24 +238,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.1.tgz", - "integrity": "sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==", + "version": "3.973.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.4.tgz", + "integrity": "sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/credential-provider-env": "^3.972.57", - "@aws-sdk/credential-provider-http": "^3.972.59", - "@aws-sdk/credential-provider-login": "^3.972.63", - "@aws-sdk/credential-provider-process": "^3.972.57", - "@aws-sdk/credential-provider-sso": "^3.973.1", - "@aws-sdk/credential-provider-web-identity": "^3.972.63", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/credential-provider-imds": "^4.4.7", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/credential-provider-env": "^3.972.59", + "@aws-sdk/credential-provider-http": "^3.972.61", + "@aws-sdk/credential-provider-login": "^3.972.66", + "@aws-sdk/credential-provider-process": "^3.972.59", + "@aws-sdk/credential-provider-sso": "^3.973.3", + "@aws-sdk/credential-provider-web-identity": "^3.972.65", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -263,17 +263,17 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.63", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.63.tgz", - "integrity": "sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==", + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.66.tgz", + "integrity": "sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -281,22 +281,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.66", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.66.tgz", - "integrity": "sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==", + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.70.tgz", + "integrity": "sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.57", - "@aws-sdk/credential-provider-http": "^3.972.59", - "@aws-sdk/credential-provider-ini": "^3.973.1", - "@aws-sdk/credential-provider-process": "^3.972.57", - "@aws-sdk/credential-provider-sso": "^3.973.1", - "@aws-sdk/credential-provider-web-identity": "^3.972.63", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/credential-provider-imds": "^4.4.7", - "@smithy/types": "^4.16.0", + "@aws-sdk/credential-provider-env": "^3.972.59", + "@aws-sdk/credential-provider-http": "^3.972.61", + "@aws-sdk/credential-provider-ini": "^3.973.4", + "@aws-sdk/credential-provider-process": "^3.972.59", + "@aws-sdk/credential-provider-sso": "^3.973.3", + "@aws-sdk/credential-provider-web-identity": "^3.972.65", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -304,16 +304,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.57", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.57.tgz", - "integrity": "sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==", + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.59.tgz", + "integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -321,18 +321,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.1.tgz", - "integrity": "sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==", + "version": "3.973.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.3.tgz", + "integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/token-providers": "3.1083.0", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/token-providers": "3.1088.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -340,17 +340,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1083.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1083.0.tgz", - "integrity": "sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==", + "version": "3.1088.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1088.0.tgz", + "integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -358,17 +358,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.63", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.63.tgz", - "integrity": "sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==", + "version": "3.972.65", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.65.tgz", + "integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -376,15 +376,15 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.26.tgz", - "integrity": "sha512-RE1fu7Nn05vG0EUJM+8Sde2GFecC658WGaC/asPzLF6K4x3H5ZaDBcQtHRE67Gdgb1VZpyUUliYejHFK1qt0Uw==", + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.29.tgz", + "integrity": "sha512-t3tKQRTVXsI2QNPE3CaNjHl0wRO9Xi3acZkAyti2RQsiFmZ9Gi0kArX2ighlRJ1BtDVuul413gThAgzyTfgmWA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -392,15 +392,15 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.22.tgz", - "integrity": "sha512-jtkgmhevnpzC1WeS+Y/sgymYbaQ6qg7pVOUl5cUT/8MiLptqrtnXQlNV80m+j2WIx5MIL7kVHIZNxxcK2tfUEQ==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.24.tgz", + "integrity": "sha512-oykin4mDWxNOuYQ7SF1cHzgYeuFEkF4cdRwgvjFFbIklkx09qIFBiOgsORafG9sXZFO3TayMmQuAQYgADXhI8w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -408,18 +408,18 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.39.tgz", - "integrity": "sha512-CS1spxRSezmTmI3PD+3Xrnp6KryTSEz0EefA8u6uGd0s2I0uXseWHALDI/03Wi0IUczXNWo2QrZEaHDuJNby/Q==", + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.41.tgz", + "integrity": "sha512-LSbGvvYmjc4Br9BPYI2dTLnIclmrSiQbahkP4D6nRGVEv4qsCZ8csVuKBPVEEFCVD+EEngGh8ROls6XpumtwMg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/fetch-http-handler": "^5.6.4", - "@smithy/signature-v4": "^5.6.3", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -427,19 +427,19 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.31.tgz", - "integrity": "sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==", + "version": "3.997.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.33.tgz", + "integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/signature-v4-multi-region": "^3.996.39", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/fetch-http-handler": "^5.6.4", - "@smithy/node-http-handler": "^4.9.4", - "@smithy/types": "^4.16.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/node-http-handler": "^4.9.6", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -447,14 +447,14 @@ } }, "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { - "version": "4.9.4", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.4.tgz", - "integrity": "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==", + "version": "4.9.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", + "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -462,15 +462,15 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.39.tgz", - "integrity": "sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==", + "version": "3.996.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz", + "integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.0", - "@smithy/signature-v4": "^5.6.3", - "@smithy/types": "^4.16.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -496,13 +496,13 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -523,13 +523,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.34.tgz", - "integrity": "sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz", + "integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -984,13 +984,13 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz", - "integrity": "sha512-Lvn89ko42h5ETUb6Z0Ku6ldskEqXaTdQBYvSa0+7bdG9V6rUEpXptv5e0OVZ1HDcvi8s6/2lGCQWsxKX+DFHNw==", + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz", + "integrity": "sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1007,9 +1007,9 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-ai": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz", - "integrity": "sha512-7xfLk8sANBp+bpPEbjoOZTbPxsa+++b1JXAoSJsNa3vbs9AHHEclmvg54XLQcxH+fuwaeti/g2jeIfJ+mVYLpA==", + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", + "integrity": "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==", "dev": true, "license": "MIT", "dependencies": { @@ -1040,15 +1040,16 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.6.tgz", - "integrity": "sha512-vcfD6tOk402isLl3Cm/qbn2O10TvgroMp1+/fEGM24ZdvETFCdOYv5VZ7m59EI5fPsjfSJh+CpQ5bhBrhfOg7g==", + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.10.tgz", + "integrity": "sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==", "dev": true, + "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.6", - "@earendil-works/pi-ai": "^0.80.6", - "@earendil-works/pi-tui": "^0.80.6", + "@earendil-works/pi-agent-core": "^0.80.10", + "@earendil-works/pi-ai": "^0.80.10", + "@earendil-works/pi-tui": "^0.80.10", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -1538,12 +1539,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz", + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1553,8 +1554,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz", + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -1578,8 +1579,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz", + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -2076,6 +2077,16 @@ "node": ">=14.0.0" } }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2894,6 +2905,13 @@ "node": ">=22.19.0" } }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -5327,13 +5345,13 @@ "license": "MIT" }, "node_modules/@smithy/core": { - "version": "3.29.2", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.2.tgz", - "integrity": "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==", + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.5.tgz", + "integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -5341,14 +5359,14 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.7.tgz", - "integrity": "sha512-UEMLOoA0Fl4uYBxh6l0uN0H6EJe/A89OGeDNTteQeXpJ20BcpfIr4wlCY9pel1jEAUHAxaYwuqrYlrKdXE1GKQ==", + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.10.tgz", + "integrity": "sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -5356,14 +5374,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.4.tgz", - "integrity": "sha512-psnst7NZWdAEvJvyW8YZEE7xNVMyLrQFfHtyrVFrxNyy+dKWkQ+rqC6oI5ZhxThpUy9RSfEshgm34zqbOxzsRw==", + "version": "5.6.7", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.7.tgz", + "integrity": "sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -5399,14 +5417,14 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.3.tgz", - "integrity": "sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw==", + "version": "5.6.6", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.6.tgz", + "integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -5414,9 +5432,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.16.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.0.tgz", - "integrity": "sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index e84997d4..1fda00a3 100644 --- a/package.json +++ b/package.json @@ -82,9 +82,9 @@ }, "devDependencies": { "@changesets/cli": "^2.31.0", - "@earendil-works/pi-agent-core": "^0.80.6", - "@earendil-works/pi-ai": "^0.80.6", - "@earendil-works/pi-coding-agent": "^0.80.6", + "@earendil-works/pi-agent-core": "0.80.10", + "@earendil-works/pi-ai": "0.80.10", + "@earendil-works/pi-coding-agent": "0.80.10", "@eslint/js": "^10.0.1", "@types/node": "^24.13.3", "@types/ws": "^8.18.1", @@ -101,7 +101,7 @@ "access": "public" }, "engines": { - "node": ">=22" + "node": ">=22.19.0" }, "repository": { "type": "git", @@ -113,9 +113,9 @@ "homepage": "https://pi-web.dev/", "packageManager": "npm@11.11.0", "peerDependencies": { - "@earendil-works/pi-agent-core": ">=0.80.0 <1", - "@earendil-works/pi-ai": ">=0.80.0 <1", - "@earendil-works/pi-coding-agent": ">=0.80.0 <1" + "@earendil-works/pi-agent-core": ">=0.80.10", + "@earendil-works/pi-ai": ">=0.80.10", + "@earendil-works/pi-coding-agent": ">=0.80.10" }, "keywords": [ "pi-package", diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 5b687a74..0f18ef6c 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,8 +1,29 @@ import { describe, expect, it } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; +import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { + it("preserves OAuth prompt, selection, and device-code metadata", () => { + expect(parseOAuthFlowState({ + flowId: "flow-1", + providerId: "provider", + providerName: "Provider", + status: "running", + auth: { + url: "https://example.test/device", + instructions: "Enter code", + deviceCode: { userCode: "ABCD", intervalSeconds: 5, expiresInSeconds: 900 }, + }, + prompt: { requestId: "prompt-1", message: "Secret", kind: "secret", placeholder: "token" }, + select: { requestId: "select-1", message: "Choose", options: [{ value: "work", label: "Work", description: "Company account" }] }, + progress: [], + })).toMatchObject({ + auth: { deviceCode: { userCode: "ABCD", intervalSeconds: 5, expiresInSeconds: 900 } }, + prompt: { kind: "secret" }, + select: { options: [{ value: "work", description: "Company account" }] }, + }); + }); + it("parses PI WEB config responses", () => { expect(parsePiWebConfigResponse({ path: "/tmp/config.json", diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index c2508a60..2583df3f 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -367,15 +367,30 @@ function parseOAuthFlowStatus(value: unknown): OAuthFlowState["status"] { function optionalOAuthAuth(value: unknown): OAuthFlowState["auth"] | undefined { if (value === undefined) return undefined; const record = requireRecord(value); - return { url: requireString(record, "url"), ...optionalField("instructions", optionalString(record, "instructions")) }; + const deviceCode = optionalOAuthDeviceCode(record["deviceCode"]); + return { + url: requireString(record, "url"), + ...optionalField("instructions", optionalString(record, "instructions")), + ...optionalField("deviceCode", deviceCode), + }; +} + +function optionalOAuthDeviceCode(value: unknown): NonNullable["deviceCode"] | undefined { + if (value === undefined) return undefined; + const record = requireRecord(value); + return { + userCode: requireString(record, "userCode"), + ...optionalField("intervalSeconds", optionalNumber(record, "intervalSeconds")), + ...optionalField("expiresInSeconds", optionalNumber(record, "expiresInSeconds")), + }; } function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefined { if (value === undefined) return undefined; const record = requireRecord(value); const kind = requireString(record, "kind"); - if (kind !== "prompt" && kind !== "manual") throw new Error("Invalid OAuth prompt kind"); - return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), kind, ...optionalField("placeholder", optionalString(record, "placeholder")), ...(record["allowEmpty"] === true ? { allowEmpty: true } : {}) }; + if (kind !== "text" && kind !== "secret" && kind !== "manual-code") throw new Error("Invalid OAuth prompt kind"); + return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), kind, ...optionalField("placeholder", optionalString(record, "placeholder")) }; } function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefined { diff --git a/src/client/src/components/AuthDialog.test.ts b/src/client/src/components/AuthDialog.test.ts new file mode 100644 index 00000000..1be0ce8a --- /dev/null +++ b/src/client/src/components/AuthDialog.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { oauthPromptInputType } from "./AuthDialog"; + +describe("oauthPromptInputType", () => { + it("renders secret prompts as password inputs", () => { + expect(oauthPromptInputType("secret")).toBe("password"); + expect(oauthPromptInputType("text")).toBe("text"); + expect(oauthPromptInputType("manual-code")).toBe("text"); + }); +}); diff --git a/src/client/src/components/AuthDialog.ts b/src/client/src/components/AuthDialog.ts index 7225ee26..835409f6 100644 --- a/src/client/src/components/AuthDialog.ts +++ b/src/client/src/components/AuthDialog.ts @@ -96,12 +96,17 @@ export class AuthDialog extends LitElement { ${flow.progress.length > 0 ? html`` : null} ${prompt !== undefined ? html` - { if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}> + { if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}>
` : null} ${select !== undefined ? html`

${select.message}

-
${select.options.map((option) => html``)}
+
${select.options.map((option) => html` + + `)}
` : null} ${state.error !== undefined && state.error !== "" ? html`
${state.error}
` : null} ${flow.status === "error" || flow.status === "cancelled" ? html`
${flow.error ?? flow.status}
` : null} @@ -158,6 +163,8 @@ export class AuthDialog extends LitElement { .error-text { color: var(--pi-danger); } .progress { margin: 0; padding-left: 18px; color: var(--pi-muted); } .inline-options { display: grid; gap: 8px; } + .inline-options button { display: grid; gap: 2px; text-align: left; } + .inline-options small { color: var(--pi-muted); } em { color: var(--pi-success); font-style: normal; font-size: 12px; } `]; } @@ -185,3 +192,7 @@ function statusLabel(provider: AuthProviderOption): string { } } + +export function oauthPromptInputType(kind: "text" | "secret" | "manual-code"): "text" | "password" { + return kind === "secret" ? "password" : "text"; +} diff --git a/src/client/src/controllers/authController.test.ts b/src/client/src/controllers/authController.test.ts index e3441742..692b785b 100644 --- a/src/client/src/controllers/authController.test.ts +++ b/src/client/src/controllers/authController.test.ts @@ -32,10 +32,10 @@ describe("AuthController", () => { }); it("keeps OAuth prompt input and submit state across poll refreshes for the same request", async () => { - const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); + const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual-code" } }); const { controller, getState } = createController( { authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } }, - { respondOAuthFlow: () => Promise.resolve(oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" }, progress: ["Still waiting"] })) }, + { respondOAuthFlow: () => Promise.resolve(oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual-code" }, progress: ["Still waiting"] })) }, ); await controller.respondOAuth(); @@ -44,7 +44,7 @@ describe("AuthController", () => { }); it("resets OAuth prompt input and submit state when the request id changes", async () => { - const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); + const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual-code" } }); const { controller, getState } = createController( { authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } }, { @@ -66,7 +66,7 @@ describe("AuthController", () => { }); it("closes the OAuth dialog and refreshes selected session status when the flow completes", async () => { - const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); + const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual-code" } }); const session = sessionInfo("session-1"); const refreshedStatus = sessionStatus(session.id); const respondCalls: { flowId: string; requestId: string; value: string; machineId: string | undefined }[] = []; @@ -97,7 +97,7 @@ describe("AuthController", () => { }); it("leaves the OAuth dialog ready to retry if responding fails", async () => { - const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); + const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual-code" } }); const { controller, getState } = createController( { authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } }, { respondOAuthFlow: () => Promise.reject(new Error("Invalid callback")) }, @@ -115,7 +115,7 @@ describe("AuthController", () => { }); it("cancels the active OAuth flow and closes the dialog even when cancellation fails", async () => { - const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); + const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual-code" } }); const cancelCalls: { flowId: string; machineId: string | undefined }[] = []; const { controller, getState } = createController( { authDialog: { step: "oauth", flow } }, diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index de7e0f64..a78e402b 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -6,7 +6,7 @@ import fastifyWebsocket from "@fastify/websocket"; import { WorkspaceActivityService } from "./activity/workspaceActivityService.js"; import { registerWorkspaceActivityRoutes } from "./activity/workspaceActivityRoutes.js"; import { SessionEventHub } from "./realtime/sessionEventHub.js"; -import { AuthService } from "./sessions/authService.js"; +import { AuthService, createModelRuntimeForAgentDir } from "./sessions/authService.js"; import { registerAuthRoutes } from "./sessions/authRoutes.js"; import { PiSessionService } from "./sessions/piSessionService.js"; import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js"; @@ -36,15 +36,16 @@ await app.register(fastifyWebsocket); await runSessionDaemonStartup({ logger: app.log, - createRuntime() { + async createRuntime() { const eventHub = new SessionEventHub(); const workspaceActivity = new WorkspaceActivityService(eventHub); - const auth = new AuthService({ agentDir: activeAgentProfile.dir }); + const modelRuntime = await createModelRuntimeForAgentDir(activeAgentProfile.dir); + const auth = new AuthService({ modelRuntime }); const spawnTargets = config.spawnSessions ? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() }) : undefined; const sessions = new PiSessionService(eventHub, { - modelRegistry: auth.modelRegistry, + modelRuntime, agentDir: activeAgentProfile.dir, workspaceActivity, logger: app.log, diff --git a/src/server/sessiond/sessionDaemonStartup.ts b/src/server/sessiond/sessionDaemonStartup.ts index b0c645d5..ef3f1973 100644 --- a/src/server/sessiond/sessionDaemonStartup.ts +++ b/src/server/sessiond/sessionDaemonStartup.ts @@ -12,7 +12,7 @@ export interface SessionDaemonStartupLogger { export interface SessionDaemonStartupSteps { logger: SessionDaemonStartupLogger; - createRuntime(): Runtime; + createRuntime(): Runtime | Promise; registerRoutes(runtime: Runtime): void; listen(runtime: Runtime): Promise; migrateArchive?: () => Promise; @@ -36,7 +36,7 @@ export async function runSessionDaemonStartup( ); } - const runtime = steps.createRuntime(); + const runtime = await steps.createRuntime(); steps.registerRoutes(runtime); await steps.listen(runtime); return runtime; diff --git a/src/server/sessions/authProviderOptions.test.ts b/src/server/sessions/authProviderOptions.test.ts index e6fa31e7..7361aadb 100644 --- a/src/server/sessions/authProviderOptions.test.ts +++ b/src/server/sessions/authProviderOptions.test.ts @@ -1,52 +1,45 @@ import { describe, expect, it } from "vitest"; -import { getLoginProviderOptions, getLogoutProviderOptions, isApiKeyLoginProvider, type AuthProviderModelRegistry } from "./authProviderOptions"; +import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderModelRuntime } from "./authProviderOptions.js"; -function registry(): AuthProviderModelRegistry { - const credentials = new Map(); - credentials.set("openai", { type: "api_key" }); +function runtime(): AuthProviderModelRuntime { + const providers = [ + { id: "anthropic", name: "Anthropic", auth: { oauth: {}, apiKey: { login: () => undefined } } }, + { id: "openai", name: "OpenAI", auth: { apiKey: { login: () => undefined } } }, + { id: "openai-codex", name: "ChatGPT Plus/Pro", auth: { oauth: {} } }, + { id: "ambient", name: "Ambient", auth: { apiKey: {} } }, + ]; return { - authStorage: { - getOAuthProviders: () => [ - { id: "anthropic", name: "Anthropic (Claude Pro/Max)" }, - { id: "github-copilot", name: "GitHub Copilot" }, - { id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)" }, - ], - list: () => Array.from(credentials.keys()), - get: (provider: string) => credentials.get(provider), - }, - getAll: () => [ - { provider: "anthropic" }, - { provider: "openai" }, - { provider: "openai-codex" }, - { provider: "github-copilot" }, - { provider: "custom" }, - ], - getProviderDisplayName: (provider: string) => ({ anthropic: "Anthropic", openai: "OpenAI", custom: "Custom" }[provider] ?? provider), - getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }), + getProviders: () => providers, + getProvider: (providerId) => providers.find((provider) => provider.id === providerId), + listCredentials: () => Promise.resolve([{ providerId: "openai", type: "api_key" }]), + getProviderAuthStatus: (providerId) => providerId === "openai" + ? { configured: true, source: "stored" } + : { configured: false }, }; } describe("auth provider options", () => { - it("keeps OAuth-only providers out of API key login options", () => { - expect(isApiKeyLoginProvider("openai-codex", new Set(["openai-codex"]))).toBe(false); - expect(isApiKeyLoginProvider("github-copilot", new Set(["github-copilot"]))).toBe(false); - expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true); - }); - - it("builds login options for OAuth-only, dual-auth, and API-key providers", () => { - const options = getLoginProviderOptions(registry()); + it("builds login options from provider-owned auth capabilities", () => { + const options = getLoginProviderOptions(runtime()); expect(options).toEqual(expect.arrayContaining([ expect.objectContaining({ id: "anthropic", authType: "oauth" }), expect.objectContaining({ id: "anthropic", authType: "api_key" }), expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }), expect.objectContaining({ id: "openai-codex", authType: "oauth" }), ])); - expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })])); + expect(options).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "openai-codex", authType: "api_key" }), + expect.objectContaining({ id: "ambient", authType: "api_key" }), + ])); + }); + + it("filters login options by auth type", () => { + expect(getLoginProviderOptions(runtime(), "oauth").every((option) => option.authType === "oauth")).toBe(true); }); - it("returns only currently stored credentials for logout", () => { - expect(getLogoutProviderOptions(registry())).toEqual([ - expect.objectContaining({ id: "openai", authType: "api_key" }), + it("returns only currently stored credentials for logout", async () => { + await expect(getLogoutProviderOptions(runtime())).resolves.toEqual([ + expect.objectContaining({ id: "openai", name: "OpenAI", authType: "api_key" }), ]); }); }); diff --git a/src/server/sessions/authProviderOptions.ts b/src/server/sessions/authProviderOptions.ts index 58211d8c..efc945f6 100644 --- a/src/server/sessions/authProviderOptions.ts +++ b/src/server/sessions/authProviderOptions.ts @@ -1,64 +1,58 @@ import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../shared/apiTypes.js"; -const OAUTH_ONLY_PROVIDERS = new Set(["github-copilot", "openai-codex"]); - -export interface AuthProviderModelRegistry { - authStorage: { - getOAuthProviders(): { id: string; name: string }[]; - list(): string[]; - get(provider: string): { type: AuthType } | undefined; +interface AuthProviderDefinition { + id: string; + name: string; + auth: { + apiKey?: { login?: unknown }; + oauth?: unknown; }; - getAll(): { provider: string }[]; - getProviderDisplayName(provider: string): string; - getProviderAuthStatus(provider: string): AuthProviderStatus; } -export function getLoginProviderOptions(modelRegistry: AuthProviderModelRegistry, authType?: AuthType): AuthProviderOption[] { - const oauthProviders = modelRegistry.authStorage.getOAuthProviders(); - const oauthProviderIds = new Set(oauthProviders.map((provider) => provider.id)); - const options: AuthProviderOption[] = oauthProviders.map((provider) => ({ - id: provider.id, - name: provider.name, - authType: "oauth", - status: modelRegistry.getProviderAuthStatus(provider.id), - })); +export interface AuthProviderModelRuntime { + getProviders(): readonly AuthProviderDefinition[]; + getProvider(providerId: string): AuthProviderDefinition | undefined; + listCredentials(): Promise; + getProviderAuthStatus(providerId: string): AuthProviderStatus; +} - const modelProviders = new Set(modelRegistry.getAll().map((model) => model.provider)); - for (const providerId of modelProviders) { - if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) continue; - options.push({ - id: providerId, - name: modelRegistry.getProviderDisplayName(providerId), - authType: "api_key", - status: modelRegistry.getProviderAuthStatus(providerId), - }); +export function getLoginProviderOptions(modelRuntime: AuthProviderModelRuntime, authType?: AuthType): AuthProviderOption[] { + const options: AuthProviderOption[] = []; + for (const provider of modelRuntime.getProviders()) { + if (provider.auth.oauth !== undefined) { + options.push({ + id: provider.id, + name: provider.name, + authType: "oauth", + status: modelRuntime.getProviderAuthStatus(provider.id), + }); + } + if (provider.auth.apiKey?.login !== undefined) { + options.push({ + id: provider.id, + name: provider.name, + authType: "api_key", + status: modelRuntime.getProviderAuthStatus(provider.id), + }); + } } - return filterAndSort(options, authType); } -export function getLogoutProviderOptions(modelRegistry: AuthProviderModelRegistry): AuthProviderOption[] { +export async function getLogoutProviderOptions(modelRuntime: AuthProviderModelRuntime): Promise { const options: AuthProviderOption[] = []; - for (const providerId of modelRegistry.authStorage.list()) { - const credential = modelRegistry.authStorage.get(providerId); - if (credential === undefined) continue; + for (const credential of await modelRuntime.listCredentials()) { + const provider = modelRuntime.getProvider(credential.providerId); options.push({ - id: providerId, - name: modelRegistry.getProviderDisplayName(providerId), + id: credential.providerId, + name: provider?.name ?? credential.providerId, authType: credential.type, - status: modelRegistry.getProviderAuthStatus(providerId), + status: modelRuntime.getProviderAuthStatus(credential.providerId), }); } return filterAndSort(options); } -export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet): boolean { - if (OAUTH_ONLY_PROVIDERS.has(providerId)) return false; - if (providerId === "anthropic") return true; - if (oauthProviderIds.has(providerId)) return false; - return true; -} - function filterAndSort(options: AuthProviderOption[], authType?: AuthType): AuthProviderOption[] { const filtered = authType === undefined ? options : options.filter((option) => option.authType === authType); return filtered.sort((a, b) => a.name.localeCompare(b.name) || a.authType.localeCompare(b.authType) || a.id.localeCompare(b.id)); diff --git a/src/server/sessions/authRoutes.ts b/src/server/sessions/authRoutes.ts index 1f000f3d..7658e239 100644 --- a/src/server/sessions/authRoutes.ts +++ b/src/server/sessions/authRoutes.ts @@ -4,7 +4,7 @@ import type { AuthService } from "./authService.js"; export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, prefix = ""): void { app.get<{ Querystring: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" } }>(`${prefix}/auth/providers`, async (request, reply) => { try { - return auth.authProviders(request.query.mode ?? "login", request.query.authType); + return await auth.authProviders(request.query.mode ?? "login", request.query.authType); } catch (error) { return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); } @@ -12,7 +12,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref app.post<{ Body: { providerId: string; key: string } }>(`${prefix}/auth/api-key`, async (request, reply) => { try { - return auth.saveApiKey(request.body.providerId, request.body.key); + return await auth.saveApiKey(request.body.providerId, request.body.key); } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } @@ -20,7 +20,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref app.post<{ Body: { providerId: string } }>(`${prefix}/auth/logout`, async (request, reply) => { try { - return auth.logoutProvider(request.body.providerId); + return await auth.logoutProvider(request.body.providerId); } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index 685295fe..b220d732 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -1,10 +1,11 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AuthInteraction, AuthPrompt, Credential, CredentialStore } from "@earendil-works/pi-ai"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { afterEach, describe, expect, it } from "vitest"; import type { OAuthFlowState } from "../../shared/apiTypes.js"; -import { AuthService, type AuthChange } from "./authService.js"; +import { AuthService, createModelRuntimeForAgentDir, type AuthChange, type AuthModelRuntime } from "./authService.js"; import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; const tempDirs: string[] = []; @@ -14,85 +15,129 @@ afterEach(async () => { }); describe("AuthService", () => { - it("saves API keys and emits a global auth change", () => { - const { auth, authStorage, changes } = createAuthService(); + it("saves API keys and emits a global auth change", async () => { + const { auth, credentials, changes } = await createAuthService(); - expect(auth.saveApiKey("anthropic", "sk-test")).toEqual({ accepted: true }); + await expect(auth.saveApiKey("anthropic", "sk-test")).resolves.toEqual({ accepted: true }); - expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "sk-test" }); + await expect(credentials.read("anthropic")).resolves.toEqual({ type: "api_key", key: "sk-test" }); expect(changes).toEqual([{}]); auth.dispose(); }); - it("logs out providers and emits the removed provider id", () => { - const { auth, authStorage, changes } = createAuthService({ anthropic: { type: "api_key", key: "sk-test" } }); + it("logs out providers and emits the removed provider id", async () => { + const { auth, credentials, changes } = await createAuthService({ anthropic: { type: "api_key", key: "sk-test" } }); - expect(auth.logoutProvider("anthropic")).toEqual({ accepted: true }); + await expect(auth.logoutProvider("anthropic")).resolves.toEqual({ accepted: true }); - expect(authStorage.get("anthropic")).toBeUndefined(); + await expect(credentials.read("anthropic")).resolves.toBeUndefined(); expect(changes).toEqual([{ removedProviderId: "anthropic" }]); auth.dispose(); }); - it("rejects blank API keys", () => { - const { auth, changes } = createAuthService(); + it("rejects blank API keys", async () => { + const { auth, changes } = await createAuthService(); - expect(() => { auth.saveApiKey("anthropic", " "); }).toThrow("API key is required"); + await expect(auth.saveApiKey("anthropic", " ")).rejects.toThrow("API key is required"); expect(changes).toEqual([]); auth.dispose(); }); it("stores credentials in the configured agent directory", async () => { const agentDir = await tempAgentDir(); - const auth = new AuthService({ agentDir }); + const modelRuntime = await createModelRuntimeForAgentDir(agentDir, false); + const auth = new AuthService({ modelRuntime }); - auth.saveApiKey("anthropic", "sk-test"); + await auth.saveApiKey("anthropic", "sk-test"); await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-test"); auth.dispose(); }); - it("refreshes auth state after OAuth login completes", () => { - const authStorage = AuthStorage.inMemory(); - const modelRegistry = ModelRegistry.create(authStorage); + + it("awaits auth-change propagation before completing API-key login", async () => { + const runtime = new PromptingAuthRuntime([{ type: "secret", message: "API key" }]); + const auth = new AuthService({ modelRuntime: runtime }); + const propagation = deferred(); + auth.subscribe(() => propagation.promise); + + let settled = false; + const saving = auth.saveApiKey("test-provider", "sk-test").finally(() => { settled = true; }); + await flushMicrotasks(); + + expect(settled).toBe(false); + propagation.resolve(undefined); + await expect(saving).resolves.toEqual({ accepted: true }); + auth.dispose(); + }); + + it.each([ + { prompt: { type: "text", message: "Account" } as const, label: "text" }, + { prompt: { type: "manual_code", message: "Code" } as const, label: "manual-code" }, + { prompt: { type: "select", message: "Region", options: [{ id: "us", label: "US" }] } as const, label: "select" }, + ])("rejects a first $label prompt in the API-key endpoint", async ({ prompt }) => { + const runtime = new PromptingAuthRuntime([prompt]); + const auth = new AuthService({ modelRuntime: runtime }); + + await expect(auth.saveApiKey("test-provider", "sk-test")).rejects.toThrow("requires interactive setup"); + expect(runtime.completedLogins).toBe(0); + auth.dispose(); + }); + + it("rejects a second API-key prompt", async () => { + const runtime = new PromptingAuthRuntime([ + { type: "secret", message: "API key" }, + { type: "text", message: "Account" }, + ]); + const auth = new AuthService({ modelRuntime: runtime }); + + await expect(auth.saveApiKey("test-provider", "sk-test")).rejects.toThrow("requires interactive setup"); + expect(runtime.completedLogins).toBe(0); + auth.dispose(); + }); + + it("rejects an aborted API-key prompt", async () => { + const abort = new AbortController(); + abort.abort(); + const runtime = new PromptingAuthRuntime([{ type: "secret", message: "API key", signal: abort.signal }]); + const auth = new AuthService({ modelRuntime: runtime }); + + await expect(auth.saveApiKey("test-provider", "sk-test")).rejects.toThrow("Login cancelled"); + expect(runtime.completedLogins).toBe(0); + auth.dispose(); + }); + + it("emits an auth change after OAuth login completes", async () => { + const modelRuntime = await ModelRuntime.create({ credentials: new MemoryCredentialStore(), modelsPath: null, allowModelNetwork: false }); const authFlows = new CapturingOAuthLoginFlowService(); - const auth = new AuthService({ modelRegistry, authFlows }); + const auth = new AuthService({ modelRuntime, authFlows }); const changes: AuthChange[] = []; auth.subscribe((change) => { changes.push(change); }); - const reload = vi.spyOn(authStorage, "reload"); - const refresh = vi.spyOn(modelRegistry, "refresh"); - const provider = authStorage.getOAuthProviders().find((option) => option.id === "anthropic"); - if (provider === undefined) throw new Error("Expected built-in OAuth provider"); - expect(auth.startOAuthLogin(provider.id)).toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" }); + expect(auth.startOAuthLogin("anthropic")).toMatchObject({ providerId: "anthropic", status: "running" }); const startOptions = authFlows.startCalls.at(0); if (startOptions === undefined) throw new Error("Expected OAuth flow to start"); - expect(startOptions.providerId).toBe(provider.id); - expect(startOptions.providerName).toBe(provider.name); - expect(startOptions.authStorage).toBe(authStorage); + expect(startOptions.providerId).toBe("anthropic"); + expect(typeof startOptions.login).toBe("function"); expect(changes).toEqual([]); - reload.mockClear(); - refresh.mockClear(); if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback"); - startOptions.onComplete(); + await startOptions.onComplete(); - expect(reload).toHaveBeenCalledOnce(); - expect(refresh).toHaveBeenCalledOnce(); expect(changes).toEqual([{}]); auth.dispose(); expect(authFlows.disposed).toBe(true); }); }); -function createAuthService(data: Parameters[0] = {}) { - const authStorage = AuthStorage.inMemory(data); - const modelRegistry = ModelRegistry.create(authStorage); - const auth = new AuthService({ modelRegistry }); +async function createAuthService(data: Record = {}) { + const credentials = new MemoryCredentialStore(data); + const modelRuntime = await ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false }); + const auth = new AuthService({ modelRuntime }); const changes: AuthChange[] = []; auth.subscribe((change) => { changes.push(change); }); - return { auth, authStorage, changes }; + return { auth, credentials, changes }; } async function tempAgentDir(): Promise { @@ -101,6 +146,33 @@ async function tempAgentDir(): Promise { return dir; } +class MemoryCredentialStore implements CredentialStore { + private readonly credentials = new Map(); + + constructor(data: Record = {}) { + for (const [providerId, credential] of Object.entries(data)) this.credentials.set(providerId, credential); + } + + read(providerId: string): Promise { + return Promise.resolve(this.credentials.get(providerId)); + } + + list(): Promise { + return Promise.resolve([...this.credentials].map(([providerId, credential]) => ({ providerId, type: credential.type }))); + } + + async modify(providerId: string, fn: (current: Credential | undefined) => Promise): Promise { + const credential = await fn(this.credentials.get(providerId)); + if (credential !== undefined) this.credentials.set(providerId, credential); + return credential; + } + + delete(providerId: string): Promise { + this.credentials.delete(providerId); + return Promise.resolve(); + } +} + class CapturingOAuthLoginFlowService extends OAuthLoginFlowService { readonly startCalls: Parameters[0][] = []; disposed = false; @@ -114,3 +186,50 @@ class CapturingOAuthLoginFlowService extends OAuthLoginFlowService { this.disposed = true; } } + + +class PromptingAuthRuntime implements AuthModelRuntime { + completedLogins = 0; + + constructor(private readonly prompts: readonly AuthPrompt[]) {} + + getProviders() { + return [{ id: "test-provider", name: "Test Provider", auth: { apiKey: { login: true }, oauth: true } }]; + } + + getProvider(providerId: string) { + return this.getProviders().find((provider) => provider.id === providerId); + } + + listCredentials(): Promise { + return Promise.resolve([]); + } + + getProviderAuthStatus() { + return { configured: false }; + } + + async login(_providerId: string, _authType: "api_key" | "oauth", interaction: AuthInteraction): Promise { + for (const prompt of this.prompts) await interaction.prompt(prompt); + this.completedLogins++; + } + + logout(): Promise { + return Promise.resolve(); + } +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function deferred() { + let resolveValue: (value: T | PromiseLike) => void = () => undefined; + let rejectValue: (reason?: unknown) => void = () => undefined; + const promise = new Promise((resolve, reject) => { + resolveValue = resolve; + rejectValue = reject; + }); + return { promise, resolve: resolveValue, reject: rejectValue }; +} diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts index 3cb13054..3cefc658 100644 --- a/src/server/sessions/authService.ts +++ b/src/server/sessions/authService.ts @@ -1,34 +1,41 @@ import { join } from "node:path"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { AuthInteraction } from "@earendil-works/pi-ai"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js"; -import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js"; +import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderModelRuntime } from "./authProviderOptions.js"; import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; export interface AuthChange { removedProviderId?: string; } -type AuthChangeListener = (change: AuthChange) => void; -type ModelRegistryInstance = ReturnType; +type AuthChangeListener = (change: AuthChange) => void | Promise; + +export interface AuthModelRuntime extends AuthProviderModelRuntime { + login(providerId: string, authType: AuthType, interaction: AuthInteraction): Promise; + logout(providerId: string): Promise; +} export interface AuthServiceDependencies { - agentDir?: string; - modelRegistry?: ModelRegistryInstance; + modelRuntime: AuthModelRuntime; authFlows?: OAuthLoginFlowService; } -export function createModelRegistryForAgentDir(agentDir: string): ModelRegistryInstance { - const authStorage = AuthStorage.create(join(agentDir, "auth.json")); - return ModelRegistry.create(authStorage, join(agentDir, "models.json")); +export function createModelRuntimeForAgentDir(agentDir: string, allowModelNetwork = true): Promise { + return ModelRuntime.create({ + authPath: join(agentDir, "auth.json"), + modelsPath: join(agentDir, "models.json"), + allowModelNetwork, + }); } export class AuthService { - readonly modelRegistry: ModelRegistryInstance; + readonly modelRuntime: AuthModelRuntime; private readonly authFlows: OAuthLoginFlowService; private readonly listeners = new Set(); - constructor(deps: AuthServiceDependencies = {}) { - this.modelRegistry = deps.modelRegistry ?? (deps.agentDir === undefined ? ModelRegistry.create(AuthStorage.create()) : createModelRegistryForAgentDir(deps.agentDir)); + constructor(deps: AuthServiceDependencies) { + this.modelRuntime = deps.modelRuntime; this.authFlows = deps.authFlows ?? new OAuthLoginFlowService(); } @@ -44,34 +51,50 @@ export class AuthService { this.listeners.clear(); } - authProviders(mode: "login" | "logout", authType?: AuthType): AuthProvidersResponse { - this.modelRegistry.refresh(); - const providers = mode === "logout" ? getLogoutProviderOptions(this.modelRegistry) : getLoginProviderOptions(this.modelRegistry, authType); + async authProviders(mode: "login" | "logout", authType?: AuthType): Promise { + const providers = mode === "logout" + ? await getLogoutProviderOptions(this.modelRuntime) + : getLoginProviderOptions(this.modelRuntime, authType); return { providers }; } - saveApiKey(providerId: string, key: string): { accepted: true } { + async saveApiKey(providerId: string, key: string): Promise<{ accepted: true }> { if (key.trim() === "") throw new Error("API key is required"); - this.modelRegistry.authStorage.set(providerId, { type: "api_key", key }); - this.refreshAuthState(); + const provider = this.requireLoginProvider(providerId, "api_key"); + let promptHandled = false; + const interaction: AuthInteraction = { + prompt: (prompt) => { + if (prompt.signal?.aborted === true) throw new Error("Login cancelled"); + if (promptHandled || prompt.type !== "secret") { + throw new Error(`${provider.name} requires interactive setup; use Pi's /login command`); + } + promptHandled = true; + return Promise.resolve(key); + }, + notify() { + // The existing API-key endpoint is intentionally one-shot. Providers + // requiring richer interactions are rejected above and remain available + // through Pi's generic login command. + }, + }; + await this.modelRuntime.login(providerId, "api_key", interaction); + await this.emit({}); return { accepted: true }; } - logoutProvider(providerId: string): { accepted: true } { - this.modelRegistry.authStorage.logout(providerId); - this.refreshAuthState({ removedProviderId: providerId }); + async logoutProvider(providerId: string): Promise<{ accepted: true }> { + await this.modelRuntime.logout(providerId); + await this.emit({ removedProviderId: providerId }); return { accepted: true }; } startOAuthLogin(providerId: string): OAuthFlowState { - const provider = this.requireOAuthLoginProvider(providerId); + const provider = this.requireLoginProvider(providerId, "oauth"); return this.authFlows.start({ providerId, providerName: provider.name, - authStorage: this.modelRegistry.authStorage, - onComplete: () => { - this.refreshAuthState(); - }, + login: (interaction) => this.modelRuntime.login(providerId, "oauth", interaction), + onComplete: () => this.emit({}), }); } @@ -87,20 +110,16 @@ export class AuthService { return this.authFlows.cancel(flowId); } - private refreshAuthState(change: AuthChange = {}): void { - this.modelRegistry.authStorage.reload(); - this.modelRegistry.refresh(); - this.emit(change); - } - - private emit(change: AuthChange): void { - for (const listener of this.listeners) listener(change); + private async emit(change: AuthChange): Promise { + await Promise.all([...this.listeners].map((listener) => Promise.resolve(listener(change)))); } - private requireOAuthLoginProvider(providerId: string) { - this.modelRegistry.refresh(); - const provider = getLoginProviderOptions(this.modelRegistry, "oauth").find((option) => option.id === providerId); - if (provider === undefined) throw new Error(`OAuth provider not found: ${providerId}`); + private requireLoginProvider(providerId: string, authType: AuthType) { + const provider = getLoginProviderOptions(this.modelRuntime, authType).find((option) => option.id === providerId); + if (provider === undefined) { + const label = authType === "oauth" ? "OAuth" : "API key"; + throw new Error(`${label} provider not found: ${providerId}`); + } return provider; } } diff --git a/src/server/sessions/oauthLoginFlowService.test.ts b/src/server/sessions/oauthLoginFlowService.test.ts index 24e3ab83..ac998b4b 100644 --- a/src/server/sessions/oauthLoginFlowService.test.ts +++ b/src/server/sessions/oauthLoginFlowService.test.ts @@ -1,10 +1,7 @@ -import type { OAuthLoginCallbacks } from "@earendil-works/pi-ai"; -import type { AuthStorage } from "@earendil-works/pi-coding-agent"; +import type { AuthInteraction } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; -type LoginHandler = (providerId: string, callbacks: OAuthLoginCallbacks) => Promise; - afterEach(() => { vi.useRealTimers(); }); @@ -17,19 +14,19 @@ describe("OAuthLoginFlowService", () => { const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { - callbacks.onAuth({ url: "https://example.test/auth", instructions: "Open it" }); - callbacks.onProgress?.("Waiting for code"); - promptValue = await callbacks.onPrompt({ message: "Paste code", placeholder: "code" }); - callbacks.onProgress?.(`Got ${promptValue}`); - }), + login: async (interaction) => { + interaction.notify({ type: "auth_url", url: "https://example.test/auth", instructions: "Open it" }); + interaction.notify({ type: "progress", message: "Waiting for code" }); + promptValue = await interaction.prompt({ type: "text", message: "Paste code", placeholder: "code" }); + interaction.notify({ type: "progress", message: `Got ${promptValue}` }); + }, onComplete, }); const prompt = state.prompt; if (prompt === undefined) throw new Error("Expected prompt"); expect(state).toMatchObject({ auth: { url: "https://example.test/auth" }, progress: ["Waiting for code"] }); - expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "prompt" }); + expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "text" }); const afterRespond = service.respond(state.flowId, prompt.requestId, "abc123"); expect(afterRespond.prompt).toBeUndefined(); @@ -47,17 +44,18 @@ describe("OAuthLoginFlowService", () => { const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { - selectedValue = await callbacks.onSelect({ + login: async (interaction) => { + selectedValue = await interaction.prompt({ + type: "select", message: "Choose account", - options: [{ id: "work", label: "Work" }, { id: "personal", label: "Personal" }], + options: [{ id: "work", label: "Work", description: "Company account" }, { id: "personal", label: "Personal" }], }); - }), + }, }); const select = state.select; if (select === undefined) throw new Error("Expected select prompt"); - expect(select).toMatchObject({ message: "Choose account", options: [{ value: "work", label: "Work" }, { value: "personal", label: "Personal" }] }); + expect(select).toMatchObject({ message: "Choose account", options: [{ value: "work", label: "Work", description: "Company account" }, { value: "personal", label: "Personal" }] }); service.respond(state.flowId, select.requestId, "personal"); await flushAsyncLogin(); @@ -67,22 +65,99 @@ describe("OAuthLoginFlowService", () => { service.dispose(); }); + it("preserves secret prompt semantics", () => { + const service = new OAuthLoginFlowService(); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + login: async (interaction) => { + await interaction.prompt({ type: "secret", message: "Enter secret", placeholder: "token" }); + }, + }); + + expect(state.prompt).toMatchObject({ kind: "secret", message: "Enter secret", placeholder: "token" }); + service.dispose(); + }); + + it("rejects values outside the pending select options", () => { + const service = new OAuthLoginFlowService(); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + login: async (interaction) => { + await interaction.prompt({ + type: "select", + message: "Choose account", + options: [{ id: "work", label: "Work" }], + }); + }, + }); + + const select = state.select; + if (select === undefined) throw new Error("Expected select prompt"); + expect(() => { service.respond(state.flowId, select.requestId, "personal"); }).toThrow("Invalid OAuth selection"); + expect(service.get(state.flowId).select).toBeDefined(); + service.dispose(); + }); + + it("preserves device-code timing metadata", () => { + const service = new OAuthLoginFlowService(); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + login: async (interaction) => { + interaction.notify({ + type: "device_code", + userCode: "ABCD-EFGH", + verificationUri: "https://example.test/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }); + await new Promise(() => undefined); + }, + }); + + expect(state.auth).toEqual({ + url: "https://example.test/device", + instructions: "Enter code: ABCD-EFGH", + deviceCode: { userCode: "ABCD-EFGH", intervalSeconds: 5, expiresInSeconds: 900 }, + }); + service.dispose(); + }); + + it("awaits completion propagation before marking the flow complete", async () => { + const completion = deferred(); + const service = new OAuthLoginFlowService(); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + login: () => Promise.resolve(), + onComplete: () => completion.promise, + }); + + await flushAsyncLogin(); + expect(service.get(state.flowId).status).toBe("running"); + + completion.resolve(undefined); + await flushAsyncLogin(); + expect(service.get(state.flowId).status).toBe("complete"); + service.dispose(); + }); + it("uses a manual-code prompt for callback-server flows", async () => { let manualValue: string | undefined; const service = new OAuthLoginFlowService(); const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { - const manualCodeInput = callbacks.onManualCodeInput; - if (manualCodeInput === undefined) throw new Error("Expected manual-code callback"); - manualValue = await manualCodeInput(); - }), + login: async (interaction) => { + manualValue = await interaction.prompt({ type: "manual_code", message: "Paste the callback URL or authorization code" }); + }, }); const prompt = state.prompt; if (prompt === undefined) throw new Error("Expected manual prompt"); - expect(prompt).toMatchObject({ kind: "manual", message: "Paste the callback URL or authorization code" }); + expect(prompt).toMatchObject({ kind: "manual-code", message: "Paste the callback URL or authorization code" }); service.respond(state.flowId, prompt.requestId, "https://localhost/callback?code=abc"); await flushAsyncLogin(); @@ -98,14 +173,7 @@ describe("OAuthLoginFlowService", () => { const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { - try { - await callbacks.onPrompt({ message: "Paste code" }); - } catch (error) { - promptRejected.resolve(toError(error)); - throw error; - } - }), + login: loginWithRejectedPrompt(promptRejected), }); expect(state.prompt).toBeDefined(); @@ -122,18 +190,10 @@ describe("OAuthLoginFlowService", () => { const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { - try { - await callbacks.onPrompt({ message: "Paste code" }); - } catch (error) { - promptRejected.resolve(toError(error)); - throw error; - } - }), + login: loginWithRejectedPrompt(promptRejected), }); expect(state.prompt).toBeDefined(); - service.dispose(); await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" }); @@ -145,9 +205,9 @@ describe("OAuthLoginFlowService", () => { const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { - await callbacks.onPrompt({ message: "Paste code" }); - }), + login: async (interaction) => { + await interaction.prompt({ type: "text", message: "Paste code" }); + }, }); const prompt = state.prompt; @@ -165,14 +225,7 @@ describe("OAuthLoginFlowService", () => { const state = service.start({ providerId: "test-provider", providerName: "Test Provider", - authStorage: fakeAuthStorage(async (_providerId, callbacks) => { - try { - await callbacks.onPrompt({ message: "Paste code" }); - } catch (error) { - promptRejected.resolve(toError(error)); - throw error; - } - }), + login: loginWithRejectedPrompt(promptRejected), }); await vi.advanceTimersByTimeAsync(1000); @@ -187,8 +240,15 @@ describe("OAuthLoginFlowService", () => { }); }); -function fakeAuthStorage(login: LoginHandler): Pick { - return { login }; +function loginWithRejectedPrompt(promptRejected: ReturnType>) { + return async (interaction: AuthInteraction) => { + try { + await interaction.prompt({ type: "text", message: "Paste code" }); + } catch (error) { + promptRejected.resolve(toError(error)); + throw error; + } + }; } async function flushAsyncLogin(): Promise { diff --git a/src/server/sessions/oauthLoginFlowService.ts b/src/server/sessions/oauthLoginFlowService.ts index 02035b54..0de13387 100644 --- a/src/server/sessions/oauthLoginFlowService.ts +++ b/src/server/sessions/oauthLoginFlowService.ts @@ -1,16 +1,19 @@ import crypto from "node:crypto"; -import type { OAuthLoginCallbacks, OAuthSelectPrompt, OAuthPrompt } from "@earendil-works/pi-ai"; -import type { AuthStorage } from "@earendil-works/pi-coding-agent"; +import type { AuthEvent, AuthInteraction, AuthPrompt } from "@earendil-works/pi-ai"; import type { CommandOption, OAuthFlowState } from "../../shared/apiTypes.js"; -type OAuthLoginStorage = Pick; type TimerHandle = ReturnType; +type LoginRunner = (interaction: AuthInteraction) => Promise; +type SelectPrompt = Extract; +type ValuePrompt = Exclude; interface PendingOAuthRequest { requestId: string; allowEmpty: boolean; resolve: (value: string | undefined) => void; reject: (error: Error) => void; + cleanup?: () => void; + allowedValues?: ReadonlySet; } interface OAuthFlowRecord { @@ -46,8 +49,8 @@ export class OAuthLoginFlowService { start(options: { providerId: string; providerName: string; - authStorage: OAuthLoginStorage; - onComplete?: () => void; + login: LoginRunner; + onComplete?: () => void | Promise; }): OAuthFlowState { const flowId = crypto.randomUUID(); const abort = new AbortController(); @@ -66,37 +69,28 @@ export class OAuthLoginFlowService { this.flows.set(flowId, record); this.scheduleRunningExpiry(record); - const callbacks: OAuthLoginCallbacks = { + const interaction: AuthInteraction = { signal: abort.signal, - onAuth: (info) => { - if (!this.isCurrentRunning(record)) return; - this.updateState(record, { ...record.state, auth: info }); - }, - // Device-code flows have no redirect URL; reuse the auth field so the web UI - // shows the verification link and user code without a dedicated API shape. - onDeviceCode: (info) => { - if (!this.isCurrentRunning(record)) return; - this.updateState(record, { ...record.state, auth: { url: info.verificationUri, instructions: `Enter code: ${info.userCode}` } }); + prompt: (prompt) => { + if (prompt.type === "select") return this.waitForSelect(record, prompt); + return this.waitForPrompt(record, prompt); }, - onPrompt: (prompt) => this.waitForPrompt(record, prompt, "prompt"), - onManualCodeInput: () => this.waitForPrompt(record, { message: "Paste the callback URL or authorization code", allowEmpty: false }, "manual"), - onSelect: (prompt) => this.waitForSelect(record, prompt), - onProgress: (message) => { - if (!this.isCurrentRunning(record)) return; - this.updateState(record, { ...record.state, progress: [...record.state.progress, message] }); + notify: (event) => { + this.handleEvent(record, event); }, }; - void options.authStorage.login(options.providerId, callbacks) - .then(() => { + void options.login(interaction) + .then(async () => { + if (!this.isCurrentRunning(record)) return; + this.clearPending(record); + await options.onComplete?.(); if (!this.isCurrentRunning(record)) return; - record.pending = undefined; this.markTerminal(record, { ...withoutInteraction(record.state), status: "complete", progress: [...record.state.progress, "Login complete"] }); - options.onComplete?.(); }) .catch((error: unknown) => { if (this.flows.get(record.flowId) !== record) return; - record.pending = undefined; + this.clearPending(record); if (record.state.status !== "running") return; this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: error instanceof Error ? error.message : String(error) }); }); @@ -117,7 +111,9 @@ export class OAuthLoginFlowService { const pending = record.pending; if (pending?.requestId !== requestId) throw new Error("OAuth login request expired"); if (!pending.allowEmpty && value.trim() === "") throw new Error("A value is required"); + if (pending.allowedValues !== undefined && !pending.allowedValues.has(value)) throw new Error("Invalid OAuth selection"); record.pending = undefined; + pending.cleanup?.(); this.updateState(record, withoutInteraction(record.state)); pending.resolve(value); return cloneState(record.state); @@ -128,8 +124,7 @@ export class OAuthLoginFlowService { if (record === undefined) throw new Error("OAuth login flow not found"); if (record.state.status === "running") { record.abort.abort(); - const pending = record.pending; - record.pending = undefined; + const pending = this.clearPending(record); this.markTerminal(record, { ...withoutInteraction(record.state), status: "cancelled", error: "Login cancelled" }); pending?.reject(new Error("Login cancelled")); } @@ -140,49 +135,128 @@ export class OAuthLoginFlowService { for (const record of this.flows.values()) { this.clearTimer(record); record.abort.abort(); - const pending = record.pending; - record.pending = undefined; + const pending = this.clearPending(record); pending?.reject(new Error("Login cancelled")); } this.flows.clear(); } - private waitForPrompt(record: OAuthFlowRecord, prompt: OAuthPrompt, kind: "prompt" | "manual"): Promise { + private handleEvent(record: OAuthFlowRecord, event: AuthEvent): void { + if (!this.isCurrentRunning(record)) return; + if (event.type === "auth_url") { + this.updateState(record, { ...record.state, auth: { url: event.url, ...(event.instructions === undefined ? {} : { instructions: event.instructions }) } }); + return; + } + if (event.type === "device_code") { + this.updateState(record, { + ...record.state, + auth: { + url: event.verificationUri, + instructions: `Enter code: ${event.userCode}`, + deviceCode: { + userCode: event.userCode, + ...(event.intervalSeconds === undefined ? {} : { intervalSeconds: event.intervalSeconds }), + ...(event.expiresInSeconds === undefined ? {} : { expiresInSeconds: event.expiresInSeconds }), + }, + }, + }); + return; + } + if (event.type === "info") { + const link = event.links?.[0]; + this.updateState(record, { + ...record.state, + progress: [...record.state.progress, event.message], + ...(link === undefined ? {} : { auth: { url: link.url, instructions: event.message } }), + }); + return; + } + this.updateState(record, { ...record.state, progress: [...record.state.progress, event.message] }); + } + + private waitForPrompt(record: OAuthFlowRecord, prompt: ValuePrompt): Promise { return new Promise((resolve, reject) => { if (!this.isCurrentRunning(record)) { reject(new Error("Login cancelled")); return; } const requestId = crypto.randomUUID(); - record.pending = { requestId, allowEmpty: prompt.allowEmpty === true, resolve: (value) => { resolve(value ?? ""); }, reject }; + const onAbort = () => { + if (record.pending?.requestId !== requestId) return; + const pending = this.clearPending(record); + this.updateState(record, withoutInteraction(record.state)); + pending?.reject(new Error("Login cancelled")); + }; + const pending: PendingOAuthRequest = { + requestId, + allowEmpty: false, + resolve: (value) => { resolve(value ?? ""); }, + reject, + cleanup: () => { prompt.signal?.removeEventListener("abort", onAbort); }, + }; + record.pending = pending; + prompt.signal?.addEventListener("abort", onAbort, { once: true }); + if (prompt.signal?.aborted === true) { + onAbort(); + return; + } const base = withoutInteraction(record.state); this.updateState(record, { ...base, prompt: { requestId, message: prompt.message, - kind, + kind: prompt.type === "manual_code" ? "manual-code" : prompt.type, ...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }), - ...(prompt.allowEmpty === true ? { allowEmpty: true } : {}), }, }); }); } - private waitForSelect(record: OAuthFlowRecord, prompt: OAuthSelectPrompt): Promise { + private waitForSelect(record: OAuthFlowRecord, prompt: SelectPrompt): Promise { return new Promise((resolve, reject) => { if (!this.isCurrentRunning(record)) { reject(new Error("Login cancelled")); return; } const requestId = crypto.randomUUID(); - const options: CommandOption[] = prompt.options.map((option) => ({ value: option.id, label: option.label })); - record.pending = { requestId, allowEmpty: true, resolve, reject }; + const onAbort = () => { + if (record.pending?.requestId !== requestId) return; + const pending = this.clearPending(record); + this.updateState(record, withoutInteraction(record.state)); + pending?.reject(new Error("Login cancelled")); + }; + const options: CommandOption[] = prompt.options.map((option) => ({ + value: option.id, + label: option.label, + ...(option.description === undefined ? {} : { description: option.description }), + })); + const pending: PendingOAuthRequest = { + requestId, + allowEmpty: false, + resolve: (value) => { resolve(value ?? ""); }, + reject, + cleanup: () => { prompt.signal?.removeEventListener("abort", onAbort); }, + allowedValues: new Set(options.map((option) => option.value)), + }; + record.pending = pending; + prompt.signal?.addEventListener("abort", onAbort, { once: true }); + if (prompt.signal?.aborted === true) { + onAbort(); + return; + } const base = withoutInteraction(record.state); this.updateState(record, { ...base, select: { requestId, message: prompt.message, options } }); }); } + private clearPending(record: OAuthFlowRecord): PendingOAuthRequest | undefined { + const pending = record.pending; + record.pending = undefined; + pending?.cleanup?.(); + return pending; + } + private isCurrentRunning(record: OAuthFlowRecord): boolean { return this.flows.get(record.flowId) === record && record.state.status === "running"; } @@ -226,8 +300,7 @@ export class OAuthLoginFlowService { private expireRunningFlow(record: OAuthFlowRecord): void { if (!this.isCurrentRunning(record)) return; record.abort.abort(); - const pending = record.pending; - record.pending = undefined; + const pending = this.clearPending(record); this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "OAuth login flow expired" }); pending?.reject(new Error("OAuth login flow expired")); } @@ -256,7 +329,12 @@ function cloneState(state: OAuthFlowState): OAuthFlowState { return { ...state, progress: [...state.progress], - ...(state.auth === undefined ? {} : { auth: { ...state.auth } }), + ...(state.auth === undefined ? {} : { + auth: { + ...state.auth, + ...(state.auth.deviceCode === undefined ? {} : { deviceCode: { ...state.auth.deviceCode } }), + }, + }), ...(state.prompt === undefined ? {} : { prompt: { ...state.prompt } }), ...(state.select === undefined ? {} : { select: { ...state.select, options: state.select.options.map((option) => ({ ...option })) } }), }; diff --git a/src/server/sessions/piSessionService.archiveCleanup.test.ts b/src/server/sessions/piSessionService.archiveCleanup.test.ts index 969ff15a..e828d65c 100644 --- a/src/server/sessions/piSessionService.archiveCleanup.test.ts +++ b/src/server/sessions/piSessionService.archiveCleanup.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { PiSessionService } from "./piSessionService.js"; -import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js"; +import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, unreachableRuntimeFactory } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; @@ -48,6 +48,7 @@ describe("PiSessionService archive and cleanup", () => { it("permanently deletes archived sessions through the archive store", async () => { const deletedSessionIds: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { + createRuntime: unreachableRuntimeFactory, agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([]), @@ -82,6 +83,7 @@ describe("PiSessionService archive and cleanup", () => { const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); }); const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); const service = new PiSessionService(new CapturingSessionEventHub(), { + createRuntime: unreachableRuntimeFactory, agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([]), @@ -195,6 +197,7 @@ describe("PiSessionService archive and cleanup", () => { const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const listCalls: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { + createRuntime: unreachableRuntimeFactory, agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([ @@ -238,6 +241,7 @@ describe("PiSessionService archive and cleanup", () => { const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" }; const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" }; const service = new PiSessionService(new CapturingSessionEventHub(), { + createRuntime: unreachableRuntimeFactory, agentDir: TEST_AGENT_DIR, now: () => new Date("2026-06-25T00:00:00.000Z"), archiveStore: { @@ -291,6 +295,7 @@ describe("PiSessionService archive and cleanup", () => { const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` })))); const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const service = new PiSessionService(new CapturingSessionEventHub(), { + createRuntime: unreachableRuntimeFactory, agentDir: TEST_AGENT_DIR, now: () => new Date("2026-06-25T00:00:00.000Z"), archiveStore: { diff --git a/src/server/sessions/piSessionService.lifecycle.test.ts b/src/server/sessions/piSessionService.lifecycle.test.ts index 3fbc23a7..130e5da7 100644 --- a/src/server/sessions/piSessionService.lifecycle.test.ts +++ b/src/server/sessions/piSessionService.lifecycle.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js"; -import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js"; +import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, unreachableRuntimeFactory, type RuntimeCreator } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; @@ -380,6 +380,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("uses injected archive and session-manager gateways for listing", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { + createRuntime: unreachableRuntimeFactory, agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]), @@ -410,6 +411,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("lists archived records that have been moved out of the active session directory", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { + createRuntime: unreachableRuntimeFactory, agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), @@ -513,6 +515,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("refuses to reload an archived session", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { + createRuntime: unreachableRuntimeFactory, agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([]), @@ -535,6 +538,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("reconciles workspace activity when listing only archived sessions", async () => { const reconciliations: { cwd: string; sessionIds: string[] }[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { + createRuntime: unreachableRuntimeFactory, agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), diff --git a/src/server/sessions/piSessionService.promptQueue.test.ts b/src/server/sessions/piSessionService.promptQueue.test.ts index be448849..01594dac 100644 --- a/src/server/sessions/piSessionService.promptQueue.test.ts +++ b/src/server/sessions/piSessionService.promptQueue.test.ts @@ -1,8 +1,7 @@ import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai"; import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; -import { PiSessionService } from "./piSessionService.js"; +import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; @@ -336,17 +335,48 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { await service.dispose(); }); + it("reloads model configuration and reads the resulting snapshot", async () => { + const firstModel = testModel(); + const updatedModel = { ...firstModel, id: "updated-model", name: "Updated model" }; + let snapshot: readonly NonNullable[] = [firstModel]; + const reloadConfig = vi.fn(() => { + snapshot = [updatedModel]; + return Promise.resolve(); + }); + const modelRuntime: PiAgentSession["modelRuntime"] = { + reloadConfig, + getAvailableSnapshot: () => snapshot, + getModel: (provider: string, modelId: string) => snapshot.find((model) => model.provider === provider && model.id === modelId), + getProviderAuthStatus: () => ({ configured: true }), + }; + const fake = fakeRuntime("models-session", { modelRuntime }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("models-session")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.availableModels(sessionRef("models-session"))).resolves.toEqual([expect.objectContaining({ id: "updated-model" })]); + expect(reloadConfig).toHaveBeenCalledOnce(); + await service.dispose(); + }); + it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => { const hub = new CapturingSessionEventHub(); - const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } }); - const modelRegistry = ModelRegistry.inMemory(authStorage); - const model = modelRegistry.find(TEST_MODEL_PROVIDER, TEST_MODEL_ID); - if (model === undefined) throw new Error("Expected Anthropic model fixture"); - const fake = fakeRuntime("auth-session", { model, modelRegistry }); + let configured = true; + const model = testModel(); + const reloadConfig = vi.fn(() => Promise.resolve()); + const modelRuntime: PiAgentSession["modelRuntime"] = { + reloadConfig, + getAvailableSnapshot: () => [model], + getModel: (provider: string, modelId: string) => provider === model.provider && modelId === model.id ? model : undefined, + getProviderAuthStatus: () => ({ configured }), + }; + const fake = fakeRuntime("auth-session", { model, modelRuntime }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, - modelRegistry, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("auth-session")]), heartbeatIntervalMs: 60_000, @@ -356,7 +386,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { hub.sessionEvents.length = 0; hub.globalEvents.length = 0; - authStorage.logout("anthropic"); + configured = false; service.applyAuthChange({ removedProviderId: "anthropic" }); service.applyAuthChange({ removedProviderId: "anthropic" }); @@ -364,11 +394,12 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { expect(warningCount()).toBe(1); expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true); - authStorage.set("anthropic", { type: "api_key", key: "sk-new" }); + configured = true; service.applyAuthChange(); - authStorage.logout("anthropic"); + configured = false; service.applyAuthChange({ removedProviderId: "anthropic" }); expect(warningCount()).toBe(2); + expect(reloadConfig).not.toHaveBeenCalled(); await service.dispose(); }); diff --git a/src/server/sessions/piSessionService.testSupport.ts b/src/server/sessions/piSessionService.testSupport.ts index 0281cef5..a878c9fd 100644 --- a/src/server/sessions/piSessionService.testSupport.ts +++ b/src/server/sessions/piSessionService.testSupport.ts @@ -1,4 +1,4 @@ -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all"; import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { PiAgentSession, PiSessionManager, PiSessionRuntime, PiSessionServiceDependencies } from "./piSessionService.js"; @@ -18,6 +18,9 @@ export class CapturingSessionEventHub extends SessionEventHub { export type SessionGateway = NonNullable; export type RuntimeCreator = NonNullable; +export const unreachableRuntimeFactory: NonNullable = () => Promise.reject( + new Error("Test unexpectedly invoked the Pi session runtime factory"), +); export interface TestSession extends PiAgentSession { sessionName: string | undefined; @@ -53,9 +56,17 @@ export const TEST_MODEL_PROVIDER = "anthropic"; export const TEST_MODEL_ID = "claude-sonnet-4-5-20250929"; export function testModel(): NonNullable { - const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find(TEST_MODEL_PROVIDER, TEST_MODEL_ID); - if (model === undefined) throw new Error("test model not found"); - return model; + return getBuiltinModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID); +} + +export function fakeModelRuntime(): PiAgentSession["modelRuntime"] { + const model = testModel(); + return { + reloadConfig: () => Promise.resolve(), + getAvailableSnapshot: () => [model], + getModel: (provider: string, modelId: string) => provider === model.provider && modelId === model.id ? model : undefined, + getProviderAuthStatus: () => ({ configured: true }), + }; } export function fakeRuntime(sessionId = "session-1", patch: Partial = {}) { @@ -76,7 +87,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial isBashRunning: false, pendingMessageCount: 0, sessionManager: fakeSessionManager(), - modelRegistry: ModelRegistry.create(AuthStorage.inMemory()), + modelRuntime: fakeModelRuntime(), scopedModels: [], extensionRunner: { getRegisteredCommands: () => [] }, promptTemplates: [], diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index cdb7f22d..9f8dff2a 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -3,13 +3,12 @@ import { open, readFile, writeFile } from "node:fs/promises"; import type { ImageContent } from "@earendil-works/pi-ai"; import type { StreamFn } from "@earendil-works/pi-agent-core"; import { - AuthStorage, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, - ModelRegistry, + ModelRuntime, SessionManager, type CreateAgentSessionRuntimeFactory, type EditToolDetails, @@ -22,7 +21,7 @@ import { SessionCommandService } from "./sessionCommandService.js"; import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js"; import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js"; import type { ActiveSession } from "./sessionRuntimeStore.js"; -import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js"; +import type { AuthChange } from "./authService.js"; import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; import { computeEditPreview, type EditPreviewResult } from "./editPreview.js"; import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js"; @@ -167,7 +166,13 @@ interface BulkDeletePlanItem { } type AgentModel = NonNullable; -type ModelRegistryInstance = ReturnType; + +export interface PiModelRuntime { + reloadConfig(): Promise; + getAvailableSnapshot(): readonly AgentModel[]; + getModel(provider: string, modelId: string): AgentModel | undefined; + getProviderAuthStatus(providerId: string): { configured: boolean }; +} export interface PiSessionManager { getCwd(): string; @@ -204,7 +209,7 @@ interface PiExtensionBindings { } export interface PiAgentSession { - modelRegistry: ModelRegistryInstance; + modelRuntime: PiModelRuntime; sessionManager: PiSessionManager; scopedModels: readonly { model: AgentModel; thinkingLevel?: ClientThinkingLevel }[]; sessionId: string; @@ -331,14 +336,13 @@ export function createPiWebCustomToolDefinitions( } function createDefaultRuntimeFactory( - authStorage: AuthStorage, - modelRegistry: ModelRegistryInstance, + modelRuntime: ModelRuntime, sessionManagers: Pick, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps, ): PiWebCreateAgentSessionRuntimeFactory { return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => { - const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry }); + const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime }); const resolvedDelegationToolsEnabled = delegationToolsEnabled ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers); const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions); @@ -353,6 +357,10 @@ function createDefaultRuntimeFactory( }; } +const missingInjectedRuntimeFactory: PiWebCreateAgentSessionRuntimeFactory = () => Promise.reject( + new Error("Injected createAgentRuntime cannot invoke Pi's built-in runtime factory without modelRuntime"), +); + type PiWebEditToolDetails = EditToolDetails | { preview: EditPreviewResult } | undefined; function createPiWebEditToolDefinition(cwd: string) { @@ -377,13 +385,10 @@ function createPiWebEditToolDefinition(cwd: string) { }); } -export interface PiSessionServiceDependencies { +interface PiSessionServiceBaseDependencies { agentDir: string; sessionManager: PiSessionManagerGateway; archiveStore?: SessionArchiveRepository; - createRuntime?: PiWebCreateAgentSessionRuntimeFactory; - createAgentRuntime?: CreateAgentRuntime; - modelRegistry?: ModelRegistryInstance; heartbeatIntervalMs?: number; workspaceActivity?: Pick; /** @@ -405,6 +410,13 @@ export interface PiSessionServiceDependencies { now?: () => Date; } +type PiSessionRuntimeDependencies = + | { modelRuntime: ModelRuntime; createRuntime?: never; createAgentRuntime?: CreateAgentRuntime } + | { modelRuntime?: never; createRuntime: PiWebCreateAgentSessionRuntimeFactory; createAgentRuntime?: CreateAgentRuntime } + | { modelRuntime?: ModelRuntime; createRuntime?: PiWebCreateAgentSessionRuntimeFactory; createAgentRuntime: CreateAgentRuntime }; + +export type PiSessionServiceDependencies = PiSessionServiceBaseDependencies & PiSessionRuntimeDependencies; + export class PiSessionService implements SessionRouteService { private readonly active = new Map>(); private readonly pendingSessionOpens = new Map(); @@ -433,7 +445,6 @@ export class PiSessionService implements SessionRouteService { private readonly sessionManager: PiSessionManagerGateway; private readonly createRuntime: PiWebCreateAgentSessionRuntimeFactory; private readonly createAgentRuntime: CreateAgentRuntime; - private readonly modelRegistry: ModelRegistryInstance; private readonly workspaceActivity: Pick | undefined; private readonly spawnTargets: SpawnTargetResolver | undefined; private readonly logger: PiSessionLogger; @@ -443,25 +454,25 @@ export class PiSessionService implements SessionRouteService { this.archiveStore = deps.archiveStore ?? new SessionArchiveStore(); this.agentDir = deps.agentDir; this.sessionManager = deps.sessionManager; - this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir); this.spawnTargets = deps.spawnTargets; this.logger = deps.logger ?? noopLogger; this.now = deps.now ?? (() => new Date()); // Subsessions are a beta capability gated behind their own flag, and they // also require the spawn capability (they share its project-scope resolver). const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true; - this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory( - this.modelRegistry.authStorage, - this.modelRegistry, - this.sessionManager, - this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), - !subsessionsActive ? undefined : { - spawn: (input) => this.spawnSubsession(input), - list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile), - check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile), - read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile), - }, - ); + this.createRuntime = deps.createRuntime ?? (deps.modelRuntime === undefined + ? missingInjectedRuntimeFactory + : createDefaultRuntimeFactory( + deps.modelRuntime, + this.sessionManager, + this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), + !subsessionsActive ? undefined : { + spawn: (input) => this.spawnSubsession(input), + list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile), + check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile), + read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile), + }, + )); this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime; this.workspaceActivity = deps.workspaceActivity; this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000); @@ -983,22 +994,22 @@ export class PiSessionService implements SessionRouteService { async availableModels(ref: PiSessionLookup): Promise { const session = await this.getOrOpen(ref); - session.modelRegistry.refresh(); + await session.modelRuntime.reloadConfig(); const models = session.scopedModels.length > 0 ? session.scopedModels.map((scoped) => scoped.model) - : session.modelRegistry.getAvailable(); + : session.modelRuntime.getAvailableSnapshot(); return models.map(modelToClientModel); } async setModel(ref: PiSessionLookup, provider: string, modelId: string): Promise { await this.assertWritable(ref); const session = await this.getOrOpen(ref); - session.modelRegistry.refresh(); + await session.modelRuntime.reloadConfig(); const candidates = session.scopedModels.length > 0 ? session.scopedModels.map((scoped) => scoped.model) - : session.modelRegistry.getAvailable(); + : session.modelRuntime.getAvailableSnapshot(); const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId) - ?? session.modelRegistry.find(provider, modelId); + ?? session.modelRuntime.getModel(provider, modelId); if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`); await session.setModel(model); this.publishActivity(session, `model: ${model.id}`, "idle", model.provider); @@ -1837,10 +1848,8 @@ export class PiSessionService implements SessionRouteService { } applyAuthChange(change: AuthChange = {}): void { - this.modelRegistry.refresh(); for (const active of this.active.values()) { const { session } = active.runtime; - session.modelRegistry.refresh(); this.syncCurrentModelAuthWarning(session, change.removedProviderId); this.publishStatus(session); } @@ -1851,9 +1860,9 @@ export class PiSessionService implements SessionRouteService { if (model === undefined) return; if (model.provider === "unknown" && model.id === "unknown") return; const warningKey = authLossWarningKey(session.sessionId, model.provider, model.id); - const registered = session.modelRegistry.find(model.provider, model.id); + const registered = session.modelRuntime.getModel(model.provider, model.id); if (registered === undefined) return; - if (session.modelRegistry.hasConfiguredAuth(registered)) { + if (session.modelRuntime.getProviderAuthStatus(model.provider).configured) { this.authLossWarnings.delete(warningKey); return; } diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts index 9202a80e..c3a6d2a9 100644 --- a/src/server/sessions/sessionRoutes.test.ts +++ b/src/server/sessions/sessionRoutes.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus } from "../../shared/apiTypes.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js"; import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js"; +import { unreachableRuntimeFactory } from "./piSessionService.testSupport.js"; import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js"; import { registerSessionRoutes } from "./sessionRoutes.js"; import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; @@ -20,7 +21,12 @@ beforeEach(async () => { await app.register(fastifyWebsocket); sessionManager = new RejectingSessionManager(); const eventHub = new SessionEventHub(); - service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, sessionManager, heartbeatIntervalMs: 60_000 }); + service = new PiSessionService(eventHub, { + createRuntime: unreachableRuntimeFactory, + agentDir: TEST_AGENT_DIR, + sessionManager, + heartbeatIntervalMs: 60_000, + }); registerSessionRoutes(app, service, eventHub); }); diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 820b741d..a89e47f8 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -384,8 +384,17 @@ export interface OAuthFlowState { providerId: string; providerName: string; status: "running" | "complete" | "error" | "cancelled"; - auth?: { url: string; instructions?: string }; - prompt?: { requestId: string; message: string; placeholder?: string; allowEmpty?: boolean; kind: "prompt" | "manual" }; + auth?: { + url: string; + instructions?: string; + deviceCode?: { userCode: string; intervalSeconds?: number; expiresInSeconds?: number }; + }; + prompt?: { + requestId: string; + message: string; + placeholder?: string; + kind: "text" | "secret" | "manual-code"; + }; select?: { requestId: string; message: string; options: CommandOption[] }; progress: string[]; error?: string;