From 20d424c48f2841eb3b88177b12f6f4dea13aab28 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 22 Jul 2026 08:57:05 +0200 Subject: [PATCH 01/10] 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). --- src/server/sessiond.ts | 4 ++ src/server/sessions/globalProviderPolicy.ts | 47 +++++++++++++++ src/server/sessions/piSessionService.ts | 67 ++++++++++++++++++++- 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/server/sessions/globalProviderPolicy.ts 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(); From fb4ceb5d04b10bab8043e9f8b11d855072587854 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 22 Jul 2026 09:30:40 +0200 Subject: [PATCH 02/10] test(sessions): cover the global provider policy Unit tests for the policy shim (swallowed registrations, no-op unregister, untouched global providers, rejection wording) and acceptance tests wired as sessiond wires production: load-time rejections surface as session warnings while extension tools and commands keep working, late registrations are broadcast to active sessions' notification inboxes, colliding provider ids across workspaces cannot affect each other, and a project-level models.json does not alter the shared runtime. --- .../globalProviderPolicy.acceptance.test.ts | 263 ++++++++++++++++++ .../sessions/globalProviderPolicy.test.ts | 69 +++++ 2 files changed, 332 insertions(+) create mode 100644 src/server/sessions/globalProviderPolicy.acceptance.test.ts create mode 100644 src/server/sessions/globalProviderPolicy.test.ts diff --git a/src/server/sessions/globalProviderPolicy.acceptance.test.ts b/src/server/sessions/globalProviderPolicy.acceptance.test.ts new file mode 100644 index 0000000..35d1875 --- /dev/null +++ b/src/server/sessions/globalProviderPolicy.acceptance.test.ts @@ -0,0 +1,263 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { InMemoryCredentialStore } from "@earendil-works/pi-ai"; +import { installGlobalProviderPolicy, providerRejectionMessage } from "./globalProviderPolicy.js"; +import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js"; +import { PiSessionService } from "./piSessionService.js"; +import { CapturingSessionEventHub, createTestModelRuntime, TEST_MODEL_ID, TEST_MODEL_PROVIDER } from "./piSessionService.testSupport.js"; + +/** + * Acceptance tests for the global provider policy, wired exactly as sessiond + * wires it in production: one shared ModelRuntime per daemon, the policy shim + * installed on it, and rejections fed into the session service. Sessions are + * created through the real default runtime factory, so project extensions in a + * temp cwd are genuinely loaded by Pi's `createAgentSessionServices`. + * + * These tests are also the tripwire for the shim's one piece of machinery + * (instance-method shadowing of `registerProvider`): if a Pi upgrade changes + * how registrations reach the runtime, the load-time and late-registration + * tests here fail loudly. + */ + +const tempDirs: string[] = []; +const services: PiSessionService[] = []; + +afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all(services.splice(0).map(async (service) => service.dispose())); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function tempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +interface PolicyHarness { + service: PiSessionService; + runtime: ModelRuntime; + agentDir: string; +} + +async function policyHarness(options: { runtime?: ModelRuntime; agentDir?: string } = {}): Promise { + const agentDir = options.agentDir ?? await tempDir("pi-web-policy-agent-"); + // Isolate Pi's per-user resource discovery (~/.agents/skills et al.) so the + // only extensions loaded are the ones a test writes into its temp cwd. + vi.stubEnv("HOME", await tempDir("pi-web-policy-home-")); + const runtime = options.runtime ?? await createTestModelRuntime(); + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir, + modelRuntime: runtime, + sessionManager: createPiSessionManagerGateway({ agentDir, env: {}, sessionDirEnvKeys: [] }), + heartbeatIntervalMs: 60_000, + }); + services.push(service); + // The exact sessiond wiring: policy on the shared runtime, rejections to the service. + installGlobalProviderPolicy(runtime, (providerId) => { service.noteRejectedProviderRegistration(providerId); }); + return { service, runtime, agentDir }; +} + +/** Write a project extension into `/.pi/extensions/` and return the cwd. */ +async function projectWithExtension(source: string): Promise { + const cwd = await tempDir("pi-web-policy-project-"); + await mkdir(join(cwd, ".pi", "extensions"), { recursive: true }); + await writeFile(join(cwd, ".pi", "extensions", "probe.js"), source); + return cwd; +} + +function providerConfig(providerId: string): Record { + return { + baseUrl: `https://${providerId}.example.com`, + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "model-1", name: "Model One", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 100 }], + }; +} + +function providerConfigJson(providerId: string): string { + return JSON.stringify(providerConfig(providerId)); +} + +/** Parse the session-start marker file without type assertions. */ +function parseToolMarker(raw: string): { activeTools: string[]; allTools: string[] } { + const value: unknown = JSON.parse(raw); + if (typeof value !== "object" || value === null || !("activeTools" in value) || !("allTools" in value)) { + throw new Error(`Unexpected marker content: ${raw}`); + } + const { activeTools, allTools } = value; + if (!Array.isArray(activeTools) || !Array.isArray(allTools)) throw new Error(`Unexpected marker content: ${raw}`); + return { activeTools: activeTools.map(String), allTools: allTools.map(String) }; +} + +function providerRegistrationSource(providerId: string): string { + return `pi.registerProvider(${JSON.stringify(providerId)}, ${providerConfigJson(providerId)});`; +} + +const POLICY_WORDING = "PI WEB only supports globally configured providers"; + +describe("global provider policy acceptance", () => { + it("rejects a load-time provider registration while the extension's tool and command keep working", async () => { + const { service, runtime } = await policyHarness(); + const markerPath = join(await tempDir("pi-web-policy-marker-"), "session-start.json"); + const cwd = await projectWithExtension(` + import { writeFileSync } from "node:fs"; + export default function (pi) { + ${providerRegistrationSource("acme-ext")} + pi.registerTool({ + name: "acme_tool", + label: "Acme Tool", + description: "acceptance probe tool", + parameters: { type: "object", properties: {} }, + async execute() { return { content: [{ type: "text", text: "acme ok" }] }; }, + }); + pi.registerCommand("acme-cmd", { description: "acceptance probe command", async handler() {} }); + pi.on("session_start", async () => { + writeFileSync(${JSON.stringify(markerPath)}, JSON.stringify({ + activeTools: pi.getActiveTools(), + allTools: pi.getAllTools().map((tool) => tool.name), + })); + }); + } + `); + + const session = await service.start(cwd); + const ref = { id: session.id, cwd }; + + // The session opens and the rejection is surfaced as the session's one warning. + const status = await service.status(ref); + expect(status.warnings).toEqual([ + { severity: "warning", message: providerRejectionMessage("acme-ext", cwd), source: "runtime" }, + ]); + + // The provider never reached the shared runtime or the model listings. + expect(runtime.getRegisteredProviderIds()).toEqual([]); + expect(runtime.getModel("acme-ext", "model-1")).toBeUndefined(); + const models = await service.availableModels(ref); + expect(models.some((model) => model.provider === "acme-ext")).toBe(false); + + // Global (built-in) providers are untouched. + expect(runtime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID)).toBeDefined(); + + // Everything else the extension registered still works. + const commands = await service.commands(ref); + expect(commands).toContainEqual({ name: "acme-cmd", description: "acceptance probe command", source: "extension" }); + const marker = parseToolMarker(await readFile(markerPath, "utf-8")); + expect(marker.activeTools).toContain("acme_tool"); + }); + + it("appends exactly one warning diagnostic per rejected provider", async () => { + const { service } = await policyHarness(); + const cwd = await projectWithExtension(` + export default function (pi) { + ${providerRegistrationSource("multi-a")} + ${providerRegistrationSource("multi-b")} + ${providerRegistrationSource("multi-a")} + } + `); + + const session = await service.start(cwd); + const status = await service.status({ id: session.id, cwd }); + + expect(status.warnings).toEqual([ + { severity: "warning", message: providerRejectionMessage("multi-a", cwd), source: "runtime" }, + { severity: "warning", message: providerRejectionMessage("multi-b", cwd), source: "runtime" }, + ]); + }); + + it("adds no policy warning when a load registers no providers", async () => { + const { service } = await policyHarness(); + const cwd = await tempDir("pi-web-policy-project-"); + + const session = await service.start(cwd); + const status = await service.status({ id: session.id, cwd }); + + expect((status.warnings ?? []).filter((warning) => warning.message.includes(POLICY_WORDING))).toEqual([]); + }); + + it("rejects a late registration from a session event handler and notifies active sessions", async () => { + const { service, runtime } = await policyHarness(); + const plainCwd = await tempDir("pi-web-policy-project-"); + const listenerCwd = await projectWithExtension(` + export default function (pi) { + pi.on("session_start", () => { + ${providerRegistrationSource("late-acme")} + }); + } + `); + + // The listener session's `session_start` fires while it is being bound, + // after the load-time rejection window has closed: a late registration. + const bystander = await service.start(plainCwd); + await service.start(listenerCwd); + + // The rejection is broadcast to the sessions active at the time. + const inbox = service.notificationInbox({ id: bystander.id, cwd: plainCwd }); + const notices = inbox.notifications.filter((notification) => notification.message.includes(POLICY_WORDING)); + expect(notices).toHaveLength(1); + expect(notices[0]).toMatchObject({ severity: "warning", message: providerRejectionMessage("late-acme") }); + + // The late registration never reached the shared runtime either. + expect(runtime.getRegisteredProviderIds()).toEqual([]); + expect(runtime.getModel("late-acme", "model-1")).toBeUndefined(); + }); + + it("keeps workspaces with colliding provider ids from affecting each other", async () => { + const { service, runtime } = await policyHarness(); + const collisionSource = ` + export default function (pi) { + ${providerRegistrationSource("collide-acme")} + } + `; + const cwdA = await projectWithExtension(collisionSource); + const cwdB = await projectWithExtension(collisionSource); + + // The pre-#76 scenario: two workspaces register the same provider id on the + // shared runtime. With the policy, both sessions open and neither + // registration exists, so there is nothing left to collide. + const sessionA = await service.start(cwdA); + const sessionB = await service.start(cwdB); + + expect((await service.status({ id: sessionA.id, cwd: cwdA })).warnings).toEqual([ + { severity: "warning", message: providerRejectionMessage("collide-acme", cwdA), source: "runtime" }, + ]); + expect((await service.status({ id: sessionB.id, cwd: cwdB })).warnings).toEqual([ + { severity: "warning", message: providerRejectionMessage("collide-acme", cwdB), source: "runtime" }, + ]); + expect(runtime.getRegisteredProviderIds()).toEqual([]); + expect(runtime.getModel("collide-acme", "model-1")).toBeUndefined(); + }); + + it("does not let a project-level models.json alter the shared runtime's provider set", async () => { + // Spike assertion for plan §2: the shared runtime reads providers from the + // agent-dir models.json only. A project-level models.json is not a + // scoped-provider vector. + const agentDir = await tempDir("pi-web-policy-agent-"); + await writeFile(join(agentDir, "models.json"), JSON.stringify({ + providers: { "global-acme": providerConfig("global-acme") }, + })); + const runtime = await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsPath: join(agentDir, "models.json"), + allowModelNetwork: false, + }); + const { service } = await policyHarness({ runtime, agentDir }); + const cwd = await tempDir("pi-web-policy-project-"); + await mkdir(join(cwd, ".pi"), { recursive: true }); + await writeFile(join(cwd, ".pi", "models.json"), JSON.stringify({ + providers: { "project-acme": providerConfig("project-acme") }, + })); + + const session = await service.start(cwd); + + // The globally configured provider is honored; the project-level one is not. + expect(runtime.getModel("global-acme", "model-1")).toBeDefined(); + expect(runtime.getModel("project-acme", "model-1")).toBeUndefined(); + expect(runtime.getRegisteredProviderIds()).toEqual([]); + const status = await service.status({ id: session.id, cwd }); + expect((status.warnings ?? []).filter((warning) => warning.message.includes(POLICY_WORDING))).toEqual([]); + }); +}); diff --git a/src/server/sessions/globalProviderPolicy.test.ts b/src/server/sessions/globalProviderPolicy.test.ts new file mode 100644 index 0000000..3192160 --- /dev/null +++ b/src/server/sessions/globalProviderPolicy.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import type { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { installGlobalProviderPolicy, providerRejectionMessage } 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(): Promise<{ runtime: ModelRuntime; rejections: string[] }> { + const runtime = await createTestModelRuntime(); + const rejections: string[] = []; + installGlobalProviderPolicy(runtime, (providerId) => { rejections.push(providerId); }); + return { runtime, rejections }; +} + +describe("installGlobalProviderPolicy", () => { + it("swallows registrations and records each rejection", async () => { + const { runtime, rejections } = await policyRuntime(); + + runtime.registerProvider("acme", { baseUrl: "https://acme.example.com" }); + runtime.registerProvider("acme", { baseUrl: "https://acme-two.example.com" }); + runtime.registerProvider("other", {}); + + expect(rejections).toEqual(["acme", "acme", "other"]); + expect(runtime.getRegisteredProviderIds()).toEqual([]); + expect(runtime.getRegisteredProviderConfig("acme")).toBeUndefined(); + }); + + it("makes unregisterProvider a no-op that cannot remove global providers", async () => { + const { runtime, rejections } = await policyRuntime(); + + runtime.unregisterProvider("acme"); + runtime.unregisterProvider(TEST_MODEL_PROVIDER); + + 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 only supports globally configured providers"); + 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 "); + }); +}); From 242911331abe8fd12e12ba745801ecb316b444c8 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 22 Jul 2026 09:36:32 +0200 Subject: [PATCH 03/10] docs: document global provider policy and extension registration rejection --- .changeset/global-provider-policy.md | 5 +++++ docs/plugins.html | 7 +++++++ docs/plugins.md | 6 ++++++ 3 files changed, 18 insertions(+) create mode 100644 .changeset/global-provider-policy.md diff --git a/.changeset/global-provider-policy.md b/.changeset/global-provider-policy.md new file mode 100644 index 0000000..0910f51 --- /dev/null +++ b/.changeset/global-provider-policy.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Support only globally configured providers (Pi built-ins, environment credentials, and the agent directory's `models.json`). Provider registrations from Pi extensions (`pi.registerProvider`) are now ignored and reported with a session warning instead of leaking into every concurrent session; all other extension features keep working. Configure such providers globally in the agent directory's `models.json` to use them. Session daemon code changed: after updating, restart `pi-web-sessiond.service` manually (`systemctl --user restart pi-web-sessiond`). diff --git a/docs/plugins.html b/docs/plugins.html index bb3a304..c2fa8c8 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -162,6 +162,13 @@ prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A routine session daemon restart is not required.

+

+ One exception applies to Pi package extensions: PI WEB supports only globally configured providers + (Pi built-ins, environment credentials, and the agent directory's models.json). If an + extension calls pi.registerProvider, PI WEB ignores the registration and warns in the + session; everything else the extension registers keeps working. Configure such providers globally in + the agent directory's models.json instead. +

diff --git a/docs/plugins.md b/docs/plugins.md index cbcfcd2..fb1d57c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -25,6 +25,12 @@ When machine federation is enabled, **Settings → Pi packages** targets the cur Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. In a federated setup, this plugin enablement surface targets the currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB server does not advertise selected-machine settings support, PI WEB reports the plugin settings as unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A routine session daemon restart is not required. +## Extension provider registrations + +PI WEB only supports globally configured providers: Pi built-ins, environment credentials, and providers declared in the agent directory's `models.json` (the directory selected by `agent.dir`; see [Configuration](https://pi-web.dev/config)). All sessions share one daemon-wide provider set, so extensions cannot add their own: if a Pi extension calls `pi.registerProvider(...)`, PI WEB ignores the registration and shows a warning in the session naming the provider. The extension itself still loads and everything else it registers keeps working; only the ignored provider's models never appear, so an extension that requires its own provider may load but remain unusable. + +To use such a provider, configure it globally in the agent directory's `models.json` instead. Project-level `models.json` files do not add providers to PI WEB sessions. + ## Trust model Plugins run as JavaScript in the browser app. Treat them as trusted code: From c2bf59599965aa0378c14f9b961253d48deee301 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 22 Jul 2026 17:13:57 +0200 Subject: [PATCH 04/10] feat(sessions): allow global-extension providers, require Pi 0.81 Relaxes the provider policy from 'global config only' to 'global sources': providers registered by agent-dir (global) extensions are learned once at daemon startup and allowed on the shared runtime; project-extension registrations are still rejected with a session warning. Global extensions load identically for every session, so their providers are daemon-consistent and cannot leak project state (#76). - Shim now allows allowlisted ids through and also covers Pi 0.81's native provider path (registerNativeProvider), closing a bypass. - Startup learning step loads only global extensions against a scratch cwd and diffs the runtime's registered provider ids. - Bumps @earendil-works/* dev/peer ranges to >=0.81.1 <0.82; adapts to the Agent.streamFn -> streamFunction rename. - Docs, changeset, unit and acceptance tests updated (global-extension allow path, late re-registration a la pi-tensorx, native provider rule). --- .changeset/global-provider-policy.md | 2 +- docs/plugins.html | 11 +- docs/plugins.md | 4 +- package-lock.json | 247 ++++++++---------- package.json | 12 +- src/server/sessiond.ts | 9 +- .../globalProviderPolicy.acceptance.test.ts | 87 +++++- .../sessions/globalProviderPolicy.test.ts | 58 +++- src/server/sessions/globalProviderPolicy.ts | 109 ++++++-- .../piSessionService.promptQueue.test.ts | 2 +- .../sessions/piSessionService.testSupport.ts | 2 +- src/server/sessions/piSessionService.ts | 14 +- 12 files changed, 360 insertions(+), 197 deletions(-) diff --git a/.changeset/global-provider-policy.md b/.changeset/global-provider-policy.md index 0910f51..ce2b5a5 100644 --- a/.changeset/global-provider-policy.md +++ b/.changeset/global-provider-policy.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Support only globally configured providers (Pi built-ins, environment credentials, and the agent directory's `models.json`). Provider registrations from Pi extensions (`pi.registerProvider`) are now ignored and reported with a session warning instead of leaking into every concurrent session; all other extension features keep working. Configure such providers globally in the agent directory's `models.json` to use them. Session daemon code changed: after updating, restart `pi-web-sessiond.service` manually (`systemctl --user restart pi-web-sessiond`). +Support providers from global sources only: Pi built-ins, environment credentials, the agent directory's `models.json`, and providers registered by globally installed (agent-dir) extensions. Provider registrations from project extensions (`pi.registerProvider` in a workspace's extensions) are ignored and reported with a session warning instead of leaking into every concurrent session; all other extension features keep working. To use such a provider, configure it globally in `models.json` or install the extension globally. Requires Pi 0.81 or newer. Session daemon code changed: after updating, restart `pi-web-sessiond.service` manually (`systemctl --user restart pi-web-sessiond`). diff --git a/docs/plugins.html b/docs/plugins.html index c2fa8c8..e39d718 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -163,11 +163,12 @@ PI WEB browser plugins. A routine session daemon restart is not required.

- One exception applies to Pi package extensions: PI WEB supports only globally configured providers - (Pi built-ins, environment credentials, and the agent directory's models.json). If an - extension calls pi.registerProvider, PI WEB ignores the registration and warns in the - session; everything else the extension registers keeps working. Configure such providers globally in - the agent directory's models.json instead. + One exception applies to Pi package extensions: PI WEB providers come from global sources only + (Pi built-ins, environment credentials, the agent directory's models.json, and providers + registered by globally installed, agent-dir extensions). If a project extension calls + pi.registerProvider, PI WEB ignores the registration and warns in the session; everything + else the extension registers keeps working. Move such a provider to a global source: declare it in + the agent directory's models.json, or install the extension globally.

diff --git a/docs/plugins.md b/docs/plugins.md index fb1d57c..6d17269 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -27,9 +27,9 @@ Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB brows ## Extension provider registrations -PI WEB only supports globally configured providers: Pi built-ins, environment credentials, and providers declared in the agent directory's `models.json` (the directory selected by `agent.dir`; see [Configuration](https://pi-web.dev/config)). All sessions share one daemon-wide provider set, so extensions cannot add their own: if a Pi extension calls `pi.registerProvider(...)`, PI WEB ignores the registration and shows a warning in the session naming the provider. The extension itself still loads and everything else it registers keeps working; only the ignored provider's models never appear, so an extension that requires its own provider may load but remain unusable. +PI WEB providers come from global sources only: Pi built-ins, environment credentials, providers declared in the agent directory's `models.json` (the directory selected by `agent.dir`; see [Configuration](https://pi-web.dev/config)), and providers registered by globally installed (agent-dir) extensions. Global extensions load identically for every session, so their providers are safe on the shared daemon-wide runtime; project extensions differ per workspace and cannot add providers. If a project extension calls `pi.registerProvider(...)`, PI WEB ignores the registration and shows a warning in the session naming the provider. The extension itself still loads and everything else it registers keeps working; only the ignored provider's models never appear, so a project extension that requires its own provider may load but remain unusable. -To use such a provider, configure it globally in the agent directory's `models.json` instead. Project-level `models.json` files do not add providers to PI WEB sessions. +To use a project extension's provider, move it to a global source: declare it in the agent directory's `models.json`, or install the extension globally in the agent directory. Project-level `models.json` files do not add providers to PI WEB sessions. This policy guards against accidental cross-workspace leakage; it is not a security boundary, since extensions run as trusted code inside the daemon. ## Trust model diff --git a/package-lock.json b/package-lock.json index 2a2e89c..c8b6487 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,9 +42,9 @@ }, "devDependencies": { "@changesets/cli": "^2.31.0", - "@earendil-works/pi-agent-core": "^0.80.8", - "@earendil-works/pi-ai": "^0.80.8", - "@earendil-works/pi-coding-agent": "^0.80.8", + "@earendil-works/pi-agent-core": "^0.81.1", + "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-coding-agent": "^0.81.1", "@eslint/js": "^10.0.1", "@types/node": "^24.13.3", "@types/ws": "^8.18.1", @@ -61,9 +61,9 @@ "node": ">=22.19.0" }, "peerDependencies": { - "@earendil-works/pi-agent-core": ">=0.80.8 <0.81", - "@earendil-works/pi-ai": ">=0.80.8 <0.81", - "@earendil-works/pi-coding-agent": ">=0.80.8 <0.81" + "@earendil-works/pi-agent-core": ">=0.81.1 <0.82", + "@earendil-works/pi-ai": ">=0.81.1 <0.82", + "@earendil-works/pi-coding-agent": ">=0.81.1 <0.82" } }, "node_modules/@anthropic-ai/sdk": { @@ -167,9 +167,9 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.975.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz", - "integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", + "version": "3.976.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.976.0.tgz", + "integrity": "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -187,13 +187,13 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.59", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.59.tgz", - "integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==", + "version": "3.972.60", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz", + "integrity": "sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", + "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", @@ -204,13 +204,13 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.61", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.61.tgz", - "integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==", + "version": "3.972.62", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz", + "integrity": "sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", + "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", @@ -223,13 +223,13 @@ } }, "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { - "version": "4.9.7", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", - "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", + "version": "4.9.9", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.9.tgz", + "integrity": "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.5", + "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -238,20 +238,20 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.4.tgz", - "integrity": "sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==", + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz", + "integrity": "sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/credential-provider-env": "^3.972.59", - "@aws-sdk/credential-provider-http": "^3.972.61", - "@aws-sdk/credential-provider-login": "^3.972.66", - "@aws-sdk/credential-provider-process": "^3.972.59", - "@aws-sdk/credential-provider-sso": "^3.973.3", - "@aws-sdk/credential-provider-web-identity": "^3.972.65", - "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/core": "^3.976.0", + "@aws-sdk/credential-provider-env": "^3.972.60", + "@aws-sdk/credential-provider-http": "^3.972.62", + "@aws-sdk/credential-provider-login": "^3.972.67", + "@aws-sdk/credential-provider-process": "^3.972.60", + "@aws-sdk/credential-provider-sso": "^3.973.4", + "@aws-sdk/credential-provider-web-identity": "^3.972.66", + "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/credential-provider-imds": "^4.4.9", @@ -263,14 +263,14 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.66", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.66.tgz", - "integrity": "sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==", + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz", + "integrity": "sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/core": "^3.976.0", + "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", @@ -281,18 +281,18 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.70", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.70.tgz", - "integrity": "sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==", + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz", + "integrity": "sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.59", - "@aws-sdk/credential-provider-http": "^3.972.61", - "@aws-sdk/credential-provider-ini": "^3.973.4", - "@aws-sdk/credential-provider-process": "^3.972.59", - "@aws-sdk/credential-provider-sso": "^3.973.3", - "@aws-sdk/credential-provider-web-identity": "^3.972.65", + "@aws-sdk/credential-provider-env": "^3.972.60", + "@aws-sdk/credential-provider-http": "^3.972.62", + "@aws-sdk/credential-provider-ini": "^3.973.5", + "@aws-sdk/credential-provider-process": "^3.972.60", + "@aws-sdk/credential-provider-sso": "^3.973.4", + "@aws-sdk/credential-provider-web-identity": "^3.972.66", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/credential-provider-imds": "^4.4.9", @@ -304,13 +304,13 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.59", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.59.tgz", - "integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==", + "version": "3.972.60", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz", + "integrity": "sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", + "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", @@ -321,15 +321,15 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.3.tgz", - "integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==", + "version": "3.973.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz", + "integrity": "sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/nested-clients": "^3.997.33", - "@aws-sdk/token-providers": "3.1088.0", + "@aws-sdk/core": "^3.976.0", + "@aws-sdk/nested-clients": "^3.997.34", + "@aws-sdk/token-providers": "3.1092.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", @@ -340,14 +340,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1088.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1088.0.tgz", - "integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==", + "version": "3.1092.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz", + "integrity": "sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/core": "^3.976.0", + "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", @@ -358,14 +358,14 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.65", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.65.tgz", - "integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==", + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz", + "integrity": "sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/core": "^3.976.0", + "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", @@ -408,13 +408,13 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.41.tgz", - "integrity": "sha512-LSbGvvYmjc4Br9BPYI2dTLnIclmrSiQbahkP4D6nRGVEv4qsCZ8csVuKBPVEEFCVD+EEngGh8ROls6XpumtwMg==", + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.42.tgz", + "integrity": "sha512-dw+GP8DC7QC2C8tUoK7DI8BnrNAjz8tb+uBHSrD2qJvxkCf58kTtFr98pljSrk+umU4n4HDW4eU2k7C2dWMzsg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", + "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", @@ -427,13 +427,13 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.33", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.33.tgz", - "integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==", + "version": "3.997.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz", + "integrity": "sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", + "@aws-sdk/core": "^3.976.0", "@aws-sdk/signature-v4-multi-region": "^3.996.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", @@ -447,13 +447,13 @@ } }, "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { - "version": "4.9.7", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", - "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", + "version": "4.9.9", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.9.tgz", + "integrity": "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.5", + "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -984,13 +984,13 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.10", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz", - "integrity": "sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz", + "integrity": "sha512-yqbh68CyhqxMov/jUogFJfMqlu2Gd37GAki+tr59YCmAPHfomiCA5ESzusXtpGzABeiZFC/OrRdQ4GwCCOMIHA==", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.10", + "@earendil-works/pi-ai": "^0.81.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1007,9 +1007,9 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-ai": { - "version": "0.80.10", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", - "integrity": "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz", + "integrity": "sha512-hzHE7Z8l5mgJk+ke67Lge0rwS2+wbKJrFKl9o5M1R1rh33+cCT7D1AHz1OAtX5wFs90E1/BTGhyJRTUHaMxGvQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1040,16 +1040,16 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.80.10", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.10.tgz", - "integrity": "sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.1.tgz", + "integrity": "sha512-r6ovAsZOgAqbC/aU6s+/dPnv/sGZBuWyZNvi3pXjpbuX5wvp3XvGkQI7/VLvX2o9XpmpFaPUxKNym1WfkN/P8A==", "dev": true, "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.10", - "@earendil-works/pi-ai": "^0.80.10", - "@earendil-works/pi-tui": "^0.80.10", + "@earendil-works/pi-agent-core": "^0.81.1", + "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-tui": "^0.81.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -1539,12 +1539,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.10", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.10", + "@earendil-works/pi-ai": "^0.81.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1554,8 +1554,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.80.10", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -1579,8 +1579,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.80.10", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -1695,9 +1695,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1715,9 +1712,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1735,9 +1729,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1755,9 +1746,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1775,9 +1763,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2146,9 +2131,9 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -5345,9 +5330,9 @@ "license": "MIT" }, "node_modules/@smithy/core": { - "version": "3.29.5", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.5.tgz", - "integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==", + "version": "3.29.7", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.7.tgz", + "integrity": "sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5359,13 +5344,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.10.tgz", - "integrity": "sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==", + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.12.tgz", + "integrity": "sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.5", + "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -5374,13 +5359,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.7", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.7.tgz", - "integrity": "sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==", + "version": "5.6.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.9.tgz", + "integrity": "sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.5", + "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -5417,13 +5402,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.6.6", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.6.tgz", - "integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==", + "version": "5.6.8", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.8.tgz", + "integrity": "sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.5", + "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, diff --git a/package.json b/package.json index 27b7500..b4ac1aa 100644 --- a/package.json +++ b/package.json @@ -83,9 +83,9 @@ }, "devDependencies": { "@changesets/cli": "^2.31.0", - "@earendil-works/pi-agent-core": "^0.80.8", - "@earendil-works/pi-ai": "^0.80.8", - "@earendil-works/pi-coding-agent": "^0.80.8", + "@earendil-works/pi-agent-core": "^0.81.1", + "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-coding-agent": "^0.81.1", "@eslint/js": "^10.0.1", "@types/node": "^24.13.3", "@types/ws": "^8.18.1", @@ -114,9 +114,9 @@ "homepage": "https://pi-web.dev/", "packageManager": "npm@11.11.0", "peerDependencies": { - "@earendil-works/pi-agent-core": ">=0.80.8 <0.81", - "@earendil-works/pi-ai": ">=0.80.8 <0.81", - "@earendil-works/pi-coding-agent": ">=0.80.8 <0.81" + "@earendil-works/pi-agent-core": ">=0.81.1 <0.82", + "@earendil-works/pi-ai": ">=0.81.1 <0.82", + "@earendil-works/pi-coding-agent": ">=0.81.1 <0.82" }, "keywords": [ "pi-package", diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 1a0d5d9..947ee27 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -7,7 +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 { installGlobalProviderPolicy, learnGlobalExtensionProviderIds } from "./sessions/globalProviderPolicy.js"; import { registerAuthRoutes } from "./sessions/authRoutes.js"; import { PiSessionService } from "./sessions/piSessionService.js"; import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js"; @@ -70,9 +70,12 @@ await runSessionDaemonStartup({ }), }); auth.subscribe((change) => { sessions.applyAuthChange(change); }); - // PI WEB only supports globally configured providers: reject every + // 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. - installGlobalProviderPolicy(auth.runtime, (providerId) => { sessions.noteRejectedProviderRegistration(providerId); }); + const globalExtensionProviderIds = await learnGlobalExtensionProviderIds(auth.runtime, activeAgentProfile.dir); + installGlobalProviderPolicy(auth.runtime, globalExtensionProviderIds, (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.acceptance.test.ts b/src/server/sessions/globalProviderPolicy.acceptance.test.ts index 35d1875..52af212 100644 --- a/src/server/sessions/globalProviderPolicy.acceptance.test.ts +++ b/src/server/sessions/globalProviderPolicy.acceptance.test.ts @@ -4,22 +4,24 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ModelRuntime } from "@earendil-works/pi-coding-agent"; import { InMemoryCredentialStore } from "@earendil-works/pi-ai"; -import { installGlobalProviderPolicy, providerRejectionMessage } from "./globalProviderPolicy.js"; +import { installGlobalProviderPolicy, learnGlobalExtensionProviderIds, providerRejectionMessage } from "./globalProviderPolicy.js"; import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js"; import { PiSessionService } from "./piSessionService.js"; import { CapturingSessionEventHub, createTestModelRuntime, TEST_MODEL_ID, TEST_MODEL_PROVIDER } from "./piSessionService.testSupport.js"; /** * Acceptance tests for the global provider policy, wired exactly as sessiond - * wires it in production: one shared ModelRuntime per daemon, the policy shim - * installed on it, and rejections fed into the session service. Sessions are - * created through the real default runtime factory, so project extensions in a - * temp cwd are genuinely loaded by Pi's `createAgentSessionServices`. + * wires it in production: one shared ModelRuntime per daemon, the global + * extensions' provider ids learned at startup, the policy shim installed on + * the runtime, and rejections fed into the session service. Sessions are + * created through the real default runtime factory, so extensions in a temp + * cwd or temp agent dir are genuinely loaded by Pi's + * `createAgentSessionServices`. * * These tests are also the tripwire for the shim's one piece of machinery - * (instance-method shadowing of `registerProvider`): if a Pi upgrade changes - * how registrations reach the runtime, the load-time and late-registration - * tests here fail loudly. + * (instance-method shadowing of `registerProvider` / `registerNativeProvider` + * / `unregisterProvider`): if a Pi upgrade changes how registrations reach + * the runtime, the load-time and late-registration tests here fail loudly. */ const tempDirs: string[] = []; @@ -56,11 +58,21 @@ async function policyHarness(options: { runtime?: ModelRuntime; agentDir?: strin heartbeatIntervalMs: 60_000, }); services.push(service); - // The exact sessiond wiring: policy on the shared runtime, rejections to the service. - installGlobalProviderPolicy(runtime, (providerId) => { service.noteRejectedProviderRegistration(providerId); }); + // The exact sessiond wiring: learn the agent dir's global-extension + // providers first, then the policy on the shared runtime, rejections to the + // service. + const allowedProviderIds = await learnGlobalExtensionProviderIds(runtime, agentDir); + installGlobalProviderPolicy(runtime, allowedProviderIds, (providerId) => { service.noteRejectedProviderRegistration(providerId); }); return { service, runtime, agentDir }; } +/** Write a global extension into `/extensions/` (agent-dir extensions load for every session). */ +async function agentDirWithExtension(agentDir: string, source: string): Promise { + await mkdir(join(agentDir, "extensions"), { recursive: true }); + await writeFile(join(agentDir, "extensions", "global-probe.js"), source); + return agentDir; +} + /** Write a project extension into `/.pi/extensions/` and return the cwd. */ async function projectWithExtension(source: string): Promise { const cwd = await tempDir("pi-web-policy-project-"); @@ -97,7 +109,7 @@ function providerRegistrationSource(providerId: string): string { return `pi.registerProvider(${JSON.stringify(providerId)}, ${providerConfigJson(providerId)});`; } -const POLICY_WORDING = "PI WEB only supports globally configured providers"; +const POLICY_WORDING = "PI WEB providers must come from global configuration"; describe("global provider policy acceptance", () => { it("rejects a load-time provider registration while the extension's tool and command keep working", async () => { @@ -231,6 +243,59 @@ describe("global provider policy acceptance", () => { expect(runtime.getModel("collide-acme", "model-1")).toBeUndefined(); }); + it("allows providers from global (agent-dir) extensions while still rejecting project extensions", async () => { + const agentDir = await agentDirWithExtension(await tempDir("pi-web-policy-agent-"), ` + export default function (pi) { + ${providerRegistrationSource("global-ext")} + } + `); + const { service, runtime } = await policyHarness({ agentDir }); + const cwd = await projectWithExtension(` + export default function (pi) { + ${providerRegistrationSource("project-ext")} + } + `); + + const session = await service.start(cwd); + const ref = { id: session.id, cwd }; + + // The global extension's provider reached the shared runtime at daemon + // startup and stays usable; the project extension's is rejected. + expect(runtime.getModel("global-ext", "model-1")).toBeDefined(); + expect(runtime.getModel("project-ext", "model-1")).toBeUndefined(); + expect(runtime.getRegisteredProviderIds()).toEqual(["global-ext"]); + const status = await service.status(ref); + expect(status.warnings).toEqual([ + { severity: "warning", message: providerRejectionMessage("project-ext", cwd), source: "runtime" }, + ]); + const models = await service.availableModels(ref); + expect(models.some((model) => model.provider === "global-ext")).toBe(true); + expect(models.some((model) => model.provider === "project-ext")).toBe(false); + }); + + it("lets a global extension re-register its provider late without warnings", async () => { + // The pi-tensorx pattern: register on load, then re-register from + // session_start with a refreshed model catalog. Both calls carry the + // learned id, so neither is a leak. + const agentDir = await agentDirWithExtension(await tempDir("pi-web-policy-agent-"), ` + export default function (pi) { + ${providerRegistrationSource("global-ext")} + pi.on("session_start", () => { + ${providerRegistrationSource("global-ext")} + }); + } + `); + const { service, runtime } = await policyHarness({ agentDir }); + const plainCwd = await tempDir("pi-web-policy-project-"); + + const bystander = await service.start(plainCwd); + const status = await service.status({ id: bystander.id, cwd: plainCwd }); + expect((status.warnings ?? []).filter((warning) => warning.message.includes(POLICY_WORDING))).toEqual([]); + const inbox = service.notificationInbox({ id: bystander.id, cwd: plainCwd }); + expect(inbox.notifications.filter((notification) => notification.message.includes(POLICY_WORDING))).toEqual([]); + expect(runtime.getModel("global-ext", "model-1")).toBeDefined(); + }); + it("does not let a project-level models.json alter the shared runtime's provider set", async () => { // Spike assertion for plan §2: the shared runtime reads providers from the // agent-dir models.json only. A project-level models.json is not a diff --git a/src/server/sessions/globalProviderPolicy.test.ts b/src/server/sessions/globalProviderPolicy.test.ts index 3192160..8abbb47 100644 --- a/src/server/sessions/globalProviderPolicy.test.ts +++ b/src/server/sessions/globalProviderPolicy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ModelRuntime } from "@earendil-works/pi-coding-agent"; +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"; @@ -8,15 +9,33 @@ import { createTestModelRuntime, TEST_MODEL_ID, TEST_MODEL_PROVIDER } from "./pi * every test builds a dedicated runtime rather than touching the shared * `testModelRuntime` from testSupport. */ -async function policyRuntime(): Promise<{ runtime: ModelRuntime; rejections: string[] }> { +async function policyRuntime( + allowedExtensionProviderIds: ReadonlySet = new Set(), +): Promise<{ runtime: ModelRuntime; rejections: string[] }> { const runtime = await createTestModelRuntime(); const rejections: string[] = []; - installGlobalProviderPolicy(runtime, (providerId) => { rejections.push(providerId); }); + installGlobalProviderPolicy(runtime, allowedExtensionProviderIds, (providerId) => { rejections.push(providerId); }); return { runtime, rejections }; } +function nativeProvider(providerId: string): Provider { + return { + id: providerId, + name: providerId, + auth: { + apiKey: { + name: `${providerId} API key`, + resolve: () => Promise.resolve(undefined), + }, + }, + getModels: () => [], + stream: () => { throw new Error("stream should not be called in this test"); }, + streamSimple: () => { throw new Error("streamSimple should not be called in this test"); }, + }; +} + describe("installGlobalProviderPolicy", () => { - it("swallows registrations and records each rejection", async () => { + it("rejects non-allowed registrations and records each rejection", async () => { const { runtime, rejections } = await policyRuntime(); runtime.registerProvider("acme", { baseUrl: "https://acme.example.com" }); @@ -28,11 +47,37 @@ describe("installGlobalProviderPolicy", () => { expect(runtime.getRegisteredProviderConfig("acme")).toBeUndefined(); }); - it("makes unregisterProvider a no-op that cannot remove global providers", async () => { - const { runtime, rejections } = await policyRuntime(); + 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([]); @@ -56,7 +101,8 @@ describe("providerRejectionMessage", () => { expect(message).toContain('Provider "acme"'); expect(message).toContain("in /workspace/project"); - expect(message).toContain("PI WEB only supports globally configured providers"); + 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."); }); diff --git a/src/server/sessions/globalProviderPolicy.ts b/src/server/sessions/globalProviderPolicy.ts index 3e67445..05959c2 100644 --- a/src/server/sessions/globalProviderPolicy.ts +++ b/src/server/sessions/globalProviderPolicy.ts @@ -1,38 +1,101 @@ -import type { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAgentSessionServices, 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. + * PI WEB providers come from global sources only: Pi built-ins, agent-dir + * `models.json`, environment credentials, and providers registered by global + * (agent-dir) extensions. Provider registrations from project extensions are + * 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. + * 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` / - * `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. + * `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, onRejection: (providerId: string) => void, ): void { - runtime.registerProvider = (providerId: string) => { + 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.unregisterProvider = () => { - // No-op: with every registration rejected, the extension provider layer is - // always empty, so there is never anything to unregister. + 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. + }; +} + +/** + * Learn which provider ids the agent directory's global extensions register. + * + * Loads extensions once for a guaranteed-empty temporary cwd — so only global + * (agent-dir) extensions load — against the daemon's shared runtime, and + * returns the ids that appeared. Those registrations stay on the runtime: they + * 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> { + const before = new Set(runtime.getRegisteredProviderIds()); + const scratchCwd = await mkdtemp(join(tmpdir(), "pi-web-global-ext-")); + try { + await createAgentSessionServices({ cwd: scratchCwd, agentDir, modelRuntime: runtime }); + } finally { + await rm(scratchCwd, { recursive: true, force: true }); + } + const learned = new Set(); + for (const providerId of runtime.getRegisteredProviderIds()) { + if (!before.has(providerId)) learned.add(providerId); + } + return learned; } /** @@ -42,6 +105,6 @@ export function installGlobalProviderPolicy( */ 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."; + return `Provider "${providerId}" ${origin} was ignored — PI WEB providers must come from global configuration ` + + "(agent-dir models.json) or a globally installed extension. All other extension features are unaffected."; } diff --git a/src/server/sessions/piSessionService.promptQueue.test.ts b/src/server/sessions/piSessionService.promptQueue.test.ts index 84f22fa..9b8a04f 100644 --- a/src/server/sessions/piSessionService.promptQueue.test.ts +++ b/src/server/sessions/piSessionService.promptQueue.test.ts @@ -99,7 +99,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { return stream; }; const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("name-session", { model, agent: { streamFn } }); + const fake = fakeRuntime("name-session", { model, agent: { streamFunction: streamFn } }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, modelRuntime: testModelRuntime, diff --git a/src/server/sessions/piSessionService.testSupport.ts b/src/server/sessions/piSessionService.testSupport.ts index c25556b..c6c7635 100644 --- a/src/server/sessions/piSessionService.testSupport.ts +++ b/src/server/sessions/piSessionService.testSupport.ts @@ -208,7 +208,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial setSessionName: (name: string) => { session.sessionName = name; }, compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }), getUserMessagesForForking: () => [], - agent: { streamFn: () => { throw new Error("streamFn should not be called in this test"); } }, + agent: { streamFunction: () => { throw new Error("streamFunction should not be called in this test"); } }, ...patch, }; const runtime: PiSessionRuntime = { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 62b3294..3fd4617 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -346,13 +346,13 @@ export interface PiAgentSession { setSessionName(name: string): void; /** * Narrow re-expression of `AgentSession.agent` (an `@earendil-works/pi-agent-core` - * `Agent`), exposing only `streamFn` — the resolved-auth/headers/retry "call this - * model" function pi's own compaction/branch-summarization code uses internally. - * Lets callers (e.g. session title generation) issue one-off model calls without - * depending on pi-ai's deprecated `/compat` provider registry or leaking the full - * `Agent`/`AgentSession` surface. + * `Agent`), exposing only `streamFunction` — the resolved-auth/headers/retry "call + * this model" function pi's own compaction/branch-summarization code uses + * internally. Lets callers (e.g. session title generation) issue one-off model + * calls without depending on pi-ai's deprecated `/compat` provider registry or + * leaking the full `Agent`/`AgentSession` surface. */ - agent: { streamFn: StreamFn }; + agent: { streamFunction: StreamFn }; } export interface PiSessionRuntime { @@ -2761,7 +2761,7 @@ export class PiSessionService implements SessionRouteService { const model = session.model; if (model === undefined) return; - void generateShortSessionName(session.agent.streamFn, model, firstMessage).then((name) => { + void generateShortSessionName(session.agent.streamFunction, model, firstMessage).then((name) => { this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage)); }).catch(() => { this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage)); From 04d8134ba4027fd1bb0dc1af01880e68c74c721b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 22 Jul 2026 20:31:08 +0200 Subject: [PATCH 05/10] fix(sessions): freeze providers after global bootstrap --- src/server/sessiond.ts | 12 +- .../sessions/globalProviderPolicy.test.ts | 259 +++++++++++------- src/server/sessions/globalProviderPolicy.ts | 201 +++++++------- src/server/sessions/piSessionService.ts | 67 +---- 4 files changed, 281 insertions(+), 258 deletions(-) diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 947ee27..8703dba 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -7,7 +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, learnGlobalExtensionProviderIds } from "./sessions/globalProviderPolicy.js"; +import { bootstrapAndFreezeGlobalExtensionProviders } from "./sessions/globalProviderPolicy.js"; import { registerAuthRoutes } from "./sessions/authRoutes.js"; import { PiSessionService } from "./sessions/piSessionService.js"; import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js"; @@ -51,6 +51,10 @@ await runSessionDaemonStartup({ await unreadStore.load(); const workspaceActivity = new WorkspaceActivityService(eventHub); 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 ? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() }) : undefined; @@ -70,12 +74,6 @@ await runSessionDaemonStartup({ }), }); 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 runtimeComponent = Object.freeze({ ...getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES), diff --git a/src/server/sessions/globalProviderPolicy.test.ts b/src/server/sessions/globalProviderPolicy.test.ts index 8abbb47..4b536e0 100644 --- a/src/server/sessions/globalProviderPolicy.test.ts +++ b/src/server/sessions/globalProviderPolicy.test.ts @@ -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 = 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; + 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 { + const path = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(path); + return path; +} + +async function agentDirWithExtension(source: string): Promise { + 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, 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>): 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", + }); }); }); diff --git a/src/server/sessions/globalProviderPolicy.ts b/src/server/sessions/globalProviderPolicy.ts index 05959c2..ebad281 100644 --- a/src/server/sessions/globalProviderPolicy.ts +++ b/src/server/sessions/globalProviderPolicy.ts @@ -1,110 +1,125 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; 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"; -/** - * PI WEB providers come from global sources only: Pi built-ins, agent-dir - * `models.json`, environment credentials, and providers registered by global - * (agent-dir) extensions. Provider registrations from project extensions are - * rejected — the user is told, and everything else the extension does keeps - * 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, - 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. - }; +/** Structured logging boundary supplied by the session daemon. */ +export interface GlobalProviderBootstrapLogger { + error(details: Record, message: string): void; + info(details: Record, message: string): void; + warn(details: Record, message: string): void; } -/** - * Learn which provider ids the agent directory's global extensions register. - * - * Loads extensions once for a guaranteed-empty temporary cwd — so only global - * (agent-dir) extensions load — against the daemon's shared runtime, and - * returns the ids that appeared. Those registrations stay on the runtime: they - * 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> { - const before = new Set(runtime.getRegisteredProviderIds()); +type ProviderMutationOperation = "registerNativeProvider" | "registerProvider" | "unregisterProvider"; +type ProviderMutationMethods = Pick; + +const LOG_CONTEXT = "global-provider-bootstrap"; + +async function loadGlobalExtensionServices(runtime: ModelRuntime, agentDir: string): Promise { const scratchCwd = await mkdtemp(join(tmpdir(), "pi-web-global-ext-")); try { - await createAgentSessionServices({ cwd: scratchCwd, agentDir, modelRuntime: runtime }); + return await createAgentSessionServices({ cwd: scratchCwd, agentDir, modelRuntime: runtime }); } finally { await rm(scratchCwd, { recursive: true, force: true }); } - const learned = new Set(); - 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> = { + 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 - * rejections raised while loading a workspace's services; late registrations - * from session event handlers carry no attribution beyond the provider id. + * Load global extensions once against the shared model runtime, then make its + * extension-provider baseline immutable for the rest of the daemon lifetime. + * 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 { - const origin = cwd === undefined ? "registered by an extension" : `registered by an extension in ${cwd}`; - return `Provider "${providerId}" ${origin} was ignored — PI WEB providers must come from global configuration ` - + "(agent-dir models.json) or a globally installed extension. All other extension features are unaffected."; +export async function bootstrapAndFreezeGlobalExtensionProviders( + runtime: ModelRuntime, + agentDir: string, + logger: GlobalProviderBootstrapLogger, +): Promise { + 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", + ); } diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 3fd4617..720d949 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -65,7 +65,6 @@ import { type SessionNotificationMutation, } from "./sessionNotificationStore.js"; import { plainTextTheme } from "./plainTextTheme.js"; -import { providerRejectionMessage } from "./globalProviderPolicy.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( modelRuntime: ModelRuntime, sessionManagers: Pick, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps, - providerRejections?: ProviderRejectionTracker, ): PiWebCreateAgentSessionRuntimeFactory { return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => { - 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 services: AgentSessionServices = await createAgentSessionServices({ cwd, agentDir, modelRuntime }); const resolvedDelegationToolsEnabled = delegationToolsEnabled ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers); const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions); @@ -729,8 +704,6 @@ 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[] = []; @@ -769,16 +742,6 @@ 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; @@ -823,34 +786,6 @@ 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(); From 66f0ea44ce278d1e13b4008c10b5c3be1077578d Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 22 Jul 2026 20:39:43 +0200 Subject: [PATCH 06/10] test(sessions): cover immutable provider baseline --- .../globalProviderPolicy.acceptance.test.ts | 537 +++++++++++------- 1 file changed, 330 insertions(+), 207 deletions(-) diff --git a/src/server/sessions/globalProviderPolicy.acceptance.test.ts b/src/server/sessions/globalProviderPolicy.acceptance.test.ts index 52af212..ecd2ee8 100644 --- a/src/server/sessions/globalProviderPolicy.acceptance.test.ts +++ b/src/server/sessions/globalProviderPolicy.acceptance.test.ts @@ -4,29 +4,159 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ModelRuntime } from "@earendil-works/pi-coding-agent"; import { InMemoryCredentialStore } from "@earendil-works/pi-ai"; -import { installGlobalProviderPolicy, learnGlobalExtensionProviderIds, providerRejectionMessage } from "./globalProviderPolicy.js"; +import { + bootstrapAndFreezeGlobalExtensionProviders, + type GlobalProviderBootstrapLogger, +} from "./globalProviderPolicy.js"; import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js"; -import { PiSessionService } from "./piSessionService.js"; -import { CapturingSessionEventHub, createTestModelRuntime, TEST_MODEL_ID, TEST_MODEL_PROVIDER } from "./piSessionService.testSupport.js"; +import { PiSessionService, type PiSessionRef } from "./piSessionService.js"; +import { + CapturingSessionEventHub, + createTestModelRuntime, + TEST_MODEL_ID, + TEST_MODEL_PROVIDER, +} from "./piSessionService.testSupport.js"; /** - * Acceptance tests for the global provider policy, wired exactly as sessiond - * wires it in production: one shared ModelRuntime per daemon, the global - * extensions' provider ids learned at startup, the policy shim installed on - * the runtime, and rejections fed into the session service. Sessions are - * created through the real default runtime factory, so extensions in a temp - * cwd or temp agent dir are genuinely loaded by Pi's - * `createAgentSessionServices`. + * Acceptance coverage for the exact sessiond lifecycle: global extensions are + * loaded once against the shared ModelRuntime, provider mutations are frozen, + * and real sessions subsequently load both global and project extensions + * through Pi's public session factories. * - * These tests are also the tripwire for the shim's one piece of machinery - * (instance-method shadowing of `registerProvider` / `registerNativeProvider` - * / `unregisterProvider`): if a Pi upgrade changes how registrations reach - * the runtime, the load-time and late-registration tests here fail loudly. + * These tests are also a tripwire for the instance-method shadowing used to + * freeze `registerProvider`, native registration, and unregistration. If Pi + * changes how real extension calls reach ModelRuntime, these scenarios fail. */ +interface LogEntry { + level: "error" | "info" | "warn"; + details: Record; + message: string; +} + +interface PolicyHarness { + service: PiSessionService; + runtime: ModelRuntime; + agentDir: string; + logEntries: LogEntry[]; +} + const tempDirs: string[] = []; const services: PiSessionService[] = []; +const IGNORED_MUTATION_MESSAGE = "ignored provider mutation after global bootstrap"; + +function modelId(providerId: string, variant: string): string { + return `${providerId}-${variant}-model`; +} + +function providerBaseUrl(providerId: string, variant: string): string { + return `https://${providerId}-${variant}.example.com`; +} + +function providerConfig(providerId: string, variant = "baseline"): Record { + return { + name: `${providerId} ${variant}`, + baseUrl: providerBaseUrl(providerId, variant), + apiKey: `sk-${providerId}-${variant}-secret`, + api: "openai-completions", + models: [{ + id: modelId(providerId, variant), + name: `${providerId} ${variant} model`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000, + maxTokens: 100, + }], + }; +} + +function providerRegistrationSource(providerId: string, variant = "baseline"): string { + return `pi.registerProvider(${JSON.stringify(providerId)}, ${JSON.stringify(providerConfig(providerId, variant))});`; +} + +function nativeProviderRegistrationSource(providerId: string, variant = "baseline"): string { + const baseUrl = providerBaseUrl(providerId, variant); + const model = { + id: modelId(providerId, variant), + name: `${providerId} ${variant} model`, + api: "openai-completions", + provider: providerId, + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 2_000, + maxTokens: 200, + }; + return `pi.registerProvider({ + id: ${JSON.stringify(providerId)}, + name: ${JSON.stringify(`${providerId} ${variant}`)}, + baseUrl: ${JSON.stringify(baseUrl)}, + auth: { + apiKey: { + name: ${JSON.stringify(`${providerId} API key`)}, + async resolve() { + return { + auth: { apiKey: ${JSON.stringify(`sk-${providerId}-${variant}-secret`)} }, + source: "acceptance fixture" + }; + } + } + }, + getModels() { return [${JSON.stringify(model)}]; }, + stream() { throw new Error("stream should not be called in this acceptance test"); }, + streamSimple() { throw new Error("streamSimple should not be called in this acceptance test"); } + });`; +} + +function globalProvidersSource(): string { + return ` + export default function (pi) { + ${providerRegistrationSource("global-config")} + ${nativeProviderRegistrationSource("global-native")} + } + `; +} + +function capturingLogger(): { entries: LogEntry[]; logger: GlobalProviderBootstrapLogger } { + const entries: LogEntry[] = []; + const record = (level: LogEntry["level"], details: Record, 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 ignoredMutationEntries(entries: readonly LogEntry[]): LogEntry[] { + return entries.filter((entry) => entry.message === IGNORED_MUTATION_MESSAGE); +} + +function expectIgnoredMutations( + entries: readonly LogEntry[], + expected: readonly { operation: string; providerId: string }[], +): void { + const ignored = ignoredMutationEntries(entries); + expect(ignored).toHaveLength(expected.length); + expect(ignored.map((entry) => entry.details)).toEqual(expect.arrayContaining( + expected.map(({ operation, providerId }) => ({ + context: "global-provider-bootstrap", + operation, + providerId, + })), + )); + expect(ignored.every((entry) => entry.level === "info")).toBe(true); + const operationProviderKeys = ignored.map((entry) => `${String(entry.details["operation"])}:${String(entry.details["providerId"])}`); + expect(new Set(operationProviderKeys).size).toBe(ignored.length); +} + afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(services.splice(0).map(async (service) => service.dispose())); @@ -39,37 +169,14 @@ async function tempDir(prefix: string): Promise { return dir; } -interface PolicyHarness { - service: PiSessionService; - runtime: ModelRuntime; - agentDir: string; -} - -async function policyHarness(options: { runtime?: ModelRuntime; agentDir?: string } = {}): Promise { - const agentDir = options.agentDir ?? await tempDir("pi-web-policy-agent-"); - // Isolate Pi's per-user resource discovery (~/.agents/skills et al.) so the - // only extensions loaded are the ones a test writes into its temp cwd. - vi.stubEnv("HOME", await tempDir("pi-web-policy-home-")); - const runtime = options.runtime ?? await createTestModelRuntime(); - const service = new PiSessionService(new CapturingSessionEventHub(), { - agentDir, - modelRuntime: runtime, - sessionManager: createPiSessionManagerGateway({ agentDir, env: {}, sessionDirEnvKeys: [] }), - heartbeatIntervalMs: 60_000, - }); - services.push(service); - // The exact sessiond wiring: learn the agent dir's global-extension - // providers first, then the policy on the shared runtime, rejections to the - // service. - const allowedProviderIds = await learnGlobalExtensionProviderIds(runtime, agentDir); - installGlobalProviderPolicy(runtime, allowedProviderIds, (providerId) => { service.noteRejectedProviderRegistration(providerId); }); - return { service, runtime, agentDir }; -} - -/** Write a global extension into `/extensions/` (agent-dir extensions load for every session). */ -async function agentDirWithExtension(agentDir: string, source: string): Promise { +async function writeAgentExtension(agentDir: string, source: string): Promise { await mkdir(join(agentDir, "extensions"), { recursive: true }); await writeFile(join(agentDir, "extensions", "global-probe.js"), source); +} + +async function agentDirWithExtension(source: string): Promise { + const agentDir = await tempDir("pi-web-policy-agent-"); + await writeAgentExtension(agentDir, source); return agentDir; } @@ -81,17 +188,31 @@ async function projectWithExtension(source: string): Promise { return cwd; } -function providerConfig(providerId: string): Record { - return { - baseUrl: `https://${providerId}.example.com`, - apiKey: "sk-test", - api: "openai-completions", - models: [{ id: "model-1", name: "Model One", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 100 }], - }; +async function policyHarness(options: { runtime?: ModelRuntime; agentDir?: string } = {}): Promise { + const agentDir = options.agentDir ?? await tempDir("pi-web-policy-agent-"); + // Isolate Pi's per-user resource discovery (~/.agents/skills et al.) so the + // harness sees only extensions written into its explicit agent/project dirs. + vi.stubEnv("HOME", await tempDir("pi-web-policy-home-")); + const runtime = options.runtime ?? await createTestModelRuntime(); + const { entries, logger } = capturingLogger(); + + await bootstrapAndFreezeGlobalExtensionProviders(runtime, agentDir, logger); + + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir, + modelRuntime: runtime, + sessionManager: createPiSessionManagerGateway({ agentDir, env: {}, sessionDirEnvKeys: [] }), + heartbeatIntervalMs: 60_000, + logger, + }); + services.push(service); + return { service, runtime, agentDir, logEntries: entries }; } -function providerConfigJson(providerId: string): string { - return JSON.stringify(providerConfig(providerId)); +async function expectNoProviderMutationFeedback(service: PiSessionService, ref: PiSessionRef): Promise { + const status = await service.status(ref); + expect(status.warnings ?? []).toEqual([]); + expect(service.notificationInbox(ref).notifications).toEqual([]); } /** Parse the session-start marker file without type assertions. */ @@ -105,32 +226,84 @@ function parseToolMarker(raw: string): { activeTools: string[]; allTools: string return { activeTools: activeTools.map(String), allTools: allTools.map(String) }; } -function providerRegistrationSource(providerId: string): string { - return `pi.registerProvider(${JSON.stringify(providerId)}, ${providerConfigJson(providerId)});`; -} +describe("immutable global provider bootstrap acceptance", () => { + it("loads global config and native providers once, then treats normal session replay as a no-op", async () => { + const agentDir = await agentDirWithExtension(globalProvidersSource()); + const { service, runtime, logEntries } = await policyHarness({ agentDir }); + const baselineConfig = runtime.getRegisteredProviderConfig("global-config"); + const baselineNative = runtime.getRegisteredNativeProvider("global-native"); -const POLICY_WORDING = "PI WEB providers must come from global configuration"; + expect(baselineConfig).toMatchObject({ baseUrl: providerBaseUrl("global-config", "baseline") }); + expect(baselineNative).toMatchObject({ + id: "global-native", + baseUrl: providerBaseUrl("global-native", "baseline"), + }); + expect(logEntries).toContainEqual({ + level: "info", + details: { context: "global-provider-bootstrap", providerIds: ["global-config", "global-native"] }, + message: "global extension provider baseline bootstrapped and frozen", + }); -describe("global provider policy acceptance", () => { - it("rejects a load-time provider registration while the extension's tool and command keep working", async () => { - const { service, runtime } = await policyHarness(); + const cwd = await tempDir("pi-web-policy-project-"); + const session = await service.start(cwd); + const ref = { id: session.id, cwd }; + + expect(runtime.getRegisteredProviderIds()).toEqual(["global-config", "global-native"]); + expect(runtime.getRegisteredProviderConfig("global-config")).toBe(baselineConfig); + expect(runtime.getRegisteredNativeProvider("global-native")).toBe(baselineNative); + expect(runtime.getModel("global-config", modelId("global-config", "baseline"))).toMatchObject({ + provider: "global-config", + baseUrl: providerBaseUrl("global-config", "baseline"), + }); + expect(runtime.getModel("global-native", modelId("global-native", "baseline"))).toMatchObject({ + provider: "global-native", + baseUrl: providerBaseUrl("global-native", "baseline"), + }); + const available = await service.availableModels(ref); + expect(available).toEqual(expect.arrayContaining([ + expect.objectContaining({ provider: "global-config", id: modelId("global-config", "baseline") }), + expect.objectContaining({ provider: "global-native", id: modelId("global-native", "baseline") }), + ])); + expectIgnoredMutations(logEntries, [ + { operation: "registerProvider", providerId: "global-config" }, + { operation: "registerNativeProvider", providerId: "global-native" }, + ]); + await expectNoProviderMutationFeedback(service, ref); + }); + + it("blocks real project add, replacement, and unregister calls without disabling other extension features", async () => { + const agentDir = await agentDirWithExtension(globalProvidersSource()); + const { service, runtime, logEntries } = await policyHarness({ agentDir }); + const baselineConfig = runtime.getRegisteredProviderConfig("global-config"); + const baselineNative = runtime.getRegisteredNativeProvider("global-native"); const markerPath = join(await tempDir("pi-web-policy-marker-"), "session-start.json"); const cwd = await projectWithExtension(` import { writeFileSync } from "node:fs"; export default function (pi) { - ${providerRegistrationSource("acme-ext")} + ${providerRegistrationSource("project-config", "project-secret")} + ${providerRegistrationSource("global-config", "project-secret")} + ${nativeProviderRegistrationSource("project-native", "project-secret")} + ${nativeProviderRegistrationSource("global-native", "project-secret")} pi.registerTool({ - name: "acme_tool", - label: "Acme Tool", - description: "acceptance probe tool", + name: "project_probe_tool", + label: "Project Probe Tool", + description: "non-provider acceptance probe", parameters: { type: "object", properties: {} }, - async execute() { return { content: [{ type: "text", text: "acme ok" }] }; }, + async execute() { return { content: [{ type: "text", text: "project probe ok" }] }; } }); - pi.registerCommand("acme-cmd", { description: "acceptance probe command", async handler() {} }); - pi.on("session_start", async () => { + pi.registerCommand("project-probe", { + description: "non-provider acceptance probe", + async handler() {} + }); + pi.on("session_start", () => { + ${providerRegistrationSource("project-config", "late-secret")} + pi.unregisterProvider("global-config"); + pi.unregisterProvider("global-config"); + pi.unregisterProvider("global-native"); + pi.unregisterProvider("global-native"); writeFileSync(${JSON.stringify(markerPath)}, JSON.stringify({ activeTools: pi.getActiveTools(), - allTools: pi.getAllTools().map((tool) => tool.name), + allTools: pi.getAllTools().map((tool) => tool.name) })); }); } @@ -139,167 +312,118 @@ describe("global provider policy acceptance", () => { const session = await service.start(cwd); const ref = { id: session.id, cwd }; - // The session opens and the rejection is surfaced as the session's one warning. - const status = await service.status(ref); - expect(status.warnings).toEqual([ - { severity: "warning", message: providerRejectionMessage("acme-ext", cwd), source: "runtime" }, - ]); - - // The provider never reached the shared runtime or the model listings. - expect(runtime.getRegisteredProviderIds()).toEqual([]); - expect(runtime.getModel("acme-ext", "model-1")).toBeUndefined(); - const models = await service.availableModels(ref); - expect(models.some((model) => model.provider === "acme-ext")).toBe(false); - - // Global (built-in) providers are untouched. + expect(runtime.getRegisteredProviderIds()).toEqual(["global-config", "global-native"]); + expect(runtime.getRegisteredProviderConfig("global-config")).toBe(baselineConfig); + expect(runtime.getRegisteredNativeProvider("global-native")).toBe(baselineNative); + expect(runtime.getRegisteredProviderConfig("project-config")).toBeUndefined(); + expect(runtime.getRegisteredNativeProvider("project-native")).toBeUndefined(); + expect(runtime.getModel("global-config", modelId("global-config", "baseline"))).toBeDefined(); + expect(runtime.getModel("global-config", modelId("global-config", "project-secret"))).toBeUndefined(); + expect(runtime.getModel("global-native", modelId("global-native", "baseline"))).toBeDefined(); + expect(runtime.getModel("global-native", modelId("global-native", "project-secret"))).toBeUndefined(); expect(runtime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID)).toBeDefined(); - // Everything else the extension registered still works. - const commands = await service.commands(ref); - expect(commands).toContainEqual({ name: "acme-cmd", description: "acceptance probe command", source: "extension" }); + expect(await service.commands(ref)).toContainEqual({ + name: "project-probe", + description: "non-provider acceptance probe", + source: "extension", + }); const marker = parseToolMarker(await readFile(markerPath, "utf-8")); - expect(marker.activeTools).toContain("acme_tool"); - }); + expect(marker.activeTools).toContain("project_probe_tool"); + expect(marker.allTools).toContain("project_probe_tool"); - it("appends exactly one warning diagnostic per rejected provider", async () => { - const { service } = await policyHarness(); - const cwd = await projectWithExtension(` - export default function (pi) { - ${providerRegistrationSource("multi-a")} - ${providerRegistrationSource("multi-b")} - ${providerRegistrationSource("multi-a")} - } - `); - - const session = await service.start(cwd); - const status = await service.status({ id: session.id, cwd }); - - expect(status.warnings).toEqual([ - { severity: "warning", message: providerRejectionMessage("multi-a", cwd), source: "runtime" }, - { severity: "warning", message: providerRejectionMessage("multi-b", cwd), source: "runtime" }, + expectIgnoredMutations(logEntries, [ + { operation: "registerProvider", providerId: "global-config" }, + { operation: "registerProvider", providerId: "project-config" }, + { operation: "registerNativeProvider", providerId: "global-native" }, + { operation: "registerNativeProvider", providerId: "project-native" }, + { operation: "unregisterProvider", providerId: "global-config" }, + { operation: "unregisterProvider", providerId: "global-native" }, ]); + expect(JSON.stringify(ignoredMutationEntries(logEntries))).not.toContain("secret"); + expect(JSON.stringify(ignoredMutationEntries(logEntries))).not.toContain("example.com"); + await expectNoProviderMutationFeedback(service, ref); }); - it("adds no policy warning when a load registers no providers", async () => { - const { service } = await policyHarness(); - const cwd = await tempDir("pi-web-policy-project-"); - - const session = await service.start(cwd); - const status = await service.status({ id: session.id, cwd }); - - expect((status.warnings ?? []).filter((warning) => warning.message.includes(POLICY_WORDING))).toEqual([]); - }); - - it("rejects a late registration from a session event handler and notifies active sessions", async () => { - const { service, runtime } = await policyHarness(); - const plainCwd = await tempDir("pi-web-policy-project-"); - const listenerCwd = await projectWithExtension(` + it("keeps a tensorX-style startup provider while ignoring its session_start refresh", async () => { + const providerId = "tensorx-style"; + const agentDir = await agentDirWithExtension(` export default function (pi) { + ${providerRegistrationSource(providerId, "startup")} pi.on("session_start", () => { - ${providerRegistrationSource("late-acme")} + ${providerRegistrationSource(providerId, "late-refresh-secret")} }); } `); - - // The listener session's `session_start` fires while it is being bound, - // after the load-time rejection window has closed: a late registration. - const bystander = await service.start(plainCwd); - await service.start(listenerCwd); - - // The rejection is broadcast to the sessions active at the time. - const inbox = service.notificationInbox({ id: bystander.id, cwd: plainCwd }); - const notices = inbox.notifications.filter((notification) => notification.message.includes(POLICY_WORDING)); - expect(notices).toHaveLength(1); - expect(notices[0]).toMatchObject({ severity: "warning", message: providerRejectionMessage("late-acme") }); - - // The late registration never reached the shared runtime either. - expect(runtime.getRegisteredProviderIds()).toEqual([]); - expect(runtime.getModel("late-acme", "model-1")).toBeUndefined(); - }); - - it("keeps workspaces with colliding provider ids from affecting each other", async () => { - const { service, runtime } = await policyHarness(); - const collisionSource = ` - export default function (pi) { - ${providerRegistrationSource("collide-acme")} - } - `; - const cwdA = await projectWithExtension(collisionSource); - const cwdB = await projectWithExtension(collisionSource); - - // The pre-#76 scenario: two workspaces register the same provider id on the - // shared runtime. With the policy, both sessions open and neither - // registration exists, so there is nothing left to collide. - const sessionA = await service.start(cwdA); - const sessionB = await service.start(cwdB); - - expect((await service.status({ id: sessionA.id, cwd: cwdA })).warnings).toEqual([ - { severity: "warning", message: providerRejectionMessage("collide-acme", cwdA), source: "runtime" }, - ]); - expect((await service.status({ id: sessionB.id, cwd: cwdB })).warnings).toEqual([ - { severity: "warning", message: providerRejectionMessage("collide-acme", cwdB), source: "runtime" }, - ]); - expect(runtime.getRegisteredProviderIds()).toEqual([]); - expect(runtime.getModel("collide-acme", "model-1")).toBeUndefined(); - }); - - it("allows providers from global (agent-dir) extensions while still rejecting project extensions", async () => { - const agentDir = await agentDirWithExtension(await tempDir("pi-web-policy-agent-"), ` - export default function (pi) { - ${providerRegistrationSource("global-ext")} - } - `); - const { service, runtime } = await policyHarness({ agentDir }); - const cwd = await projectWithExtension(` - export default function (pi) { - ${providerRegistrationSource("project-ext")} - } - `); + const { service, runtime, logEntries } = await policyHarness({ agentDir }); + const baseline = runtime.getRegisteredProviderConfig(providerId); + const cwd = await tempDir("pi-web-policy-project-"); const session = await service.start(cwd); const ref = { id: session.id, cwd }; - // The global extension's provider reached the shared runtime at daemon - // startup and stays usable; the project extension's is rejected. - expect(runtime.getModel("global-ext", "model-1")).toBeDefined(); - expect(runtime.getModel("project-ext", "model-1")).toBeUndefined(); - expect(runtime.getRegisteredProviderIds()).toEqual(["global-ext"]); - const status = await service.status(ref); - expect(status.warnings).toEqual([ - { severity: "warning", message: providerRejectionMessage("project-ext", cwd), source: "runtime" }, + expect(runtime.getRegisteredProviderConfig(providerId)).toBe(baseline); + expect(runtime.getRegisteredProviderConfig(providerId)).toMatchObject({ + baseUrl: providerBaseUrl(providerId, "startup"), + }); + expect(runtime.getModel(providerId, modelId(providerId, "startup"))).toBeDefined(); + expect(runtime.getModel(providerId, modelId(providerId, "late-refresh-secret"))).toBeUndefined(); + expectIgnoredMutations(logEntries, [ + { operation: "registerProvider", providerId }, ]); - const models = await service.availableModels(ref); - expect(models.some((model) => model.provider === "global-ext")).toBe(true); - expect(models.some((model) => model.provider === "project-ext")).toBe(false); + expect(JSON.stringify(ignoredMutationEntries(logEntries))).not.toContain("late-refresh-secret"); + await expectNoProviderMutationFeedback(service, ref); }); - it("lets a global extension re-register its provider late without warnings", async () => { - // The pi-tensorx pattern: register on load, then re-register from - // session_start with a refreshed model catalog. Both calls carry the - // learned id, so neither is a leak. - const agentDir = await agentDirWithExtension(await tempDir("pi-web-policy-agent-"), ` + it("requires a fresh daemon bootstrap for global extension changes instead of applying them on reload", async () => { + const providerId = "reload-global"; + const variantEnv = "PI_WEB_ACCEPTANCE_PROVIDER_VARIANT"; + vi.stubEnv(variantEnv, "first"); + const agentDir = await agentDirWithExtension(` export default function (pi) { - ${providerRegistrationSource("global-ext")} - pi.on("session_start", () => { - ${providerRegistrationSource("global-ext")} + const variant = process.env[${JSON.stringify(variantEnv)}] ?? "missing"; + pi.registerProvider(${JSON.stringify(providerId)}, { + name: "reload global " + variant, + baseUrl: "https://reload-" + variant + ".example.com", + apiKey: "sk-reload-" + variant, + api: "openai-completions", + models: [{ + id: "model-" + variant, + name: "Reload " + variant, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100 + }] }); } `); - const { service, runtime } = await policyHarness({ agentDir }); - const plainCwd = await tempDir("pi-web-policy-project-"); + const firstDaemon = await policyHarness({ agentDir }); + const firstBaseline = firstDaemon.runtime.getRegisteredProviderConfig(providerId); + const cwd = await tempDir("pi-web-policy-project-"); + const session = await firstDaemon.service.start(cwd); + const ref = { id: session.id, cwd }; - const bystander = await service.start(plainCwd); - const status = await service.status({ id: bystander.id, cwd: plainCwd }); - expect((status.warnings ?? []).filter((warning) => warning.message.includes(POLICY_WORDING))).toEqual([]); - const inbox = service.notificationInbox({ id: bystander.id, cwd: plainCwd }); - expect(inbox.notifications.filter((notification) => notification.message.includes(POLICY_WORDING))).toEqual([]); - expect(runtime.getModel("global-ext", "model-1")).toBeDefined(); + vi.stubEnv(variantEnv, "second"); + await expect(firstDaemon.service.runCommand(ref, "/reload")).resolves.toMatchObject({ type: "done" }); + + expect(firstDaemon.runtime.getRegisteredProviderConfig(providerId)).toBe(firstBaseline); + expect(firstDaemon.runtime.getRegisteredProviderConfig(providerId)).toMatchObject({ + baseUrl: "https://reload-first.example.com", + }); + expect(firstDaemon.runtime.getModel(providerId, "model-first")).toBeDefined(); + expect(firstDaemon.runtime.getModel(providerId, "model-second")).toBeUndefined(); + + const secondDaemon = await policyHarness({ agentDir }); + expect(secondDaemon.runtime.getRegisteredProviderConfig(providerId)).toMatchObject({ + baseUrl: "https://reload-second.example.com", + }); + expect(secondDaemon.runtime.getModel(providerId, "model-first")).toBeUndefined(); + expect(secondDaemon.runtime.getModel(providerId, "model-second")).toBeDefined(); }); - it("does not let a project-level models.json alter the shared runtime's provider set", async () => { - // Spike assertion for plan §2: the shared runtime reads providers from the - // agent-dir models.json only. A project-level models.json is not a - // scoped-provider vector. + it("leaves project-level models.json behavior unchanged", async () => { const agentDir = await tempDir("pi-web-policy-agent-"); await writeFile(join(agentDir, "models.json"), JSON.stringify({ providers: { "global-acme": providerConfig("global-acme") }, @@ -317,12 +441,11 @@ describe("global provider policy acceptance", () => { })); const session = await service.start(cwd); + const ref = { id: session.id, cwd }; - // The globally configured provider is honored; the project-level one is not. - expect(runtime.getModel("global-acme", "model-1")).toBeDefined(); - expect(runtime.getModel("project-acme", "model-1")).toBeUndefined(); + expect(runtime.getModel("global-acme", modelId("global-acme", "baseline"))).toBeDefined(); + expect(runtime.getModel("project-acme", modelId("project-acme", "baseline"))).toBeUndefined(); expect(runtime.getRegisteredProviderIds()).toEqual([]); - const status = await service.status({ id: session.id, cwd }); - expect((status.warnings ?? []).filter((warning) => warning.message.includes(POLICY_WORDING))).toEqual([]); + await expectNoProviderMutationFeedback(service, ref); }); }); From ee8d9e53594c7fa9c1a4c0609ac9a303ce7e367e Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 22 Jul 2026 20:46:41 +0200 Subject: [PATCH 07/10] docs: document immutable provider bootstrap --- .changeset/global-provider-policy.md | 4 ++- README.md | 2 +- docs/config.html | 2 +- docs/config.md | 2 +- docs/index.html | 2 +- docs/install.html | 2 +- docs/plugins.html | 41 +++++++++++++++++++++++----- docs/plugins.md | 10 +++++-- 8 files changed, 49 insertions(+), 16 deletions(-) diff --git a/.changeset/global-provider-policy.md b/.changeset/global-provider-policy.md index ce2b5a5..7343178 100644 --- a/.changeset/global-provider-policy.md +++ b/.changeset/global-provider-policy.md @@ -2,4 +2,6 @@ "@jmfederico/pi-web": patch --- -Support providers from global sources only: Pi built-ins, environment credentials, the agent directory's `models.json`, and providers registered by globally installed (agent-dir) extensions. Provider registrations from project extensions (`pi.registerProvider` in a workspace's extensions) are ignored and reported with a session warning instead of leaking into every concurrent session; all other extension features keep working. To use such a provider, configure it globally in `models.json` or install the extension globally. Requires Pi 0.81 or newer. Session daemon code changed: after updating, restart `pi-web-sessiond.service` manually (`systemctl --user restart pi-web-sessiond`). +Require Pi Coding Agent `>=0.81.1 <0.82` and build an immutable provider baseline at session-daemon startup. Globally installed extensions can register both config-form and native providers during startup bootstrap; every later extension registration or unregistration—including global replay, project same-ID replacement, lifecycle callbacks, and `/reload`—is ignored. Non-provider extension features still work, and ignored calls are de-duplicated in session-daemon logs by operation/provider ID without logging provider configuration or credentials or creating session warnings/notifications. + +After updating PI WEB, or after installing, removing, or updating a globally installed extension that registers providers, manually restart `pi-web-sessiond.service` (`systemctl --user restart pi-web-sessiond`). Restarting only the web/API service and running `/reload` do not rebuild the provider baseline. diff --git a/README.md b/README.md index ddfdff1..cbde920 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Requirements: - Node.js 22.19.0 or newer - npm -- Pi Coding Agent `>=0.80.8 <0.81`, configured for your user +- Pi Coding Agent `>=0.81.1 <0.82`, configured for your user - git and the development tools your agents need Install and start PI WEB as per-user services: diff --git a/docs/config.html b/docs/config.html index a837f2a..e12f0d8 100644 --- a/docs/config.html +++ b/docs/config.html @@ -175,7 +175,7 @@
  • pathAccess: applies on the next request; existing file views may need a browser refresh.
  • uploads.defaultFolder: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
  • plugins: reload the browser tab after changing PI WEB plugin enablement.
  • -
  • Pi package install/remove/update: not a PI WEB config key; after a mutation, type /reload in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required.
  • +
  • Pi package install/remove/update: not a PI WEB config key; after a mutation, type /reload in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required for those ordinary resources. If a globally installed extension adds, removes, or changes a provider, manually restart pi-web-sessiond.service; /reload cannot change the startup provider baseline. See Extension provider registrations.
  • shortcuts: saved settings apply in the browser after config refresh/save.
  • diff --git a/docs/config.md b/docs/config.md index 61b70b1..63d2586 100644 --- a/docs/config.md +++ b/docs/config.md @@ -43,7 +43,7 @@ Process restarts depend on the key: - `pathAccess`: applies on the next request; existing file views may need a browser refresh. - `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh. - `plugins`: reload the browser tab after changing PI WEB plugin enablement. -- Pi package install/remove/update: not a PI WEB config key; after a mutation, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required. +- Pi package install/remove/update: not a PI WEB config key; after a mutation, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required for those ordinary resources. If a globally installed extension adds, removes, or changes a provider, manually restart `pi-web-sessiond.service`; `/reload` cannot change the startup provider baseline. See [Extension provider registrations](https://pi-web.dev/plugins#extension-provider-registrations). - `shortcuts`: saved settings apply in the browser after config refresh/save. ## Global config example diff --git a/docs/index.html b/docs/index.html index b79e41e..5debb5f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -38,7 +38,7 @@ "downloadUrl": "https://www.npmjs.com/package/@jmfederico/pi-web", "codeRepository": "https://github.com/jmfederico/pi-web", "description": "PI WEB is a web UI for Pi Coding Agent that keeps persistent agent sessions running in real workspaces on your machine or server.", - "softwareRequirements": "Node.js 22.19.0 or newer and Pi Coding Agent >=0.80.8 <0.81", + "softwareRequirements": "Node.js 22.19.0 or newer and Pi Coding Agent >=0.81.1 <0.82", "license": "https://github.com/jmfederico/pi-web/blob/main/LICENSE" } diff --git a/docs/install.html b/docs/install.html index c4985fc..9373721 100644 --- a/docs/install.html +++ b/docs/install.html @@ -107,7 +107,7 @@

    Requirements

    • Node.js 22.19.0 or newer and npm.
    • -
    • Pi Coding Agent >=0.80.8 <0.81 installed/configured so the pi command works for your user.
    • +
    • Pi Coding Agent >=0.81.1 <0.82 installed/configured so the pi command works for your user.
    • A shell login environment that exposes Node, npm, Pi, git, and any tools your agents need.
    • For the automatic installer: a supported per-user service manager.
    diff --git a/docs/plugins.html b/docs/plugins.html index e39d718..a36761a 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -160,16 +160,43 @@ updating a Pi package, type /reload in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed - PI WEB browser plugins. A routine session daemon restart is not required. + PI WEB browser plugins. For these ordinary resources, a routine session daemon restart is not required. + Extension-provided global provider changes are the exception described below. +

    +

    Extension provider registrations

    +

    + PI WEB builds one provider baseline for the lifetime of the session daemon. At daemon startup, before any + project resources load, it initializes globally installed, agent-dir extensions through Pi's + session-services factory. Both config-form registrations + (pi.registerProvider("id", config)) and native-provider registrations + (pi.registerProvider(provider)) made during that initialization join the shared baseline, + alongside Pi built-ins, environment credentials, and providers declared in the agent directory's + models.json.

    - One exception applies to Pi package extensions: PI WEB providers come from global sources only - (Pi built-ins, environment credentials, the agent directory's models.json, and providers - registered by globally installed, agent-dir extensions). If a project extension calls - pi.registerProvider, PI WEB ignores the registration and warns in the session; everything - else the extension registers keeps working. Move such a provider to a global source: declare it in - the agent directory's models.json, or install the extension globally. + After startup capture, every extension provider registration, native registration, and unregistration is + a no-op, regardless of source or provider ID. This includes global extensions replayed while sessions + load, project extensions adding a provider or replacing a global provider with the same ID, late lifecycle + calls such as session_start, and /reload. The captured provider remains unchanged, + while non-provider extension features continue to load and reload normally.

    +

    + Ignored mutations are written to the session-daemon log once per operation and provider ID. These entries + contain no provider configuration or credentials, and PI WEB does not show a session warning or + notification. The policy prevents accidental provider, configuration, or credential contamination between + projects; it is not a security boundary, because extensions remain trusted daemon code. +

    +

    + Configure providers globally before the daemon starts: use the agent directory's + models.json, or install the extension globally in the agent directory. Project-level + models.json files do not add providers to PI WEB sessions. +

    +
    + Restart required: after updating PI WEB, or after installing, removing, or updating a + globally installed extension that registers providers, manually restart + pi-web-sessiond.service (systemctl --user restart pi-web-sessiond). Restarting + only the web/API service and running /reload do not recapture the baseline. +
    diff --git a/docs/plugins.md b/docs/plugins.md index 6d17269..11b8e02 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -23,13 +23,17 @@ Use **Settings → Pi packages** to view configured Pi packages or install/remov When machine federation is enabled, **Settings → Pi packages** targets the currently selected machine. The panel labels whether changes will run on the local/gateway machine or on a selected remote PI WEB machine. If an older or unavailable remote PI WEB server does not expose package-management routes, PI WEB reports the package management operation as unsupported or unavailable instead of silently falling back to the gateway. -Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. In a federated setup, this plugin enablement surface targets the currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB server does not advertise selected-machine settings support, PI WEB reports the plugin settings as unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A routine session daemon restart is not required. +Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. In a federated setup, this plugin enablement surface targets the currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB server does not advertise selected-machine settings support, PI WEB reports the plugin settings as unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. For these ordinary resources, a routine session daemon restart is not required. Extension-provided global provider changes are the exception described below. ## Extension provider registrations -PI WEB providers come from global sources only: Pi built-ins, environment credentials, providers declared in the agent directory's `models.json` (the directory selected by `agent.dir`; see [Configuration](https://pi-web.dev/config)), and providers registered by globally installed (agent-dir) extensions. Global extensions load identically for every session, so their providers are safe on the shared daemon-wide runtime; project extensions differ per workspace and cannot add providers. If a project extension calls `pi.registerProvider(...)`, PI WEB ignores the registration and shows a warning in the session naming the provider. The extension itself still loads and everything else it registers keeps working; only the ignored provider's models never appear, so a project extension that requires its own provider may load but remain unusable. +PI WEB builds one provider baseline for the lifetime of the session daemon. At daemon startup, before any project resources load, it initializes globally installed (agent-dir) extensions through Pi's session-services factory. Both config-form registrations (`pi.registerProvider("id", config)`) and native-provider registrations (`pi.registerProvider(provider)`) made during that initialization join the shared baseline, alongside Pi built-ins, environment credentials, and providers declared in the agent directory's `models.json` (the directory selected by `agent.dir`; see [Configuration](https://pi-web.dev/config)). -To use a project extension's provider, move it to a global source: declare it in the agent directory's `models.json`, or install the extension globally in the agent directory. Project-level `models.json` files do not add providers to PI WEB sessions. This policy guards against accidental cross-workspace leakage; it is not a security boundary, since extensions run as trusted code inside the daemon. +After startup capture, every extension provider registration, native registration, and unregistration is a no-op, regardless of source or provider ID. This includes global extensions replayed while sessions load, project extensions adding a provider or replacing a global provider with the same ID, late lifecycle calls such as `session_start`, and `/reload`. The captured provider remains unchanged, while non-provider extension features continue to load and reload normally. + +Ignored mutations are written to the session-daemon log once per operation and provider ID. These entries contain no provider configuration or credentials, and PI WEB does not show a session warning or notification. The policy prevents accidental provider, configuration, or credential contamination between projects; it is not a security boundary, because extensions remain trusted daemon code. + +Configure providers globally before the daemon starts: use the agent directory's `models.json`, or install the extension globally in the agent directory. Project-level `models.json` files do not add providers to PI WEB sessions. **Restart required:** after updating PI WEB, or after installing, removing, or updating a globally installed extension that registers providers, manually restart `pi-web-sessiond.service` (`systemctl --user restart pi-web-sessiond`). Restarting only the web/API service and running `/reload` do not recapture the baseline. ## Trust model From e70b3d6bb9e92f78282f9f004a765397a254e00d Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 22 Jul 2026 20:58:56 +0200 Subject: [PATCH 08/10] fix(sessions): keep ignored provider mutations inert --- src/server/sessions/authService.test.ts | 8 ++++++- .../sessions/globalProviderPolicy.test.ts | 21 +++++++++++++++++++ src/server/sessions/globalProviderPolicy.ts | 12 +++++++---- .../piSessionService.promptQueue.test.ts | 14 +++++++++++-- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index e427e6c..8a07d0a 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -3,13 +3,19 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { ModelRuntime } from "@earendil-works/pi-coding-agent"; import { InMemoryCredentialStore, type AuthPrompt, type Credential } from "@earendil-works/pi-ai"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OAuthFlowState } from "../../shared/apiTypes.js"; import { AuthService, createModelRuntimeForAgentDir, type AuthChange, type AuthServiceLogger } from "./authService.js"; import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; const tempDirs: string[] = []; +beforeEach(() => { + // Pi 0.81 uses PI_OFFLINE for refreshes after runtime creation. Auth tests + // exercise local credential behavior and must never fetch provider catalogs. + vi.stubEnv("PI_OFFLINE", "1"); +}); + afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); diff --git a/src/server/sessions/globalProviderPolicy.test.ts b/src/server/sessions/globalProviderPolicy.test.ts index 4b536e0..0545f9b 100644 --- a/src/server/sessions/globalProviderPolicy.test.ts +++ b/src/server/sessions/globalProviderPolicy.test.ts @@ -157,6 +157,27 @@ describe("bootstrapAndFreezeGlobalExtensionProviders", () => { expect(JSON.stringify(ignoredMutations)).not.toContain("secret"); }); + it("keeps ignored mutations as no-ops when structured logging fails", async () => { + const agentDir = await tempDir("pi-web-global-provider-unit-"); + const runtime = await createTestModelRuntime(); + const { logger } = capturingLogger(); + const loggingError = new Error("provider mutation logger failed"); + const throwingLogger: GlobalProviderBootstrapLogger = { + ...logger, + info(details, message) { + if (message === "ignored provider mutation after global bootstrap") throw loggingError; + logger.info(details, message); + }, + }; + + await bootstrapAndFreezeGlobalExtensionProviders(runtime, agentDir, throwingLogger); + + expect(() => { registerProjectConfigProvider(runtime); }).not.toThrow(); + expect(() => { runtime.registerNativeProvider(nativeProvider("project-native")); }).not.toThrow(); + expect(() => { runtime.unregisterProvider("project-only"); }).not.toThrow(); + expect(runtime.getRegisteredProviderIds()).toEqual([]); + }); + it("logs non-fatal Pi bootstrap diagnostics and still freezes the runtime", async () => { const agentDir = await agentDirWithExtension(` export default function (pi) { diff --git a/src/server/sessions/globalProviderPolicy.ts b/src/server/sessions/globalProviderPolicy.ts index ebad281..c999526 100644 --- a/src/server/sessions/globalProviderPolicy.ts +++ b/src/server/sessions/globalProviderPolicy.ts @@ -62,10 +62,14 @@ function freezeProviderMutations(runtime: ModelRuntime, logger: GlobalProviderBo 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", - ); + try { + logger.info( + { context: LOG_CONTEXT, operation, providerId }, + "ignored provider mutation after global bootstrap", + ); + } catch { + // Logging must not turn an ignored mutation into an extension failure. + } }; const frozenMethods: ProviderMutationMethods = { registerProvider(providerId) { diff --git a/src/server/sessions/piSessionService.promptQueue.test.ts b/src/server/sessions/piSessionService.promptQueue.test.ts index 9b8a04f..781acce 100644 --- a/src/server/sessions/piSessionService.promptQueue.test.ts +++ b/src/server/sessions/piSessionService.promptQueue.test.ts @@ -4,12 +4,22 @@ import { join } from "node:path"; import { createAssistantMessageEventStream, InMemoryCredentialStore, type AssistantMessage } from "@earendil-works/pi-ai"; import type { StreamFn } from "@earendil-works/pi-agent-core"; import { ModelRuntime } from "@earendil-works/pi-coding-agent"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PiSessionService } from "./piSessionService.js"; import { CapturingSessionEventHub, createTestModelRuntime, fakeRuntime, runtimeCreator, seedCredential, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; +beforeEach(() => { + // Pi 0.81 uses PI_OFFLINE for refreshes after runtime creation. These tests + // exercise local model/auth behavior and must never fetch provider catalogs. + vi.stubEnv("PI_OFFLINE", "1"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("PiSessionService prompt, queue, and auth warnings", () => { it("sends prompts to an injected runtime without touching the SDK runtime", async () => { const fake = fakeRuntime("prompt-session"); @@ -78,7 +88,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { await service.dispose(); }); - it("generates a session name for the first prompt via the session's agent.streamFn", async () => { + it("generates a session name for the first prompt via the session's agent.streamFunction", async () => { const model = testModel(); const streamCalls: unknown[] = []; const streamFn: StreamFn = (streamModel, context, options) => { From ced3261651cd64f9525217719aa5210a40a29cde Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 22 Jul 2026 23:36:28 +0200 Subject: [PATCH 09/10] test(sessions): make unread checks portable --- .../sessions/piSessionService.unread.test.ts | 31 ++++++++++--------- .../sessions/sessionUnreadStore.test.ts | 3 +- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/server/sessions/piSessionService.unread.test.ts b/src/server/sessions/piSessionService.unread.test.ts index 3d21df9..c5a432a 100644 --- a/src/server/sessions/piSessionService.unread.test.ts +++ b/src/server/sessions/piSessionService.unread.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; import { @@ -22,6 +22,9 @@ import { } from "./sessionUnreadStore.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; +// Unread identities are persisted after platform-native cwd canonicalization. +const WORKSPACE_CWD = resolve("/workspace"); +const FEATURE_CWD = resolve("/workspace-feature"); const tempRoots: string[] = []; afterEach(async () => { @@ -50,11 +53,11 @@ describe("PiSessionService daemon-owned unread state", () => { const secondSnapshot = await service.unreadCatalog(); const current = secondSnapshot.sessions[0]; - expect(current).toMatchObject({ sessionId: "session-1", cwd: "/workspace", completionOrder: 2 }); + expect(current).toMatchObject({ sessionId: "session-1", cwd: WORKSPACE_CWD, completionOrder: 2 }); expect(unreadEvents(hub).map((event) => event.catalogRevision)).toEqual([1, 2]); const staleSnapshot = await service.acknowledgeUnread("session-1", { - cwd: "/workspace", + cwd: WORKSPACE_CWD, catalogId: "catalog-test", throughCompletionOrder: 1, }); @@ -62,7 +65,7 @@ describe("PiSessionService daemon-owned unread state", () => { expect(unreadEvents(hub).map((event) => event.catalogRevision)).toEqual([1, 2]); const acknowledged = await service.acknowledgeUnread("session-1", { - cwd: "/workspace", + cwd: WORKSPACE_CWD, catalogId: "catalog-test", throughCompletionOrder: current?.completionOrder ?? 0, }); @@ -100,7 +103,7 @@ describe("PiSessionService daemon-owned unread state", () => { finishBash?.(); await Promise.resolve(); - expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "session-1", cwd: "/workspace", completionOrder: 1 }]); + expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "session-1", cwd: WORKSPACE_CWD, completionOrder: 1 }]); } finally { await service.dispose(); } @@ -258,13 +261,13 @@ describe("PiSessionService daemon-owned unread state", () => { initial.session.isStreaming = false; expect((await service.unreadCatalog()).sessions).toEqual([]); - completeStoreWork(unreadStore, "session-1", "/workspace"); + completeStoreWork(unreadStore, "session-1", WORKSPACE_CWD); const beforeReload = (await service.unreadCatalog()).sessions[0]; await service.reload(sessionRef("session-1")); const afterReload = (await service.unreadCatalog()).sessions[0]; expect(beforeReload).toBeDefined(); - expect(afterReload).toMatchObject({ sessionId: "session-1", cwd: "/workspace" }); + expect(afterReload).toMatchObject({ sessionId: "session-1", cwd: WORKSPACE_CWD }); expect(afterReload?.completionOrder).toBeGreaterThan(beforeReload?.completionOrder ?? 0); } finally { await service.dispose(); @@ -273,7 +276,7 @@ describe("PiSessionService daemon-owned unread state", () => { it("clears stale unread when a runtime rebind changes logical session identity", async () => { const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" }); - completeStoreWork(unreadStore, "session-old", "/workspace"); + completeStoreWork(unreadStore, "session-old", WORKSPACE_CWD); const original = fakeRuntime("session-old"); let rebindSession: ((session: PiAgentSession) => Promise) | undefined; original.runtime.setRebindSession = (callback) => { rebindSession = callback; }; @@ -302,7 +305,7 @@ describe("PiSessionService daemon-owned unread state", () => { it("cleans unread state through archive, restore, delete, and cwd reconciliation", async () => { const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" }); for (const sessionId of ["archive-me", "restore-me", "delete-me", "orphan"]) { - completeStoreWork(unreadStore, sessionId, "/workspace"); + completeStoreWork(unreadStore, sessionId, WORKSPACE_CWD); } const archived = new Map([ ["restore-me", { sessionId: "restore-me", cwd: "/workspace", archivedAt: "2026-07-01T00:00:00.000Z", archivePath: "/archive/restore-me.jsonl" }], @@ -348,7 +351,7 @@ describe("PiSessionService daemon-owned unread state", () => { await service.deleteArchived(sessionRef("delete-me")); expect((await service.unreadCatalog()).sessions.map((summary) => summary.sessionId)).toEqual(["orphan"]); - await service.list("/workspace"); + await service.list(WORKSPACE_CWD); expect((await service.unreadCatalog()).sessions).toEqual([]); } finally { await service.dispose(); @@ -404,7 +407,7 @@ describe("PiSessionService daemon-owned unread state", () => { completeRuntimeWork(child); expect((await service.unreadCatalog()).sessions).toContainEqual(expect.objectContaining({ sessionId: "child-1", - cwd: "/workspace-feature", + cwd: FEATURE_CWD, })); } finally { await service.dispose(); @@ -420,7 +423,7 @@ describe("PiSessionService daemon-owned unread state", () => { await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" }); - completeStoreWork(unreadStore, "child-1", "/workspace-feature"); + completeStoreWork(unreadStore, "child-1", FEATURE_CWD); const hub = new CapturingSessionEventHub(); const parentManager = fakeSessionManager("/workspace", { getEntries: () => [{ @@ -452,7 +455,7 @@ describe("PiSessionService daemon-owned unread state", () => { ]); expect((await service.unreadCatalog()).sessions).toEqual([]); - expect(unreadEvents(hub).at(-1)).toMatchObject({ sessionId: "child-1", cwd: "/workspace-feature", unread: null }); + expect(unreadEvents(hub).at(-1)).toMatchObject({ sessionId: "child-1", cwd: FEATURE_CWD, unread: null }); } finally { await service.dispose(); } @@ -466,7 +469,7 @@ describe("PiSessionService daemon-owned unread state", () => { await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" }); - completeStoreWork(unreadStore, "child-1", "/workspace-feature"); + completeStoreWork(unreadStore, "child-1", FEATURE_CWD); const parentManager = fakeSessionManager("/workspace", { getEntries: () => [{ type: "custom", diff --git a/src/server/sessions/sessionUnreadStore.test.ts b/src/server/sessions/sessionUnreadStore.test.ts index 7bf84b9..a6ec905 100644 --- a/src/server/sessions/sessionUnreadStore.test.ts +++ b/src/server/sessions/sessionUnreadStore.test.ts @@ -464,7 +464,8 @@ describe("FileSessionUnreadPersistence", () => { nextCompletionOrder: 1, sessions: [{ sessionId: "session-1", cwd: "/repo", completionOrder: 1 }], }); - expect((await stat(filePath)).mode & 0o777).toBe(0o600); + // Windows reports synthesized POSIX mode bits and ignores the requested file mode. + if (process.platform !== "win32") expect((await stat(filePath)).mode & 0o777).toBe(0o600); expect((await readdir(join(root, "state"))).filter((name) => name.endsWith(".tmp"))).toEqual([]); const reloaded = new SessionUnreadStore({ persistence, createCatalogId: () => "unused-catalog" }); From e8d418a71af37002471b95b64a7b03ba20a11d48 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 22 Jul 2026 23:53:30 +0200 Subject: [PATCH 10/10] docs: distinguish Pi extensions from PI WEB plugins --- .changeset/global-provider-policy.md | 4 +- README.md | 10 ++-- docs/config.html | 57 ++++++++++++++++++--- docs/config.md | 20 ++++++-- docs/plugins.html | 76 ++++++++++------------------ docs/plugins.md | 24 ++++----- 6 files changed, 108 insertions(+), 83 deletions(-) diff --git a/.changeset/global-provider-policy.md b/.changeset/global-provider-policy.md index 7343178..17c6325 100644 --- a/.changeset/global-provider-policy.md +++ b/.changeset/global-provider-policy.md @@ -2,6 +2,6 @@ "@jmfederico/pi-web": patch --- -Require Pi Coding Agent `>=0.81.1 <0.82` and build an immutable provider baseline at session-daemon startup. Globally installed extensions can register both config-form and native providers during startup bootstrap; every later extension registration or unregistration—including global replay, project same-ID replacement, lifecycle callbacks, and `/reload`—is ignored. Non-provider extension features still work, and ignored calls are de-duplicated in session-daemon logs by operation/provider ID without logging provider configuration or credentials or creating session warnings/notifications. +Require Pi Coding Agent `>=0.81.1 <0.82` and build an immutable provider baseline at session-daemon startup. Globally installed Pi extensions can register both config-form and native providers during startup bootstrap; every later Pi extension registration or unregistration—including global replay, project same-ID replacement, lifecycle callbacks, and `/reload`—is ignored. PI WEB browser plugins are a separate browser-only system and are unaffected. Non-provider Pi extension features still work, and ignored calls are de-duplicated in session-daemon logs by operation/provider ID without logging provider configuration or credentials or creating session warnings/notifications. -After updating PI WEB, or after installing, removing, or updating a globally installed extension that registers providers, manually restart `pi-web-sessiond.service` (`systemctl --user restart pi-web-sessiond`). Restarting only the web/API service and running `/reload` do not rebuild the provider baseline. +After updating PI WEB, or after installing, removing, or updating a globally installed Pi extension that registers providers, manually restart `pi-web-sessiond.service` (`systemctl --user restart pi-web-sessiond`). Restarting only the web/API service and running `/reload` do not rebuild the provider baseline. diff --git a/README.md b/README.md index cbde920..0127950 100644 --- a/README.md +++ b/README.md @@ -108,15 +108,13 @@ When a remote machine is selected, Settings tabs label their target. Pi packages Read more: [Fleet and machines guide](https://pi-web.dev/machines) -## Plugins +## PI WEB plugins -PI WEB supports trusted browser-side PI WEB plugins that can add actions, workspace panels, and workspace metadata. +PI WEB supports trusted browser-side plugins that can add actions, workspace panels, and workspace metadata. Use **Settings → PI WEB plugins** to enable or disable them on the selected machine. -Pi packages are managed separately through Pi's package manager or **Settings → Pi packages**. In a federated setup, the Pi packages panel targets the selected machine and labels where installs, updates, or removals will run. Use **Settings → PI WEB plugins** to enable or disable discovered browser plugins on the selected machine. +Pi packages are a separate Pi package-manager concept. A Pi package may include a PI WEB browser plugin, but installing a package and enabling its browser plugin are different operations. -After installing, updating, or removing a Pi package, type `/reload` in each idle PI WEB session on that machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB plugins. - -Read more: [Plugin API](https://pi-web.dev/plugins) +Read more: [PI WEB plugin guide and API](https://pi-web.dev/plugins) ## Configuration diff --git a/docs/config.html b/docs/config.html index e12f0d8..f814d69 100644 --- a/docs/config.html +++ b/docs/config.html @@ -99,6 +99,7 @@ External path access Manual uploads Agent profile and companion CLI + Pi extension providers Session tools Completion tools @@ -175,7 +176,7 @@
  • pathAccess: applies on the next request; existing file views may need a browser refresh.
  • uploads.defaultFolder: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
  • plugins: reload the browser tab after changing PI WEB plugin enablement.
  • -
  • Pi package install/remove/update: not a PI WEB config key; after a mutation, type /reload in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required for those ordinary resources. If a globally installed extension adds, removes, or changes a provider, manually restart pi-web-sessiond.service; /reload cannot change the startup provider baseline. See Extension provider registrations.
  • +
  • Pi package install/remove/update: not a PI WEB config key; after a mutation, type /reload in each idle PI WEB session on the target machine to refresh ordinary Pi resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for PI WEB browser plugin changes. If a global Pi extension adds, removes, or changes a provider, manually restart pi-web-sessiond.service; /reload cannot change the startup provider baseline. See Pi extension provider baseline.
  • shortcuts: saved settings apply in the browser after config refresh/save.
  • @@ -347,7 +348,7 @@ PI_WEB_AGENT_DIR (PI_CODING_AGENT_DIR for Pi compatibility) Global/session daemon Not supported locally - Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and package-backed plugins + Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and Pi-package-backed PI WEB plugins Agent can spawn sessions @@ -583,12 +584,12 @@ secret-free active profile stays fixed for the daemon lifetime. Settings → Session daemon saves command and directory together as desired configuration and shows whether the profile is active, needs a restart, or cannot be compared. Until the daemon restarts, sessions, Pi package operations, - package-backed plugin discovery, status/install detection, and update planning continue to use the - daemon-owned active profile; a web/API restart recovers that same active profile instead of applying the - newly saved values. + Pi-package-backed PI WEB plugin discovery, status/install detection, and update planning continue to use + the daemon-owned active profile; a web/API restart recovers that same active profile instead of applying + the newly saved values.

    - If the session daemon cannot report a valid active profile, profile-dependent package and plugin + If the session daemon cannot report a valid active profile, profile-dependent Pi package and PI WEB plugin operations report unavailable instead of falling back to independently resolved config. A package-managed update command is shown only when PI WEB can preserve the active profile with a recognized, safe Pi companion CLI; otherwise the command is omitted. Remote profile editing likewise requires advertised @@ -597,6 +598,50 @@
    +
    +

    Pi extension provider baseline

    +

    + This policy applies to Pi runtime extensions, not PI WEB browser plugins. Pi extensions + are runtime modules loaded by the session daemon and can call pi.registerProvider(...); + PI WEB plugins are browser-side UI modules and never run in the session daemon. +

    +

    + PI WEB shares one model runtime across all sessions. When the session daemon starts, before any project + resources load, it initializes global Pi extensions from the active agent profile + (agent.dir), including extensions supplied by globally configured Pi packages. Provider + registrations made by synchronous or awaited asynchronous extension factories during this bootstrap join + the shared baseline. PI WEB captures both config-form registrations + (pi.registerProvider("id", config)) and native-provider registrations + (pi.registerProvider(provider)), alongside Pi built-ins, environment credentials, and + providers from the active agent directory's models.json. +

    +

    + After startup capture, every later Pi extension provider registration, native registration, and + unregistration is a no-op, regardless of source or provider ID. This includes global extensions replayed + while sessions load, project extensions attempting to add or replace a provider, same-ID replacement or + unregistration, lifecycle callbacks such as session_start, and /reload. The + captured provider stays unchanged while non-provider Pi extension features continue to load and reload + normally. +

    +

    + Ignored mutations are written to the session-daemon log once per operation and provider ID. The log entry + contains no provider configuration or credentials, and PI WEB does not show a session warning or + notification. This prevents accidental provider, configuration, or credential contamination between + projects; it is not a security boundary because Pi extensions remain trusted daemon code. +

    +

    + Configure providers before the daemon starts: use the active agent directory's + models.json, or install the Pi extension globally in that agent profile. Project Pi extensions + and project-level models.json files cannot add providers to PI WEB's shared baseline. +

    +
    + Restart required: after updating PI WEB—or after installing, removing, or updating a + global Pi extension that registers providers—manually restart pi-web-sessiond.service + (systemctl --user restart pi-web-sessiond). Restarting only the web/API service and running + /reload do not rebuild the baseline. +
    +
    +

    Session daemon tools

    spawnSessions

    diff --git a/docs/config.md b/docs/config.md index 63d2586..90b9d85 100644 --- a/docs/config.md +++ b/docs/config.md @@ -43,7 +43,7 @@ Process restarts depend on the key: - `pathAccess`: applies on the next request; existing file views may need a browser refresh. - `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh. - `plugins`: reload the browser tab after changing PI WEB plugin enablement. -- Pi package install/remove/update: not a PI WEB config key; after a mutation, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required for those ordinary resources. If a globally installed extension adds, removes, or changes a provider, manually restart `pi-web-sessiond.service`; `/reload` cannot change the startup provider baseline. See [Extension provider registrations](https://pi-web.dev/plugins#extension-provider-registrations). +- Pi package install/remove/update: not a PI WEB config key; after a mutation, type `/reload` in each idle PI WEB session on the target machine to refresh ordinary Pi resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for PI WEB browser plugin changes. If a global Pi extension adds, removes, or changes a provider, manually restart `pi-web-sessiond.service`; `/reload` cannot change the startup provider baseline. See [Pi extension provider baseline](#pi-extension-provider-baseline). - `shortcuts`: saved settings apply in the browser after config refresh/save. ## Global config example @@ -113,7 +113,7 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh | | Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon on that machine | | Companion CLI command | `agent.command` | `PI_WEB_AGENT_COMMAND` | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects doctor/status/update checks | -| Agent profile state directory | `agent.dir` | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and package-backed plugins | +| Agent profile state directory | `agent.dir` | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and Pi-package-backed PI WEB plugins | | Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine | | Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine | | Plugin enablement/settings | `plugins..enabled`, `plugins..settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab | @@ -202,9 +202,21 @@ An alternate command always requires an explicit state directory. The command mu Environment variables take precedence over the config file. `PI_WEB_AGENT_COMMAND` selects the companion CLI, `PI_WEB_AGENT_DIR` sets the profile state directory, and `PI_WEB_AGENT_SESSION_DIR` overrides session storage separately from `agent.dir`. The legacy `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR` names apply only to a canonical Pi companion command; PI WEB never derives ambient environment-variable names from an arbitrary command. Use the explicit `PI_WEB_AGENT_*` names for alternate commands. `PI_WEB_AGENT_DIR` is an unconditional override, while a legacy `PI_CODING_AGENT_DIR` override stops applying when Settings selects an alternate command so the command and directory can transition together. -The session daemon resolves the persisted desired values plus its environment once at startup. That secret-free active profile stays fixed for the daemon lifetime. **Settings → Session daemon** saves command and directory together as desired configuration and shows whether the profile is active, needs a restart, or cannot be compared. Until the daemon restarts, sessions, Pi package operations, package-backed plugin discovery, status/install detection, and update planning continue to use the daemon-owned active profile; a web/API restart recovers that same active profile instead of applying the newly saved values. +The session daemon resolves the persisted desired values plus its environment once at startup. That secret-free active profile stays fixed for the daemon lifetime. **Settings → Session daemon** saves command and directory together as desired configuration and shows whether the profile is active, needs a restart, or cannot be compared. Until the daemon restarts, sessions, Pi package operations, Pi-package-backed PI WEB plugin discovery, status/install detection, and update planning continue to use the daemon-owned active profile; a web/API restart recovers that same active profile instead of applying the newly saved values. -If the session daemon cannot report a valid active profile, profile-dependent package and plugin operations report unavailable instead of falling back to independently resolved config. A package-managed update command is shown only when PI WEB can preserve the active profile with a recognized, safe Pi companion CLI; otherwise the command is omitted. Remote profile editing likewise requires advertised support, and the gateway rejects a remote save if the target does not return the requested profile. Restart the session daemon on the selected machine to establish the next active profile. +If the session daemon cannot report a valid active profile, profile-dependent Pi package and PI WEB plugin operations report unavailable instead of falling back to independently resolved config. A package-managed update command is shown only when PI WEB can preserve the active profile with a recognized, safe Pi companion CLI; otherwise the command is omitted. Remote profile editing likewise requires advertised support, and the gateway rejects a remote save if the target does not return the requested profile. Restart the session daemon on the selected machine to establish the next active profile. + +### Pi extension provider baseline + +This policy applies to **Pi runtime extensions**, not PI WEB browser plugins. Pi extensions are runtime modules loaded by the session daemon and can call `pi.registerProvider(...)`; PI WEB plugins are browser-side UI modules and never run in the session daemon. + +PI WEB shares one model runtime across all sessions. When the session daemon starts, before any project resources load, it initializes global Pi extensions from the active agent profile (`agent.dir`), including extensions supplied by globally configured Pi packages. Provider registrations made by synchronous or awaited asynchronous extension factories during this bootstrap join the shared baseline. PI WEB captures both config-form registrations (`pi.registerProvider("id", config)`) and native-provider registrations (`pi.registerProvider(provider)`), alongside Pi built-ins, environment credentials, and providers from the active agent directory's `models.json`. + +After startup capture, every later Pi extension provider registration, native registration, and unregistration is a no-op, regardless of source or provider ID. This includes global extensions replayed while sessions load, project extensions attempting to add or replace a provider, same-ID replacement or unregistration, lifecycle callbacks such as `session_start`, and `/reload`. The captured provider stays unchanged while non-provider Pi extension features continue to load and reload normally. + +Ignored mutations are written to the session-daemon log once per operation and provider ID. The log entry contains no provider configuration or credentials, and PI WEB does not show a session warning or notification. This prevents accidental provider, configuration, or credential contamination between projects; it is not a security boundary because Pi extensions remain trusted daemon code. + +Configure providers before the daemon starts: use the active agent directory's `models.json`, or install the Pi extension globally in that agent profile. Project Pi extensions and project-level `models.json` files cannot add providers to PI WEB's shared baseline. After updating PI WEB—or after installing, removing, or updating a global Pi extension that registers providers—manually restart `pi-web-sessiond.service` (`systemctl --user restart pi-web-sessiond`). Restarting only the web/API service and running `/reload` do not rebuild the baseline. ### Session daemon tools diff --git a/docs/plugins.html b/docs/plugins.html index a36761a..955df12 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -90,7 +90,7 @@
    -

    Pi packages vs PI WEB plugins

    +

    Pi packages, Pi extensions, and PI WEB plugins

    - Pi packages are packages managed by Pi (pi install, pi remove, - pi update). A Pi package can provide extensions, skills, prompt templates, themes, - context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin. + Pi packages are distribution bundles managed by Pi (pi install, + pi remove, pi update). A Pi package can provide Pi extensions, skills, prompt + templates, themes, context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not + include a PI WEB plugin. +

    +

    + Pi extensions are runtime modules loaded by the session daemon. They can register Pi + tools, hooks, commands, and model providers. They are not PI WEB plugins.

    PI WEB plugins are browser-side UI modules discovered from bundled, local, dev, and - installed Pi-package sources. Enabling or disabling a PI WEB plugin is a PI WEB config task; installing, - removing, or updating a Pi package is a Pi package-manager task. + installed Pi-package sources. They cannot register model providers or server-side hooks. Enabling or + disabling a PI WEB plugin is a PI WEB config task; installing, removing, or updating a Pi package is a + separate Pi package-manager task.

    Use Settings → Pi packages to view configured Pi packages or install/remove/update a @@ -156,47 +162,16 @@ before the browser imports them. In a federated setup, this plugin enablement surface targets the currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB server does not advertise selected-machine settings support, PI WEB reports the plugin settings as - unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or - updating a Pi package, type /reload in each idle PI WEB session on the target machine to - refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system - prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed - PI WEB browser plugins. For these ordinary resources, a routine session daemon restart is not required. - Extension-provided global provider changes are the exception described below. -

    -

    Extension provider registrations

    -

    - PI WEB builds one provider baseline for the lifetime of the session daemon. At daemon startup, before any - project resources load, it initializes globally installed, agent-dir extensions through Pi's - session-services factory. Both config-form registrations - (pi.registerProvider("id", config)) and native-provider registrations - (pi.registerProvider(provider)) made during that initialization join the shared baseline, - alongside Pi built-ins, environment credentials, and providers declared in the agent directory's - models.json. + unsupported or unavailable instead of silently falling back to the gateway.

    - After startup capture, every extension provider registration, native registration, and unregistration is - a no-op, regardless of source or provider ID. This includes global extensions replayed while sessions - load, project extensions adding a provider or replacing a global provider with the same ID, late lifecycle - calls such as session_start, and /reload. The captured provider remains unchanged, - while non-provider extension features continue to load and reload normally. + After installing, removing, or updating a Pi package, type /reload in each idle PI WEB + session on the target machine to refresh ordinary Pi resources such as extensions, skills, prompt + templates, themes, and context/system prompt files. Reload the browser page separately for newly + discovered or changed PI WEB browser plugins. A provider-registering Pi extension follows a separate + daemon-start policy; see + Pi extension provider baseline.

    -

    - Ignored mutations are written to the session-daemon log once per operation and provider ID. These entries - contain no provider configuration or credentials, and PI WEB does not show a session warning or - notification. The policy prevents accidental provider, configuration, or credential contamination between - projects; it is not a security boundary, because extensions remain trusted daemon code. -

    -

    - Configure providers globally before the daemon starts: use the agent directory's - models.json, or install the extension globally in the agent directory. Project-level - models.json files do not add providers to PI WEB sessions. -

    -
    - Restart required: after updating PI WEB, or after installing, removing, or updating a - globally installed extension that registers providers, manually restart - pi-web-sessiond.service (systemctl --user restart pi-web-sessiond). Restarting - only the web/API service and running /reload do not recapture the baseline. -
    @@ -364,17 +339,18 @@ After editing, check the manifest endpoint and browser-console failure cases.

    Manage PI WEB plugins

    - Open Settings → PI WEB plugins to review discovered bundled, local, dev, and Pi package - plugins for the selected PI WEB machine. When the local machine is selected, this is the gateway plugin - list; when a remote machine is selected, the list comes from that remote PI WEB server and includes + Open Settings → PI WEB plugins to review discovered bundled, local, dev, and + Pi-package-supplied PI WEB plugins for the selected PI WEB machine. When the local machine is selected, + this is the gateway plugin list; when a remote machine is selected, the list comes from that remote PI WEB + server and includes disabled discovered plugins it exposes. PI WEB can disable any discovered selected-machine plugin before the browser imports it. Core app contributions such as the command palette, base workspace tools, and themes are not managed through this plugin list.

    This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may - provide plugins or other Pi resources, use Settings → Pi packages. In a federated setup, - both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement + provide PI WEB plugins or other Pi resources, use Settings → Pi packages. In a federated + setup, both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement still writes the PI WEB plugins config key rather than changing Pi package-manager settings.

    diff --git a/docs/plugins.md b/docs/plugins.md index 11b8e02..cb4a988 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -13,27 +13,21 @@ Plugins can currently: They do **not** run in the session daemon, do not get a server-side hook API, and are not sandboxed. -## Pi packages vs PI WEB plugins +## Pi packages, Pi extensions, and PI WEB plugins -**Pi packages** are packages managed by Pi (`pi install`, `pi remove`, `pi update`). A Pi package can provide extensions, skills, prompt templates, themes, context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin. +**Pi packages** are distribution bundles managed by Pi (`pi install`, `pi remove`, `pi update`). A Pi package can provide Pi extensions, skills, prompt templates, themes, context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin. -**PI WEB plugins** are browser-side PI WEB UI modules discovered from bundled, local, dev, and installed Pi-package sources. Enabling or disabling a PI WEB plugin is a PI WEB config task; installing, removing, or updating a Pi package is a Pi package-manager task. +**Pi extensions** are runtime modules loaded by the session daemon. They can register Pi tools, hooks, commands, and model providers. They are not PI WEB plugins. + +**PI WEB plugins** are browser-side UI modules discovered from bundled, local, dev, and installed Pi-package sources. They cannot register model providers or server-side hooks. Enabling or disabling a PI WEB plugin is a PI WEB config task; installing, removing, or updating a Pi package is a separate Pi package-manager task. Use **Settings → Pi packages** to view configured Pi packages or install/remove/update a package. Enter only the package source, such as `npm:@scope/package`, a git/URL source, or a local path. PI WEB uses Pi's default package location, equivalent to `pi install `, and does not ask for an install location. When machine federation is enabled, **Settings → Pi packages** targets the currently selected machine. The panel labels whether changes will run on the local/gateway machine or on a selected remote PI WEB machine. If an older or unavailable remote PI WEB server does not expose package-management routes, PI WEB reports the package management operation as unsupported or unavailable instead of silently falling back to the gateway. -Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. In a federated setup, this plugin enablement surface targets the currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB server does not advertise selected-machine settings support, PI WEB reports the plugin settings as unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. For these ordinary resources, a routine session daemon restart is not required. Extension-provided global provider changes are the exception described below. +Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. In a federated setup, this plugin enablement surface targets the currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB server does not advertise selected-machine settings support, PI WEB reports the plugin settings as unsupported or unavailable instead of silently falling back to the gateway. -## Extension provider registrations - -PI WEB builds one provider baseline for the lifetime of the session daemon. At daemon startup, before any project resources load, it initializes globally installed (agent-dir) extensions through Pi's session-services factory. Both config-form registrations (`pi.registerProvider("id", config)`) and native-provider registrations (`pi.registerProvider(provider)`) made during that initialization join the shared baseline, alongside Pi built-ins, environment credentials, and providers declared in the agent directory's `models.json` (the directory selected by `agent.dir`; see [Configuration](https://pi-web.dev/config)). - -After startup capture, every extension provider registration, native registration, and unregistration is a no-op, regardless of source or provider ID. This includes global extensions replayed while sessions load, project extensions adding a provider or replacing a global provider with the same ID, late lifecycle calls such as `session_start`, and `/reload`. The captured provider remains unchanged, while non-provider extension features continue to load and reload normally. - -Ignored mutations are written to the session-daemon log once per operation and provider ID. These entries contain no provider configuration or credentials, and PI WEB does not show a session warning or notification. The policy prevents accidental provider, configuration, or credential contamination between projects; it is not a security boundary, because extensions remain trusted daemon code. - -Configure providers globally before the daemon starts: use the agent directory's `models.json`, or install the extension globally in the agent directory. Project-level `models.json` files do not add providers to PI WEB sessions. **Restart required:** after updating PI WEB, or after installing, removing, or updating a globally installed extension that registers providers, manually restart `pi-web-sessiond.service` (`systemctl --user restart pi-web-sessiond`). Restarting only the web/API service and running `/reload` do not recapture the baseline. +After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh ordinary Pi resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A provider-registering Pi extension follows a separate daemon-start policy; see [Pi extension provider baseline](https://pi-web.dev/config#pi-extension-provider-baseline). ## Trust model @@ -179,9 +173,9 @@ If a remote plugin constructs absolute asset URLs, it should use the `pluginId` ## Manage PI WEB plugins -Open **Settings → PI WEB plugins** to review discovered bundled, local, dev, and Pi package plugins for the selected PI WEB machine. When the local machine is selected, this is the gateway plugin list; when a remote machine is selected, the list comes from that remote PI WEB server and includes disabled discovered plugins it exposes. PI WEB can disable any discovered selected-machine plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list. +Open **Settings → PI WEB plugins** to review discovered bundled, local, dev, and Pi-package-supplied PI WEB plugins for the selected PI WEB machine. When the local machine is selected, this is the gateway plugin list; when a remote machine is selected, the list comes from that remote PI WEB server and includes disabled discovered plugins it exposes. PI WEB can disable any discovered selected-machine plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list. -This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may provide plugins or other Pi resources, use **Settings → Pi packages**. In a federated setup, both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement still writes the PI WEB `plugins` config key rather than changing Pi package-manager settings. +This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may provide PI WEB plugins or other Pi resources, use **Settings → Pi packages**. In a federated setup, both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement still writes the PI WEB `plugins` config key rather than changing Pi package-manager settings. Plugin preferences are stored under the top-level `plugins` config key in the PI WEB config file: