@adea-ai/plugins 4.0.0 → 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 +76 -0
- package/dist/catalog-store.js +463 -0
- package/dist/catalog-store.js.map +1 -0
- 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 +7 -29
- package/dist/consumer-index.js +24 -15
- 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 +5 -4
- package/dist/icon-mirror.js +73 -72
- 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 -309
- package/dist/index.js +12 -1900
- 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 +12 -12
- package/dist/publication.js +31 -21
- 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 +4 -3
- package/dist/site-icons.js +38 -11
- 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 +3 -3
- 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.test.ts +80 -0
- package/src/artifacts.ts +234 -0
- package/src/build.ts +131 -0
- package/src/capabilities.ts +153 -0
- package/src/catalog-store.ts +519 -0
- package/src/concurrency.ts +19 -0
- package/src/consumer-index.test.ts +147 -16
- package/src/consumer-index.ts +35 -43
- package/src/default-head.test.ts +143 -0
- 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 +78 -72
- package/src/icons.test.ts +0 -4
- package/src/icons.ts +4 -4
- package/src/index.test.ts +172 -46
- package/src/index.ts +58 -2562
- package/src/integrity-manifest.ts +41 -21
- package/src/json-values.ts +11 -0
- package/src/manifest-retry.test.ts +49 -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 +38 -22
- package/src/published-output.ts +60 -0
- package/src/site-icons.test.ts +73 -1
- package/src/site-icons.ts +31 -14
- package/src/snapshots.ts +287 -0
- package/src/source-config-sync.test.ts +84 -0
- package/src/source-digest.test.ts +61 -8
- package/src/source-resolution.ts +233 -0
- package/src/sources-json.test.ts +18 -2
- 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 +374 -0
- package/dist/source-digest.d.ts +0 -2
- package/dist/source-digest.js +0 -12
- package/dist/source-digest.js.map +0 -1
- package/native/README.md +0 -24
- package/src/source-digest.ts +0 -14
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { MarketplacePluginEntry } from './sources.js'
|
|
2
|
+
import type { ResolvedSource } from './source-resolution.js'
|
|
3
|
+
import { stablePluginId } from './taxonomy.js'
|
|
4
|
+
|
|
5
|
+
export type PluginSkipReasonCode =
|
|
6
|
+
| 'GIT_SUBMODULE_UNSUPPORTED'
|
|
7
|
+
| 'PLUGIN_FILE_TOO_LARGE'
|
|
8
|
+
| 'PLUGIN_FILE_UNAVAILABLE'
|
|
9
|
+
| 'PLUGIN_MANIFEST_INVALID'
|
|
10
|
+
| 'PLUGIN_PATH_NOT_FOUND'
|
|
11
|
+
| 'PLUGIN_SCHEMA_INVALID'
|
|
12
|
+
| 'PLUGIN_SIZE_POLICY'
|
|
13
|
+
| 'PLUGIN_SOURCE_UNAVAILABLE'
|
|
14
|
+
| 'PLUGIN_TOO_LARGE'
|
|
15
|
+
| 'PLUGIN_TOO_MANY_FILES'
|
|
16
|
+
| 'SNAPSHOT_PATH_INVALID'
|
|
17
|
+
| 'SYMLINK_ESCAPE'
|
|
18
|
+
|
|
19
|
+
export interface SkippedPlugin {
|
|
20
|
+
readonly sourceId: string
|
|
21
|
+
readonly pluginId: string
|
|
22
|
+
readonly pluginName: string
|
|
23
|
+
readonly reasonCode: PluginSkipReasonCode
|
|
24
|
+
readonly securityReason: string
|
|
25
|
+
readonly incompleteContent: true
|
|
26
|
+
readonly paths: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class PluginSafetyError extends Error {
|
|
30
|
+
constructor(
|
|
31
|
+
readonly reasonCode: PluginSkipReasonCode,
|
|
32
|
+
readonly paths: readonly string[]
|
|
33
|
+
) {
|
|
34
|
+
super(`${reasonCode}${paths.length > 0 ? `: ${paths.join(',')}` : ''}`)
|
|
35
|
+
this.name = 'PluginSafetyError'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pluginSkipReasons: Readonly<Record<PluginSkipReasonCode, string>> = {
|
|
40
|
+
GIT_SUBMODULE_UNSUPPORTED:
|
|
41
|
+
'The plugin contains a Git submodule, so the catalog excludes it rather than traversing an unpinned repository boundary.',
|
|
42
|
+
PLUGIN_FILE_TOO_LARGE:
|
|
43
|
+
'The plugin contains a file over the configured size limit, so the catalog excludes the plugin before publication.',
|
|
44
|
+
PLUGIN_FILE_UNAVAILABLE:
|
|
45
|
+
'A plugin file could not be fetched from upstream after retries, so the catalog excludes the plugin rather than publishing partial content.',
|
|
46
|
+
PLUGIN_MANIFEST_INVALID:
|
|
47
|
+
'The plugin manifest is malformed, so the catalog excludes the plugin before consuming its metadata.',
|
|
48
|
+
PLUGIN_PATH_NOT_FOUND:
|
|
49
|
+
'The declared plugin path does not exist at the pinned commit, so the catalog excludes the plugin.',
|
|
50
|
+
PLUGIN_SCHEMA_INVALID:
|
|
51
|
+
'The normalized plugin record exceeds the published catalog schema, so the catalog excludes it before publication.',
|
|
52
|
+
PLUGIN_SIZE_POLICY:
|
|
53
|
+
'The plugin exceeds the configured size policy, so the catalog excludes it before publication.',
|
|
54
|
+
PLUGIN_SOURCE_UNAVAILABLE:
|
|
55
|
+
'The plugin repository tree could not be fetched from upstream after retries, so the catalog excludes the plugin rather than publishing partial content.',
|
|
56
|
+
PLUGIN_TOO_LARGE:
|
|
57
|
+
'The plugin exceeds the configured aggregate size limit, so the catalog excludes it before publication.',
|
|
58
|
+
PLUGIN_TOO_MANY_FILES:
|
|
59
|
+
'The plugin exceeds the configured file-count limit, so the catalog excludes it before publication.',
|
|
60
|
+
SNAPSHOT_PATH_INVALID:
|
|
61
|
+
'The plugin snapshot contains an unsafe path, so the catalog excludes it rather than allowing repository escape.',
|
|
62
|
+
SYMLINK_ESCAPE:
|
|
63
|
+
'The plugin snapshot contains symlinks; symlinks are never followed, so the catalog excludes the plugin.',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createSkippedPlugin(
|
|
67
|
+
source: ResolvedSource,
|
|
68
|
+
entry: MarketplacePluginEntry,
|
|
69
|
+
error: unknown
|
|
70
|
+
): SkippedPlugin | undefined {
|
|
71
|
+
const parsed =
|
|
72
|
+
error instanceof PluginSafetyError
|
|
73
|
+
? { reasonCode: error.reasonCode, paths: error.paths }
|
|
74
|
+
: parsePluginSafetyError(error)
|
|
75
|
+
if (!parsed) return undefined
|
|
76
|
+
return {
|
|
77
|
+
sourceId: source.config.sourceId,
|
|
78
|
+
pluginId: stablePluginId(source.config.sourceId, entry.name),
|
|
79
|
+
pluginName: entry.name,
|
|
80
|
+
reasonCode: parsed.reasonCode,
|
|
81
|
+
securityReason: pluginSkipReasons[parsed.reasonCode],
|
|
82
|
+
incompleteContent: true,
|
|
83
|
+
paths: [...new Set(parsed.paths)].toSorted(),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parsePluginSafetyError(
|
|
88
|
+
error: unknown
|
|
89
|
+
): { reasonCode: PluginSkipReasonCode; paths: readonly string[] } | undefined {
|
|
90
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
91
|
+
const match = /^([A-Z_]+)(?::\s?(.*))?$/.exec(message)
|
|
92
|
+
if (!match) return undefined
|
|
93
|
+
const reasonCode = match[1] as PluginSkipReasonCode
|
|
94
|
+
if (!(reasonCode in pluginSkipReasons)) return undefined
|
|
95
|
+
return {
|
|
96
|
+
reasonCode,
|
|
97
|
+
paths: match[2]
|
|
98
|
+
? match[2]
|
|
99
|
+
.split(',')
|
|
100
|
+
.map((path) => path.trim())
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
: [],
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const UPSTREAM_WEATHER_SKIP_CODES: ReadonlySet<PluginSkipReasonCode> = new Set([
|
|
107
|
+
'PLUGIN_SOURCE_UNAVAILABLE',
|
|
108
|
+
'PLUGIN_FILE_UNAVAILABLE',
|
|
109
|
+
'PLUGIN_PATH_NOT_FOUND',
|
|
110
|
+
])
|
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
|
*
|
|
@@ -9,10 +11,11 @@
|
|
|
9
11
|
* that slot, so no version is ever the one a visitor sees first.
|
|
10
12
|
*
|
|
11
13
|
* The branch gives each catalog an immutable path — `catalogs/<catalogId>/…`,
|
|
12
|
-
* where `catalogId` is the canonical digest of the catalog itself
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* CORS so a browser
|
|
14
|
+
* where `catalogId` is the canonical digest of the catalog itself. The
|
|
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 = [
|
|
@@ -63,11 +66,9 @@ export interface StagedAssetAudit {
|
|
|
63
66
|
/**
|
|
64
67
|
* Audits the bytes this build staged against what it declared.
|
|
65
68
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* therefore a different path. What survives is the check that cannot be
|
|
70
|
-
* delegated to the path — that the bytes exist here at all.
|
|
69
|
+
* This checks that the declared bytes exist here at all. Publication must
|
|
70
|
+
* separately compare the complete staged snapshot with any existing snapshot:
|
|
71
|
+
* companion artifacts can differ without changing the catalog identity.
|
|
71
72
|
*/
|
|
72
73
|
export function auditStagedAssets(input: {
|
|
73
74
|
readonly declared: readonly DeclaredAsset[]
|
|
@@ -95,14 +96,13 @@ export interface PublicationPlan {
|
|
|
95
96
|
/**
|
|
96
97
|
* The 64-hex digest a `catalog:<hex>` identity is addressed by.
|
|
97
98
|
*
|
|
98
|
-
* A catalog identity names
|
|
99
|
-
* snapshot path
|
|
100
|
-
* different identity and cannot write over this one.
|
|
99
|
+
* A catalog identity names the catalog content. The publisher must also guard
|
|
100
|
+
* the snapshot path against changes to companion artifacts under the same ID.
|
|
101
101
|
*/
|
|
102
102
|
export function catalogIdSuffix(catalogId: string): string {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return
|
|
103
|
+
if (!CatalogSchema.shape.catalogId.safeParse(catalogId).success)
|
|
104
|
+
throw new Error(`CATALOG_ID_INVALID: ${catalogId}`)
|
|
105
|
+
return catalogId.slice('catalog:'.length)
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/**
|
|
@@ -110,7 +110,10 @@ export function catalogIdSuffix(catalogId: string): string {
|
|
|
110
110
|
* catalog pointer.
|
|
111
111
|
*/
|
|
112
112
|
export function catalogAssetsBaseUrl(repositoryUrl: string): string {
|
|
113
|
-
|
|
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}`
|
|
114
117
|
}
|
|
115
118
|
|
|
116
119
|
/** Root of one catalog's immutable artifacts, content-addressed by identity. */
|
|
@@ -137,6 +140,11 @@ export function createPublicationPlan(input: {
|
|
|
137
140
|
}
|
|
138
141
|
}
|
|
139
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
|
+
|
|
140
148
|
/**
|
|
141
149
|
* Immutable URL for one artifact of one catalog.
|
|
142
150
|
*
|
|
@@ -150,22 +158,30 @@ export function immutableAssetUrl(
|
|
|
150
158
|
catalogId: string,
|
|
151
159
|
assetName: string
|
|
152
160
|
): string {
|
|
153
|
-
if (
|
|
154
|
-
throw new Error(`ASSET_NAME_INVALID: ${assetName}`)
|
|
161
|
+
if (!isPublishedName(assetName)) throw new Error(`ASSET_NAME_INVALID: ${assetName}`)
|
|
155
162
|
return `${catalogSnapshotBaseUrl(repositoryUrl, catalogId)}/${assetName}`
|
|
156
163
|
}
|
|
157
164
|
|
|
158
|
-
function
|
|
165
|
+
function repositoryLocation(repositoryUrl: string): {
|
|
166
|
+
readonly host: 'github.com' | 'gitlab.com'
|
|
167
|
+
readonly path: string
|
|
168
|
+
} {
|
|
159
169
|
let parsed: URL
|
|
160
170
|
try {
|
|
161
171
|
parsed = new URL(repositoryUrl)
|
|
162
172
|
} catch {
|
|
163
173
|
throw new Error(`REPOSITORY_URL_INVALID: ${repositoryUrl}`)
|
|
164
174
|
}
|
|
165
|
-
|
|
175
|
+
const host = parsed.hostname
|
|
176
|
+
if (parsed.protocol !== 'https:' || (host !== 'github.com' && host !== 'gitlab.com'))
|
|
166
177
|
throw new Error(`REPOSITORY_URL_UNSUPPORTED: ${repositoryUrl}`)
|
|
167
178
|
const path = parsed.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')
|
|
168
|
-
|
|
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 === '..'))
|
|
169
185
|
throw new Error(`REPOSITORY_URL_INVALID: ${repositoryUrl}`)
|
|
170
|
-
return path
|
|
186
|
+
return { host, path }
|
|
171
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
|
+
}
|