feat(providers): make bundled Hoplite opt-in from the catalog

Keep the contributor ACP adapter and provisioning progress while activating it only when a provider config entry exists. Removing the entry removes membership across restarts; existing provider defaults remain unchanged.

Present the Add provider catalog as a searchable grid and gate Hoplite activation on host support.

Co-authored-by: Odin Glynn-Martin <odin.glynn@icloud.com>
This commit is contained in:
Mohamed Boudra
2026-10-15 13:24:50 +02:00
co-authored by Odin Glynn-Martin
parent 665e15ed31
commit 95ea57746a
42 changed files with 977 additions and 185 deletions
@@ -1121,3 +1121,50 @@ describe("plugin provider launch and status", () => {
},
);
});
test("config-required plugin membership is independent of enablement and other providers", async () => {
const logger = createTestLogger();
const plugins = new PluginAgentClientRegistry(logger);
const harness = createProviderHarness();
let connections = 0;
let probes = 0;
plugins.replace([
{
...harness.registration,
requiresConfiguration: true,
async status() {
probes++;
return { available: true };
},
async connect(request) {
connections++;
return harness.registration.connect(request);
},
},
]);
const build = (providerOverrides = {}) =>
buildProviderRegistry(logger, {
pluginProviders: plugins.definitions(),
providerOverrides,
});
try {
const before = build();
expect(before["plugin-direct"]).toBeUndefined();
expect({ probes, connections }).toEqual({ probes: 0, connections: 0 });
const added = build({ "plugin-direct": {} });
expect(added["plugin-direct"].enabled).toBe(true);
const client = added["plugin-direct"].createClient(logger);
expect(await client.isAvailable()).toBe(true);
const catalog = await client.fetchCatalog({ scope: "global" });
expect(catalog.models.map((model) => model.id)).toEqual(["plugin-model"]);
expect({ probes, connections }).toEqual({ probes: 1, connections: 1 });
const disabled = build({ "plugin-direct": { enabled: false } });
expect(disabled["plugin-direct"].enabled).toBe(false);
expect(Object.keys(build())).toEqual(Object.keys(before));
for (const id of Object.keys(before)) {
expect(added[id].enabled).toBe(before[id].enabled);
}
} finally {
await plugins.shutdown();
}
});
@@ -885,6 +885,7 @@ function createPluginProviderDefinition(
label: registration.label,
description: registration.description ?? `Plugin provider ${registration.label}`,
iconSvg: registration.icon,
requiresConfiguration: registration.requiresConfiguration,
defaultModeId: null,
modes: [],
supportsExactMcpPreapproval: true,
@@ -97,6 +97,7 @@ export interface ProviderDefinition extends AgentProviderDefinition {
/** Provider metadata and an unconfigured factory; the registry owns override application. */
export interface RegisteredProviderDefinition extends AgentProviderDefinition {
requiresConfiguration?: boolean;
iconSvg?: string;
supportsExactMcpPreapproval: boolean;
createClient: (logger: Logger, runtimeSettings?: ProviderRuntimeSettings) => AgentClient;
@@ -925,6 +926,8 @@ export function buildProviderRegistry(
throw new Error(`Plugin provider '${provider}' conflicts with a built-in provider`);
}
const override = providerOverrides[provider];
if (definition.requiresConfiguration && !override) continue;
if (override?.extends) {
logger.warn({ provider }, "Plugin provider shadowed by configured provider");
continue;
@@ -348,6 +348,10 @@ export class ProviderSnapshotManager {
return Object.prototype.hasOwnProperty.call(this.generation.definitions, provider);
}
supportsConfigRequiredProvider(provider: AgentProvider): boolean {
return this.pluginProviders.definitions()[provider]?.requiresConfiguration === true;
}
getProviderLabel(provider: AgentProvider): string {
return this.generation.definitions[provider]?.label ?? provider;
}
@@ -0,0 +1,37 @@
// Local ACP peer for the bundled Hoplite adapter; no Hoplite service or credentials.
const fs = require("node:fs");
const readline = require("node:readline");
fs.appendFileSync(process.env.HOPLITE_TEST_RECORD, "launch\n");
const configOptions = [
{
id: "model",
category: "model",
type: "select",
name: "Model",
currentValue: "fixture-model",
options: [{ value: "fixture-model", name: "Fixture model" }],
},
];
const send = (message) =>
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n");
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
if (request.id === undefined) return;
let result = {};
if (request.method === "initialize")
result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] };
if (request.method === "session/new")
result = {
sessionId: "hoplite-fixture",
configOptions,
};
if (request.method === "session/set_config_option") result = { configOptions };
if (request.method === "session/prompt") {
send({
method: "_hoplite/run_phase",
params: { runId: "fixture-run", phase: "provisioning", label: "Preparing fixture workspace" },
});
result = { stopReason: "end_turn" };
}
send({ id: request.id, result });
});
@@ -1,7 +1,8 @@
import { describe, test, expect } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { loadConfig } from "../config.js";
import { findExecutable } from "../../executable-resolution/executable-resolution.js";
import { BuiltinPluginLoader } from "../plugins/builtin/index.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -165,3 +166,125 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({
await rm(cwd, { recursive: true, force: true });
}
}, 120000);
test("bundled Hoplite requires a config entry and removal survives restart", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-hoplite-optin-"));
const record = path.join(root, "launches.txt");
await writeFile(record, "");
const options = {
paseoHomeRoot: root,
cleanup: false,
mcpEnabled: false,
builtinPlugins: new BuiltinPluginLoader(undefined, [
"hoplite-provider",
"antigravity-provider",
]),
};
let daemon = await createTestPaseoDaemon(options);
const connect = async () => {
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.11.2",
});
await client.connect();
await client.fetchAgents({ subscribe: {} });
return client;
};
let client = await connect();
try {
const before = await client.getProvidersSnapshot();
expect(before.entries.some((entry) => entry.provider === "hoplite")).toBe(false);
expect(before.entries.some((entry) => entry.provider === "antigravity")).toBe(true);
expect(await readFile(record, "utf8")).toBe("");
await client.patchDaemonConfig({
providers: {
hoplite: {
enabled: true,
command: [process.execPath, path.resolve(import.meta.dirname, "hoplite-fixture.cjs")],
env: { HOPLITE_TEST_RECORD: record },
},
},
});
await expect
.poll(
async () =>
(await client.getProvidersSnapshot()).entries.find(
(entry) => entry.provider === "hoplite",
)?.status,
)
.toBe("ready");
expect((await client.listProviderModels("hoplite")).models.map((model) => model.id)).toEqual([
"fixture-model",
]);
const agent = await client.createAgent({
provider: "hoplite",
cwd: root,
model: "fixture-model",
});
await client.sendMessage(agent.id, "Exercise the contributor transformer");
await client.waitForFinish(agent.id, 10000);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
expect(timeline.entries.map((entry) => entry.item)).toContainEqual(
expect.objectContaining({
type: "tool_call",
name: "Hoplite workspace",
status: "completed",
}),
);
await client.archiveAgent(agent.id);
expect(await readFile(record, "utf8")).toContain("launch");
await client.patchDaemonConfig({ providers: { hoplite: { enabled: false } } });
expect(
(await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite")
?.enabled,
).toBe(false);
await client.close();
await daemon.close();
daemon = await createTestPaseoDaemon({
...options,
staticDir: daemon.staticDir,
providerOverrides: loadConfig(daemon.paseoHome, { env: {} }).providerOverrides,
});
client = await connect();
expect(
(await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite")
?.enabled,
).toBe(false);
await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
expect(
(await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
).toBe(false);
expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined();
const persisted = JSON.parse(
await readFile(path.join(daemon.paseoHome, "config.json"), "utf8"),
);
expect(persisted.agents?.providers?.hoplite).toBeUndefined();
const launchesAfterRemoval = await readFile(record, "utf8");
await client.close();
await daemon.close();
daemon = await createTestPaseoDaemon({
...options,
staticDir: daemon.staticDir,
providerOverrides: loadConfig(daemon.paseoHome, { env: {} }).providerOverrides,
});
client = await connect();
const restarted = await client.getProvidersSnapshot();
expect(restarted.entries.some((entry) => entry.provider === "hoplite")).toBe(false);
expect(restarted.entries.map((entry) => entry.provider)).toEqual(
before.entries.map((entry) => entry.provider),
);
expect(await readFile(record, "utf8")).toBe(launchesAfterRemoval);
await client.patchDaemonConfig({ providers: { hoplite: { enabled: false } } });
expect(
(await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite")
?.enabled,
).toBe(false);
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
await rm(daemon.staticDir, { recursive: true, force: true });
}
}, 60000);
@@ -9,6 +9,7 @@ export const builtinPlugins = [
"copilot-usage-source",
"cursor-usage-source",
"grok-usage-source",
"hoplite-provider",
"kimi-usage-source",
"minimax-usage-source",
"muse-provider",
@@ -629,6 +629,7 @@ export class PluginService {
label: provider.label,
description: provider.description,
command: provider.command,
requiresConfiguration: provider.requiresConfiguration,
status: provider.hasStatus
? (request) => {
if (!this.runtime.getProviderStatus)
@@ -14,6 +14,7 @@ import {
import { z } from "zod";
export interface PluginProviderMetadata {
requiresConfiguration?: boolean;
hasCatalogCacheKey?: boolean;
hasStatus?: boolean;
command?: readonly [string, ...string[]];
@@ -112,6 +113,7 @@ const providerMetadataSchema = z
label: z.string().min(1),
description: z.string().optional(),
iconPath: z.string().optional(),
requiresConfiguration: z.boolean().optional(),
hasCatalogCacheKey: z.boolean().optional(),
hasStatus: z.boolean().optional(),
command: z.tuple([z.string().min(1)], z.string()).optional(),
@@ -158,6 +158,7 @@ export function createPluginWorker(options: {
label: provider.label,
description: provider.description,
iconPath: provider.icon,
requiresConfiguration: provider.requiresConfiguration,
hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined,
hasStatus: provider.status !== undefined,
command: provider.command,
@@ -1915,6 +1915,7 @@ export class VoiceAssistantWebSocketServer {
commitBaseClassification: true,
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
providerRemoval: true,
hopliteProvider: this.providerSnapshotManager.supportsConfigRequiredProvider("hoplite"),
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
importSessionWorkspaceTarget: true,
// COMPAT(importSessionSearch): added in v0.7.3, remove gate after 2027-03-02.