@adea-ai/plugins 4.0.0 → 4.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/agent-plugins-catalog.d.ts +2 -4
  2. package/dist/agent-plugins-catalog.js +42 -50
  3. package/dist/agent-plugins-catalog.js.map +1 -1
  4. package/dist/agent-plugins-schema.d.ts +8 -0
  5. package/dist/agent-plugins-schema.js +10 -1
  6. package/dist/agent-plugins-schema.js.map +1 -1
  7. package/dist/artifacts.d.ts +57 -0
  8. package/dist/artifacts.js +159 -0
  9. package/dist/artifacts.js.map +1 -0
  10. package/dist/build.d.ts +20 -0
  11. package/dist/build.js +103 -0
  12. package/dist/build.js.map +1 -0
  13. package/dist/capabilities.d.ts +7 -0
  14. package/dist/capabilities.js +116 -0
  15. package/dist/capabilities.js.map +1 -0
  16. package/dist/catalog-store.d.ts +76 -0
  17. package/dist/catalog-store.js +463 -0
  18. package/dist/catalog-store.js.map +1 -0
  19. package/dist/concurrency.d.ts +1 -0
  20. package/dist/concurrency.js +15 -0
  21. package/dist/concurrency.js.map +1 -0
  22. package/dist/consumer-index.d.ts +7 -29
  23. package/dist/consumer-index.js +24 -15
  24. package/dist/consumer-index.js.map +1 -1
  25. package/dist/digest.d.ts +13 -0
  26. package/dist/digest.js +47 -0
  27. package/dist/digest.js.map +1 -0
  28. package/dist/fs-atomic.d.ts +7 -0
  29. package/dist/fs-atomic.js +30 -0
  30. package/dist/fs-atomic.js.map +1 -0
  31. package/dist/harness.js +2 -1
  32. package/dist/harness.js.map +1 -1
  33. package/dist/icon-mirror.d.ts +5 -4
  34. package/dist/icon-mirror.js +73 -72
  35. package/dist/icon-mirror.js.map +1 -1
  36. package/dist/icons.d.ts +2 -2
  37. package/dist/icons.js +4 -12
  38. package/dist/icons.js.map +1 -1
  39. package/dist/index.d.ts +16 -309
  40. package/dist/index.js +12 -1900
  41. package/dist/index.js.map +1 -1
  42. package/dist/integrity-manifest.d.ts +43 -13
  43. package/dist/integrity-manifest.js +28 -17
  44. package/dist/integrity-manifest.js.map +1 -1
  45. package/dist/json-values.d.ts +3 -0
  46. package/dist/json-values.js +10 -0
  47. package/dist/json-values.js.map +1 -0
  48. package/dist/plugin-release.d.ts +38 -0
  49. package/dist/plugin-release.js +321 -0
  50. package/dist/plugin-release.js.map +1 -0
  51. package/dist/plugin-skips.d.ts +19 -0
  52. package/dist/plugin-skips.js +65 -0
  53. package/dist/plugin-skips.js.map +1 -0
  54. package/dist/policy.d.ts +13 -0
  55. package/dist/policy.js +26 -0
  56. package/dist/policy.js.map +1 -0
  57. package/dist/portable-catalog.d.ts +4 -4
  58. package/dist/portable-catalog.js +14 -126
  59. package/dist/portable-catalog.js.map +1 -1
  60. package/dist/portable-verify.d.ts +2 -0
  61. package/dist/portable-verify.js +68 -0
  62. package/dist/portable-verify.js.map +1 -0
  63. package/dist/publication.d.ts +12 -12
  64. package/dist/publication.js +31 -21
  65. package/dist/publication.js.map +1 -1
  66. package/dist/published-output.d.ts +17 -0
  67. package/dist/published-output.js +49 -0
  68. package/dist/published-output.js.map +1 -0
  69. package/dist/site-icons.d.ts +4 -3
  70. package/dist/site-icons.js +38 -11
  71. package/dist/site-icons.js.map +1 -1
  72. package/dist/snapshots.d.ts +39 -0
  73. package/dist/snapshots.js +238 -0
  74. package/dist/snapshots.js.map +1 -0
  75. package/dist/source-resolution.d.ts +28 -0
  76. package/dist/source-resolution.js +158 -0
  77. package/dist/source-resolution.js.map +1 -0
  78. package/dist/sources.d.ts +5 -1
  79. package/dist/sources.js +21 -27
  80. package/dist/sources.js.map +1 -1
  81. package/dist/synchronize.d.ts +44 -0
  82. package/dist/synchronize.js +118 -0
  83. package/dist/synchronize.js.map +1 -0
  84. package/dist/taxonomy.d.ts +23 -0
  85. package/dist/taxonomy.js +62 -0
  86. package/dist/taxonomy.js.map +1 -0
  87. package/dist/upstream.d.ts +42 -0
  88. package/dist/upstream.js +179 -0
  89. package/dist/upstream.js.map +1 -0
  90. package/dist/verify-artifacts.d.ts +36 -0
  91. package/dist/verify-artifacts.js +286 -0
  92. package/dist/verify-artifacts.js.map +1 -0
  93. package/native/build-provenance.txt +5 -5
  94. package/native/staged-reader-win32-arm64.node +0 -0
  95. package/native/staged-reader-win32-x64.node +0 -0
  96. package/package.json +3 -3
  97. package/src/agent-plugins-catalog.test.ts +2 -2
  98. package/src/agent-plugins-catalog.ts +46 -49
  99. package/src/agent-plugins-schema.ts +11 -1
  100. package/src/artifacts.test.ts +80 -0
  101. package/src/artifacts.ts +234 -0
  102. package/src/build.ts +131 -0
  103. package/src/capabilities.ts +153 -0
  104. package/src/catalog-store.ts +519 -0
  105. package/src/concurrency.ts +19 -0
  106. package/src/consumer-index.test.ts +147 -16
  107. package/src/consumer-index.ts +35 -43
  108. package/src/default-head.test.ts +143 -0
  109. package/src/digest.ts +55 -0
  110. package/src/fs-atomic.ts +32 -0
  111. package/src/harness.test.ts +4 -32
  112. package/src/harness.ts +2 -1
  113. package/src/icon-mirror.ts +78 -72
  114. package/src/icons.test.ts +0 -4
  115. package/src/icons.ts +4 -4
  116. package/src/index.test.ts +172 -46
  117. package/src/index.ts +58 -2562
  118. package/src/integrity-manifest.ts +41 -21
  119. package/src/json-values.ts +11 -0
  120. package/src/manifest-retry.test.ts +49 -0
  121. package/src/plugin-release.ts +457 -0
  122. package/src/plugin-skips.ts +110 -0
  123. package/src/policy.ts +39 -0
  124. package/src/portable-catalog.test.ts +0 -5
  125. package/src/portable-catalog.ts +16 -143
  126. package/src/portable-verify.ts +71 -0
  127. package/src/publication.test.ts +29 -1
  128. package/src/publication.ts +38 -22
  129. package/src/published-output.ts +60 -0
  130. package/src/site-icons.test.ts +73 -1
  131. package/src/site-icons.ts +31 -14
  132. package/src/snapshots.ts +287 -0
  133. package/src/source-config-sync.test.ts +84 -0
  134. package/src/source-digest.test.ts +61 -8
  135. package/src/source-resolution.ts +233 -0
  136. package/src/sources-json.test.ts +18 -2
  137. package/src/sources.ts +27 -33
  138. package/src/synchronize.ts +213 -0
  139. package/src/taxonomy.ts +87 -0
  140. package/src/upstream.ts +194 -0
  141. package/src/verify-artifacts.ts +365 -0
  142. package/src/write-artifacts.test.ts +374 -0
  143. package/dist/source-digest.d.ts +0 -2
  144. package/dist/source-digest.js +0 -12
  145. package/dist/source-digest.js.map +0 -1
  146. package/native/README.md +0 -24
  147. package/src/source-digest.ts +0 -14
@@ -0,0 +1,110 @@
1
+ import type { MarketplacePluginEntry } from './sources.js'
2
+ import type { ResolvedSource } from './source-resolution.js'
3
+ import { stablePluginId } from './taxonomy.js'
4
+
5
+ export type PluginSkipReasonCode =
6
+ | 'GIT_SUBMODULE_UNSUPPORTED'
7
+ | 'PLUGIN_FILE_TOO_LARGE'
8
+ | 'PLUGIN_FILE_UNAVAILABLE'
9
+ | 'PLUGIN_MANIFEST_INVALID'
10
+ | 'PLUGIN_PATH_NOT_FOUND'
11
+ | 'PLUGIN_SCHEMA_INVALID'
12
+ | 'PLUGIN_SIZE_POLICY'
13
+ | 'PLUGIN_SOURCE_UNAVAILABLE'
14
+ | 'PLUGIN_TOO_LARGE'
15
+ | 'PLUGIN_TOO_MANY_FILES'
16
+ | 'SNAPSHOT_PATH_INVALID'
17
+ | 'SYMLINK_ESCAPE'
18
+
19
+ export interface SkippedPlugin {
20
+ readonly sourceId: string
21
+ readonly pluginId: string
22
+ readonly pluginName: string
23
+ readonly reasonCode: PluginSkipReasonCode
24
+ readonly securityReason: string
25
+ readonly incompleteContent: true
26
+ readonly paths: readonly string[]
27
+ }
28
+
29
+ export class PluginSafetyError extends Error {
30
+ constructor(
31
+ readonly reasonCode: PluginSkipReasonCode,
32
+ readonly paths: readonly string[]
33
+ ) {
34
+ super(`${reasonCode}${paths.length > 0 ? `: ${paths.join(',')}` : ''}`)
35
+ this.name = 'PluginSafetyError'
36
+ }
37
+ }
38
+
39
+ const pluginSkipReasons: Readonly<Record<PluginSkipReasonCode, string>> = {
40
+ GIT_SUBMODULE_UNSUPPORTED:
41
+ 'The plugin contains a Git submodule, so the catalog excludes it rather than traversing an unpinned repository boundary.',
42
+ PLUGIN_FILE_TOO_LARGE:
43
+ 'The plugin contains a file over the configured size limit, so the catalog excludes the plugin before publication.',
44
+ PLUGIN_FILE_UNAVAILABLE:
45
+ 'A plugin file could not be fetched from upstream after retries, so the catalog excludes the plugin rather than publishing partial content.',
46
+ PLUGIN_MANIFEST_INVALID:
47
+ 'The plugin manifest is malformed, so the catalog excludes the plugin before consuming its metadata.',
48
+ PLUGIN_PATH_NOT_FOUND:
49
+ 'The declared plugin path does not exist at the pinned commit, so the catalog excludes the plugin.',
50
+ PLUGIN_SCHEMA_INVALID:
51
+ 'The normalized plugin record exceeds the published catalog schema, so the catalog excludes it before publication.',
52
+ PLUGIN_SIZE_POLICY:
53
+ 'The plugin exceeds the configured size policy, so the catalog excludes it before publication.',
54
+ PLUGIN_SOURCE_UNAVAILABLE:
55
+ 'The plugin repository tree could not be fetched from upstream after retries, so the catalog excludes the plugin rather than publishing partial content.',
56
+ PLUGIN_TOO_LARGE:
57
+ 'The plugin exceeds the configured aggregate size limit, so the catalog excludes it before publication.',
58
+ PLUGIN_TOO_MANY_FILES:
59
+ 'The plugin exceeds the configured file-count limit, so the catalog excludes it before publication.',
60
+ SNAPSHOT_PATH_INVALID:
61
+ 'The plugin snapshot contains an unsafe path, so the catalog excludes it rather than allowing repository escape.',
62
+ SYMLINK_ESCAPE:
63
+ 'The plugin snapshot contains symlinks; symlinks are never followed, so the catalog excludes the plugin.',
64
+ }
65
+
66
+ export function createSkippedPlugin(
67
+ source: ResolvedSource,
68
+ entry: MarketplacePluginEntry,
69
+ error: unknown
70
+ ): SkippedPlugin | undefined {
71
+ const parsed =
72
+ error instanceof PluginSafetyError
73
+ ? { reasonCode: error.reasonCode, paths: error.paths }
74
+ : parsePluginSafetyError(error)
75
+ if (!parsed) return undefined
76
+ return {
77
+ sourceId: source.config.sourceId,
78
+ pluginId: stablePluginId(source.config.sourceId, entry.name),
79
+ pluginName: entry.name,
80
+ reasonCode: parsed.reasonCode,
81
+ securityReason: pluginSkipReasons[parsed.reasonCode],
82
+ incompleteContent: true,
83
+ paths: [...new Set(parsed.paths)].toSorted(),
84
+ }
85
+ }
86
+
87
+ function parsePluginSafetyError(
88
+ error: unknown
89
+ ): { reasonCode: PluginSkipReasonCode; paths: readonly string[] } | undefined {
90
+ const message = error instanceof Error ? error.message : String(error)
91
+ const match = /^([A-Z_]+)(?::\s?(.*))?$/.exec(message)
92
+ if (!match) return undefined
93
+ const reasonCode = match[1] as PluginSkipReasonCode
94
+ if (!(reasonCode in pluginSkipReasons)) return undefined
95
+ return {
96
+ reasonCode,
97
+ paths: match[2]
98
+ ? match[2]
99
+ .split(',')
100
+ .map((path) => path.trim())
101
+ .filter(Boolean)
102
+ : [],
103
+ }
104
+ }
105
+
106
+ export const UPSTREAM_WEATHER_SKIP_CODES: ReadonlySet<PluginSkipReasonCode> = new Set([
107
+ 'PLUGIN_SOURCE_UNAVAILABLE',
108
+ 'PLUGIN_FILE_UNAVAILABLE',
109
+ 'PLUGIN_PATH_NOT_FOUND',
110
+ ])
package/src/policy.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { PACKAGE_LIMITS } from './agent-plugins-schema.js'
2
+ import { canonicalRepositoryUrl } from './sources.js'
3
+
4
+ export interface CatalogPolicy {
5
+ readonly allowedRepositoryProtocols: readonly string[]
6
+ readonly allowedRepositoryHosts: readonly string[]
7
+ readonly maxFilesPerPlugin: number
8
+ readonly maxBytesPerPlugin: number
9
+ readonly maxFileBytes: number
10
+ readonly maxMarketplacePlugins: number
11
+ readonly denyExecutableLifecycleScripts: boolean
12
+ readonly publishRequiresCompleteContent: boolean
13
+ readonly sensitiveCapabilityTypes: readonly string[]
14
+ }
15
+
16
+ export const DEFAULT_POLICY: CatalogPolicy = {
17
+ allowedRepositoryProtocols: ['https:'],
18
+ allowedRepositoryHosts: ['github.com', 'www.github.com'],
19
+ ...PACKAGE_LIMITS,
20
+ maxMarketplacePlugins: 4096,
21
+ denyExecutableLifecycleScripts: true,
22
+ publishRequiresCompleteContent: false,
23
+ sensitiveCapabilityTypes: [
24
+ 'mcp-server',
25
+ 'connector',
26
+ 'hook',
27
+ 'browser',
28
+ 'scheduled-task',
29
+ 'executable',
30
+ ],
31
+ }
32
+
33
+ export function assertRepositoryAllowed(repositoryUrl: string, policy: CatalogPolicy): void {
34
+ const parsed = new URL(canonicalRepositoryUrl(repositoryUrl))
35
+ if (!policy.allowedRepositoryProtocols.includes(parsed.protocol))
36
+ throw new Error(`GIT_PROTOCOL_POLICY: ${parsed.protocol}`)
37
+ if (!policy.allowedRepositoryHosts.includes(parsed.hostname.toLowerCase()))
38
+ throw new Error(`GIT_HOST_POLICY: ${parsed.hostname}`)
39
+ }
@@ -17,7 +17,6 @@ import type { HarnessProfile } from './agent-plugins-schema.js'
17
17
  import { snapshotFromDirectory } from './index.js'
18
18
  import {
19
19
  copyDependency,
20
- nodeCandidates,
21
20
  packageLifecycleScripts,
22
21
  selectPinnedNode,
23
22
  withPackedConsumerFixture,
@@ -612,10 +611,6 @@ it('qualifies the compiled artifact only on Node 24', async () => {
612
611
  assert.equal(selectPinnedNode([node20, node24]), node24)
613
612
  assert.throws(() => selectPinnedNode([node20]), /needs Node 24/)
614
613
  assert.throws(() => selectPinnedNode([]), /needs Node 24/)
615
- // The discovery ends with the `node` on PATH, which is where a CI
616
- // runner's pinned setup action puts Node 24.
617
- const candidates = nodeCandidates()
618
- assert.ok(candidates.includes('node'))
619
614
  } finally {
620
615
  await rm(directory, { recursive: true, force: true })
621
616
  }
@@ -1,22 +1,13 @@
1
- import { CatalogSchema, HarnessSchema, type Catalog, type Plugin } from './schema.js'
2
- import {
3
- orderLeadingPlugins,
4
- synchronize,
5
- createArtifacts,
6
- verifyArtifacts,
7
- digest,
8
- type SyncInput,
9
- type SyncResult,
10
- type ReleaseTransform,
11
- } from './index.js'
12
- import {
13
- compileAgentPackage,
14
- verifyAgentPackage,
15
- NORMALIZER_VERSION,
16
- } from './agent-plugins-catalog.js'
17
- import { CATALOG_CONTRACT_VERSION } from './index.js'
1
+ import { compileAgentPackage, verifyAgentPackage } from './agent-plugins-catalog.js'
2
+ import { NORMALIZER_VERSION, PACKAGE_METADATA_KEY } from './agent-plugins-schema.js'
3
+
4
+ import { CATALOG_CONTRACT_VERSION, type ReleaseTransform } from './plugin-release.js'
5
+ import { HarnessSchema, type Catalog, type Plugin } from './schema.js'
6
+ import { verifyPortableCatalog } from './portable-verify.js'
7
+ import { synchronize, type SyncInput, type SyncResult } from './synchronize.js'
8
+
9
+ export { PACKAGE_METADATA_KEY, verifyPortableCatalog }
18
10
 
19
- export const PACKAGE_METADATA_KEY = 'agentPlugins'
20
11
  function currentContract(catalog: Catalog | undefined): boolean {
21
12
  return (
22
13
  !!catalog &&
@@ -96,10 +87,14 @@ export async function synchronizePortable(options: SyncInput): Promise<SyncResul
96
87
  }
97
88
  // Compile while each upstream snapshot is already in scope. Do not retain all
98
89
  // marketplace file bytes or fetch every package a second time after cataloging.
99
- const input = {
90
+ const input: SyncInput = {
100
91
  ...options,
101
92
  metadataOnly: options.metadataOnly === true || options.dryRun === true,
102
93
  transformRelease,
94
+ releaseCompatibility: (release) => {
95
+ const pkg = release.releaseMetadata[PACKAGE_METADATA_KEY]
96
+ return pkg ? compatibility(verifyAgentPackage(pkg)) : undefined
97
+ },
103
98
  }
104
99
  let result = await synchronize(input)
105
100
  if (
@@ -112,128 +107,6 @@ export async function synchronizePortable(options: SyncInput): Promise<SyncResul
112
107
  // need not change for a format migration, and dry runs still never publish.
113
108
  result = await synchronize({ ...input, fromLock: input.existingLock })
114
109
  }
115
- if (!result.catalog || !result.lock || input.metadataOnly) return result
116
- const plugins: Plugin[] = []
117
- for (const plugin of result.catalog.plugins) {
118
- const releases = plugin.availableReleases
119
- const selected = releases.find((release) => release.releaseId === plugin.currentReleaseId)
120
- const pkg = selected?.releaseMetadata[PACKAGE_METADATA_KEY]
121
- plugins.push({
122
- ...plugin,
123
- availableReleases: releases,
124
- ...(pkg ? { harnessCompatibility: compatibility(verifyAgentPackage(pkg)) } : {}),
125
- })
126
- }
127
- const { catalogId: _oldId, ...old } = CatalogSchema.parse(result.catalog)
128
- // Ordering is applied before the catalog ID is computed so the emitted
129
- // identifier digests the final plugin order: each category's leading
130
- // products head the list.
131
- const ordered = orderLeadingPlugins({ ...old, plugins }, options.leading)
132
- const catalog = CatalogSchema.parse({
133
- ...ordered,
134
- catalogId: `catalog:${digest(ordered).slice(7)}`,
135
- })
136
- verifyPortableCatalog(catalog)
137
- const artifacts = createArtifacts(catalog, result.lock, {
138
- ...(options.leading ? { leading: options.leading } : {}),
139
- ...(options.topCount !== undefined ? { topCount: options.topCount } : {}),
140
- ...(options.productPreference ? { productPreference: options.productPreference } : {}),
141
- // Without this the shipped artifacts cannot advertise where they are
142
- // published, so brand marks and the browsing index lose their URLs. This
143
- // path replaces the artifacts the legacy compiler already produced, which is
144
- // why it must repeat every option it needs.
145
- ...(options.publicationRepositoryUrl
146
- ? { publicationRepositoryUrl: options.publicationRepositoryUrl }
147
- : {}),
148
- ...(options.publicationAssetsBaseUrl
149
- ? { publicationAssetsBaseUrl: options.publicationAssetsBaseUrl }
150
- : {}),
151
- ...(options.publicationCatalogsBaseUrl
152
- ? { publicationCatalogsBaseUrl: options.publicationCatalogsBaseUrl }
153
- : {}),
154
- curationDiagnostics: (options.mode ?? 'live') !== 'offline',
155
- })
156
- verifyArtifacts(artifacts)
157
- const changed = catalog.plugins
158
- .filter((plugin) => {
159
- const prior = input.existingCatalog?.plugins.find((item) => item.pluginId === plugin.pluginId)
160
- return prior && digest(prior) !== digest(plugin)
161
- })
162
- .map((plugin) => plugin.pluginId)
163
- return {
164
- ...result,
165
- catalog,
166
- artifacts,
167
- changeReport: {
168
- ...result.changeReport,
169
- changedPlugins: [...new Set([...result.changeReport.changedPlugins, ...changed])].toSorted(),
170
- },
171
- }
172
- }
173
-
174
- /**
175
- * A single-line summary of why a release's package descriptor was refused.
176
- *
177
- * The schema's own failure is a structured issue list; the catalog's refusal
178
- * contract is one typed line. The first issue is kept as the detail, because
179
- * it names the field that was wrong.
180
- */
181
- function describeProvenanceFailure(error: unknown): string {
182
- const issues = (error as { issues?: unknown }).issues
183
- if (Array.isArray(issues) && issues.length > 0) {
184
- const first = issues[0] as { path?: unknown; message?: unknown }
185
- const path = Array.isArray(first.path) ? first.path.join('.') : ''
186
- const message = typeof first.message === 'string' ? first.message : ''
187
- return [path, message].filter(Boolean).join(': ').replace(/\s+/g, ' ').slice(0, 200)
188
- }
189
- return error instanceof Error
190
- ? error.message.replace(/\s+/g, ' ').slice(0, 200)
191
- : 'the descriptor is not a package recipe'
192
- }
193
-
194
- export function verifyPortableCatalog(catalog: Catalog, requirePortable = false): void {
195
- const { catalogId, ...body } = CatalogSchema.parse(catalog)
196
- if (catalogId !== `catalog:${digest(body).slice(7)}`) throw new Error('CATALOG_ID_MISMATCH')
197
- const ids = new Set<string>()
198
- for (const plugin of catalog.plugins) {
199
- if (ids.has(plugin.pluginId)) throw new Error('DUPLICATE_PLUGIN_ID')
200
- ids.add(plugin.pluginId)
201
- const releases = new Set(plugin.availableReleases.map((release) => release.releaseId))
202
- if (releases.size !== plugin.availableReleases.length || !releases.has(plugin.currentReleaseId))
203
- throw new Error('RELEASE_SELECTION_INVALID')
204
- }
205
- for (const plugin of catalog.plugins)
206
- for (const release of plugin.availableReleases) {
207
- if (requirePortable && release.contentResolution !== 'complete')
208
- throw new Error('COMPLETE_SOURCE_REQUIRED')
209
- const descriptor = release.releaseMetadata[PACKAGE_METADATA_KEY]
210
- if (descriptor === undefined) {
211
- if (requirePortable && release.contentResolution === 'complete')
212
- throw new Error(`PORTABLE_RESYNC_REQUIRED: ${plugin.pluginId}`)
213
- continue
214
- }
215
- let pkg: ReturnType<typeof verifyAgentPackage>
216
- try {
217
- pkg = verifyAgentPackage(descriptor)
218
- } catch (error) {
219
- // A consumer imports a catalog it did not compile, so a malformed
220
- // provenance descriptor is an IMPORT failure, and it must be refused
221
- // the way every other failure here is refused: one typed, single-line
222
- // reason that names the plugin. The schema's own multi-line issue
223
- // dump is not a refusal a consumer can match on.
224
- throw new Error(
225
- `PACKAGE_PROVENANCE_INVALID: ${plugin.pluginId} (${describeProvenanceFailure(error)})`,
226
- { cause: error }
227
- )
228
- }
229
- if (
230
- release.contentResolution !== 'complete' ||
231
- pkg.sourceDigest !== release.canonicalContentDigest
232
- )
233
- throw new Error(`PACKAGE_PROVENANCE_MISMATCH: ${plugin.pluginId}`)
234
- const sourcePaths = new Set(release.fileIndex)
235
- for (const file of pkg.files)
236
- if (file.action === 'copy' && !sourcePaths.has(file.sourcePath))
237
- throw new Error(`PACKAGE_SOURCE_FILE_MISSING: ${plugin.pluginId}`)
238
- }
110
+ if (result.catalog && !input.metadataOnly) verifyPortableCatalog(result.catalog)
111
+ return result
239
112
  }
@@ -0,0 +1,71 @@
1
+ import { verifyAgentPackage } from './agent-plugins-catalog.js'
2
+ import { PACKAGE_METADATA_KEY } from './agent-plugins-schema.js'
3
+ import { contentId } from './digest.js'
4
+ import { CatalogSchema, type Catalog } from './schema.js'
5
+
6
+ /**
7
+ * A single-line summary of why a release's package descriptor was refused.
8
+ *
9
+ * The schema's own failure is a structured issue list; the catalog's refusal
10
+ * contract is one typed line. The first issue is kept as the detail, because
11
+ * it names the field that was wrong.
12
+ */
13
+ function describeProvenanceFailure(error: unknown): string {
14
+ const issues = (error as { issues?: unknown }).issues
15
+ if (Array.isArray(issues) && issues.length > 0) {
16
+ const first = issues[0] as { path?: unknown; message?: unknown }
17
+ const path = Array.isArray(first.path) ? first.path.join('.') : ''
18
+ const message = typeof first.message === 'string' ? first.message : ''
19
+ return [path, message].filter(Boolean).join(': ').replace(/\s+/g, ' ').slice(0, 200)
20
+ }
21
+ return error instanceof Error
22
+ ? error.message.replace(/\s+/g, ' ').slice(0, 200)
23
+ : 'the descriptor is not a package recipe'
24
+ }
25
+
26
+ export function verifyPortableCatalog(catalog: Catalog, requirePortable = false): void {
27
+ const { catalogId, ...body } = CatalogSchema.parse(catalog)
28
+ if (catalogId !== contentId('catalog', body)) throw new Error('CATALOG_ID_MISMATCH')
29
+ const ids = new Set<string>()
30
+ for (const plugin of catalog.plugins) {
31
+ if (ids.has(plugin.pluginId)) throw new Error('DUPLICATE_PLUGIN_ID')
32
+ ids.add(plugin.pluginId)
33
+ const releases = new Set(plugin.availableReleases.map((release) => release.releaseId))
34
+ if (releases.size !== plugin.availableReleases.length || !releases.has(plugin.currentReleaseId))
35
+ throw new Error('RELEASE_SELECTION_INVALID')
36
+ }
37
+ for (const plugin of catalog.plugins)
38
+ for (const release of plugin.availableReleases) {
39
+ if (requirePortable && release.contentResolution !== 'complete')
40
+ throw new Error('COMPLETE_SOURCE_REQUIRED')
41
+ const descriptor = release.releaseMetadata[PACKAGE_METADATA_KEY]
42
+ if (descriptor === undefined) {
43
+ if (requirePortable && release.contentResolution === 'complete')
44
+ throw new Error(`PORTABLE_RESYNC_REQUIRED: ${plugin.pluginId}`)
45
+ continue
46
+ }
47
+ let pkg: ReturnType<typeof verifyAgentPackage>
48
+ try {
49
+ pkg = verifyAgentPackage(descriptor)
50
+ } catch (error) {
51
+ // A consumer imports a catalog it did not compile, so a malformed
52
+ // provenance descriptor is an IMPORT failure, and it must be refused
53
+ // the way every other failure here is refused: one typed, single-line
54
+ // reason that names the plugin. The schema's own multi-line issue
55
+ // dump is not a refusal a consumer can match on.
56
+ throw new Error(
57
+ `PACKAGE_PROVENANCE_INVALID: ${plugin.pluginId} (${describeProvenanceFailure(error)})`,
58
+ { cause: error }
59
+ )
60
+ }
61
+ if (
62
+ release.contentResolution !== 'complete' ||
63
+ pkg.sourceDigest !== release.canonicalContentDigest
64
+ )
65
+ throw new Error(`PACKAGE_PROVENANCE_MISMATCH: ${plugin.pluginId}`)
66
+ const sourcePaths = new Set(release.fileIndex)
67
+ for (const file of pkg.files)
68
+ if (file.action === 'copy' && !sourcePaths.has(file.sourcePath))
69
+ throw new Error(`PACKAGE_SOURCE_FILE_MISSING: ${plugin.pluginId}`)
70
+ }
71
+ }
@@ -66,7 +66,7 @@ describe('catalog publication addresses', () => {
66
66
  )
67
67
  })
68
68
 
69
- test('only a GitHub repository can be addressed', () => {
69
+ test('refuses a repository URL it cannot address', () => {
70
70
  expect(() => catalogAssetsBaseUrl('https://example.com/adea-ai/plugins')).toThrow(
71
71
  'REPOSITORY_URL_UNSUPPORTED'
72
72
  )
@@ -115,3 +115,31 @@ describe('staged asset audit', () => {
115
115
  expect(audit.unstaged).toEqual(['icon-unstaged.png'])
116
116
  })
117
117
  })
118
+
119
+ describe('GitLab publication URLs', () => {
120
+ const repository = 'https://gitlab.com/adea-ai/ade/plugins'
121
+
122
+ test('addresses the branch through GitLab raw URLs in nested groups', () => {
123
+ expect(catalogAssetsBaseUrl(repository)).toBe(
124
+ 'https://gitlab.com/adea-ai/ade/plugins/-/raw/catalog-assets'
125
+ )
126
+ expect(latestPointerUrl(`${repository}.git`)).toBe(
127
+ 'https://gitlab.com/adea-ai/ade/plugins/-/raw/catalog-assets/catalog-latest.v1.json'
128
+ )
129
+ })
130
+
131
+ test('still addresses GitHub so the forge can switch back', () => {
132
+ expect(catalogAssetsBaseUrl('https://github.com/adea-ai/plugins')).toBe(
133
+ 'https://raw.githubusercontent.com/adea-ai/plugins/catalog-assets'
134
+ )
135
+ })
136
+
137
+ test('rejects other hosts and traversal', () => {
138
+ expect(() => catalogAssetsBaseUrl('https://example.com/a/b')).toThrow(
139
+ 'REPOSITORY_URL_UNSUPPORTED'
140
+ )
141
+ expect(() => catalogAssetsBaseUrl('https://gitlab.com/a/../b')).toThrow(
142
+ 'REPOSITORY_URL_INVALID'
143
+ )
144
+ })
145
+ })
@@ -1,3 +1,5 @@
1
+ import { CatalogSchema } from './schema.js'
2
+
1
3
  /**
2
4
  * Where a published catalog lives, and the URLs that address it.
3
5
  *
@@ -9,10 +11,11 @@
9
11
  * that slot, so no version is ever the one a visitor sees first.
10
12
  *
11
13
  * The branch gives each catalog an immutable path — `catalogs/<catalogId>/…`,
12
- * where `catalogId` is the canonical digest of the catalog itself — so a
13
- * pinned URL is immutable by construction, exactly as a content-addressed
14
- * release tag was, and `raw.githubusercontent.com` serves it with permissive
15
- * CORS so a browser can fetch it directly.
14
+ * where `catalogId` is the canonical digest of the catalog itself. The
15
+ * publisher must reject replacement of existing snapshot bytes. GitLab raw URLs
16
+ * serve the branch to servers and image tags; unlike
17
+ * `raw.githubusercontent.com` they send no CORS header, so a browser `fetch`
18
+ * needs a same-origin proxy.
16
19
  */
17
20
 
18
21
  export const publishedArtifactNames = [
@@ -63,11 +66,9 @@ export interface StagedAssetAudit {
63
66
  /**
64
67
  * Audits the bytes this build staged against what it declared.
65
68
  *
66
- * A content-addressed path removes the comparison the release audit used to
67
- * perform: a snapshot published at `catalogs/<catalogId>/` can only hold this
68
- * catalog's bytes, because any different build has a different `catalogId` and
69
- * therefore a different path. What survives is the check that cannot be
70
- * delegated to the path — that the bytes exist here at all.
69
+ * This checks that the declared bytes exist here at all. Publication must
70
+ * separately compare the complete staged snapshot with any existing snapshot:
71
+ * companion artifacts can differ without changing the catalog identity.
71
72
  */
72
73
  export function auditStagedAssets(input: {
73
74
  readonly declared: readonly DeclaredAsset[]
@@ -95,14 +96,13 @@ export interface PublicationPlan {
95
96
  /**
96
97
  * The 64-hex digest a `catalog:<hex>` identity is addressed by.
97
98
  *
98
- * A catalog identity names its own bytes, so this is also what makes the
99
- * snapshot path immutable: any build that produced different content has a
100
- * different identity and cannot write over this one.
99
+ * A catalog identity names the catalog content. The publisher must also guard
100
+ * the snapshot path against changes to companion artifacts under the same ID.
101
101
  */
102
102
  export function catalogIdSuffix(catalogId: string): string {
103
- const match = /^catalog:([a-f0-9]{64})$/.exec(catalogId)
104
- if (!match) throw new Error(`CATALOG_ID_INVALID: ${catalogId}`)
105
- return match[1] as string
103
+ if (!CatalogSchema.shape.catalogId.safeParse(catalogId).success)
104
+ throw new Error(`CATALOG_ID_INVALID: ${catalogId}`)
105
+ return catalogId.slice('catalog:'.length)
106
106
  }
107
107
 
108
108
  /**
@@ -110,7 +110,10 @@ export function catalogIdSuffix(catalogId: string): string {
110
110
  * catalog pointer.
111
111
  */
112
112
  export function catalogAssetsBaseUrl(repositoryUrl: string): string {
113
- return `https://raw.githubusercontent.com/${repositorySlug(repositoryUrl)}/${catalogAssetsBranch}`
113
+ const { host, path } = repositoryLocation(repositoryUrl)
114
+ return host === 'gitlab.com'
115
+ ? `https://gitlab.com/${path}/-/raw/${catalogAssetsBranch}`
116
+ : `https://raw.githubusercontent.com/${path}/${catalogAssetsBranch}`
114
117
  }
115
118
 
116
119
  /** Root of one catalog's immutable artifacts, content-addressed by identity. */
@@ -137,6 +140,11 @@ export function createPublicationPlan(input: {
137
140
  }
138
141
  }
139
142
 
143
+ /** A flat file name that is safe as both a local file and a URL path segment. */
144
+ export function isPublishedName(name: string): boolean {
145
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)
146
+ }
147
+
140
148
  /**
141
149
  * Immutable URL for one artifact of one catalog.
142
150
  *
@@ -150,22 +158,30 @@ export function immutableAssetUrl(
150
158
  catalogId: string,
151
159
  assetName: string
152
160
  ): string {
153
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(assetName))
154
- throw new Error(`ASSET_NAME_INVALID: ${assetName}`)
161
+ if (!isPublishedName(assetName)) throw new Error(`ASSET_NAME_INVALID: ${assetName}`)
155
162
  return `${catalogSnapshotBaseUrl(repositoryUrl, catalogId)}/${assetName}`
156
163
  }
157
164
 
158
- function repositorySlug(repositoryUrl: string): string {
165
+ function repositoryLocation(repositoryUrl: string): {
166
+ readonly host: 'github.com' | 'gitlab.com'
167
+ readonly path: string
168
+ } {
159
169
  let parsed: URL
160
170
  try {
161
171
  parsed = new URL(repositoryUrl)
162
172
  } catch {
163
173
  throw new Error(`REPOSITORY_URL_INVALID: ${repositoryUrl}`)
164
174
  }
165
- if (parsed.protocol !== 'https:' || parsed.hostname !== 'github.com')
175
+ const host = parsed.hostname
176
+ if (parsed.protocol !== 'https:' || (host !== 'github.com' && host !== 'gitlab.com'))
166
177
  throw new Error(`REPOSITORY_URL_UNSUPPORTED: ${repositoryUrl}`)
167
178
  const path = parsed.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')
168
- if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(path))
179
+ // GitLab projects can sit in nested groups; GitHub is always owner/repo.
180
+ const shape =
181
+ host === 'gitlab.com'
182
+ ? /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/
183
+ : /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
184
+ if (!shape.test(path) || path.split('/').some((part) => part === '.' || part === '..'))
169
185
  throw new Error(`REPOSITORY_URL_INVALID: ${repositoryUrl}`)
170
- return path
186
+ return { host, path }
171
187
  }
@@ -0,0 +1,60 @@
1
+ import type { GeneratedArtifacts } from './artifacts.js'
2
+ import { byteDigest } from './digest.js'
3
+ import { parseIntegrityManifest, type PublishedAsset } from './integrity-manifest.js'
4
+ import { verifyPortableCatalog } from './portable-verify.js'
5
+ import { parseCatalog, type Catalog } from './schema.js'
6
+ import { readStagedFile } from './staged-file.js'
7
+ import { verifyArtifacts } from './verify-artifacts.js'
8
+
9
+ /**
10
+ * Reads a published artifact set. `integrity.json` is the file list, so the
11
+ * consumer shards can grow with the category configuration.
12
+ */
13
+ export async function readArtifacts(directory: string): Promise<GeneratedArtifacts> {
14
+ const integrity = (await readStagedFile(directory, 'integrity.json')).toString('utf8')
15
+ const { files } = parseIntegrityManifest(JSON.parse(integrity))
16
+ const names = Object.keys(files).toSorted()
17
+ const values = await Promise.all(
18
+ names.map(
19
+ async (name) => [name, (await readStagedFile(directory, name)).toString('utf8')] as const
20
+ )
21
+ )
22
+ return Object.fromEntries([
23
+ ...values,
24
+ ['integrity.json', integrity],
25
+ ]) as unknown as GeneratedArtifacts
26
+ }
27
+
28
+ /** Verifies an artifact set and returns its catalog, checked as a portable catalog. */
29
+ export function verifiedCatalog(artifacts: GeneratedArtifacts, requirePortable = false): Catalog {
30
+ verifyArtifacts(artifacts)
31
+ const catalog = parseCatalog(JSON.parse(artifacts['catalog.v1.json']))
32
+ verifyPortableCatalog(catalog, requirePortable)
33
+ return catalog
34
+ }
35
+
36
+ /** The mirrored marks `integrity.json` declares, or undefined for a snapshot without any. */
37
+ export function declaredAssets(
38
+ artifacts: GeneratedArtifacts
39
+ ): readonly PublishedAsset[] | undefined {
40
+ return parseIntegrityManifest(JSON.parse(artifacts['integrity.json'])).assets
41
+ }
42
+
43
+ /** Compares staged mark bytes in `directory` with the digests they were declared with. */
44
+ export async function checkStagedAssets(
45
+ directory: string,
46
+ declared: readonly PublishedAsset[]
47
+ ): Promise<{ readonly missing: readonly string[]; readonly mismatched: readonly string[] }> {
48
+ const missing: string[] = []
49
+ const mismatched: string[] = []
50
+ for (const asset of declared) {
51
+ const bytes = await readStagedFile(directory, asset.name).catch((error: unknown) => {
52
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
53
+ throw error
54
+ })
55
+ if (!bytes) missing.push(asset.name)
56
+ else if (byteDigest(bytes) !== asset.digest || bytes.byteLength !== asset.bytes)
57
+ mismatched.push(asset.name)
58
+ }
59
+ return { missing, mismatched }
60
+ }