@adea-ai/plugins 3.0.10 → 4.0.1

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 (75) 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/catalog-store.d.ts +81 -0
  6. package/dist/catalog-store.js +487 -0
  7. package/dist/catalog-store.js.map +1 -0
  8. package/dist/consumer-index.d.ts +1 -1
  9. package/dist/consumer-index.js +17 -7
  10. package/dist/consumer-index.js.map +1 -1
  11. package/dist/display-name.js +3 -2
  12. package/dist/display-name.js.map +1 -1
  13. package/dist/harness.d.ts +50 -9
  14. package/dist/harness.js +190 -147
  15. package/dist/harness.js.map +1 -1
  16. package/dist/icon-mirror.d.ts +2 -2
  17. package/dist/icon-mirror.js +38 -25
  18. package/dist/icon-mirror.js.map +1 -1
  19. package/dist/icons.js +5 -27
  20. package/dist/icons.js.map +1 -1
  21. package/dist/index.d.ts +9 -3
  22. package/dist/index.js +103 -88
  23. package/dist/index.js.map +1 -1
  24. package/dist/publication.d.ts +8 -11
  25. package/dist/publication.js +8 -11
  26. package/dist/publication.js.map +1 -1
  27. package/dist/schema.d.ts +0 -40
  28. package/dist/schema.js +0 -36
  29. package/dist/schema.js.map +1 -1
  30. package/dist/site-icons.d.ts +2 -0
  31. package/dist/site-icons.js +46 -9
  32. package/dist/site-icons.js.map +1 -1
  33. package/dist/source-digest.d.ts +2 -0
  34. package/dist/source-digest.js +12 -0
  35. package/dist/source-digest.js.map +1 -0
  36. package/dist/sources.js +3 -3
  37. package/dist/sources.js.map +1 -1
  38. package/native/build-provenance.txt +5 -5
  39. package/native/staged-reader-win32-arm64.node +0 -0
  40. package/native/staged-reader-win32-x64.node +0 -0
  41. package/package.json +4 -9
  42. package/src/agent-plugins-catalog.ts +3 -10
  43. package/src/artifacts.test.ts +80 -0
  44. package/src/catalog-installation.test.ts +1 -1
  45. package/src/catalog-store.ts +546 -0
  46. package/src/consumer-index.test.ts +125 -0
  47. package/src/consumer-index.ts +23 -8
  48. package/src/default-head.test.ts +143 -0
  49. package/src/display-name.test.ts +8 -0
  50. package/src/display-name.ts +3 -2
  51. package/src/{agent-plugins-harness.test.ts → harness.test.ts} +1 -1
  52. package/src/harness.ts +249 -170
  53. package/src/icon-mirror.ts +36 -23
  54. package/src/icons.test.ts +5 -0
  55. package/src/icons.ts +6 -29
  56. package/src/index.test.ts +272 -40
  57. package/src/index.ts +131 -94
  58. package/src/manifest-retry.test.ts +49 -0
  59. package/src/portable-catalog.test.ts +8 -5
  60. package/src/publication.ts +8 -11
  61. package/src/schema.ts +0 -40
  62. package/src/site-icon-fallback.test.ts +36 -19
  63. package/src/site-icons.test.ts +104 -0
  64. package/src/site-icons.ts +47 -9
  65. package/src/source-config-sync.test.ts +84 -0
  66. package/src/source-digest.test.ts +101 -0
  67. package/src/source-digest.ts +14 -0
  68. package/src/sources-json.test.ts +53 -0
  69. package/src/sources.ts +3 -3
  70. package/src/upstream-headers.test.ts +31 -7
  71. package/src/write-artifacts.test.ts +375 -0
  72. package/dist/agent-plugins-harness.d.ts +0 -54
  73. package/dist/agent-plugins-harness.js +0 -212
  74. package/dist/agent-plugins-harness.js.map +0 -1
  75. package/src/agent-plugins-harness.ts +0 -281
@@ -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
 
@@ -92,6 +96,68 @@ describe('site icon discovery', () => {
92
96
  ])
93
97
  })
94
98
 
99
+ test('resolves relative icon links against the first document base URL', () => {
100
+ const baseHtml = `<!-- <base href="https://ignored.example/"> <script> -->
101
+ <script>const sample = '<!-- <base href="https://script.example/">'</script>
102
+ <meta content="<base href='https://attribute.example/'>">
103
+ <base target="_blank"><base href="/assets/icons/">
104
+ <base href="https://later.example/">
105
+ <link rel="icon" href="mark.png">`
106
+ expect(discoverSiteIcons(baseHtml, 'https://vendor.example/docs/page')).toEqual([
107
+ 'https://vendor.example/assets/icons/mark.png',
108
+ 'https://vendor.example/favicon.ico',
109
+ ])
110
+ })
111
+
112
+ test('ignores bases and links inside text and template content', () => {
113
+ for (const inert of [
114
+ '<title><base href="https://title.example/"></title>',
115
+ '<textarea><base href="https://textarea.example/"></textarea>',
116
+ `<template><template><base href="https://nested.example/"></template>
117
+ <link rel="icon" href="https://template.example/icon.png"></template>`,
118
+ ]) {
119
+ expect(
120
+ discoverSiteIcons(
121
+ `${inert}<base href="/real/"><link rel="icon" href="mark.png">`,
122
+ 'https://vendor.example/'
123
+ )
124
+ ).toEqual(['https://vendor.example/real/mark.png', 'https://vendor.example/favicon.ico'])
125
+ }
126
+ })
127
+
128
+ test('decodes HTML character references in URLs', () => {
129
+ expect(
130
+ discoverSiteIcons(
131
+ '<base href="/icons/?a=1&amp;b=2"><link rel="icon" href="mark.png?x=1&amp;y=2">',
132
+ 'https://vendor.example/'
133
+ )
134
+ ).toEqual([
135
+ 'https://vendor.example/icons/mark.png?x=1&y=2',
136
+ 'https://vendor.example/favicon.ico',
137
+ ])
138
+ })
139
+
140
+ test('retains the homepage fallback and URL policy with a document base', () => {
141
+ expect(
142
+ discoverSiteIcons(
143
+ '<base href="https://cdn.example/icons/"><link rel="icon" href="mark.png">',
144
+ 'https://vendor.example/docs/page'
145
+ )
146
+ ).toEqual(['https://cdn.example/icons/mark.png', 'https://vendor.example/favicon.ico'])
147
+ expect(
148
+ discoverSiteIcons(
149
+ '<base href="http://vendor.example/assets/"><link rel="icon" href="mark.png">',
150
+ 'https://vendor.example/docs/page'
151
+ )
152
+ ).toEqual(['https://vendor.example/favicon.ico'])
153
+ expect(
154
+ discoverSiteIcons(
155
+ '<base href="http://["><base href="/later/"><link rel="icon" href="mark.png">',
156
+ 'https://vendor.example/docs/page'
157
+ )
158
+ ).toEqual(['https://vendor.example/docs/mark.png', 'https://vendor.example/favicon.ico'])
159
+ })
160
+
95
161
  test('prefers the larger declared size within a group', () => {
96
162
  const sizes = `<link rel="icon" sizes="32x32" href="/small.png">
97
163
  <link rel="icon" sizes="512x512" href="/large.png">`
@@ -100,6 +166,33 @@ describe('site icon discovery', () => {
100
166
  )
101
167
  })
102
168
 
169
+ test('accepts unquoted HTML attributes without reading the next attribute as rel', () => {
170
+ expect(
171
+ discoverSiteIcons('<link rel=icon href=/mark.svg sizes=32x32>', 'https://vendor.example/')
172
+ ).toEqual(['https://vendor.example/mark.svg', 'https://vendor.example/favicon.ico'])
173
+ })
174
+
175
+ test('only reads actual link attributes and complete relation tokens', () => {
176
+ const attributesHtml = `<link data-rel="icon" href="/data-rel.png">
177
+ <link rel="not-icon" href="/not-icon.png">
178
+ <link rel="stylesheet" title="rel=icon" href="/style.css">
179
+ <link-preview rel="icon" href="/custom.png">
180
+ <link rel="icon" data-href="/data-href.png" href="/real.png">`
181
+ expect(discoverSiteIcons(attributesHtml, 'https://vendor.example/')).toEqual([
182
+ 'https://vendor.example/real.png',
183
+ 'https://vendor.example/favicon.ico',
184
+ ])
185
+ })
186
+
187
+ test('does not end a link tag at a greater-than character inside a quoted attribute', () => {
188
+ expect(
189
+ discoverSiteIcons(
190
+ '<link title="a > b" rel="icon" href="/mark.svg">',
191
+ 'https://vendor.example/'
192
+ )
193
+ ).toEqual(['https://vendor.example/mark.svg', 'https://vendor.example/favicon.ico'])
194
+ })
195
+
103
196
  test('falls back to /favicon.ico and refuses unsafe candidates', () => {
104
197
  expect(discoverSiteIcons('<html><head></head></html>', 'https://vendor.example/page')).toEqual([
105
198
  'https://vendor.example/favicon.ico',
@@ -132,6 +225,17 @@ describe('site icon resolution', () => {
132
225
  expect(io.byteCalls).toEqual(['https://vendor.example/apple-icon.png'])
133
226
  })
134
227
 
228
+ test('fetches a relative icon from the document base', async () => {
229
+ const page = 'https://vendor.example/docs/page'
230
+ const baseHtml = '<head><base href="/assets/"><link rel="icon" href="mark.png"></head>'
231
+ const io = fetcher({ [page]: baseHtml, 'https://vendor.example/assets/mark.png': PNG })
232
+ expect(await resolveSiteIcon(page, io)).toMatchObject({
233
+ url: 'https://vendor.example/assets/mark.png',
234
+ homepage: page,
235
+ })
236
+ expect(io.byteCalls).toEqual(['https://vendor.example/assets/mark.png'])
237
+ })
238
+
135
239
  test('accepts an icon container and skips a candidate that fails inspection', async () => {
136
240
  const ico = fetcher({ [homepage]: html, 'https://vendor.example/apple-icon.png': ICO })
137
241
  expect(await resolveSiteIcon(homepage, ico)).toMatchObject({ contentType: 'image/x-icon' })
package/src/site-icons.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseFragment, type DefaultTreeAdapterTypes } from 'parse5'
1
2
  import { inspectIconBytes, MAX_ICON_BYTES, type IconContentType } from './icons.js'
2
3
 
3
4
  /**
@@ -52,7 +53,10 @@ export function isPublicHostname(hostname: string): boolean {
52
53
  const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
53
54
  if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return false
54
55
  if (host.endsWith('.internal') || host.endsWith('.home.arpa')) return false
55
- if (host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd'))
56
+ if (
57
+ host.includes(':') &&
58
+ (host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd'))
59
+ )
56
60
  return false
57
61
  const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
58
62
  if (ipv4) {
@@ -73,25 +77,59 @@ export function isPublicHostname(hostname: string): boolean {
73
77
  * `apple-touch-icon` is preferred because it is a square PNG sized for a home
74
78
  * screen; declared `icon` links follow in declared order; `/favicon.ico` is the
75
79
  * conventional last resort when nothing is declared.
80
+ * Relative links use the document's first base href when present; the fallback
81
+ * stays on the homepage origin.
76
82
  */
77
83
  export function discoverSiteIcons(html: string, baseUrl: string): readonly string[] {
78
84
  const candidates: { href: string; rank: number }[] = []
79
- const linkTags = html.match(/<link\b[^>]*>/gi) ?? []
80
- for (const tag of linkTags) {
81
- const rel = /\brel\s*=\s*["']?([^"'>]+)/i.exec(tag)?.[1]?.toLowerCase() ?? ''
82
- if (!/\b(apple-touch-icon|icon|shortcut|mask-icon)\b/.test(rel)) continue
83
- const href = /\bhref\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1]
85
+ const elements: DefaultTreeAdapterTypes.Element[] = []
86
+ const nodes: DefaultTreeAdapterTypes.ChildNode[] = parseFragment(html).childNodes.toReversed()
87
+ while (nodes.length > 0) {
88
+ const node = nodes.pop()!
89
+ if (!('tagName' in node)) continue
90
+ if (node.namespaceURI === 'http://www.w3.org/1999/xhtml') elements.push(node)
91
+ // Template contents are a separate, inert document fragment.
92
+ if (node.tagName !== 'template') nodes.push(...node.childNodes.toReversed())
93
+ }
94
+ let resolutionBase = baseUrl
95
+ for (const element of elements) {
96
+ if (element.tagName !== 'base') continue
97
+ const attributes = new Map(element.attrs.map(({ name, value }) => [name, value]))
98
+ if (!attributes.has('href')) continue
99
+ try {
100
+ resolutionBase = new URL(attributes.get('href')!, baseUrl).toString()
101
+ } catch {
102
+ // An invalid first base falls back to the document URL.
103
+ }
104
+ break
105
+ }
106
+ for (const element of elements) {
107
+ if (element.tagName !== 'link') continue
108
+ const attributes = new Map(element.attrs.map(({ name, value }) => [name, value]))
109
+ const rel = new Set((attributes.get('rel') ?? '').toLowerCase().split(/\s+/))
110
+ if (
111
+ !['apple-touch-icon', 'apple-touch-icon-precomposed', 'icon', 'mask-icon'].some((name) =>
112
+ rel.has(name)
113
+ )
114
+ )
115
+ continue
116
+ const href = attributes.get('href')
84
117
  if (!href) continue
85
- const sizes = /\bsizes\s*=\s*["']?([0-9]+x[0-9]+)/i.exec(tag)?.[1]
118
+ const sizes = /^(\d+x\d+)(?:\s|$)/i.exec(attributes.get('sizes') ?? '')?.[1]
86
119
  const edge = sizes ? Number.parseInt(sizes, 10) : 180
87
120
  // Apple touch icons lead; larger declared sizes lead within a group.
88
- const rank = rel.includes('apple-touch-icon') ? 0 : rel.includes('mask-icon') ? 2 : 1
121
+ const rank =
122
+ rel.has('apple-touch-icon') || rel.has('apple-touch-icon-precomposed')
123
+ ? 0
124
+ : rel.has('mask-icon')
125
+ ? 2
126
+ : 1
89
127
  candidates.push({ href, rank: rank * 1000 + Math.max(0, 512 - (edge || 180)) })
90
128
  }
91
129
  const resolved: { url: string; rank: number }[] = []
92
130
  for (const candidate of candidates.toSorted((left, right) => left.rank - right.rank)) {
93
131
  try {
94
- const url = new URL(candidate.href, baseUrl)
132
+ const url = new URL(candidate.href, resolutionBase)
95
133
  if (url.protocol !== 'https:' || !isPublicHostname(url.hostname)) continue
96
134
  resolved.push({ url: url.toString(), rank: candidate.rank })
97
135
  } catch {
@@ -0,0 +1,84 @@
1
+ import { copyFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { expect, test } from 'bun:test'
4
+ import { synchronizePortable } from './portable-catalog.js'
5
+ import { withPortableFixture } from '../test-support/portable-fixture.js'
6
+
7
+ test('rebuilds and reports source metadata changes when source commits stay fixed', () =>
8
+ withPortableFixture(async (input, root) => {
9
+ const first = await synchronizePortable(input)
10
+ const old = input.sources[0]!
11
+ await copyFile(
12
+ join(root, 'fixtures/source/marketplace.json'),
13
+ join(root, 'fixtures/source/alternate.json')
14
+ )
15
+ const cases = [
16
+ { ...old, displayName: 'Renamed source' },
17
+ { ...old, defaultBranch: 'stable' },
18
+ { ...old, manifestPath: 'alternate.json' },
19
+ { ...old, marketplaceDialect: 'openai' as const },
20
+ ]
21
+ for (const source of cases) {
22
+ const result = await synchronizePortable({
23
+ ...input,
24
+ sources: [source],
25
+ existingLock: first.lock!,
26
+ existingCatalog: first.catalog!,
27
+ })
28
+ expect(result.changed).toBe(true)
29
+ expect(result.changeReport.changedSources).toContain(source.sourceId)
30
+ expect(result.catalog?.sources[0]?.displayName).toBe(source.displayName)
31
+ expect(result.lock?.sources[0]?.defaultBranch).toBe(source.defaultBranch)
32
+ expect(result.lock?.sources[0]?.manifestPath).toBe(source.manifestPath)
33
+ expect(result.lock?.sources[0]?.marketplaceDialect).toBe(source.marketplaceDialect)
34
+ const repeated = await synchronizePortable({
35
+ ...input,
36
+ sources: [source],
37
+ existingLock: result.lock!,
38
+ existingCatalog: result.catalog!,
39
+ })
40
+ expect(repeated.changed).toBe(false)
41
+ }
42
+ const equivalent = await synchronizePortable({
43
+ ...input,
44
+ sources: [{ ...old, repositoryUrl: `${old.repositoryUrl}.git` }],
45
+ existingLock: first.lock!,
46
+ existingCatalog: first.catalog!,
47
+ })
48
+ expect(equivalent.changed).toBe(false)
49
+ }))
50
+
51
+ test('rebuilds if the marketplace repository changes while external plugin pins stay equal', () =>
52
+ withPortableFixture(async (input, root) => {
53
+ const { writeFile } = await import('node:fs/promises')
54
+ await writeFile(
55
+ join(root, 'fixtures/source/marketplace.json'),
56
+ JSON.stringify({
57
+ plugins: [
58
+ { name: 'remote', source: { source: 'git', url: 'https://github.com/example/external' } },
59
+ ],
60
+ })
61
+ )
62
+ const snapshotLoader = {
63
+ load: async () => ({
64
+ files: new Map([['plugin.json', new TextEncoder().encode('{"name":"Remote"}')]]),
65
+ symlinks: [],
66
+ }),
67
+ }
68
+ const options = { ...input, snapshotLoader }
69
+ const first = await synchronizePortable(options)
70
+ const source = {
71
+ ...input.sources[0]!,
72
+ repositoryUrl: 'https://github.com/example/moved-marketplace',
73
+ }
74
+ const result = await synchronizePortable({
75
+ ...options,
76
+ sources: [source],
77
+ existingLock: first.lock!,
78
+ })
79
+ expect(result.changed).toBe(true)
80
+ expect(result.lock?.sources[0]?.repositoryUrl).toBe(source.repositoryUrl)
81
+ expect(result.catalog?.sources[0]?.repositoryUrl).toBe(source.repositoryUrl)
82
+ expect(result.changeReport.changedSources).toEqual([source.sourceId])
83
+ expect(result.lock?.sources[0]?.pluginPins).toEqual(first.lock?.sources[0]?.pluginPins)
84
+ }))
@@ -0,0 +1,101 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { spawnSync } from 'node:child_process'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { expect, test } from 'bun:test'
6
+
7
+ // Exercise the public source identity functions as a Node consumer, where the
8
+ // default ICU locale follows the machine rather than Bun's fixed default.
9
+ test('source digests agree across consumer locales and equivalent Unicode path order', async () => {
10
+ const directory = await mkdtemp(join(import.meta.dirname, '.source-digest-'))
11
+ try {
12
+ const entry = join(directory, 'entry.ts')
13
+ const output = join(directory, 'consumer.mjs')
14
+ await writeFile(
15
+ entry,
16
+ `export { bytesDigest } from '../index.ts'\nexport { sourceDigest } from '../agent-plugins-catalog.ts'\n`
17
+ )
18
+ const build = await Bun.build({ entrypoints: [entry], target: 'node', packages: 'external' })
19
+ expect(build.success).toBe(true)
20
+ await Bun.write(output, build.outputs[0]!)
21
+ const script = `
22
+ import { bytesDigest, sourceDigest } from ${JSON.stringify(pathToFileURL(output).href)};
23
+ const paths = ['z.txt', 'ä.txt', 'é.txt', 'e\\u0301.txt'];
24
+ const entries = paths.map((path) => [path, new TextEncoder().encode(path)]);
25
+ const forward = new Map(entries), reverse = new Map([...entries].reverse());
26
+ console.log(JSON.stringify([
27
+ bytesDigest(forward), bytesDigest(reverse), sourceDigest(forward), sourceDigest(reverse)
28
+ ]));
29
+ `
30
+ const results = ['en_US.UTF-8', 'sv_SE.UTF-8'].map((locale) => {
31
+ const result = spawnSync('node', ['--input-type=module', '-e', script], {
32
+ encoding: 'utf8',
33
+ env: { ...process.env, LANG: locale, LC_ALL: locale },
34
+ })
35
+ if (result.error) throw result.error
36
+ if (result.status !== 0) throw new Error(result.stderr)
37
+ expect(result.status).toBe(0)
38
+ return JSON.parse(result.stdout) as string[]
39
+ })
40
+ expect(new Set(results.flat()).size).toBe(1)
41
+ } finally {
42
+ await rm(directory, { recursive: true, force: true })
43
+ }
44
+ })
45
+
46
+ test('source lock identity is independent of the consumer locale', async () => {
47
+ const directory = await mkdtemp(join(import.meta.dirname, '.source-lock-'))
48
+ try {
49
+ const source = join(directory, 'fixture', 'source')
50
+ await mkdir(source, { recursive: true })
51
+ await writeFile(
52
+ join(source, 'marketplace.json'),
53
+ JSON.stringify({
54
+ plugins: [
55
+ { name: 'a-plugin', source: './a' },
56
+ { name: 'ä-plugin', source: './u' },
57
+ { name: 'z-plugin', source: './z' },
58
+ ],
59
+ })
60
+ )
61
+ const entry = join(directory, 'entry.ts')
62
+ const output = join(directory, 'consumer.mjs')
63
+ await writeFile(entry, "export { synchronize } from '../index.ts'\n")
64
+ const build = await Bun.build({ entrypoints: [entry], target: 'node', packages: 'external' })
65
+ expect(build.success).toBe(true)
66
+ await Bun.write(output, build.outputs[0]!)
67
+ const script = `
68
+ import { synchronize } from ${JSON.stringify(pathToFileURL(output).href)};
69
+ const result = await synchronize({
70
+ mode: 'offline', fixtureRoot: ${JSON.stringify(join(directory, 'fixture'))},
71
+ sources: [{sourceId:'test-source',displayName:'Test',marketplaceDialect:'claude',
72
+ repositoryUrl:'https://github.com/example/plugins',manifestPath:'marketplace.json',
73
+ defaultBranch:'main',trustClassification:'official',fixturePath:'source',
74
+ fixtureCommitSha:'a'.repeat(40)}],
75
+ categoryMap:{aliases:{},fallback:'other'},productAliases:{aliases:{}},
76
+ policy:{allowedRepositoryProtocols:['https:'],allowedRepositoryHosts:['github.com'],
77
+ maxFilesPerPlugin:100,maxBytesPerPlugin:100000,maxFileBytes:20000,
78
+ maxMarketplacePlugins:100,denyExecutableLifecycleScripts:true,
79
+ publishRequiresCompleteContent:false,sensitiveCapabilityTypes:[]},metadataOnly:true
80
+ });
81
+ console.log(JSON.stringify({lockId:result.lock.lockId,pins:result.lock.sources[0].pluginPins}));
82
+ `
83
+ const results = ['en_US.UTF-8', 'sv_SE.UTF-8'].map((locale) => {
84
+ const result = spawnSync('node', ['--input-type=module', '-e', script], {
85
+ encoding: 'utf8',
86
+ env: { ...process.env, LANG: locale, LC_ALL: locale },
87
+ })
88
+ if (result.error) throw result.error
89
+ if (result.status !== 0) throw new Error(result.stderr)
90
+ return JSON.parse(result.stdout) as { lockId: string; pins: { pluginName: string }[] }
91
+ })
92
+ expect(results[0]).toEqual(results[1])
93
+ expect(results[0]!.pins.map((pin) => pin.pluginName)).toEqual([
94
+ 'a-plugin',
95
+ 'z-plugin',
96
+ 'ä-plugin',
97
+ ])
98
+ } finally {
99
+ await rm(directory, { recursive: true, force: true })
100
+ }
101
+ })
@@ -0,0 +1,14 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /** Shared source-byte identity for catalog provenance and canonical package recipes. */
4
+ export function sourceDigest(files: ReadonlyMap<string, Uint8Array>): string {
5
+ const hash = createHash('sha256')
6
+ // English matches the compiler runtime; ordinal ties distinguish equivalent Unicode names.
7
+ for (const [path, bytes] of [...files].toSorted(
8
+ ([a], [b]) => a.localeCompare(b, 'en') || (a < b ? -1 : a > b ? 1 : 0)
9
+ )) {
10
+ hash.update(`${path.length}:${path}:${bytes.byteLength}:`)
11
+ hash.update(bytes)
12
+ }
13
+ return `sha256:${hash.digest('hex')}`
14
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { parseJsonDocument, parseMarketplaceManifest, type SourceConfig } from './sources.js'
3
+
4
+ describe('JSON document whitespace', () => {
5
+ test('accepts the four JSON whitespace characters around nested values', () => {
6
+ const text = ' \t\r\n{ "plugins"\t:\r[\ntrue, false, null, 1, "text", {} ] }\r\n'
7
+ expect(parseJsonDocument(text)).toEqual(JSON.parse(text))
8
+ })
9
+
10
+ test.each(['\u000b', '\u000c', '\u00a0', '\u1680', '\u2028', '\u2029', '\ufeff'])(
11
+ 'rejects non-JSON whitespace %j outside strings',
12
+ (space) => {
13
+ for (const text of [
14
+ `${space}{}`,
15
+ `{}${space}`,
16
+ `{${space}"plugins":[]}`,
17
+ `{"plugins"${space}:[]}`,
18
+ `{"plugins":${space}[]}`,
19
+ `[1${space},2]`,
20
+ `[1,${space}2]`,
21
+ ]) {
22
+ expect(() => JSON.parse(text)).toThrow()
23
+ expect(() => parseJsonDocument(text)).toThrow('JSON_MALFORMED')
24
+ }
25
+ }
26
+ )
27
+
28
+ test('preserves Unicode whitespace inside JSON strings', () => {
29
+ const text = JSON.stringify({ value: '\u00a0\u1680\u2028\u2029\ufeff' })
30
+ expect(parseJsonDocument(text)).toEqual(JSON.parse(text))
31
+ })
32
+
33
+ test('continues rejecting escaped duplicate keys', () => {
34
+ expect(() => parseJsonDocument('{"name":1,"na\\u006de":2}')).toThrow('JSON_DUPLICATE_KEY')
35
+ })
36
+ })
37
+
38
+ test('sorts marketplace metadata independently of the host locale', () => {
39
+ const source: SourceConfig = {
40
+ sourceId: 'test',
41
+ displayName: 'Test',
42
+ repositoryUrl: 'https://github.com/example/plugins',
43
+ marketplaceDialect: 'openai',
44
+ manifestPath: 'marketplace.json',
45
+ defaultBranch: 'main',
46
+ trustClassification: 'official',
47
+ }
48
+ const parsed = parseMarketplaceManifest(
49
+ { plugins: [{ name: 'test', source: './test', keywords: ['ä', 'z', 'a', 'ä'] }] },
50
+ source
51
+ )
52
+ expect(parsed.entries[0]?.keywords).toEqual(['a', 'z', 'ä'])
53
+ })
package/src/sources.ts CHANGED
@@ -82,7 +82,7 @@ export function parseMarketplaceManifest(input: unknown, source: SourceConfig):
82
82
  export function parseJsonDocument(text: string): unknown {
83
83
  let index = 0
84
84
  const skip = () => {
85
- while (/\s/.test(text[index] ?? '')) index += 1
85
+ while (/[ \t\r\n]/.test(text[index] ?? '')) index += 1
86
86
  }
87
87
  const string = (): string => {
88
88
  const start = index
@@ -158,7 +158,7 @@ export function parseJsonDocument(text: string): unknown {
158
158
  }
159
159
  if (character === '"') return string()
160
160
  const start = index
161
- while (index < text.length && !/[\s,\]}]/.test(text[index] ?? '')) index += 1
161
+ while (index < text.length && !/[ \t\r\n,\]}]/.test(text[index] ?? '')) index += 1
162
162
  const token = text.slice(start, index)
163
163
  try {
164
164
  return JSON.parse(token) as unknown
@@ -312,7 +312,7 @@ function isString(input: unknown): input is string {
312
312
 
313
313
  function uniqueStrings(input: readonly string[]): string[] {
314
314
  return [...new Set(input.map((value) => value.trim()).filter(Boolean))].toSorted((a, b) =>
315
- a.localeCompare(b)
315
+ a < b ? -1 : a > b ? 1 : 0
316
316
  )
317
317
  }
318
318
 
@@ -132,17 +132,41 @@ describe('site-icon fetcher bounds', () => {
132
132
  }
133
133
  })
134
134
 
135
- test('honours a caller deadline that has already passed', async () => {
136
- const mock = captureFetch()
135
+ test('honours a caller deadline that has already passed or expires during backoff', async () => {
136
+ const mock = captureFetch(() => {
137
+ throw new DOMException('Aborted', 'AbortError')
138
+ })
137
139
  try {
138
140
  const fetcher = createSiteIconFetcher(AbortSignal.abort())
139
- await fetcher.fetchBytes(VENDOR_ICON)
141
+ await expect(fetcher.fetchBytes(VENDOR_ICON)).rejects.toThrow()
142
+ expect(mock.inits).toHaveLength(1)
140
143
  } finally {
141
144
  mock.restore()
142
145
  }
143
- // The request still goes out carrying the spent deadline, so the platform
144
- // aborts it; a real fetch rejects rather than returning a page.
145
- expect(mock.inits).toHaveLength(1)
146
- expect(mock.inits[0]?.signal?.aborted).toBe(true)
146
+
147
+ const controller = new AbortController()
148
+ let cancelled = false
149
+ const retry = captureFetch(
150
+ () =>
151
+ new Response(
152
+ new ReadableStream({
153
+ cancel() {
154
+ cancelled = true
155
+ },
156
+ }),
157
+ { status: 503, headers: { 'retry-after': '0.05' } }
158
+ )
159
+ )
160
+ const timer = setTimeout(() => controller.abort(), 5)
161
+ try {
162
+ await expect(
163
+ createSiteIconFetcher(controller.signal).fetchBytes(VENDOR_ICON)
164
+ ).rejects.toThrow()
165
+ expect(retry.inits).toHaveLength(1)
166
+ expect(cancelled).toBe(true)
167
+ } finally {
168
+ clearTimeout(timer)
169
+ retry.restore()
170
+ }
147
171
  })
148
172
  })