Enable plugin registry installs by default (#6224)

* feat(plugins): enable registry installs by default

* fix(plugins): explain explicit Git source for missing registry IDs

* fix(plugins): tailor missing registry guidance to installation

* test(app): sample footer geometry in one browser frame

* fix(plugins): make registry source resolution deterministic

Directory existence must not change the meaning of a registry identifier shared by CLI, RPC, and UI installs. Classify explicit host paths in the source parser and explain recovery from registry transport and revision-selection failures.

* fix(plugins): recognize dot and Windows directory sources

* test(cli): wait for readable daemon ownership before cancellation

The cancellation test raced PID-file creation against its first write and parsed empty JSON in CI. Use the public daemon-instance reader to wait for and capture the supervisor PID before asserting cancellation cleanup.

* test(app): expect missing directory install guidance
This commit is contained in:
Mohamed Boudra
2026-10-06 16:52:02 +01:00
committed by GitHub
parent 56d5680742
commit 7fd469ae19
24 changed files with 385 additions and 118 deletions
-2
View File
@@ -414,7 +414,6 @@ export interface PaseoDaemonConfig {
plugins?: Record<string, PluginSource>;
pluginRegistries?: PluginRegistries;
pluginRegistryUrl?: string;
pluginRegistryEnabled?: boolean;
staticDir: string;
mcpDebug: boolean;
isDev?: boolean;
@@ -625,7 +624,6 @@ export async function createPaseoDaemon(
usageSession: (id) => agentManager.usageSession(id),
},
managedSources: new ManagedPluginSources(config.paseoHome, {
enabled: config.pluginRegistryEnabled ?? false,
registries: config.pluginRegistries,
defaultUrl: config.pluginRegistryUrl,
}),
@@ -30,7 +30,6 @@ export const DAEMON_SETTING_ENV_KEYS = [
"PASEO_LOG_ROTATE_SIZE",
"PASEO_PASSWORD",
"PASEO_PLUGIN_REGISTRY",
"PASEO_PLUGIN_REGISTRY_ENABLED",
"PASEO_RELAY_ENABLED",
"PASEO_RELAY_ENDPOINT",
"PASEO_RELAY_PUBLIC_ENDPOINT",
+9 -11
View File
@@ -205,21 +205,19 @@ test("loads private plugin registry settings through the configuration boundary"
const home = await mkdtemp(path.join(os.tmpdir(), "paseo-registry-config-"));
try {
const pluginRegistries = { "plugins.example.test": { authorization: "Bearer fixture" } };
await writeFile(path.join(home, "config.json"), JSON.stringify({ pluginRegistries }));
await writeFile(
path.join(home, "config.json"),
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: false }),
);
const config = loadConfig(home, {
env: { PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal" },
env: {
PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal",
PASEO_PLUGIN_REGISTRY_ENABLED: "false",
},
});
expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal");
expect(config.pluginRegistries).toEqual(pluginRegistries);
expect(config.pluginRegistryEnabled).toBe(false);
await writeFile(
path.join(home, "config.json"),
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: true }),
);
expect(loadConfig(home, { env: {} }).pluginRegistryEnabled).toBe(true);
expect(
loadConfig(home, { env: { PASEO_PLUGIN_REGISTRY_ENABLED: "false" } }).pluginRegistryEnabled,
).toBe(false);
expect(config).not.toHaveProperty("pluginRegistryEnabled");
} finally {
await rm(home, { recursive: true, force: true });
}
-4
View File
@@ -628,10 +628,6 @@ export function resolveConfigFromPersisted(
plugins: persisted.plugins,
pluginRegistries: persisted.pluginRegistries,
pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY,
pluginRegistryEnabled:
parseBooleanEnv(env.PASEO_PLUGIN_REGISTRY_ENABLED) ??
persisted.pluginRegistryEnabled ??
false,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),
@@ -308,6 +308,7 @@ export const PersistedConfigSchema = z
providers: ProvidersSchema.optional(),
pluginRegistries: PluginRegistriesSchema.optional(),
// COMPAT(plugin-registry-gate): added in v0.11.0; accept the ignored startup key until 2027-04-06.
pluginRegistryEnabled: z.boolean().optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
@@ -10,6 +10,7 @@ import {
stat,
writeFile,
} from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -451,7 +452,102 @@ describe("PluginService", () => {
await service.stopAllPlugins();
});
it("prefers an existing directory and installs its selected plugin subdirectory", async () => {
it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
const owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`);
await mkdir(owner);
roots.push(home, owner);
const source = `${path.basename(owner)}/example`;
const repository = await createPlugin("registry-example", "export default () => () => {};\n");
await mkdir(path.join(owner, "example"));
await cp(repository, path.join(owner, "example"), { recursive: true });
await runGitCommand(["init", "-b", "main"], { cwd: repository });
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(
[
"-c",
"user.name=Paseo Tests",
"-c",
"user.email=paseo@example.test",
"commit",
"-m",
"fixture",
],
{ cwd: repository },
);
const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository });
const requests: Array<string | undefined> = [];
const server = createServer((request, response) => {
requests.push(request.url);
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify({
id: source,
name: "Example",
description: "Test",
categories: [],
author: { github: path.basename(owner) },
repository: { url: "https://github.com/fixture/example" },
artifact: {
kind: "git",
remote: pathToFileURL(repository).href,
commit: stdout.trim(),
pluginPath: ".",
},
screenshots: [],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
publishedAt: "2026-10-03",
readme: "# Test",
}),
);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
const url = `http://127.0.0.1:${address.port}`;
const store = createStore(home);
store.patch({ pluginsEnabled: false });
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
managedSources: new ManagedPluginSources(home, { defaultUrl: url }),
}),
);
try {
await service.start();
await expect(service.installSource({ source })).resolves.toMatchObject({
id: "registry-example",
status: "disabled",
installation: {
identity: { kind: "git", registry: { url, id: source } },
currentRevision: stdout.trim(),
},
});
expect(requests).toEqual([`/plugins/${source}.json`]);
await expect(
service.installSource({ source: `./${source}`, id: "local-example" }),
).resolves.toMatchObject({
installation: { identity: { kind: "directory", path: path.join(owner, "example") } },
});
expect(requests).toEqual([`/plugins/${source}.json`]);
} finally {
await service.stopAllPlugins();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}, 30_000);
it("names a missing explicit directory instead of trying a managed source", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const directory = path.join(home, "missing");
const service = createService(home);
await expect(service.installSource({ source: directory })).rejects.toThrow(
`Plugin directory does not exist: ${directory}`,
);
});
it("installs an explicit directory and its selected plugin subdirectory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "owner-repository-"));
+6 -10
View File
@@ -21,6 +21,7 @@ import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js";
import { readPluginManifest } from "./manifest.js";
import { expandTilde } from "../../utils/path.js";
import { runPluginBuild } from "./preparation.js";
import { PluginRuntime } from "./runtime.js";
import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js";
@@ -286,16 +287,11 @@ export class PluginService {
if (input.id && this.builtinPluginIds.has(input.id)) {
throw new Error(`Plugin ID "${input.id}" is reserved for a built-in plugin`);
}
const directDirectory = path.resolve(input.source);
const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source);
const directInfo = await stat(directDirectory).catch(() => null);
const reference = directInfo?.isDirectory()
? { source: input.source, pluginPath: undefined }
: parsePluginSourceReference(input.source);
const directory = path.resolve(reference.source);
let info = directInfo;
if (!info?.isDirectory() && !explicit) info = await stat(directory).catch(() => null);
if (info?.isDirectory()) {
const reference = parsePluginSourceReference(input.source);
if (reference.kind === "directory") {
const directory = path.resolve(expandTilde(reference.source));
const info = await stat(directory).catch(() => null);
if (!info?.isDirectory()) throw new Error(`Plugin directory does not exist: ${directory}`);
if (input.ref) throw new Error("Plugin --ref is only valid for Git sources");
const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath);
return this.installDirectory({ path: pluginDirectory, id: input.id });
@@ -280,7 +280,60 @@ describe("managed Git plugin sources", () => {
});
describe("registry plugin sources", () => {
it("treats owner/repository as GitHub shorthand without contacting the registry while registry installs are off", async () => {
it("explains how to choose a revision when --ref is used with a registry id", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-ref-home-"));
roots.push(home);
const sources = new ManagedPluginSources(home);
await expect(
sources.prepareInstall({ source: "fixture/example", ref: "main" }),
).rejects.toThrow(
"Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.",
);
});
it("names the registry URL and recovery steps when the registry fetch fails", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-offline-home-"));
roots.push(home);
const server = createServer((request) => request.socket.destroy());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
const url = `http://127.0.0.1:${address.port}`;
try {
const sources = new ManagedPluginSources(home, { defaultUrl: url });
await expect(sources.prepareInstall({ source: "fixture/example" })).rejects.toThrow(
`Could not reach plugin registry ${url}/plugins/fixture/example.json. Plugin fixture/example was not installed. Retry or use an explicit npm: or github: source.`,
);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("explains explicit local and GitHub sources when a registry ID is missing", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-missing-home-"));
roots.push(home);
const requests: Array<string | undefined> = [];
const server = createServer((request, response) => {
requests.push(request.url);
response.writeHead(404).end();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
try {
const sources = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}`,
});
await expect(sources.prepareInstall({ source: "fixture/missing" })).rejects.toThrow(
"If you intended a local directory, use ./fixture/missing. If you intended a GitHub source, use git:fixture/missing, or a full Git URL for another Git host.",
);
expect(requests).toEqual(["/plugins/fixture/missing.json"]);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("keeps explicit Git sources and persisted Git installs independent of the registry", async () => {
const repository = await createRepository();
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-off-home-"));
roots.push(home);
@@ -298,12 +351,34 @@ describe("registry plugin sources", () => {
const sources = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}`,
});
const candidate = await sources.prepareInstall({ source: "fixture/repository" });
expect(candidate.record).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
});
await sources.discard(candidate);
for (const source of [
"git:fixture/repository",
"https://github.com/fixture/repository.git",
]) {
const candidate = await sources.place(
"managed-example",
await sources.prepareInstall({ source }),
);
expect(candidate.record).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
});
sources.commit("managed-example", candidate.record);
const restarted = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}/changed`,
});
expect((await restarted.preview("managed-example", candidate.directory)).outcome).toBe(
"current",
);
expect(
(await restarted.describe("managed-example", candidate.directory)).identity,
).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
pluginPath: ".",
});
await sources.discard(candidate);
}
});
expect(requests).toEqual([]);
} finally {
@@ -332,12 +407,17 @@ describe("registry plugin sources", () => {
intent: string | string[] | undefined;
authorization: string | undefined;
}> = [];
let missing = false;
const server = createServer((req, res) => {
requests.push({
path: req.url,
intent: req.headers["x-paseo-install"],
authorization: req.headers.authorization,
});
if (missing) {
res.writeHead(404).end();
return;
}
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
@@ -369,7 +449,6 @@ describe("registry plugin sources", () => {
const url = `http://${host}/internal`;
try {
const sources = new ManagedPluginSources(home, {
enabled: true,
defaultUrl: url,
registries: { [host]: { authorization: "Bearer test" } },
});
@@ -380,7 +459,11 @@ describe("registry plugin sources", () => {
).toBe("managed-example");
await sources.verifyCandidate("managed-example", candidate);
sources.commit("managed-example", candidate.record);
const preview = await sources.preview("managed-example", candidate.directory);
const restarted = new ManagedPluginSources(home, {
defaultUrl: "http://127.0.0.1:1/changed",
registries: { [host]: { authorization: "Bearer test" } },
});
const preview = await restarted.preview("managed-example", candidate.directory);
expect(preview.outcome).toBe("current");
expect(preview.current?.identity).toEqual({
kind: "git",
@@ -388,6 +471,10 @@ describe("registry plugin sources", () => {
pluginPath: "packages/example",
registry: { url, id: "acme/example" },
});
missing = true;
await expect(restarted.preview("managed-example", candidate.directory)).rejects.toThrow(
"Check that the installed plugin is still published in this registry before updating",
);
expect(requests).toEqual([
{ path: "/internal/plugins/acme/example.json", intent: "1", authorization: "Bearer test" },
{
@@ -395,6 +482,11 @@ describe("registry plugin sources", () => {
intent: undefined,
authorization: "Bearer test",
},
{
path: "/internal/plugins/acme/example.json",
intent: undefined,
authorization: "Bearer test",
},
]);
} finally {
await new Promise<void>((resolve, reject) =>
@@ -444,7 +536,7 @@ it("installs and updates only the npm artifacts pinned by the plugin registry",
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
const options = { enabled: true, defaultUrl: `http://127.0.0.1:${address.port}` };
const options = { defaultUrl: `http://127.0.0.1:${address.port}` };
try {
const sources = new ManagedPluginSources(home, options);
const candidate = await sources.place(
@@ -147,11 +147,13 @@ export class ManagedPluginSources {
input: InstallInput,
target?: PluginUpdateTarget,
): Promise<ManagedPluginCandidate> {
const registry = this.registryOptions.enabled
? parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl)
: null;
const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl);
if (registry) {
if (input.ref || input.pluginPath)
if (input.ref)
throw new Error(
"Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.",
);
if (input.pluginPath)
throw new Error("Registry sources use their reviewed artifact path and revision");
const resolved = await resolveRegistryPlugin(registry, this.registryOptions, true);
const candidate = await this.prepareInstall(resolved.input, resolved.target);
@@ -175,7 +177,7 @@ export class ManagedPluginSources {
sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName);
record = { kind: "npm" };
} else {
const remote = normalizeGitSource(input.source, !this.registryOptions.enabled);
const remote = normalizeGitSource(input.source);
sourceRoot = path.join(versionRoot, "checkout");
await clone(remote, sourceRoot);
const commit = await resolveRequestedRef(sourceRoot, input.ref);
@@ -227,7 +229,7 @@ export class ManagedPluginSources {
const location = this.locate(pluginId, configuredPath);
let target: PluginUpdateTarget;
let outcome: PluginUpdatePreview["outcome"];
if (this.registryOptions.enabled && current.identity.registry) {
if (current.identity.registry) {
if (selection) throw new Error("Registry updates use only the reviewed registry pin");
const resolved = await resolveRegistryPlugin(
current.identity.registry,
@@ -304,7 +306,7 @@ export class ManagedPluginSources {
await this.assertCurrent(proposal, configuredPath);
const identity = this.locate(proposal.id, configuredPath).identity;
const record = this.records[proposal.id];
if (this.registryOptions.enabled && identity.kind !== "directory" && identity.registry) {
if (identity.kind !== "directory" && identity.registry) {
const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false);
assertRegistryArtifactIdentity(identity, resolved.artifact);
if (!isDeepStrictEqual(resolved.target, proposal.target))
@@ -436,10 +438,9 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s
}
return [];
}
/** Bare owner/repo is GitHub shorthand unless registry references own that syntax. */
function normalizeGitSource(source: string, bareGitHubShorthand: boolean): string {
const expandsShorthand =
bareGitHubShorthand || source.startsWith("github:") || source.startsWith("git:");
/** Only explicit Git prefixes expand GitHub shorthand. */
function normalizeGitSource(source: string): string {
const expandsShorthand = source.startsWith("github:") || source.startsWith("git:");
if (source.startsWith("github:")) {
const shorthand = source.slice(7);
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) {
@@ -6,8 +6,6 @@ import {
import type { PluginUpdateTarget } from "@getpaseo/protocol/messages";
export interface RegistryOptions {
/** Off: bare owner/repo is GitHub shorthand and no registry is contacted. */
enabled?: boolean;
defaultUrl?: string;
registries?: PluginRegistries;
}
@@ -29,11 +27,26 @@ export async function resolveRegistryPlugin(
const headers: Record<string, string> = {};
if (authorization) headers.Authorization = authorization;
if (install) headers["X-Paseo-Install"] = "1";
const response = await fetch(`${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`, {
headers,
redirect: "error",
signal: AbortSignal.timeout(30000),
});
const url = `${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`;
let response: Response;
try {
response = await fetch(url, {
headers,
redirect: "error",
signal: AbortSignal.timeout(30000),
});
} catch (cause) {
const guidance = install
? `Plugin ${identity.id} was not installed. Retry or use an explicit npm: or github: source.`
: `Could not check updates for ${identity.id}. Retry later.`;
throw new Error(`Could not reach plugin registry ${url}. ${guidance}`, { cause });
}
if (response.status === 404) {
const guidance = install
? `If you intended a local directory, use ./${identity.id}. If you intended a GitHub source, use git:${identity.id}, or a full Git URL for another Git host.`
: "Check that the installed plugin is still published in this registry before updating.";
throw new Error(`Plugin ${identity.id} was not found in registry ${base.host}. ${guidance}`);
}
if (!response.ok)
throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`);
const plugin = PublishedPluginDetailSchema.parse(await response.json());