mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
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:
+36
-4
@@ -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
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
>) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user