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