Archived
fix(sessions): freeze providers after global bootstrap
This commit is contained in:
@@ -7,7 +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, learnGlobalExtensionProviderIds } from "./sessions/globalProviderPolicy.js";
|
import { bootstrapAndFreezeGlobalExtensionProviders } 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";
|
||||||
@@ -51,6 +51,10 @@ await runSessionDaemonStartup({
|
|||||||
await unreadStore.load();
|
await unreadStore.load();
|
||||||
const workspaceActivity = new WorkspaceActivityService(eventHub);
|
const workspaceActivity = new WorkspaceActivityService(eventHub);
|
||||||
const auth = await AuthService.create({ agentDir: activeAgentProfile.dir, logger: app.log });
|
const auth = await AuthService.create({ agentDir: activeAgentProfile.dir, logger: app.log });
|
||||||
|
// Capture providers registered by global extensions while the runtime is
|
||||||
|
// still mutable, then freeze every later extension-provider mutation before
|
||||||
|
// any real session can load project resources.
|
||||||
|
await bootstrapAndFreezeGlobalExtensionProviders(auth.runtime, activeAgentProfile.dir, app.log);
|
||||||
const spawnTargets = config.spawnSessions
|
const spawnTargets = config.spawnSessions
|
||||||
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -70,12 +74,6 @@ await runSessionDaemonStartup({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
auth.subscribe((change) => { sessions.applyAuthChange(change); });
|
auth.subscribe((change) => { sessions.applyAuthChange(change); });
|
||||||
// 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.
|
|
||||||
const globalExtensionProviderIds = await learnGlobalExtensionProviderIds(auth.runtime, activeAgentProfile.dir);
|
|
||||||
installGlobalProviderPolicy(auth.runtime, globalExtensionProviderIds, (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),
|
||||||
|
|||||||
@@ -1,27 +1,62 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import type { Provider } from "@earendil-works/pi-ai";
|
import type { Provider } from "@earendil-works/pi-ai";
|
||||||
import { installGlobalProviderPolicy, providerRejectionMessage } from "./globalProviderPolicy.js";
|
import {
|
||||||
import { createTestModelRuntime, TEST_MODEL_ID, TEST_MODEL_PROVIDER } from "./piSessionService.testSupport.js";
|
bootstrapAndFreezeGlobalExtensionProviders,
|
||||||
|
type GlobalProviderBootstrapLogger,
|
||||||
|
} from "./globalProviderPolicy.js";
|
||||||
|
import {
|
||||||
|
createTestModelRuntime,
|
||||||
|
TEST_MODEL_ID,
|
||||||
|
TEST_MODEL_PROVIDER,
|
||||||
|
} from "./piSessionService.testSupport.js";
|
||||||
|
|
||||||
/**
|
interface LogEntry {
|
||||||
* The shim permanently mutates the runtime instance it is installed on, so
|
level: "error" | "info" | "warn";
|
||||||
* every test builds a dedicated runtime rather than touching the shared
|
details: Record<string, unknown>;
|
||||||
* `testModelRuntime` from testSupport.
|
message: string;
|
||||||
*/
|
|
||||||
async function policyRuntime(
|
|
||||||
allowedExtensionProviderIds: ReadonlySet<string> = new Set(),
|
|
||||||
): Promise<{ runtime: ModelRuntime; rejections: string[] }> {
|
|
||||||
const runtime = await createTestModelRuntime();
|
|
||||||
const rejections: string[] = [];
|
|
||||||
installGlobalProviderPolicy(runtime, allowedExtensionProviderIds, (providerId) => { rejections.push(providerId); });
|
|
||||||
return { runtime, rejections };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nativeProvider(providerId: string): Provider {
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function tempDir(prefix: string): Promise<string> {
|
||||||
|
const path = await mkdtemp(join(tmpdir(), prefix));
|
||||||
|
tempDirs.push(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function agentDirWithExtension(source: string): Promise<string> {
|
||||||
|
const agentDir = await tempDir("pi-web-global-provider-unit-");
|
||||||
|
await mkdir(join(agentDir, "extensions"), { recursive: true });
|
||||||
|
await writeFile(join(agentDir, "extensions", "provider.js"), source);
|
||||||
|
return agentDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capturingLogger(): { entries: LogEntry[]; logger: GlobalProviderBootstrapLogger } {
|
||||||
|
const entries: LogEntry[] = [];
|
||||||
|
const record = (level: LogEntry["level"], details: Record<string, unknown>, message: string): void => {
|
||||||
|
entries.push({ level, details, message });
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
logger: {
|
||||||
|
error: (details, message) => { record("error", details, message); },
|
||||||
|
info: (details, message) => { record("info", details, message); },
|
||||||
|
warn: (details, message) => { record("warn", details, message); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeProvider(providerId: string, name = providerId): Provider {
|
||||||
return {
|
return {
|
||||||
id: providerId,
|
id: providerId,
|
||||||
name: providerId,
|
name,
|
||||||
auth: {
|
auth: {
|
||||||
apiKey: {
|
apiKey: {
|
||||||
name: `${providerId} API key`,
|
name: `${providerId} API key`,
|
||||||
@@ -34,82 +69,122 @@ function nativeProvider(providerId: string): Provider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("installGlobalProviderPolicy", () => {
|
function registerProjectConfigProvider(runtime: Awaited<ReturnType<typeof createTestModelRuntime>>): void {
|
||||||
it("rejects non-allowed registrations and records each rejection", async () => {
|
runtime.registerProvider("project-config", {
|
||||||
const { runtime, rejections } = await policyRuntime();
|
name: "Project Config",
|
||||||
|
baseUrl: "https://project-secret.example.com",
|
||||||
|
apiKey: "project-secret-api-key",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [{
|
||||||
|
id: "project-model",
|
||||||
|
name: "Project Model",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 8_192,
|
||||||
|
maxTokens: 1_024,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
runtime.registerProvider("acme", { baseUrl: "https://acme.example.com" });
|
describe("bootstrapAndFreezeGlobalExtensionProviders", () => {
|
||||||
runtime.registerProvider("acme", { baseUrl: "https://acme-two.example.com" });
|
it("captures the global baseline before making every later provider mutation a no-op", async () => {
|
||||||
runtime.registerProvider("other", {});
|
const agentDir = await agentDirWithExtension(`
|
||||||
|
export default function (pi) {
|
||||||
|
pi.registerProvider("global-config", {
|
||||||
|
name: "Global Config",
|
||||||
|
baseUrl: "https://global.example.com",
|
||||||
|
apiKey: "$GLOBAL_PROVIDER_KEY",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [{
|
||||||
|
id: "global-model",
|
||||||
|
name: "Global Model",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 8192,
|
||||||
|
maxTokens: 1024
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const runtime = await createTestModelRuntime();
|
||||||
|
const builtInModel = runtime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
|
||||||
|
expect(builtInModel).toBeDefined();
|
||||||
|
const { entries, logger } = capturingLogger();
|
||||||
|
|
||||||
expect(rejections).toEqual(["acme", "acme", "other"]);
|
await bootstrapAndFreezeGlobalExtensionProviders(runtime, agentDir, logger);
|
||||||
|
|
||||||
|
const baselineConfig = runtime.getRegisteredProviderConfig("global-config");
|
||||||
|
expect(baselineConfig).toMatchObject({ baseUrl: "https://global.example.com" });
|
||||||
|
expect(runtime.getModel("global-config", "global-model")).toBeDefined();
|
||||||
|
expect(entries).toContainEqual({
|
||||||
|
level: "info",
|
||||||
|
details: { context: "global-provider-bootstrap", providerIds: ["global-config"] },
|
||||||
|
message: "global extension provider baseline bootstrapped and frozen",
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
runtime.registerProvider("global-config", {
|
||||||
|
baseUrl: "https://replacement-secret.example.com",
|
||||||
|
headers: { Authorization: "replacement-secret-token" },
|
||||||
|
});
|
||||||
|
runtime.registerNativeProvider(nativeProvider("global-config", "native-secret-name"));
|
||||||
|
runtime.unregisterProvider("global-config");
|
||||||
|
registerProjectConfigProvider(runtime);
|
||||||
|
runtime.registerNativeProvider(nativeProvider("project-native", "project-native-secret-name"));
|
||||||
|
runtime.unregisterProvider("project-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(runtime.getRegisteredProviderIds()).toEqual(["global-config"]);
|
||||||
|
expect(runtime.getRegisteredProviderConfig("global-config")).toBe(baselineConfig);
|
||||||
|
expect(runtime.getRegisteredNativeProvider("global-config")).toBeUndefined();
|
||||||
|
expect(runtime.getRegisteredProviderConfig("project-config")).toBeUndefined();
|
||||||
|
expect(runtime.getRegisteredNativeProvider("project-native")).toBeUndefined();
|
||||||
|
expect(runtime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID)).toBe(builtInModel);
|
||||||
|
|
||||||
|
const ignoredMutations = entries
|
||||||
|
.filter((entry) => entry.message === "ignored provider mutation after global bootstrap")
|
||||||
|
.map((entry) => entry.details);
|
||||||
|
expect(ignoredMutations).toEqual([
|
||||||
|
{ context: "global-provider-bootstrap", operation: "registerProvider", providerId: "global-config" },
|
||||||
|
{ context: "global-provider-bootstrap", operation: "registerNativeProvider", providerId: "global-config" },
|
||||||
|
{ context: "global-provider-bootstrap", operation: "unregisterProvider", providerId: "global-config" },
|
||||||
|
{ context: "global-provider-bootstrap", operation: "registerProvider", providerId: "project-config" },
|
||||||
|
{ context: "global-provider-bootstrap", operation: "registerNativeProvider", providerId: "project-native" },
|
||||||
|
{ context: "global-provider-bootstrap", operation: "unregisterProvider", providerId: "project-only" },
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(ignoredMutations)).not.toContain("secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs non-fatal Pi bootstrap diagnostics and still freezes the runtime", async () => {
|
||||||
|
const agentDir = await agentDirWithExtension(`
|
||||||
|
export default function (pi) {
|
||||||
|
pi.registerProvider("broken-provider", { streamSimple() {} });
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const runtime = await createTestModelRuntime();
|
||||||
|
const { entries, logger } = capturingLogger();
|
||||||
|
|
||||||
|
await bootstrapAndFreezeGlobalExtensionProviders(runtime, agentDir, logger);
|
||||||
|
|
||||||
|
const diagnosticEntry = entries.find((entry) => entry.details["diagnosticType"] === "error");
|
||||||
|
expect(diagnosticEntry?.level).toBe("error");
|
||||||
|
expect(diagnosticEntry?.details["context"]).toBe("global-provider-bootstrap");
|
||||||
|
expect(diagnosticEntry?.message).toBe("global extension provider bootstrap diagnostic");
|
||||||
|
expect(diagnosticEntry?.details["diagnostic"])
|
||||||
|
.toEqual(expect.stringContaining('"api" is required when registering streamSimple'));
|
||||||
|
|
||||||
|
runtime.registerProvider("after-diagnostic", {});
|
||||||
expect(runtime.getRegisteredProviderIds()).toEqual([]);
|
expect(runtime.getRegisteredProviderIds()).toEqual([]);
|
||||||
expect(runtime.getRegisteredProviderConfig("acme")).toBeUndefined();
|
expect(entries).toContainEqual({
|
||||||
});
|
level: "info",
|
||||||
|
details: {
|
||||||
it("lets allowed (global-extension) providers through to the runtime", async () => {
|
context: "global-provider-bootstrap",
|
||||||
const { runtime, rejections } = await policyRuntime(new Set(["tensorx"]));
|
operation: "registerProvider",
|
||||||
|
providerId: "after-diagnostic",
|
||||||
runtime.registerProvider("tensorx", { baseUrl: "https://tensorx.example.com" });
|
},
|
||||||
runtime.registerProvider("acme", { baseUrl: "https://acme.example.com" });
|
message: "ignored provider mutation after global bootstrap",
|
||||||
|
});
|
||||||
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([]);
|
|
||||||
expect(runtime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID)).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps global (built-in) providers resolvable after rejections", async () => {
|
|
||||||
const { runtime } = await policyRuntime();
|
|
||||||
const before = runtime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
|
|
||||||
|
|
||||||
runtime.registerProvider("acme", {});
|
|
||||||
|
|
||||||
expect(before).toBeDefined();
|
|
||||||
expect(runtime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID)).toBe(before);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("providerRejectionMessage", () => {
|
|
||||||
it("names the provider and the loading workspace when the cwd is known", () => {
|
|
||||||
const message = providerRejectionMessage("acme", "/workspace/project");
|
|
||||||
|
|
||||||
expect(message).toContain('Provider "acme"');
|
|
||||||
expect(message).toContain("in /workspace/project");
|
|
||||||
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.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to a generic origin for late registrations without a cwd", () => {
|
|
||||||
const message = providerRejectionMessage("acme");
|
|
||||||
|
|
||||||
expect(message).toContain('Provider "acme" registered by an extension was ignored');
|
|
||||||
expect(message).not.toContain(" in ");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,110 +1,125 @@
|
|||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createAgentSessionServices, type ModelRuntime } from "@earendil-works/pi-coding-agent";
|
import {
|
||||||
|
createAgentSessionServices,
|
||||||
|
type AgentSessionRuntimeDiagnostic,
|
||||||
|
type AgentSessionServices,
|
||||||
|
type ModelRuntime,
|
||||||
|
} from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
/**
|
/** Structured logging boundary supplied by the session daemon. */
|
||||||
* PI WEB providers come from global sources only: Pi built-ins, agent-dir
|
export interface GlobalProviderBootstrapLogger {
|
||||||
* `models.json`, environment credentials, and providers registered by global
|
error(details: Record<string, unknown>, message: string): void;
|
||||||
* (agent-dir) extensions. Provider registrations from project extensions are
|
info(details: Record<string, unknown>, message: string): void;
|
||||||
* rejected — the user is told, and everything else the extension does keeps
|
warn(details: Record<string, unknown>, message: string): void;
|
||||||
* working.
|
|
||||||
*
|
|
||||||
* 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` /
|
|
||||||
* `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 {
|
|
||||||
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.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.
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type ProviderMutationOperation = "registerNativeProvider" | "registerProvider" | "unregisterProvider";
|
||||||
* Learn which provider ids the agent directory's global extensions register.
|
type ProviderMutationMethods = Pick<ModelRuntime, ProviderMutationOperation>;
|
||||||
*
|
|
||||||
* Loads extensions once for a guaranteed-empty temporary cwd — so only global
|
const LOG_CONTEXT = "global-provider-bootstrap";
|
||||||
* (agent-dir) extensions load — against the daemon's shared runtime, and
|
|
||||||
* returns the ids that appeared. Those registrations stay on the runtime: they
|
async function loadGlobalExtensionServices(runtime: ModelRuntime, agentDir: string): Promise<AgentSessionServices> {
|
||||||
* 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-"));
|
const scratchCwd = await mkdtemp(join(tmpdir(), "pi-web-global-ext-"));
|
||||||
try {
|
try {
|
||||||
await createAgentSessionServices({ cwd: scratchCwd, agentDir, modelRuntime: runtime });
|
return await createAgentSessionServices({ cwd: scratchCwd, agentDir, modelRuntime: runtime });
|
||||||
} finally {
|
} finally {
|
||||||
await rm(scratchCwd, { recursive: true, force: true });
|
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);
|
function logBootstrapDiagnostic(
|
||||||
|
logger: GlobalProviderBootstrapLogger,
|
||||||
|
diagnostic: AgentSessionRuntimeDiagnostic,
|
||||||
|
): void {
|
||||||
|
const details = {
|
||||||
|
context: LOG_CONTEXT,
|
||||||
|
diagnosticType: diagnostic.type,
|
||||||
|
diagnostic: diagnostic.message,
|
||||||
|
};
|
||||||
|
if (diagnostic.type === "error") {
|
||||||
|
logger.error(details, "global extension provider bootstrap diagnostic");
|
||||||
|
} else if (diagnostic.type === "warning") {
|
||||||
|
logger.warn(details, "global extension provider bootstrap diagnostic");
|
||||||
|
} else {
|
||||||
|
logger.info(details, "global extension provider bootstrap diagnostic");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeProviderMutations(runtime: ModelRuntime, logger: GlobalProviderBootstrapLogger): void {
|
||||||
|
const originalMethods: ProviderMutationMethods = {
|
||||||
|
registerNativeProvider: runtime.registerNativeProvider.bind(runtime),
|
||||||
|
registerProvider: runtime.registerProvider.bind(runtime),
|
||||||
|
unregisterProvider: runtime.unregisterProvider.bind(runtime),
|
||||||
|
};
|
||||||
|
const loggedProviderIds: Record<ProviderMutationOperation, Set<string>> = {
|
||||||
|
registerNativeProvider: new Set(),
|
||||||
|
registerProvider: new Set(),
|
||||||
|
unregisterProvider: new Set(),
|
||||||
|
};
|
||||||
|
const logIgnoredMutation = (operation: ProviderMutationOperation, providerId: string): void => {
|
||||||
|
const loggedIds = loggedProviderIds[operation];
|
||||||
|
if (loggedIds.has(providerId)) return;
|
||||||
|
loggedIds.add(providerId);
|
||||||
|
logger.info(
|
||||||
|
{ context: LOG_CONTEXT, operation, providerId },
|
||||||
|
"ignored provider mutation after global bootstrap",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const frozenMethods: ProviderMutationMethods = {
|
||||||
|
registerProvider(providerId) {
|
||||||
|
logIgnoredMutation("registerProvider", providerId);
|
||||||
|
},
|
||||||
|
registerNativeProvider(provider) {
|
||||||
|
logIgnoredMutation("registerNativeProvider", provider.id);
|
||||||
|
},
|
||||||
|
unregisterProvider(providerId) {
|
||||||
|
logIgnoredMutation("unregisterProvider", providerId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object.assign(runtime, frozenMethods);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
Object.assign(runtime, originalMethods);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return learned;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-facing wording for a rejected registration. `cwd` is known only for
|
* Load global extensions once against the shared model runtime, then make its
|
||||||
* rejections raised while loading a workspace's services; late registrations
|
* extension-provider baseline immutable for the rest of the daemon lifetime.
|
||||||
* from session event handlers carry no attribution beyond the provider id.
|
* All sessions share this runtime, so accepting project-dependent mutations
|
||||||
|
* would leak provider configuration across workspaces. This is an accidental
|
||||||
|
* contamination guard, not a sandbox for otherwise trusted extensions.
|
||||||
|
*
|
||||||
|
* The temporary cwd is guaranteed to be empty, so Pi discovers agent-dir
|
||||||
|
* extensions without loading project resources. Documented initialization-time
|
||||||
|
* config and native registrations therefore reach the runtime through Pi's
|
||||||
|
* public service factory. Pi exposes no provider-freeze hook, so the daemon
|
||||||
|
* deliberately shadows the three public instance mutation methods afterward;
|
||||||
|
* every registration replay or later call is then a logged no-op.
|
||||||
*/
|
*/
|
||||||
export function providerRejectionMessage(providerId: string, cwd?: string): string {
|
export async function bootstrapAndFreezeGlobalExtensionProviders(
|
||||||
const origin = cwd === undefined ? "registered by an extension" : `registered by an extension in ${cwd}`;
|
runtime: ModelRuntime,
|
||||||
return `Provider "${providerId}" ${origin} was ignored — PI WEB providers must come from global configuration `
|
agentDir: string,
|
||||||
+ "(agent-dir models.json) or a globally installed extension. All other extension features are unaffected.";
|
logger: GlobalProviderBootstrapLogger,
|
||||||
|
): Promise<void> {
|
||||||
|
const services = await loadGlobalExtensionServices(runtime, agentDir);
|
||||||
|
const providerIds = Object.freeze([...runtime.getRegisteredProviderIds()].sort());
|
||||||
|
|
||||||
|
freezeProviderMutations(runtime, logger);
|
||||||
|
|
||||||
|
for (const diagnostic of services.diagnostics) logBootstrapDiagnostic(logger, diagnostic);
|
||||||
|
for (const extensionError of services.resourceLoader.getExtensions().errors) {
|
||||||
|
logger.error(
|
||||||
|
{ context: LOG_CONTEXT, error: extensionError.error },
|
||||||
|
"global extension failed during provider bootstrap",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
{ context: LOG_CONTEXT, providerIds },
|
||||||
|
"global extension provider baseline bootstrapped and frozen",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ 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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -581,38 +580,14 @@ 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 rejectedProviderIds = providerRejections?.begin();
|
const services: AgentSessionServices = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
|
||||||
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);
|
||||||
@@ -729,8 +704,6 @@ 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[] = [];
|
||||||
@@ -769,16 +742,6 @@ 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;
|
||||||
@@ -823,34 +786,6 @@ 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