Archived
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:
@@ -7,6 +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 { registerAuthRoutes } from "./sessions/authRoutes.js";
|
||||
import { PiSessionService } from "./sessions/piSessionService.js";
|
||||
import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js";
|
||||
@@ -69,6 +70,9 @@ await runSessionDaemonStartup({
|
||||
}),
|
||||
});
|
||||
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 runtimeComponent = Object.freeze({
|
||||
...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.";
|
||||
}
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
type SessionNotificationMutation,
|
||||
} from "./sessionNotificationStore.js";
|
||||
import { plainTextTheme } from "./plainTextTheme.js";
|
||||
import { providerRejectionMessage } from "./globalProviderPolicy.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(
|
||||
modelRuntime: ModelRuntime,
|
||||
sessionManagers: Pick<PiSessionManagerGateway, "open">,
|
||||
spawn?: SpawnSessionFn,
|
||||
subsessions?: SubsessionToolDeps,
|
||||
providerRejections?: ProviderRejectionTracker,
|
||||
): PiWebCreateAgentSessionRuntimeFactory {
|
||||
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
|
||||
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
||||
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
|
||||
@@ -704,6 +729,8 @@ export class PiSessionService implements SessionRouteService {
|
||||
private readonly now: () => Date;
|
||||
private readonly notificationStore: SessionNotificationStore;
|
||||
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 unreadPublicationRetryInitialMs: number;
|
||||
private readonly pendingUnreadMutations: SessionUnreadMutation[] = [];
|
||||
@@ -742,6 +769,16 @@ export class PiSessionService implements SessionRouteService {
|
||||
check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, 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.workspaceActivity = deps.workspaceActivity;
|
||||
@@ -786,6 +823,34 @@ export class PiSessionService implements SessionRouteService {
|
||||
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> {
|
||||
await this.publishUnreadMutations([]);
|
||||
return this.unreadStore.durableCatalogSnapshot();
|
||||
|
||||
Reference in New Issue
Block a user