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
+6 -3
View File
@@ -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),
@@ -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 `<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. */
async function projectWithExtension(source: string): Promise<string> {
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
@@ -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.");
});
+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
* `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<string>,
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<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 {
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.";
}
@@ -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,
@@ -208,7 +208,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession>
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 = {
+7 -7
View File
@@ -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));