Managed SSH previously tied remote Agent and Runtime artifacts to desktop packaging and Host activation. Settings now manages verified Linux x64 and arm64 .gbagent packages explicitly, with source-aligned download and update plus offline import and export. Each package contains the signed Agent, pinned Node, and compatible OpenCode Runtime, and project activation never downloads one automatically. Dedicated native CI and a protected release workflow publish deterministic, signed cumulative catalogs to GitHub and Beijing OSS without replacing the desktop Latest release. Desktop packaging is metadata-only, remains independent of Agent artifacts and signing credentials, and Linux desktop releases add RPM installers. The managed SSH path also keeps model credentials in Main, enforces Ask as read-only, verifies signed Agent and Runtime inputs, preserves ACP recovery, and reports Host update cancellation and failures accurately. Release note: 远程项目现可在“设置”中手动下载、更新或离线导入签名 Agent 包,桌面安装包与远程组件发布相互独立;Linux 桌面版同时新增 RPM 安装包。
812 lines
21 KiB
JavaScript
812 lines
21 KiB
JavaScript
const {
|
|
createHash,
|
|
createPublicKey,
|
|
sign,
|
|
verify
|
|
} = require('node:crypto')
|
|
const {
|
|
closeSync,
|
|
openSync,
|
|
readFileSync,
|
|
readSync,
|
|
statSync,
|
|
writeFileSync
|
|
} = require('node:fs')
|
|
const { basename, resolve } = require('node:path')
|
|
const {
|
|
Unzip,
|
|
UnzipInflate,
|
|
UnzipPassThrough
|
|
} = require('fflate')
|
|
const {
|
|
productionSigningKey,
|
|
publicKeySpkiBase64,
|
|
readTrustedKeyRegistry,
|
|
redactSecrets
|
|
} = require('./agent-bundle.cjs')
|
|
const {
|
|
canonicalJson,
|
|
descriptorContentDigest,
|
|
signatureDomain: packageSignatureDomain
|
|
} = require('./agent-package.cjs')
|
|
|
|
const root = resolve(__dirname, '..')
|
|
const catalogName = 'agent-catalog.json'
|
|
const catalogSignatureName = 'agent-catalog.sig'
|
|
const catalogSignatureDomain = Buffer.from(
|
|
'GoodBuddy Agent Package Catalog Signature v1\0',
|
|
'utf8'
|
|
)
|
|
const semanticVersionPattern =
|
|
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*)))*)?$/u
|
|
const sha256Pattern = /^[a-f0-9]{64}$/u
|
|
const maximumPackageBytes = 512 * 1024 * 1024
|
|
const maximumCatalogEntries = 200
|
|
const windowsReservedNamePattern =
|
|
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu
|
|
|
|
function canonicalBytes(value) {
|
|
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
}
|
|
|
|
function trustedProductionKey(registry, keyId) {
|
|
const key = registry.keys.find(
|
|
(candidate) =>
|
|
candidate.keyId === keyId &&
|
|
candidate.environment === 'production'
|
|
)
|
|
if (
|
|
!key ||
|
|
registry.revocations.some(
|
|
(revocation) => revocation.keyId === keyId
|
|
)
|
|
) {
|
|
throw new Error(`Agent signing key is not trusted: ${keyId}`)
|
|
}
|
|
return key
|
|
}
|
|
|
|
function verifyDetachedSignature(
|
|
bytes,
|
|
signatureText,
|
|
key,
|
|
domain,
|
|
label
|
|
) {
|
|
const signature = Buffer.from(
|
|
signatureText.toString('utf8').trim(),
|
|
'base64'
|
|
)
|
|
const publicKey = createPublicKey({
|
|
key: Buffer.from(key.publicKeySpkiBase64, 'base64'),
|
|
format: 'der',
|
|
type: 'spki'
|
|
})
|
|
if (
|
|
signature.length !== 64 ||
|
|
!verify(
|
|
null,
|
|
Buffer.concat([domain, bytes]),
|
|
publicKey,
|
|
signature
|
|
)
|
|
) {
|
|
throw new Error(`${label} signature verification failed`)
|
|
}
|
|
}
|
|
|
|
function parseCanonicalJson(bytes, label) {
|
|
const value = JSON.parse(
|
|
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
)
|
|
if (!canonicalBytes(value).equals(bytes)) {
|
|
throw new Error(`${label} is not canonical`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function assertProtocol(value, label) {
|
|
if (
|
|
typeof value !== 'object' ||
|
|
value === null ||
|
|
!Number.isSafeInteger(value.major) ||
|
|
value.major < 1 ||
|
|
!Number.isSafeInteger(value.minor) ||
|
|
value.minor < 0
|
|
) {
|
|
throw new Error(`${label} is invalid`)
|
|
}
|
|
}
|
|
|
|
function assertPackageDescriptor(descriptor) {
|
|
if (
|
|
typeof descriptor !== 'object' ||
|
|
descriptor === null ||
|
|
descriptor.format !== 'goodbuddy-agent-package' ||
|
|
descriptor.formatVersion !== 1 ||
|
|
descriptor.product !== 'GoodBuddy' ||
|
|
descriptor.component !== 'agent' ||
|
|
!semanticVersionPattern.test(descriptor.version ?? '') ||
|
|
!semanticVersionPattern.test(
|
|
descriptor.minimumDesktopVersion ?? ''
|
|
) ||
|
|
descriptor.platform !== 'linux' ||
|
|
!['x64', 'arm64'].includes(descriptor.architecture) ||
|
|
typeof descriptor.signingKeyId !== 'string' ||
|
|
!/^sha256:[a-f0-9]{64}$/u.test(
|
|
descriptor.contentDigest ?? ''
|
|
) ||
|
|
!Array.isArray(descriptor.files) ||
|
|
descriptor.files.length === 0 ||
|
|
descriptor.files.length > 50_000 ||
|
|
typeof descriptor.remoteRuntime !== 'object' ||
|
|
descriptor.remoteRuntime === null ||
|
|
descriptor.remoteRuntime.runtimeId !== 'opencode' ||
|
|
descriptor.remoteRuntime.provider !== 'opencode' ||
|
|
!semanticVersionPattern.test(
|
|
descriptor.remoteRuntime.version ?? ''
|
|
) ||
|
|
!/^sha256:[a-f0-9]{64}$/u.test(
|
|
descriptor.remoteRuntime.bundleDigest ?? ''
|
|
)
|
|
) {
|
|
throw new Error('Agent package descriptor is invalid')
|
|
}
|
|
assertProtocol(descriptor.agentProtocol, 'Agent protocol')
|
|
assertProtocol(
|
|
descriptor.remoteRuntime.protocol,
|
|
'Remote Runtime protocol'
|
|
)
|
|
const paths = new Set()
|
|
for (const file of descriptor.files) {
|
|
if (
|
|
typeof file !== 'object' ||
|
|
file === null ||
|
|
!safePackagePath(file.path) ||
|
|
!Number.isSafeInteger(file.size) ||
|
|
file.size < 0 ||
|
|
!sha256Pattern.test(file.sha256 ?? '') ||
|
|
!['0644', '0755'].includes(file.mode) ||
|
|
paths.has(file.path)
|
|
) {
|
|
throw new Error('Agent package file inventory is invalid')
|
|
}
|
|
paths.add(file.path)
|
|
}
|
|
if (
|
|
descriptor.contentDigest !==
|
|
descriptorContentDigest(descriptor)
|
|
) {
|
|
throw new Error('Agent package content identity is invalid')
|
|
}
|
|
}
|
|
|
|
function readPackageMetadata(archivePath, registry) {
|
|
const archive = resolve(archivePath)
|
|
const status = statSync(archive)
|
|
if (
|
|
!status.isFile() ||
|
|
status.size <= 0 ||
|
|
status.size > maximumPackageBytes
|
|
) {
|
|
throw new Error('Agent package archive is not a bounded file')
|
|
}
|
|
const streamed = streamPackageMetadata(archive)
|
|
const descriptorBytes = streamed.descriptor
|
|
const signatureBytes = streamed.signature
|
|
if (!descriptorBytes || !signatureBytes) {
|
|
throw new Error('Agent package metadata is missing')
|
|
}
|
|
const descriptor = parseCanonicalJson(
|
|
Buffer.from(descriptorBytes),
|
|
'Agent package descriptor'
|
|
)
|
|
assertPackageDescriptor(descriptor)
|
|
verifyPackageFileInventory(descriptor, streamed.files)
|
|
const key = trustedProductionKey(
|
|
registry,
|
|
descriptor.signingKeyId
|
|
)
|
|
verifyDetachedSignature(
|
|
Buffer.from(descriptorBytes),
|
|
Buffer.from(signatureBytes),
|
|
key,
|
|
packageSignatureDomain,
|
|
'Agent package'
|
|
)
|
|
const expectedArchive =
|
|
`goodbuddy-agent-${descriptor.version}` +
|
|
`-linux-${descriptor.architecture}.gbagent`
|
|
if (basename(archive) !== expectedArchive) {
|
|
throw new Error(
|
|
`Agent package archive must be named ${expectedArchive}`
|
|
)
|
|
}
|
|
return {
|
|
archive: expectedArchive,
|
|
descriptor,
|
|
size: status.size,
|
|
sha256: streamed.sha256
|
|
}
|
|
}
|
|
|
|
function safePackagePath(path) {
|
|
return (
|
|
typeof path === 'string' &&
|
|
path.length > 0 &&
|
|
path.length <= 512 &&
|
|
!path.startsWith('/') &&
|
|
!path.endsWith('/') &&
|
|
!path.includes('\\') &&
|
|
path.split('/').every(
|
|
(part) =>
|
|
part.length > 0 &&
|
|
part !== '.' &&
|
|
part !== '..' &&
|
|
!part.endsWith('.') &&
|
|
!part.endsWith(' ') &&
|
|
!part.includes(':') &&
|
|
[...part].every(
|
|
(character) => character.charCodeAt(0) > 0x1f
|
|
) &&
|
|
!windowsReservedNamePattern.test(part)
|
|
)
|
|
)
|
|
}
|
|
|
|
function verifyPackageFileInventory(descriptor, actualFiles) {
|
|
const expected = new Set([
|
|
...descriptor.files.map((file) => file.path),
|
|
'agent-package.json',
|
|
'agent-package.sig'
|
|
])
|
|
if (
|
|
actualFiles.size !== expected.size ||
|
|
[...actualFiles.keys()].some((path) => !expected.has(path))
|
|
) {
|
|
throw new Error(
|
|
'Agent package contains undeclared or missing files'
|
|
)
|
|
}
|
|
for (const file of descriptor.files) {
|
|
const actual = actualFiles.get(file.path)
|
|
if (
|
|
!actual ||
|
|
actual.size !== file.size ||
|
|
actual.sha256 !== file.sha256
|
|
) {
|
|
throw new Error(
|
|
`Agent package payload verification failed: ${file.path}`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
function streamPackageMetadata(archivePath) {
|
|
const handle = openSync(archivePath, 'r')
|
|
const hash = createHash('sha256')
|
|
const buffer = Buffer.allocUnsafe(64 * 1024)
|
|
const values = new Map()
|
|
const files = new Map()
|
|
const seen = new Set()
|
|
let fatalError
|
|
let entries = 0
|
|
let expandedBytes = 0
|
|
const unzip = new Unzip((file) => {
|
|
entries += 1
|
|
if (entries > 50_002) {
|
|
fatalError ??= new Error(
|
|
'Agent package contains too many entries'
|
|
)
|
|
file.terminate()
|
|
return
|
|
}
|
|
if (!safePackagePath(file.name) || seen.has(file.name)) {
|
|
fatalError ??= new Error(
|
|
'Agent package contains an unsafe or duplicate path'
|
|
)
|
|
file.terminate()
|
|
return
|
|
}
|
|
seen.add(file.name)
|
|
const wanted =
|
|
file.name === 'agent-package.json' ||
|
|
file.name === 'agent-package.sig'
|
|
if (wanted && values.has(file.name)) {
|
|
fatalError ??= new Error(
|
|
'Agent package contains duplicate metadata'
|
|
)
|
|
file.terminate()
|
|
return
|
|
}
|
|
const chunks = []
|
|
let size = 0
|
|
const fileHash = createHash('sha256')
|
|
file.ondata = (error, data, final) => {
|
|
if (error) {
|
|
fatalError ??= error
|
|
return
|
|
}
|
|
size += data.byteLength
|
|
expandedBytes += data.byteLength
|
|
if (
|
|
size > 384 * 1024 * 1024 ||
|
|
expandedBytes > 1024 * 1024 * 1024
|
|
) {
|
|
fatalError ??= new Error(
|
|
'Agent package expanded payload exceeds its limit'
|
|
)
|
|
file.terminate()
|
|
return
|
|
}
|
|
fileHash.update(data)
|
|
if (wanted) {
|
|
if (size > 1024 * 1024) {
|
|
fatalError ??= new Error(
|
|
'Agent package metadata exceeds its limit'
|
|
)
|
|
file.terminate()
|
|
return
|
|
}
|
|
chunks.push(Buffer.from(data))
|
|
}
|
|
if (final) {
|
|
files.set(file.name, {
|
|
size,
|
|
sha256: fileHash.digest('hex')
|
|
})
|
|
if (wanted) {
|
|
values.set(file.name, Buffer.concat(chunks, size))
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
file.start()
|
|
} catch (error) {
|
|
fatalError ??= error
|
|
file.terminate()
|
|
}
|
|
})
|
|
unzip.register(UnzipPassThrough)
|
|
unzip.register(UnzipInflate)
|
|
try {
|
|
while (true) {
|
|
const bytesRead = readSync(
|
|
handle,
|
|
buffer,
|
|
0,
|
|
buffer.length,
|
|
null
|
|
)
|
|
if (bytesRead === 0) {
|
|
unzip.push(new Uint8Array(), true)
|
|
break
|
|
}
|
|
const chunk = Uint8Array.from(
|
|
buffer.subarray(0, bytesRead)
|
|
)
|
|
hash.update(chunk)
|
|
unzip.push(chunk, false)
|
|
if (fatalError) {
|
|
throw fatalError
|
|
}
|
|
}
|
|
} finally {
|
|
closeSync(handle)
|
|
}
|
|
if (fatalError) {
|
|
throw fatalError
|
|
}
|
|
return {
|
|
descriptor: values.get('agent-package.json'),
|
|
signature: values.get('agent-package.sig'),
|
|
files,
|
|
sha256: hash.digest('hex')
|
|
}
|
|
}
|
|
|
|
function catalogEntry(metadata) {
|
|
const descriptor = metadata.descriptor
|
|
const {
|
|
contentDigest: _contentDigest,
|
|
files: _files,
|
|
signingKeyId: _signingKeyId,
|
|
...identity
|
|
} = descriptor
|
|
void _contentDigest
|
|
void _files
|
|
void _signingKeyId
|
|
return {
|
|
...identity,
|
|
archive: metadata.archive,
|
|
size: metadata.size,
|
|
sha256: metadata.sha256,
|
|
downloads: {
|
|
github: {
|
|
url:
|
|
'https://github.com/mesalogo/goodbuddy/releases/download/' +
|
|
`agent-v${descriptor.version}/${metadata.archive}`
|
|
},
|
|
mirror: {
|
|
url:
|
|
'https://goodbuddy.oss-cn-beijing.aliyuncs.com/' +
|
|
`agent-releases/v${descriptor.version}/${metadata.archive}`
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertCatalog(catalog) {
|
|
if (
|
|
typeof catalog !== 'object' ||
|
|
catalog === null ||
|
|
catalog.formatVersion !== 1 ||
|
|
catalog.product !== 'GoodBuddy' ||
|
|
catalog.component !== 'agent' ||
|
|
typeof catalog.signingKeyId !== 'string' ||
|
|
typeof catalog.generatedAt !== 'string' ||
|
|
!Number.isFinite(Date.parse(catalog.generatedAt)) ||
|
|
new Date(catalog.generatedAt).toISOString() !==
|
|
catalog.generatedAt ||
|
|
!Array.isArray(catalog.entries) ||
|
|
catalog.entries.length === 0 ||
|
|
catalog.entries.length > maximumCatalogEntries
|
|
) {
|
|
throw new Error('Agent catalog is invalid')
|
|
}
|
|
const identities = new Set()
|
|
for (const entry of catalog.entries) {
|
|
if (
|
|
typeof entry !== 'object' ||
|
|
entry === null ||
|
|
!semanticVersionPattern.test(entry.version ?? '') ||
|
|
!semanticVersionPattern.test(
|
|
entry.minimumDesktopVersion ?? ''
|
|
) ||
|
|
entry.platform !== 'linux' ||
|
|
!['x64', 'arm64'].includes(entry.architecture) ||
|
|
entry.archive !==
|
|
`goodbuddy-agent-${entry.version}` +
|
|
`-linux-${entry.architecture}.gbagent` ||
|
|
!Number.isSafeInteger(entry.size) ||
|
|
entry.size <= 0 ||
|
|
!sha256Pattern.test(entry.sha256 ?? '') ||
|
|
entry.downloads?.github?.url !==
|
|
'https://github.com/mesalogo/goodbuddy/releases/download/' +
|
|
`agent-v${entry.version}/${entry.archive}` ||
|
|
entry.downloads?.mirror?.url !==
|
|
'https://goodbuddy.oss-cn-beijing.aliyuncs.com/' +
|
|
`agent-releases/v${entry.version}/${entry.archive}` ||
|
|
entry.format !== 'goodbuddy-agent-package' ||
|
|
entry.formatVersion !== 1 ||
|
|
entry.product !== 'GoodBuddy' ||
|
|
entry.component !== 'agent' ||
|
|
entry.remoteRuntime?.runtimeId !== 'opencode' ||
|
|
entry.remoteRuntime?.provider !== 'opencode' ||
|
|
!semanticVersionPattern.test(
|
|
entry.remoteRuntime?.version ?? ''
|
|
) ||
|
|
!/^sha256:[a-f0-9]{64}$/u.test(
|
|
entry.remoteRuntime?.bundleDigest ?? ''
|
|
)
|
|
) {
|
|
throw new Error('Agent catalog entry is invalid')
|
|
}
|
|
assertProtocol(entry.agentProtocol, 'Agent protocol')
|
|
assertProtocol(
|
|
entry.remoteRuntime.protocol,
|
|
'Remote Runtime protocol'
|
|
)
|
|
const identity = `${entry.version}:${entry.architecture}`
|
|
if (identities.has(identity)) {
|
|
throw new Error('Agent catalog contains duplicate entries')
|
|
}
|
|
identities.add(identity)
|
|
}
|
|
}
|
|
|
|
function readVerifiedCatalog(catalogPath, signaturePath, registry) {
|
|
const bytes = readFileSync(resolve(catalogPath))
|
|
const catalog = parseCanonicalJson(bytes, 'Agent catalog')
|
|
assertCatalog(catalog)
|
|
const key = trustedProductionKey(
|
|
registry,
|
|
catalog.signingKeyId
|
|
)
|
|
verifyDetachedSignature(
|
|
bytes,
|
|
readFileSync(resolve(signaturePath)),
|
|
key,
|
|
catalogSignatureDomain,
|
|
'Agent catalog'
|
|
)
|
|
return catalog
|
|
}
|
|
|
|
function parseVersion(value) {
|
|
const match =
|
|
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$/u
|
|
.exec(value)
|
|
if (!match) {
|
|
throw new Error(`Invalid semantic version: ${value}`)
|
|
}
|
|
return [
|
|
BigInt(match[1]),
|
|
BigInt(match[2]),
|
|
BigInt(match[3]),
|
|
match[4]?.split('.')
|
|
]
|
|
}
|
|
|
|
function compareVersions(left, right) {
|
|
const leftParts = parseVersion(left)
|
|
const rightParts = parseVersion(right)
|
|
for (const index of [0, 1, 2]) {
|
|
if (leftParts[index] !== rightParts[index]) {
|
|
return leftParts[index] > rightParts[index] ? 1 : -1
|
|
}
|
|
}
|
|
const leftPre = leftParts[3]
|
|
const rightPre = rightParts[3]
|
|
if (!leftPre && !rightPre) return 0
|
|
if (!leftPre) return 1
|
|
if (!rightPre) return -1
|
|
for (
|
|
let index = 0;
|
|
index < Math.max(leftPre.length, rightPre.length);
|
|
index += 1
|
|
) {
|
|
const leftValue = leftPre[index]
|
|
const rightValue = rightPre[index]
|
|
if (leftValue === undefined) return -1
|
|
if (rightValue === undefined) return 1
|
|
if (leftValue === rightValue) continue
|
|
const leftNumeric = /^\d+$/u.test(leftValue)
|
|
const rightNumeric = /^\d+$/u.test(rightValue)
|
|
if (leftNumeric && rightNumeric) {
|
|
return BigInt(leftValue) > BigInt(rightValue) ? 1 : -1
|
|
}
|
|
if (leftNumeric !== rightNumeric) {
|
|
return leftNumeric ? -1 : 1
|
|
}
|
|
return leftValue > rightValue ? 1 : -1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
function createCatalog(options) {
|
|
const projectRoot = options.projectRoot ?? root
|
|
const registry =
|
|
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
|
const signingIdentity =
|
|
options.signingIdentity ??
|
|
productionSigningKey(options.environment ?? process.env)
|
|
const signingRecord = trustedProductionKey(
|
|
registry,
|
|
signingIdentity.keyId
|
|
)
|
|
if (
|
|
publicKeySpkiBase64(signingIdentity.privateKey) !==
|
|
signingRecord.publicKeySpkiBase64
|
|
) {
|
|
throw new Error(
|
|
'Agent catalog signing key does not match the trusted registry'
|
|
)
|
|
}
|
|
const packages = [
|
|
readPackageMetadata(options.x64Package, registry),
|
|
readPackageMetadata(options.arm64Package, registry)
|
|
]
|
|
const [first, second] = packages
|
|
const {
|
|
bundleDigest: _firstRuntimeDigest,
|
|
...firstRuntimeIdentity
|
|
} = first.descriptor.remoteRuntime
|
|
const {
|
|
bundleDigest: _secondRuntimeDigest,
|
|
...secondRuntimeIdentity
|
|
} = second.descriptor.remoteRuntime
|
|
void _firstRuntimeDigest
|
|
void _secondRuntimeDigest
|
|
if (
|
|
first.descriptor.version !== second.descriptor.version ||
|
|
first.descriptor.minimumDesktopVersion !==
|
|
second.descriptor.minimumDesktopVersion ||
|
|
canonicalJson(first.descriptor.agentProtocol) !==
|
|
canonicalJson(second.descriptor.agentProtocol) ||
|
|
canonicalJson(firstRuntimeIdentity) !==
|
|
canonicalJson(secondRuntimeIdentity) ||
|
|
new Set(packages.map(
|
|
(metadata) => metadata.descriptor.architecture
|
|
)).size !== 2
|
|
) {
|
|
throw new Error(
|
|
'Agent package matrix does not describe one coherent release'
|
|
)
|
|
}
|
|
const previous =
|
|
options.previousCatalog && options.previousSignature
|
|
? readVerifiedCatalog(
|
|
options.previousCatalog,
|
|
options.previousSignature,
|
|
registry
|
|
)
|
|
: undefined
|
|
if (
|
|
Boolean(options.previousCatalog) !==
|
|
Boolean(options.previousSignature)
|
|
) {
|
|
throw new Error(
|
|
'Previous Agent catalog and signature must be provided together'
|
|
)
|
|
}
|
|
const entries = new Map()
|
|
for (const entry of previous?.entries ?? []) {
|
|
entries.set(`${entry.version}:${entry.architecture}`, entry)
|
|
}
|
|
for (const metadata of packages) {
|
|
const entry = catalogEntry(metadata)
|
|
const identity = `${entry.version}:${entry.architecture}`
|
|
const existing = entries.get(identity)
|
|
if (
|
|
existing &&
|
|
canonicalJson(existing) !== canonicalJson(entry)
|
|
) {
|
|
throw new Error(
|
|
`Agent release identity is immutable: ${identity}`
|
|
)
|
|
}
|
|
entries.set(identity, entry)
|
|
}
|
|
const sortedEntries = [...entries.values()].sort(
|
|
(left, right) =>
|
|
compareVersions(right.version, left.version) ||
|
|
left.architecture.localeCompare(right.architecture, 'en')
|
|
)
|
|
if (sortedEntries.length > maximumCatalogEntries) {
|
|
throw new Error('Agent catalog exceeds its retention limit')
|
|
}
|
|
const catalog = {
|
|
formatVersion: 1,
|
|
product: 'GoodBuddy',
|
|
component: 'agent',
|
|
signingKeyId: signingIdentity.keyId,
|
|
generatedAt: new Date(options.generatedAt).toISOString(),
|
|
entries: sortedEntries
|
|
}
|
|
assertCatalog(catalog)
|
|
const bytes = canonicalBytes(catalog)
|
|
const signature = sign(
|
|
null,
|
|
Buffer.concat([catalogSignatureDomain, bytes]),
|
|
signingIdentity.privateKey
|
|
)
|
|
writeFileSync(resolve(options.outputCatalog), bytes)
|
|
writeFileSync(
|
|
resolve(options.outputSignature),
|
|
`${signature.toString('base64')}\n`,
|
|
'utf8'
|
|
)
|
|
return {
|
|
version: first.descriptor.version,
|
|
catalog: basename(options.outputCatalog),
|
|
signature: basename(options.outputSignature),
|
|
entries: sortedEntries.length
|
|
}
|
|
}
|
|
|
|
function parseArguments(argv) {
|
|
const command = argv[0]
|
|
if (!['create', 'verify'].includes(command)) {
|
|
throw new Error(
|
|
'Usage: agent-catalog.cjs <create|verify> [options]'
|
|
)
|
|
}
|
|
const options = {}
|
|
for (let index = 1; index < argv.length; index += 2) {
|
|
const flag = argv[index]
|
|
const value = argv[index + 1]
|
|
if (
|
|
![
|
|
'--x64-package',
|
|
'--arm64-package',
|
|
'--previous-catalog',
|
|
'--previous-signature',
|
|
'--catalog',
|
|
'--signature',
|
|
'--generated-at'
|
|
].includes(flag) ||
|
|
!value ||
|
|
value.startsWith('--') ||
|
|
options[flag] !== undefined
|
|
) {
|
|
throw new Error('Invalid Agent catalog argument')
|
|
}
|
|
options[flag] = value
|
|
}
|
|
for (const flag of ['--catalog', '--signature']) {
|
|
if (!options[flag]) {
|
|
throw new Error(`Agent catalog argument is required: ${flag}`)
|
|
}
|
|
}
|
|
if (command === 'verify') {
|
|
return {
|
|
command,
|
|
catalog: resolve(options['--catalog']),
|
|
signature: resolve(options['--signature'])
|
|
}
|
|
}
|
|
for (const flag of [
|
|
'--x64-package',
|
|
'--arm64-package',
|
|
'--generated-at'
|
|
]) {
|
|
if (!options[flag]) {
|
|
throw new Error(`Agent catalog argument is required: ${flag}`)
|
|
}
|
|
}
|
|
return {
|
|
command,
|
|
x64Package: resolve(options['--x64-package']),
|
|
arm64Package: resolve(options['--arm64-package']),
|
|
...(options['--previous-catalog']
|
|
? {
|
|
previousCatalog: resolve(
|
|
options['--previous-catalog']
|
|
)
|
|
}
|
|
: {}),
|
|
...(options['--previous-signature']
|
|
? {
|
|
previousSignature: resolve(
|
|
options['--previous-signature']
|
|
)
|
|
}
|
|
: {}),
|
|
outputCatalog: resolve(options['--catalog']),
|
|
outputSignature: resolve(options['--signature']),
|
|
generatedAt: options['--generated-at']
|
|
}
|
|
}
|
|
|
|
function main(argv = process.argv.slice(2)) {
|
|
const options = parseArguments(argv)
|
|
const registry = readTrustedKeyRegistry(root)
|
|
const result =
|
|
options.command === 'create'
|
|
? createCatalog(options)
|
|
: {
|
|
entries: readVerifiedCatalog(
|
|
options.catalog,
|
|
options.signature,
|
|
registry
|
|
).entries.length
|
|
}
|
|
process.stdout.write(`${JSON.stringify(result)}\n`)
|
|
}
|
|
|
|
module.exports = {
|
|
catalogName,
|
|
catalogSignatureDomain,
|
|
catalogSignatureName,
|
|
canonicalBytes,
|
|
catalogEntry,
|
|
createCatalog,
|
|
parseArguments,
|
|
readPackageMetadata,
|
|
readVerifiedCatalog
|
|
}
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
main()
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`${redactSecrets(
|
|
error instanceof Error ? error.message : String(error)
|
|
)}\n`
|
|
)
|
|
process.exitCode = 1
|
|
}
|
|
}
|