feat(sessions): allow global-extension providers, require Pi 0.81

Relaxes the provider policy from 'global config only' to 'global sources':
providers registered by agent-dir (global) extensions are learned once at
daemon startup and allowed on the shared runtime; project-extension
registrations are still rejected with a session warning. Global extensions
load identically for every session, so their providers are daemon-consistent
and cannot leak project state (#76).

- Shim now allows allowlisted ids through and also covers Pi 0.81's native
  provider path (registerNativeProvider), closing a bypass.
- Startup learning step loads only global extensions against a scratch cwd
  and diffs the runtime's registered provider ids.
- Bumps @earendil-works/* dev/peer ranges to >=0.81.1 <0.82; adapts to the
  Agent.streamFn -> streamFunction rename.
- Docs, changeset, unit and acceptance tests updated (global-extension allow
  path, late re-registration a la pi-tensorx, native provider rule).
This commit is contained in:
Federico Jaramillo Martinez
2026-07-22 17:13:57 +02:00
parent 242911331a
commit c2bf595999
12 changed files with 360 additions and 197 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch "@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`).
+6 -5
View File
@@ -163,11 +163,12 @@
PI WEB browser plugins. A routine session daemon restart is not required. PI WEB browser plugins. A routine session daemon restart is not required.
</p> </p>
<p> <p>
One exception applies to Pi package extensions: PI WEB supports only globally configured providers One exception applies to Pi package extensions: PI WEB providers come from global sources only
(Pi built-ins, environment credentials, and the agent directory's <code>models.json</code>). If an (Pi built-ins, environment credentials, the agent directory's <code>models.json</code>, and providers
extension calls <code>pi.registerProvider</code>, PI WEB ignores the registration and warns in the registered by globally installed, agent-dir extensions). If a project extension calls
session; everything else the extension registers keeps working. Configure such providers globally in <code>pi.registerProvider</code>, PI WEB ignores the registration and warns in the session; everything
the agent directory's <code>models.json</code> instead. else the extension registers keeps working. Move such a provider to a global source: declare it in
the agent directory's <code>models.json</code>, or install the extension globally.
</p> </p>
</section> </section>
+2 -2
View File
@@ -27,9 +27,9 @@ Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB brows
## Extension provider registrations ## 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 ## Trust model
+116 -131
View File
@@ -42,9 +42,9 @@
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.31.0", "@changesets/cli": "^2.31.0",
"@earendil-works/pi-agent-core": "^0.80.8", "@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.80.8", "@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-coding-agent": "^0.80.8", "@earendil-works/pi-coding-agent": "^0.81.1",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
@@ -61,9 +61,9 @@
"node": ">=22.19.0" "node": ">=22.19.0"
}, },
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-agent-core": ">=0.80.8 <0.81", "@earendil-works/pi-agent-core": ">=0.81.1 <0.82",
"@earendil-works/pi-ai": ">=0.80.8 <0.81", "@earendil-works/pi-ai": ">=0.81.1 <0.82",
"@earendil-works/pi-coding-agent": ">=0.80.8 <0.81" "@earendil-works/pi-coding-agent": ">=0.81.1 <0.82"
} }
}, },
"node_modules/@anthropic-ai/sdk": { "node_modules/@anthropic-ai/sdk": {
@@ -167,9 +167,9 @@
} }
}, },
"node_modules/@aws-sdk/core": { "node_modules/@aws-sdk/core": {
"version": "3.975.3", "version": "3.976.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.976.0.tgz",
"integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", "integrity": "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -187,13 +187,13 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-env": { "node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.59", "version": "3.972.60",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.59.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz",
"integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==", "integrity": "sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@@ -204,13 +204,13 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-http": { "node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.61", "version": "3.972.62",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.61.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz",
"integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==", "integrity": "sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6", "@smithy/fetch-http-handler": "^5.6.6",
@@ -223,13 +223,13 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": {
"version": "4.9.7", "version": "4.9.9",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.9.tgz",
"integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", "integrity": "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.29.5", "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
@@ -238,20 +238,20 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-ini": { "node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.4", "version": "3.973.5",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.4.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz",
"integrity": "sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==", "integrity": "sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/credential-provider-env": "^3.972.59", "@aws-sdk/credential-provider-env": "^3.972.60",
"@aws-sdk/credential-provider-http": "^3.972.61", "@aws-sdk/credential-provider-http": "^3.972.62",
"@aws-sdk/credential-provider-login": "^3.972.66", "@aws-sdk/credential-provider-login": "^3.972.67",
"@aws-sdk/credential-provider-process": "^3.972.59", "@aws-sdk/credential-provider-process": "^3.972.60",
"@aws-sdk/credential-provider-sso": "^3.973.3", "@aws-sdk/credential-provider-sso": "^3.973.4",
"@aws-sdk/credential-provider-web-identity": "^3.972.65", "@aws-sdk/credential-provider-web-identity": "^3.972.66",
"@aws-sdk/nested-clients": "^3.997.33", "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/credential-provider-imds": "^4.4.9", "@smithy/credential-provider-imds": "^4.4.9",
@@ -263,14 +263,14 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-login": { "node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.66", "version": "3.972.67",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.66.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz",
"integrity": "sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==", "integrity": "sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/nested-clients": "^3.997.33", "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@@ -281,18 +281,18 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-node": { "node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.70", "version": "3.972.71",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.70.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz",
"integrity": "sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==", "integrity": "sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.59", "@aws-sdk/credential-provider-env": "^3.972.60",
"@aws-sdk/credential-provider-http": "^3.972.61", "@aws-sdk/credential-provider-http": "^3.972.62",
"@aws-sdk/credential-provider-ini": "^3.973.4", "@aws-sdk/credential-provider-ini": "^3.973.5",
"@aws-sdk/credential-provider-process": "^3.972.59", "@aws-sdk/credential-provider-process": "^3.972.60",
"@aws-sdk/credential-provider-sso": "^3.973.3", "@aws-sdk/credential-provider-sso": "^3.973.4",
"@aws-sdk/credential-provider-web-identity": "^3.972.65", "@aws-sdk/credential-provider-web-identity": "^3.972.66",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/credential-provider-imds": "^4.4.9", "@smithy/credential-provider-imds": "^4.4.9",
@@ -304,13 +304,13 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-process": { "node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.59", "version": "3.972.60",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.59.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz",
"integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==", "integrity": "sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@@ -321,15 +321,15 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-sso": { "node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.3", "version": "3.973.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.3.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz",
"integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==", "integrity": "sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/nested-clients": "^3.997.33", "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/token-providers": "3.1088.0", "@aws-sdk/token-providers": "3.1092.0",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@@ -340,14 +340,14 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
"version": "3.1088.0", "version": "3.1092.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1088.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz",
"integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==", "integrity": "sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/nested-clients": "^3.997.33", "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@@ -358,14 +358,14 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-web-identity": { "node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.65", "version": "3.972.66",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.65.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz",
"integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==", "integrity": "sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/nested-clients": "^3.997.33", "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@@ -408,13 +408,13 @@
} }
}, },
"node_modules/@aws-sdk/middleware-websocket": { "node_modules/@aws-sdk/middleware-websocket": {
"version": "3.972.41", "version": "3.972.42",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.41.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.42.tgz",
"integrity": "sha512-LSbGvvYmjc4Br9BPYI2dTLnIclmrSiQbahkP4D6nRGVEv4qsCZ8csVuKBPVEEFCVD+EEngGh8ROls6XpumtwMg==", "integrity": "sha512-dw+GP8DC7QC2C8tUoK7DI8BnrNAjz8tb+uBHSrD2qJvxkCf58kTtFr98pljSrk+umU4n4HDW4eU2k7C2dWMzsg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6", "@smithy/fetch-http-handler": "^5.6.6",
@@ -427,13 +427,13 @@
} }
}, },
"node_modules/@aws-sdk/nested-clients": { "node_modules/@aws-sdk/nested-clients": {
"version": "3.997.33", "version": "3.997.34",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.33.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz",
"integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==", "integrity": "sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/signature-v4-multi-region": "^3.996.41", "@aws-sdk/signature-v4-multi-region": "^3.996.41",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
@@ -447,13 +447,13 @@
} }
}, },
"node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": {
"version": "4.9.7", "version": "4.9.9",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.9.tgz",
"integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", "integrity": "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.29.5", "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
@@ -984,13 +984,13 @@
} }
}, },
"node_modules/@earendil-works/pi-agent-core": { "node_modules/@earendil-works/pi-agent-core": {
"version": "0.80.10", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
"integrity": "sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==", "integrity": "sha512-yqbh68CyhqxMov/jUogFJfMqlu2Gd37GAki+tr59YCmAPHfomiCA5ESzusXtpGzABeiZFC/OrRdQ4GwCCOMIHA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.80.10", "@earendil-works/pi-ai": "^0.81.1",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.1.38",
"yaml": "2.9.0" "yaml": "2.9.0"
@@ -1007,9 +1007,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@earendil-works/pi-ai": { "node_modules/@earendil-works/pi-ai": {
"version": "0.80.10", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
"integrity": "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==", "integrity": "sha512-hzHE7Z8l5mgJk+ke67Lge0rwS2+wbKJrFKl9o5M1R1rh33+cCT7D1AHz1OAtX5wFs90E1/BTGhyJRTUHaMxGvQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1040,16 +1040,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@earendil-works/pi-coding-agent": { "node_modules/@earendil-works/pi-coding-agent": {
"version": "0.80.10", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.10.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.1.tgz",
"integrity": "sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==", "integrity": "sha512-r6ovAsZOgAqbC/aU6s+/dPnv/sGZBuWyZNvi3pXjpbuX5wvp3XvGkQI7/VLvX2o9XpmpFaPUxKNym1WfkN/P8A==",
"dev": true, "dev": true,
"hasShrinkwrap": true, "hasShrinkwrap": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "^0.80.10", "@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.80.10", "@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-tui": "^0.80.10", "@earendil-works/pi-tui": "^0.81.1",
"@silvia-odwyer/photon-node": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2", "chalk": "5.6.2",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
@@ -1539,12 +1539,12 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
"version": "0.80.10", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.80.10", "@earendil-works/pi-ai": "^0.81.1",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.1.38",
"yaml": "2.9.0" "yaml": "2.9.0"
@@ -1554,8 +1554,8 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": {
"version": "0.80.10", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1579,8 +1579,8 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": {
"version": "0.80.10", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1695,9 +1695,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1715,9 +1712,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1735,9 +1729,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1755,9 +1746,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1775,9 +1763,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2146,9 +2131,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5345,9 +5330,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@smithy/core": { "node_modules/@smithy/core": {
"version": "3.29.5", "version": "3.29.7",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.5.tgz", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.7.tgz",
"integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==", "integrity": "sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -5359,13 +5344,13 @@
} }
}, },
"node_modules/@smithy/credential-provider-imds": { "node_modules/@smithy/credential-provider-imds": {
"version": "4.4.10", "version": "4.4.12",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.10.tgz", "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.12.tgz",
"integrity": "sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==", "integrity": "sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.29.5", "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
@@ -5374,13 +5359,13 @@
} }
}, },
"node_modules/@smithy/fetch-http-handler": { "node_modules/@smithy/fetch-http-handler": {
"version": "5.6.7", "version": "5.6.9",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.7.tgz", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.9.tgz",
"integrity": "sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==", "integrity": "sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.29.5", "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
@@ -5417,13 +5402,13 @@
} }
}, },
"node_modules/@smithy/signature-v4": { "node_modules/@smithy/signature-v4": {
"version": "5.6.6", "version": "5.6.8",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.6.tgz", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.8.tgz",
"integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==", "integrity": "sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.29.5", "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
+6 -6
View File
@@ -83,9 +83,9 @@
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.31.0", "@changesets/cli": "^2.31.0",
"@earendil-works/pi-agent-core": "^0.80.8", "@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.80.8", "@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-coding-agent": "^0.80.8", "@earendil-works/pi-coding-agent": "^0.81.1",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
@@ -114,9 +114,9 @@
"homepage": "https://pi-web.dev/", "homepage": "https://pi-web.dev/",
"packageManager": "[email protected]", "packageManager": "[email protected]",
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-agent-core": ">=0.80.8 <0.81", "@earendil-works/pi-agent-core": ">=0.81.1 <0.82",
"@earendil-works/pi-ai": ">=0.80.8 <0.81", "@earendil-works/pi-ai": ">=0.81.1 <0.82",
"@earendil-works/pi-coding-agent": ">=0.80.8 <0.81" "@earendil-works/pi-coding-agent": ">=0.81.1 <0.82"
}, },
"keywords": [ "keywords": [
"pi-package", "pi-package",
+6 -3
View File
@@ -7,7 +7,7 @@ import { WorkspaceActivityService } from "./activity/workspaceActivityService.js
import { registerWorkspaceActivityRoutes } from "./activity/workspaceActivityRoutes.js"; import { registerWorkspaceActivityRoutes } from "./activity/workspaceActivityRoutes.js";
import { SessionEventHub } from "./realtime/sessionEventHub.js"; import { SessionEventHub } from "./realtime/sessionEventHub.js";
import { AuthService } from "./sessions/authService.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 { registerAuthRoutes } from "./sessions/authRoutes.js";
import { PiSessionService } from "./sessions/piSessionService.js"; import { PiSessionService } from "./sessions/piSessionService.js";
import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js"; import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js";
@@ -70,9 +70,12 @@ await runSessionDaemonStartup({
}), }),
}); });
auth.subscribe((change) => { sessions.applyAuthChange(change); }); 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. // 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 terminals = new TerminalService(eventHub, workspaceActivity);
const runtimeComponent = Object.freeze({ const runtimeComponent = Object.freeze({
...getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES), ...getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES),
@@ -4,22 +4,24 @@ import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { ModelRuntime } from "@earendil-works/pi-coding-agent"; import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { InMemoryCredentialStore } from "@earendil-works/pi-ai"; 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 { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
import { PiSessionService } from "./piSessionService.js"; import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, createTestModelRuntime, TEST_MODEL_ID, TEST_MODEL_PROVIDER } from "./piSessionService.testSupport.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 * Acceptance tests for the global provider policy, wired exactly as sessiond
* wires it in production: one shared ModelRuntime per daemon, the policy shim * wires it in production: one shared ModelRuntime per daemon, the global
* installed on it, and rejections fed into the session service. Sessions are * extensions' provider ids learned at startup, the policy shim installed on
* created through the real default runtime factory, so project extensions in a * the runtime, and rejections fed into the session service. Sessions are
* temp cwd are genuinely loaded by Pi's `createAgentSessionServices`. * 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 * These tests are also the tripwire for the shim's one piece of machinery
* (instance-method shadowing of `registerProvider`): if a Pi upgrade changes * (instance-method shadowing of `registerProvider` / `registerNativeProvider`
* how registrations reach the runtime, the load-time and late-registration * / `unregisterProvider`): if a Pi upgrade changes how registrations reach
* tests here fail loudly. * the runtime, the load-time and late-registration tests here fail loudly.
*/ */
const tempDirs: string[] = []; const tempDirs: string[] = [];
@@ -56,11 +58,21 @@ async function policyHarness(options: { runtime?: ModelRuntime; agentDir?: strin
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
}); });
services.push(service); services.push(service);
// The exact sessiond wiring: policy on the shared runtime, rejections to the service. // The exact sessiond wiring: learn the agent dir's global-extension
installGlobalProviderPolicy(runtime, (providerId) => { service.noteRejectedProviderRegistration(providerId); }); // 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 }; return { service, runtime, agentDir };
} }
/** Write a global extension into `<agentDir>/extensions/` (agent-dir extensions load for every session). */
async function agentDirWithExtension(agentDir: string, source: string): Promise<string> {
await mkdir(join(agentDir, "extensions"), { recursive: true });
await writeFile(join(agentDir, "extensions", "global-probe.js"), source);
return agentDir;
}
/** Write a project extension into `<cwd>/.pi/extensions/` and return the cwd. */ /** Write a project extension into `<cwd>/.pi/extensions/` and return the cwd. */
async function projectWithExtension(source: string): Promise<string> { async function projectWithExtension(source: string): Promise<string> {
const cwd = await tempDir("pi-web-policy-project-"); 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)});`; 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", () => { describe("global provider policy acceptance", () => {
it("rejects a load-time provider registration while the extension's tool and command keep working", async () => { 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(); 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 () => { 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 // 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 // agent-dir models.json only. A project-level models.json is not a
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ModelRuntime } from "@earendil-works/pi-coding-agent"; import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
import type { Provider } from "@earendil-works/pi-ai";
import { installGlobalProviderPolicy, providerRejectionMessage } from "./globalProviderPolicy.js"; import { installGlobalProviderPolicy, providerRejectionMessage } from "./globalProviderPolicy.js";
import { createTestModelRuntime, TEST_MODEL_ID, TEST_MODEL_PROVIDER } from "./piSessionService.testSupport.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 * every test builds a dedicated runtime rather than touching the shared
* `testModelRuntime` from testSupport. * `testModelRuntime` from testSupport.
*/ */
async function policyRuntime(): Promise<{ runtime: ModelRuntime; rejections: string[] }> { async function policyRuntime(
allowedExtensionProviderIds: ReadonlySet<string> = new Set(),
): Promise<{ runtime: ModelRuntime; rejections: string[] }> {
const runtime = await createTestModelRuntime(); const runtime = await createTestModelRuntime();
const rejections: string[] = []; const rejections: string[] = [];
installGlobalProviderPolicy(runtime, (providerId) => { rejections.push(providerId); }); installGlobalProviderPolicy(runtime, allowedExtensionProviderIds, (providerId) => { rejections.push(providerId); });
return { runtime, rejections }; 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", () => { 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(); const { runtime, rejections } = await policyRuntime();
runtime.registerProvider("acme", { baseUrl: "https://acme.example.com" }); runtime.registerProvider("acme", { baseUrl: "https://acme.example.com" });
@@ -28,11 +47,37 @@ describe("installGlobalProviderPolicy", () => {
expect(runtime.getRegisteredProviderConfig("acme")).toBeUndefined(); expect(runtime.getRegisteredProviderConfig("acme")).toBeUndefined();
}); });
it("makes unregisterProvider a no-op that cannot remove global providers", async () => { it("lets allowed (global-extension) providers through to the runtime", async () => {
const { runtime, rejections } = await policyRuntime(); 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("acme");
runtime.unregisterProvider(TEST_MODEL_PROVIDER); runtime.unregisterProvider(TEST_MODEL_PROVIDER);
expect(runtime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID)).toBeDefined();
runtime.unregisterProvider("tensorx");
expect(rejections).toEqual([]); expect(rejections).toEqual([]);
expect(runtime.getRegisteredProviderIds()).toEqual([]); expect(runtime.getRegisteredProviderIds()).toEqual([]);
@@ -56,7 +101,8 @@ describe("providerRejectionMessage", () => {
expect(message).toContain('Provider "acme"'); expect(message).toContain('Provider "acme"');
expect(message).toContain("in /workspace/project"); 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."); expect(message).toContain("All other extension features are unaffected.");
}); });
+86 -23
View File
@@ -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 * PI WEB providers come from global sources only: Pi built-ins, agent-dir
* `models.json`, and environment credentials. Any provider an extension tries * `models.json`, environment credentials, and providers registered by global
* to register is rejected — the user is told, and everything else the * (agent-dir) extensions. Provider registrations from project extensions are
* extension does keeps working. * rejected — the user is told, and everything else the extension does keeps
* working.
* *
* Why: all sessions share one daemon-wide {@link ModelRuntime}. Letting one * Why: all sessions share one daemon-wide {@link ModelRuntime}. Global
* workspace's extensions mutate it corrupts the provider set of every other * extensions are identical for every session, so their providers are
* concurrent session (issue #76). Rather than building scoped-provider * daemon-wide consistent; project extensions differ per workspace, and letting
* isolation, pi-web rejects scoped registrations outright. * them mutate the shared runtime corrupts the provider set of every other
* concurrent session (issue #76).
* *
* Mechanism: this deliberately shadows the `registerProvider` / * Mechanism: this deliberately shadows the `registerProvider` /
* `unregisterProvider` instance methods because Pi 0.80.10 offers no * `registerNativeProvider` / `unregisterProvider` instance methods because Pi
* registration hook. Both Pi call sites (the load-time * 0.81.1 offers no registration hook with extension attribution (the internal
* `pendingProviderRegistrations` drain in `createAgentSessionServices` and the * drain, the bind-time flush, and the late `pi.registerProvider` path all
* late `pi.registerProvider` path through `ModelRegistry`) reach the runtime * reach the runtime through call-time property lookup with the extension path
* through call-time property lookup, so instance shadowing intercepts them * already dropped, so instance shadowing intercepts them identically). The
* identically. The acceptance test that exercises both paths is the tripwire: * acceptance suite that exercises the load-time and late paths is the
* if a Pi upgrade changes these internals, that test fails loudly and this * tripwire: if a Pi upgrade changes these internals, those tests fail loudly
* shim must be revisited. * 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( export function installGlobalProviderPolicy(
runtime: ModelRuntime, runtime: ModelRuntime,
allowedExtensionProviderIds: ReadonlySet<string>,
onRejection: (providerId: string) => void, onRejection: (providerId: string) => void,
): 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. // Swallow: the shared runtime is never mutated, the rejection is surfaced.
onRejection(providerId); onRejection(providerId);
}; };
runtime.unregisterProvider = () => { runtime.registerNativeProvider = (provider) => {
// No-op: with every registration rejected, the extension provider layer is if (allowedExtensionProviderIds.has(provider.id)) {
// always empty, so there is never anything to unregister. 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<ReadonlySet<string>> {
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<string>();
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 { export function providerRejectionMessage(providerId: string, cwd?: string): string {
const origin = cwd === undefined ? "registered by an extension" : `registered by an extension in ${cwd}`; 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. ` return `Provider "${providerId}" ${origin} was ignored — PI WEB providers must come from global configuration `
+ "Configure it globally (e.g. agent-dir models.json) to use it here. All other extension features are unaffected."; + "(agent-dir models.json) or a globally installed extension. All other extension features are unaffected.";
} }
@@ -99,7 +99,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
return stream; return stream;
}; };
const hub = new CapturingSessionEventHub(); 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, { const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR, agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime, modelRuntime: testModelRuntime,
@@ -208,7 +208,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession>
setSessionName: (name: string) => { session.sessionName = name; }, setSessionName: (name: string) => { session.sessionName = name; },
compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }), compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }),
getUserMessagesForForking: () => [], 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, ...patch,
}; };
const runtime: PiSessionRuntime = { const runtime: PiSessionRuntime = {
+7 -7
View File
@@ -346,13 +346,13 @@ export interface PiAgentSession {
setSessionName(name: string): void; setSessionName(name: string): void;
/** /**
* Narrow re-expression of `AgentSession.agent` (an `@earendil-works/pi-agent-core` * Narrow re-expression of `AgentSession.agent` (an `@earendil-works/pi-agent-core`
* `Agent`), exposing only `streamFn` — the resolved-auth/headers/retry "call this * `Agent`), exposing only `streamFunction` — the resolved-auth/headers/retry "call
* model" function pi's own compaction/branch-summarization code uses internally. * this model" function pi's own compaction/branch-summarization code uses
* Lets callers (e.g. session title generation) issue one-off model calls without * internally. Lets callers (e.g. session title generation) issue one-off model
* depending on pi-ai's deprecated `/compat` provider registry or leaking the full * calls without depending on pi-ai's deprecated `/compat` provider registry or
* `Agent`/`AgentSession` surface. * leaking the full `Agent`/`AgentSession` surface.
*/ */
agent: { streamFn: StreamFn }; agent: { streamFunction: StreamFn };
} }
export interface PiSessionRuntime { export interface PiSessionRuntime {
@@ -2761,7 +2761,7 @@ export class PiSessionService implements SessionRouteService {
const model = session.model; const model = session.model;
if (model === undefined) return; 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)); this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
}).catch(() => { }).catch(() => {
this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage)); this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));