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 { 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.";
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user