diff --git a/.changeset/global-provider-policy.md b/.changeset/global-provider-policy.md index 0910f51..ce2b5a5 100644 --- a/.changeset/global-provider-policy.md +++ b/.changeset/global-provider-policy.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Support only globally configured providers (Pi built-ins, environment credentials, and the agent directory's `models.json`). Provider registrations from Pi extensions (`pi.registerProvider`) are now ignored and reported with a session warning instead of leaking into every concurrent session; all other extension features keep working. Configure such providers globally in the agent directory's `models.json` to use them. Session daemon code changed: after updating, restart `pi-web-sessiond.service` manually (`systemctl --user restart pi-web-sessiond`). +Support providers from global sources only: Pi built-ins, environment credentials, the agent directory's `models.json`, and providers registered by globally installed (agent-dir) extensions. Provider registrations from project extensions (`pi.registerProvider` in a workspace's extensions) are ignored and reported with a session warning instead of leaking into every concurrent session; all other extension features keep working. To use such a provider, configure it globally in `models.json` or install the extension globally. Requires Pi 0.81 or newer. Session daemon code changed: after updating, restart `pi-web-sessiond.service` manually (`systemctl --user restart pi-web-sessiond`). diff --git a/docs/plugins.html b/docs/plugins.html index c2fa8c8..e39d718 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -163,11 +163,12 @@ PI WEB browser plugins. A routine session daemon restart is not required.

- One exception applies to Pi package extensions: PI WEB supports only globally configured providers - (Pi built-ins, environment credentials, and the agent directory's models.json). If an - extension calls pi.registerProvider, PI WEB ignores the registration and warns in the - session; everything else the extension registers keeps working. Configure such providers globally in - the agent directory's models.json instead. + One exception applies to Pi package extensions: PI WEB providers come from global sources only + (Pi built-ins, environment credentials, the agent directory's models.json, and providers + registered by globally installed, agent-dir extensions). If a project extension calls + pi.registerProvider, PI WEB ignores the registration and warns in the session; everything + else the extension registers keeps working. Move such a provider to a global source: declare it in + the agent directory's models.json, or install the extension globally.

diff --git a/docs/plugins.md b/docs/plugins.md index fb1d57c..6d17269 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -27,9 +27,9 @@ Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB brows ## Extension provider registrations -PI WEB only supports globally configured providers: Pi built-ins, environment credentials, and providers declared in the agent directory's `models.json` (the directory selected by `agent.dir`; see [Configuration](https://pi-web.dev/config)). All sessions share one daemon-wide provider set, so extensions cannot add their own: if a Pi extension calls `pi.registerProvider(...)`, PI WEB ignores the registration and shows a warning in the session naming the provider. The extension itself still loads and everything else it registers keeps working; only the ignored provider's models never appear, so an extension that requires its own provider may load but remain unusable. +PI WEB providers come from global sources only: Pi built-ins, environment credentials, providers declared in the agent directory's `models.json` (the directory selected by `agent.dir`; see [Configuration](https://pi-web.dev/config)), and providers registered by globally installed (agent-dir) extensions. Global extensions load identically for every session, so their providers are safe on the shared daemon-wide runtime; project extensions differ per workspace and cannot add providers. If a project extension calls `pi.registerProvider(...)`, PI WEB ignores the registration and shows a warning in the session naming the provider. The extension itself still loads and everything else it registers keeps working; only the ignored provider's models never appear, so a project extension that requires its own provider may load but remain unusable. -To use such a provider, configure it globally in the agent directory's `models.json` instead. Project-level `models.json` files do not add providers to PI WEB sessions. +To use a project extension's provider, move it to a global source: declare it in the agent directory's `models.json`, or install the extension globally in the agent directory. Project-level `models.json` files do not add providers to PI WEB sessions. This policy guards against accidental cross-workspace leakage; it is not a security boundary, since extensions run as trusted code inside the daemon. ## Trust model diff --git a/package-lock.json b/package-lock.json index 2a2e89c..c8b6487 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.8", - "@earendil-works/pi-ai": "^0.80.8", - "@earendil-works/pi-coding-agent": "^0.80.8", + "@earendil-works/pi-agent-core": "^0.81.1", + "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-coding-agent": "^0.81.1", "@eslint/js": "^10.0.1", "@types/node": "^24.13.3", "@types/ws": "^8.18.1", @@ -61,9 +61,9 @@ "node": ">=22.19.0" }, "peerDependencies": { - "@earendil-works/pi-agent-core": ">=0.80.8 <0.81", - "@earendil-works/pi-ai": ">=0.80.8 <0.81", - "@earendil-works/pi-coding-agent": ">=0.80.8 <0.81" + "@earendil-works/pi-agent-core": ">=0.81.1 <0.82", + "@earendil-works/pi-ai": ">=0.81.1 <0.82", + "@earendil-works/pi-coding-agent": ">=0.81.1 <0.82" } }, "node_modules/@anthropic-ai/sdk": { @@ -167,9 +167,9 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.975.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz", - "integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", + "version": "3.976.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.976.0.tgz", + "integrity": "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -187,13 +187,13 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "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==", + "version": "3.972.60", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz", + "integrity": "sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", + "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", @@ -204,13 +204,13 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "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==", + "version": "3.972.62", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz", + "integrity": "sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", + "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", @@ -223,13 +223,13 @@ } }, "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { - "version": "4.9.7", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", - "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", + "version": "4.9.9", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.9.tgz", + "integrity": "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.5", + "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -238,20 +238,20 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "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==", + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz", + "integrity": "sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@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/core": "^3.976.0", + "@aws-sdk/credential-provider-env": "^3.972.60", + "@aws-sdk/credential-provider-http": "^3.972.62", + "@aws-sdk/credential-provider-login": "^3.972.67", + "@aws-sdk/credential-provider-process": "^3.972.60", + "@aws-sdk/credential-provider-sso": "^3.973.4", + "@aws-sdk/credential-provider-web-identity": "^3.972.66", + "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/credential-provider-imds": "^4.4.9", @@ -263,14 +263,14 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "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==", + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz", + "integrity": "sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/core": "^3.976.0", + "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", @@ -281,18 +281,18 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "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==", + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz", + "integrity": "sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@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/credential-provider-env": "^3.972.60", + "@aws-sdk/credential-provider-http": "^3.972.62", + "@aws-sdk/credential-provider-ini": "^3.973.5", + "@aws-sdk/credential-provider-process": "^3.972.60", + "@aws-sdk/credential-provider-sso": "^3.973.4", + "@aws-sdk/credential-provider-web-identity": "^3.972.66", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/credential-provider-imds": "^4.4.9", @@ -304,13 +304,13 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "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==", + "version": "3.972.60", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz", + "integrity": "sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", + "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", @@ -321,15 +321,15 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "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==", + "version": "3.973.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz", + "integrity": "sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/nested-clients": "^3.997.33", - "@aws-sdk/token-providers": "3.1088.0", + "@aws-sdk/core": "^3.976.0", + "@aws-sdk/nested-clients": "^3.997.34", + "@aws-sdk/token-providers": "3.1092.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", @@ -340,14 +340,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1088.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1088.0.tgz", - "integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==", + "version": "3.1092.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz", + "integrity": "sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/core": "^3.976.0", + "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", @@ -358,14 +358,14 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "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==", + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz", + "integrity": "sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/core": "^3.976.0", + "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", @@ -408,13 +408,13 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.41.tgz", - "integrity": "sha512-LSbGvvYmjc4Br9BPYI2dTLnIclmrSiQbahkP4D6nRGVEv4qsCZ8csVuKBPVEEFCVD+EEngGh8ROls6XpumtwMg==", + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.42.tgz", + "integrity": "sha512-dw+GP8DC7QC2C8tUoK7DI8BnrNAjz8tb+uBHSrD2qJvxkCf58kTtFr98pljSrk+umU4n4HDW4eU2k7C2dWMzsg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", + "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", @@ -427,13 +427,13 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "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==", + "version": "3.997.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz", + "integrity": "sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", + "@aws-sdk/core": "^3.976.0", "@aws-sdk/signature-v4-multi-region": "^3.996.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", @@ -447,13 +447,13 @@ } }, "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { - "version": "4.9.7", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", - "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", + "version": "4.9.9", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.9.tgz", + "integrity": "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.5", + "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -984,13 +984,13 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "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==", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz", + "integrity": "sha512-yqbh68CyhqxMov/jUogFJfMqlu2Gd37GAki+tr59YCmAPHfomiCA5ESzusXtpGzABeiZFC/OrRdQ4GwCCOMIHA==", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.10", + "@earendil-works/pi-ai": "^0.81.1", "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.10", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", - "integrity": "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz", + "integrity": "sha512-hzHE7Z8l5mgJk+ke67Lge0rwS2+wbKJrFKl9o5M1R1rh33+cCT7D1AHz1OAtX5wFs90E1/BTGhyJRTUHaMxGvQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1040,16 +1040,16 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent": { - "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==", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.1.tgz", + "integrity": "sha512-r6ovAsZOgAqbC/aU6s+/dPnv/sGZBuWyZNvi3pXjpbuX5wvp3XvGkQI7/VLvX2o9XpmpFaPUxKNym1WfkN/P8A==", "dev": true, "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.10", - "@earendil-works/pi-ai": "^0.80.10", - "@earendil-works/pi-tui": "^0.80.10", + "@earendil-works/pi-agent-core": "^0.81.1", + "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-tui": "^0.81.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -1539,12 +1539,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.10", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.10", + "@earendil-works/pi-ai": "^0.81.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1554,8 +1554,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.80.10", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -1579,8 +1579,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.80.10", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -1695,9 +1695,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1715,9 +1712,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1735,9 +1729,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1755,9 +1746,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1775,9 +1763,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2146,9 +2131,9 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -5345,9 +5330,9 @@ "license": "MIT" }, "node_modules/@smithy/core": { - "version": "3.29.5", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.5.tgz", - "integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==", + "version": "3.29.7", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.7.tgz", + "integrity": "sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5359,13 +5344,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "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==", + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.12.tgz", + "integrity": "sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.5", + "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -5374,13 +5359,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.7", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.7.tgz", - "integrity": "sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==", + "version": "5.6.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.9.tgz", + "integrity": "sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.5", + "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -5417,13 +5402,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.6.6", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.6.tgz", - "integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==", + "version": "5.6.8", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.8.tgz", + "integrity": "sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.5", + "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, diff --git a/package.json b/package.json index 27b7500..b4ac1aa 100644 --- a/package.json +++ b/package.json @@ -83,9 +83,9 @@ }, "devDependencies": { "@changesets/cli": "^2.31.0", - "@earendil-works/pi-agent-core": "^0.80.8", - "@earendil-works/pi-ai": "^0.80.8", - "@earendil-works/pi-coding-agent": "^0.80.8", + "@earendil-works/pi-agent-core": "^0.81.1", + "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-coding-agent": "^0.81.1", "@eslint/js": "^10.0.1", "@types/node": "^24.13.3", "@types/ws": "^8.18.1", @@ -114,9 +114,9 @@ "homepage": "https://pi-web.dev/", "packageManager": "npm@11.11.0", "peerDependencies": { - "@earendil-works/pi-agent-core": ">=0.80.8 <0.81", - "@earendil-works/pi-ai": ">=0.80.8 <0.81", - "@earendil-works/pi-coding-agent": ">=0.80.8 <0.81" + "@earendil-works/pi-agent-core": ">=0.81.1 <0.82", + "@earendil-works/pi-ai": ">=0.81.1 <0.82", + "@earendil-works/pi-coding-agent": ">=0.81.1 <0.82" }, "keywords": [ "pi-package", diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 1a0d5d9..947ee27 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -7,7 +7,7 @@ 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 { installGlobalProviderPolicy } from "./sessions/globalProviderPolicy.js"; +import { installGlobalProviderPolicy, learnGlobalExtensionProviderIds } from "./sessions/globalProviderPolicy.js"; import { registerAuthRoutes } from "./sessions/authRoutes.js"; import { PiSessionService } from "./sessions/piSessionService.js"; import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js"; @@ -70,9 +70,12 @@ await runSessionDaemonStartup({ }), }); auth.subscribe((change) => { sessions.applyAuthChange(change); }); - // PI WEB only supports globally configured providers: reject every + // PI WEB providers come from global sources only. Learn which providers + // the agent directory's global extensions register (they are identical + // for every session, hence daemon-safe), then reject every other // extension provider registration against the shared daemon-wide runtime. - installGlobalProviderPolicy(auth.runtime, (providerId) => { sessions.noteRejectedProviderRegistration(providerId); }); + const globalExtensionProviderIds = await learnGlobalExtensionProviderIds(auth.runtime, activeAgentProfile.dir); + installGlobalProviderPolicy(auth.runtime, globalExtensionProviderIds, (providerId) => { sessions.noteRejectedProviderRegistration(providerId); }); const terminals = new TerminalService(eventHub, workspaceActivity); const runtimeComponent = Object.freeze({ ...getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES), diff --git a/src/server/sessions/globalProviderPolicy.acceptance.test.ts b/src/server/sessions/globalProviderPolicy.acceptance.test.ts index 35d1875..52af212 100644 --- a/src/server/sessions/globalProviderPolicy.acceptance.test.ts +++ b/src/server/sessions/globalProviderPolicy.acceptance.test.ts @@ -4,22 +4,24 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ModelRuntime } from "@earendil-works/pi-coding-agent"; import { InMemoryCredentialStore } from "@earendil-works/pi-ai"; -import { installGlobalProviderPolicy, providerRejectionMessage } from "./globalProviderPolicy.js"; +import { installGlobalProviderPolicy, learnGlobalExtensionProviderIds, providerRejectionMessage } from "./globalProviderPolicy.js"; import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js"; import { PiSessionService } from "./piSessionService.js"; import { CapturingSessionEventHub, createTestModelRuntime, TEST_MODEL_ID, TEST_MODEL_PROVIDER } from "./piSessionService.testSupport.js"; /** * Acceptance tests for the global provider policy, wired exactly as sessiond - * wires it in production: one shared ModelRuntime per daemon, the policy shim - * installed on it, and rejections fed into the session service. Sessions are - * created through the real default runtime factory, so project extensions in a - * temp cwd are genuinely loaded by Pi's `createAgentSessionServices`. + * wires it in production: one shared ModelRuntime per daemon, the global + * extensions' provider ids learned at startup, the policy shim installed on + * the runtime, and rejections fed into the session service. Sessions are + * created through the real default runtime factory, so extensions in a temp + * cwd or temp agent dir are genuinely loaded by Pi's + * `createAgentSessionServices`. * * These tests are also the tripwire for the shim's one piece of machinery - * (instance-method shadowing of `registerProvider`): if a Pi upgrade changes - * how registrations reach the runtime, the load-time and late-registration - * tests here fail loudly. + * (instance-method shadowing of `registerProvider` / `registerNativeProvider` + * / `unregisterProvider`): if a Pi upgrade changes how registrations reach + * the runtime, the load-time and late-registration tests here fail loudly. */ const tempDirs: string[] = []; @@ -56,11 +58,21 @@ async function policyHarness(options: { runtime?: ModelRuntime; agentDir?: strin heartbeatIntervalMs: 60_000, }); services.push(service); - // The exact sessiond wiring: policy on the shared runtime, rejections to the service. - installGlobalProviderPolicy(runtime, (providerId) => { service.noteRejectedProviderRegistration(providerId); }); + // The exact sessiond wiring: learn the agent dir's global-extension + // providers first, then the policy on the shared runtime, rejections to the + // service. + const allowedProviderIds = await learnGlobalExtensionProviderIds(runtime, agentDir); + installGlobalProviderPolicy(runtime, allowedProviderIds, (providerId) => { service.noteRejectedProviderRegistration(providerId); }); return { service, runtime, agentDir }; } +/** Write a global extension into `/extensions/` (agent-dir extensions load for every session). */ +async function agentDirWithExtension(agentDir: string, source: string): Promise { + await mkdir(join(agentDir, "extensions"), { recursive: true }); + await writeFile(join(agentDir, "extensions", "global-probe.js"), source); + return agentDir; +} + /** Write a project extension into `/.pi/extensions/` and return the cwd. */ async function projectWithExtension(source: string): Promise { const cwd = await tempDir("pi-web-policy-project-"); @@ -97,7 +109,7 @@ function providerRegistrationSource(providerId: string): string { return `pi.registerProvider(${JSON.stringify(providerId)}, ${providerConfigJson(providerId)});`; } -const POLICY_WORDING = "PI WEB only supports globally configured providers"; +const POLICY_WORDING = "PI WEB providers must come from global configuration"; describe("global provider policy acceptance", () => { it("rejects a load-time provider registration while the extension's tool and command keep working", async () => { @@ -231,6 +243,59 @@ describe("global provider policy acceptance", () => { expect(runtime.getModel("collide-acme", "model-1")).toBeUndefined(); }); + it("allows providers from global (agent-dir) extensions while still rejecting project extensions", async () => { + const agentDir = await agentDirWithExtension(await tempDir("pi-web-policy-agent-"), ` + export default function (pi) { + ${providerRegistrationSource("global-ext")} + } + `); + const { service, runtime } = await policyHarness({ agentDir }); + const cwd = await projectWithExtension(` + export default function (pi) { + ${providerRegistrationSource("project-ext")} + } + `); + + const session = await service.start(cwd); + const ref = { id: session.id, cwd }; + + // The global extension's provider reached the shared runtime at daemon + // startup and stays usable; the project extension's is rejected. + expect(runtime.getModel("global-ext", "model-1")).toBeDefined(); + expect(runtime.getModel("project-ext", "model-1")).toBeUndefined(); + expect(runtime.getRegisteredProviderIds()).toEqual(["global-ext"]); + const status = await service.status(ref); + expect(status.warnings).toEqual([ + { severity: "warning", message: providerRejectionMessage("project-ext", cwd), source: "runtime" }, + ]); + const models = await service.availableModels(ref); + expect(models.some((model) => model.provider === "global-ext")).toBe(true); + expect(models.some((model) => model.provider === "project-ext")).toBe(false); + }); + + it("lets a global extension re-register its provider late without warnings", async () => { + // The pi-tensorx pattern: register on load, then re-register from + // session_start with a refreshed model catalog. Both calls carry the + // learned id, so neither is a leak. + const agentDir = await agentDirWithExtension(await tempDir("pi-web-policy-agent-"), ` + export default function (pi) { + ${providerRegistrationSource("global-ext")} + pi.on("session_start", () => { + ${providerRegistrationSource("global-ext")} + }); + } + `); + const { service, runtime } = await policyHarness({ agentDir }); + const plainCwd = await tempDir("pi-web-policy-project-"); + + const bystander = await service.start(plainCwd); + const status = await service.status({ id: bystander.id, cwd: plainCwd }); + expect((status.warnings ?? []).filter((warning) => warning.message.includes(POLICY_WORDING))).toEqual([]); + const inbox = service.notificationInbox({ id: bystander.id, cwd: plainCwd }); + expect(inbox.notifications.filter((notification) => notification.message.includes(POLICY_WORDING))).toEqual([]); + expect(runtime.getModel("global-ext", "model-1")).toBeDefined(); + }); + it("does not let a project-level models.json alter the shared runtime's provider set", async () => { // Spike assertion for plan §2: the shared runtime reads providers from the // agent-dir models.json only. A project-level models.json is not a diff --git a/src/server/sessions/globalProviderPolicy.test.ts b/src/server/sessions/globalProviderPolicy.test.ts index 3192160..8abbb47 100644 --- a/src/server/sessions/globalProviderPolicy.test.ts +++ b/src/server/sessions/globalProviderPolicy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import type { Provider } from "@earendil-works/pi-ai"; import { installGlobalProviderPolicy, providerRejectionMessage } from "./globalProviderPolicy.js"; import { createTestModelRuntime, TEST_MODEL_ID, TEST_MODEL_PROVIDER } from "./piSessionService.testSupport.js"; @@ -8,15 +9,33 @@ import { createTestModelRuntime, TEST_MODEL_ID, TEST_MODEL_PROVIDER } from "./pi * every test builds a dedicated runtime rather than touching the shared * `testModelRuntime` from testSupport. */ -async function policyRuntime(): Promise<{ runtime: ModelRuntime; rejections: string[] }> { +async function policyRuntime( + allowedExtensionProviderIds: ReadonlySet = new Set(), +): Promise<{ runtime: ModelRuntime; rejections: string[] }> { const runtime = await createTestModelRuntime(); const rejections: string[] = []; - installGlobalProviderPolicy(runtime, (providerId) => { rejections.push(providerId); }); + installGlobalProviderPolicy(runtime, allowedExtensionProviderIds, (providerId) => { rejections.push(providerId); }); return { runtime, rejections }; } +function nativeProvider(providerId: string): Provider { + return { + id: providerId, + name: providerId, + auth: { + apiKey: { + name: `${providerId} API key`, + resolve: () => Promise.resolve(undefined), + }, + }, + getModels: () => [], + stream: () => { throw new Error("stream should not be called in this test"); }, + streamSimple: () => { throw new Error("streamSimple should not be called in this test"); }, + }; +} + describe("installGlobalProviderPolicy", () => { - it("swallows registrations and records each rejection", async () => { + it("rejects non-allowed registrations and records each rejection", async () => { const { runtime, rejections } = await policyRuntime(); runtime.registerProvider("acme", { baseUrl: "https://acme.example.com" }); @@ -28,11 +47,37 @@ describe("installGlobalProviderPolicy", () => { expect(runtime.getRegisteredProviderConfig("acme")).toBeUndefined(); }); - it("makes unregisterProvider a no-op that cannot remove global providers", async () => { - const { runtime, rejections } = await policyRuntime(); + it("lets allowed (global-extension) providers through to the runtime", async () => { + const { runtime, rejections } = await policyRuntime(new Set(["tensorx"])); + + runtime.registerProvider("tensorx", { baseUrl: "https://tensorx.example.com" }); + runtime.registerProvider("acme", { baseUrl: "https://acme.example.com" }); + + expect(rejections).toEqual(["acme"]); + expect(runtime.getRegisteredProviderIds()).toEqual(["tensorx"]); + expect(runtime.getRegisteredProviderConfig("tensorx")).toEqual({ baseUrl: "https://tensorx.example.com" }); + }); + + it("applies the same allow rule to native provider registrations", async () => { + const { runtime, rejections } = await policyRuntime(new Set(["native-global"])); + + runtime.registerNativeProvider(nativeProvider("native-global")); + runtime.registerNativeProvider(nativeProvider("native-project")); + + expect(rejections).toEqual(["native-project"]); + expect(runtime.getRegisteredProviderIds()).toEqual(["native-global"]); + expect(runtime.getRegisteredNativeProvider("native-global")).toBeDefined(); + }); + + it("unregisters only allowed providers; other unregisters are a no-op", async () => { + const { runtime, rejections } = await policyRuntime(new Set(["tensorx"])); + runtime.registerProvider("tensorx", { baseUrl: "https://tensorx.example.com" }); runtime.unregisterProvider("acme"); runtime.unregisterProvider(TEST_MODEL_PROVIDER); + expect(runtime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID)).toBeDefined(); + + runtime.unregisterProvider("tensorx"); expect(rejections).toEqual([]); expect(runtime.getRegisteredProviderIds()).toEqual([]); @@ -56,7 +101,8 @@ describe("providerRejectionMessage", () => { expect(message).toContain('Provider "acme"'); expect(message).toContain("in /workspace/project"); - expect(message).toContain("PI WEB only supports globally configured providers"); + expect(message).toContain("PI WEB providers must come from global configuration"); + expect(message).toContain("globally installed extension"); expect(message).toContain("All other extension features are unaffected."); }); diff --git a/src/server/sessions/globalProviderPolicy.ts b/src/server/sessions/globalProviderPolicy.ts index 3e67445..05959c2 100644 --- a/src/server/sessions/globalProviderPolicy.ts +++ b/src/server/sessions/globalProviderPolicy.ts @@ -1,38 +1,101 @@ -import type { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAgentSessionServices, type ModelRuntime } from "@earendil-works/pi-coding-agent"; /** - * PI WEB supports only globally configured providers: Pi built-ins, agent-dir - * `models.json`, and environment credentials. Any provider an extension tries - * to register is rejected — the user is told, and everything else the - * extension does keeps working. + * PI WEB providers come from global sources only: Pi built-ins, agent-dir + * `models.json`, environment credentials, and providers registered by global + * (agent-dir) extensions. Provider registrations from project extensions are + * rejected — the user is told, and everything else the extension does keeps + * working. * - * Why: all sessions share one daemon-wide {@link ModelRuntime}. Letting one - * workspace's extensions mutate it corrupts the provider set of every other - * concurrent session (issue #76). Rather than building scoped-provider - * isolation, pi-web rejects scoped registrations outright. + * Why: all sessions share one daemon-wide {@link ModelRuntime}. Global + * extensions are identical for every session, so their providers are + * daemon-wide consistent; project extensions differ per workspace, and letting + * them mutate the shared runtime corrupts the provider set of every other + * concurrent session (issue #76). * * Mechanism: this deliberately shadows the `registerProvider` / - * `unregisterProvider` instance methods because Pi 0.80.10 offers no - * registration hook. Both Pi call sites (the load-time - * `pendingProviderRegistrations` drain in `createAgentSessionServices` and the - * late `pi.registerProvider` path through `ModelRegistry`) reach the runtime - * through call-time property lookup, so instance shadowing intercepts them - * identically. The acceptance test that exercises both paths is the tripwire: - * if a Pi upgrade changes these internals, that test fails loudly and this - * shim must be revisited. + * `registerNativeProvider` / `unregisterProvider` instance methods because Pi + * 0.81.1 offers no registration hook with extension attribution (the internal + * drain, the bind-time flush, and the late `pi.registerProvider` path all + * reach the runtime through call-time property lookup with the extension path + * already dropped, so instance shadowing intercepts them identically). The + * acceptance suite that exercises the load-time and late paths is the + * tripwire: if a Pi upgrade changes these internals, those tests fail loudly + * and this shim must be revisited. + * + * Attribution: Pi drops the registering extension's path before calls reach + * the runtime, so the shim cannot tell global from project extensions per + * call. Instead the daemon learns once, at startup, which provider ids global + * extensions register ({@link learnGlobalExtensionProviderIds}) and allows + * exactly those. This is not a security boundary — extensions run in-process + * with full trust, and a project extension re-registering an allowed id would + * pass. It is a guard against accidental cross-workspace leakage. */ export function installGlobalProviderPolicy( runtime: ModelRuntime, + allowedExtensionProviderIds: ReadonlySet, onRejection: (providerId: string) => void, ): void { - runtime.registerProvider = (providerId: string) => { + const registerProvider = runtime.registerProvider.bind(runtime); + const registerNativeProvider = runtime.registerNativeProvider.bind(runtime); + const unregisterProvider = runtime.unregisterProvider.bind(runtime); + + runtime.registerProvider = (providerId, config) => { + if (allowedExtensionProviderIds.has(providerId)) { + registerProvider(providerId, config); + return; + } // Swallow: the shared runtime is never mutated, the rejection is surfaced. onRejection(providerId); }; - runtime.unregisterProvider = () => { - // No-op: with every registration rejected, the extension provider layer is - // always empty, so there is never anything to unregister. + runtime.registerNativeProvider = (provider) => { + if (allowedExtensionProviderIds.has(provider.id)) { + registerNativeProvider(provider); + return; + } + onRejection(provider.id); }; + runtime.unregisterProvider = (providerId) => { + if (allowedExtensionProviderIds.has(providerId)) { + unregisterProvider(providerId); + return; + } + // No-op: rejected registrations never reached the runtime, so there is + // never anything of theirs to unregister. + }; +} + +/** + * Learn which provider ids the agent directory's global extensions register. + * + * Loads extensions once for a guaranteed-empty temporary cwd — so only global + * (agent-dir) extensions load — against the daemon's shared runtime, and + * returns the ids that appeared. Those registrations stay on the runtime: they + * are the daemon baseline, re-registered identically on every session load. + * Must run before {@link installGlobalProviderPolicy} is installed. + * + * Caveat: global extension code runs one extra time at daemon startup. Any + * commands/tools it registers land on the discarded scratch loader. + */ +export async function learnGlobalExtensionProviderIds( + runtime: ModelRuntime, + agentDir: string, +): Promise> { + const before = new Set(runtime.getRegisteredProviderIds()); + const scratchCwd = await mkdtemp(join(tmpdir(), "pi-web-global-ext-")); + try { + await createAgentSessionServices({ cwd: scratchCwd, agentDir, modelRuntime: runtime }); + } finally { + await rm(scratchCwd, { recursive: true, force: true }); + } + const learned = new Set(); + for (const providerId of runtime.getRegisteredProviderIds()) { + if (!before.has(providerId)) learned.add(providerId); + } + return learned; } /** @@ -42,6 +105,6 @@ export function installGlobalProviderPolicy( */ export function providerRejectionMessage(providerId: string, cwd?: string): string { const origin = cwd === undefined ? "registered by an extension" : `registered by an extension in ${cwd}`; - return `Provider "${providerId}" ${origin} was ignored — PI WEB only supports globally configured providers. ` - + "Configure it globally (e.g. agent-dir models.json) to use it here. All other extension features are unaffected."; + return `Provider "${providerId}" ${origin} was ignored — PI WEB providers must come from global configuration ` + + "(agent-dir models.json) or a globally installed extension. All other extension features are unaffected."; } diff --git a/src/server/sessions/piSessionService.promptQueue.test.ts b/src/server/sessions/piSessionService.promptQueue.test.ts index 84f22fa..9b8a04f 100644 --- a/src/server/sessions/piSessionService.promptQueue.test.ts +++ b/src/server/sessions/piSessionService.promptQueue.test.ts @@ -99,7 +99,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { return stream; }; const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("name-session", { model, agent: { streamFn } }); + const fake = fakeRuntime("name-session", { model, agent: { streamFunction: streamFn } }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, modelRuntime: testModelRuntime, diff --git a/src/server/sessions/piSessionService.testSupport.ts b/src/server/sessions/piSessionService.testSupport.ts index c25556b..c6c7635 100644 --- a/src/server/sessions/piSessionService.testSupport.ts +++ b/src/server/sessions/piSessionService.testSupport.ts @@ -208,7 +208,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial setSessionName: (name: string) => { session.sessionName = name; }, compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }), getUserMessagesForForking: () => [], - agent: { streamFn: () => { throw new Error("streamFn should not be called in this test"); } }, + agent: { streamFunction: () => { throw new Error("streamFunction should not be called in this test"); } }, ...patch, }; const runtime: PiSessionRuntime = { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 62b3294..3fd4617 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -346,13 +346,13 @@ export interface PiAgentSession { setSessionName(name: string): void; /** * Narrow re-expression of `AgentSession.agent` (an `@earendil-works/pi-agent-core` - * `Agent`), exposing only `streamFn` — the resolved-auth/headers/retry "call this - * model" function pi's own compaction/branch-summarization code uses internally. - * Lets callers (e.g. session title generation) issue one-off model calls without - * depending on pi-ai's deprecated `/compat` provider registry or leaking the full - * `Agent`/`AgentSession` surface. + * `Agent`), exposing only `streamFunction` — the resolved-auth/headers/retry "call + * this model" function pi's own compaction/branch-summarization code uses + * internally. Lets callers (e.g. session title generation) issue one-off model + * calls without depending on pi-ai's deprecated `/compat` provider registry or + * leaking the full `Agent`/`AgentSession` surface. */ - agent: { streamFn: StreamFn }; + agent: { streamFunction: StreamFn }; } export interface PiSessionRuntime { @@ -2761,7 +2761,7 @@ export class PiSessionService implements SessionRouteService { const model = session.model; if (model === undefined) return; - void generateShortSessionName(session.agent.streamFn, model, firstMessage).then((name) => { + void generateShortSessionName(session.agent.streamFunction, model, firstMessage).then((name) => { this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage)); }).catch(() => { this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));