feat(sessions): reject extension-scoped provider registrations

PI WEB only supports globally configured providers (Pi built-ins,
agent-dir models.json, environment credentials). A daemon-wide shim on
the shared ModelRuntime swallows extension registerProvider calls and
makes unregisterProvider a no-op, so one workspace's extensions can no
longer corrupt the provider set of concurrent sessions (issue #76).

Rejections during a services load surface as session warnings through
the existing diagnostics pipeline; late registrations from session
event handlers broadcast a notification to active sessions. Everything
else extensions register keeps working.

Requires manual restart of pi-web-sessiond.service (daemon wiring changed).
This commit is contained in:
Federico Jaramillo Martinez
2026-07-22 08:57:05 +02:00
parent 24a3d3611e
commit 20d424c48f
3 changed files with 117 additions and 1 deletions
+4
View File
@@ -7,6 +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 { 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";
@@ -69,6 +70,9 @@ await runSessionDaemonStartup({
}), }),
}); });
auth.subscribe((change) => { sessions.applyAuthChange(change); }); auth.subscribe((change) => { sessions.applyAuthChange(change); });
// PI WEB only supports globally configured providers: reject every
// extension provider registration against the shared daemon-wide runtime.
installGlobalProviderPolicy(auth.runtime, (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),
@@ -0,0 +1,47 @@
import 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.
*
* 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.
*
* 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.
*/
export function installGlobalProviderPolicy(
runtime: ModelRuntime,
onRejection: (providerId: string) => void,
): void {
runtime.registerProvider = (providerId: string) => {
// 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.
};
}
/**
* User-facing wording for a rejected registration. `cwd` is known only for
* rejections raised while loading a workspace's services; late registrations
* from session event handlers carry no attribution beyond the provider id.
*/
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.";
}
+66 -1
View File
@@ -65,6 +65,7 @@ import {
type SessionNotificationMutation, type SessionNotificationMutation,
} from "./sessionNotificationStore.js"; } from "./sessionNotificationStore.js";
import { plainTextTheme } from "./plainTextTheme.js"; import { plainTextTheme } from "./plainTextTheme.js";
import { providerRejectionMessage } from "./globalProviderPolicy.js";
import { SessionUnreadStore, type SessionUnreadMutation } from "./sessionUnreadStore.js"; import { SessionUnreadStore, type SessionUnreadMutation } from "./sessionUnreadStore.js";
/** /**
@@ -580,14 +581,38 @@ export function createPiWebCustomToolDefinitions(
]; ];
} }
/**
* Correlates provider registrations rejected by the global provider policy
* with the services load that triggered them. `begin` returns the mutable list
* the policy listener appends to while the load is in flight; `end` detaches
* it. Implemented by {@link PiSessionService}, which owns the in-flight set.
*/
interface ProviderRejectionTracker {
begin(): string[];
end(rejectedProviderIds: string[]): void;
}
function createDefaultRuntimeFactory( function createDefaultRuntimeFactory(
modelRuntime: ModelRuntime, modelRuntime: ModelRuntime,
sessionManagers: Pick<PiSessionManagerGateway, "open">, sessionManagers: Pick<PiSessionManagerGateway, "open">,
spawn?: SpawnSessionFn, spawn?: SpawnSessionFn,
subsessions?: SubsessionToolDeps, subsessions?: SubsessionToolDeps,
providerRejections?: ProviderRejectionTracker,
): PiWebCreateAgentSessionRuntimeFactory { ): PiWebCreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => { return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime }); const rejectedProviderIds = providerRejections?.begin();
let services: AgentSessionServices;
try {
services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
} finally {
if (rejectedProviderIds !== undefined) providerRejections?.end(rejectedProviderIds);
}
// Surface each provider the policy rejected during this load as a session
// warning, through the same diagnostics pipeline as other runtime setup
// issues. The registration was ignored; nothing else about the load changes.
for (const providerId of new Set(rejectedProviderIds ?? [])) {
services.diagnostics.push({ type: "warning", message: providerRejectionMessage(providerId, cwd) });
}
const resolvedDelegationToolsEnabled = delegationToolsEnabled const resolvedDelegationToolsEnabled = delegationToolsEnabled
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers); ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions); const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
@@ -704,6 +729,8 @@ export class PiSessionService implements SessionRouteService {
private readonly now: () => Date; private readonly now: () => Date;
private readonly notificationStore: SessionNotificationStore; private readonly notificationStore: SessionNotificationStore;
private readonly notificationGenerationBySession = new WeakMap<PiAgentSession, SessionNotificationGeneration>(); private readonly notificationGenerationBySession = new WeakMap<PiAgentSession, SessionNotificationGeneration>();
/** Rejection lists of in-flight services loads; see {@link noteRejectedProviderRegistration}. */
private readonly pendingProviderRejectionLoads = new Set<string[]>();
private readonly unreadStore: SessionUnreadStore; private readonly unreadStore: SessionUnreadStore;
private readonly unreadPublicationRetryInitialMs: number; private readonly unreadPublicationRetryInitialMs: number;
private readonly pendingUnreadMutations: SessionUnreadMutation[] = []; private readonly pendingUnreadMutations: SessionUnreadMutation[] = [];
@@ -742,6 +769,16 @@ export class PiSessionService implements SessionRouteService {
check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile), check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile), read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
}, },
{
begin: () => {
const rejectedProviderIds: string[] = [];
this.pendingProviderRejectionLoads.add(rejectedProviderIds);
return rejectedProviderIds;
},
end: (rejectedProviderIds) => {
this.pendingProviderRejectionLoads.delete(rejectedProviderIds);
},
},
); );
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime; this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
this.workspaceActivity = deps.workspaceActivity; this.workspaceActivity = deps.workspaceActivity;
@@ -786,6 +823,34 @@ export class PiSessionService implements SessionRouteService {
return this.notificationStore.catalogSnapshot(); return this.notificationStore.catalogSnapshot();
} }
/**
* Handle a provider registration rejected by the daemon-wide global provider
* policy (installed on the shared model runtime by sessiond).
*
* A rejection raised while at least one services load is in flight is
* recorded onto every in-flight load, which surfaces it as that session's
* load warning. The shim cannot attribute a registration to a specific
* extension or load, so overlapping loads may each report the same provider
* id; that over-reports but never drops a rejection.
*
* With no load in flight this is a late registration from a bound session's
* extension event handler. It cannot be attributed to a session either, so
* the notice is broadcast to every active session's notification inbox.
*/
noteRejectedProviderRegistration(providerId: string): void {
if (this.pendingProviderRejectionLoads.size > 0) {
for (const load of this.pendingProviderRejectionLoads) load.push(providerId);
return;
}
const message = providerRejectionMessage(providerId);
for (const record of this.active.values()) {
const generation = this.notificationGenerationBySession.get(record.runtime.session);
if (generation === undefined) continue;
const added = this.notificationStore.addNotification(generation, message, "warning");
this.publishNotificationMutations(added.mutations);
}
}
async unreadCatalog(): Promise<SessionUnreadCatalogSnapshot> { async unreadCatalog(): Promise<SessionUnreadCatalogSnapshot> {
await this.publishUnreadMutations([]); await this.publishUnreadMutations([]);
return this.unreadStore.durableCatalogSnapshot(); return this.unreadStore.durableCatalogSnapshot();