@adea-ai/plugins 1.0.0

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 (79) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +74 -0
  3. package/dist/agent-plugins-catalog.d.ts +26 -0
  4. package/dist/agent-plugins-catalog.js +699 -0
  5. package/dist/agent-plugins-catalog.js.map +1 -0
  6. package/dist/agent-plugins-harness.d.ts +54 -0
  7. package/dist/agent-plugins-harness.js +212 -0
  8. package/dist/agent-plugins-harness.js.map +1 -0
  9. package/dist/agent-plugins-schema.d.ts +402 -0
  10. package/dist/agent-plugins-schema.js +200 -0
  11. package/dist/agent-plugins-schema.js.map +1 -0
  12. package/dist/consumer-index.d.ts +217 -0
  13. package/dist/consumer-index.js +358 -0
  14. package/dist/consumer-index.js.map +1 -0
  15. package/dist/display-name.d.ts +8 -0
  16. package/dist/display-name.js +160 -0
  17. package/dist/display-name.js.map +1 -0
  18. package/dist/harness.d.ts +13 -0
  19. package/dist/harness.js +169 -0
  20. package/dist/harness.js.map +1 -0
  21. package/dist/icon-mirror.d.ts +69 -0
  22. package/dist/icon-mirror.js +147 -0
  23. package/dist/icon-mirror.js.map +1 -0
  24. package/dist/icons.d.ts +65 -0
  25. package/dist/icons.js +268 -0
  26. package/dist/icons.js.map +1 -0
  27. package/dist/index.d.ts +304 -0
  28. package/dist/index.js +1877 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/portable-catalog.d.ts +9 -0
  31. package/dist/portable-catalog.js +175 -0
  32. package/dist/portable-catalog.js.map +1 -0
  33. package/dist/publication.d.ts +98 -0
  34. package/dist/publication.js +121 -0
  35. package/dist/publication.js.map +1 -0
  36. package/dist/schema.d.ts +705 -0
  37. package/dist/schema.js +267 -0
  38. package/dist/schema.js.map +1 -0
  39. package/dist/site-icons.d.ts +55 -0
  40. package/dist/site-icons.js +155 -0
  41. package/dist/site-icons.js.map +1 -0
  42. package/dist/sources.d.ts +54 -0
  43. package/dist/sources.js +306 -0
  44. package/dist/sources.js.map +1 -0
  45. package/package.json +102 -0
  46. package/schemas/agent-package.v1.json +387 -0
  47. package/schemas/catalog.v1.schema.json +407 -0
  48. package/schemas/compatibility.v1.schema.json +23 -0
  49. package/schemas/harness-profile.v1.json +75 -0
  50. package/schemas/installation-plan.v2.json +695 -0
  51. package/schemas/sources-lock.v1.schema.json +61 -0
  52. package/src/agent-plugins-catalog.test.ts +402 -0
  53. package/src/agent-plugins-catalog.ts +800 -0
  54. package/src/agent-plugins-harness.test.ts +272 -0
  55. package/src/agent-plugins-harness.ts +281 -0
  56. package/src/agent-plugins-schema.test.ts +27 -0
  57. package/src/agent-plugins-schema.ts +219 -0
  58. package/src/artifacts.test.ts +545 -0
  59. package/src/catalog-installation.test.ts +42 -0
  60. package/src/consumer-index.test.ts +465 -0
  61. package/src/consumer-index.ts +592 -0
  62. package/src/display-name.test.ts +56 -0
  63. package/src/display-name.ts +157 -0
  64. package/src/harness.ts +202 -0
  65. package/src/icon-mirror.ts +196 -0
  66. package/src/icons.test.ts +185 -0
  67. package/src/icons.ts +297 -0
  68. package/src/index.test.ts +888 -0
  69. package/src/index.ts +2538 -0
  70. package/src/portable-catalog.test.ts +186 -0
  71. package/src/portable-catalog.ts +203 -0
  72. package/src/publication.test.ts +117 -0
  73. package/src/publication.ts +171 -0
  74. package/src/schema.ts +309 -0
  75. package/src/site-icon-fallback.test.ts +267 -0
  76. package/src/site-icons.test.ts +171 -0
  77. package/src/site-icons.ts +161 -0
  78. package/src/sources.ts +360 -0
  79. package/src/upstream-headers.test.ts +148 -0
@@ -0,0 +1,888 @@
1
+ import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'node:fs/promises'
2
+ import { readdirSync, readFileSync } from 'node:fs'
3
+ import { gzipSync } from 'node:zlib'
4
+ import { pack } from 'tar-stream'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { describe, expect, test } from 'bun:test'
8
+ import {
9
+ buildCatalog,
10
+ bytesDigest,
11
+ digest,
12
+ NetworkSnapshotLoader,
13
+ normalizeCategory,
14
+ snapshotFromDirectory,
15
+ stablePluginId,
16
+ stableReleaseId,
17
+ synchronize,
18
+ verifyArtifacts,
19
+ writeArtifacts,
20
+ type CatalogPolicy,
21
+ type CategoryMap,
22
+ type ProductAliases,
23
+ type ResolvedSource,
24
+ } from './index.js'
25
+ import {
26
+ parseJsonDocument,
27
+ parseMarketplaceManifest,
28
+ parseSourceSpec,
29
+ type SourceConfig,
30
+ } from './sources.js'
31
+ import { createMaterializationPlan, supportedHarnesses } from './harness.js'
32
+
33
+ const sources: SourceConfig[] = [
34
+ {
35
+ sourceId: 'openai-official',
36
+ displayName: 'OpenAI fixture',
37
+ repositoryUrl: 'https://github.com/openai/plugins',
38
+ marketplaceDialect: 'openai',
39
+ manifestPath: '.agents/plugins/marketplace.json',
40
+ defaultBranch: 'main',
41
+ trustClassification: 'official',
42
+ fixturePath: 'openai',
43
+ fixtureCommitSha: '1111111111111111111111111111111111111111',
44
+ },
45
+ {
46
+ sourceId: 'cursor-official',
47
+ displayName: 'Cursor fixture',
48
+ repositoryUrl: 'https://github.com/cursor/plugins',
49
+ marketplaceDialect: 'cursor',
50
+ manifestPath: '.cursor-plugin/marketplace.json',
51
+ defaultBranch: 'main',
52
+ trustClassification: 'official',
53
+ fixturePath: 'cursor',
54
+ fixtureCommitSha: '2222222222222222222222222222222222222222',
55
+ },
56
+ {
57
+ sourceId: 'claude-official',
58
+ displayName: 'Claude fixture',
59
+ repositoryUrl: 'https://github.com/anthropics/claude-plugins-official',
60
+ marketplaceDialect: 'claude',
61
+ manifestPath: '.claude-plugin/marketplace.json',
62
+ defaultBranch: 'main',
63
+ trustClassification: 'official',
64
+ fixturePath: 'claude',
65
+ fixtureCommitSha: '3333333333333333333333333333333333333333',
66
+ },
67
+ {
68
+ sourceId: 'knowledge-work-official',
69
+ displayName: 'Knowledge fixture',
70
+ repositoryUrl: 'https://github.com/anthropics/knowledge-work-plugins',
71
+ marketplaceDialect: 'claude',
72
+ manifestPath: '.claude-plugin/marketplace.json',
73
+ defaultBranch: 'main',
74
+ trustClassification: 'official',
75
+ fixturePath: 'knowledge',
76
+ fixtureCommitSha: '4444444444444444444444444444444444444444',
77
+ },
78
+ ]
79
+
80
+ const categoryMap: CategoryMap = {
81
+ aliases: {
82
+ productivity: 'productivity',
83
+ 'developer tools': 'developer-tools',
84
+ security: 'security',
85
+ sales: 'sales',
86
+ data: 'data',
87
+ },
88
+ fallback: 'other',
89
+ }
90
+ const productAliases: ProductAliases = { aliases: { linear: 'linear', calendar: 'calendar' } }
91
+ const policy: CatalogPolicy = {
92
+ allowedRepositoryProtocols: ['https:'],
93
+ allowedRepositoryHosts: ['github.com', 'www.github.com'],
94
+ maxFilesPerPlugin: 100,
95
+ maxBytesPerPlugin: 100_000,
96
+ maxFileBytes: 20_000,
97
+ maxMarketplacePlugins: 100,
98
+ denyExecutableLifecycleScripts: true,
99
+ publishRequiresCompleteContent: false,
100
+ sensitiveCapabilityTypes: [
101
+ 'mcp-server',
102
+ 'connector',
103
+ 'hook',
104
+ 'browser',
105
+ 'scheduled-task',
106
+ 'executable',
107
+ ],
108
+ }
109
+
110
+ function stubFetch(handler: (url: string) => Response | Promise<Response>) {
111
+ const original = globalThis.fetch
112
+ const calls: string[] = []
113
+ globalThis.fetch = (async (input: unknown) => {
114
+ const url = String(input)
115
+ calls.push(url)
116
+ return handler(url)
117
+ }) as typeof fetch
118
+ return {
119
+ calls,
120
+ restore: () => {
121
+ globalThis.fetch = original
122
+ },
123
+ }
124
+ }
125
+
126
+ const jsonResponse = (body: unknown, status = 200, headers: Record<string, string> = {}) =>
127
+ new Response(JSON.stringify(body), {
128
+ status,
129
+ headers: { 'content-type': 'application/json', ...headers },
130
+ })
131
+
132
+ const treePayload = (paths: string[]) => ({
133
+ tree: paths.map((path) => ({
134
+ path,
135
+ type: 'blob',
136
+ sha: 'b'.repeat(40),
137
+ size: 64,
138
+ mode: '100644',
139
+ })),
140
+ })
141
+
142
+ const ARCHIVE_ROOT = 'demo-a1b2c3d'
143
+
144
+ /**
145
+ * A real gzipped tarball, because the loader streams and parses the archive
146
+ * rather than trusting a stubbed body.
147
+ */
148
+ async function tarballResponse(
149
+ entries: readonly { path: string; body?: string; type?: 'symlink'; linkpath?: string }[]
150
+ ): Promise<Response> {
151
+ const packer = pack()
152
+ const chunks: Buffer[] = []
153
+ packer.on('data', (chunk: unknown) => chunks.push(chunk as Buffer))
154
+ const finished = new Promise<void>((done, fail) => {
155
+ packer.on('end', () => done())
156
+ packer.on('error', fail)
157
+ })
158
+ for (const entry of entries) {
159
+ // GitHub wraps every entry in one top-level directory; the loader strips
160
+ // it, so fixtures must carry it too.
161
+ const name = `${ARCHIVE_ROOT}/${entry.path}`
162
+ if (entry.type === 'symlink')
163
+ packer.entry({ name, type: 'symlink', linkname: entry.linkpath ?? '../escape' }, '')
164
+ else packer.entry({ name }, entry.body ?? '')
165
+ }
166
+ packer.finalize()
167
+ await finished
168
+ return new Response(gzipSync(Buffer.concat(chunks)), {
169
+ status: 200,
170
+ headers: { 'content-type': 'application/gzip' },
171
+ })
172
+ }
173
+
174
+ const isArchive = (url: string): boolean => url.includes('/tarball/')
175
+
176
+ /**
177
+ * Exact hostname match. A substring test on a URL is not a host check: the
178
+ * needle can appear anywhere in the string.
179
+ */
180
+ function isRawGitHubHost(url: string): boolean {
181
+ try {
182
+ return new URL(url).hostname === 'raw.githubusercontent.com'
183
+ } catch {
184
+ return false
185
+ }
186
+ }
187
+
188
+ function testEntry(name: string, subdir: string) {
189
+ return {
190
+ name,
191
+ description: `${name} plugin`,
192
+ categories: ['productivity'] as readonly string[],
193
+ keywords: [] as readonly string[],
194
+ authors: ['Acme'],
195
+ icons: [] as readonly string[],
196
+ source: { kind: 'local', path: subdir } as const,
197
+ policy: {},
198
+ raw: {},
199
+ entryDigest: `sha256:${'e'.repeat(64)}`,
200
+ }
201
+ }
202
+
203
+ function walkFixtureFiles(dir: string, base = ''): string[] {
204
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
205
+ const full = join(dir, entry.name)
206
+ const rel = base === '' ? entry.name : `${base}/${entry.name}`
207
+ return entry.isDirectory() ? walkFixtureFiles(full, rel) : [rel]
208
+ })
209
+ }
210
+
211
+ describe('source adapters', () => {
212
+ test('accepts all supported marketplace dialects and local source forms', async () => {
213
+ for (const source of sources) {
214
+ const text = await Bun.file(
215
+ join(process.cwd(), 'fixtures', source.fixturePath!, source.manifestPath)
216
+ ).text()
217
+ const marketplace = parseMarketplaceManifest(parseJsonDocument(text), source)
218
+ expect(marketplace.entries).toHaveLength(2)
219
+ expect(marketplace.entries[0]?.source.kind).toBe('local')
220
+ }
221
+ expect(parseSourceSpec('./plugins/demo', sources[0]!).kind).toBe('local')
222
+ const gitSource = parseSourceSpec(
223
+ {
224
+ source: 'git-subdir',
225
+ url: 'https://github.com/acme/plugins.git',
226
+ path: 'plugins/demo',
227
+ ref: 'main',
228
+ sha: 'a'.repeat(40),
229
+ },
230
+ sources[0]!
231
+ )
232
+ expect(gitSource.kind === 'git' ? gitSource.subdirectory : undefined).toBe('plugins/demo')
233
+ })
234
+
235
+ test('rejects duplicate keys, traversal, and unsupported URL schemes', () => {
236
+ expect(() => parseJsonDocument('{"plugins":[],"plugins":[]}')).toThrow('JSON_DUPLICATE_KEY')
237
+ expect(() => parseSourceSpec({ source: 'local', path: '../outside' }, sources[0]!)).toThrow(
238
+ 'PATH_TRAVERSAL'
239
+ )
240
+ expect(() => parseSourceSpec('ftp://example.com/plugin', sources[0]!)).toThrow(
241
+ 'PLUGIN_SOURCE_SCHEME_UNSUPPORTED'
242
+ )
243
+ expect(() =>
244
+ parseSourceSpec({ source: 'url', url: 'http://github.com/acme/plugin' }, sources[0]!)
245
+ ).toThrow('GIT_URL_PROTOCOL_UNSUPPORTED')
246
+ expect(() => parseSourceSpec('https://github.com:444/acme/plugin', sources[0]!)).toThrow(
247
+ 'GIT_URL_CREDENTIALS_OR_QUERY_FORBIDDEN'
248
+ )
249
+ expect(() => parseMarketplaceManifest({}, sources[0]!)).toThrow('MARKETPLACE_PLUGINS_REQUIRED')
250
+ })
251
+ })
252
+
253
+ describe('deterministic catalog synchronization', () => {
254
+ test('produces byte-identical complete artifacts from the same fixture commits', async () => {
255
+ const first = await synchronize({
256
+ sources,
257
+ categoryMap,
258
+ productAliases,
259
+ policy,
260
+ mode: 'offline',
261
+ fixtureRoot: join(process.cwd(), 'fixtures'),
262
+ })
263
+ const second = await synchronize({
264
+ sources,
265
+ categoryMap,
266
+ productAliases,
267
+ policy,
268
+ mode: 'offline',
269
+ fixtureRoot: join(process.cwd(), 'fixtures'),
270
+ })
271
+ expect(first.artifacts).toEqual(second.artifacts)
272
+ expect(first.catalog?.plugins).toHaveLength(8)
273
+ expect(first.changeReport.contentResolution).toBe('complete')
274
+ verifyArtifacts(first.artifacts!)
275
+ })
276
+
277
+ test('exits unchanged and reconstructs from the source lock without querying refs', async () => {
278
+ const first = await synchronize({
279
+ sources,
280
+ categoryMap,
281
+ productAliases,
282
+ policy,
283
+ mode: 'offline',
284
+ fixtureRoot: join(process.cwd(), 'fixtures'),
285
+ })
286
+ const unchanged = await synchronize({
287
+ sources,
288
+ categoryMap,
289
+ productAliases,
290
+ policy,
291
+ mode: 'offline',
292
+ fixtureRoot: join(process.cwd(), 'fixtures'),
293
+ existingLock: first.lock!,
294
+ })
295
+ expect(unchanged.changed).toBe(false)
296
+ // Curation and build-logic changes produce different artifacts under the
297
+ // same pins, so an explicit force must not take the unchanged path — that
298
+ // is the only way such a change becomes publishable.
299
+ const forced = await synchronize({
300
+ sources,
301
+ categoryMap,
302
+ productAliases,
303
+ policy,
304
+ mode: 'offline',
305
+ fixtureRoot: join(process.cwd(), 'fixtures'),
306
+ existingLock: first.lock!,
307
+ forceRebuild: true,
308
+ })
309
+ expect(forced.changed).toBe(true)
310
+ expect(forced.artifacts).toEqual(first.artifacts)
311
+ const rebuilt = await synchronize({
312
+ sources,
313
+ categoryMap,
314
+ productAliases,
315
+ policy,
316
+ mode: 'offline',
317
+ fixtureRoot: join(process.cwd(), 'fixtures'),
318
+ fromLock: first.lock!,
319
+ })
320
+ expect(rebuilt.artifacts).toEqual(first.artifacts)
321
+ })
322
+
323
+ test('persists and reuses immutable pins for external plugin refs', async () => {
324
+ const fixtureRoot = await mkdtemp(join(tmpdir(), 'plugin-pin-'))
325
+ await mkdir(join(fixtureRoot, 'pin-test'), { recursive: true })
326
+ await writeFile(
327
+ join(fixtureRoot, 'pin-test', 'marketplace.json'),
328
+ JSON.stringify({
329
+ plugins: [
330
+ {
331
+ name: 'remote',
332
+ description: 'Remote fixture plugin.',
333
+ source: {
334
+ source: 'git',
335
+ url: 'https://github.com/acme/remote-plugin',
336
+ ref: 'main',
337
+ },
338
+ },
339
+ ],
340
+ })
341
+ )
342
+ const source: SourceConfig = {
343
+ ...sources[0]!,
344
+ sourceId: 'pin-test',
345
+ displayName: 'Pin fixture',
346
+ manifestPath: 'marketplace.json',
347
+ fixturePath: 'pin-test',
348
+ fixtureCommitSha: '5'.repeat(40),
349
+ }
350
+ const snapshotLoader = {
351
+ load: async (repositoryUrl: string, commitSha: string, pluginSubdirectory: string) => {
352
+ expect(repositoryUrl).toBe('https://github.com/acme/remote-plugin')
353
+ expect(commitSha).toBe('5'.repeat(40))
354
+ expect(pluginSubdirectory).toBe('')
355
+ return {
356
+ files: new Map([['plugin.json', new TextEncoder().encode('{"name":"Remote"}')]]),
357
+ symlinks: [],
358
+ }
359
+ },
360
+ }
361
+ const first = await synchronize({
362
+ sources: [source],
363
+ categoryMap,
364
+ productAliases,
365
+ policy,
366
+ mode: 'offline',
367
+ fixtureRoot,
368
+ snapshotLoader,
369
+ })
370
+ expect(first.lock?.sources[0]?.pluginPins[0]).toEqual({
371
+ pluginName: 'remote',
372
+ repositoryUrl: 'https://github.com/acme/remote-plugin',
373
+ pluginSubdirectory: '',
374
+ resolvedCommitSha: '5'.repeat(40),
375
+ })
376
+ const rebuilt = await synchronize({
377
+ sources: [source],
378
+ categoryMap,
379
+ productAliases,
380
+ policy,
381
+ mode: 'offline',
382
+ fixtureRoot,
383
+ fromLock: first.lock!,
384
+ snapshotLoader,
385
+ })
386
+ expect(rebuilt.artifacts).toEqual(first.artifacts)
387
+
388
+ const staleLock = {
389
+ ...first.lock!,
390
+ sources: first.lock!.sources.map((entry) => ({
391
+ ...entry,
392
+ pluginPins: entry.pluginPins.map((pin) => ({
393
+ ...pin,
394
+ repositoryUrl: 'https://github.com/box-for-ai/remote-plugin',
395
+ })),
396
+ })),
397
+ }
398
+ await expect(
399
+ synchronize({
400
+ sources: [source],
401
+ categoryMap,
402
+ productAliases,
403
+ policy,
404
+ mode: 'offline',
405
+ fixtureRoot,
406
+ fromLock: staleLock,
407
+ snapshotLoader,
408
+ })
409
+ ).rejects.toThrow('SOURCE_LOCK_PLUGIN_PIN_MISMATCH: pin-test:remote')
410
+ })
411
+
412
+ test('skips apollo-skills symlinks deterministically without dropping safe plugins', async () => {
413
+ const fixtureRoot = await mkdtemp(join(tmpdir(), 'apollo-symlink-'))
414
+ await mkdir(join(fixtureRoot, 'apollo'), { recursive: true })
415
+ await writeFile(
416
+ join(fixtureRoot, 'apollo', 'marketplace.json'),
417
+ JSON.stringify({
418
+ plugins: [
419
+ {
420
+ name: 'apollo-skills',
421
+ description: 'Unsafe symlink regression fixture.',
422
+ source: './apollo-skills',
423
+ },
424
+ {
425
+ name: 'safe-plugin',
426
+ description: 'Safe plugin fixture.',
427
+ source: './safe-plugin',
428
+ },
429
+ ],
430
+ })
431
+ )
432
+ const source: SourceConfig = {
433
+ ...sources[0]!,
434
+ sourceId: 'apollo-regression',
435
+ displayName: 'Apollo symlink regression',
436
+ manifestPath: 'marketplace.json',
437
+ fixturePath: 'apollo',
438
+ fixtureCommitSha: 'a'.repeat(40),
439
+ }
440
+ const result = await synchronize({
441
+ sources: [source],
442
+ categoryMap,
443
+ productAliases,
444
+ policy,
445
+ mode: 'offline',
446
+ fixtureRoot,
447
+ snapshotLoader: {
448
+ load: async (_repositoryUrl, _commitSha, pluginSubdirectory) =>
449
+ pluginSubdirectory === 'apollo-skills'
450
+ ? {
451
+ files: new Map(),
452
+ symlinks: ['.github/skills/skill-creator', 'CLAUDE.md'],
453
+ }
454
+ : {
455
+ files: new Map([['skill.md', new TextEncoder().encode('# Safe plugin')]]),
456
+ symlinks: [],
457
+ },
458
+ },
459
+ })
460
+
461
+ expect(result.catalog?.plugins.map((plugin) => plugin.upstreamPluginName)).toEqual([
462
+ 'safe-plugin',
463
+ ])
464
+ expect(result.changeReport.contentResolution).toBe('complete-with-skips')
465
+ expect(result.changeReport.skippedPlugins).toEqual([
466
+ {
467
+ sourceId: 'apollo-regression',
468
+ pluginId: 'plugin:apollo-regression:apollo-skills',
469
+ pluginName: 'apollo-skills',
470
+ reasonCode: 'SYMLINK_ESCAPE',
471
+ securityReason:
472
+ 'The plugin snapshot contains symlinks; symlinks are never followed, so the catalog excludes the plugin.',
473
+ incompleteContent: true,
474
+ paths: ['.github/skills/skill-creator', 'CLAUDE.md'].toSorted(),
475
+ },
476
+ ])
477
+ verifyArtifacts(result.artifacts!)
478
+ })
479
+
480
+ test('keeps a failed build from producing a new artifact set', async () => {
481
+ const first = await synchronize({
482
+ sources,
483
+ categoryMap,
484
+ productAliases,
485
+ policy,
486
+ mode: 'offline',
487
+ fixtureRoot: join(process.cwd(), 'fixtures'),
488
+ })
489
+ const outputDirectory = join(await mkdtemp(join(tmpdir(), 'plugin-lkg-')), 'generated')
490
+ await writeArtifacts(outputDirectory, first.artifacts!)
491
+ const previousCatalog = await readFile(join(outputDirectory, 'catalog.v1.json'), 'utf8')
492
+ await expect(
493
+ synchronize({
494
+ sources,
495
+ categoryMap,
496
+ productAliases,
497
+ policy,
498
+ mode: 'offline',
499
+ fixtureRoot: join(process.cwd(), 'fixtures'),
500
+ snapshotLoader: {
501
+ load: async () => {
502
+ throw new Error('UPSTREAM_FETCH_FAILED')
503
+ },
504
+ },
505
+ })
506
+ ).rejects.toThrow('UPSTREAM_FETCH_FAILED')
507
+ expect(first.artifacts?.['catalog.v1.json']).toContain(first.catalog?.catalogId)
508
+ expect(await readFile(join(outputDirectory, 'catalog.v1.json'), 'utf8')).toBe(previousCatalog)
509
+ })
510
+ })
511
+
512
+ describe('identity, classification, and harness contracts', () => {
513
+ test('keeps source-qualified variants distinct while grouping known products', async () => {
514
+ expect(stablePluginId('openai-official', 'gmail')).not.toBe(
515
+ stablePluginId('cursor-official', 'gmail')
516
+ )
517
+ expect(stablePluginId('--OpenAI--', '---Gmail---')).toBe('plugin:openai:gmail')
518
+ expect(
519
+ stableReleaseId('https://github.com/acme/a', 'plugins/x', 'a'.repeat(40), digest('same'))
520
+ ).toBe(
521
+ stableReleaseId('https://github.com/acme/a', 'plugins/x', 'a'.repeat(40), digest('same'))
522
+ )
523
+ expect(
524
+ bytesDigest(
525
+ new Map([
526
+ ['b.txt', new TextEncoder().encode('b')],
527
+ ['a.txt', new TextEncoder().encode('a')],
528
+ ])
529
+ )
530
+ ).toBe(
531
+ bytesDigest(
532
+ new Map([
533
+ ['a.txt', new TextEncoder().encode('a')],
534
+ ['b.txt', new TextEncoder().encode('b')],
535
+ ])
536
+ )
537
+ )
538
+ expect(normalizeCategory('Developer Tools', categoryMap)).toBe('developer-tools')
539
+ })
540
+
541
+ test('generates a deterministic non-installing plan for every harness', async () => {
542
+ const result = await synchronize({
543
+ sources,
544
+ categoryMap,
545
+ productAliases,
546
+ policy,
547
+ mode: 'offline',
548
+ fixtureRoot: join(process.cwd(), 'fixtures'),
549
+ })
550
+ const catalog = result.catalog!
551
+ for (const harness of supportedHarnesses()) {
552
+ const plan = createMaterializationPlan({
553
+ catalog,
554
+ pluginId: 'plugin:openai-official:linear',
555
+ harness,
556
+ })
557
+ expect(plan.pluginId).toBe('plugin:openai-official:linear')
558
+ expect(plan.configuration.executeInstallation).toBe(false)
559
+ expect(JSON.stringify(plan)).toBe(
560
+ JSON.stringify(
561
+ createMaterializationPlan({
562
+ catalog,
563
+ pluginId: plan.pluginId,
564
+ harness,
565
+ releaseId: plan.releaseId,
566
+ })
567
+ )
568
+ )
569
+ }
570
+ })
571
+ })
572
+
573
+ describe('filesystem safety', () => {
574
+ test('rejects a symlinked plugin root before following it', async () => {
575
+ const root = await mkdtemp(join(tmpdir(), 'plugins-fixture-'))
576
+ const outside = await mkdtemp(join(tmpdir(), 'plugins-outside-'))
577
+ await writeFile(join(outside, 'secret.txt'), 'secret')
578
+ await symlink(outside, join(root, 'plugin'))
579
+ const snapshot = await snapshotFromDirectory(root, 'plugin')
580
+ expect(snapshot.symlinks).toEqual(['plugin'])
581
+ })
582
+
583
+ test('enforces file and archive-like size limits', async () => {
584
+ const root = await mkdtemp(join(tmpdir(), 'plugins-size-'))
585
+ await mkdir(join(root, 'plugin'), { recursive: true })
586
+ await writeFile(join(root, 'plugin', 'large.txt'), '1234567890')
587
+ const snapshot = await snapshotFromDirectory(root, 'plugin')
588
+ expect(snapshot.files.size).toBe(1)
589
+ expect(() => {
590
+ if ([...snapshot.files.values()][0]!.byteLength > 5) throw new Error('PLUGIN_FILE_TOO_LARGE')
591
+ }).toThrow('PLUGIN_FILE_TOO_LARGE')
592
+ })
593
+ })
594
+
595
+ describe('upstream fetch quarantine', () => {
596
+ const sha = 'a'.repeat(40)
597
+ const repo = 'https://github.com/acme/demo'
598
+ const treeUrl = `https://api.github.com/repos/acme/demo/git/trees/${sha}?recursive=1`
599
+ const manifestBytes = '{"name":"demo","description":"demo plugin","version":"1.0.0"}'
600
+
601
+ function testSource(entries: ReturnType<typeof testEntry>[]): ResolvedSource {
602
+ return {
603
+ config: sources[0]!,
604
+ commitSha: sha,
605
+ manifestText: '{}',
606
+ parsed: {
607
+ marketplaceName: 'Acme',
608
+ marketplaceDescription: 'Acme fixture',
609
+ owner: ['Acme'],
610
+ entries,
611
+ raw: {},
612
+ },
613
+ pluginPins: [],
614
+ retrievedAt: new Date().toISOString(),
615
+ manifestDigest: digest('{}'),
616
+ }
617
+ }
618
+
619
+ async function buildWith(
620
+ entries: ReturnType<typeof testEntry>[],
621
+ handler: (url: string) => Response | Promise<Response>
622
+ ) {
623
+ const stub = stubFetch(handler)
624
+ try {
625
+ const catalog = await buildCatalog({
626
+ resolvedSources: [testSource(entries)],
627
+ categoryMap,
628
+ productAliases,
629
+ policy,
630
+ metadataOnly: false,
631
+ snapshotLoader: new NetworkSnapshotLoader(policy),
632
+ resolveExternalRefs: false,
633
+ })
634
+ return { catalog, calls: stub.calls }
635
+ } finally {
636
+ stub.restore()
637
+ }
638
+ }
639
+
640
+ test('retries transient tree failures then succeeds', async () => {
641
+ let treeCalls = 0
642
+ const stub = stubFetch(async (url) => {
643
+ if (url === treeUrl) {
644
+ treeCalls += 1
645
+ if (treeCalls < 3) return new Response('busy', { status: 500 })
646
+ return jsonResponse(treePayload(['plugins/demo/plugin.json']))
647
+ }
648
+ if (isArchive(url))
649
+ return tarballResponse([{ path: 'plugins/demo/plugin.json', body: manifestBytes }])
650
+ return new Response(manifestBytes, { status: 200 })
651
+ })
652
+ try {
653
+ const loader = new NetworkSnapshotLoader(policy)
654
+ const snapshot = await loader.load(repo, sha, 'plugins/demo')
655
+ expect(snapshot.files.has('plugin.json')).toBe(true)
656
+ expect(treeCalls).toBe(3)
657
+ } finally {
658
+ stub.restore()
659
+ }
660
+ })
661
+
662
+ test('quarantines a plugin when the repository tree stays unavailable', async () => {
663
+ const stub = stubFetch((url) => {
664
+ if (url === treeUrl) return new Response('down', { status: 500 })
665
+ return new Response('unexpected', { status: 500 })
666
+ })
667
+ try {
668
+ const loader = new NetworkSnapshotLoader(policy)
669
+ const error = await loader.load(repo, sha, 'plugins/demo').then(
670
+ () => undefined,
671
+ (caught: unknown) => caught as Error
672
+ )
673
+ expect(error?.message).toMatch(/^PLUGIN_SOURCE_UNAVAILABLE:/)
674
+ expect(stub.calls.filter((url) => url === treeUrl)).toHaveLength(3)
675
+ } finally {
676
+ stub.restore()
677
+ }
678
+ })
679
+
680
+ test('reports a tree entry the archive does not carry', async () => {
681
+ const stub = stubFetch(async (url) => {
682
+ if (url === treeUrl) return jsonResponse(treePayload(['plugins/demo/plugin.json']))
683
+ if (isArchive(url)) return tarballResponse([])
684
+ return new Response('unexpected', { status: 200 })
685
+ })
686
+ try {
687
+ const loader = new NetworkSnapshotLoader(policy)
688
+ const error = await loader.load(repo, sha, 'plugins/demo').then(
689
+ () => undefined,
690
+ (caught: unknown) => caught as Error
691
+ )
692
+ // The archive arrived intact, so a missing entry is a content gap, not
693
+ // a fetch to retry: exactly one archive call, no backoff.
694
+ expect(error?.message).toMatch(/^PLUGIN_FILE_UNAVAILABLE:/)
695
+ expect(stub.calls.filter(isArchive)).toHaveLength(1)
696
+ } finally {
697
+ stub.restore()
698
+ }
699
+ })
700
+
701
+ test('quarantines a plugin when the archive keeps failing', async () => {
702
+ const stub = stubFetch((url) => {
703
+ if (url === treeUrl) return jsonResponse(treePayload(['plugins/demo/plugin.json']))
704
+ if (isArchive(url)) return new Response('broken', { status: 500 })
705
+ return new Response('unexpected', { status: 200 })
706
+ })
707
+ try {
708
+ const loader = new NetworkSnapshotLoader(policy)
709
+ const error = await loader.load(repo, sha, 'plugins/demo').then(
710
+ () => undefined,
711
+ (caught: unknown) => caught as Error
712
+ )
713
+ expect(error?.message).toMatch(/^PLUGIN_SOURCE_UNAVAILABLE:/)
714
+ expect(stub.calls.filter(isArchive)).toHaveLength(3)
715
+ } finally {
716
+ stub.restore()
717
+ }
718
+ })
719
+
720
+ test('retries a rate-limited tree without hammering', async () => {
721
+ let treeCalls = 0
722
+ const stub = stubFetch(async (url) => {
723
+ if (url === treeUrl) {
724
+ treeCalls += 1
725
+ if (treeCalls === 1)
726
+ return new Response('limited', {
727
+ status: 403,
728
+ headers: { 'x-ratelimit-remaining': '0', 'retry-after': '0' },
729
+ })
730
+ return jsonResponse(treePayload(['plugins/demo/plugin.json']))
731
+ }
732
+ if (isArchive(url))
733
+ return tarballResponse([{ path: 'plugins/demo/plugin.json', body: manifestBytes }])
734
+ return new Response(manifestBytes, { status: 200 })
735
+ })
736
+ try {
737
+ const loader = new NetworkSnapshotLoader(policy)
738
+ const snapshot = await loader.load(repo, sha, 'plugins/demo')
739
+ expect(snapshot.files.has('plugin.json')).toBe(true)
740
+ expect(treeCalls).toBe(2)
741
+ } finally {
742
+ stub.restore()
743
+ }
744
+ })
745
+
746
+ test('does not retry a forbidden tree without rate-limit signals', async () => {
747
+ let treeCalls = 0
748
+ const stub = stubFetch((url) => {
749
+ if (url === treeUrl) {
750
+ treeCalls += 1
751
+ return new Response('denied', { status: 403 })
752
+ }
753
+ return new Response('unexpected', { status: 500 })
754
+ })
755
+ try {
756
+ const loader = new NetworkSnapshotLoader(policy)
757
+ const error = await loader.load(repo, sha, 'plugins/demo').then(
758
+ () => undefined,
759
+ (caught: unknown) => caught as Error
760
+ )
761
+ expect(error?.message).toMatch(/^PLUGIN_SOURCE_UNAVAILABLE:/)
762
+ expect(treeCalls).toBe(1)
763
+ } finally {
764
+ stub.restore()
765
+ }
766
+ })
767
+
768
+ test('one archive serves every plugin in a repository', async () => {
769
+ const paths = ['plugins/one/plugin.json', 'plugins/two/plugin.json', 'plugins/two/run.sh']
770
+ const stub = stubFetch(async (url) => {
771
+ if (url === treeUrl) return jsonResponse(treePayload(paths))
772
+ if (isArchive(url))
773
+ return tarballResponse([
774
+ { path: 'plugins/one/plugin.json', body: manifestBytes },
775
+ { path: 'plugins/two/plugin.json', body: manifestBytes },
776
+ { path: 'plugins/two/run.sh', body: '#!/bin/sh\n' },
777
+ ])
778
+ return new Response('unexpected', { status: 500 })
779
+ })
780
+ try {
781
+ const loader = new NetworkSnapshotLoader(policy)
782
+ const first = await loader.load(repo, sha, 'plugins/one')
783
+ const second = await loader.load(repo, sha, 'plugins/two')
784
+ expect([...first.files.keys()]).toEqual(['plugin.json'])
785
+ expect([...second.files.keys()].toSorted()).toEqual(['plugin.json', 'run.sh'])
786
+ // The whole point: three files, and the archive is fetched once.
787
+ expect(stub.calls.filter(isArchive)).toHaveLength(1)
788
+ expect(stub.calls.filter(isRawGitHubHost)).toHaveLength(0)
789
+ } finally {
790
+ stub.restore()
791
+ }
792
+ })
793
+
794
+ test('rejects an archive entry that escapes the repository', async () => {
795
+ const stub = stubFetch(async (url) => {
796
+ if (url === treeUrl) return jsonResponse(treePayload(['plugins/demo/plugin.json']))
797
+ if (isArchive(url))
798
+ return tarballResponse([
799
+ { path: 'plugins/demo/plugin.json', body: manifestBytes },
800
+ { path: '../escape.txt', body: 'owned' },
801
+ ])
802
+ return new Response('unexpected', { status: 500 })
803
+ })
804
+ try {
805
+ const loader = new NetworkSnapshotLoader(policy)
806
+ const error = await loader.load(repo, sha, 'plugins/demo').then(
807
+ () => undefined,
808
+ (caught: unknown) => caught as Error
809
+ )
810
+ expect(String(error?.message)).toMatch(/PATH_TRAVERSAL|TREE_PATH_INVALID/)
811
+ } finally {
812
+ stub.restore()
813
+ }
814
+ })
815
+
816
+ test('never materializes a symlink entry as file content', async () => {
817
+ const stub = stubFetch(async (url) => {
818
+ if (url === treeUrl) return jsonResponse(treePayload(['plugins/demo/plugin.json']))
819
+ if (isArchive(url))
820
+ return tarballResponse([
821
+ { path: 'plugins/demo/plugin.json', body: manifestBytes },
822
+ { path: 'plugins/demo/link', type: 'symlink', linkpath: '../../../../etc/passwd' },
823
+ ])
824
+ return new Response('unexpected', { status: 500 })
825
+ })
826
+ try {
827
+ const loader = new NetworkSnapshotLoader(policy)
828
+ const snapshot = await loader.load(repo, sha, 'plugins/demo')
829
+ expect(snapshot.files.has('link')).toBe(false)
830
+ expect(snapshot.files.has('plugin.json')).toBe(true)
831
+ } finally {
832
+ stub.restore()
833
+ }
834
+ })
835
+
836
+ test('keeps reachable plugins when only some fail', async () => {
837
+ const fixtureRoot = join(process.cwd(), 'fixtures', 'openai', 'plugins', 'calendar')
838
+ const calendarFiles = walkFixtureFiles(fixtureRoot)
839
+ // The archive is per repository, so a per-file 404 no longer exists: the
840
+ // `gone` plugin now fails because the tree advertises a file the archive
841
+ // does not carry, which is the same quarantine outcome for one plugin
842
+ // while its sibling still resolves from the same archive.
843
+ const { catalog, calls } = await buildWith(
844
+ [testEntry('calendar', 'plugins/calendar'), testEntry('gone', 'plugins/gone')],
845
+ async (url) => {
846
+ if (url.includes('/git/trees/'))
847
+ return jsonResponse(
848
+ treePayload([
849
+ ...calendarFiles.map((file) => `plugins/calendar/${file}`),
850
+ 'plugins/gone/plugin.json',
851
+ ])
852
+ )
853
+ if (isArchive(url))
854
+ return tarballResponse(
855
+ calendarFiles.map((file) => ({
856
+ path: `plugins/calendar/${file}`,
857
+ body: readFileSync(join(fixtureRoot, file), 'utf8'),
858
+ }))
859
+ )
860
+ return new Response('unexpected', { status: 500 })
861
+ }
862
+ )
863
+ expect(calls.length).toBeGreaterThan(0)
864
+ expect(calls.filter(isRawGitHubHost)).toHaveLength(0)
865
+ expect(catalog.plugins.map((plugin) => plugin.pluginId)).toEqual([
866
+ 'plugin:openai-official:calendar',
867
+ ])
868
+ })
869
+
870
+ test('fails closed when every plugin in a source is unreachable', async () => {
871
+ const stub = stubFetch(() => new Response('down', { status: 500 }))
872
+ try {
873
+ await expect(
874
+ buildCatalog({
875
+ resolvedSources: [testSource([testEntry('demo', 'plugins/demo')])],
876
+ categoryMap,
877
+ productAliases,
878
+ policy,
879
+ metadataOnly: false,
880
+ snapshotLoader: new NetworkSnapshotLoader(policy),
881
+ resolveExternalRefs: false,
882
+ })
883
+ ).rejects.toThrow('PLUGIN_SOURCE_UNAVAILABLE')
884
+ } finally {
885
+ stub.restore()
886
+ }
887
+ })
888
+ })