diff --git a/.changeset/plugin-logo-cursor.md b/.changeset/plugin-logo-cursor.md new file mode 100644 index 000000000..6715128c1 --- /dev/null +++ b/.changeset/plugin-logo-cursor.md @@ -0,0 +1,7 @@ +--- +"agent-bundle": minor +--- + +Add optional `plugin.logo` so Cursor artifacts can emit a `logo` field. + +The path is validated at build time (AB4012) and copied into the artifact; Cursor `.cursor-plugin/plugin.json` references it relatively. Claude and Codex manifests still have no icon field, so they omit it on purpose. Artifact validation fails with AB6025 when a declared logo is missing from the deploy tree. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 098b8b195..352b8cdcc 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -11,7 +11,7 @@ gate a build, a validation, or a dev rebuild. | Family | Area | | --- | --- | | `AB30xx` | Skill documents: Markdown parsing (`AB3000`–`AB3002`: unreadable, missing or malformed frontmatter) and rendered-skill compilation (`AB3003`: module failed to load, `AB3004`: missing/invalid default component or `frontmatter` export, `AB3005`: content outside the supported Markdown element subset). | -| `AB40xx` | Plugin metadata and Skill source validation. | +| `AB40xx` | Plugin metadata and Skill source validation (`AB4000`/`AB4001`: missing name/version; `AB4002`–`AB4007`: Skill fields; `AB4008`–`AB4011`: package identity; `AB4012`: declared `plugin.logo` is missing, not a file, or outside the project). | | `AB41xx` | Normalized model invariants (unknown targets, duplicate IDs and outputs). | | `AB42xx` | Hook configuration and native hook sources. | | `AB43xx` | MCP server and MCP App configuration. | @@ -24,6 +24,7 @@ gate a build, a validation, or a dev rebuild. | `AB473x` | Migration nudges (informational; see below). | | `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). | | `AB5000` | General CLI and adapter failures. | +| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree). | | `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. | | `AB7xxx` | Project preparation and development rebuilds. | | `AB7300`–`AB7315` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, and runtime endpoint health. | diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index dba298cf4..5a70346f1 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -50,6 +50,7 @@ import { type TargetArtifactLayout, type TargetArtifactPlan, } from './types.ts'; +import { pluginLogoManifestRef, withPluginLogoEntry } from './plugin-logo.ts'; import { withInstallSurface } from '../install/surface.ts'; const cursorName = 'cursor'; @@ -256,6 +257,7 @@ export const cursorManifest = ( description: model.metadata.description ?? model.metadata.name, displayName: model.metadata.name, ...(pointers.hooks === undefined ? {} : { hooks: pointers.hooks }), + ...(model.metadata.logo === undefined ? {} : { logo: pluginLogoManifestRef(model.metadata.logo.path) }), ...(pointers.mcp === undefined ? {} : { mcpServers: pointers.mcp }), name: model.metadata.name, ...(pointers.rules === undefined ? {} : { rules: pointers.rules }), @@ -406,6 +408,7 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan additionalPluginSourceInputs: [ ...selectedCommands.map((command) => command.source), ...selectedRules.map((rule) => rule.source), + ...(model.metadata.logo === undefined ? [] : [model.metadata.logo.source]), ], diagnostics, hookDocument, @@ -426,12 +429,12 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan }); return withInstallSurface(Object.freeze({ ...basePlan, - entries: sortedEntries([ + entries: sortedEntries(withPluginLogoEntry([ ...basePlan.entries, ...commandWriteEntries(model, isSelected, (command) => command.markdown === command.body ? command.markdown : command.body), ...ruleWriteEntries(model, isSelected), - ]), + ], model)), }), model, 'cursor'); }; diff --git a/packages/agent-bundle/src/adapters/plugin-logo.ts b/packages/agent-bundle/src/adapters/plugin-logo.ts new file mode 100644 index 000000000..b78eb156e --- /dev/null +++ b/packages/agent-bundle/src/adapters/plugin-logo.ts @@ -0,0 +1,28 @@ +import type { NormalizedPlugin } from '../core/types.ts'; +import { sourceInputs, type TargetArtifactCopy, type TargetArtifactEntry } from './types.ts'; + +export const pluginLogoManifestRef = (artifactPath: string): string => + artifactPath.startsWith('./') ? artifactPath : `./${artifactPath}`; + +export const pluginLogoCopyEntry = (model: NormalizedPlugin): TargetArtifactCopy | undefined => { + const logo = model.metadata.logo; + if (logo === undefined) return undefined; + return { + bytes: logo.bytes, + kind: 'copy', + relativePath: logo.path, + source: logo.source, + sourceInputs: sourceInputs(logo.source, model.metadata.provenance.sourcePath), + }; +}; + +export const withPluginLogoEntry = ( + entries: readonly TargetArtifactEntry[], + model: NormalizedPlugin, +): TargetArtifactEntry[] => { + const logoEntry = pluginLogoCopyEntry(model); + if (logoEntry === undefined || entries.some((entry) => entry.relativePath === logoEntry.relativePath)) { + return [...entries]; + } + return [...entries, logoEntry]; +}; diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 5b18b4580..fa587fc20 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -34,6 +34,7 @@ import { planCursorMarketplace, planCursorMcpServer, } from './cursor.ts'; +import { pluginLogoCopyEntry } from './plugin-logo.ts'; import { encodeNativeHookPlaygroundInput, encodeNativeHookPlaygroundOutput, @@ -445,8 +446,13 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { model.metadata.provenance.sourcePath, ...targetSourceInputs, ...selectedRules.map((rule) => rule.source), + model.metadata.logo?.source, ), }); + const logoEntry = pluginLogoCopyEntry(model); + if (logoEntry !== undefined && !entries.some((entry) => entry.relativePath === logoEntry.relativePath)) { + entries.push(logoEntry); + } if (cursorMcp !== undefined && cursorMcpValid) { entries.push({ content: `${stableJson(cursorMcp)}\n`, diff --git a/packages/agent-bundle/src/build/artifact-diagnostics.ts b/packages/agent-bundle/src/build/artifact-diagnostics.ts index f096280c1..9f8dd176c 100644 --- a/packages/agent-bundle/src/build/artifact-diagnostics.ts +++ b/packages/agent-bundle/src/build/artifact-diagnostics.ts @@ -25,7 +25,8 @@ export type ArtifactDiagnosticCode = | 'AB6021' | 'AB6022' | 'AB6023' - | 'AB6024'; + | 'AB6024' + | 'AB6025'; export const artifactDiagnosticRecoveries: Readonly> = Object.freeze({ AB6000: 'Restore a readable artifact root and canonical manifest, then rebuild the artifact.', @@ -53,6 +54,7 @@ export const artifactDiagnosticRecoveries: Readonly diff --git a/packages/agent-bundle/src/build/validate-artifact-logo.ts b/packages/agent-bundle/src/build/validate-artifact-logo.ts new file mode 100644 index 000000000..595fc6e87 --- /dev/null +++ b/packages/agent-bundle/src/build/validate-artifact-logo.ts @@ -0,0 +1,41 @@ +import { posix } from 'node:path'; + +import { isContainedRelativePath, safeArtifactPath } from '../core/paths.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { artifactDiagnostic as diagnostic } from './artifact-diagnostics.ts'; +import { targetArtifactPath } from './artifact-layout.ts'; + +const isRemoteLogoReference = (value: string): boolean => { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + +export const manifestLogoPathDiagnostics = (options: { + readonly files: ReadonlySet; + readonly generatedPath: string; + readonly logo: string; + readonly target: string; +}): readonly Diagnostic[] => { + if (isRemoteLogoReference(options.logo)) return Object.freeze([]); + const relativePath = posix.normalize(options.logo.replace(/^\.\//u, '')); + if (!isContainedRelativePath(relativePath) || !safeArtifactPath(relativePath)) { + return Object.freeze([diagnostic( + 'AB6025', + `Plugin logo ${JSON.stringify(options.logo)} escapes the artifact for target ${JSON.stringify(options.target)}.`, + options.generatedPath, + options.target, + )]); + } + const artifactPath = targetArtifactPath(options.target, relativePath); + if (options.files.has(artifactPath)) return Object.freeze([]); + return Object.freeze([diagnostic( + 'AB6025', + `Plugin logo ${JSON.stringify(options.logo)} references missing artifact file ${JSON.stringify(artifactPath)}.`, + options.generatedPath, + options.target, + )]); +}; diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 9bf7e068e..04050e884 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -37,6 +37,7 @@ import type { } from './artifact-validation-types.ts'; import { validateJavaScriptModules } from './validate-artifact-modules.ts'; import { validateHookCoherence } from './validate-artifact-hooks.ts'; +import { manifestLogoPathDiagnostics } from './validate-artifact-logo.ts'; import { validateMcpCoherence } from './validate-artifact-mcp.ts'; import { pathTarget, targetNamespaces, validateEmittedSkills } from './validate-artifact-skills.ts'; import { installSurfaceRequirements } from '../install/surface.ts'; @@ -394,6 +395,18 @@ const validateTargetContracts = async (options: { target.name, )); } + if ( + document.path.endsWith('plugin.json') && + isRecord(parsed) && + typeof parsed.logo === 'string' + ) { + diagnostics.push(...manifestLogoPathDiagnostics({ + files, + generatedPath, + logo: parsed.logo, + target: target.name, + })); + } } } return Object.freeze(diagnostics); diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 58613d9e6..854ffee6c 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { existsSync, statSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; -import { basename, extname, relative, resolve } from 'node:path'; +import { basename, extname, posix, relative, resolve, sep } from 'node:path'; import { digest } from '../core/digest.ts'; import { deepFreeze } from '../core/freeze.ts'; @@ -45,6 +45,7 @@ import type { NormalizedPackageBuild, NormalizedPayload, NormalizedPlugin, + NormalizedPluginLogo, NormalizedRuntime, NormalizedRule, NormalizedScript, @@ -890,6 +891,21 @@ const skillProvenance = ( sourcePath, }); +const normalizePluginLogo = (loaded: LoadedConfig): NormalizedPluginLogo | undefined => { + const declared = loaded.config.plugin.logo; + if (typeof declared !== 'string' || declared.trim().length === 0) return undefined; + const projectRoot = loaded.context.projectRoot; + const source = resolve(projectRoot, declared); + if (!isInside(projectRoot, source) || !existsSync(source)) return undefined; + const stats = statSync(source); + if (!stats.isFile()) return undefined; + return { + bytes: stats.size, + path: posix.join('assets', relative(projectRoot, source).split(sep).join(posix.sep)), + source, + }; +}; + const normalizeAssets = ( loaded: LoadedConfig, discovered: DiscoveredProject, @@ -1000,6 +1016,7 @@ export const normalizeProject = async ( }; }); const description = loaded.config.plugin.description; + const logo = normalizePluginLogo(loaded); // The npm package axes are derived, never authored in config: package.json // is authoritative for release identity (issue #94), while plugin.version // remains the host-facing declared version during the migration. @@ -1033,6 +1050,7 @@ export const normalizeProject = async ( metadata: { ...(typeof description === 'string' ? { description } : {}), id: `plugin:${loaded.config.plugin.name}`, + ...(logo === undefined ? {} : { logo }), name: loaded.config.plugin.name, ...(packageIdentity.packageName === undefined ? {} : { packageName: packageIdentity.packageName }), ...(packageIdentity.packageVersion === undefined ? {} : { packageVersion: packageIdentity.packageVersion }), diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index c47abbb37..5d3e80e5a 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -746,6 +746,33 @@ const validateMcpServer = ( return diagnostics; }; +const validatePluginLogo = ( + loaded: LoadedConfig, + pluginRecord: Record | undefined, +): Diagnostic[] => { + if (pluginRecord === undefined || !Object.hasOwn(pluginRecord, 'logo')) return []; + const declared = pluginRecord.logo; + const recovery = 'Set plugin.logo to an existing file inside the project root, or omit the field.'; + const fail = (message: string): Diagnostic => ({ + code: 'AB4012', + message, + recovery, + severity: 'error', + sourcePath: loaded.configPath, + }); + if (typeof declared !== 'string' || declared.trim().length === 0) { + return [fail('Plugin logo must be a nonempty path to an existing file inside the project.')]; + } + const source = resolve(loaded.context.projectRoot, declared); + if (!isInside(loaded.context.projectRoot, source) || resolve(loaded.context.projectRoot) === source) { + return [fail(`Plugin logo ${JSON.stringify(declared)} must resolve inside the project root.`)]; + } + if (!localEntryExists(loaded.context.projectRoot, declared)) { + return [fail(`Plugin logo ${JSON.stringify(declared)} must name an existing file.`)]; + } + return []; +}; + const validateAssets = (loaded: LoadedConfig): Diagnostic[] => { const assets = loaded.config.assets; if (assets === undefined) return []; @@ -1736,6 +1763,7 @@ export const validateSource = ( ), ); } + diagnostics.push(...validatePluginLogo(loaded, pluginRecord)); const skillNames = new Map(); for (const skill of discovered.skills) { diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index ad604c269..4e63da6af 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -11,6 +11,8 @@ import type { CapabilityState } from './capabilities.ts'; export interface AgentBundlePluginConfig { description?: string; + /** Project-relative path to a logo image copied into host artifacts that support it. */ + logo?: string; name: string; version: string; [key: string]: unknown; @@ -262,9 +264,17 @@ export interface SourceProvenance { readonly sourcePath: string; } +export interface NormalizedPluginLogo { + readonly bytes: number; + /** Artifact-relative POSIX path written into host manifests that support logo. */ + readonly path: string; + readonly source: string; +} + export interface NormalizedMetadata { readonly description?: string; readonly id: string; + readonly logo?: NormalizedPluginLogo; readonly name: string; /** The validated npm package name derived from the project's package.json. */ readonly packageName?: string; diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 119e564ee..23a7a43a1 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -186,6 +186,17 @@ it('records observed capability versions and rehashes schema snapshots against p expect(schema.sha256).toBe(provenance.schemas[fileName]?.sha256); expect(schema.revision).toBe(metadata.observedVersion); } + + if (target === 'cursor') { + const pluginSchema = JSON.parse(await readFile( + new URL('../src/adapters/schemas/cursor/plugin.schema.json', import.meta.url), + 'utf8', + )) as { readonly properties: { readonly logo?: unknown } }; + expect(pluginSchema.properties.logo).toEqual({ + description: 'Path to a logo image (relative to the plugin root) or an absolute URL.', + type: 'string', + }); + } } }); diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 46c2ea053..11f84846b 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -1877,7 +1877,7 @@ it('documents recovery for every stable artifact diagnostic code', async () => { 'AB6000', 'AB6001', 'AB6002', 'AB6003', 'AB6004', 'AB6005', 'AB6006', 'AB6007', 'AB6008', 'AB6009', 'AB6010', 'AB6011', 'AB6012', 'AB6013', 'AB6014', 'AB6015', 'AB6016', 'AB6017', 'AB6018', 'AB6019', 'AB6020', - 'AB6021', 'AB6022', 'AB6023', 'AB6024', + 'AB6021', 'AB6022', 'AB6023', 'AB6024', 'AB6025', ]); expect(Object.values(artifactDiagnosticRecoveries).every((recovery) => recovery.trim().length > 0)).toBe(true); expect(artifactDiagnosticRecoveries.AB6015).not.toBe(artifactDiagnosticRecoveries.AB6016); @@ -1976,3 +1976,63 @@ it.each(['claude', 'codex'] as const)( } }, ); + +const logoSurfaceModel = (target: 'cursor' | 'plugin'): NormalizedPlugin => ({ + ...installSurfaceModel(target), + metadata: { + ...installSurfaceModel(target).metadata, + logo: { + bytes: 12, + path: 'assets/docs/media/logo.svg', + source: '/project/docs/media/logo.svg', + }, + }, +}); + +it('fails artifact validation when a Cursor manifest logo is missing from the deploy tree', async () => { + const registry = createDefaultRegistry(); + const files = registry.get('cursor').plan(logoSurfaceModel('cursor')).entries + .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') + .map((entry) => ({ + contents: entry.content, + kind: 'generated' as const, + path: `cursor/${entry.relativePath}`, + })); + const root = await writeArtifact(files, true, [targetFromRegistry(registry, 'cursor')]); + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB6025', + generatedPath: 'cursor/.cursor-plugin/plugin.json', + target: 'cursor', + }), + ])); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('accepts a Cursor manifest logo that resolves inside the artifact', async () => { + const registry = createDefaultRegistry(); + const files = [ + ...registry.get('cursor').plan(logoSurfaceModel('cursor')).entries + .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') + .map((entry) => ({ + contents: entry.content, + kind: 'generated' as const, + path: `cursor/${entry.relativePath}`, + })), + { + contents: '\n', + kind: 'copy' as const, + path: 'cursor/assets/docs/media/logo.svg', + }, + ]; + const root = await writeArtifact(files, true, [targetFromRegistry(registry, 'cursor')]); + try { + const diagnostics = await validateArtifact({ artifactRoot: root }); + expect(diagnostics.filter((diagnostic) => diagnostic.code === 'AB6025')).toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index 72ee144f6..e95ecd229 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -158,6 +158,36 @@ it('validates Cursor documents against the vendored real-host schemas', () => { expect(cursorHooksValidator({ hooks: { afterShellExecutionn: [{ command: 'echo typo' }] }, version: 1 })).toBe(false); expect(cursorHooksValidator({ hooks: { stop: [{ timeout: 5 }] }, version: 1 })).toBe(false); expect(cursorHooksValidator({ hooks: {}, version: 2 })).toBe(false); + expect(cursorPluginValidator({ + logo: './assets/docs/media/logo.svg', + name: 'cursor-review', + version: '1.2.3', + })).toBe(true); +}); + +it('copies plugin.logo into the artifact and references it from plugin.json', () => { + const model: NormalizedPlugin = { + ...plugin(), + metadata: { + ...plugin().metadata, + logo: { + bytes: 12, + path: 'assets/docs/media/logo.svg', + source: '/workspace/docs/media/logo.svg', + }, + }, + }; + const plan = cursorAdapter.plan(model); + expect(plan.diagnostics).toEqual([]); + expect(JSON.parse(writeContents(model)['.cursor-plugin/plugin.json']!)).toMatchObject({ + logo: './assets/docs/media/logo.svg', + }); + expect(plan.entries).toContainEqual(expect.objectContaining({ + bytes: 12, + kind: 'copy', + relativePath: 'assets/docs/media/logo.svg', + source: '/workspace/docs/media/logo.svg', + })); }); it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit manifest pointers', () => { diff --git a/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts b/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts index d7534ae49..1b3530b4c 100644 --- a/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts +++ b/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts @@ -10,6 +10,7 @@ export default { }, plugin: { description: 'Proves real host installation of Skills, Hooks, and MCP metadata.', + logo: './docs/media/logo.svg', name: 'host-install-proof', version: '1.0.0', }, diff --git a/packages/agent-bundle/tests/fixtures/host-install/docs/media/logo.svg b/packages/agent-bundle/tests/fixtures/host-install/docs/media/logo.svg new file mode 100644 index 000000000..3fc13f4ca --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/host-install/docs/media/logo.svg @@ -0,0 +1 @@ + diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index 381400b97..00d5a5818 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -135,6 +135,10 @@ it('installs into an isolated Cursor home, validates schemas, and is idempotent' }, host: 'cursor', install: { first: 'installed', second: 'already-installed', version: '1.0.0' }, + logo: { + path: './assets/docs/media/logo.svg', + resolvesInsideDeployTree: true, + }, pluginRootVariable: { locations: [ 'hooks/hooks.json#/hooks/sessionStart/0/command', diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 6525e2e15..47611e721 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -169,6 +169,34 @@ it('lays both host manifests over one shared bundle root', () => { }); }); +it('emits Cursor logo and omits it from Claude and Codex manifests', () => { + const model: NormalizedPlugin = { + ...bundleModel, + metadata: { + ...bundleModel.metadata, + logo: { + bytes: 64, + path: 'assets/docs/media/logo.svg', + source: '/workspace/docs/media/logo.svg', + }, + }, + }; + const plan = planBundle(model); + expect(plan.diagnostics).toEqual([]); + const documents = writeContents(model); + const claudePlugin = JSON.parse(documents['.claude-plugin/plugin.json']!) as Record; + const codexPlugin = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; + const cursorPlugin = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; + expect(claudePlugin).not.toHaveProperty('logo'); + expect(codexPlugin).not.toHaveProperty('logo'); + expect(cursorPlugin.logo).toBe('./assets/docs/media/logo.svg'); + expect(plan.entries).toContainEqual(expect.objectContaining({ + kind: 'copy', + relativePath: 'assets/docs/media/logo.svg', + source: '/workspace/docs/media/logo.svg', + })); +}); + it('bundles subagent hooks at Codex default hooks/hooks.json location', () => { const model: NormalizedPlugin = { ...bundleModel, diff --git a/packages/agent-bundle/tests/plugin-logo.test.ts b/packages/agent-bundle/tests/plugin-logo.test.ts new file mode 100644 index 000000000..dea68878a --- /dev/null +++ b/packages/agent-bundle/tests/plugin-logo.test.ts @@ -0,0 +1,188 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterAll, expect, it } from '@rstest/core'; + +import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { cursorAdapter, cursorPluginValidator } from '../src/adapters/cursor.ts'; +import { pluginAdapter } from '../src/adapters/plugin.ts'; +import { normalizeProject, validateSource } from '../src/config/index.ts'; +import type { LoadedConfig } from '../src/config/load.ts'; +import type { AgentBundleConfig, NormalizedPlugin } from '../src/core/types.ts'; + +const registry = createDefaultRegistry(); +const logoSvg = '\n'; +const tempRoots: string[] = []; + +afterAll(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { force: true, recursive: true }))); +}); + +const loadedProject = async ( + plugin: AgentBundleConfig['plugin'], + files: Readonly> = {}, +): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-plugin-logo-')); + tempRoots.push(root); + for (const [relativePath, contents] of Object.entries(files)) { + const path = join(root, relativePath); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents); + } + return { + config: { plugin }, + configPath: join(root, 'agent-bundle.config.ts'), + context: { + command: 'build', + mode: 'production', + projectRoot: root, + selectedTargets: [], + }, + }; +}; + +const logoModel = (target: 'cursor' | 'plugin'): NormalizedPlugin => ({ + extensions: {}, + hooks: [], + metadata: { + description: 'Logo fixture', + id: 'plugin:logo-fixture', + logo: { + bytes: Buffer.byteLength(logoSvg), + path: 'assets/docs/media/logo.svg', + source: '/workspace/docs/media/logo.svg', + }, + name: 'logo-fixture', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + version: '1.0.0', + }, + mcpServers: [], + runtime: { node: '22.12.0' }, + scripts: [], + skills: [], + targets: [{ + id: `target:${target}`, + name: target, + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + }], +}); + +it('rejects a missing or invalid plugin.logo with AB4012', async () => { + const missing = await loadedProject({ + logo: 'docs/media/missing.svg', + name: 'logo-fixture', + version: '1.0.0', + }); + expect(validateSource(missing, { skills: [] }, registry).filter(({ code }) => code === 'AB4012')).toMatchObject([{ + code: 'AB4012', + severity: 'error', + sourcePath: missing.configPath, + }]); + + const empty = await loadedProject({ + logo: ' ', + name: 'logo-fixture', + version: '1.0.0', + }); + expect(validateSource(empty, { skills: [] }, registry).filter(({ code }) => code === 'AB4012')).toMatchObject([{ + code: 'AB4012', + severity: 'error', + sourcePath: empty.configPath, + }]); + + const directory = await loadedProject({ + logo: 'docs/media', + name: 'logo-fixture', + version: '1.0.0', + }, { 'docs/media/.keep': '' }); + expect(validateSource(directory, { skills: [] }, registry).filter(({ code }) => code === 'AB4012')).toMatchObject([{ + code: 'AB4012', + severity: 'error', + sourcePath: directory.configPath, + }]); + + const outside = await loadedProject({ + logo: '../outside.svg', + name: 'logo-fixture', + version: '1.0.0', + }); + expect(validateSource(outside, { skills: [] }, registry).filter(({ code }) => code === 'AB4012')).toMatchObject([{ + code: 'AB4012', + severity: 'error', + sourcePath: outside.configPath, + }]); +}); + +it('accepts an existing in-project plugin.logo and normalizes it onto metadata', async () => { + const loaded = await loadedProject({ + description: 'Logo fixture', + logo: 'docs/media/logo.svg', + name: 'logo-fixture', + version: '1.0.0', + }, { 'docs/media/logo.svg': logoSvg }); + + expect(validateSource(loaded, { skills: [] }, registry).filter(({ code }) => code === 'AB4012')).toEqual([]); + + const model = await normalizeProject(loaded, { skills: [] }, registry); + expect(model.metadata.logo).toEqual({ + bytes: Buffer.byteLength(logoSvg), + path: 'assets/docs/media/logo.svg', + source: join(loaded.context.projectRoot, 'docs/media/logo.svg'), + }); +}); + +it('omits logo from the normalized model when the field is absent', async () => { + const loaded = await loadedProject({ + name: 'logo-fixture', + version: '1.0.0', + }); + const model = await normalizeProject(loaded, { skills: [] }, registry); + expect(model.metadata).not.toHaveProperty('logo'); +}); + +it('permits a relative logo path on the pinned Cursor plugin schema', () => { + expect(cursorPluginValidator({ + logo: './assets/docs/media/logo.svg', + name: 'logo-fixture', + version: '1.0.0', + })).toBe(true); +}); + +it('emits Cursor plugin.json logo and copies the image into the artifact', () => { + const model = logoModel('cursor'); + const plan = cursorAdapter.plan(model); + expect(plan.diagnostics).toEqual([]); + const manifest = JSON.parse( + (plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, + ) as Record; + expect(manifest.logo).toBe('./assets/docs/media/logo.svg'); + expect(plan.entries).toContainEqual(expect.objectContaining({ + bytes: Buffer.byteLength(logoSvg), + kind: 'copy', + relativePath: 'assets/docs/media/logo.svg', + source: '/workspace/docs/media/logo.svg', + })); +}); + +it('omits logo from Claude and Codex manifests while still emitting it for Cursor', () => { + const model = logoModel('plugin'); + const plan = pluginAdapter.plan(model); + expect(plan.diagnostics).toEqual([]); + const documents = Object.fromEntries( + plan.entries + .filter((entry): entry is Extract => entry.kind === 'write') + .map((entry) => [entry.relativePath, entry.content]), + ); + const claude = JSON.parse(documents['.claude-plugin/plugin.json']!) as Record; + const codex = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; + const cursor = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; + expect(claude).not.toHaveProperty('logo'); + expect(codex).not.toHaveProperty('logo'); + expect(cursor.logo).toBe('./assets/docs/media/logo.svg'); + expect(plan.entries).toContainEqual(expect.objectContaining({ + kind: 'copy', + relativePath: 'assets/docs/media/logo.svg', + source: '/workspace/docs/media/logo.svg', + })); +}); diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 6166ac24f..6389fb962 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -1,7 +1,7 @@ import { execFile as executeFile } from 'node:child_process'; import { access, cp, mkdir, mkdtemp, readFile, realpath, rm, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { isAbsolute, join, relative, sep } from 'node:path'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { promisify } from 'node:util'; import { parse as parseYaml } from 'yaml'; @@ -11,6 +11,7 @@ import { cursorMcpValidator, cursorPluginValidator, } from '../../src/adapters/cursor.ts'; +import { isInsideOrEqual } from '../../src/core/paths.ts'; import { validateCodexOpenaiYaml } from '../../src/schemas/skill-hosts/contract.ts'; import { HOST_INSTALL_PROOF_LEVEL, @@ -147,6 +148,10 @@ export interface CursorHostInstallReport { readonly second: 'already-installed'; readonly version: '1.0.0'; }; + readonly logo: { + readonly path: string; + readonly resolvesInsideDeployTree: true; + }; readonly pluginRootVariable: { readonly locations: readonly string[]; readonly resolvedAtInstall: false; @@ -635,6 +640,16 @@ export const runCursorHostInstallProof = async ( const pluginDocument = await readJson(join(destination, '.cursor-plugin', 'plugin.json'), 'Cursor plugin manifest'); assertProof(cursorPluginValidator(pluginDocument), `Cursor plugin manifest failed its pinned schema: ${JSON.stringify(cursorPluginValidator.errors)}`); + const logo = record(pluginDocument)?.logo; + assertProof(typeof logo === 'string' && logo.length > 0, 'Cursor plugin manifest did not emit a logo path.'); + assertProof(!logo.includes('..'), `Cursor plugin logo ${JSON.stringify(logo)} escapes the deploy tree.`); + const logoRelative = logo.replace(/^\.\//u, ''); + const logoPath = resolve(destination, logoRelative); + assertProof( + isInsideOrEqual(destination, logoPath), + `Cursor plugin logo ${JSON.stringify(logo)} does not resolve inside the deploy tree.`, + ); + await access(logoPath).catch(() => fail(`Cursor plugin logo ${JSON.stringify(logo)} is missing from the deploy tree.`)); const hooksText = await readText(join(destination, 'hooks', 'hooks.json'), 'Cursor hooks document'); const hooksDocument = parseJson(hooksText, 'Cursor hooks document'); assertProof(cursorHooksValidator(hooksDocument), `Cursor hooks document failed its pinned schema: ${JSON.stringify(cursorHooksValidator.errors)}`); @@ -668,6 +683,10 @@ export const runCursorHostInstallProof = async ( second: 'already-installed', version, }), + logo: Object.freeze({ + path: logo, + resolvesInsideDeployTree: true as const, + }), pluginRootVariable: Object.freeze({ locations: pluginRootLocations, resolvedAtInstall: false,