feat(plugins): first-class plugin providers — status, launch resolution, config overrides (#5707)

* feat(plugins): support provider status, launch resolution, and config overrides

* fix(plugins): defer override warnings until provider startup settles
This commit is contained in:
Mohamed Boudra
2026-09-30 13:46:35 +01:00
committed by GitHub
parent 8cccc1f6fa
commit b0c9de0c90
21 changed files with 1033 additions and 139 deletions
+36 -4
View File
@@ -381,9 +381,38 @@ export default function contribute(server: PluginServerContext) {
}
```
Declare `command: ["agent", "serve"]` when your provider launches an executable. The daemon applies
`agents.providers.<id>.command` and `env`, resolves the executable against the effective PATH, and
removes parent-session and daemon-control environment variables. `connect({ launch })` receives
`{ command, args, env }`: an executable path, effective arguments, and the complete sanitized
environment. Spawn with those values; do not merge the plugin process's environment back in.
The launch travels as data through the same provider protocol for built-in and subprocess plugins.
Without a declared command, existing providers keep their connection behavior and receive no launch.
Implement optional `status({ launch })` to check credentials, versions, or other prerequisites using
that effective launch. Return `{ available: true }` or
`{ available: false, diagnostic: "Run agent login" }`. The daemon checks executable availability
before calling status; it exposes your diagnostic through the normal provider diagnostic command.
A command-backed provider without status is available when its executable resolves. Providers with
neither command nor status retain connection-based availability.
A same-ID config entry without `extends` overrides the plugin's `enabled`, `command`, `env`, label,
description, and model configuration through the normal provider registry. `enabled: false` disables
selection and discovery. An entry with `extends` defines the user's own provider, shadows the plugin,
and logs a warning. Overrides validate before plugins load; an override for an absent plugin stays
inactive and logs a warning naming the unmatched ID after built-in and configured plugin startup
settles (including disabled plugins), then on each installed registry generation.
Use IDs matching `/^[a-z][a-z0-9-]*$/` for configurable providers. Dots and underscores
remain valid for plugin registration but cannot be used as config provider IDs.
Provider config changes rebuild the affected registry entry and invalidate its catalogue; the next
connection receives the new launch without restarting the daemon. Running sessions keep their
current launch until refreshed, as native providers do.
Implement optional `ProviderRegistration.getCatalogCacheKey(options)` to share equivalent catalogue
probes. The callback runs in the plugin process before discovery and receives the actual global or
workspace target. Return a key covering effective configuration and execution environment, or
workspace target plus the resolved `launch` when a command is declared. Return a key covering
effective configuration and execution environment, or
`undefined` for target-specific caching. Ignore `force` when choosing identity. Existing providers
need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh-contract).
@@ -394,12 +423,15 @@ Provider settings are toggle/select data that Paseo renders in the composer. Kee
the opaque `providerOptions` config object.
Agent refresh closes the current provider session and opens it again with current configuration and
persistence. Providers re-read credentials, environment, global configuration, and MCP servers on
`session.open`; there is no provider reload input.
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
no provider reload input.
For an ACP command, register `runAcpProvider({ id, label, command })` from
`@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct`
whole provider event stream. The shim uses the resolved launch for every probe and session, overlaying
only the supplied per-session env for sessions. The direct and ACP examples live in
`plugin-examples/provider-direct`
and `plugin-examples/provider-acp-transformer`.
Provider-emitted plugin timeline items use the same renderer registration as transformed and
+1 -1
View File
@@ -295,7 +295,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
### 2. Add to the provider manifest
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
In `packages/protocol/src/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
First, define the modes with visual metadata:
@@ -40,6 +40,7 @@ import {
type ProviderConnection,
type ProviderEvent,
type ProviderInput,
type ProviderLaunch,
type ProviderPermissionResponse,
type ProviderPersistence,
type ProviderSessionConfig,
@@ -69,6 +70,7 @@ export async function createAcpProviderConnection(
}
const probe = await AcpRuntime.start({
options,
launch: request.launch,
boundarySessionId: "capability-probe",
env: {},
emit: () => undefined,
@@ -97,6 +99,7 @@ export async function createAcpProviderConnection(
};
const state: AcpConnectionState = {
options,
launch: request.launch,
capabilities,
sessions,
emit,
@@ -141,6 +144,7 @@ export async function createAcpProviderConnection(
}
interface AcpConnectionState {
launch?: ProviderLaunch;
options: RunAcpProviderOptions;
capabilities: readonly ProviderCapability[];
sessions: Map<string, AcpBoundarySession>;
@@ -245,6 +249,7 @@ async function discover(
): Promise<void> {
const runtime = await AcpRuntime.start({
options: state.options,
launch: state.launch,
boundarySessionId: "catalog",
env: {},
emit: state.emit,
@@ -269,6 +274,7 @@ async function listSessions(
): Promise<void> {
const runtime = await AcpRuntime.start({
options: state.options,
launch: state.launch,
boundarySessionId: "sessions",
env: {},
emit: state.emit,
@@ -299,6 +305,7 @@ async function openSession(
throw new Error(`Session already exists: ${input.sessionId}`);
const runtime = await AcpRuntime.start({
options: state.options,
launch: state.launch,
boundarySessionId: input.sessionId,
env: input.config.env,
emit: state.emit,
@@ -336,6 +343,7 @@ function requireSession(state: AcpConnectionState, sessionId: string): AcpBounda
}
interface StartRuntimeOptions {
launch?: ProviderLaunch;
options: RunAcpProviderOptions;
boundarySessionId: string;
env: Readonly<Record<string, string>>;
@@ -385,9 +393,15 @@ class AcpRuntime {
let stream: Stream;
let closeConnector = async () => {};
if (options.options.command) {
const [executable, ...args] = options.options.command;
child = spawn(executable, args, {
env: { ...process.env, ...options.env },
// COMPAT(pluginProviderLaunch): added in v0.10.0, remove after 2027-03-29 once plugin host floor >= v0.10.0.
// Standalone callers and older hosts do not send a daemon-resolved launch.
const launch = options.launch ?? {
command: options.options.command[0],
args: options.options.command.slice(1),
env: process.env,
};
child = spawn(launch.command, launch.args, {
env: { ...launch.env, ...options.env },
stdio: ["pipe", "pipe", "pipe"],
});
spawnFailure = new Promise<never>((_resolve, reject) => child!.once("error", reject));
+47
View File
@@ -225,6 +225,53 @@ lines.on("line", (line) => {
});`;
describe("runAcpProvider", () => {
it("spawns the daemon-resolved launch for probes, catalogues, listing, and sessions", async () => {
const executable = await fakeAcp(`
if (process.env.LAUNCH_TOKEN !== "resolved" || process.env.CLAUDECODE || process.env.PASEO_NODE_ENV) process.exit(1);
const readline = require("node:readline");
const lines = readline.createInterface({ input: process.stdin });
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
lines.on("line", (line) => {
const message = JSON.parse(line);
let result;
if (message.method === "initialize") result = { protocolVersion: message.params.protocolVersion, agentCapabilities: { sessionCapabilities: { list: {} } } };
else if (message.method === "session/new") result = { sessionId: "native", configOptions: [{ id: "model", name: "Model", category: "model", type: "select", currentValue: "resolved", options: [{ value: "resolved", name: process.env.SESSION_TOKEN || "probe" }] }] };
else if (message.method === "session/list") result = { sessions: [] };
else return;
send({ jsonrpc: "2.0", id: message.id, result });
});`);
const registration = runAcpProvider({
id: "launch-acp",
label: "Launch ACP",
command: ["missing-default-command"],
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message", "session.list"],
launch: { command: process.execPath, args: [executable], env: { LAUNCH_TOKEN: "resolved" } },
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
try {
await connection.send({ type: "catalog", requestId: "catalog" });
expect(await waitForEvent(events, (event) => event.type === "catalog")).toMatchObject({
catalog: { models: [{ id: "resolved", label: "probe" }] },
});
await connection.send({ type: "sessions", requestId: "list" });
expect(await waitForEvent(events, (event) => event.type === "sessions")).toMatchObject({
sessions: [],
});
const input = openInput();
input.config.env = { SESSION_TOKEN: "session" };
await connection.send(input);
expect(await waitForEvent(events, (event) => event.type === "session.config")).toMatchObject({
config: { models: [{ id: "resolved", label: "session" }] },
});
} finally {
await connection.close();
}
});
it("rejects connection cleanly when the ACP executable does not exist", async () => {
const registration = runAcpProvider({
id: "missing-acp",
+1
View File
@@ -115,6 +115,7 @@ export function runAcpProvider(options: RunAcpProviderOptions): ProviderRegistra
label: options.label,
description: options.description,
icon: options.icon,
command: options.command,
connect(request) {
return createAcpProviderConnection(options, request);
},
+29 -2
View File
@@ -28,6 +28,9 @@ export type ProviderCapability = (typeof PROVIDER_CAPABILITIES)[number];
export interface ProviderRegistration {
/** Equal keys share discovery within this provider. Include effective configuration and execution environment. */
getCatalogCacheKey?(options: ProviderCatalogOptions): Promise<string | undefined>;
/** Default executable and arguments; the daemon resolves overrides before status/connect. */
command?: readonly [string, ...string[]];
status?(request: ProviderStatusRequest): Promise<ProviderStatus>;
id: string;
label: string;
description?: string;
@@ -37,10 +40,34 @@ export interface ProviderRegistration {
}
export type ProviderCatalogOptions =
| { scope: "global"; force?: boolean }
| { scope: "workspace"; cwd: string; force?: boolean };
| { scope: "global"; force?: boolean; launch?: ProviderLaunch }
| { scope: "workspace"; cwd: string; force?: boolean; launch?: ProviderLaunch };
export const ProviderLaunchSchema = z
.object({
/** Resolved executable path. */
command: z.string().min(1),
args: z.array(z.string()),
/** Complete daemon-owned environment, including overrides and parent-session stripping. */
env: z.record(z.string(), z.string()),
})
.strip();
export type ProviderLaunch = z.infer<typeof ProviderLaunchSchema>;
export interface ProviderStatusRequest {
launch?: ProviderLaunch;
}
export const ProviderStatusSchema = z.discriminatedUnion("available", [
z.object({ available: z.literal(true), diagnostic: z.string().optional() }).strip(),
z.object({ available: z.literal(false), diagnostic: z.string().min(1) }).strip(),
]);
export type ProviderStatus = z.infer<typeof ProviderStatusSchema>;
export interface ProviderConnectRequest {
launch?: ProviderLaunch;
versions: readonly number[];
capabilities: readonly string[];
}
+4 -9
View File
@@ -82,15 +82,10 @@ export const ProviderOverridesSchema = z
}
const isBuiltinProvider = builtinProviderIdSet.has(providerId);
if (!isBuiltinProvider && !provider.extends) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [providerId, "extends"],
message: `Custom provider "${providerId}" must declare extends.`,
});
}
if (!isBuiltinProvider && !provider.label) {
// Config loads before plugin registration. An entry without extends is an override;
// registry construction decides whether its provider exists. Configurable plugin IDs
// use this existing config alphabet; dots/underscores remain registration-only.
if (!isBuiltinProvider && provider.extends && !provider.label) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [providerId, "label"],
@@ -1,12 +1,16 @@
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import type { ProcessEnvRecord } from "../server/paseo-env.js";
import { execCommand } from "../utils/spawn.js";
import { isWindowsCommandScript } from "../utils/windows-command.js";
import { windowsExecutableResolution } from "./windows.js";
export { quoteWindowsArgument, quoteWindowsCommand } from "../utils/windows-command.js";
type Which = (command: string, options: { all: true }) => Promise<string[]>;
type Which = (
command: string,
options: { all: true; path?: string; pathExt?: string },
) => Promise<string[]>;
const require = createRequire(import.meta.url);
const which = require("which") as Which;
@@ -16,16 +20,20 @@ function hasPathSeparator(value: string): boolean {
return value.includes("/") || value.includes("\\");
}
async function enumerateCandidates(name: string): Promise<string[]> {
async function enumerateCandidates(name: string, env?: ProcessEnvRecord): Promise<string[]> {
if (process.platform !== "win32" && existsSync("/usr/bin/which")) {
return enumerateCandidatesViaSystemWhich(name);
return enumerateCandidatesViaSystemWhich(name, env);
}
return enumerateCandidatesViaLibrary(name);
return enumerateCandidatesViaLibrary(name, env);
}
async function enumerateCandidatesViaSystemWhich(name: string): Promise<string[]> {
async function enumerateCandidatesViaSystemWhich(
name: string,
env?: ProcessEnvRecord,
): Promise<string[]> {
try {
const { stdout } = await execCommand("/usr/bin/which", ["-a", name], {
baseEnv: env,
timeout: 3000,
killSignal: "SIGKILL",
});
@@ -37,10 +45,13 @@ async function enumerateCandidatesViaSystemWhich(name: string): Promise<string[]
}
}
async function enumerateCandidatesViaLibrary(name: string): Promise<string[]> {
async function enumerateCandidatesViaLibrary(
name: string,
env?: ProcessEnvRecord,
): Promise<string[]> {
let candidates: string[];
try {
candidates = await which(name, { all: true });
candidates = await which(name, { all: true, path: env?.PATH, pathExt: env?.PATHEXT });
} catch (error) {
// `which` throws ENOENT when the command is absent from PATH.
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
@@ -61,10 +72,13 @@ async function enumerateCandidatesViaLibrary(name: string): Promise<string[]> {
export async function probeExecutable(
executablePath: string,
timeoutMs = PROBE_TIMEOUT_MS,
options: number | ExecutableResolutionOptions = PROBE_TIMEOUT_MS,
): Promise<boolean> {
const { probeTimeoutMs: timeoutMs = PROBE_TIMEOUT_MS, env } =
typeof options === "number" ? { probeTimeoutMs: options } : options;
try {
await execCommand(executablePath, ["--version"], {
baseEnv: env,
timeout: timeoutMs,
killSignal: "SIGKILL",
maxBuffer: 64 * 1024,
@@ -110,10 +124,17 @@ export function executableExists(
return exists(executablePath) ? executablePath : null;
}
export interface ExecutableResolutionOptions {
probeTimeoutMs?: number;
env?: ProcessEnvRecord;
}
export async function findExecutable(
name: string,
probeTimeoutMs = PROBE_TIMEOUT_MS,
options: number | ExecutableResolutionOptions = PROBE_TIMEOUT_MS,
): Promise<string | null> {
const { probeTimeoutMs = PROBE_TIMEOUT_MS, env } =
typeof options === "number" ? { probeTimeoutMs: options } : options;
const trimmed = name.trim();
if (!trimmed) {
return null;
@@ -121,20 +142,21 @@ export async function findExecutable(
if (process.platform === "win32") {
return windowsExecutableResolution.find(trimmed, {
enumeratePathCandidates: enumerateCandidates,
probeExecutable,
enumeratePathCandidates: (command) => enumerateCandidates(command, env),
probeExecutable: (command, timeout) =>
probeExecutable(command, { probeTimeoutMs: timeout, env }),
exists: existsSync,
probeTimeoutMs,
});
}
if (hasPathSeparator(trimmed)) {
return (await probeExecutable(trimmed, probeTimeoutMs)) ? trimmed : null;
return (await probeExecutable(trimmed, { probeTimeoutMs, env })) ? trimmed : null;
}
const candidates = await enumerateCandidates(trimmed);
const candidates = await enumerateCandidates(trimmed, env);
for (const candidate of candidates) {
if (await probeExecutable(candidate, probeTimeoutMs)) {
if (await probeExecutable(candidate, { probeTimeoutMs, env })) {
return candidate;
}
}
@@ -1,5 +1,7 @@
import { relative } from "node:path";
import type {
ProviderConnection,
ProviderLaunch,
ProviderEvent,
ProviderInput,
ProviderRegistration,
@@ -817,3 +819,148 @@ describe("pending provider responses", () => {
},
);
});
describe("plugin provider launch and status", () => {
test("resolves the executable against the provider's PATH override", async () => {
const harness = createProviderHarness();
const registration: ProviderRegistration = { ...harness.registration, command: ["node"] };
const logger = createTestLogger();
const registry = new PluginAgentClientRegistry(logger);
registry.replace([registration]);
try {
const definition = registry.definitions()[registration.id]!;
const client = definition.createClient(logger, { env: { PATH: "/paseo-nonexistent-bin" } });
expect(await client.isAvailable()).toBe(false);
expect(await client.getDiagnostic!()).toEqual({ diagnostic: "node not found on PATH" });
} finally {
await registry.shutdown();
}
});
test("strips parent-session and daemon-control variables from session env overlays", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
try {
const client = registry.clients()[harness.registration.id]!;
await client.createSession(
{ provider: harness.registration.id, cwd: "/workspace" },
{
env: {
SESSION_TOKEN: "session",
PASEO_AGENT_ID: "agent",
CLAUDECODE: "parent",
PASEO_NODE_ENV: "development",
},
},
);
const input = harness.inputs.find((value) => value.type === "session.open");
expect(input).toMatchObject({
config: { env: { SESSION_TOKEN: "session", PASEO_AGENT_ID: "agent" } },
});
if (input?.type !== "session.open") throw new Error("Expected session.open");
expect(input.config.env).not.toHaveProperty("CLAUDECODE");
expect(input.config.env).not.toHaveProperty("PASEO_NODE_ENV");
} finally {
await registry.shutdown();
}
});
test("resolves relative executable paths before sending launch data", async () => {
const harness = createProviderHarness();
const launches: Array<ProviderLaunch | undefined> = [];
const registration: ProviderRegistration = {
...harness.registration,
command: [relative(process.cwd(), process.execPath)],
async connect(request) {
launches.push(request.launch);
return harness.registration.connect(request);
},
};
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([registration]);
try {
await registry.clients()[registration.id]!.fetchCatalog({ scope: "global" });
expect(launches).toMatchObject([{ command: process.execPath }]);
} finally {
await registry.shutdown();
}
});
test("a missing executable is unavailable with a diagnostic without connecting", async () => {
const harness = createProviderHarness();
const registration: ProviderRegistration = {
...harness.registration,
command: ["paseo-nonexistent-provider-executable"],
};
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([registration]);
try {
const client = registry.clients()[registration.id]!;
expect(await client.isAvailable()).toBe(false);
expect(await client.getDiagnostic!()).toEqual({
diagnostic: "paseo-nonexistent-provider-executable not found on PATH",
});
expect(harness.inputs).toEqual([]);
} finally {
await registry.shutdown();
}
});
test.each([
{ mode: "default" as const },
{ mode: "append" as const, args: ["extra"] },
{ mode: "replace" as const, argv: [process.execPath, "replacement"] },
])(
"resolves $mode launch and supplies identical data to status, catalogue identity, and connect",
async (command) => {
const harness = createProviderHarness();
const launches: Array<ProviderLaunch | undefined> = [];
const registration: ProviderRegistration = {
...harness.registration,
command: [process.execPath, "default"],
async status(request) {
launches.push(request.launch);
return { available: true };
},
async getCatalogCacheKey(options) {
launches.push(options.launch);
return "shared";
},
async connect(request) {
launches.push(request.launch);
return harness.registration.connect(request);
},
};
const logger = createTestLogger();
const registry = new PluginAgentClientRegistry(logger);
registry.replace([registration]);
try {
const client = registry.definitions()[registration.id]!.createClient(logger, {
command,
env: { PLUGIN_SETTING: "custom", CLAUDECODE: "parent" },
});
expect(await client.isAvailable()).toBe(true);
expect(await client.getCatalogCacheKey!({ scope: "global" })).toBe("shared");
await client.fetchCatalog({ scope: "global" });
let expectedArgs = ["default"];
if (command.mode === "replace") expectedArgs = ["replacement"];
if (command.mode === "append") expectedArgs = ["default", "extra"];
expect(launches).toHaveLength(3);
for (const launch of launches) {
expect(launch).toMatchObject({
command: process.execPath,
args: expectedArgs,
env: { PLUGIN_SETTING: "custom" },
});
expect(launch!.env).not.toHaveProperty("CLAUDECODE");
expect(launch!.env).not.toHaveProperty("PASEO_NODE_ENV");
}
expect(launches[0]).toEqual(launches[1]);
expect(launches[1]).toEqual(launches[2]);
} finally {
await registry.shutdown();
}
},
);
});
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { resolve as resolvePath } from "node:path";
import type { Logger } from "pino";
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
import { z } from "zod";
@@ -7,6 +8,9 @@ import {
PROVIDER_PROTOCOL_VERSION,
ProviderEventSchema,
ProviderInputSchema,
ProviderStatusSchema,
type ProviderStatus,
type ProviderLaunch,
requireProviderCapabilities,
type ProviderCapability,
type ProviderConfigChanges,
@@ -53,10 +57,12 @@ import type {
SteerResult,
} from "./agent-sdk-types.js";
import {
isDefaultAgentCreateConfigUnattended,
resolveDefaultAgentCreateConfig,
} from "./create-agent-mode.js";
import type { ProviderDefinition } from "./provider-registry.js";
resolveProviderLaunch,
checkProviderLaunchAvailable,
createProviderEnv,
type ProviderRuntimeSettings,
} from "./provider-launch-config.js";
import type { RegisteredProviderDefinition } from "./provider-registry.js";
import { runProviderTurn } from "./providers/provider-runner.js";
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
@@ -136,7 +142,46 @@ class ProviderRuntime {
PendingOpenDescendantState
>();
constructor(private readonly registration: ProviderRegistration) {}
constructor(
private readonly registration: ProviderRegistration,
private readonly runtimeSettings?: ProviderRuntimeSettings,
) {}
private async resolveLaunch(): Promise<ProviderLaunch | undefined> {
const command = this.registration.command;
if (!command) return undefined;
const launch = await resolveProviderLaunch({
commandConfig: this.runtimeSettings?.command,
defaultBinary: command[0],
});
if (launch.source !== "override") launch.args.unshift(...command.slice(1));
const env = createProviderEnv({ runtimeSettings: this.runtimeSettings });
launch.env = env;
const availability = await checkProviderLaunchAvailable(launch);
if (!availability.resolvedPath) throw new Error(`${launch.command} not found on PATH`);
return { command: resolvePath(availability.resolvedPath), args: launch.args, env };
}
async status(): Promise<ProviderStatus> {
try {
const launch = await this.resolveLaunch();
if (this.registration.status)
return ProviderStatusSchema.parse(await this.registration.status({ launch }));
if (!this.registration.command) await this.getConnection();
return { available: true };
} catch (error) {
return {
available: false,
diagnostic: error instanceof Error ? error.message : String(error),
};
}
}
async catalogCacheKey(options: FetchCatalogOptions): Promise<string | undefined> {
const callback = this.registration.getCatalogCacheKey;
if (!callback) return undefined;
return callback({ ...options, launch: await this.resolveLaunch() });
}
get negotiatedCapabilities(): readonly string[] {
return this.connection?.capabilities ?? [];
@@ -147,8 +192,7 @@ class ProviderRuntime {
}
async isAvailable(): Promise<boolean> {
await this.getConnection();
return true;
return (await this.status()).available;
}
async catalog(
@@ -328,6 +372,7 @@ class ProviderRuntime {
private async establishConnection(generation: number): Promise<ProviderConnection> {
const rawConnection = await this.registration.connect({
launch: await this.resolveLaunch(),
versions: [PROVIDER_PROTOCOL_VERSION],
capabilities: PROVIDER_CAPABILITIES,
});
@@ -765,7 +810,8 @@ function normalizeConnection(connection: ProviderConnection): ProviderConnection
interface AdaptedPluginProvider {
registration: ProviderRegistration;
client: PluginAgentClient;
definition: ProviderDefinition;
definition: RegisteredProviderDefinition;
clients: Set<PluginAgentClient>;
}
/** Owns the complete plugin-provider adaptation behind the existing core provider boundary. */
@@ -786,7 +832,7 @@ export class PluginAgentClientRegistry {
for (const [id, adapted] of this.providers) {
if (incoming.get(id) === adapted.registration) continue;
this.providers.delete(id);
void adapted.client.shutdown().catch((error) => {
void Promise.all([...adapted.clients].map((client) => client.shutdown())).catch((error) => {
this.logger.warn({ err: error, provider: id }, "Failed to stop plugin provider");
});
}
@@ -794,15 +840,20 @@ export class PluginAgentClientRegistry {
for (const [id, registration] of incoming) {
if (this.providers.has(id)) continue;
const client = new PluginAgentClient(registration);
this.providers.set(id, {
const clients = new Set([client]);
const definition = createPluginProviderDefinition(
registration,
client,
definition: createPluginProviderDefinition(registration, client),
});
(_logger, runtimeSettings) => {
const configuredClient = new PluginAgentClient(registration, runtimeSettings);
clients.add(configuredClient);
return configuredClient;
},
);
this.providers.set(id, { registration, client, definition, clients });
}
}
definitions(): Record<string, ProviderDefinition> {
definitions(): Record<string, RegisteredProviderDefinition> {
return Object.fromEntries(
[...this.providers].map(([id, provider]) => [id, provider.definition]),
);
@@ -819,7 +870,9 @@ export class PluginAgentClientRegistry {
async shutdown(): Promise<void> {
const providers = [...this.providers.values()];
this.providers.clear();
await Promise.all(providers.map(({ client }) => client.shutdown()));
await Promise.all(
providers.flatMap(({ clients }) => [...clients].map((client) => client.shutdown())),
);
}
}
@@ -827,28 +880,18 @@ const PluginProviderOptionsSchema: z.ZodType<ProviderOptions> = z.record(z.strin
function createPluginProviderDefinition(
registration: ProviderRegistration,
client: PluginAgentClient,
): ProviderDefinition {
createClient: RegisteredProviderDefinition["createClient"],
): RegisteredProviderDefinition {
return {
id: registration.id,
configuration: null,
label: registration.label,
description: registration.description ?? `Plugin provider ${registration.label}`,
iconSvg: registration.icon,
defaultModeId: null,
modes: [],
enabled: true,
derivedFromProviderId: null,
optionsSchema: PluginProviderOptionsSchema,
supportsExactMcpPreapproval: true,
validateOptions: (options) =>
options === undefined ? undefined : PluginProviderOptionsSchema.parse(options),
applyOptions: (config, options) => ({ ...config, providerOptions: options }),
applyToolPolicy: (config, toolPolicy) => ({ ...config, toolPolicy }),
createClient: () => client,
resolveCreateConfig: resolveDefaultAgentCreateConfig,
isCreateConfigUnattended: isDefaultAgentCreateConfigUnattended,
fetchCatalog: (options, _client, context) => client.fetchCatalog(options, context),
createClient,
};
}
@@ -865,10 +908,11 @@ class PluginAgentClient implements AgentClient {
readonly getCatalogCacheKey?: AgentClient["getCatalogCacheKey"];
constructor(registration: ProviderRegistration) {
this.getCatalogCacheKey = registration.getCatalogCacheKey?.bind(registration);
constructor(registration: ProviderRegistration, runtimeSettings?: ProviderRuntimeSettings) {
this.provider = registration.id;
this.runtime = new ProviderRuntime(registration);
this.runtime = new ProviderRuntime(registration, runtimeSettings);
if (registration.getCatalogCacheKey)
this.getCatalogCacheKey = (options) => this.runtime.catalogCacheKey(options);
this.runtime.onSessionOpened((session, opened) => this.acceptChild(session, opened));
}
@@ -934,6 +978,11 @@ class PluginAgentClient implements AgentClient {
return await this.runtime.isAvailable();
}
async getDiagnostic(): Promise<{ diagnostic: string }> {
const status = await this.runtime.status();
return { diagnostic: status.diagnostic ?? "Provider is available" };
}
async listImportableSessions(
options: ListImportableSessionsOptions = {},
): Promise<ImportableProviderSession[]> {
@@ -1566,7 +1615,7 @@ function mapSessionConfig(
): ProviderSessionConfig {
return {
cwd: config.cwd,
env: { ...launchContext?.env },
env: createProviderEnv({ baseEnv: {}, overlays: [launchContext?.env] }),
systemPrompt: combineSystemPrompts(config.systemPrompt, config.daemonAppendSystemPrompt),
mcpServers: { ...config.mcpServers },
toolPolicy: config.toolPolicy
@@ -43,6 +43,7 @@ export interface ResolvedProviderLaunch {
command: string;
args: string[];
source: ProviderLaunchSource;
env?: ProcessEnvRecord;
}
export interface ProviderLaunchAvailability {
@@ -64,8 +65,8 @@ function normalizeLaunchDefault(
return defaultBinary;
}
async function resolveLaunchPath(command: string): Promise<string | null> {
const found = await findExecutable(command);
async function resolveLaunchPath(command: string, env?: ProcessEnvRecord): Promise<string | null> {
const found = await findExecutable(command, { env });
if (found) {
return found;
}
@@ -120,7 +121,7 @@ export async function checkProviderLaunchAvailable(
const resolvedPath =
defaultBinary && launch.source !== "override"
? await resolveDefaultLaunchPath(defaultBinary)
: await resolveLaunchPath(launch.command);
: await resolveLaunchPath(launch.command, launch.env);
return {
available: resolvedPath !== null,
resolvedPath,
@@ -105,7 +105,16 @@ export interface ProviderDefinition extends AgentProviderDefinition {
) => Promise<ProviderCatalog>;
}
/** Provider metadata and an unconfigured factory; the registry owns override application. */
export interface RegisteredProviderDefinition extends AgentProviderDefinition {
iconSvg?: string;
optionsSchema: z.ZodType<ProviderOptions>;
supportsExactMcpPreapproval: boolean;
createClient: (logger: Logger, runtimeSettings?: ProviderRuntimeSettings) => AgentClient;
}
export interface BuildProviderRegistryOptions {
pluginProviders?: Record<string, RegisteredProviderDefinition>;
runtimeSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
@@ -492,8 +501,11 @@ function wrapClientProvider(
return {
provider,
capabilities: inner.capabilities,
createSession: async (config, launchContext) =>
get capabilities() {
return inner.capabilities;
},
shutdown: inner.shutdown?.bind(inner),
createSession: async (config, launchContext, options) =>
wrapSessionProvider(
provider,
await inner.createSession(
@@ -502,6 +514,7 @@ function wrapClientProvider(
provider: inner.provider,
},
launchContext,
options,
),
),
resumeSession: async (handle, overrides, launchContext, options) =>
@@ -713,6 +726,31 @@ function createResolvedProviderClient(
);
}
interface RegisteredProvider {
definition: AgentProviderDefinition;
createClient: ProviderClientFactory;
contract: ProviderContract;
runtimeSettings?: ProviderRuntimeSettings;
override?: ProviderOverride;
}
function resolveRegisteredProvider(input: RegisteredProvider): ResolvedProvider {
const { definition, override, contract } = input;
const runtimeSettings = mergeRuntimeSettings(input.runtimeSettings, toRuntimeSettings(override));
return {
definition: applyOverrideToDefinition(definition, override),
runtimeSettings,
profileModels: override?.models ?? [],
additionalModels: override?.additionalModels ?? [],
profileModelsAreAdditive: false,
enabled: override?.enabled ?? definition.enabledByDefault ?? true,
derivedFromProviderId: null,
providerParams: override?.params,
createBaseClient: (logger) => input.createClient(logger, runtimeSettings),
contract,
};
}
function buildResolvedBuiltinProviders(
providerOverrides: Record<string, ProviderOverride>,
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
@@ -731,30 +769,23 @@ function buildResolvedBuiltinProviders(
for (const definition of definitions) {
const override = providerOverrides[definition.id];
const factory = getProviderClientFactory(definition.id);
const mergedRuntimeSettings = mergeRuntimeSettings(
runtimeSettings?.[definition.id],
toRuntimeSettings(override),
resolvedProviders.set(
definition.id,
resolveRegisteredProvider({
definition,
override,
runtimeSettings: runtimeSettings?.[definition.id],
createClient: (logger, settings) =>
factory(logger, settings, {
workspaceGitService: options.workspaceGitService,
managedProcesses: options.managedProcesses,
ompRuntime: options.ompRuntime,
openCodeBridge: options.openCodeBridge,
providerParams: override?.params,
}),
contract: PROVIDER_CONTRACTS[definition.id] ?? UNSUPPORTED_PROVIDER_CONTRACT,
}),
);
resolvedProviders.set(definition.id, {
definition: applyOverrideToDefinition(definition, override),
runtimeSettings: mergedRuntimeSettings,
profileModels: override?.models ?? [],
additionalModels: override?.additionalModels ?? [],
profileModelsAreAdditive: false,
enabled: override?.enabled ?? definition.enabledByDefault ?? true,
derivedFromProviderId: null,
providerParams: override?.params,
createBaseClient: (logger) =>
factory(logger, mergedRuntimeSettings, {
workspaceGitService: options.workspaceGitService,
managedProcesses: options.managedProcesses,
ompRuntime: options.ompRuntime,
openCodeBridge: options.openCodeBridge,
providerParams: override?.params,
}),
contract: PROVIDER_CONTRACTS[definition.id] ?? UNSUPPORTED_PROVIDER_CONTRACT,
});
}
return resolvedProviders;
@@ -771,7 +802,8 @@ function addDerivedProviders(
}
if (!override.extends) {
throw new Error(`Custom provider '${providerId}' requires an extends value`);
// Plugin overrides can precede registration (including disabled or removed plugins).
continue;
}
if (override.extends === "acp") {
@@ -889,6 +921,29 @@ export function buildProviderRegistry(
},
options?.isDev === true,
);
for (const [provider, definition] of Object.entries(options?.pluginProviders ?? {})) {
if (resolvedProviders.has(provider)) {
throw new Error(`Plugin provider '${provider}' conflicts with a built-in provider`);
}
const override = providerOverrides[provider];
if (override?.extends) {
logger.warn({ provider }, "Plugin provider shadowed by configured provider");
continue;
}
resolvedProviders.set(
provider,
resolveRegisteredProvider({
definition,
override,
runtimeSettings: runtimeSettings?.[provider],
createClient: definition.createClient,
contract: {
optionsSchema: definition.optionsSchema,
supportsExactMcpPreapproval: definition.supportsExactMcpPreapproval,
},
}),
);
}
addDerivedProviders(resolvedProviders, providerOverrides, {
managedProcesses: options?.managedProcesses,
openCodeBridge: options?.openCodeBridge,
@@ -2,7 +2,11 @@ import pino from "pino";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { describe, expect, test, vi } from "vitest";
import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider";
import type {
ProviderEvent,
ProviderRegistration,
ProviderLaunch,
} from "@getpaseo/plugin/server/provider";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type {
@@ -3448,3 +3452,316 @@ test("binding a settled catalogue publishes once and rebinding an equal settled
manager.destroy();
}
});
describe("plugin provider overrides and launch", () => {
function provider() {
const launches: Array<ProviderLaunch | undefined> = [];
const registration: ProviderRegistration = {
id: "launch-plugin",
label: "Launch plugin",
command: [process.execPath, "default.js"],
async status({ launch }) {
if (launch?.env.PLUGIN_LOGIN !== "yes") {
return { available: false, diagnostic: "Sign in to the provider" };
}
return { available: true };
},
async connect(request) {
launches.push(request.launch);
let listener: ((event: ProviderEvent) => void) | undefined;
return {
version: 1,
capabilities: ["session.persistence"],
async send(input) {
if (input.type !== "catalog") return;
listener?.({
type: "catalog",
requestId: input.requestId,
catalog: { models: [{ id: "runtime", label: "Runtime" }], modes: [] },
});
},
onEvent(next) {
listener = next;
return () => {
listener = undefined;
};
},
async close() {},
};
},
};
return { registration, launches };
}
test("reports plugin status diagnostics without connecting", async () => {
const { registration, launches } = provider();
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
try {
manager.replacePluginProviders([registration]);
expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({
status: "unavailable",
});
expect((await manager.getProviderDiagnostic(registration.id)).diagnostic).toContain(
"Sign in to the provider",
);
expect(launches).toEqual([]);
} finally {
await manager.shutdown();
manager.destroy();
}
});
test("applies plugin overrides and refreshes the resolved launch on config edits", async () => {
const { registration, launches } = provider();
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: {
[registration.id]: {
enabled: false,
label: "Configured plugin",
models: [{ id: "configured", label: "Configured" }],
additionalModels: [{ id: "extra", label: "Extra" }],
command: [process.execPath, "override.js"],
env: { PLUGIN_LOGIN: "yes", CLAUDECODE: "parent" },
},
},
});
try {
manager.replacePluginProviders([registration]);
expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({
enabled: false,
status: "unavailable",
label: "Configured plugin",
});
expect(launches).toEqual([]);
manager.applyMutableProviderConfig({ [registration.id]: { enabled: true } });
expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({
status: "ready",
});
expect(
(await manager.getProvider({ provider: registration.id })).models?.map((model) => model.id),
).toEqual(["configured", "extra"]);
expect(launches[0]).toMatchObject({
command: process.execPath,
args: ["override.js"],
env: { PLUGIN_LOGIN: "yes" },
});
expect(launches[0]!.env).not.toHaveProperty("CLAUDECODE");
const previous = manager.getAgentManagerProviderState().clients[registration.id];
manager.applyMutableProviderConfig({
[registration.id]: {
enabled: true,
command: [process.execPath, "edited.js"],
env: { PLUGIN_LOGIN: "no" },
},
});
expect(manager.getAgentManagerProviderState().clients[registration.id]).not.toBe(previous);
expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({
status: "unavailable",
});
manager.applyMutableProviderConfig({
[registration.id]: {
enabled: true,
command: [process.execPath, "edited.js"],
env: { PLUGIN_LOGIN: "yes" },
},
});
expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({
status: "ready",
});
expect(launches.at(-1)).toMatchObject({ args: ["edited.js"] });
} finally {
await manager.shutdown();
manager.destroy();
}
});
test("does not warn for a plugin override during startup before provider registration settles", async () => {
const { registration } = provider();
const warnings: string[] = [];
const logger = pino(
{ level: "warn" },
{
write(line: string) {
warnings.push(line);
},
},
);
const manager = new ProviderSnapshotManager({
logger,
providerOverrides: {
[registration.id]: { command: [process.execPath], env: { PLUGIN_LOGIN: "yes" } },
},
});
try {
expect(warnings).toEqual([]);
manager.replacePluginProviders([]);
expect(warnings).toEqual([]);
manager.replacePluginProviders([registration]);
manager.settlePluginProviders();
expect(warnings).toEqual([]);
expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({
status: "ready",
});
} finally {
await manager.shutdown();
manager.destroy();
}
});
test("warns for unknown overrides when plugin startup settles without registered plugins", async () => {
const warnings: string[] = [];
const logger = pino(
{ level: "warn" },
{
write(line: string) {
warnings.push(line);
},
},
);
const manager = new ProviderSnapshotManager({
logger,
providerOverrides: { "typo-provider": { enabled: false } },
});
try {
expect(warnings).toEqual([]);
manager.settlePluginProviders();
manager.settlePluginProviders();
expect(warnings).toHaveLength(1);
expect(JSON.parse(warnings[0]!)).toMatchObject({
provider: "typo-provider",
msg: "Provider override matches no registered provider",
});
} finally {
await manager.shutdown();
manager.destroy();
}
});
test("warns once for unknown overrides after plugin startup settles without warning for known providers", async () => {
const { registration } = provider();
const warnings: string[] = [];
const logger = pino(
{ level: "warn" },
{
write(line: string) {
warnings.push(line);
},
},
);
const manager = new ProviderSnapshotManager({
logger,
providerOverrides: {
"typo-provider": { enabled: false },
[registration.id]: { enabled: false },
codex: { enabled: false },
},
});
try {
expect(warnings).toEqual([]);
manager.replacePluginProviders([registration]);
expect(warnings).toEqual([]);
manager.settlePluginProviders();
manager.settlePluginProviders();
const unknown = warnings.map((line) => JSON.parse(line) as { provider: string; msg: string });
expect(unknown).toEqual([
expect.objectContaining({
provider: "typo-provider",
msg: "Provider override matches no registered provider",
}),
]);
} finally {
await manager.shutdown();
manager.destroy();
}
});
test("a same-ID extends entry shadows the plugin without throwing", async () => {
const { registration, launches } = provider();
const warnings: string[] = [];
const logger = pino(
{ level: "warn" },
{
write(line: string) {
warnings.push(line);
},
},
);
const manager = new ProviderSnapshotManager({
logger,
providerOverrides: {
[registration.id]: {
extends: "acp",
label: "Own provider",
command: [process.execPath],
enabled: true,
},
},
});
try {
const previous = manager.getAgentManagerProviderState().clients[registration.id];
manager.replacePluginProviders([registration]);
expect(manager.getAgentManagerProviderState().clients[registration.id]).toBe(previous);
expect(manager.getProviderLabel(registration.id)).toBe("Own provider");
expect(
manager.getAgentManagerProviderState().providerDefinitions[registration.id],
).toMatchObject({ enabled: true });
expect(warnings.join("\n")).toContain("shadowed");
expect(launches).toEqual([]);
manager.replacePluginProviders([]);
expect(manager.hasProvider(registration.id)).toBe(true);
} finally {
await manager.shutdown();
manager.destroy();
}
});
});
test("model overrides preserve negotiated plugin capabilities and connection shutdown", async () => {
let closed = 0;
const registration: ProviderRegistration = {
id: "model-plugin",
label: "Model plugin",
async connect() {
let listener: ((event: ProviderEvent) => void) | undefined;
return {
version: 1,
capabilities: ["session.persistence"],
async send(input) {
if (input.type === "catalog")
listener?.({
type: "catalog",
requestId: input.requestId,
catalog: { models: [], modes: [] },
});
},
onEvent(next) {
listener = next;
return () => {
listener = undefined;
};
},
async close() {
closed += 1;
},
};
},
};
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: {
[registration.id]: { models: [{ id: "configured", label: "Configured" }] },
},
});
try {
manager.replacePluginProviders([registration]);
await manager.getProvider({ provider: registration.id, wait: true });
const client = manager.getAgentManagerProviderState().clients[registration.id]!;
expect(client.capabilities.supportsSessionPersistence).toBe(true);
await manager.shutdown();
expect(closed).toBe(1);
} finally {
await manager.shutdown();
manager.destroy();
}
});
@@ -256,6 +256,7 @@ export class ProviderSnapshotManager {
private providerClients: Record<AgentProvider, AgentClient>;
private readonly ownedClients = new Set<AgentClient>();
private readonly pluginProviders: PluginAgentClientRegistry;
private pluginProvidersSettled = false;
constructor(options: ProviderSnapshotManagerOptions) {
this.logger = options.logger;
@@ -281,7 +282,6 @@ export class ProviderSnapshotManager {
);
this.providerClients = {
...this.extraClients,
...this.pluginProviders.clients(),
} as Record<AgentProvider, AgentClient>;
for (const client of Object.values(this.providerClients)) this.ownedClients.add(client);
}
@@ -385,17 +385,28 @@ export class ProviderSnapshotManager {
return { providerDefinitions, clients };
}
/** Called after built-in and configured plugin startup has completed, including disabled plugins. */
settlePluginProviders(): void {
if (this.pluginProvidersSettled) return;
this.pluginProvidersSettled = true;
this.warnUnknownProviderOverrides();
}
private warnUnknownProviderOverrides(): void {
if (!this.pluginProvidersSettled) return;
for (const [provider, override] of Object.entries(this.providerOverrides ?? {})) {
if (!override.extends && !this.generation.definitions[provider]) {
this.logger.warn({ provider }, "Provider override matches no registered provider");
}
}
}
replacePluginProviders(
registrations: readonly ProviderRegistration[],
): AgentManagerProviderState {
for (const registration of registrations) {
if (
(this.generation.definitions[registration.id] || this.extraClients[registration.id]) &&
!this.pluginProviders.has(registration.id)
) {
throw new Error(
`Plugin provider '${registration.id}' conflicts with a configured provider`,
);
if (BUILTIN_PROVIDER_IDS.includes(registration.id) || this.extraClients[registration.id]) {
throw new Error(`Plugin provider '${registration.id}' conflicts with a built-in provider`);
}
}
const previousPlugins = this.pluginProviders.definitions();
@@ -405,17 +416,30 @@ export class ProviderSnapshotManager {
this.pluginProviders.replace(registrations);
const plugins = this.pluginProviders.definitions();
const retiredProviders = Object.keys(previousPlugins).filter(
(provider) => previousPlugins[provider] !== plugins[provider],
(provider) =>
previousPlugins[provider] !== plugins[provider] &&
!this.providerOverrides?.[provider]?.extends,
);
const definitions = { ...this.generation.definitions };
const definitions = this.buildRegistry(this.runtimeSettings, this.providerOverrides);
const changed = new Set<AgentProvider>();
for (const provider of new Set([...Object.keys(previousPlugins), ...Object.keys(plugins)])) {
if (previousPlugins[provider] !== plugins[provider]) changed.add(provider);
delete definitions[provider];
delete clients[provider];
for (const provider of new Set([...this.generation.order, ...Object.keys(definitions)])) {
const before = this.generation.definitions[provider];
const after = definitions[provider];
const registrationChanged =
previousPlugins[provider] !== plugins[provider] &&
!this.providerOverrides?.[provider]?.extends;
if (
registrationChanged ||
!before ||
!after ||
!isDeepStrictEqual(before.configuration, after.configuration)
) {
changed.add(provider);
delete clients[provider];
} else {
definitions[provider] = before;
}
}
Object.assign(definitions, plugins);
Object.assign(clients, this.pluginProviders.clients());
for (const client of Object.values(clients)) this.ownedClients.add(client);
const generation = this.createGeneration(definitions, this.providerOverrides);
const state = this.createAgentManagerState(definitions, clients);
@@ -607,7 +631,7 @@ export class ProviderSnapshotManager {
definitions[provider] = before;
}
}
Object.assign(clients, this.extraClients, this.pluginProviders.clients());
Object.assign(clients, this.extraClients);
const generation = this.createGeneration(definitions, providerOverrides);
const agentManagerState = this.createAgentManagerState(definitions, clients);
return {
@@ -631,6 +655,7 @@ export class ProviderSnapshotManager {
}
this.generation = generation;
this.providerClients = clients;
this.warnUnknownProviderOverrides();
for (const [key, catalogs] of this.catalogs) {
for (const provider of changed) catalogs.delete(provider);
if (catalogs.size === 0) this.catalogs.delete(key);
@@ -689,19 +714,13 @@ export class ProviderSnapshotManager {
const registry = buildProviderRegistry(this.logger, {
runtimeSettings,
providerOverrides,
pluginProviders: this.pluginProviders.definitions(),
workspaceGitService: this.workspaceGitService,
managedProcesses: this.managedProcesses,
openCodeBridge: this.openCodeBridge,
isDev: this.isDev,
});
for (const [provider, definition] of Object.entries(this.pluginProviders.definitions())) {
if (registry[provider]) {
throw new Error(`Plugin provider '${provider}' conflicts with a configured provider`);
}
registry[provider] = definition;
}
for (const [provider, client] of Object.entries(this.extraClients) as Array<
[AgentProvider, AgentClient]
>) {
+1
View File
@@ -1734,6 +1734,7 @@ export async function createPaseoDaemon(
);
pluginRuntime.bindPaseoSessionHost(wsServer);
await pluginRuntime.start();
providerSnapshotManager.settlePluginProviders();
wsServer.beginAcceptingConnections();
relayRuntime = createRelayRuntime({
config: {
@@ -413,20 +413,24 @@ describe("provider overrides (new format)", () => {
expect(parsed.agents?.providers?.claude?.order).toBe(1);
});
test("new provider without extends → error", () => {
const result = PersistedConfigSchema.safeParse({
agents: {
providers: {
zai: {
label: "ZAI",
},
},
},
test("accepts a plugin override before plugin registration", () => {
const override = { enabled: false, command: ["/opt/agent"], env: { LOGIN: "yes" } };
const parsed = PersistedConfigSchema.parse({
agents: { providers: { "plugin-agent": override } },
});
expect(result.success).toBe(false);
expect(parsed.agents?.providers?.["plugin-agent"]).toEqual(override);
});
test.each(["plugin.agent", "plugin_agent"])(
"keeps %s outside the config provider ID alphabet",
(id) => {
expect(
PersistedConfigSchema.safeParse({ agents: { providers: { [id]: { enabled: false } } } })
.success,
).toBe(false);
},
);
test("new provider without label → error", () => {
const result = PersistedConfigSchema.safeParse({
agents: {
@@ -1,5 +1,15 @@
import { randomUUID } from "node:crypto";
import { rename, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import {
cp,
rename,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -7,6 +17,7 @@ import pino from "pino";
import { afterEach, describe, expect, it } from "vitest";
import { DaemonConfigStore } from "../daemon-config-store.js";
import { PluginService } from "./index.js";
import { BuiltinPluginLoader } from "./builtin/index.js";
import { ManagedPluginSources } from "./managed-source.js";
import {
startNpmRegistry,
@@ -168,6 +179,78 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
}
describe("PluginService", () => {
it.each([
{ shadow: false, runtime: "subprocess" },
{ shadow: true, runtime: "subprocess" },
{ shadow: false, runtime: "builtin" },
{ shadow: true, runtime: "builtin" },
])(
"publishes status and launch metadata through $runtime despite a configured provider (extends: $shadow)",
async ({ shadow, runtime }) => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const directory = await createPlugin(
"provider-status",
`export default function contribute(server) {
server.registerProvider({ id: "plugin-agent", label: "Plugin agent", command: ["agent", "serve"],
async status({ launch }) { return launch.env.LOGIN === "yes" ? { available: true } : { available: false, diagnostic: "Sign in" }; },
async getCatalogCacheKey({ launch }) { return launch.command + ":" + launch.env.LOGIN; },
async connect({ launch }) {
if (launch.command !== process.execPath || launch.env.LOGIN !== "no") throw new Error("Unexpected launch");
return { version: 1, capabilities: [], async send() {}, onEvent() { return () => {}; }, async close() {} };
},
});
return () => {};
}`,
);
const store = createStore(home);
await store.patch({
providers: {
"plugin-agent": shadow
? { extends: "acp", label: "Own provider", command: ["own-agent"] }
: { command: ["configured-agent"], enabled: false },
},
});
const builtinRoot = path.join(home, "builtins");
if (runtime === "builtin") {
await mkdir(builtinRoot);
await cp(directory, path.join(builtinRoot, "provider-status"), { recursive: true });
}
const builtinPlugins = new BuiltinPluginLoader(
builtinRoot,
runtime === "builtin" ? ["provider-status"] : [],
);
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", { builtinPlugins }),
);
await service.start();
try {
if (runtime === "subprocess") await service.installDirectory({ path: directory });
const [registration] = service.getProviderRegistrations();
expect(registration).toMatchObject({
id: "plugin-agent",
command: ["agent", "serve"],
status: expect.any(Function),
});
const launch = { command: process.execPath, args: [], env: { LOGIN: "no" } };
expect(await registration!.getCatalogCacheKey!({ scope: "global", launch })).toBe(
process.execPath + ":no",
);
expect(await registration!.status!({ launch })).toEqual({
available: false,
diagnostic: "Sign in",
});
expect(
await registration!.status!({ launch: { ...launch, env: { LOGIN: "yes" } } }),
).toEqual({ available: true });
const connection = await registration!.connect({ versions: [1], capabilities: [], launch });
await connection.close();
} finally {
await service.stopAllPlugins();
}
},
);
it("resolves a provider icon path to sanitized inline SVG", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
+9 -6
View File
@@ -45,6 +45,7 @@ interface PluginRuntimePort {
discoverUsage: PluginRuntime["discoverUsage"];
connectProvider: PluginRuntime["connectProvider"];
getProviderCatalogCacheKey?: PluginRuntime["getProviderCatalogCacheKey"];
getProviderStatus?: PluginRuntime["getProviderStatus"];
validatePlugin?(path: string): Promise<void>;
startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise<void>;
startBuiltinPlugin?(plugin: BuiltinPlugin): Promise<void>;
@@ -602,16 +603,10 @@ export class PluginService {
pluginDirectory: string,
): Promise<void> {
const metadata = this.runtime.getProviderRegistrations?.(pluginId) ?? [];
const configuredIds = new Set(Object.keys(this.configStore.get().providers));
for (const provider of metadata) {
if (BUILTIN_PROVIDER_ID_SET.has(provider.id)) {
throw new Error(`Plugin ${pluginId} cannot register builtin provider ID "${provider.id}"`);
}
if (configuredIds.has(provider.id)) {
throw new Error(
`Plugin ${pluginId} cannot register configured provider ID "${provider.id}"`,
);
}
if (this.providers.has(provider.id)) {
throw new Error(`Plugin ${pluginId} cannot register provider ID "${provider.id}" twice`);
}
@@ -622,6 +617,14 @@ export class PluginService {
id: provider.id,
label: provider.label,
description: provider.description,
command: provider.command,
status: provider.hasStatus
? (request) => {
if (!this.runtime.getProviderStatus)
throw new Error("Plugin runtime cannot resolve provider status");
return this.runtime.getProviderStatus(pluginId, provider.id, request);
}
: undefined,
getCatalogCacheKey: provider.hasCatalogCacheKey
? (options) => {
if (!this.runtime.getProviderCatalogCacheKey)
@@ -3,12 +3,19 @@ import type {
ProviderCatalogOptions,
ProviderEvent,
ProviderInput,
ProviderStatusRequest,
} from "@getpaseo/plugin/server/provider";
import {
ProviderEventSchema,
ProviderInputSchema,
ProviderLaunchSchema,
} from "@getpaseo/plugin/server/provider";
import { ProviderEventSchema, ProviderInputSchema } from "@getpaseo/plugin/server/provider";
import { z } from "zod";
export interface PluginProviderMetadata {
hasCatalogCacheKey?: boolean;
hasStatus?: boolean;
command?: readonly [string, ...string[]];
id: string;
label: string;
description?: string;
@@ -22,6 +29,12 @@ export interface PluginUsageSourceMetadata {
}
export type PluginProcessRequest =
| {
type: "provider.status";
requestId: string;
providerId: string;
request: ProviderStatusRequest;
}
| {
type: "initialize";
pluginId: string;
@@ -100,6 +113,8 @@ const providerMetadataSchema = z
description: z.string().optional(),
iconPath: z.string().optional(),
hasCatalogCacheKey: z.boolean().optional(),
hasStatus: z.boolean().optional(),
command: z.tuple([z.string().min(1)], z.string()).optional(),
})
.strict();
const usageSourceMetadataSchema = z
@@ -111,6 +126,7 @@ const usageSourceMetadataSchema = z
.strict();
const providerConnectRequestSchema = z
.object({
launch: ProviderLaunchSchema.optional(),
versions: z.array(z.number().int().positive()),
capabilities: z.array(z.string()),
})
@@ -123,6 +139,14 @@ const frameFields = {
export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.discriminatedUnion(
"type",
[
z
.object({
type: z.literal("provider.status"),
requestId: z.string().min(1),
providerId: z.string().min(1),
request: z.object({ launch: ProviderLaunchSchema.optional() }).strict(),
})
.strict(),
z
.object({
type: z.literal("initialize"),
@@ -139,12 +163,19 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
requestId: z.string().min(1),
providerId: z.string().min(1),
options: z.discriminatedUnion("scope", [
z.object({ scope: z.literal("global"), force: z.boolean().optional() }).strict(),
z
.object({
scope: z.literal("global"),
force: z.boolean().optional(),
launch: ProviderLaunchSchema.optional(),
})
.strict(),
z
.object({
scope: z.literal("workspace"),
cwd: z.string(),
force: z.boolean().optional(),
launch: ProviderLaunchSchema.optional(),
})
.strict(),
]),
@@ -14,6 +14,7 @@ import {
ProviderEventSchema,
type ProviderConnection,
type ProviderRegistration,
ProviderStatusSchema,
} from "@getpaseo/plugin/server/provider";
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -123,6 +124,14 @@ export function createPluginWorker(options: {
) {
throw new Error(`Invalid catalogue key callback for plugin provider ${id}`);
}
if (provider.status !== undefined && typeof provider.status !== "function")
throw new Error(`Invalid status callback for plugin provider ${id}`);
if (
provider.command !== undefined &&
(provider.command.length === 0 ||
provider.command.some((part) => typeof part !== "string" || !part.trim()))
)
throw new Error(`Invalid command for plugin provider ${id}`);
if (providers.has(id)) throw new Error(`Duplicate plugin provider ID: ${id}`);
providers.set(id, { ...provider, id });
}
@@ -150,6 +159,8 @@ export function createPluginWorker(options: {
description: provider.description,
iconPath: provider.icon,
hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined,
hasStatus: provider.status !== undefined,
command: provider.command,
};
}
@@ -351,6 +362,7 @@ export function createPluginWorker(options: {
function rejectWhileStopping(message: PluginProcessRequest): void {
if (
message.type === "provider.status" ||
message.type === "provider.catalog_key" ||
message.type === "usage.identify" ||
message.type === "usage.fetch" ||
@@ -416,6 +428,18 @@ export function createPluginWorker(options: {
rejectWhileStopping(message);
return;
}
if (message.type === "provider.status") {
void (async () => {
const provider = providers.get(message.providerId);
if (!provider || !provider.status)
throw new Error(`Provider has no status capability: ${message.providerId}`);
const output = ProviderStatusSchema.parse(await provider.status(message.request));
send({ type: "result", requestId: message.requestId, output });
})().catch((error) =>
send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
return;
}
if (message.type === "provider.catalog_key") {
void (async () => {
const provider = providers.get(message.providerId);
@@ -1,3 +1,8 @@
import {
ProviderStatusSchema,
type ProviderStatus,
type ProviderStatusRequest,
} from "@getpaseo/plugin/server/provider";
import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server";
import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js";
import { fork } from "node:child_process";
@@ -413,6 +418,23 @@ export class PluginRuntime {
return this.request(loaded, { type: "usage.discover", requestId: randomUUID(), sourceId });
}
async getProviderStatus(
pluginId: string,
providerId: string,
request: ProviderStatusRequest,
): Promise<ProviderStatus> {
const loaded = this.plugins.get(pluginId);
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
return ProviderStatusSchema.parse(
await this.request(loaded, {
type: "provider.status",
requestId: randomUUID(),
providerId,
request,
}),
);
}
async connectProvider(
pluginId: string,
providerId: string,