@adea-ai/plugins 3.0.9 → 4.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 (53) hide show
  1. package/README.md +18 -11
  2. package/dist/agent-plugins-catalog.d.ts +2 -3
  3. package/dist/agent-plugins-catalog.js +2 -10
  4. package/dist/agent-plugins-catalog.js.map +1 -1
  5. package/dist/display-name.js +3 -2
  6. package/dist/display-name.js.map +1 -1
  7. package/dist/harness.d.ts +50 -9
  8. package/dist/harness.js +190 -147
  9. package/dist/harness.js.map +1 -1
  10. package/dist/icons.js +5 -27
  11. package/dist/icons.js.map +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.js +47 -37
  14. package/dist/index.js.map +1 -1
  15. package/dist/schema.d.ts +0 -40
  16. package/dist/schema.js +0 -36
  17. package/dist/schema.js.map +1 -1
  18. package/dist/site-icons.js +18 -7
  19. package/dist/site-icons.js.map +1 -1
  20. package/dist/source-digest.d.ts +2 -0
  21. package/dist/source-digest.js +12 -0
  22. package/dist/source-digest.js.map +1 -0
  23. package/dist/sources.js +2 -2
  24. package/dist/sources.js.map +1 -1
  25. package/native/README.md +24 -0
  26. package/native/build-provenance.txt +5 -5
  27. package/native/staged-reader-win32-arm64.node +0 -0
  28. package/native/staged-reader-win32-x64.node +0 -0
  29. package/package.json +3 -10
  30. package/src/agent-plugins-catalog.ts +3 -10
  31. package/src/catalog-installation.test.ts +1 -1
  32. package/src/display-name.test.ts +8 -0
  33. package/src/display-name.ts +3 -2
  34. package/src/{agent-plugins-harness.test.ts → harness.test.ts} +1 -1
  35. package/src/harness.ts +249 -170
  36. package/src/icons.test.ts +5 -0
  37. package/src/icons.ts +6 -29
  38. package/src/index.test.ts +150 -40
  39. package/src/index.ts +49 -41
  40. package/src/portable-catalog.test.ts +8 -5
  41. package/src/schema.ts +0 -40
  42. package/src/site-icon-fallback.test.ts +36 -19
  43. package/src/site-icons.test.ts +31 -0
  44. package/src/site-icons.ts +28 -7
  45. package/src/source-digest.test.ts +44 -0
  46. package/src/source-digest.ts +14 -0
  47. package/src/sources-json.test.ts +36 -0
  48. package/src/sources.ts +2 -2
  49. package/src/upstream-headers.test.ts +31 -7
  50. package/dist/agent-plugins-harness.d.ts +0 -54
  51. package/dist/agent-plugins-harness.js +0 -212
  52. package/dist/agent-plugins-harness.js.map +0 -1
  53. package/src/agent-plugins-harness.ts +0 -281
package/src/index.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'node:fs/promises'
1
+ import { chmod, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
2
2
  import { readdirSync, readFileSync } from 'node:fs'
3
3
  import { gzipSync } from 'node:zlib'
4
4
  import { pack } from 'tar-stream'
@@ -28,7 +28,6 @@ import {
28
28
  parseSourceSpec,
29
29
  type SourceConfig,
30
30
  } from './sources.js'
31
- import { createMaterializationPlan, supportedHarnesses } from './harness.js'
32
31
 
33
32
  const sources: SourceConfig[] = [
34
33
  {
@@ -409,6 +408,132 @@ describe('deterministic catalog synchronization', () => {
409
408
  ).rejects.toThrow('SOURCE_LOCK_PLUGIN_PIN_MISMATCH: pin-test:remote')
410
409
  })
411
410
 
411
+ test('refreshes explicit external revisions and repositories outside lock replay', async () => {
412
+ const fixtureRoot = await mkdtemp(join(tmpdir(), 'plugin-pin-refresh-'))
413
+ try {
414
+ await mkdir(join(fixtureRoot, 'pin-test'), { recursive: true })
415
+ const manifestPath = join(fixtureRoot, 'pin-test', 'marketplace.json')
416
+ const writeManifest = (sha: string, url = 'https://github.com/acme/remote-plugin') =>
417
+ writeFile(
418
+ manifestPath,
419
+ JSON.stringify({
420
+ plugins: [
421
+ {
422
+ name: 'remote',
423
+ description: 'Remote fixture plugin.',
424
+ source: { source: 'git', url, sha },
425
+ },
426
+ ],
427
+ })
428
+ )
429
+ const source: SourceConfig = {
430
+ ...sources[0]!,
431
+ sourceId: 'pin-test',
432
+ manifestPath: 'marketplace.json',
433
+ fixturePath: 'pin-test',
434
+ fixtureCommitSha: '5'.repeat(40),
435
+ }
436
+ const options = {
437
+ sources: [source],
438
+ categoryMap,
439
+ productAliases,
440
+ policy,
441
+ mode: 'offline' as const,
442
+ fixtureRoot,
443
+ snapshotLoader: {
444
+ load: async () => ({
445
+ files: new Map([['plugin.json', new TextEncoder().encode('{"name":"Remote"}')]]),
446
+ symlinks: [],
447
+ }),
448
+ },
449
+ }
450
+ await writeManifest('6'.repeat(40))
451
+ const first = await synchronize(options)
452
+ await writeManifest('7'.repeat(40))
453
+ const refreshed = await synchronize({ ...options, existingLock: first.lock! })
454
+ expect(refreshed.lock?.sources[0]?.pluginPins[0]?.resolvedCommitSha).toBe('7'.repeat(40))
455
+ expect(refreshed.catalog?.plugins[0]?.availableReleases[0]?.resolvedCommitSha).toBe(
456
+ '7'.repeat(40)
457
+ )
458
+ await writeManifest('8'.repeat(40), 'https://github.com/acme/replacement-plugin')
459
+ const replaced = await synchronize({ ...options, existingLock: refreshed.lock! })
460
+ expect(replaced.lock?.sources[0]?.pluginPins[0]?.repositoryUrl).toBe(
461
+ 'https://github.com/acme/replacement-plugin'
462
+ )
463
+ expect(replaced.lock?.sources[0]?.pluginPins[0]?.resolvedCommitSha).toBe('8'.repeat(40))
464
+ } finally {
465
+ await rm(fixtureRoot, { recursive: true, force: true })
466
+ }
467
+ })
468
+
469
+ test.skipIf(process.platform === 'win32')(
470
+ 'refreshes floating external refs even when the marketplace head is unchanged',
471
+ async () => {
472
+ const bin = await mkdtemp(join(tmpdir(), 'plugin-live-pin-'))
473
+ const originalPath = process.env.PATH
474
+ const originalFetch = globalThis.fetch
475
+ const sourceSha = '5'.repeat(40)
476
+ const source: SourceConfig = {
477
+ ...sources[0]!,
478
+ sourceId: 'live-pin-test',
479
+ repositoryUrl: 'https://github.com/acme/marketplace',
480
+ manifestPath: 'marketplace.json',
481
+ }
482
+ const manifest = JSON.stringify({
483
+ plugins: [
484
+ {
485
+ name: 'remote',
486
+ description: 'Remote plugin.',
487
+ source: { source: 'git', url: 'https://github.com/acme/remote-plugin', ref: 'main' },
488
+ },
489
+ ],
490
+ })
491
+ const gitPath = join(bin, 'git')
492
+ const writeGit = async (pluginSha: string) => {
493
+ await writeFile(
494
+ gitPath,
495
+ `#!/bin/sh\nif [ "$2" = "${source.repositoryUrl}" ]; then\n printf '%s\\t%s\\n' '${sourceSha}' "$3"\nelse\n printf '%s\\t%s\\n' '${pluginSha}' "$3"\nfi\n`
496
+ )
497
+ await chmod(gitPath, 0o700)
498
+ }
499
+ try {
500
+ process.env.PATH = `${bin}:${originalPath ?? ''}`
501
+ globalThis.fetch = (async (url) => {
502
+ expect(String(url)).toBe(
503
+ `https://raw.githubusercontent.com/acme/marketplace/${sourceSha}/marketplace.json`
504
+ )
505
+ return new Response(manifest)
506
+ }) as typeof fetch
507
+ const options = {
508
+ sources: [source],
509
+ categoryMap,
510
+ productAliases,
511
+ policy,
512
+ mode: 'live' as const,
513
+ snapshotLoader: {
514
+ load: async () => ({
515
+ files: new Map([['plugin.json', new TextEncoder().encode('{"name":"Remote"}')]]),
516
+ symlinks: [],
517
+ }),
518
+ },
519
+ }
520
+ await writeGit('6'.repeat(40))
521
+ const first = await synchronize(options)
522
+ await writeGit('7'.repeat(40))
523
+ const next = await synchronize({ ...options, existingLock: first.lock! })
524
+ expect(next.changed).toBe(true)
525
+ expect(next.lock?.sources[0]?.pluginPins[0]?.resolvedCommitSha).toBe('7'.repeat(40))
526
+ const replay = await synchronize({ ...options, fromLock: first.lock! })
527
+ expect(replay.lock?.sources[0]?.pluginPins[0]?.resolvedCommitSha).toBe('6'.repeat(40))
528
+ } finally {
529
+ globalThis.fetch = originalFetch
530
+ if (originalPath === undefined) delete process.env.PATH
531
+ else process.env.PATH = originalPath
532
+ await rm(bin, { recursive: true, force: true })
533
+ }
534
+ }
535
+ )
536
+
412
537
  test('skips apollo-skills symlinks deterministically without dropping safe plugins', async () => {
413
538
  const fixtureRoot = await mkdtemp(join(tmpdir(), 'apollo-symlink-'))
414
539
  await mkdir(join(fixtureRoot, 'apollo'), { recursive: true })
@@ -537,37 +662,6 @@ describe('identity, classification, and harness contracts', () => {
537
662
  )
538
663
  expect(normalizeCategory('Developer Tools', categoryMap)).toBe('developer-tools')
539
664
  })
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
665
  })
572
666
 
573
667
  describe('filesystem safety', () => {
@@ -699,9 +793,13 @@ describe('upstream fetch quarantine', () => {
699
793
  })
700
794
 
701
795
  test('quarantines a plugin when the archive keeps failing', async () => {
702
- const stub = stubFetch((url) => {
796
+ let recovered = false
797
+ const stub = stubFetch(async (url) => {
703
798
  if (url === treeUrl) return jsonResponse(treePayload(['plugins/demo/plugin.json']))
704
- if (isArchive(url)) return new Response('broken', { status: 500 })
799
+ if (isArchive(url))
800
+ return recovered
801
+ ? tarballResponse([{ path: 'plugins/demo/plugin.json', body: manifestBytes }])
802
+ : new Response('broken', { status: 500 })
705
803
  return new Response('unexpected', { status: 200 })
706
804
  })
707
805
  try {
@@ -712,6 +810,9 @@ describe('upstream fetch quarantine', () => {
712
810
  )
713
811
  expect(error?.message).toMatch(/^PLUGIN_SOURCE_UNAVAILABLE:/)
714
812
  expect(stub.calls.filter(isArchive)).toHaveLength(3)
813
+ recovered = true
814
+ expect((await loader.load(repo, sha, 'plugins/demo')).files.has('plugin.json')).toBe(true)
815
+ expect(stub.calls.filter(isArchive)).toHaveLength(4)
715
816
  } finally {
716
817
  stub.restore()
717
818
  }
@@ -745,11 +846,15 @@ describe('upstream fetch quarantine', () => {
745
846
 
746
847
  test('does not retry a forbidden tree without rate-limit signals', async () => {
747
848
  let treeCalls = 0
748
- const stub = stubFetch((url) => {
849
+ const stub = stubFetch(async (url) => {
749
850
  if (url === treeUrl) {
750
851
  treeCalls += 1
751
- return new Response('denied', { status: 403 })
852
+ return treeCalls === 1
853
+ ? new Response('denied', { status: 403 })
854
+ : jsonResponse(treePayload(['plugins/demo/plugin.json']))
752
855
  }
856
+ if (isArchive(url))
857
+ return tarballResponse([{ path: 'plugins/demo/plugin.json', body: manifestBytes }])
753
858
  return new Response('unexpected', { status: 500 })
754
859
  })
755
860
  try {
@@ -760,6 +865,8 @@ describe('upstream fetch quarantine', () => {
760
865
  )
761
866
  expect(error?.message).toMatch(/^PLUGIN_SOURCE_UNAVAILABLE:/)
762
867
  expect(treeCalls).toBe(1)
868
+ expect((await loader.load(repo, sha, 'plugins/demo')).files.has('plugin.json')).toBe(true)
869
+ expect(treeCalls).toBe(2)
763
870
  } finally {
764
871
  stub.restore()
765
872
  }
@@ -779,11 +886,14 @@ describe('upstream fetch quarantine', () => {
779
886
  })
780
887
  try {
781
888
  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')
889
+ const [first, second] = await Promise.all([
890
+ loader.load(repo, sha, 'plugins/one'),
891
+ loader.load(repo, sha, 'plugins/two'),
892
+ ])
784
893
  expect([...first.files.keys()]).toEqual(['plugin.json'])
785
894
  expect([...second.files.keys()].toSorted()).toEqual(['plugin.json', 'run.sh'])
786
- // The whole point: three files, and the archive is fetched once.
895
+ // Concurrent plugins share both requests, not only the finished cache.
896
+ expect(stub.calls.filter((url) => url === treeUrl)).toHaveLength(1)
787
897
  expect(stub.calls.filter(isArchive)).toHaveLength(1)
788
898
  expect(stub.calls.filter(isRawGitHubHost)).toHaveLength(0)
789
899
  } finally {
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'
3
3
  import { promises as fs } from 'node:fs'
4
4
  import { basename, dirname, join, relative, resolve } from 'node:path'
5
5
  import { Readable } from 'node:stream'
6
+ import { setTimeout as sleep } from 'node:timers/promises'
6
7
  import { createGunzip } from 'node:zlib'
7
8
  import { extract } from 'tar-stream'
8
9
  import {
@@ -59,6 +60,9 @@ import {
59
60
  } from './site-icons.js'
60
61
  import { normalizeDisplayName } from './display-name.js'
61
62
  import { immutableAssetUrl } from './publication.js'
63
+ import { sourceDigest as bytesDigest } from './source-digest.js'
64
+
65
+ export { bytesDigest }
62
66
 
63
67
  /**
64
68
  * Marks the catalog format this compiler emits.
@@ -297,17 +301,6 @@ export function digest(value: unknown): string {
297
301
  return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`
298
302
  }
299
303
 
300
- export function bytesDigest(files: ReadonlyMap<string, Uint8Array>): string {
301
- const hash = createHash('sha256')
302
- for (const [path, bytes] of [...files.entries()].toSorted(([left], [right]) =>
303
- left.localeCompare(right)
304
- )) {
305
- hash.update(`${path.length}:${path}:${bytes.byteLength}:`)
306
- hash.update(bytes)
307
- }
308
- return `sha256:${hash.digest('hex')}`
309
- }
310
-
311
304
  export function stablePluginId(sourceId: string, upstreamName: string): string {
312
305
  return `plugin:${slug(sourceId)}:${slug(upstreamName)}`
313
306
  }
@@ -463,6 +456,7 @@ export async function buildCatalog(input: {
463
456
  ...(input.productCategories ? { productCategories: input.productCategories } : {}),
464
457
  ...(input.leading ? { leading: input.leading } : {}),
465
458
  ...(input.productIconOverrides ? { productIconOverrides: input.productIconOverrides } : {}),
459
+ ...(input.iconAssetsDirectory ? { iconAssetsDirectory: input.iconAssetsDirectory } : {}),
466
460
  })
467
461
  ).catalog
468
462
  }
@@ -2002,7 +1996,10 @@ async function resolvePluginPins(input: {
2002
1996
 
2003
1997
  const repositoryUrl = canonicalRepositoryUrl(entry.source.repositoryUrl)
2004
1998
  const pluginSubdirectory = entry.source.subdirectory
2005
- const locked = input.lockedPins?.find((pin) => pin.pluginName === entry.name)
1999
+ // Existing locks describe the prior sync; only explicit replay freezes revisions.
2000
+ const locked = input.fromLock
2001
+ ? input.lockedPins?.find((pin) => pin.pluginName === entry.name)
2002
+ : undefined
2006
2003
  if (input.fromLock && !locked)
2007
2004
  throw new Error(`SOURCE_LOCK_PLUGIN_PIN_MISSING: ${input.config.sourceId}:${entry.name}`)
2008
2005
  if (locked) {
@@ -2192,10 +2189,6 @@ const UPSTREAM_WEATHER_SKIP_CODES: ReadonlySet<PluginSkipReasonCode> = new Set([
2192
2189
  'PLUGIN_PATH_NOT_FOUND',
2193
2190
  ])
2194
2191
 
2195
- function sleep(ms: number): Promise<void> {
2196
- return new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
2197
- }
2198
-
2199
2192
  function upstreamRetryDelayMs(attempt: number, response?: Response): number {
2200
2193
  const retryAfter = response?.headers.get('retry-after')
2201
2194
  if (retryAfter !== null && retryAfter !== undefined) {
@@ -2230,8 +2223,8 @@ async function fetchUpstream(url: string, init: RequestInit): Promise<Response>
2230
2223
  try {
2231
2224
  response = await fetch(url, init)
2232
2225
  } catch (error) {
2233
- if (attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1) throw error
2234
- await sleep(upstreamRetryDelayMs(attempt))
2226
+ if (init.signal?.aborted || attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1) throw error
2227
+ await sleep(upstreamRetryDelayMs(attempt), undefined, { signal: init.signal ?? undefined })
2235
2228
  attempt += 1
2236
2229
  continue
2237
2230
  }
@@ -2241,7 +2234,10 @@ async function fetchUpstream(url: string, init: RequestInit): Promise<Response>
2241
2234
  attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1
2242
2235
  )
2243
2236
  return response
2244
- await sleep(upstreamRetryDelayMs(attempt, response))
2237
+ await response.body?.cancel().catch(() => {})
2238
+ await sleep(upstreamRetryDelayMs(attempt, response), undefined, {
2239
+ signal: init.signal ?? undefined,
2240
+ })
2245
2241
  attempt += 1
2246
2242
  }
2247
2243
  }
@@ -2343,8 +2339,8 @@ export async function readArchiveFiles(
2343
2339
 
2344
2340
  export class NetworkSnapshotLoader implements SnapshotLoader {
2345
2341
  readonly #policy: CatalogPolicy
2346
- readonly #trees = new Map<string, readonly TreeEntry[]>()
2347
- readonly #archives = new Map<string, ReadonlyMap<string, Uint8Array>>()
2342
+ readonly #trees = new Map<string, Promise<readonly TreeEntry[]>>()
2343
+ readonly #archives = new Map<string, Promise<ReadonlyMap<string, Uint8Array>>>()
2348
2344
 
2349
2345
  constructor(policy: CatalogPolicy) {
2350
2346
  this.#policy = policy
@@ -2361,22 +2357,28 @@ export class NetworkSnapshotLoader implements SnapshotLoader {
2361
2357
  const [owner, repo] = parsed.pathname.split('/').filter(Boolean)
2362
2358
  if (!owner || !repo) throw new Error('GIT_REPOSITORY_INVALID')
2363
2359
  const key = `${owner}/${repo}@${commitSha}`
2364
- let tree = this.#trees.get(key)
2365
- if (!tree) {
2366
- const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${commitSha}?recursive=1`
2367
- const response = await fetchUpstream(treeUrl, {
2368
- headers: upstreamRequestHeaders(treeUrl),
2360
+ let pendingTree = this.#trees.get(key)
2361
+ if (!pendingTree) {
2362
+ pendingTree = (async () => {
2363
+ const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${commitSha}?recursive=1`
2364
+ const response = await fetchUpstream(treeUrl, {
2365
+ headers: upstreamRequestHeaders(treeUrl),
2366
+ })
2367
+ if (!response.ok)
2368
+ throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [`${owner}/${repo}`])
2369
+ const payload = (await response.json()) as { truncated?: boolean; tree?: TreeEntry[] }
2370
+ if (payload.truncated) throw new Error(`GIT_TREE_TRUNCATED: ${owner}/${repo}@${commitSha}`)
2371
+ return (payload.tree ?? []).map((entry) => ({
2372
+ ...entry,
2373
+ path: safeRelativePath(entry.path, 'TREE_PATH_INVALID'),
2374
+ }))
2375
+ })().catch((error: unknown) => {
2376
+ this.#trees.delete(key)
2377
+ throw error
2369
2378
  })
2370
- if (!response.ok)
2371
- throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [`${owner}/${repo}`])
2372
- const payload = (await response.json()) as { truncated?: boolean; tree?: TreeEntry[] }
2373
- if (payload.truncated) throw new Error(`GIT_TREE_TRUNCATED: ${owner}/${repo}@${commitSha}`)
2374
- tree = (payload.tree ?? []).map((entry) => ({
2375
- ...entry,
2376
- path: safeRelativePath(entry.path, 'TREE_PATH_INVALID'),
2377
- }))
2378
- this.#trees.set(key, tree)
2379
+ this.#trees.set(key, pendingTree)
2379
2380
  }
2381
+ const tree = await pendingTree
2380
2382
  const prefix = pluginSubdirectory ? `${safeRelativePath(pluginSubdirectory)}/` : ''
2381
2383
  const submodules = tree.filter(
2382
2384
  (entry) =>
@@ -2428,12 +2430,18 @@ export class NetworkSnapshotLoader implements SnapshotLoader {
2428
2430
  ): Promise<ReadonlyMap<string, Uint8Array>> {
2429
2431
  const cached = this.#archives.get(key)
2430
2432
  if (cached) return cached
2431
- const url = `https://api.github.com/repos/${owner}/${repo}/tarball/${commitSha}`
2432
- const response = await fetchUpstream(url, { headers: upstreamRequestHeaders(url) })
2433
- if (!response.ok) throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [`${owner}/${repo}`])
2434
- const files = await readArchiveFiles(new Uint8Array(await response.arrayBuffer()), this.#policy)
2435
- this.#archives.set(key, files)
2436
- return files
2433
+ const pending = (async () => {
2434
+ const url = `https://api.github.com/repos/${owner}/${repo}/tarball/${commitSha}`
2435
+ const response = await fetchUpstream(url, { headers: upstreamRequestHeaders(url) })
2436
+ if (!response.ok)
2437
+ throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [`${owner}/${repo}`])
2438
+ return readArchiveFiles(new Uint8Array(await response.arrayBuffer()), this.#policy)
2439
+ })().catch((error: unknown) => {
2440
+ this.#archives.delete(key)
2441
+ throw error
2442
+ })
2443
+ this.#archives.set(key, pending)
2444
+ return pending
2437
2445
  }
2438
2446
  }
2439
2447
 
@@ -12,7 +12,7 @@ import {
12
12
  PACKAGE_METADATA_KEY,
13
13
  } from './portable-catalog.js'
14
14
  import { verifyAgentPackage, materializeAgentPackage } from './agent-plugins-catalog.js'
15
- import { createCatalogInstallationPlan, createInstallationPlan } from './agent-plugins-harness.js'
15
+ import { createCatalogInstallationPlan, createInstallationPlan } from './harness.js'
16
16
  import type { HarnessProfile } from './agent-plugins-schema.js'
17
17
  import { snapshotFromDirectory } from './index.js'
18
18
  import {
@@ -351,7 +351,7 @@ it('refuses a malformed provenance descriptor with one typed line naming the plu
351
351
  }))
352
352
 
353
353
  const CONSUMER_SCRIPT = `import { readFileSync } from 'node:fs'
354
- import { createCatalogInstallationPlan } from '@adea-ai/plugins/agent-plugins/harness'
354
+ import { createCatalogInstallationPlan } from '@adea-ai/plugins/harness'
355
355
  import { synchronizePortable } from '@adea-ai/plugins/portable-catalog'
356
356
  import { digest } from '@adea-ai/plugins'
357
357
  const request = JSON.parse(readFileSync(process.argv[2], 'utf8'))
@@ -377,7 +377,7 @@ try {
377
377
  ok: true,
378
378
  plan,
379
379
  catalogId: result.catalog.catalogId,
380
- resolvedHarness: import.meta.resolve('@adea-ai/plugins/agent-plugins/harness'),
380
+ resolvedHarness: import.meta.resolve('@adea-ai/plugins/harness'),
381
381
  resolvedCatalog: import.meta.resolve('@adea-ai/plugins/portable-catalog'),
382
382
  })
383
383
  )
@@ -475,7 +475,7 @@ it('keeps the packed consumer inside the packed artifact and its own dependencie
475
475
  // The packed artifact was built from the CURRENT source: a stale build
476
476
  // would still carry the sentinel the rebuild removes.
477
477
  assert.equal(consumer.builtFromCurrentSource, true)
478
- assert.equal(existsSync(join(consumer.packedRoot, 'dist', 'agent-plugins-harness.js')), true)
478
+ assert.equal(existsSync(join(consumer.packedRoot, 'dist', 'harness.js')), true)
479
479
  // Every copied dependency really resolves INSIDE the consumer: the
480
480
  // workspace installs them as links into the package manager's store, so
481
481
  // a copy that kept the link would resolve outside the consumer.
@@ -487,7 +487,10 @@ it('keeps the packed consumer inside the packed artifact and its own dependencie
487
487
  // dependencies, and never the whole workspace.
488
488
  const packedManifest = JSON.parse(
489
489
  await readFile(join(consumer.packedRoot, 'package.json'), 'utf8')
490
- ) as { dependencies?: Record<string, string> }
490
+ ) as { dependencies?: Record<string, string>; exports: Record<string, unknown> }
491
+ assert.ok(packedManifest.exports['./harness'])
492
+ assert.equal(packedManifest.exports['./agent-plugins/harness'], undefined)
493
+ assert.equal(existsSync(join(consumer.packedRoot, 'dist', 'agent-plugins-harness.js')), false)
491
494
  const declared = Object.keys(packedManifest.dependencies ?? {})
492
495
  for (const name of declared) assert.ok(consumer.linkedDependencies.includes(name))
493
496
  const installed = await readdir(join(consumer.consumerRoot, 'node_modules'))
package/src/schema.ts CHANGED
@@ -248,45 +248,6 @@ export const SourcesLockSchema = z
248
248
  })
249
249
  .strict()
250
250
 
251
- export const MaterializationPlanSchema = z
252
- .object({
253
- planVersion: z.literal(1),
254
- pluginId: PluginSchema.shape.pluginId,
255
- releaseId: PluginReleaseSchema.shape.releaseId,
256
- harness: HarnessSchema,
257
- source: z
258
- .object({
259
- repositoryUrl: z.string().url(),
260
- commitSha: ShaSchema,
261
- pluginSubdirectory: z.string().min(1),
262
- contentDigest: DigestSchema,
263
- })
264
- .strict(),
265
- targetLayout: z
266
- .object({
267
- root: z.string().min(1),
268
- notes: z.array(z.string().min(1)).max(32),
269
- })
270
- .strict(),
271
- files: z
272
- .array(
273
- z
274
- .object({
275
- sourcePath: z.string().min(1),
276
- targetPath: z.string().min(1),
277
- action: z.enum(['copy', 'translate', 'ignore', 'unsupported']),
278
- reason: z.string().min(1).max(500),
279
- })
280
- .strict()
281
- )
282
- .max(4096),
283
- configuration: JsonObjectSchema,
284
- requiredConnectors: z.array(z.string().min(1)).max(128),
285
- requiredCredentials: z.array(z.string().min(1)).max(128),
286
- policyConstraints: z.array(z.string().min(1)).max(128),
287
- })
288
- .strict()
289
-
290
251
  export type CatalogSource = z.output<typeof CatalogSourceSchema>
291
252
  export type Capability = z.output<typeof CapabilitySchema>
292
253
  export type HarnessCompatibility = z.output<typeof HarnessCompatibilitySchema>
@@ -298,7 +259,6 @@ export type Plugin = z.output<typeof PluginSchema>
298
259
  export type Catalog = z.output<typeof CatalogSchema>
299
260
  export type SourceLockEntry = z.output<typeof SourceLockEntrySchema>
300
261
  export type SourcesLock = z.output<typeof SourcesLockSchema>
301
- export type MaterializationPlan = z.output<typeof MaterializationPlanSchema>
302
262
 
303
263
  export function parseCatalog(input: unknown): Catalog {
304
264
  return CatalogSchema.parse(input)
@@ -1,3 +1,6 @@
1
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
1
4
  import { describe, expect, test } from 'bun:test'
2
5
  import {
3
6
  buildCatalog,
@@ -12,7 +15,7 @@ import {
12
15
  } from './index.js'
13
16
  import { buildConsumerIndex } from './consumer-index.js'
14
17
  import { resolveIconSources, stageIconAssets } from './icon-mirror.js'
15
- import { selectIconPath } from './icons.js'
18
+ import { iconAssetName, selectIconPath } from './icons.js'
16
19
 
17
20
  /**
18
21
  * End-to-end cover for the brand-mark fallback: a plugin that ships no mark of
@@ -23,8 +26,8 @@ import { selectIconPath } from './icons.js'
23
26
  const PNG = new Uint8Array([
24
27
  0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
25
28
  ])
26
- const HOME = 'https://vendor.example/'
27
- const APPLE_ICON = 'https://vendor.example/apple-icon.png'
29
+ const HOME = 'https://fc-vendor.example/'
30
+ const APPLE_ICON = 'https://fd-cdn.example/apple-icon.png'
28
31
 
29
32
  const policy: CatalogPolicy = {
30
33
  allowedRepositoryHosts: ['github.com'],
@@ -98,10 +101,14 @@ function stubFetch(handler: (url: string) => Response) {
98
101
  return { calls, restore: () => void (globalThis.fetch = original) }
99
102
  }
100
103
 
101
- async function build(entryName: string, homepage?: string, options: { live?: boolean } = {}) {
104
+ async function build(
105
+ entryName: string,
106
+ homepage?: string,
107
+ options: { live?: boolean; iconAssetsDirectory?: string } = {}
108
+ ) {
102
109
  const stub = stubFetch((url) => {
103
110
  if (url === HOME)
104
- return new Response(`<head><link rel="apple-touch-icon" href="/apple-icon.png"></head>`, {
111
+ return new Response(`<head><link rel="apple-touch-icon" href="${APPLE_ICON}"></head>`, {
105
112
  status: 200,
106
113
  })
107
114
  if (url === APPLE_ICON) return new Response(PNG, { status: 200 })
@@ -116,6 +123,7 @@ async function build(entryName: string, homepage?: string, options: { live?: boo
116
123
  metadataOnly: false,
117
124
  snapshotLoader: loader({ 'README.md': new TextEncoder().encode('no mark here') }),
118
125
  resolveExternalRefs: options.live ?? true,
126
+ ...(options.iconAssetsDirectory ? { iconAssetsDirectory: options.iconAssetsDirectory } : {}),
119
127
  })
120
128
  return { catalog, calls: stub.calls }
121
129
  } finally {
@@ -124,18 +132,26 @@ async function build(entryName: string, homepage?: string, options: { live?: boo
124
132
  }
125
133
 
126
134
  describe('product site icon fallback', () => {
127
- test('records the site icon for a plugin that ships no mark', async () => {
128
- const { catalog, calls } = await build('connector', HOME)
129
- const release = catalog.plugins[0]!.availableReleases[0]!
130
- expect(release.icon).toEqual({
131
- kind: 'favicon',
132
- url: APPLE_ICON,
133
- homepage: HOME,
134
- contentType: 'image/png',
135
- digest: byteDigest(PNG),
136
- bytes: PNG.byteLength,
137
- })
138
- expect(calls).toEqual([HOME, APPLE_ICON])
135
+ test('records and stages the site icon for a plugin that ships no mark', async () => {
136
+ const directory = await mkdtemp(join(tmpdir(), 'catalog-icons-'))
137
+ try {
138
+ const { catalog, calls } = await build('connector', HOME, { iconAssetsDirectory: directory })
139
+ const release = catalog.plugins[0]!.availableReleases[0]!
140
+ expect(release.icon).toEqual({
141
+ kind: 'favicon',
142
+ url: APPLE_ICON,
143
+ homepage: HOME,
144
+ contentType: 'image/png',
145
+ digest: byteDigest(PNG),
146
+ bytes: PNG.byteLength,
147
+ })
148
+ expect(calls).toEqual([HOME, APPLE_ICON])
149
+ expect(await readFile(join(directory, iconAssetName(byteDigest(PNG), 'image/png')))).toEqual(
150
+ Buffer.from(PNG)
151
+ )
152
+ } finally {
153
+ await rm(directory, { recursive: true, force: true })
154
+ }
139
155
  })
140
156
 
141
157
  test('prefers the plugin content and never contacts the site when a mark exists', async () => {
@@ -148,15 +164,16 @@ describe('product site icon fallback', () => {
148
164
  policy,
149
165
  metadataOnly: false,
150
166
  snapshotLoader: loader({
151
- 'assets/logo.svg': new TextEncoder().encode(
167
+ 'assets/brand.svg': new TextEncoder().encode(
152
168
  '<svg xmlns="http://www.w3.org/2000/svg"><rect width="8" height="8"/></svg>'
153
169
  ),
170
+ 'assets/icon.png': PNG,
154
171
  }),
155
172
  resolveExternalRefs: true,
156
173
  })
157
174
  expect(catalog.plugins[0]!.availableReleases[0]!.icon).toMatchObject({
158
175
  kind: 'content',
159
- path: 'assets/logo.svg',
176
+ path: 'assets/icon.png',
160
177
  })
161
178
  expect(stub.calls).toEqual([])
162
179
  } finally {
@@ -71,6 +71,10 @@ describe('vendor homepage eligibility', () => {
71
71
  expect(isPublicHostname('bare-hostname')).toBe(false)
72
72
  expect(isPublicHostname('::1')).toBe(false)
73
73
  expect(isPublicHostname('fd00::1')).toBe(false)
74
+ expect(isPublicHostname('[fc00::1]')).toBe(false)
75
+ expect(isPublicHostname('fe80::1')).toBe(false)
76
+ expect(isPublicHostname('fc-vendor.example')).toBe(true)
77
+ expect(isPublicHostname('fd-cdn.example')).toBe(true)
74
78
  })
75
79
  })
76
80
 
@@ -100,6 +104,33 @@ describe('site icon discovery', () => {
100
104
  )
101
105
  })
102
106
 
107
+ test('accepts unquoted HTML attributes without reading the next attribute as rel', () => {
108
+ expect(
109
+ discoverSiteIcons('<link rel=icon href=/mark.svg sizes=32x32>', 'https://vendor.example/')
110
+ ).toEqual(['https://vendor.example/mark.svg', 'https://vendor.example/favicon.ico'])
111
+ })
112
+
113
+ test('only reads actual link attributes and complete relation tokens', () => {
114
+ const attributesHtml = `<link data-rel="icon" href="/data-rel.png">
115
+ <link rel="not-icon" href="/not-icon.png">
116
+ <link rel="stylesheet" title="rel=icon" href="/style.css">
117
+ <link-preview rel="icon" href="/custom.png">
118
+ <link rel="icon" data-href="/data-href.png" href="/real.png">`
119
+ expect(discoverSiteIcons(attributesHtml, 'https://vendor.example/')).toEqual([
120
+ 'https://vendor.example/real.png',
121
+ 'https://vendor.example/favicon.ico',
122
+ ])
123
+ })
124
+
125
+ test('does not end a link tag at a greater-than character inside a quoted attribute', () => {
126
+ expect(
127
+ discoverSiteIcons(
128
+ '<link title="a > b" rel="icon" href="/mark.svg">',
129
+ 'https://vendor.example/'
130
+ )
131
+ ).toEqual(['https://vendor.example/mark.svg', 'https://vendor.example/favicon.ico'])
132
+ })
133
+
103
134
  test('falls back to /favicon.ico and refuses unsafe candidates', () => {
104
135
  expect(discoverSiteIcons('<html><head></head></html>', 'https://vendor.example/page')).toEqual([
105
136
  'https://vendor.example/favicon.ico',