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