diff --git a/docs/plugins.md b/docs/plugins.md index cf08b2a62a..f3c0572689 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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..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 diff --git a/docs/providers.md b/docs/providers.md index fa608ba7f0..87cdf527d2 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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: diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index 5589cab825..a71f2ca696 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -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; @@ -245,6 +249,7 @@ async function discover( ): Promise { 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 { 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>; @@ -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((_resolve, reject) => child!.once("error", reject)); diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index be52710dba..a62b2f2c48 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -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", diff --git a/packages/plugin/src/server/acp.ts b/packages/plugin/src/server/acp.ts index f737034a44..533453f15e 100644 --- a/packages/plugin/src/server/acp.ts +++ b/packages/plugin/src/server/acp.ts @@ -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); }, diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts index c65a814753..772d0d3eac 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -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; + /** Default executable and arguments; the daemon resolves overrides before status/connect. */ + command?: readonly [string, ...string[]]; + status?(request: ProviderStatusRequest): Promise; 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; + +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; export interface ProviderConnectRequest { + launch?: ProviderLaunch; versions: readonly number[]; capabilities: readonly string[]; } diff --git a/packages/protocol/src/provider-config.ts b/packages/protocol/src/provider-config.ts index ccf73f23fd..7687c813c6 100644 --- a/packages/protocol/src/provider-config.ts +++ b/packages/protocol/src/provider-config.ts @@ -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"], diff --git a/packages/server/src/executable-resolution/executable-resolution.ts b/packages/server/src/executable-resolution/executable-resolution.ts index d7af13a3d0..75f230409f 100644 --- a/packages/server/src/executable-resolution/executable-resolution.ts +++ b/packages/server/src/executable-resolution/executable-resolution.ts @@ -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; +type Which = ( + command: string, + options: { all: true; path?: string; pathExt?: string }, +) => Promise; 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 { +async function enumerateCandidates(name: string, env?: ProcessEnvRecord): Promise { 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 { +async function enumerateCandidatesViaSystemWhich( + name: string, + env?: ProcessEnvRecord, +): Promise { 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 { +async function enumerateCandidatesViaLibrary( + name: string, + env?: ProcessEnvRecord, +): Promise { 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 { export async function probeExecutable( executablePath: string, - timeoutMs = PROBE_TIMEOUT_MS, + options: number | ExecutableResolutionOptions = PROBE_TIMEOUT_MS, ): Promise { + 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 { + 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; } } diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index c18f30a108..e1976e22a0 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -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 = []; + 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 = []; + 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(); + } + }, + ); +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 1c26066fa8..af936a0f7f 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -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 { + 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 { + 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 { + 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 { - await this.getConnection(); - return true; + return (await this.status()).available; } async catalog( @@ -328,6 +372,7 @@ class ProviderRuntime { private async establishConnection(generation: number): Promise { 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; } /** 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 { + definitions(): Record { return Object.fromEntries( [...this.providers].map(([id, provider]) => [id, provider.definition]), ); @@ -819,7 +870,9 @@ export class PluginAgentClientRegistry { async shutdown(): Promise { 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 = 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 { @@ -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 diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index fddd83b626..29554fd2b2 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -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 { - const found = await findExecutable(command); +async function resolveLaunchPath(command: string, env?: ProcessEnvRecord): Promise { + 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, diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 569e842125..004f16c6c4 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -105,7 +105,16 @@ export interface ProviderDefinition extends AgentProviderDefinition { ) => Promise; } +/** Provider metadata and an unconfigured factory; the registry owns override application. */ +export interface RegisteredProviderDefinition extends AgentProviderDefinition { + iconSvg?: string; + optionsSchema: z.ZodType; + supportsExactMcpPreapproval: boolean; + createClient: (logger: Logger, runtimeSettings?: ProviderRuntimeSettings) => AgentClient; +} + export interface BuildProviderRegistryOptions { + pluginProviders?: Record; runtimeSettings?: AgentProviderRuntimeSettingsMap; providerOverrides?: Record; workspaceGitService?: Pick; @@ -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, 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, diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 3f81e5e9c7..b8c59d739d 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -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 = []; + 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(); + } +}); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 0307dc14be..22a706a279 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -256,6 +256,7 @@ export class ProviderSnapshotManager { private providerClients: Record; private readonly ownedClients = new Set(); 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; 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(); - 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] >) { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 695d7b25cb..cf7b147929 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1734,6 +1734,7 @@ export async function createPaseoDaemon( ); pluginRuntime.bindPaseoSessionHost(wsServer); await pluginRuntime.start(); + providerSnapshotManager.settlePluginProviders(); wsServer.beginAcceptingConnections(); relayRuntime = createRelayRuntime({ config: { diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 63f18e2b5e..f443f84865 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -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: { diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 4b76a4af25..544b609df5 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -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); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 830d12251a..edf88de4ca 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -45,6 +45,7 @@ interface PluginRuntimePort { discoverUsage: PluginRuntime["discoverUsage"]; connectProvider: PluginRuntime["connectProvider"]; getProviderCatalogCacheKey?: PluginRuntime["getProviderCatalogCacheKey"]; + getProviderStatus?: PluginRuntime["getProviderStatus"]; validatePlugin?(path: string): Promise; startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise; startBuiltinPlugin?(plugin: BuiltinPlugin): Promise; @@ -602,16 +603,10 @@ export class PluginService { pluginDirectory: string, ): Promise { 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) diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index cf1cd2c32a..6ae2cf140f 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -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 = 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 = 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(), ]), diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 2c371d6ba1..ee1d17310a 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -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); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 312d4d069a..9db0853718 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -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 { + 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,