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,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<string> = 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.");
});