Files
oh-my-pi/packages/coding-agent/src/discovery/agent-plugin-format.ts
T
can1357 7cebe901b7 refactor(coding-agent): narrowed over-exported internal symbols
- 28 symbols across discovery, mcp header policy, agent-hub projection and
  rendering, the agent registry, shell tokenizing and changelog comparison
  were exported but referenced only inside their own module; they are now
  module-private, shrinking the deep-import surface.
- Kept AGENT_PLUGIN_MANIFEST_SCHEMA, AGENT_PLUGIN_MCP_SCHEMA,
  parseAgentPluginManifest, clearAgentPluginRootCache and mergeMCPHeaders
  exported: each is a seam for tests that defend real parsing or header
  precedence behavior.
- Nothing reachable from an explicit exports entry or public barrel changed.
2026-08-08 06:32:01 +02:00

552 lines
22 KiB
TypeScript

/**
* Agent Plugins 1.0.0 format support (https://agent-plugins.org).
*
* Pure validation and transformation logic for the portable plugin package
* format: the closed `plugin.json` manifest (spec §5), the closed `mcp.json`
* MCP configuration (spec §7.2), `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` placeholder
* expansion (spec §9.2), and package-boundary containment (spec §4.1).
*
* The discovery provider in `./agent-plugins.ts` wires this into the
* capability registry; `./claude-plugins.ts` and `./omp-plugins.ts` consult
* {@link classifyAgentPluginRoot} so roots governed by the standard are not
* double-loaded through legacy conventions.
*/
import * as path from "node:path";
import { isRecord } from "@oh-my-pi/pi-utils";
import { readFile } from "../capability/fs";
import { isContainedResolved, realpathIfExists, resolveContainedPath } from "./contained-path";
import { registerPluginCacheInvalidator } from "./helpers";
/** Canonical `plugin.json` `$schema` identifier for Agent Plugins 1.0.0 (spec §5.2). */
export const AGENT_PLUGIN_MANIFEST_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
/** Canonical `mcp.json` `$schema` identifier for Agent Plugins 1.0.0 (spec §7.2.1). */
export const AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
/** Prefix shared by every Agent Plugins canonical schema identifier, across spec versions. */
const AGENT_PLUGIN_SCHEMA_PREFIX = "https://agent-plugins.org/schemas/";
const MANIFEST_FIELDS: Record<string, true> = {
$schema: true,
name: true,
version: true,
description: true,
author: true,
homepage: true,
repository: true,
license: true,
keywords: true,
extensions: true,
};
const AUTHOR_FIELDS: Record<string, true> = { name: true, email: true, url: true };
const STDIO_FIELDS: Record<string, true> = { type: true, command: true, args: true, env: true, cwd: true };
const REMOTE_FIELDS: Record<string, true> = { type: true, url: true, headers: true };
/** Reserved subprocess environment names the client always supplies itself (spec §9.2). */
const RESERVED_ENV_NAMES: Record<string, true> = { PLUGIN_ROOT: true, PLUGIN_DATA: true };
/** RFC 9110 token characters, the only bytes legal in an HTTP field name. */
const HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
// Concatenation avoids the noTemplateCurlyInString lint on literal placeholder names.
const PLUGIN_ROOT_VAR = "$" + "{PLUGIN_ROOT}";
const PLUGIN_DATA_VAR = "$" + "{PLUGIN_DATA}";
/** Validated portable manifest fields from a plugin's root `plugin.json` (spec §5). */
export interface AgentPluginManifest {
name: string;
version?: string;
description?: string;
author?: { name?: string; email?: string; url?: string };
homepage?: string;
repository?: string;
license?: string;
keywords?: string[];
/**
* Client-owned data keyed by reverse-domain namespace. Member values are
* opaque: this client implements no namespace, and §8.1/§11.1 require
* ignoring unimplemented namespaces without validating their values.
*/
extensions?: Record<string, unknown>;
}
/**
* Outcome of parsing a candidate `plugin.json`:
* - `none` — the document does not target Agent Plugins (no recognized `$schema`);
* legacy plugin conventions may still apply to the directory.
* - `valid` — a conformant manifest; `warnings` carries the non-fatal violations
* the spec requires reporting (unknown top-level fields, non-object `extensions`).
* - `invalid` — the document targets Agent Plugins but violates the closed schema
* fatally; the plugin must be rejected and none of its components loaded (spec §5.2).
*/
type AgentPluginManifestResult =
| { status: "none" }
| { status: "valid"; manifest: AgentPluginManifest; warnings: string[] }
| { status: "invalid"; reason: string };
/** Enforce the plugin `name` constraints from spec §5.5. */
function isValidAgentPluginName(name: string): boolean {
if (name.length < 1 || name.length > 64) return false;
if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(name)) return false;
return !name.includes("--") && !name.includes("..");
}
/** The closed frontmatter field set from the skills-ref reference validator. */
const SKILL_FIELDS: Record<string, true> = {
name: true,
description: true,
license: true,
"allowed-tools": true,
metadata: true,
compatibility: true,
};
/** Skill `name` characters: Unicode letters/digits (Python `str.isalnum`) and hyphens. */
const SKILL_NAME_CHARS_RE = /^[\p{L}\p{N}-]+$/u;
/** Validate the skill `name` per the skills-ref reference rules (NFKC-normalized). */
function validateSkillName(raw: unknown, dirName: string): string | null {
if (typeof raw !== "string" || raw.trim().length === 0) return `missing required "name"`;
const name = raw.trim().normalize("NFKC");
if (Array.from(name).length > 64) return `"name" exceeds 64 characters`;
if (name !== name.toLowerCase()) return `"name" must be lowercase`;
if (name.startsWith("-") || name.endsWith("-")) return `"name" cannot start or end with a hyphen`;
if (name.includes("--")) return `"name" cannot contain consecutive hyphens`;
if (!SKILL_NAME_CHARS_RE.test(name)) return `invalid "name" ${JSON.stringify(name)}`;
if (name !== dirName.normalize("NFKC")) {
return `"name" ${JSON.stringify(name)} does not match directory ${JSON.stringify(dirName)}`;
}
return null;
}
/**
* Validate `SKILL.md` frontmatter against the Agent Skills specification
* (https://agentskills.io/specification), the source of truth for skill
* validity under Agent Plugins §7.1, mirroring the official skills-ref
* reference validator: the frontmatter schema is CLOSED to its six fields and
* any unexpected key rejects the skill. Returns the first violation, or `null`
* when the skill conforms. Frontmatter keys must be raw (unnormalized).
*/
export function validateAgentSkillFrontmatter(frontmatter: Record<string, unknown>, dirName: string): string | null {
for (const key in frontmatter) {
if (!SKILL_FIELDS[key]) return `unexpected frontmatter field "${key}"`;
}
const nameViolation = validateSkillName(frontmatter.name, dirName);
if (nameViolation !== null) return nameViolation;
const description = frontmatter.description;
if (typeof description !== "string" || description.trim().length === 0) {
return `missing required "description"`;
}
if (description.length > 1024) return `"description" exceeds 1024 characters`;
const license = frontmatter.license;
if (license !== undefined && typeof license !== "string") return `"license" must be a string`;
const compatibility = frontmatter.compatibility;
if (compatibility !== undefined) {
if (typeof compatibility !== "string") return `"compatibility" must be a string`;
if (compatibility.length > 500) return `"compatibility" exceeds 500 characters`;
}
const metadata = frontmatter.metadata;
if (metadata !== undefined) {
if (!isRecord(metadata)) return `"metadata" must be a map of string keys to string values`;
for (const key in metadata) {
if (typeof metadata[key] !== "string") return `"metadata.${key}" must be a string`;
}
}
const allowedTools = frontmatter["allowed-tools"];
if (allowedTools !== undefined && typeof allowedTools !== "string") {
return `"allowed-tools" must be a string`;
}
return null;
}
/**
* Parse and validate a root `plugin.json` document against the closed
* Agent Plugins 1.0.0 manifest schema (spec §5.2–§5.5, §8.1).
*/
export function parseAgentPluginManifest(raw: string): AgentPluginManifestResult {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
// Unparseable documents carry no $schema, so the package cannot claim
// Agent Plugins conformance; leave it to legacy conventions.
return { status: "none" };
}
if (!isRecord(parsed)) return { status: "none" };
const schema = parsed.$schema;
if (typeof schema !== "string" || !schema.startsWith(AGENT_PLUGIN_SCHEMA_PREFIX)) return { status: "none" };
if (schema !== AGENT_PLUGIN_MANIFEST_SCHEMA) {
return { status: "invalid", reason: `unsupported Agent Plugins version ($schema: ${schema})` };
}
const warnings: string[] = [];
// Unknown top-level fields are schema violations, but spec §5.2 requires
// reporting and ignoring them rather than rejecting the plugin.
for (const key in parsed) {
if (!MANIFEST_FIELDS[key]) warnings.push(`Ignoring unknown plugin.json field "${key}"`);
}
const name = parsed.name;
if (typeof name !== "string" || !isValidAgentPluginName(name)) {
return { status: "invalid", reason: `invalid plugin name ${JSON.stringify(name)}` };
}
const manifest: AgentPluginManifest = { name };
for (const field of ["version", "description", "homepage", "repository", "license"] as const) {
const value = parsed[field];
if (value === undefined) continue;
if (typeof value !== "string") return { status: "invalid", reason: `"${field}" must be a string` };
manifest[field] = value;
}
const keywords = parsed.keywords;
if (keywords !== undefined) {
if (!Array.isArray(keywords) || keywords.some(entry => typeof entry !== "string")) {
return { status: "invalid", reason: `"keywords" must be an array of strings` };
}
manifest.keywords = keywords as string[];
}
const author = parsed.author;
if (author !== undefined) {
if (!isRecord(author)) return { status: "invalid", reason: `"author" must be an object` };
for (const key in author) {
if (!AUTHOR_FIELDS[key]) return { status: "invalid", reason: `unknown "author" field "${key}"` };
if (typeof author[key] !== "string") return { status: "invalid", reason: `"author.${key}" must be a string` };
}
manifest.author = author as AgentPluginManifest["author"];
}
const extensions = parsed.extensions;
if (extensions !== undefined) {
if (!isRecord(extensions)) {
// Non-object extensions is the second explicitly non-fatal violation (spec §8.1).
warnings.push(`Ignoring non-object "extensions" field`);
} else {
// §8.1/§11.1: unimplemented namespace entries are ignored WITHOUT
// validating the contents of their values — even non-object values
// (another client's convention) never reject the plugin.
manifest.extensions = extensions;
}
}
return { status: "valid", manifest, warnings };
}
/**
* Expand `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` in a configuration value
* (spec §9.2). Single non-recursive pass; replacement text is never rescanned,
* and no other placeholder or environment-variable expansion is performed.
*/
function expandAgentPluginPlaceholders(value: string, pluginRoot: string, pluginData: string): string {
return value.replace(/\$\{PLUGIN_(ROOT|DATA)\}/g, (_match, which: string) =>
which === "ROOT" ? pluginRoot : pluginData,
);
}
/** A validated `mcp.json` server entry, resolved to launch-ready values. */
interface AgentPluginMcpServer {
name: string;
transport: "stdio" | "http" | "sse";
/** Absolute path for `./`-relative commands; bare executable token otherwise. */
command?: string;
args?: string[];
/** Expanded configured env plus the client-supplied `PLUGIN_ROOT`/`PLUGIN_DATA`. */
env?: Record<string, string>;
/** Absolute working directory; defaults to the plugin root for stdio servers. */
cwd?: string;
url?: string;
headers?: Record<string, string>;
}
/**
* Outcome of parsing a plugin's `mcp.json`:
* - `disabled` — the top-level document is invalid, so MCP is disabled for the
* plugin while other component types keep loading (spec §7.2.2 rule 2).
* - `ok` — the document is valid; individually invalid server entries are
* skipped with a warning (spec §7.2.2 rule 3).
*/
type AgentPluginMcpResult =
| { status: "disabled"; reason: string }
| { status: "ok"; servers: AgentPluginMcpServer[]; warnings: string[] };
interface AgentPluginMcpOptions {
/** Filesystem-resolved plugin root. */
pluginRoot: string;
/** Client-managed persistent data directory for this plugin (spec §9.1). */
pluginData: string;
}
/** Loopback check for plain-HTTP MCP endpoints (spec §7.2.1): `localhost` or a loopback IP literal. */
function isLoopbackHost(hostname: string): boolean {
if (hostname === "localhost") return true;
if (hostname === "[::1]" || hostname === "::1") return true;
const octets = /^(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(hostname);
return octets !== null && octets[1] === "127";
}
function validateRemoteUrl(rawUrl: string): string | null {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return "url is not an absolute URL";
}
if (url.protocol !== "http:" && url.protocol !== "https:") return "url must use http or https";
if (url.username || url.password) return "url must not contain user information";
if (url.hash) return "url must not contain a fragment";
if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) {
return "non-loopback endpoints must use https";
}
return null;
}
function validateHeaders(headers: Record<string, unknown>): string | null {
const seen = new Set<string>();
for (const name in headers) {
const value = headers[name];
if (!HEADER_NAME_RE.test(name)) return `invalid header name "${name}"`;
if (typeof value !== "string") return `header "${name}" value must be a string`;
if (/[\0\r\n]/.test(value)) return `header "${name}" value contains control characters`;
const lower = name.toLowerCase();
if (seen.has(lower)) return `duplicate header name "${name}"`;
seen.add(lower);
}
return null;
}
/** Per-entry validation result: a resolved server, or the reason the entry is skipped. */
type ServerEntryResult = { server: AgentPluginMcpServer } | { error: string };
async function parseStdioServer(
name: string,
cfg: Record<string, unknown>,
{ pluginRoot, pluginData }: AgentPluginMcpOptions,
): Promise<ServerEntryResult> {
for (const key in cfg) {
if (!STDIO_FIELDS[key]) return { error: `unknown field "${key}"` };
}
// `command` is one executable token: a bare name resolved by platform search
// rules, or a plugin-relative `./` path. No placeholder expansion (spec §7.2.1).
const command = cfg.command;
if (typeof command !== "string" || command.length === 0) return { error: `"command" must be a non-empty string` };
let resolvedCommand: string;
if (command.startsWith("./")) {
resolvedCommand = path.resolve(pluginRoot, command);
if (!(await isContainedResolved(pluginRoot, resolvedCommand))) {
return { error: `"command" resolves outside the plugin root` };
}
} else if (command.includes("/") || command.includes("\\")) {
return { error: `"command" must be a bare executable name or a plugin-relative ./ path` };
} else {
resolvedCommand = command;
}
const args = cfg.args;
if (args !== undefined && (!Array.isArray(args) || args.some(entry => typeof entry !== "string"))) {
return { error: `"args" must be an array of strings` };
}
const expandedArgs = (args as string[] | undefined)?.map(arg =>
expandAgentPluginPlaceholders(arg, pluginRoot, pluginData),
);
const env = cfg.env;
const expandedEnv: Record<string, string> = {};
if (env !== undefined) {
if (!isRecord(env)) return { error: `"env" must be an object of strings` };
for (const key in env) {
const value = env[key];
if (typeof value !== "string") return { error: `"env.${key}" must be a string` };
if (RESERVED_ENV_NAMES[key]) return { error: `"env" must not set reserved variable ${key}` };
expandedEnv[key] = expandAgentPluginPlaceholders(value, pluginRoot, pluginData);
}
}
// The client supplies the reserved variables itself, after the configured
// overlay, replacing any equivalently named ambient entries (spec §9.1).
expandedEnv.PLUGIN_ROOT = pluginRoot;
expandedEnv.PLUGIN_DATA = pluginData;
// `cwd` accepts exactly three forms (spec §7.2.1); anything else — or a
// post-resolution escape from its governing directory — invalidates the entry.
let resolvedCwd = pluginRoot;
const cwd = cfg.cwd;
if (cwd !== undefined) {
if (typeof cwd !== "string") return { error: `"cwd" must be a string` };
const dataRooted = cwd === PLUGIN_DATA_VAR || cwd.startsWith(`${PLUGIN_DATA_VAR}/`);
const rootRooted = cwd === PLUGIN_ROOT_VAR || cwd.startsWith(`${PLUGIN_ROOT_VAR}/`);
if (!dataRooted && !rootRooted && !cwd.startsWith("./")) {
return { error: `"cwd" must be plugin-relative or rooted at ${PLUGIN_ROOT_VAR} or ${PLUGIN_DATA_VAR}` };
}
const expanded = expandAgentPluginPlaceholders(cwd, pluginRoot, pluginData);
resolvedCwd = path.resolve(pluginRoot, expanded);
const base = dataRooted ? pluginData : pluginRoot;
if (!(await isContainedResolved(base, resolvedCwd))) {
return { error: `"cwd" resolves outside ${dataRooted ? "the plugin data directory" : "the plugin root"}` };
}
}
return {
server: {
name,
transport: "stdio",
command: resolvedCommand,
...(expandedArgs !== undefined && { args: expandedArgs }),
env: expandedEnv,
cwd: resolvedCwd,
},
};
}
function parseRemoteServer(name: string, cfg: Record<string, unknown>, transport: "http" | "sse"): ServerEntryResult {
for (const key in cfg) {
if (!REMOTE_FIELDS[key]) return { error: `unknown field "${key}"` };
}
const url = cfg.url;
if (typeof url !== "string" || url.length === 0) return { error: `"url" must be a non-empty string` };
const urlError = validateRemoteUrl(url);
if (urlError) return { error: urlError };
const headers = cfg.headers;
if (headers !== undefined) {
if (!isRecord(headers)) return { error: `"headers" must be an object of strings` };
const headerError = validateHeaders(headers);
if (headerError) return { error: headerError };
}
return {
server: {
name,
transport,
url,
...(headers !== undefined && { headers: headers as Record<string, string> }),
},
};
}
/**
* Parse and validate a plugin's `mcp.json` against the closed Agent Plugins
* 1.0.0 MCP configuration (spec §7.2), resolving stdio commands and working
* directories against the plugin root and expanding plugin variables.
*/
export async function parseAgentPluginMcp(raw: string, options: AgentPluginMcpOptions): Promise<AgentPluginMcpResult> {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { status: "disabled", reason: "mcp.json is not valid JSON" };
}
if (!isRecord(parsed)) return { status: "disabled", reason: "mcp.json must be a JSON object" };
// The plugin manifest already pinned Agent Plugins 1.0.0, so the version
// match rule of spec §10.1 collapses to requiring the exact 1.0.0 identifier.
if (parsed.$schema !== AGENT_PLUGIN_MCP_SCHEMA) {
return { status: "disabled", reason: `mcp.json $schema must be ${AGENT_PLUGIN_MCP_SCHEMA}` };
}
for (const key in parsed) {
if (key !== "$schema" && key !== "mcpServers") {
return { status: "disabled", reason: `mcp.json has unknown top-level field "${key}"` };
}
}
const servers = parsed.mcpServers;
if (!isRecord(servers)) return { status: "disabled", reason: `"mcpServers" must be an object` };
const items: AgentPluginMcpServer[] = [];
const warnings: string[] = [];
for (const name in servers) {
const cfg = servers[name];
let result: ServerEntryResult;
if (!isRecord(cfg)) {
result = { error: "server entry must be an object" };
} else if (cfg.type === "stdio") {
result = await parseStdioServer(name, cfg, options);
} else if (cfg.type === "streamable-http") {
result = parseRemoteServer(name, cfg, "http");
} else if (cfg.type === "sse") {
result = parseRemoteServer(name, cfg, "sse");
} else {
result = { error: `unknown transport type ${JSON.stringify(cfg.type)}` };
}
if ("error" in result) {
warnings.push(`Skipping MCP server "${name}": ${result.error}`);
continue;
}
items.push(result.server);
}
return { status: "ok", servers: items, warnings };
}
/**
* Classification of a plugin root directory against the Agent Plugins standard:
* - `none` — no Agent Plugins manifest; legacy plugin conventions govern.
* - `standard` — a valid Agent Plugin; the standard governs its portable
* components (skills and MCP servers). `realRoot` is the filesystem-resolved
* plugin root every package path is contained within.
* - `invalid` — the root claims Agent Plugins conformance but its manifest is
* fatally invalid; no component may be discovered or executed (spec §11.3).
*/
type AgentPluginRootStatus =
| { kind: "none" }
| { kind: "standard"; manifest: AgentPluginManifest; warnings: string[]; realRoot: string }
| { kind: "invalid"; reason: string };
const rootStatusCache = new Map<string, Promise<AgentPluginRootStatus>>();
registerPluginCacheInvalidator(() => rootStatusCache.clear());
/** Drop cached classifications (tests, or after plugin installs outside the shared registry flow). */
export function clearAgentPluginRootCache(): void {
rootStatusCache.clear();
}
async function classifyUncached(rootPath: string): Promise<AgentPluginRootStatus> {
const realRoot = await realpathIfExists(rootPath);
if (realRoot === null) return { kind: "none" };
// Spec §4.1 failure boundary 1: resolve plugin.json and prove containment
// BEFORE reading it — an escaping manifest is rejected without consuming a
// single byte from outside the package.
const manifest = await resolveContainedPath(realRoot, path.join(realRoot, "plugin.json"));
if (manifest.status === "missing") return { kind: "none" };
if (manifest.status === "outside") {
return { kind: "invalid", reason: "plugin.json resolves outside the plugin root" };
}
const raw = await readFile(manifest.realPath);
if (raw === null) return { kind: "none" };
const parsed = parseAgentPluginManifest(raw);
if (parsed.status === "none") return { kind: "none" };
if (parsed.status === "invalid") return { kind: "invalid", reason: parsed.reason };
return { kind: "standard", manifest: parsed.manifest, warnings: parsed.warnings, realRoot };
}
/**
* Determine whether a plugin root is governed by the Agent Plugins standard.
* Results are cached per root path; the cache clears with the shared plugin
* roots cache and via {@link clearAgentPluginRootCache}.
*/
export function classifyAgentPluginRoot(rootPath: string): Promise<AgentPluginRootStatus> {
let cached = rootStatusCache.get(rootPath);
if (!cached) {
cached = classifyUncached(rootPath);
rootStatusCache.set(rootPath, cached);
}
return cached;
}
/**
* Whether a legacy plugin provider (claude-plugins, omp-plugins) may process a
* root for the given surface. Roots governed by the Agent Plugins standard keep
* their portable components (`skills`, `mcp`) exclusive to the standard loader,
* while client-specific surfaces (commands, hooks, tools, …) still load from
* hybrid packages. Fatally invalid Agent Plugins packages are rejected entirely.
*/
export async function legacyProviderAllowed(rootPath: string, surface: "skills" | "mcp" | "other"): Promise<boolean> {
const status = await classifyAgentPluginRoot(rootPath);
if (status.kind === "none") return true;
if (status.kind === "invalid") return false;
return surface === "other";
}