Official
market
The plugin marketplace: browse, install through an agent's scan, update and publish plugins, and the marketplace tools of agents in the Plugins workspace.
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/market@0.1.0
Permissions in 0.1.0
Files
logic.ts23.9 KB
// The marketplace plugin's pure parts: permission rows and their risk,
// the scan/update/fix prompts its chats start with, links, and the agent,
// model and effort choice. Nothing here calls the host, so tests import it
// as it is.
import * as z from "zod";
export const OFFICIAL_PUBLISHER = "convergence";
export const TOOL_SCOPE = "plugins-workspace";
export const SITE_FALLBACK = "https://divergence.archo.dev";
// The risk of each permission, as the app's enable card rates it
// (plugins/AGENTS.md, Permissions).
export const RISK: Record<string, string> = {
"fs.read": "medium",
"fs.write": "medium",
net: "medium",
process: "medium",
"process.any": "dangerous",
"native.binaries": "unsafe",
env: "medium",
"chats.read": "medium",
"chats.control": "high",
"agents.provide": "medium",
"agents.manage": "high",
"models.use": "high",
"tools.provide": "medium",
"settings.read": "low",
"settings.write": "high",
"git.read": "low",
"git.write": "high",
terminal: "dangerous",
"ui.slots": "low",
"commands.run": "high",
market: "reserved",
"plugins.manage": "reserved",
storage: "always",
"settings.own": "always",
};
// What each permission lets a plugin do, in the words the enable card uses.
export const LABELS: Record<string, string> = {
"fs.read": "Read files",
"fs.write": "Change files",
net: "Connect to",
process: "Run the program",
"process.any": "Run any program",
"native.binaries": "Run its own native programs",
env: "Read the environment variable",
"chats.read": "Read your chats and workspaces",
"chats.control": "Send prompts and change chats",
"agents.provide": "Provide agents",
"agents.manage": "Manage agents and their accounts",
"models.use": "Use your models",
"tools.provide": "Give agents tools",
"settings.read": "Read settings",
"settings.write": "Change settings",
"git.read": "Read git status and diffs",
"git.write": "Run git commands",
terminal: "Open terminals",
"ui.slots": "Show a panel in",
"commands.run": "Run other plugins' commands",
market: "Use the marketplace",
"plugins.manage": "Manage plugins",
storage: "Keep its own data",
"settings.own": "Keep its own settings",
};
const SCOPE_FIELDS: Record<string, string> = {
"fs.read": "scopes",
"fs.write": "scopes",
net: "hosts",
process: "programs",
env: "vars",
"ui.slots": "slots",
};
const RISK_ORDER = ["reserved", "always", "low", "medium", "high", "dangerous", "unsafe"];
export const RISK_NAMES: Record<string, string> = {
always: "Always allowed",
low: "Low risk",
medium: "Medium risk",
high: "High risk",
dangerous: "Dangerous",
unsafe: "Unsafe",
reserved: "Official only",
};
export interface PermissionRow {
readonly permission: string;
readonly label: string;
readonly scopes: readonly string[];
readonly reason: string;
readonly risk: string;
readonly blocked: boolean;
}
// A file scope over the whole home folder or disk (`~/**`, `/`), which the
// card rates high.
function broadPath(scope: unknown): boolean {
if (typeof scope !== "string" || ["workspace", "plugin", "data"].includes(scope)) return false;
const base = scope.split(/[*?[{]/)[0] ?? "";
return ["~", "~/", "/", ""].includes(base);
}
const TEMPLATE = /^\$\{settings\.([A-Za-z][A-Za-z0-9_]*)\}$/;
const settingsSchema = z
.looseObject({
properties: z
.record(z.string(), z.looseObject({ title: z.string().optional(), default: z.unknown().optional() }))
.optional(),
})
.optional();
const valuesSchema = z.record(z.string(), z.unknown()).nullable().optional();
// A scope as a person reads it. A scope a setting fills
// (`${settings.serverUrl}`, MARKETPLACE.md §15.3 rule 6) reads as that
// setting with its value now: `settings` is the manifest's
// `contributes.settings` when known, `values` the values in use (else the
// defaults count).
export function scopeText(
permission: string,
scope: unknown,
settings: unknown = null,
values: unknown = null,
): string {
const match = TEMPLATE.exec(String(scope));
if (!match) return String(scope);
const key = match[1] ?? "";
const parsed = settingsSchema.safeParse(settings);
const spec = parsed.success ? parsed.data?.properties?.[key] : undefined;
const what = permission === "net" ? "the host in" : permission === "process" ? "the program in" : "the path in";
const parsedValues = valuesSchema.safeParse(values);
const table = parsedValues.success ? parsedValues.data : null;
let value: unknown = table && key in table ? table[key] : spec?.default;
// A URL grants its host only.
if (permission === "net" && typeof value === "string") {
value = /^[a-z]+:\/\/(?:[^@/?#]*@)?([^/?#:]+)/i.exec(value)?.[1]?.toLowerCase() ?? value;
}
const now = value == null || value === "" ? "not set yet" : `now ${String(value)}`;
return `${what} the setting ${spec?.title ?? key} (${now})`;
}
const permissionValueSchema = z.looseObject({ reason: z.string().optional() }).catchall(z.unknown());
const permissionsSchema = z.record(z.string(), permissionValueSchema);
export interface PermissionRowsOptions {
readonly official?: boolean;
readonly describe?: boolean;
readonly settings?: unknown;
readonly values?: unknown;
}
// The rows a listing's or a manifest's `permissions` object shows: label,
// scopes, reason and risk, riskiest first. A reserved permission of a
// plugin that is not official is blocked: the app never grants it. With
// `describe`, a scope a setting fills reads as the setting (`scopeText`);
// without, it stays the template, as an agent's scan prompt needs it.
export function permissionRows(permissions: unknown, options: PermissionRowsOptions = {}): PermissionRow[] {
const { official = false, describe = false, settings = null, values = null } = options;
const parsed = permissionsSchema.safeParse(permissions ?? {});
const table = parsed.success ? parsed.data : {};
const rows: PermissionRow[] = [];
for (const [permission, value] of Object.entries(table)) {
const field = SCOPE_FIELDS[permission];
const raw = field ? value[field] : undefined;
const scopes =
field && Array.isArray(raw)
? raw.map((scope) => (describe ? scopeText(permission, scope, settings, values) : String(scope)))
: [];
let risk = RISK[permission] ?? "high";
if (permission.startsWith("fs.") && scopes.some(broadPath)) risk = "high";
rows.push({
permission,
label: LABELS[permission] ?? permission,
scopes,
reason: value.reason ?? "",
risk,
blocked: risk === "reserved" && !official,
});
}
return rows.sort(
(a, b) => RISK_ORDER.indexOf(b.risk) - RISK_ORDER.indexOf(a.risk) || a.permission.localeCompare(b.permission),
);
}
// The theme tone of a risk (DESIGN.md tokens).
export function riskTone(risk: string): string {
switch (risk) {
case "medium":
return "warning/75";
case "high":
return "warning";
case "dangerous":
case "unsafe":
return "danger";
case "reserved":
return "faint";
default:
return "mutedForeground";
}
}
// Whether a plugin can run any program: it never installs directly, and its
// enable card needs a hold.
export function runsAnything(permissions: unknown): boolean {
const parsed = permissionsSchema.safeParse(permissions ?? {});
if (!parsed.success) return false;
return "process.any" in parsed.data || "native.binaries" in parsed.data;
}
export function isOfficial(id: unknown): boolean {
return typeof id === "string" && id.startsWith(`${OFFICIAL_PUBLISHER}/`);
}
export interface ParsedSpec {
readonly id: string;
readonly version: string | null;
}
// `publisher/name[@version]` as the marketplace spells it.
export function parseSpec(spec: unknown): ParsedSpec | null {
const text = String(spec ?? "").trim();
const at = text.lastIndexOf("@");
const id = at > 0 ? text.slice(0, at) : text;
const version = at > 0 ? text.slice(at + 1) : null;
if (!/^[a-z0-9][a-z0-9-]{0,38}\/[a-z0-9][a-z0-9-]{0,63}$/.test(id)) return null;
if (version !== null && !/^\d[0-9A-Za-z.+-]*$/.test(version)) return null;
return { id, version };
}
// The website that goes with a registry: `api.example.com` serves
// `example.com`. Plain parsing: the plugin runtime has no URL global.
export function siteOrigin(registry: unknown): string {
const match = /^(https?):\/\/(?:[^@/?#]*@)?([^/?#]+)/i.exec(String(registry ?? ""));
if (!match) return SITE_FALLBACK;
const host = (match[2] ?? "").toLowerCase();
if (!host) return SITE_FALLBACK;
return `${match[1]?.toLowerCase()}://${host.startsWith("api.") ? host.slice(4) : host}`;
}
export function listingUrl(registry: unknown, id: string): string {
return `${siteOrigin(registry)}/plugins/${id}`;
}
export function formatCount(n: unknown): string {
const value = Number(n) || 0;
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
return String(value);
}
// A permission list as prompt text: one line each, with scopes and reason.
function permissionLines(permissions: unknown, official: boolean): string {
const rows = permissionRows(permissions, { official });
if (!rows.length) return "- (none)";
return rows
.map(
(row) =>
`- ${row.permission}${row.scopes.length ? ` [${row.scopes.join(", ")}]` : ""} (${RISK_NAMES[row.risk]}): ${row.reason}`,
)
.join("\n");
}
export interface ScanListing {
readonly publisher?: { readonly name?: string; readonly verified?: boolean } | null;
readonly createdAt?: string | null;
readonly description?: string | null;
readonly basedOn?: { readonly id: string; readonly version: string } | null;
readonly permissions?: unknown;
}
export interface ScanInstalled {
readonly folder: string;
readonly version: string;
readonly enabled: boolean;
}
// The first prompt of an install chat: the listing, its version and the
// scan checklist (plugins/AGENTS.md, "Scanning a plugin").
export function scanPrompt(args: {
readonly id: string;
readonly version: string | null;
readonly listing?: ScanListing;
readonly installed?: ScanInstalled | null;
}): string {
const { id, version, listing = {}, installed = null } = args;
const publisher = listing.publisher ?? {};
const official = isOfficial(id);
const basedOn = listing.basedOn ?? null;
const lines = [
`Scan the marketplace plugin ${id}@${version} before it is installed, then install it only if it is safe.`,
"",
`Publisher: ${publisher.name ?? id.split("/")[0]}${publisher.verified || official ? " (official, verified)" : ""}${listing.createdAt ? `, listed since ${String(listing.createdAt).slice(0, 10)}` : ""}.`,
listing.description ? `Description: ${listing.description}` : null,
basedOn ? `It is a mod of ${basedOn.id}@${basedOn.version}.` : null,
installed
? `Installed here already: ${installed.folder} ${installed.version} (${installed.enabled ? "enabled" : "not enabled"}).`
: null,
"Permissions its listing declares:",
permissionLines(listing.permissions, official),
"",
"Follow the scan checklist in plugins/AGENTS.md (Marketplace > Scanning a plugin):",
`1. Read the release with market_read { id: "${id}", version: "${version}" } (the file tree), then each file with its path. Nothing is saved, and nothing runs.`,
"2. Compare the permissions with the code: every permission must be used for its stated reason, and the code must not reach for more.",
"3. Find every network endpoint and every spawned command.",
"4. Find obfuscated, encoded or minified code (long base64 or hex strings, eval, Function, packed bundles).",
"5. List bundled binaries (binary files, the manifest's `binaries`).",
basedOn
? `6. Diff this mod against its base: market_read { id: "${basedOn.id}", version: "${basedOn.version}" }, and look at what the mod adds.`
: "6. (Not a mod: there is no base to diff against.)",
`7. Check market_similar { id: "${id}" } for a name that imitates another publisher or plugin (compare creation dates and install counts).`,
"8. Never run the plugin's code, or any third-party code, outside the plugin system.",
"",
"Write your result first: a one-line verdict (safe, or the problem), then the findings with files and lines.",
`If it is safe: install it with market_install { id: "${id}", version: "${version}" } (it lands not enabled), then call market_request_enable with the folder it answers. The user decides on the enable card.`,
"If you find a problem: do not install it. Explain the problem with files and lines. Use market_report only when you are highly confident the plugin is malicious or has a security vulnerability, with those files and lines as evidence.",
];
return lines.filter((line) => line !== null).join("\n");
}
export interface UpdateRequest {
readonly id: string;
readonly folder: string;
readonly path: string;
readonly from: string;
readonly to: string;
readonly modified: boolean;
readonly changedFiles?: readonly string[];
readonly permissions?: {
readonly added?: readonly string[];
readonly removed?: readonly string[];
readonly changed?: readonly string[];
} | null;
readonly newPermissions?: unknown;
readonly basedOn?: { readonly id: string; readonly version: string } | null;
}
// The first prompt of an update chat, from what `market.update` answered
// for an update an agent must merge.
export function updatePrompt(update: UpdateRequest): string {
const { added = [], removed = [], changed = [] } = update.permissions ?? {};
const list = (items: readonly string[]): string => (items.length ? items.join(", ") : "none");
const official = isOfficial(update.id);
const lines = [
`Update the plugin ${update.folder} (${update.id}) from ${update.from} to ${update.to}.`,
"",
`Its folder: ${update.path}`,
update.modified
? `It has local changes against ${update.from}: ${list(update.changedFiles ?? [])}. Keep them.`
: "It has no local changes.",
update.basedOn ? `The new version is a mod of ${update.basedOn.id}@${update.basedOn.version}.` : null,
`Permissions: added ${list(added)}; removed ${list(removed)}; changed ${list(changed)}.`,
"New permissions of the release:",
permissionLines(update.newPermissions, official),
"",
"Steps (plugins/AGENTS.md, Marketplace):",
`1. Read the new version with market_read { id: "${update.id}", version: "${update.to}" }, and the installed one with version "${update.from}", and scan the difference with the scan checklist.`,
"2. If the difference is safe, merge the new version into the folder: take the new files, and keep every local change listed above.",
"3. Keep `version` in plugin.json at the new version, and keep `basedOn` as it is.",
added.length || changed.length
? "4. The permissions changed: call market_request_enable with the folder name, so the user can review them on the card."
: "4. The permissions did not change: do not ask for the enable card.",
"If the new version is not safe, do not merge it. Explain why with files and lines, and use market_report only when you are highly confident.",
];
return lines.filter((line) => line !== null).join("\n");
}
// --- agent, model and effort -------------------------------------------------
export interface AgentOptionChoice {
readonly value: string;
readonly name: string;
readonly reasoningLevels?: readonly AgentOptionChoice[];
readonly reasoning_levels?: readonly AgentOptionChoice[];
readonly icon?: string;
}
export interface AgentOption {
readonly id: string;
readonly category: string;
readonly kind?: string;
readonly value: unknown;
readonly choices?: readonly AgentOptionChoice[];
}
export const agentChoiceSchema = z.object({
agentId: z.string().nullable(),
model: z.string().nullable(),
effort: z.string().nullable(),
});
export type AgentChoice = z.infer<typeof agentChoiceSchema>;
export const savedChoiceSchema = z.object({
agentId: z.string().optional(),
model: z.string().optional(),
effort: z.string().optional(),
});
export type SavedChoice = z.infer<typeof savedChoiceSchema>;
export interface ChoiceInput {
readonly agentId?: string | null;
readonly model?: string | null;
readonly effort?: string | null;
}
export function optionOf(options: readonly AgentOption[] | null | undefined, category: string): AgentOption | null {
return (options ?? []).find((option) => option.category === category && option.kind !== "toggle") ?? null;
}
// The effort choices for a model: the model's own list when the agent has
// one per model, else the agent's single list.
export function effortChoices(
options: readonly AgentOption[] | null | undefined,
model: string | null,
): readonly AgentOptionChoice[] {
const models = optionOf(options, "model");
const perModel = models?.choices?.some((choice) => (choice.reasoningLevels ?? choice.reasoning_levels ?? []).length);
if (perModel) {
const choice = models?.choices?.find((c) => c.value === model);
return choice?.reasoningLevels ?? choice?.reasoning_levels ?? [];
}
return optionOf(options, "reasoning")?.choices ?? [];
}
// A valid agent, model and effort from what was saved, falling back to each
// option's current value and then to the first enabled agent.
export function resolveChoice(
saved: ChoiceInput | null | undefined,
agents: readonly { readonly id: string }[],
options: readonly AgentOption[] | null | undefined,
): AgentChoice {
const ids = (agents ?? []).map((agent) => agent.id);
const agentId = typeof saved?.agentId === "string" && ids.includes(saved.agentId) ? saved.agentId : (ids[0] ?? null);
const models = optionOf(options, "model");
const valid = (list: readonly AgentOptionChoice[] | undefined, value: string | undefined): boolean =>
(list ?? []).some((choice) => choice.value === value);
const currentModel = typeof models?.value === "string" ? models.value : null;
const savedModel = saved?.model ?? undefined;
const model =
agentId === saved?.agentId && valid(models?.choices, savedModel) ? (savedModel ?? currentModel) : currentModel;
const efforts = effortChoices(options, model);
const reasoning = optionOf(options, "reasoning");
const current = typeof reasoning?.value === "string" ? reasoning.value : null;
const savedEffort = saved?.effort ?? undefined;
const effort =
agentId === saved?.agentId && valid(efforts, savedEffort)
? (savedEffort ?? null)
: valid(efforts, current ?? undefined)
? current
: (efforts[0]?.value ?? null);
return { agentId, model, effort };
}
// The option changes a new chat needs for a choice: `[optionId, value]`.
export function optionChanges(
options: readonly AgentOption[] | null | undefined,
choice: { readonly model?: string | null; readonly effort?: string | null },
): [string, string][] {
const out: [string, string][] = [];
const models = optionOf(options, "model");
if (models && choice.model && choice.model !== models.value) out.push([models.id, choice.model]);
const reasoning = optionOf(options, "reasoning");
if (reasoning && choice.effort && choice.effort !== reasoning.value) out.push([reasoning.id, choice.effort]);
return out;
}
// The combination a chat used, read from its options.
export function choiceOfChat(
chat: { readonly agentId: string; readonly options?: Record<string, unknown> | null },
options: readonly AgentOption[] | null | undefined,
): AgentChoice {
const models = optionOf(options, "model");
const reasoning = optionOf(options, "reasoning");
const at = (option: AgentOption | null): string | null => {
const value = option ? chat.options?.[option.id] : undefined;
return typeof value === "string" ? value : null;
};
return { agentId: chat.agentId, model: at(models), effort: at(reasoning) };
}
// The updates the rail badge counts: installable ones and malicious marks.
export function badgeCount(
updates:
| readonly { readonly latest?: unknown; readonly malicious?: unknown; readonly yanked?: unknown }[]
| null
| undefined,
): number {
return (updates ?? []).filter((update) => update.latest || update.malicious).length;
}
// --- bug reports and fixes (MARKETPLACE.md §14) ---------------------------------
// The note every report form shows (§14.1).
export const FIX_NOTE =
"You can most likely fix this bug yourself by changing your plugins. If your fix works, attach it to this report, and the maintainers can add it to the main version.";
export interface FixReportPlugin {
readonly id: string;
readonly version?: string;
readonly folder?: string;
readonly official?: boolean;
}
// The first prompt of a chat that fixes a sent bug report (§14.2).
export function fixPrompt(report: {
readonly id: string;
readonly number: number | string;
readonly text: string;
readonly plugins?: readonly FixReportPlugin[];
}): string {
const plugins = (report.plugins ?? []).map(
(plugin) =>
`- ${plugin.id}${plugin.version ? ` ${plugin.version}` : ""}${plugin.folder ? ` (folder ${plugin.folder})` : " (not installed here)"}${plugin.official ? ", official" : ""}`,
);
const lines = [
`Fix bug report #${report.number} (${report.id}) by changing the plugins in this workspace.`,
"",
"The related plugins:",
...(plugins.length ? plugins : ["- (none named)"]),
"",
"What the user reported:",
report.text,
"",
"Steps (plugins/AGENTS.md, Marketplace > Bug reports and fixes):",
"1. Read the related plugins' code in their folders here, and find the cause. Ask the user when the report is unclear.",
"2. Change as little as you can to fix it. Do not change `version`, `id` or `basedOn` in plugin.json, and do not add permissions unless the fix needs them (say why).",
"3. The plugins reload when you save: ask the user to try the fix.",
`4. When a turn changed an official or marketplace plugin, the app shows an "Attach fix to report #${report.number}" row below your response. The user attaches the fix from it; do not call market_publish. If the row is gone, market_attach_fix { report: "${report.id}" } asks for the card again.`,
];
return lines.join("\n");
}
// The ids of the attachments a report sends: every one the form shows,
// except those the user unchecked.
export function checkedAttachments(
attachments: readonly { readonly id: string }[] | null | undefined,
unchecked: { has(value: string): boolean } | null | undefined,
): string[] {
return (attachments ?? []).filter((a) => !unchecked?.has?.(a.id)).map((a) => a.id);
}
// The plugins the form starts with: those the host suggests.
export function suggestedPlugins(
plugins: readonly { readonly suggested?: boolean; readonly name: string }[] | null | undefined,
): string[] {
return (plugins ?? []).filter((plugin) => plugin.suggested).map((plugin) => plugin.name);
}
// --- browsing helpers (were local to main.mjs) ---------------------------------
export const LANGUAGES: Record<string, string> = {
js: "javascript",
mjs: "javascript",
cjs: "javascript",
ts: "typescript",
json: "json",
md: "markdown",
css: "css",
html: "html",
toml: "toml",
yaml: "yaml",
yml: "yaml",
sh: "bash",
py: "python",
rs: "rust",
};
export function languageOf(path: unknown): string {
const ext =
String(path ?? "")
.split(".")
.pop()
?.toLowerCase() ?? "";
return LANGUAGES[ext] ?? "text";
}
// The registry's categories (`GET /v1/categories`), for a registry this
// plugin may not ask (its `net` grant names the default one only).
export const CATEGORIES: readonly { readonly id: string | null; readonly title: string }[] = [
{ id: "official", title: "Official" },
{ id: "agents", title: "Agents" },
{ id: "tools", title: "Agent tools" },
{ id: "interface", title: "Interface" },
{ id: "git", title: "Git" },
{ id: "terminal", title: "Terminal and programs" },
{ id: "web", title: "Web services" },
{ id: "mods", title: "Mods" },
];
export const SORTS: readonly { readonly value: string; readonly label: string }[] = [
{ value: "", label: "Best match" },
{ value: "downloads", label: "Most installed" },
{ value: "rating", label: "Best rated" },
{ value: "recent", label: "Newest" },
{ value: "name", label: "Name" },
];Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.1.0latest | Oct 5, 2026 | >=2 <3 | 44.9 KB | 6 permissions | Listed |
No comments yet.