From c2bf59599965aa0378c14f9b961253d48deee301 Mon Sep 17 00:00:00 2001
From: Federico Jaramillo Martinez
Date: Wed, 22 Jul 2026 17:13:57 +0200
Subject: [PATCH] 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).
---
.changeset/global-provider-policy.md | 2 +-
docs/plugins.html | 11 +-
docs/plugins.md | 4 +-
package-lock.json | 247 ++++++++----------
package.json | 12 +-
src/server/sessiond.ts | 9 +-
.../globalProviderPolicy.acceptance.test.ts | 87 +++++-
.../sessions/globalProviderPolicy.test.ts | 58 +++-
src/server/sessions/globalProviderPolicy.ts | 109 ++++++--
.../piSessionService.promptQueue.test.ts | 2 +-
.../sessions/piSessionService.testSupport.ts | 2 +-
src/server/sessions/piSessionService.ts | 14 +-
12 files changed, 360 insertions(+), 197 deletions(-)
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));