Archived
fix(sessions): freeze providers after global bootstrap
This commit is contained in:
@@ -1,27 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
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 { installGlobalProviderPolicy, providerRejectionMessage } from "./globalProviderPolicy.js";
|
||||
import { createTestModelRuntime, TEST_MODEL_ID, TEST_MODEL_PROVIDER } from "./piSessionService.testSupport.js";
|
||||
import {
|
||||
bootstrapAndFreezeGlobalExtensionProviders,
|
||||
type GlobalProviderBootstrapLogger,
|
||||
} from "./globalProviderPolicy.js";
|
||||
import {
|
||||
createTestModelRuntime,
|
||||
TEST_MODEL_ID,
|
||||
TEST_MODEL_PROVIDER,
|
||||
} from "./piSessionService.testSupport.js";
|
||||
|
||||
/**
|
||||
* The shim permanently mutates the runtime instance it is installed on, so
|
||||
* every test builds a dedicated runtime rather than touching the shared
|
||||
* `testModelRuntime` from testSupport.
|
||||
*/
|
||||
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 };
|
||||
interface LogEntry {
|
||||
level: "error" | "info" | "warn";
|
||||
details: Record<string, unknown>;
|
||||
message: string;
|
||||
}
|
||||
|
||||
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 {
|
||||
id: providerId,
|
||||
name: providerId,
|
||||
name,
|
||||
auth: {
|
||||
apiKey: {
|
||||
name: `${providerId} API key`,
|
||||
@@ -34,82 +69,122 @@ function nativeProvider(providerId: string): Provider {
|
||||
};
|
||||
}
|
||||
|
||||
describe("installGlobalProviderPolicy", () => {
|
||||
it("rejects non-allowed registrations and records each rejection", async () => {
|
||||
const { runtime, rejections } = await policyRuntime();
|
||||
function registerProjectConfigProvider(runtime: Awaited<ReturnType<typeof createTestModelRuntime>>): void {
|
||||
runtime.registerProvider("project-config", {
|
||||
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" });
|
||||
runtime.registerProvider("acme", { baseUrl: "https://acme-two.example.com" });
|
||||
runtime.registerProvider("other", {});
|
||||
describe("bootstrapAndFreezeGlobalExtensionProviders", () => {
|
||||
it("captures the global baseline before making every later provider mutation a no-op", async () => {
|
||||
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.getRegisteredProviderConfig("acme")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets allowed (global-extension) providers through to the runtime", async () => {
|
||||
const { runtime, rejections } = await policyRuntime(new Set(["tensorx"]));
|
||||
|
||||
runtime.registerProvider("tensorx", { baseUrl: "https://tensorx.example.com" });
|
||||
runtime.registerProvider("acme", { baseUrl: "https://acme.example.com" });
|
||||
|
||||
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 ");
|
||||
expect(entries).toContainEqual({
|
||||
level: "info",
|
||||
details: {
|
||||
context: "global-provider-bootstrap",
|
||||
operation: "registerProvider",
|
||||
providerId: "after-diagnostic",
|
||||
},
|
||||
message: "ignored provider mutation after global bootstrap",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user