diff --git a/.changeset/bright-taxis-source.md b/.changeset/bright-taxis-source.md new file mode 100644 index 000000000..025876b8a --- /dev/null +++ b/.changeset/bright-taxis-source.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Support the documented Claude marketplace plugin source matrix, including pinned git, npm, archive, and command copy/link sources. diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index 626227df0..f316f8b56 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -320,6 +320,18 @@ ], "entryRelevanceSignals": ["cli", "cwd", "filesRead", "hosts", "manifestDeps"], "generatedSourceForms": ["relative"], + "sourceMatrix": { + "archiveIntegrity": "sha256-64-hex", + "authoredForms": ["relative", "github", "url", "git-subdir", "npm", "archive", "command"], + "generatedDefault": "relative", + "gitPinFields": ["ref", "sha"], + "shaOverridesRef": true, + "versionGates": { + "archive": "2.1.224", + "command": "2.1.229", + "pluginRootBareName": "2.1.239" + } + }, "renames": true, "topLevelFields": [ "$schema", @@ -437,13 +449,19 @@ "2026-09-01: https://code.claude.com/docs/en/plugin-dependencies exposes dependency-unsatisfied, range-conflict, dependency-version-unsatisfied, and no-matching-tag in `claude plugin list --json` errors.", "2026-09-01: Local host proof against the observed Claude Code 2.1.257 binary (newer than the pinned 2.1.250 table): `claude plugin validate --strict` accepts an emitted plugin manifest declaring one bare dependency and one `{name, version}` dependency object and prints \"Validation passed\" (host-adapters.native.test.ts).", "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugin-marketplaces documents required top-level marketplace name, owner, and plugins plus optional $schema, description, version, metadata.pluginRoot, allowCrossMarketplaceDependenciesOn, and renames; owner requires name and optionally accepts email and url.", - "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugin-marketplaces documents plugin-entry name and source plus standard metadata, category, tags, strict, relevance, defaultEnabled, component configuration, headers, and headersHelper. This slice authors catalog metadata, relevance, and authentication fields while retaining the generated relative `./` source; github, git URL, git-subdir, npm, archive, and command source variants remain the source-matrix follow-up.", + "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugin-marketplaces documents plugin-entry name and source plus standard metadata, category, tags, strict, relevance, defaultEnabled, component configuration, headers, and headersHelper. Agent Bundle keeps generated `./` as the default and lets the authored plugin overlay record a distributed relative, github, git URL, git-subdir, npm, archive, or command source.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugin-marketplaces documents relative ./ paths, bare names under metadata.pluginRoot, github owner/repo, HTTPS or git@ URL sources, git-subdir repository plus required path, npm package/version/registry, HTTPS archive, and command copy/link as the complete plugin source matrix.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugin-marketplaces documents ref and full 40-character sha pins for github, url, and git-subdir sources; when both are present sha is the effective pin.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugin-marketplaces documents archive sha256 as a case-insensitive 64-hex integrity pin whose mismatch fails installation and whose digest supplies the plugin version when neither the entry nor plugin manifest declares one.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugin-marketplaces gates archive sources at Claude Code v2.1.224+, command sources at v2.1.229+, and bare relative names under metadata.pluginRoot at v2.1.239+.", "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugin-relevance defines relevance topic (maximum 64 characters) and nonempty signals from cwd, cli, hosts, filesRead, and manifestDeps, including the documented item and character limits and bare-hostname rule.", "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugins-reference confirms marketplace-entry metadata and plugin manifest metadata/component field shapes; marketplace entries may carry plugin manifest fields in addition to marketplace-specific source, category, tags, strict, relevance, headers, and headersHelper.", "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugin-dependencies requires cross-marketplace dependency targets in the root marketplace's allowCrossMarketplaceDependenciesOn array; only the root allowlist is consulted and trust does not chain.", "2026-09-02: Claude Code 2.1.257 `claude plugin validate --strict` accepts the generated relative-source marketplace enriched with owner contacts, metadata.pluginRoot, version, renames, allowCrossMarketplaceDependenciesOn, plugin metadata, relevance, tags, and strict mode.", + "2026-09-02: Claude Code 2.1.257 `claude plugin validate --strict` accepts emitted github with ref+sha, HTTPS git URL with ref+sha, git-subdir shorthand with path+ref+sha, npm with version+registry, archive with sha256+headers+headersHelper under strict:false, and command sources in copy and link modes.", + "2026-09-02: Claude Code 2.1.257 strict marketplace validation rejects a short git sha, an http archive URL, and an unknown command mode. It does not fetch a lexically valid unreachable HTTPS archive or execute a failing command while validating, so reachability, downloaded integrity, and command runtime behavior remain install-time checks.", "2026-09-02: Claude Code 2.1.257 strict validation accepts an empty string inside allowCrossMarketplaceDependenciesOn without naming the field, so Agent Bundle's kebab-case/nonempty/unique validation is the pre-distribution allowlist guard.", - "2026-09-02: Claude Code 2.1.257 recognizes headers and headersHelper but warns that they have no effect on the generated relative source; --strict promotes that warning to failure. The pinned schema retains the documented fields, but the authored overlay rejects them with claude.marketplace.plugin.headers.inapplicable and claude.marketplace.plugin.headersHelper.inapplicable so the compiler never emits a strict-failing manifest; archive and other URL-capable sources remain the source-matrix follow-up.", + "2026-09-02: Claude Code 2.1.257 recognizes headers and headersHelper but warns that they have no effect on non-archive sources; --strict promotes that warning to failure. The authored overlay accepts both only with an archive source and additionally requires strict:false beside headersHelper.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents `displayName` as the human-readable UI name that falls back to `name`, `metadata` as a free-form object Claude Code does not read, and `defaultEnabled` as the boolean fallback enabled state whose default is true. The same metadata table documents `$schema`, version, description, author, homepage, repository, license, and keywords.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents that an existing `enabledPlugins` user setting and an active dependency requirement both take precedence over plugin.json `defaultEnabled`, while a marketplace entry's `defaultEnabled` takes precedence over the plugin manifest value.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents that wrong types make most manifest fields fail plugin loading, but non-object `experimental` and `metadata` values are ignored with a `claude plugin validate` warning; `--strict` promotes warnings to failure. Before v2.1.222, `metadata` was treated as unrecognized.", diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 9209b3c9e..ff63ee41d 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -1,6 +1,7 @@ import { createTargetDiagnostics } from './diagnostics.ts'; import { hasErrors, type Diagnostic } from '../core/diagnostics.ts'; import { readMcpTransport, unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts'; +import { isValidPackageName } from '../core/project-context.ts'; import { pathTokens, type AgentBundleConfig, @@ -236,10 +237,61 @@ export interface ClaudeMarketplaceRelevanceConfig { readonly topic?: string; } +export interface ClaudeMarketplaceGithubSource { + readonly source: 'github'; + readonly repo: string; + readonly ref?: string; + readonly sha?: string; +} + +export interface ClaudeMarketplaceGitUrlSource { + readonly source: 'url'; + readonly url: string; + readonly ref?: string; + readonly sha?: string; +} + +export interface ClaudeMarketplaceGitSubdirSource { + readonly source: 'git-subdir'; + readonly url: string; + readonly path: string; + readonly ref?: string; + readonly sha?: string; +} + +export interface ClaudeMarketplaceNpmSource { + readonly source: 'npm'; + readonly package: string; + readonly version?: string; + readonly registry?: string; +} + +export interface ClaudeMarketplaceArchiveSource { + readonly source: 'archive'; + readonly url: string; + readonly sha256?: string; +} + +export interface ClaudeMarketplaceCommandSource { + readonly source: 'command'; + readonly command: string; + readonly timeout?: number; + readonly mode?: 'copy' | 'link'; +} + +export type ClaudeMarketplacePluginSource = + | string + | ClaudeMarketplaceGithubSource + | ClaudeMarketplaceGitUrlSource + | ClaudeMarketplaceGitSubdirSource + | ClaudeMarketplaceNpmSource + | ClaudeMarketplaceArchiveSource + | ClaudeMarketplaceCommandSource; + /** * Authored fields that enrich the one generated marketplace plugin entry. - * Plugin identity, component paths, and the relative `./` source remain - * generator-owned; URL and command source variants are tracked separately. + * Plugin identity and component paths remain generator-owned. Source defaults + * to the generated relative `./` path and may identify a distributed copy. */ export interface ClaudeMarketplacePluginConfig { readonly author?: ClaudeMarketplaceContactConfig & { readonly name: string }; @@ -247,9 +299,9 @@ export interface ClaudeMarketplacePluginConfig { readonly defaultEnabled?: boolean; readonly description?: string; readonly displayName?: string; - /** Archive-download headers; rejected until URL-capable sources are emitted. */ + /** Archive-download headers; applicable only to an archive source. */ readonly headers?: Readonly>; - /** Archive-download header command; rejected until URL-capable sources are emitted. */ + /** Archive-download header command; requires an archive source and strict: false. */ readonly headersHelper?: string; readonly homepage?: string; readonly keywords?: readonly string[]; @@ -257,6 +309,7 @@ export interface ClaudeMarketplacePluginConfig { readonly metadata?: Readonly>; readonly relevance?: ClaudeMarketplaceRelevanceConfig; readonly repository?: string; + readonly source?: ClaudeMarketplacePluginSource; readonly strict?: boolean; readonly tags?: readonly string[]; readonly version?: string; @@ -297,7 +350,7 @@ export interface ClaudeHostConfig extends AgentBundleHostConfig { /** Human-readable plugin name shown in Claude Code UI surfaces. */ readonly displayName?: string; readonly lspServers?: Readonly>; - /** Enriches the generated marketplace and its single relative-source plugin entry. */ + /** Enriches the generated marketplace and its single plugin entry. */ readonly marketplace?: ClaudeMarketplaceConfig; /** Free-form catalog or entitlement data that Claude Code preserves but does not interpret. */ readonly metadata?: Readonly>; @@ -369,7 +422,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.15.0', + adapterRevision: '1.16.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -764,6 +817,7 @@ const marketplacePluginFields: ReadonlySet = new Set([ 'metadata', 'relevance', 'repository', + 'source', 'strict', 'tags', 'version', @@ -774,6 +828,28 @@ const relevanceSignalFields: ReadonlySet = new Set(['cli', 'cwd', 'files const manifestDependencySignalFields: ReadonlySet = new Set(['file', 'pattern']); const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; const hostnamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; +const githubRepositoryPattern = + /^[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?\/[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?$/u; +const gitSshUrlPattern = /^git@[^:\s]+:[^\s]+$/u; +const gitShaPattern = /^[0-9a-f]{40}$/iu; +const archiveSha256Pattern = /^[0-9a-f]{64}$/iu; +const npmSourceVersionPattern = new RegExp(`^(?:\\^|~)?${fullSemverVersion}$`, 'u'); +const printableCommandPattern = /^[\x20-\x7e]+$/u; +const sourceFormFields = { + archive: new Set(['source', 'url', 'sha256']), + command: new Set(['source', 'command', 'timeout', 'mode']), + github: new Set(['source', 'repo', 'ref', 'sha']), + 'git-subdir': new Set(['source', 'url', 'path', 'ref', 'sha']), + npm: new Set(['source', 'package', 'version', 'registry']), + url: new Set(['source', 'url', 'ref', 'sha']), +} as const satisfies Readonly['source'], ReadonlySet>>; +const blockedArchiveHosts: ReadonlySet = new Set([ + 'instance-data.ec2.internal', + 'metadata', + 'metadata.azure.internal', + 'metadata.google', + 'metadata.google.internal', +]); const reservedMarketplaceNames: ReadonlySet = new Set([ 'agent-skills', 'anthropic-agent-skills', @@ -1007,8 +1083,337 @@ const planMarketplaceRelevance = ( }; }; +type ClaudeMarketplaceObjectSource = Exclude; +type ClaudeMarketplaceSourceForm = ClaudeMarketplaceObjectSource['source']; + +interface ClaudeMarketplaceSourcePlan { + readonly diagnostics: readonly Diagnostic[]; + readonly form?: 'relative' | ClaudeMarketplaceSourceForm; + readonly value?: ClaudeMarketplacePluginSource; +} + +const isGitUrl = (value: string): boolean => { + if (gitSshUrlPattern.test(value)) return true; + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +}; + +const isInternalSubdirectory = (value: string): boolean => + isNonemptyString(value) && + !value.startsWith('/') && + !value.startsWith('\\') && + !value.split(/[\\/]/u).some((segment) => segment === '.' || segment === '..'); + +const isSafeArchiveUrl = (value: string): boolean => { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return false; + } + if (parsed.protocol !== 'https:') return false; + const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/gu, '').replace(/\.$/u, ''); + if ( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + blockedArchiveHosts.has(hostname) + ) { + return false; + } + const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(hostname); + if (ipv4 !== null) { + const octets = ipv4.slice(1).map(Number); + if ( + octets.some((octet) => octet > 255) || + octets[0] === 0 || + octets[0] === 127 || + (octets[0] === 169 && octets[1] === 254) + ) { + return false; + } + } + return hostname !== '::' && hostname !== '::1' && !/^fe[89ab]:/iu.test(hostname); +}; + +const planMarketplacePluginSource = ( + declared: unknown, + pluginRoot: string | undefined, +): ClaudeMarketplaceSourcePlan => { + if (typeof declared === 'string') { + const internalRelative = + declared.startsWith('./') && + !declared.split('/').includes('..'); + const bareUnderPluginRoot = + pluginRoot !== undefined && + declared !== '.' && + declared !== '..' && + /^[^/\\]+$/u.test(declared); + if (!internalRelative && !bareUnderPluginRoot) { + return { + diagnostics: [marketplaceDiagnostic( + 'claude.marketplace.plugin.source.relative.invalid', + 'Claude marketplace relative plugin source must start with ./ and stay inside the marketplace, or be one bare name under metadata.pluginRoot.', + 'Use an internal ./ path, or set metadata.pluginRoot and use one bare directory name, then rebuild.', + )], + }; + } + return { diagnostics: [], form: 'relative', value: declared }; + } + if (!isPlainDataRecord(declared)) { + return { + diagnostics: [marketplaceDiagnostic( + 'claude.marketplace.plugin.source.invalid', + 'Claude marketplace plugin source must be a documented relative string or source object.', + 'Set plugin.source to an internal relative path or a documented closed source object, then rebuild.', + )], + }; + } + const source = declared['source']; + if ( + typeof source !== 'string' || + !Object.hasOwn(sourceFormFields, source) + ) { + return { + diagnostics: [marketplaceDiagnostic( + 'claude.marketplace.plugin.source.form.invalid', + 'Claude marketplace plugin source object requires source set to github, url, git-subdir, npm, archive, or command.', + 'Choose one documented plugin source form and provide its required fields, then rebuild.', + )], + }; + } + const form = source as ClaudeMarketplaceSourceForm; + const diagnostics: Diagnostic[] = []; + const allowedFields = sourceFormFields[form]; + for (const field of Object.keys(declared).sort()) { + if (allowedFields.has(field)) continue; + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.field.unknown', + `Claude marketplace ${form} source declares unknown field ${JSON.stringify(field)}.`, + `Remove plugin.source.${field}; the ${form} source object is closed, then rebuild.`, + )); + } + const planGitPins = (value: Record): void => { + const ref = declared['ref']; + if (ref !== undefined) { + if (!isNonemptyString(ref)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.ref.invalid', + `Claude marketplace ${form} source ref must be a nonempty branch or tag.`, + 'Set plugin.source.ref to a nonempty branch or tag, or remove it, then rebuild.', + )); + } else { + value['ref'] = ref; + } + } + const sha = declared['sha']; + if (sha !== undefined) { + if (typeof sha !== 'string' || !gitShaPattern.test(sha)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.sha.invalid', + `Claude marketplace ${form} source sha must be a full 40-character hexadecimal commit.`, + 'Set plugin.source.sha to the full 40-hex commit pin, or remove it, then rebuild.', + )); + } else { + value['sha'] = sha; + } + } + }; + + switch (form) { + case 'github': { + const value: Record = { source: form }; + const repo = declared['repo']; + if (typeof repo !== 'string' || !githubRepositoryPattern.test(repo)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.repo.invalid', + 'Claude marketplace github source repo must use owner/repo format.', + 'Set plugin.source.repo to the GitHub owner and repository as owner/repo, then rebuild.', + )); + } else { + value['repo'] = repo; + } + planGitPins(value); + return diagnostics.length === 0 + ? { diagnostics, form, value: value as unknown as ClaudeMarketplaceGithubSource } + : { diagnostics }; + } + case 'url': { + const value: Record = { source: form }; + const url = declared['url']; + if (typeof url !== 'string' || !isGitUrl(url)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.url.invalid', + 'Claude marketplace url source requires an HTTPS or git@ repository URL.', + 'Set plugin.source.url to the full HTTPS or git@ repository URL, then rebuild.', + )); + } else { + value['url'] = url; + } + planGitPins(value); + return diagnostics.length === 0 + ? { diagnostics, form, value: value as unknown as ClaudeMarketplaceGitUrlSource } + : { diagnostics }; + } + case 'git-subdir': { + const value: Record = { source: form }; + const url = declared['url']; + if ( + typeof url !== 'string' || + (!isGitUrl(url) && !githubRepositoryPattern.test(url)) + ) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.url.invalid', + 'Claude marketplace git-subdir source requires an HTTPS URL, git@ URL, or owner/repo shorthand.', + 'Set plugin.source.url to a documented git repository location, then rebuild.', + )); + } else { + value['url'] = url; + } + const path = declared['path']; + if (typeof path !== 'string' || !isInternalSubdirectory(path)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.path.invalid', + 'Claude marketplace git-subdir source path must be a nonempty internal repository subdirectory without . or .. segments.', + 'Set plugin.source.path to the repository subdirectory containing the plugin, then rebuild.', + )); + } else { + value['path'] = path; + } + planGitPins(value); + return diagnostics.length === 0 + ? { diagnostics, form, value: value as unknown as ClaudeMarketplaceGitSubdirSource } + : { diagnostics }; + } + case 'npm': { + const value: Record = { source: form }; + const packageName = declared['package']; + if ( + typeof packageName !== 'string' || + packageName.includes('*') || + !isValidPackageName(packageName) + ) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.package.invalid', + 'Claude marketplace npm source package must be a valid lowercase npm package name.', + 'Set plugin.source.package to a valid package name such as package-name or @scope/package-name, then rebuild.', + )); + } else { + value['package'] = packageName; + } + const version = declared['version']; + if (version !== undefined) { + if (typeof version !== 'string' || !npmSourceVersionPattern.test(version)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.version.invalid', + 'Claude marketplace npm source version must be an exact semantic version or a ^ or ~ range.', + 'Set plugin.source.version to a form such as 2.1.0, ^2.0.0, or ~1.5.0, then rebuild.', + )); + } else { + value['version'] = version; + } + } + const registry = declared['registry']; + if (registry !== undefined) { + if (typeof registry !== 'string' || !isHttpUrl(registry)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.registry.invalid', + 'Claude marketplace npm source registry must be an absolute HTTP(S) URL.', + 'Set plugin.source.registry to the custom npm registry URL, or remove it, then rebuild.', + )); + } else { + value['registry'] = registry; + } + } + return diagnostics.length === 0 + ? { diagnostics, form, value: value as unknown as ClaudeMarketplaceNpmSource } + : { diagnostics }; + } + case 'archive': { + const value: Record = { source: form }; + const url = declared['url']; + if (typeof url !== 'string' || !isSafeArchiveUrl(url)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.url.invalid', + 'Claude marketplace archive source requires an HTTPS URL that is not loopback, link-local, or a known cloud-metadata host.', + 'Set plugin.source.url to a safe externally reachable HTTPS archive URL, then rebuild.', + )); + } else { + value['url'] = url; + } + const sha256 = declared['sha256']; + if (sha256 !== undefined) { + if (typeof sha256 !== 'string' || !archiveSha256Pattern.test(sha256)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.sha256.invalid', + 'Claude marketplace archive source sha256 must contain exactly 64 hexadecimal characters.', + 'Set plugin.source.sha256 to the archive SHA-256 integrity pin, or remove it, then rebuild.', + )); + } else { + value['sha256'] = sha256; + } + } + return diagnostics.length === 0 + ? { diagnostics, form, value: value as unknown as ClaudeMarketplaceArchiveSource } + : { diagnostics }; + } + case 'command': { + const value: Record = { source: form }; + const command = declared['command']; + if ( + !isNonemptyString(command) || + !printableCommandPattern.test(command) || + command.length > 500 || + command.includes(' ') + ) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.command.invalid', + 'Claude marketplace command source command must be 1-500 printable ASCII characters with no run of four spaces.', + 'Set plugin.source.command to a reviewable shell command that prints the absolute plugin directory, then rebuild.', + )); + } else { + value['command'] = command; + } + const timeout = declared['timeout']; + if (timeout !== undefined) { + if (!Number.isInteger(timeout) || typeof timeout !== 'number' || timeout <= 0 || timeout > 600) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.timeout.invalid', + 'Claude marketplace command source timeout must be a positive whole number no greater than 600 seconds.', + 'Set plugin.source.timeout to a whole number from 1 through 600, or remove it, then rebuild.', + )); + } else { + value['timeout'] = timeout; + } + } + const mode = declared['mode']; + if (mode !== undefined) { + if (mode !== 'copy' && mode !== 'link') { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.source.mode.invalid', + 'Claude marketplace command source mode must be copy or link.', + 'Set plugin.source.mode to copy or link, or remove it to use copy, then rebuild.', + )); + } else { + value['mode'] = mode; + } + } + return diagnostics.length === 0 + ? { diagnostics, form, value: value as unknown as ClaudeMarketplaceCommandSource } + : { diagnostics }; + } + default: { + const exhaustive: never = form; + return exhaustive; + } + } +}; + const planMarketplacePlugin = ( declared: unknown, + pluginRoot: string | undefined, ): { readonly diagnostics: readonly Diagnostic[]; readonly value?: Record } => { const diagnostics: Diagnostic[] = []; if (!isPlainDataRecord(declared)) { @@ -1093,16 +1498,57 @@ const planMarketplacePlugin = ( value['metadata'] = metadataValue; } } - // Claude Code 2.1.257 warns that headers/headersHelper only apply to - // archive sources and --strict promotes that warning to failure, so the - // authored overlay rejects them until URL-capable sources are emitted. - for (const field of ['headers', 'headersHelper'] as const) { - if (declared[field] === undefined) continue; + const sourcePlan: ClaudeMarketplaceSourcePlan = declared['source'] === undefined + ? { diagnostics: [], form: 'relative' } + : planMarketplacePluginSource(declared['source'], pluginRoot); + diagnostics.push(...sourcePlan.diagnostics); + if (sourcePlan.value !== undefined) value['source'] = sourcePlan.value; + const headers = declared['headers']; + if (headers !== undefined && sourcePlan.form !== 'archive') { diagnostics.push(marketplaceDiagnostic( - `claude.marketplace.plugin.${field}.inapplicable`, - `Claude Code applies marketplace ${field} only to archive sources, and the generated plugin entry uses the relative './' source, so strict native validation rejects it.`, - `Remove plugin.${field} until Agent Bundle emits URL-capable marketplace sources, then rebuild.`, + 'claude.marketplace.plugin.headers.inapplicable', + `Claude Code applies marketplace headers only to archive sources, not the ${sourcePlan.form ?? 'invalid'} source form.`, + 'Remove plugin.headers or change plugin.source to an archive source, then rebuild.', )); + } else if (headers !== undefined) { + if ( + !isPlainDataRecord(headers) || + Object.keys(headers).length === 0 || + !Object.entries(headers).every(([name, headerValue]) => + isNonemptyString(name) && isNonemptyString(headerValue)) + ) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.headers.invalid', + 'Claude marketplace archive headers must be a nonempty record of nonempty string values.', + 'Set plugin.headers to one or more nonempty HTTP header names and string values, then rebuild.', + )); + } else { + value['headers'] = headers; + } + } + const headersHelper = declared['headersHelper']; + if (headersHelper !== undefined && sourcePlan.form !== 'archive') { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.headersHelper.inapplicable', + `Claude Code applies marketplace headersHelper only to archive sources, not the ${sourcePlan.form ?? 'invalid'} source form.`, + 'Remove plugin.headersHelper or change plugin.source to an archive source, then rebuild.', + )); + } else if (headersHelper !== undefined) { + if (!isNonemptyString(headersHelper)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.headersHelper.invalid', + 'Claude marketplace archive headersHelper must be a nonempty command string.', + 'Set plugin.headersHelper to the command that prints one JSON header object, then rebuild.', + )); + } else if (declared['strict'] !== false) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.headersHelper.strict', + 'Claude Code requires a marketplace archive entry with headersHelper to set strict to false.', + 'Set plugin.strict to false beside plugin.headersHelper, then rebuild.', + )); + } else { + value['headersHelper'] = headersHelper; + } } const author = declared['author']; if (author !== undefined) { @@ -1294,7 +1740,12 @@ const planClaudeMarketplace = (model: NormalizedPlugin): ClaudeMarketplacePlan = } const pluginOverlay = declared['plugin']; if (pluginOverlay !== undefined) { - const planned = planMarketplacePlugin(pluginOverlay); + const marketplaceMetadata = document['metadata']; + const pluginRoot = isPlainDataRecord(marketplaceMetadata) && + typeof marketplaceMetadata['pluginRoot'] === 'string' + ? marketplaceMetadata['pluginRoot'] + : undefined; + const planned = planMarketplacePlugin(pluginOverlay, pluginRoot); diagnostics.push(...planned.diagnostics); if (planned.value !== undefined) { document['plugins'] = [{ ...basePlugin, ...planned.value }]; @@ -2712,7 +3163,19 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ capabilityTable.plugin.marketplaceManifest.entryMetadataFields.includes('strict') && capabilityTable.plugin.marketplaceManifest.entryRelevanceSignals.includes('manifestDeps') && capabilityTable.plugin.marketplaceManifest.generatedSourceForms.length === 1 && - capabilityTable.plugin.marketplaceManifest.generatedSourceForms[0] === 'relative', + capabilityTable.plugin.marketplaceManifest.generatedSourceForms[0] === 'relative' && + capabilityTable.plugin.marketplaceManifest.sourceMatrix.authoredForms.includes('relative') && + capabilityTable.plugin.marketplaceManifest.sourceMatrix.authoredForms.includes('github') && + capabilityTable.plugin.marketplaceManifest.sourceMatrix.authoredForms.includes('url') && + capabilityTable.plugin.marketplaceManifest.sourceMatrix.authoredForms.includes('git-subdir') && + capabilityTable.plugin.marketplaceManifest.sourceMatrix.authoredForms.includes('npm') && + capabilityTable.plugin.marketplaceManifest.sourceMatrix.authoredForms.includes('archive') && + capabilityTable.plugin.marketplaceManifest.sourceMatrix.authoredForms.includes('command') && + capabilityTable.plugin.marketplaceManifest.sourceMatrix.archiveIntegrity === 'sha256-64-hex' && + capabilityTable.plugin.marketplaceManifest.sourceMatrix.shaOverridesRef && + capabilityTable.plugin.marketplaceManifest.sourceMatrix.versionGates.archive === '2.1.224' && + capabilityTable.plugin.marketplaceManifest.sourceMatrix.versionGates.command === '2.1.229' && + capabilityTable.plugin.marketplaceManifest.sourceMatrix.versionGates.pluginRootBareName === '2.1.239', evidence, 'The pinned Claude plugin contract does not document the complete marketplace manifest surface.', ), diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 70941cc77..0ae9acc2f 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -186,7 +186,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.14.0', + adapterRevision: '1.15.0', observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`, // Metadata schemas must exactly match the validation contract: each host's // documents, with one shared Claude-format hook schema (the pinned Codex diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 578e0aa37..0eb080655 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -3,6 +3,7 @@ "retrievedAt": "2026-09-02", "schemaSource": "https://docs.anthropic.com/en/docs/claude-code/plugins", "notes": "lsp.schema.json and plugin.json's `lspServers` property were pinned from the Claude Code 2.1.x plugin reference LSP servers section (retrieved 2026-09-01), which documents `.lsp.json` at the plugin root or inline `lspServers` in the manifest, required `command` / `extensionToLanguage`, and the optional `args`, `transport`, `env`, `initializationOptions`, `settings`, `workspaceFolder`, `startupTimeout`, `shutdownTimeout`, `restartOnCrash`, `maxRestarts`, and `diagnostics` fields. `restartOnCrash` and `shutdownTimeout` require Claude Code v2.1.205 or later, which the pinned 2.1.250 revision satisfies. Manifest `lspServers` keeps the documented `string|array|object` union rather than being narrowed to the one emitted form the way `hooks` is; the emitted document itself is `.lsp.json` at the plugin root. Two agent-bundle tightenings over the documented text: a server map and an `extensionToLanguage` map must both be nonempty, because an empty map claims no extension and can never start a server. The current hooks reference at https://code.claude.com/docs/en/hooks supplies the SubagentStart/SubagentStop wire and decision evidence recorded in claude-2.1.250.json. settings.schema.json was pinned (retrieved 2026-09-01) from the \"Ship default settings with your plugin\" section of https://code.claude.com/docs/en/plugins and the file-locations row of https://code.claude.com/docs/en/plugins-reference, which bound the plugin-root settings.json to the `agent` and `subagentStatusLine` keys, plus https://code.claude.com/docs/en/statusline for the subagentStatusLine command-object shape. Three agent-bundle tightenings over the documented text: the closed schema rejects the unknown keys the host \"silently ignores\", so a requested default never disappears at runtime; minProperties 1 rejects an empty settings.json, which declares no default configuration at all; and subagentStatusLine admits only the two fields its own examples show (`type` and `command`) - statusLine's optional `padding` is documented for the user status line, never for the plugin default, so it stays out of the pinned shape. The plugins-reference placeholder table (\"Which fields substitute them inline depends on the plugin component\") lists Skill and agent content, hook and monitor commands, MCP servers, and LSP servers but not settings.json, so the adapter rejects Agent Bundle path tokens in settings values rather than emitting a placeholder Claude Code never resolves. plugin.json's `userConfig` property and closed `userConfigOption` definition were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle deliberately tightens the documented \"valid identifiers\" requirement to `^[A-Za-z_][A-Za-z0-9_]*$`, rejects option keys that collide after uppercasing because both would map to one `CLAUDE_PLUGIN_OPTION_` environment variable, requires the userConfig record to be nonempty, and rejects `sensitive: true` with `default` because a baked-in secure-storage default would ship a secret in the manifest. plugin.schema.json's `dependencies` property was pinned (retrieved 2026-09-01) from https://code.claude.com/docs/en/plugin-dependencies and the manifest schema in https://code.claude.com/docs/en/plugins-reference: a nonempty array whose entries are nonempty plugin-name strings or closed objects with required name and optional version and marketplace strings. Agent Bundle tightens dependency names to the manifest's existing lowercase kebab-case name pattern, rejects an empty array, and closes object fields so malformed declarations fail before distribution; semver range grammar remains plan-time validation because JSON Schema cannot honestly encode npm range syntax. plugin.schema.json's `displayName`, `metadata`, and `defaultEnabled` properties were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle tightens Claude Code's warning-and-ignore handling for a non-object `metadata` value into build error claude.manifest.metadata.invalid, rejects an empty or whitespace-only `displayName` with claude.manifest.displayName.invalid, and rejects a non-boolean `defaultEnabled` with claude.manifest.defaultEnabled.invalid. The normalized generic model currently carries description but not homepage, repository, license, keywords, or `$schema`, so this slice deliberately emits only the three new Claude host-config fields and does not widen the generic model. Component path fields are deliberately excluded from the emitted schema and config surface: the generator owns the canonical default commands/, skills/, hooks/hooks.json, .mcp.json, .lsp.json, settings.json, workflows/, and output-styles/ layout, while custom replace/add path rules remain documented host-discovery evidence in claude-2.1.250.json. plugin.json's `channels` property was pinned from the Channels section of https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01): a nonempty array of closed declarations with required nonempty `server` and optional per-channel `userConfig` reusing the top-level option definition. Agent Bundle tightens the documented contract by rejecting an empty channels array, empty per-channel userConfig, unknown channel fields, invalid or uppercase-colliding option identifiers, and any server name absent from the MCP server map successfully planned for the selected target. Duplicate channel declarations for one server remain allowed and preserve authored order because the reference imposes no uniqueness rule. Per-channel `sensitive: true` follows the top-level secure-storage semantics because the reference says the channel field uses the same schema; the existing prohibition on sensitive defaults therefore applies unchanged. Claude Code 2.1.257 strict validation accepts both valid bindings and deliberately dangling server names, so claude.channels.server.unknown is an intentional compiler tightening and the only pre-enable cross-document binding guard. Workflows and output styles deliberately reuse the bin slice's normalized directory/file payload shape and byte-faithful copy entries, but retain separate registry hooks, normalized fields, destination paths, and diagnostics so the executable policy cannot leak into non-executable components. The shared enumerator preserves source file modes through copy entries and rejects lexical or realpath escapes, including a configured directory symlink that resolves outside the project. The adapter emits only the canonical plugin-root workflows/ and output-styles/ directories, so it does not emit the optional `workflows` or `outputStyles` manifest path fields and leaves plugin.schema.json plus its SHA-256 pin unchanged. https://code.claude.com/docs/en/output-styles (retrieved 2026-09-01) explicitly defines output styles as Markdown, so Agent Bundle tightens the directory to `.md` files with claude.outputStyles.file.invalid. It does not validate frontmatter: `name` is optional because the filename supplies it, other documented fields are optional, and Claude Code 2.1.257 strict plugin validation accepts a Markdown output style with no frontmatter. The plugins reference gives workflow scripts no deeper file schema, so workflow file contents and suffixes remain opaque. monitors.schema.json and theme.schema.json were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01), which marks both components experimental and warns their manifest schema may change between releases. The monitor schema is a nonempty array of closed entries requiring nonempty unique name, command, and description; optional when admits only always or on-skill-invoke:, and the plan tightens the latter by requiring the named skill to be emitted by this plugin. The schema and plan both reject ${user_config.*} in monitor commands because Claude Code rejects it at shell execution time and supplies no CLAUDE_PLUGIN_OPTION_ variables to monitors. The theme schema is closed, requires a nonempty base, and admits optional nonempty name plus a sparse string-valued overrides map. Agent Bundle further requires a nonempty themes declaration, safe file-stem keys, defaults omitted name to that key, and rejects empty override strings; it deliberately accepts any nonempty color string because the reference shows hex examples but does not mandate hex syntax. Generated themes and monitors use the default locations, so no unstable experimental.* manifest fields are emitted. Local proof against Claude Code 2.1.257 shows strict validation accepts valid emitted themes and monitors but does not inspect either default-location document's contents: malformed themes missing base and carrying non-string overrides pass, as do monitors missing command. The pinned schemas and compiler diagnostics are therefore the content guard. The same strict host rejects the deprecated top-level monitors manifest key, so default-location emission also avoids the warning-to-error migration path. The artifact contract records themes/*.json as a schema family and validates every concrete generated theme path. marketplace.schema.json was re-pinned from https://code.claude.com/docs/en/plugin-marketplaces, https://code.claude.com/docs/en/plugins-reference, https://code.claude.com/docs/en/plugin-dependencies, and the linked relevance contract (retrieved 2026-09-02). It loosens the old generated-only snapshot by making top-level description and plugin-entry description/version optional, allowing documented owner email/url, top-level $schema/version/metadata/renames/allowCrossMarketplaceDependenciesOn, and every documented plugin-entry metadata, component, relevance, authentication, dependency, channel, userConfig, settings, and experimental field; plugin-entry version is now any nonempty documented version string rather than generated-only semver. It deliberately retains only the relative ./ source form: github, git URL, git-subdir, npm, archive, and command sources are deferred to the source-matrix follow-up. The authored overlay is narrower than the validation schema and does not expose plugin name/source or component routing, because generated identity/layout remains authoritative and the agents component remains gate-deferred. Agent Bundle tightens the prose contract by closing every structured object (free-form metadata uses recursive patternProperties with additionalProperties false), requiring nonempty arrays/maps where an empty declaration has no effect, requiring unique kebab-case cross-marketplace allowlist names and kebab-case rename keys/targets, rejecting the documented exact reserved marketplace names, requiring relative sources and component paths to stay inside the marketplace, limiting owner/author URLs to absolute HTTP(S), requiring metadata.pluginRoot to be an internal ./ path, requiring nonempty header names/values, and requiring explicit strict:false with headersHelper. Relevance enforces the documented topic, item-count, character-count, bare-hostname, and regular-expression bounds; Claude Code 2.1.257 strict validation does not reject an empty allowCrossMarketplaceDependenciesOn entry. The same native validator warns that headers and headersHelper have no effect on the generated relative source and promotes that warning to failure under --strict, so the authored overlay rejects both fields with claude.marketplace.plugin.headers.inapplicable and claude.marketplace.plugin.headersHelper.inapplicable rather than emitting a manifest the host's strict validation refuses; the pinned schema keeps the documented fields, including the strict:false requirement beside headersHelper, for the URL-capable source-matrix follow-up.", + "sourceMatrixNotes": "Source-matrix addendum (retrieved 2026-09-02): marketplace.schema.json now admits the documented relative, github, git URL, git-subdir, npm, archive, and command plugin sources as closed alternatives. Agent Bundle tightens relative paths and git-subdir paths against lexical escapes; GitHub shorthand to one owner/repo pair; git URLs to HTTPS or git@ forms; npm names to npm's lowercase package grammar and npm versions to exact, ^, or ~ semantic versions; registry URLs to HTTP(S); archive URLs to HTTPS with lexical rejection for localhost, IPv4/IPv6 loopback, 169.254/16 and fe80::/10 link-local addresses, and known AWS, Azure, and Google metadata hostnames; git sha to 40 hex; archive sha256 to 64 case-insensitive hex; command text to 1-500 printable ASCII characters without four-space runs; timeout to integer seconds from 1 through 600; and mode to copy or link. Entry headers remain a nonempty record of nonempty string values, and headersHelper requires a nonempty command plus strict:false. These checks do not resolve DNS, follow redirects, contact registries or repositories, download archives, verify archive bytes, execute command sources, inspect produced directories, or attempt to predict Windows link-mode support; those remain native install-time behavior.", "schemas": { "hooks.schema.json": { "bytes": 1108, @@ -15,8 +16,8 @@ "url": "https://docs.anthropic.com/en/docs/claude-code/plugins" }, "marketplace.schema.json": { - "bytes": 9920, - "sha256": "2d01f4965ed671d91e8bd2c26fff6f30d49373f4daf51465a98fc46179600cab", + "bytes": 14865, + "sha256": "44e105038ced3fceee4cb7ff81c7caad63965e3fd5a42b6db6255da771236b5d", "url": "https://code.claude.com/docs/en/plugin-marketplaces" }, "mcp.schema.json": { diff --git a/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json index 9609a7354..776a94e2b 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json @@ -128,6 +128,117 @@ "required": ["signals"], "type": "object" }, + "pluginSource": { + "oneOf": [ + { + "oneOf": [ + { + "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))\\./", + "type": "string" + }, + { + "pattern": "^[^/\\\\]+$", + "type": "string" + } + ] + }, + { "$ref": "#/$defs/pluginSourceGithub" }, + { "$ref": "#/$defs/pluginSourceUrl" }, + { "$ref": "#/$defs/pluginSourceGitSubdir" }, + { "$ref": "#/$defs/pluginSourceNpm" }, + { "$ref": "#/$defs/pluginSourceArchive" }, + { "$ref": "#/$defs/pluginSourceCommand" } + ] + }, + "pluginSourceArchive": { + "additionalProperties": false, + "properties": { + "sha256": { "pattern": "^[0-9A-Fa-f]{64}$", "type": "string" }, + "source": { "const": "archive" }, + "url": { + "format": "uri", + "pattern": "^https://(?!(?:localhost|[^/]+\\.localhost|127(?:\\.[0-9]{1,3}){3}|169\\.254(?:\\.[0-9]{1,3}){2}|metadata(?:\\.google(?:\\.internal)?)?|metadata\\.azure\\.internal|instance-data\\.ec2\\.internal)(?::[0-9]+)?/)", + "type": "string" + } + }, + "required": ["source", "url"], + "type": "object" + }, + "pluginSourceCommand": { + "additionalProperties": false, + "properties": { + "command": { + "maxLength": 500, + "minLength": 1, + "pattern": "^(?!.* {4})[ -~]+$", + "type": "string" + }, + "mode": { "enum": ["copy", "link"] }, + "source": { "const": "command" }, + "timeout": { "maximum": 600, "minimum": 1, "type": "integer" } + }, + "required": ["source", "command"], + "type": "object" + }, + "pluginSourceGitSubdir": { + "additionalProperties": false, + "properties": { + "path": { + "minLength": 1, + "pattern": "^(?![\\\\/])(?!\\.\\.?$)(?!.*[\\\\/]\\.\\.?$)(?!.*[\\\\/]\\.\\.?(?:[\\\\/])).+$", + "type": "string" + }, + "ref": { "minLength": 1, "type": "string" }, + "sha": { "pattern": "^[0-9A-Fa-f]{40}$", "type": "string" }, + "source": { "const": "git-subdir" }, + "url": { + "pattern": "^(?:https://\\S+|git@[^:\\s]+:\\S+|[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?/[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?)$", + "type": "string" + } + }, + "required": ["source", "url", "path"], + "type": "object" + }, + "pluginSourceGithub": { + "additionalProperties": false, + "properties": { + "ref": { "minLength": 1, "type": "string" }, + "repo": { "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?/[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?$", "type": "string" }, + "sha": { "pattern": "^[0-9A-Fa-f]{40}$", "type": "string" }, + "source": { "const": "github" } + }, + "required": ["source", "repo"], + "type": "object" + }, + "pluginSourceNpm": { + "additionalProperties": false, + "properties": { + "package": { + "maxLength": 214, + "pattern": "^(?:@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$", + "type": "string" + }, + "registry": { "format": "uri", "pattern": "^https?://", "type": "string" }, + "source": { "const": "npm" }, + "version": { + "pattern": "^(?:\\^|~)?(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", + "type": "string" + } + }, + "required": ["source", "package"], + "type": "object" + }, + "pluginSourceUrl": { + "additionalProperties": false, + "properties": { + "ref": { "minLength": 1, "type": "string" }, + "sha": { "pattern": "^[0-9A-Fa-f]{40}$", "type": "string" }, + "source": { "const": "url" }, + "url": { "pattern": "^(?:https://\\S+|git@[^:\\s]+:\\S+)$", "type": "string" } + }, + "required": ["source", "url"], + "type": "object" + }, "userConfigOption": { "additionalProperties": false, "properties": { @@ -152,6 +263,29 @@ "type": "object" } }, + "allOf": [ + { + "if": { + "properties": { + "plugins": { + "contains": { + "properties": { + "source": { "pattern": "^[^/\\\\]+$", "type": "string" } + }, + "required": ["source"] + } + } + }, + "required": ["plugins"] + }, + "then": { + "properties": { + "metadata": { "required": ["pluginRoot"] } + }, + "required": ["metadata"] + } + } + ], "additionalProperties": false, "properties": { "$schema": { "format": "uri", "pattern": "^https?://", "type": "string" }, @@ -185,6 +319,23 @@ "properties": { "strict": { "const": false } }, "required": ["strict"] } + }, + { + "if": { + "anyOf": [ + { "required": ["headers"] }, + { "required": ["headersHelper"] } + ] + }, + "then": { + "properties": { + "source": { + "properties": { "source": { "const": "archive" } }, + "required": ["source"], + "type": "object" + } + } + } } ], "additionalProperties": false, @@ -255,10 +406,7 @@ "repository": { "format": "uri", "pattern": "^https?://", "type": "string" }, "settings": { "$ref": "#/$defs/freeformObject" }, "skills": { "$ref": "#/$defs/componentPath" }, - "source": { - "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))\\./", - "type": "string" - }, + "source": { "$ref": "#/$defs/pluginSource" }, "strict": { "type": "boolean" }, "tags": { "$ref": "#/$defs/nonemptyStrings" }, "userConfig": { diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index bb8c29d50..dea954b5c 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -369,6 +369,21 @@ it.each([ expect(registry.supports('plugin', capability)).toBe(false); }); +it('pins the authored Claude marketplace source matrix and version gates', () => { + expect(claudeCapabilityTable.plugin.marketplaceManifest.sourceMatrix).toEqual({ + archiveIntegrity: 'sha256-64-hex', + authoredForms: ['relative', 'github', 'url', 'git-subdir', 'npm', 'archive', 'command'], + generatedDefault: 'relative', + gitPinFields: ['ref', 'sha'], + shaOverridesRef: true, + versionGates: { + archive: '2.1.224', + command: '2.1.229', + pluginRootBareName: '2.1.239', + }, + }); +}); + it.each([ ['manifestMetadata', 'manifest metadata fields'], ['manifestPaths', 'custom manifest path rules'], diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index da9067844..d0757290a 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -93,7 +93,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'claude')).toEqual({ - adapterRevision: '1.15.0', + adapterRevision: '1.16.0', observedVersion: '2.1.250', schemas: [ { @@ -109,7 +109,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'marketplace', revision: '2.1.250', - sha256: '2d01f4965ed671d91e8bd2c26fff6f30d49373f4daf51465a98fc46179600cab', + sha256: '44e105038ced3fceee4cb7ff81c7caad63965e3fd5a42b6db6255da771236b5d', }, { name: 'mcp', @@ -164,7 +164,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.14.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.15.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 96f272148..ab017a2f4 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -1909,7 +1909,12 @@ it('validates an enriched Claude marketplace against the full closed pinned cont metadata: { catalogId: 'claude-marketplace-artifact' }, name: 'claude-marketplace-artifact', relevance: { signals: { hosts: ['api.example.test'] } }, - source: './', + source: { + repo: 'acme/claude-marketplace-artifact', + ref: 'main', + sha: 'a'.repeat(40), + source: 'github', + }, strict: true, }], renames: { 'legacy-marketplace-artifact': 'claude-marketplace-artifact' }, @@ -1929,7 +1934,7 @@ it('validates an enriched Claude marketplace against the full closed pinned cont { contents: `${JSON.stringify({ ...marketplace, - plugins: [{ ...marketplace.plugins[0], source: { repo: 'acme/plugin', source: 'github' } }], + plugins: [{ ...marketplace.plugins[0], source: { repo: 'not-owner-repo', source: 'github' } }], })}\n`, kind: 'generated' as const, path: marketplacePath, diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index fa2bf541e..0f229cdb1 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -332,6 +332,160 @@ nativeIt('accepts the enriched Claude marketplace under strict native validation } }); +nativeIt('accepts every documented Claude marketplace plugin source form', async () => { + const sources = [ + { + label: 'github', + plugin: { + source: { + source: 'github', + repo: 'acme/review-tools', + ref: 'v1.2.3', + sha: 'a'.repeat(40), + }, + }, + }, + { + label: 'git-url', + plugin: { + source: { + source: 'url', + url: 'https://git.example.test/acme/review-tools.git', + ref: 'main', + sha: 'b'.repeat(40), + }, + }, + }, + { + label: 'git-subdir', + plugin: { + source: { + source: 'git-subdir', + url: 'acme/monorepo', + path: 'plugins/review-tools', + ref: 'main', + sha: 'c'.repeat(40), + }, + }, + }, + { + label: 'npm', + plugin: { + source: { + source: 'npm', + package: '@acme/review-tools', + version: '^1.2.3', + registry: 'https://npm.example.test', + }, + }, + }, + { + label: 'archive', + plugin: { + headers: { Authorization: 'Bearer native-test-token' }, + headersHelper: 'printf \'{}\'', + source: { + source: 'archive', + url: 'https://artifacts.example.test/review-tools.zip', + sha256: 'd'.repeat(64), + }, + strict: false, + }, + }, + { + label: 'command-copy', + plugin: { + source: { + source: 'command', + command: 'review-tools plugin-path', + timeout: 120, + mode: 'copy', + }, + }, + }, + { + label: 'command-link', + plugin: { + source: { + source: 'command', + command: 'review-tools plugin-path', + mode: 'link', + }, + }, + }, + ] as const; + + for (const { label, plugin } of sources) { + const root = await mkdtemp(join(tmpdir(), `agent-bundle-claude-source-${label}-`)); + try { + await writeClaudeArtifact(root, withClaudeMarketplace({ plugin })); + const validation = await runClaudeValidation( + root, + join(root, '.claude-plugin', 'marketplace.json'), + ); + + expect(validation.code, `${label}: ${validation.output}`).toBe(0); + expect(validation.output).toContain('Validation passed'); + } finally { + await rm(root, { force: true, recursive: true }); + } + } +}); + +nativeIt('records which source constraints strict Claude marketplace validation enforces', async () => { + const cases = [ + { + expectedCode: 1, + label: 'short github sha', + output: 'full 40-character lowercase git commit SHA', + source: { source: 'github', repo: 'acme/review-tools', sha: 'abc123' }, + }, + { + expectedCode: 1, + label: 'insecure archive URL', + output: 'Archive URLs must use https://', + source: { source: 'archive', url: 'http://artifacts.example.test/review-tools.zip' }, + }, + { + expectedCode: 0, + label: 'unreachable archive URL', + output: 'Validation passed', + source: { source: 'archive', url: 'https://does-not-exist.invalid/review-tools.zip' }, + }, + { + expectedCode: 0, + label: 'failing command', + output: 'Validation passed', + source: { source: 'command', command: 'exit 9' }, + }, + { + expectedCode: 1, + label: 'unknown command mode', + output: 'plugins.0.source: Invalid input', + source: { source: 'command', command: 'review-tools plugin-path', mode: 'move' }, + }, + ] as const; + + for (const { expectedCode, label, output, source } of cases) { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-source-negative-')); + try { + await writeClaudeArtifact(root, model); + const marketplacePath = join(root, '.claude-plugin', 'marketplace.json'); + const marketplace = JSON.parse(await readFile(marketplacePath, 'utf8')) as { + plugins: Record[]; + }; + marketplace.plugins[0]!['source'] = source; + await writeFile(marketplacePath, `${JSON.stringify(marketplace)}\n`); + const validation = await runClaudeValidation(root, marketplacePath); + + expect(validation.code, `${label}: ${validation.output}`).toBe(expectedCode); + expect(validation.output).toContain(output); + } finally { + await rm(root, { force: true, recursive: true }); + } + } +}); + nativeIt('records whether strict native validation enforces marketplace allowlist entry names', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-marketplace-allowlist-invalid-')); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index d91f2d804..7053cbbfa 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -643,6 +643,68 @@ it('enriches the generated Claude marketplace with the complete authored catalog await validateDocuments('claude', writeContents(model, 'claude')); }); +it.each([ + ['./plugins/review-tools', undefined], + ['review-tools', { pluginRoot: './plugins' }], + [{ source: 'github', repo: 'acme/review-tools', ref: 'v1', sha: 'a'.repeat(40) }, undefined], + [{ source: 'url', url: 'git@example.test:acme/review-tools.git', ref: 'main', sha: 'b'.repeat(40) }, undefined], + [{ + source: 'git-subdir', + url: 'acme/review-tools', + path: 'plugins/review-tools', + ref: 'main', + sha: 'c'.repeat(40), + }, undefined], + [{ + source: 'npm', + package: '@acme/review-tools', + version: '^1.2.3', + registry: 'https://npm.example.test', + }, undefined], + [{ + source: 'archive', + url: 'https://artifacts.example.test/review-tools.zip', + sha256: 'd'.repeat(64), + }, undefined], + [{ source: 'command', command: 'review-tools plugin-path', timeout: 120, mode: 'copy' }, undefined], + [{ source: 'command', command: 'review-tools plugin-path', mode: 'link' }, undefined], +] as const)('emits an authored Claude marketplace plugin source %#', (source, metadata) => { + const marketplace = { + ...(metadata === undefined ? {} : { metadata }), + plugin: { source }, + }; + const document = writeContents(withClaudeMarketplace(plugin, marketplace), 'claude'); + + expect(JSON.parse(document['.claude-plugin/marketplace.json']!)).toMatchObject({ + plugins: [{ source }], + }); +}); + +it('accepts archive entry authentication only for an archive source', () => { + const source = { + source: 'archive', + url: 'https://artifacts.example.test/review-tools.zip', + sha256: 'e'.repeat(64), + }; + const document = writeContents(withClaudeMarketplace(plugin, { + plugin: { + headers: { Authorization: 'Bearer catalog-token' }, + headersHelper: '/opt/bin/mint-plugin-token', + source, + strict: false, + }, + }), 'claude'); + + expect(JSON.parse(document['.claude-plugin/marketplace.json']!)).toMatchObject({ + plugins: [{ + headers: { Authorization: 'Bearer catalog-token' }, + headersHelper: '/opt/bin/mint-plugin-token', + source, + strict: false, + }], + }); +}); + it.each([ { code: 'claude.marketplace.declaration.invalid', @@ -678,7 +740,7 @@ it.each([ }, { code: 'claude.marketplace.plugin.field.unknown', - marketplace: { plugin: { source: { source: 'github', repo: 'acme/review-tools' } } }, + marketplace: { plugin: { unknown: true } }, }, { code: 'claude.marketplace.plugin.headers.inapplicable', @@ -688,6 +750,106 @@ it.each([ code: 'claude.marketplace.plugin.headersHelper.inapplicable', marketplace: { plugin: { headersHelper: './scripts/headers.sh', strict: false } }, }, + { + code: 'claude.marketplace.plugin.source.invalid', + marketplace: { plugin: { source: [] } }, + }, + { + code: 'claude.marketplace.plugin.source.relative.invalid', + marketplace: { plugin: { source: './../outside' } }, + }, + { + code: 'claude.marketplace.plugin.source.relative.invalid', + marketplace: { plugin: { source: 'review-tools' } }, + }, + { + code: 'claude.marketplace.plugin.source.form.invalid', + marketplace: { plugin: { source: { source: 'other' } } }, + }, + { + code: 'claude.marketplace.plugin.source.field.unknown', + marketplace: { plugin: { source: { source: 'github', repo: 'acme/review-tools', depth: 1 } } }, + }, + { + code: 'claude.marketplace.plugin.source.repo.invalid', + marketplace: { plugin: { source: { source: 'github', repo: 'not-a-repository' } } }, + }, + { + code: 'claude.marketplace.plugin.source.url.invalid', + marketplace: { plugin: { source: { source: 'url', url: 'ftp://example.test/plugin.git' } } }, + }, + { + code: 'claude.marketplace.plugin.source.path.invalid', + marketplace: { plugin: { source: { source: 'git-subdir', url: 'acme/repo', path: '../plugin' } } }, + }, + { + code: 'claude.marketplace.plugin.source.ref.invalid', + marketplace: { plugin: { source: { source: 'github', repo: 'acme/repo', ref: '' } } }, + }, + { + code: 'claude.marketplace.plugin.source.sha.invalid', + marketplace: { plugin: { source: { source: 'github', repo: 'acme/repo', sha: 'abc123' } } }, + }, + { + code: 'claude.marketplace.plugin.source.package.invalid', + marketplace: { plugin: { source: { source: 'npm', package: '@acme' } } }, + }, + { + code: 'claude.marketplace.plugin.source.version.invalid', + marketplace: { plugin: { source: { source: 'npm', package: '@acme/review-tools', version: 'latest' } } }, + }, + { + code: 'claude.marketplace.plugin.source.registry.invalid', + marketplace: { plugin: { source: { source: 'npm', package: '@acme/review-tools', registry: 'npm.example.test' } } }, + }, + { + code: 'claude.marketplace.plugin.source.url.invalid', + marketplace: { plugin: { source: { source: 'archive', url: 'http://artifacts.example.test/plugin.zip' } } }, + }, + { + code: 'claude.marketplace.plugin.source.url.invalid', + marketplace: { plugin: { source: { source: 'archive', url: 'https://169.254.169.254/plugin.zip' } } }, + }, + { + code: 'claude.marketplace.plugin.source.sha256.invalid', + marketplace: { plugin: { source: { source: 'archive', url: 'https://example.test/plugin.zip', sha256: 'abc123' } } }, + }, + { + code: 'claude.marketplace.plugin.source.command.invalid', + marketplace: { plugin: { source: { source: 'command', command: ' ' } } }, + }, + { + code: 'claude.marketplace.plugin.source.timeout.invalid', + marketplace: { plugin: { source: { source: 'command', command: 'plugin-path', timeout: 601 } } }, + }, + { + code: 'claude.marketplace.plugin.source.mode.invalid', + marketplace: { plugin: { source: { source: 'command', command: 'plugin-path', mode: 'move' } } }, + }, + { + code: 'claude.marketplace.plugin.headers.invalid', + marketplace: { plugin: { source: { source: 'archive', url: 'https://example.test/plugin.zip' }, headers: {} } }, + }, + { + code: 'claude.marketplace.plugin.headersHelper.invalid', + marketplace: { + plugin: { + source: { source: 'archive', url: 'https://example.test/plugin.zip' }, + headersHelper: '', + strict: false, + }, + }, + }, + { + code: 'claude.marketplace.plugin.headersHelper.strict', + marketplace: { + plugin: { + source: { source: 'archive', url: 'https://example.test/plugin.zip' }, + headersHelper: 'mint-token', + strict: true, + }, + }, + }, { code: 'claude.marketplace.plugin.relevance.invalid', marketplace: { plugin: { relevance: { signals: {} } } }, @@ -709,7 +871,7 @@ it.each([ expect(document['.claude-plugin/marketplace.json']).toBeUndefined(); }); -it('pins the full closed Claude marketplace schema while retaining relative sources', async () => { +it('pins the full closed Claude marketplace schema with the documented source matrix', async () => { const schema = (await import('../src/adapters/schemas/claude/marketplace.schema.json', { with: { type: 'json' }, })).default; @@ -723,19 +885,39 @@ it('pins the full closed Claude marketplace schema while retaining relative sour headersHelper: './scripts/headers.sh', name: 'review-tools', relevance: { signals: { hosts: ['api.example.test'] }, topic: 'Review' }, - source: './', + source: { source: 'archive', url: 'https://artifacts.example.test/review-tools.zip' }, strict: false, }], }; expect(validate(manifest), JSON.stringify(validate.errors)).toBe(true); + const sourcePlugin = { + name: manifest.plugins[0].name, + relevance: manifest.plugins[0].relevance, + }; + for (const source of [ + './plugins/review-tools', + { source: 'github', repo: 'acme/review-tools', ref: 'main', sha: 'a'.repeat(40) }, + { source: 'url', url: 'https://git.example.test/acme/review-tools.git', sha: 'b'.repeat(40) }, + { source: 'git-subdir', url: 'acme/monorepo', path: 'plugins/review-tools' }, + { source: 'npm', package: '@acme/review-tools', version: '~1.2.3', registry: 'https://npm.example.test' }, + { source: 'archive', url: 'https://artifacts.example.test/review-tools.zip', sha256: 'c'.repeat(64) }, + { source: 'command', command: 'review-tools plugin-path', timeout: 60, mode: 'link' }, + ]) { + expect(validate({ + ...manifest, + plugins: [{ ...sourcePlugin, source }], + }), JSON.stringify(validate.errors)).toBe(true); + } for (const invalid of [ { ...manifest, unknown: true }, { ...manifest, owner: { ...manifest.owner, unknown: true } }, { ...manifest, plugins: [{ ...manifest.plugins[0], unknown: true }] }, { ...manifest, plugins: [{ ...manifest.plugins[0], source: 'review-tools' }] }, { ...manifest, plugins: [{ ...manifest.plugins[0], source: './../outside' }] }, - { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'github', repo: 'acme/review-tools' } }] }, + { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'github', repo: 'acme/review-tools', extra: true } }] }, + { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'archive', url: 'https://example.test/plugin.zip', sha256: 'bad' } }] }, + { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'command', command: 'plugin-path', mode: 'move' } }] }, { ...manifest, plugins: [{ ...manifest.plugins[0], relevance: { signals: { unknown: ['value'] } } }] }, ]) { expect(validate(invalid)).toBe(false); diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index d5d71145c..a48210971 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -184,6 +184,11 @@ it('keeps the Claude marketplace overlay host-specific in the unified bundle', ( owner: { email: 'plugins@example.test' }, plugin: { relevance: { signals: { hosts: ['api.example.test'] } }, + source: { + package: '@acme/bundle-example', + source: 'npm', + version: '^1.0.0', + }, strict: true, }, renames: { 'legacy-bundle-example': 'bundle-example' }, @@ -202,7 +207,11 @@ it('keeps the Claude marketplace overlay host-specific in the unified bundle', ( plugins: [{ name: 'bundle-example', relevance: { signals: { hosts: ['api.example.test'] } }, - source: './', + source: { + package: '@acme/bundle-example', + source: 'npm', + version: '^1.0.0', + }, strict: true, }], renames: { 'legacy-bundle-example': 'bundle-example' },