diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 32d64f1..1a0d5d9 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -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), diff --git a/src/server/sessions/globalProviderPolicy.ts b/src/server/sessions/globalProviderPolicy.ts new file mode 100644 index 0000000..3e67445 --- /dev/null +++ b/src/server/sessions/globalProviderPolicy.ts @@ -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."; +} diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index c3d87ae..62b3294 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -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, 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(); + /** Rejection lists of in-flight services loads; see {@link noteRejectedProviderRegistration}. */ + private readonly pendingProviderRejectionLoads = new Set(); 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 { await this.publishUnreadMutations([]); return this.unreadStore.durableCatalogSnapshot();