@adea-ai/plugins 4.0.1 → 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 (142) 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 +0 -5
  17. package/dist/catalog-store.js +1 -25
  18. package/dist/catalog-store.js.map +1 -1
  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 +6 -28
  23. package/dist/consumer-index.js +7 -8
  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 +3 -2
  34. package/dist/icon-mirror.js +51 -63
  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 -315
  40. package/dist/index.js +12 -1905
  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 +6 -3
  64. package/dist/publication.js +25 -12
  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 +2 -3
  70. package/dist/site-icons.js +4 -3
  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 +2 -4
  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.ts +234 -0
  101. package/src/build.ts +131 -0
  102. package/src/capabilities.ts +153 -0
  103. package/src/catalog-store.ts +1 -28
  104. package/src/concurrency.ts +19 -0
  105. package/src/consumer-index.test.ts +27 -21
  106. package/src/consumer-index.ts +12 -35
  107. package/src/digest.ts +55 -0
  108. package/src/fs-atomic.ts +32 -0
  109. package/src/harness.test.ts +4 -32
  110. package/src/harness.ts +2 -1
  111. package/src/icon-mirror.ts +57 -64
  112. package/src/icons.test.ts +0 -4
  113. package/src/icons.ts +4 -4
  114. package/src/index.test.ts +52 -48
  115. package/src/index.ts +50 -2583
  116. package/src/integrity-manifest.ts +41 -21
  117. package/src/json-values.ts +11 -0
  118. package/src/plugin-release.ts +457 -0
  119. package/src/plugin-skips.ts +110 -0
  120. package/src/policy.ts +39 -0
  121. package/src/portable-catalog.test.ts +0 -5
  122. package/src/portable-catalog.ts +16 -143
  123. package/src/portable-verify.ts +71 -0
  124. package/src/publication.test.ts +29 -1
  125. package/src/publication.ts +32 -13
  126. package/src/published-output.ts +60 -0
  127. package/src/site-icons.test.ts +0 -1
  128. package/src/site-icons.ts +4 -4
  129. package/src/snapshots.ts +287 -0
  130. package/src/source-digest.test.ts +3 -7
  131. package/src/source-resolution.ts +233 -0
  132. package/src/sources-json.test.ts +0 -1
  133. package/src/sources.ts +27 -33
  134. package/src/synchronize.ts +213 -0
  135. package/src/taxonomy.ts +87 -0
  136. package/src/upstream.ts +194 -0
  137. package/src/verify-artifacts.ts +365 -0
  138. package/src/write-artifacts.test.ts +0 -1
  139. package/dist/source-digest.d.ts +0 -2
  140. package/dist/source-digest.js +0 -12
  141. package/dist/source-digest.js.map +0 -1
  142. package/src/source-digest.ts +0 -14
@@ -101,7 +101,7 @@ describe('product placement', () => {
101
101
  categories: ['productivity'],
102
102
  }),
103
103
  ]),
104
- preference,
104
+ productPreference: preference,
105
105
  })
106
106
  expect(index.counts).toEqual({ plugins: 2, products: 1, redundantPlugins: 1 })
107
107
  expect(index.products['google-calendar']).toMatchObject({
@@ -121,7 +121,7 @@ describe('product placement', () => {
121
121
  plugin({ sourceId: 'claude-official', name: 'slack', categories: ['productivity'] }),
122
122
  plugin({ sourceId: 'openai-official', name: 'slack', categories: ['communication'] }),
123
123
  ])
124
- const index = buildConsumerIndex({ catalog: base, preference })
124
+ const index = buildConsumerIndex({ catalog: base, productPreference: preference })
125
125
  // Sources disagree about where Slack belongs; the product belongs in both,
126
126
  // while the canonical variant still decides identity.
127
127
  expect(index.products.slack!.categories).toEqual(['communication', 'productivity'])
@@ -156,7 +156,7 @@ describe('product placement', () => {
156
156
  { ...first, currentReleaseId: 'release:same' },
157
157
  { ...second, currentReleaseId: 'release:same' },
158
158
  ]),
159
- preference,
159
+ productPreference: preference,
160
160
  })
161
161
  expect(identical.products.atlan!.variantsIdentical).toBe(true)
162
162
  })
@@ -172,7 +172,7 @@ describe('product placement', () => {
172
172
  releaseRepositoryUrl: 'https://github.com/slackapi/slack-mcp-plugin',
173
173
  }),
174
174
  ]),
175
- preference,
175
+ productPreference: preference,
176
176
  })
177
177
  expect(index.products.slack!.pluginId).toBe('plugin:openai-official:slack')
178
178
  })
@@ -196,7 +196,10 @@ describe('product placement', () => {
196
196
  resolvedRepositoryUrl: 'https://github.com/openai-official/marketplace',
197
197
  }
198
198
  const vendorNow = { ...candidate, availableReleases: [oldWrapper, current] }
199
- const vendorIndex = buildConsumerIndex({ catalog: catalog([wrapper, vendorNow]), preference })
199
+ const vendorIndex = buildConsumerIndex({
200
+ catalog: catalog([wrapper, vendorNow]),
201
+ productPreference: preference,
202
+ })
200
203
  expect(vendorIndex.products.slack!.pluginId).toBe(candidate.pluginId)
201
204
  expect(vendorIndex.products.slack!.release.releaseId).toBe(current.releaseId)
202
205
 
@@ -206,7 +209,10 @@ describe('product placement', () => {
206
209
  }
207
210
  const oldVendor = { ...current, releaseId: 'release:old-vendor' }
208
211
  const wrapperNow = { ...candidate, availableReleases: [oldVendor, currentWrapper] }
209
- const wrapperIndex = buildConsumerIndex({ catalog: catalog([wrapper, wrapperNow]), preference })
212
+ const wrapperIndex = buildConsumerIndex({
213
+ catalog: catalog([wrapper, wrapperNow]),
214
+ productPreference: preference,
215
+ })
210
216
  expect(wrapperIndex.products.slack!.pluginId).toBe(wrapper.pluginId)
211
217
  })
212
218
 
@@ -216,7 +222,7 @@ describe('product placement', () => {
216
222
  plugin({ sourceId: 'openai-official', name: 'github', categories: ['developer-tools'] }),
217
223
  plugin({ sourceId: 'claude-official', name: 'github', categories: ['developer-tools'] }),
218
224
  ]),
219
- preference,
225
+ productPreference: preference,
220
226
  })
221
227
  expect(index.products.github!.pluginId).toBe('plugin:claude-official:github')
222
228
  })
@@ -227,7 +233,7 @@ describe('product placement', () => {
227
233
  plugin({ sourceId: 'claude-official', name: 'github', categories: ['developer-tools'] }),
228
234
  plugin({ sourceId: 'cursor-official', name: 'github', categories: ['developer-tools'] }),
229
235
  ]),
230
- preference: {
236
+ productPreference: {
231
237
  ...preference,
232
238
  canonicalOverrides: { github: 'plugin:cursor-official:github' },
233
239
  },
@@ -246,7 +252,7 @@ describe('product placement', () => {
246
252
  displayName: 'Figma',
247
253
  }),
248
254
  ]),
249
- preference,
255
+ productPreference: preference,
250
256
  })
251
257
  expect(index.products.figma!.displayName).toBe('Figma')
252
258
  expect(index.products.figma!.pluginId).toBe('plugin:claude-official:figma')
@@ -257,7 +263,7 @@ describe('product placement', () => {
257
263
  catalog: catalog([
258
264
  plugin({ sourceId: 'unlisted-source', name: 'tool', categories: ['developer-tools'] }),
259
265
  ]),
260
- preference,
266
+ productPreference: preference,
261
267
  })
262
268
  expect(index.products.tool!.pluginId).toBe('plugin:unlisted-source:tool')
263
269
  })
@@ -273,7 +279,7 @@ describe('category shelves', () => {
273
279
  plugin({ sourceId: 'claude-official', name, categories: ['productivity'] })
274
280
  )
275
281
  ),
276
- preference,
282
+ productPreference: preference,
277
283
  }
278
284
  expect(buildConsumerIndex({ ...input, topCount: 0 }).categories[0]!.topProductKeys).toEqual([])
279
285
  for (const topCount of [
@@ -300,7 +306,7 @@ describe('category shelves', () => {
300
306
  ),
301
307
  leading: { productivity: ['zeta', 'gamma'] },
302
308
  topCount: 4,
303
- preference,
309
+ productPreference: preference,
304
310
  })
305
311
  const category = index.categories[0]!
306
312
  expect(category.productKeys).toEqual([
@@ -328,7 +334,7 @@ describe('category shelves', () => {
328
334
  }),
329
335
  ]),
330
336
  leading: { 'education-research': ['teaching'] },
331
- preference,
337
+ productPreference: preference,
332
338
  })
333
339
  expect(
334
340
  index.categories.find((item) => item.category === 'developer-tools')!.topProductKeys
@@ -354,7 +360,7 @@ describe('category shelves', () => {
354
360
  security: ['nightvision', 'no-such-plugin'],
355
361
  'unknown-category': ['anything'],
356
362
  },
357
- preference,
363
+ productPreference: preference,
358
364
  })
359
365
  expect(index.diagnostics).toEqual([
360
366
  { category: 'developer-tools', name: 'getsentry', reason: 'PRODUCT_NOT_FOUND' },
@@ -393,7 +399,7 @@ describe('category shelves', () => {
393
399
  const index = buildConsumerIndex({
394
400
  catalog: catalog(entries),
395
401
  leading: { tools: ['shared', 'renamed', 'present'] },
396
- preference,
402
+ productPreference: preference,
397
403
  })
398
404
  expect(index.diagnostics).toEqual([
399
405
  { category: 'tools', name: 'renamed', reason: 'PRODUCT_NOT_IN_CATEGORY' },
@@ -425,7 +431,7 @@ describe('category shelves', () => {
425
431
  const index = buildConsumerIndex({
426
432
  catalog: catalog(entries),
427
433
  leading: { tools: ['named'] },
428
- preference,
434
+ productPreference: preference,
429
435
  })
430
436
  expect(index.diagnostics).toEqual([])
431
437
  expect(index.categories.find((item) => item.category === 'tools')!.topProductKeys[0]).toBe(
@@ -441,12 +447,12 @@ describe('category shelves', () => {
441
447
  const first = buildConsumerIndex({
442
448
  catalog: catalog(forward),
443
449
  leading: { productivity: ['gamma'] },
444
- preference,
450
+ productPreference: preference,
445
451
  })
446
452
  const second = buildConsumerIndex({
447
453
  catalog: catalog(forward.toReversed()),
448
454
  leading: { productivity: ['gamma'] },
449
- preference,
455
+ productPreference: preference,
450
456
  })
451
457
  expect(JSON.stringify(first)).toBe(JSON.stringify(second))
452
458
  })
@@ -527,7 +533,7 @@ describe('consumer index verification', () => {
527
533
  const index = buildConsumerIndex({
528
534
  catalog: base,
529
535
  leading: { productivity: ['b'] },
530
- preference,
536
+ productPreference: preference,
531
537
  })
532
538
  const result = verifyConsumerIndex(JSON.stringify(index), base)
533
539
  expect(result.hasConsumerIndex).toBe(true)
@@ -554,7 +560,7 @@ describe('consumer index verification', () => {
554
560
 
555
561
  test('rejects a category list that names a plugin outside the catalog', () => {
556
562
  const base = catalog(plugins)
557
- const index = buildConsumerIndex({ catalog: base, preference })
563
+ const index = buildConsumerIndex({ catalog: base, productPreference: preference })
558
564
  const tampered = {
559
565
  ...index,
560
566
  categories: index.categories.map((category) => ({
@@ -575,7 +581,7 @@ describe('consumer index verification', () => {
575
581
  categories: ['productivity', 'communication'],
576
582
  }),
577
583
  ])
578
- const index = buildConsumerIndex({ catalog: base, preference })
584
+ const index = buildConsumerIndex({ catalog: base, productPreference: preference })
579
585
  const tampered = {
580
586
  ...index,
581
587
  categories: index.categories.map((category) => ({
@@ -1,4 +1,5 @@
1
1
  import type { Catalog, Plugin, PluginRelease } from './schema.js'
2
+ import { PACKAGE_METADATA_KEY } from './agent-plugins-schema.js'
2
3
  import { iconAssetName, type IconContentType } from './icons.js'
3
4
  import { immutableAssetUrl } from './publication.js'
4
5
 
@@ -60,7 +61,7 @@ export function parseProductPreference(input: unknown): ProductPreference {
60
61
  export interface ProductIcon {
61
62
  /** `content` is the vendor's own plugin file; `favicon` is the product site's. */
62
63
  readonly kind: 'content' | 'favicon'
63
- /** Release asset name; fetch it from the release for this `catalogId`. */
64
+ /** Content-addressed asset name, published beside the catalog's artifacts. */
64
65
  readonly asset: string
65
66
  /**
66
67
  * Absolute, immutable URL for the asset, present when the build knows where
@@ -111,29 +112,6 @@ export interface ConsumerRelease {
111
112
  readonly packageDigest?: string
112
113
  }
113
114
 
114
- /** One installable product, resolved from every source variant that publishes it. */ /**
115
- * The install-relevant facts of the release a product installs.
116
- *
117
- * A browsing client shows a plugin, then plans an install from it: the digest it
118
- * must pin, the connectors and credentials setup will ask for, and the
119
- * capabilities the harness has to negotiate. Carrying them here means the grid
120
- * needs no second artifact.
121
- */
122
- export interface ConsumerRelease {
123
- readonly releaseId: string
124
- readonly canonicalContentDigest: string
125
- readonly contentResolution: string
126
- readonly capabilities: readonly { readonly type: string; readonly name: string }[]
127
- readonly requiredConnectors: readonly string[]
128
- readonly requiredCredentials: readonly string[]
129
- /** Commit the release was resolved from, for audit and update state. */
130
- readonly sourceRevision: string
131
- readonly upstreamVersion?: string
132
- /** Canonical package status, when the release carries one. */
133
- readonly packageStatus?: string
134
- readonly packageDigest?: string
135
- }
136
-
137
115
  /** One installable product, resolved from every source variant that publishes it. */
138
116
  export interface ConsumerProduct {
139
117
  readonly productKey: string
@@ -319,7 +297,9 @@ function nestedString(value: unknown, key: string): string | undefined {
319
297
 
320
298
  function describeRelease(plugin: Plugin): ConsumerRelease {
321
299
  const release = currentRelease(plugin)!
322
- const packages = ((release.releaseMetadata ?? {}) as Record<string, unknown>).agentPlugins
300
+ const packages = release.releaseMetadata[PACKAGE_METADATA_KEY]
301
+ const packageStatus = nestedString(packages, 'status')
302
+ const packageDigest = nestedString(packages, 'packageDigest')
323
303
  return {
324
304
  releaseId: release.releaseId,
325
305
  canonicalContentDigest: release.canonicalContentDigest,
@@ -332,12 +312,8 @@ function describeRelease(plugin: Plugin): ConsumerRelease {
332
312
  requiredCredentials: [...release.requiredCredentials],
333
313
  sourceRevision: release.resolvedCommitSha,
334
314
  ...(release.upstreamVersion ? { upstreamVersion: release.upstreamVersion } : {}),
335
- ...(nestedString(packages, 'status')
336
- ? { packageStatus: nestedString(packages, 'status')! }
337
- : {}),
338
- ...(nestedString(packages, 'packageDigest')
339
- ? { packageDigest: nestedString(packages, 'packageDigest')! }
340
- : {}),
315
+ ...(packageStatus ? { packageStatus } : {}),
316
+ ...(packageDigest ? { packageDigest } : {}),
341
317
  }
342
318
  }
343
319
 
@@ -407,16 +383,17 @@ function compactCompatibility(plugin: Plugin): Record<string, string> {
407
383
  * stable identifiers.
408
384
  */
409
385
  export function buildConsumerIndex(input: {
410
- readonly catalog: Pick<Catalog, 'plugins' | 'sources'>
386
+ /** A catalog without its ID yields records with no published asset URLs. */
387
+ readonly catalog: Pick<Catalog, 'plugins' | 'sources'> & { readonly catalogId?: string }
411
388
  readonly leading?: Readonly<Record<string, readonly string[]>>
412
389
  readonly topCount?: number
413
- readonly preference?: ProductPreference
390
+ readonly productPreference?: ProductPreference
414
391
  /** Where the catalog will be published, so records can carry absolute URLs. */
415
392
  readonly publicationRepositoryUrl?: string
416
393
  /** Committed asset base a browser can fetch directly; preferred when set. */
417
394
  readonly publicationAssetsBaseUrl?: string
418
395
  }): ConsumerIndex {
419
- const preference = input.preference ?? {
396
+ const preference = input.productPreference ?? {
420
397
  schemaVersion: 1 as const,
421
398
  sourcePreference: [...DEFAULT_SOURCE_PREFERENCE],
422
399
  }
@@ -432,7 +409,7 @@ export function buildConsumerIndex(input: {
432
409
  if (list) list.push(plugin)
433
410
  else variantsByProduct.set(plugin.productGroupingKey, [plugin])
434
411
  }
435
- const catalogId = (input.catalog as { catalogId?: string }).catalogId ?? ''
412
+ const catalogId = input.catalog.catalogId ?? ''
436
413
  const products: Record<string, ConsumerProduct> = {}
437
414
  for (const [productKey, variants] of [...variantsByProduct].toSorted((left, right) =>
438
415
  left[0].localeCompare(right[0])
package/src/digest.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ const DIGEST_PREFIX = 'sha256:'
4
+
5
+ function sha256Hex(data: string | Uint8Array): string {
6
+ return createHash('sha256').update(data).digest('hex')
7
+ }
8
+
9
+ /** Ordinal (UTF-16 code unit) order: the same on every machine and locale. */
10
+ export function ordinalCompare(left: string, right: string): number {
11
+ return left < right ? -1 : left > right ? 1 : 0
12
+ }
13
+
14
+ export function canonicalJson(value: unknown): string {
15
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
16
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
17
+ const object = value as Record<string, unknown>
18
+ return `{${Object.keys(object)
19
+ .toSorted()
20
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
21
+ .join(',')}}`
22
+ }
23
+
24
+ /** Digest of a JSON value's canonical form, in the catalog's digest format. */
25
+ export function digest(value: unknown): string {
26
+ return `${DIGEST_PREFIX}${sha256Hex(canonicalJson(value))}`
27
+ }
28
+
29
+ /** Digest of one byte buffer (a string is hashed as UTF-8), in the catalog's digest format. */
30
+ export function byteDigest(bytes: string | Uint8Array): string {
31
+ return `${DIGEST_PREFIX}${sha256Hex(bytes)}`
32
+ }
33
+
34
+ /** Shared source-byte identity for catalog provenance and canonical package recipes. */
35
+ export function sourceDigest(files: ReadonlyMap<string, Uint8Array>): string {
36
+ const hash = createHash('sha256')
37
+ // English matches the compiler runtime; ordinal ties distinguish equivalent Unicode names.
38
+ for (const [path, bytes] of [...files].toSorted(
39
+ ([a], [b]) => a.localeCompare(b, 'en') || ordinalCompare(a, b)
40
+ )) {
41
+ hash.update(`${path.length}:${path}:${bytes.byteLength}:`)
42
+ hash.update(bytes)
43
+ }
44
+ return `${DIGEST_PREFIX}${hash.digest('hex')}`
45
+ }
46
+
47
+ /** A content-addressed identifier such as `catalog:<hex>` for a JSON body. */
48
+ export function contentId(prefix: string, body: unknown): string {
49
+ return `${prefix}:${sha256Hex(canonicalJson(body))}`
50
+ }
51
+
52
+ /** The hex part of a `sha256:<hex>` digest. */
53
+ export function digestHex(value: string): string {
54
+ return value.startsWith(DIGEST_PREFIX) ? value.slice(DIGEST_PREFIX.length) : value
55
+ }
@@ -0,0 +1,32 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { promises as fs } from 'node:fs'
3
+ import { basename, dirname, join } from 'node:path'
4
+
5
+ /** Creates `path`, failing if it exists, and syncs the bytes before returning. */
6
+ export async function writeDurably(path: string, content: string | Uint8Array): Promise<void> {
7
+ const handle = await fs.open(path, 'wx')
8
+ try {
9
+ await handle.writeFile(content)
10
+ await handle.sync()
11
+ } finally {
12
+ await handle.close()
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Writes one file so a reader sees either the old or the new bytes. For
18
+ * content-addressed assets and the browsing index beside the catalog.
19
+ */
20
+ export async function writeFileAtomically(
21
+ path: string,
22
+ content: string | Uint8Array
23
+ ): Promise<void> {
24
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`)
25
+ try {
26
+ await writeDurably(temporary, content)
27
+ await fs.rename(temporary, path)
28
+ } catch (error) {
29
+ await fs.rm(temporary, { force: true })
30
+ throw error
31
+ }
32
+ }
@@ -129,34 +129,6 @@ describe('Control Plane installation plan v2', () => {
129
129
  assert.equal(plan.compatibility, 'partial')
130
130
  assert.ok(plan.disabled.some((item) => item.component === 'hooks'))
131
131
  })
132
- it('preserves instance data across releases and harnesses but isolates other instances', () => {
133
- const first = createInstallationPlan(input())
134
- const updatedPkg = packageFixture({ 'CHANGELOG.md': 'A new release.' })
135
- const updated = createInstallationPlan({
136
- ...input(updatedPkg),
137
- releaseId: `release:${'c'.repeat(64)}`,
138
- profile: profile({ harness: 'another-harness' }),
139
- })
140
- assert.equal(first.dataKey, updated.dataKey)
141
- assert.notEqual(first.packageKey, updated.packageKey)
142
- assert.notEqual(
143
- first.dataKey,
144
- createInstallationPlan({ ...input(), instanceId: 'workspace-b/user-a' }).dataKey
145
- )
146
- assert.notEqual(
147
- first.dataKey,
148
- createInstallationPlan({ ...input(), pluginId: 'plugin:other:issues' }).dataKey
149
- )
150
- })
151
- it('does not reuse a package cache across source commits with potentially different file modes', () => {
152
- const original = input()
153
- const other = { ...original, source: { ...original.source, commitSha: 'c'.repeat(40) } }
154
- assert.notEqual(
155
- createInstallationPlan(original).packageKey,
156
- createInstallationPlan(other).packageKey
157
- )
158
- assert.equal(createInstallationPlan(original).dataKey, createInstallationPlan(other).dataKey)
159
- })
160
132
  it('rejects mismatched source provenance and unversioned capability claims', () => {
161
133
  const args = input()
162
134
  assert.throws(() =>
@@ -442,7 +414,7 @@ const MATRIX: {
442
414
  disabled: ['mcp:api'],
443
415
  },
444
416
  {
445
- name: 'the same package is adopted partially when the caller opts in',
417
+ name: 'a package missing one native transport is adopted partially on opt-in',
446
418
  pkg: 'full',
447
419
  profile: 'nativeWithoutHttp',
448
420
  allowPartial: true,
@@ -483,7 +455,7 @@ const MATRIX: {
483
455
  disabled: ['hooks'],
484
456
  },
485
457
  {
486
- name: 'the same package is adopted partially when the caller opts in',
458
+ name: 'a package with a non-portable extra is adopted partially on opt-in',
487
459
  pkg: 'withHooks',
488
460
  profile: 'native',
489
461
  allowPartial: true,
@@ -560,10 +532,11 @@ describe('upgrade identity isolation', () => {
560
532
  )
561
533
 
562
534
  // An upgraded package (new bytes) is a different PACKAGE, under the same
563
- // instance data: the data is preserved across the upgrade.
535
+ // instance data: the data is preserved across the upgrade and the harness.
564
536
  const upgraded = createInstallationPlan({
565
537
  ...input(packageFixture({ 'CHANGELOG.md': 'A new release.' })),
566
538
  releaseId: `release:${'c'.repeat(64)}`,
539
+ profile: profile({ harness: 'another-harness' }),
567
540
  })
568
541
  assert.notEqual(first.packageKey, upgraded.packageKey)
569
542
  assert.equal(first.dataKey, upgraded.dataKey)
@@ -575,7 +548,6 @@ describe('upgrade identity isolation', () => {
575
548
  const rereleased = createInstallationPlan({ ...base, releaseId: `release:${'d'.repeat(64)}` })
576
549
  assert.equal(first.packageKey, rereleased.packageKey)
577
550
  assert.equal(first.dataKey, rereleased.dataKey)
578
- assert.notEqual(first.releaseId, rereleased.releaseId)
579
551
 
580
552
  // A different source commit is a different package identity under the same
581
553
  // instance data.
package/src/harness.ts CHANGED
@@ -9,7 +9,8 @@ import {
9
9
  type InstallationPlan,
10
10
  } from './agent-plugins-schema.js'
11
11
  import type { Catalog } from './schema.js'
12
- import { verifyPortableCatalog, PACKAGE_METADATA_KEY } from './portable-catalog.js'
12
+ import { PACKAGE_METADATA_KEY } from './agent-plugins-schema.js'
13
+ import { verifyPortableCatalog } from './portable-verify.js'
13
14
  import { verifyAgentPackage, validateServer } from './agent-plugins-catalog.js'
14
15
 
15
16
  export interface InstallationInput {
@@ -1,10 +1,11 @@
1
1
  import { promises as fs } from 'node:fs'
2
- import { dirname, join } from 'node:path'
2
+ import { join } from 'node:path'
3
3
  import type { Catalog } from './schema.js'
4
- import { byteDigest, type PublishedAsset } from './index.js'
4
+ import { byteDigest } from './digest.js'
5
5
  import { iconAssetName, inspectIconBytes, type IconContentType } from './icons.js'
6
6
  import type { ConsumerIndex } from './consumer-index.js'
7
- import { writeFileAtomically } from './catalog-store.js'
7
+ import { writeFileAtomically } from './fs-atomic.js'
8
+ import { publishedAssetOf, type PublishedAsset } from './integrity-manifest.js'
8
9
 
9
10
  /**
10
11
  * Where an icon's bytes come from, so a mirror needs no tree walk and no
@@ -36,7 +37,7 @@ export type IconSource =
36
37
  readonly homepage: string
37
38
  }
38
39
 
39
- export interface IconMirrorResult {
40
+ interface IconMirrorResult {
40
41
  /** Icons whose verified bytes are present in the assets directory. */
41
42
  readonly staged: readonly PublishedAsset[]
42
43
  /** Icons that could not be staged, with the reason, never silently dropped. */
@@ -116,78 +117,54 @@ export async function stageIconAssets(input: {
116
117
  /** Reads the icon bytes for a source; live fetches raw content, offline reads fixtures. */
117
118
  readonly readBytes: (source: IconSource) => Promise<Uint8Array>
118
119
  }): Promise<IconMirrorResult> {
119
- const staged: PublishedAsset[] = []
120
+ const staged = new Map<string, PublishedAsset>()
120
121
  const skipped = new Map<string, string>()
121
- const complete = new Set<string>()
122
122
  const skip = (asset: string, reason: string) => {
123
123
  if (!skipped.has(asset)) skipped.set(asset, reason)
124
124
  }
125
125
  await fs.mkdir(input.assetsDirectory, { recursive: true })
126
126
  for (const source of input.sources) {
127
- if (complete.has(source.asset)) continue
128
- const destination = join(input.assetsDirectory, source.asset)
129
- const existing = await fs.readFile(destination).catch(() => undefined)
130
- if (existing && byteDigest(existing) === source.digest) {
131
- staged.push(publishedAssetOf(source))
132
- complete.add(source.asset)
133
- skipped.delete(source.asset)
134
- continue
135
- }
136
- if (existing) {
127
+ if (staged.has(source.asset)) continue
128
+ const existing = await stagedState(input.assetsDirectory, source.asset, source.digest)
129
+ if (existing === 'conflict') {
137
130
  skip(source.asset, 'ASSET_CONFLICT')
138
131
  continue
139
132
  }
140
- let bytes: Uint8Array
141
- try {
142
- bytes = await input.readBytes(source)
143
- } catch (error) {
144
- skip(source.asset, `FETCH_FAILED: ${error instanceof Error ? error.message : String(error)}`)
145
- continue
146
- }
147
- const inspected = inspectIconBytes(bytes)
148
- if (!('contentType' in inspected)) {
149
- skip(source.asset, inspected.rejected)
150
- continue
151
- }
152
- if (inspected.contentType !== source.contentType) {
153
- skip(source.asset, 'CONTENT_TYPE_CHANGED')
154
- continue
155
- }
156
- if (byteDigest(bytes) !== source.digest || bytes.byteLength !== source.bytes) {
157
- skip(source.asset, 'DIGEST_MISMATCH')
158
- continue
133
+ if (existing === 'absent') {
134
+ let bytes: Uint8Array
135
+ try {
136
+ bytes = await input.readBytes(source)
137
+ } catch (error) {
138
+ skip(
139
+ source.asset,
140
+ `FETCH_FAILED: ${error instanceof Error ? error.message : String(error)}`
141
+ )
142
+ continue
143
+ }
144
+ const inspected = inspectIconBytes(bytes)
145
+ if (!('contentType' in inspected)) {
146
+ skip(source.asset, inspected.rejected)
147
+ continue
148
+ }
149
+ if (inspected.contentType !== source.contentType) {
150
+ skip(source.asset, 'CONTENT_TYPE_CHANGED')
151
+ continue
152
+ }
153
+ if (byteDigest(bytes) !== source.digest || bytes.byteLength !== source.bytes) {
154
+ skip(source.asset, 'DIGEST_MISMATCH')
155
+ continue
156
+ }
157
+ await writeFileAtomically(join(input.assetsDirectory, source.asset), bytes)
159
158
  }
160
- await fs.mkdir(dirname(destination), { recursive: true })
161
- await writeFileAtomically(destination, bytes)
162
- staged.push(publishedAssetOf(source))
163
- complete.add(source.asset)
159
+ staged.set(source.asset, publishedAssetOf(source))
164
160
  skipped.delete(source.asset)
165
161
  }
166
- // Distinct assets: two products can share one mark, and the catalog declares
167
- // each asset once.
168
162
  return {
169
- staged: dedupeAssets(staged),
163
+ staged: [...staged.values()],
170
164
  skipped: [...skipped].map(([asset, reason]) => ({ asset, reason })),
171
165
  }
172
166
  }
173
167
 
174
- function dedupeAssets(assets: readonly PublishedAsset[]): readonly PublishedAsset[] {
175
- const unique = new Map<string, PublishedAsset>()
176
- for (const asset of assets) unique.set(asset.name, asset)
177
- return [...unique.values()]
178
- }
179
-
180
- function publishedAssetOf(source: IconSource): PublishedAsset {
181
- return {
182
- name: source.asset,
183
- digest: source.digest,
184
- bytes: source.bytes,
185
- contentType: source.contentType,
186
- kind: source.kind,
187
- origin: source.kind === 'content' ? source.sourcePath : source.url,
188
- }
189
- }
190
-
191
168
  /**
192
169
  * Writes one verified icon into the assets directory.
193
170
  *
@@ -201,9 +178,25 @@ export async function stageIconBytes(
201
178
  asset: string,
202
179
  bytes: Uint8Array
203
180
  ): Promise<void> {
204
- const destination = join(assetsDirectory, asset)
205
- const existing = await fs.readFile(destination).catch(() => undefined)
206
- if (existing && byteDigest(existing) === byteDigest(bytes)) return
207
- await fs.mkdir(dirname(destination), { recursive: true })
208
- await writeFileAtomically(destination, bytes)
181
+ // These bytes are the ones the digest was computed from, so they replace a
182
+ // differing file rather than fail the build over it.
183
+ if ((await stagedState(assetsDirectory, asset, byteDigest(bytes))) === 'present') return
184
+ await fs.mkdir(assetsDirectory, { recursive: true })
185
+ await writeFileAtomically(join(assetsDirectory, asset), bytes)
186
+ }
187
+
188
+ /** Whether a content-addressed asset is already staged with the expected bytes. */
189
+ async function stagedState(
190
+ directory: string,
191
+ asset: string,
192
+ digest: string
193
+ ): Promise<'absent' | 'present' | 'conflict'> {
194
+ let existing: Uint8Array
195
+ try {
196
+ existing = await fs.readFile(join(directory, asset))
197
+ } catch (error) {
198
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'absent'
199
+ throw error
200
+ }
201
+ return byteDigest(existing) === digest ? 'present' : 'conflict'
209
202
  }
package/src/icons.test.ts CHANGED
@@ -138,10 +138,6 @@ describe('icon asset names', () => {
138
138
  expect(iconAssetName(`sha256:${'b'.repeat(64)}`, 'image/svg+xml')).toBe(
139
139
  `icon-${'b'.repeat(32)}.svg`
140
140
  )
141
- // Identical bytes always resolve to the same asset, so caches stay warm.
142
- expect(iconAssetName(`sha256:${'c'.repeat(64)}`, 'image/webp')).toBe(
143
- iconAssetName(`sha256:${'c'.repeat(64)}`, 'image/webp')
144
- )
145
141
  })
146
142
 
147
143
  test('refuses a content type it cannot name', () => {
package/src/icons.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { digestHex } from './digest.js'
1
2
  /**
2
3
  * Compile-time icon resolution.
3
4
  *
@@ -209,14 +210,13 @@ const EXTENSION_BY_CONTENT_TYPE: Readonly<Record<IconContentType, string>> = {
209
210
  }
210
211
 
211
212
  /**
212
- * Content-addressed asset name. Release assets cannot contain `/`, so the
213
- * digest prefix and extension form one flat name that any release can host.
213
+ * Content-addressed asset name. Published names are flat (no `/`), so the
214
+ * digest and extension form one name that is safe as a file and a URL segment.
214
215
  */
215
216
  export function iconAssetName(digest: string, contentType: IconContentType): string {
216
217
  const extension = EXTENSION_BY_CONTENT_TYPE[contentType]
217
218
  if (!extension) throw new Error(`ICON_CONTENT_TYPE_UNSUPPORTED: ${contentType}`)
218
- const hex = digest.startsWith('sha256:') ? digest.slice('sha256:'.length) : digest
219
- return `icon-${hex.slice(0, 32)}.${extension}`
219
+ return `icon-${digestHex(digest).slice(0, 32)}.${extension}`
220
220
  }
221
221
 
222
222
  /**