Execute could stall on workspace-external access or native child-session permissions, while structured questions could be lost or answered incorrectly. Route owned OpenCode descendant interactions, correct Continue question and custom-answer handling, and resolve DSH Execute ACP permissions without a second approval. Ask remains read-only and unrelated sessions stay isolated. Restore the required DSH session projection service. Reuse offline OpenCode configuration by plugin version, remove unused npm shims from remote payloads, normalize dependency modes, and accept real compound descriptors up to 4 MiB. Fix intermittent Agent attach rejection and closed-handshake waits. Add focused and real-Runtime regressions, document interaction boundaries and validation evidence, and include the existing application/tool navigation design drafts. Those navigation changes are design-only, not implemented UI. Validation: full suite 3597 passed, 0 failed, 61 skipped across 338 files; typecheck, lint, release-note verification, whitespace and relative-link checks passed. Current-source Linux x64 installation, Ask/Execute, native subagent external writes, reconnect and stop/bootstrap passed with 10 completed real model calls, 13 native endpoint tests and 20 consecutive handshakes. System sleep/wake and release CI packaging remain unverified. Release note: 修复 Execute 访问工作区外目录或运行原生子代理时卡在权限等待的问题,并恢复 OpenCode 与 Continue 结构化问答的正确转交。修复远程 Agent 安装与重连失败;Ask 仍保持只读,任意终端密码或业务确认不会自动代答。
943 lines
26 KiB
JavaScript
943 lines
26 KiB
JavaScript
const {
|
|
createHash,
|
|
createPublicKey,
|
|
sign,
|
|
verify
|
|
} = require('node:crypto')
|
|
const {
|
|
closeSync,
|
|
fstatSync,
|
|
openSync,
|
|
readFileSync,
|
|
readSync,
|
|
writeFileSync
|
|
} = require('node:fs')
|
|
const { basename, resolve } = require('node:path')
|
|
const { crc32 } = require('node:zlib')
|
|
const {
|
|
productionSigningKey,
|
|
publicKeySpkiBase64,
|
|
readTrustedKeyRegistry,
|
|
redactSecrets
|
|
} = require('./agent-bundle.cjs')
|
|
const {
|
|
canonicalJson,
|
|
descriptorContentDigest,
|
|
signatureDomain: packageSignatureDomain
|
|
} = require('./agent-package.cjs')
|
|
const {
|
|
localFileHeaderSignature,
|
|
readExactZipBytes,
|
|
readZipCentralDirectory
|
|
} = require('./zip-central-directory.cjs')
|
|
|
|
const root = resolve(__dirname, '..')
|
|
function supportedTarget(platform, architecture) {
|
|
return (platform === 'linux' && ['x64', 'arm64'].includes(architecture)) ||
|
|
(platform === 'darwin' && architecture === 'arm64')
|
|
}
|
|
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 maximumPackageEntries = 50_002
|
|
const maximumPackageEntryBytes = 384 * 1024 * 1024
|
|
const maximumExpandedPackageBytes = 1024 * 1024 * 1024
|
|
const maximumMetadataBytes = 1024 * 1024
|
|
const maximumDescriptorBytes = 4 * 1024 * 1024
|
|
const maximumCentralDirectoryBytes = 32 * 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 ?? ''
|
|
) ||
|
|
!supportedTarget(descriptor.platform, descriptor.architecture) ||
|
|
!['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 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}` +
|
|
`-${descriptor.platform}-${descriptor.architecture}.gbagent`
|
|
if (basename(archive) !== expectedArchive) {
|
|
throw new Error(
|
|
`Agent package archive must be named ${expectedArchive}`
|
|
)
|
|
}
|
|
return {
|
|
archive: expectedArchive,
|
|
descriptor,
|
|
size: streamed.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 centralDirectoryEntries(handle, archiveSize) {
|
|
const parsed = readZipCentralDirectory(handle, archiveSize, {
|
|
label: 'Agent package',
|
|
minimumEntries: 1,
|
|
maximumEntries: maximumPackageEntries,
|
|
maximumCentralDirectoryBytes
|
|
})
|
|
const entries = []
|
|
const seen = new Set()
|
|
let expandedBytes = 0
|
|
for (const entry of parsed.entries) {
|
|
if (
|
|
entry.flags !== 0x0800 ||
|
|
entry.compression !== 0 ||
|
|
entry.compressedSize === 0xffffffff ||
|
|
entry.size === 0xffffffff ||
|
|
entry.compressedSize !== entry.size ||
|
|
entry.size > maximumPackageEntryBytes ||
|
|
entry.diskStart !== 0 ||
|
|
entry.localOffset === 0xffffffff
|
|
) {
|
|
throw new Error('Agent package ZIP entry is unsupported')
|
|
}
|
|
if (
|
|
!safePackagePath(entry.name) ||
|
|
seen.has(entry.name)
|
|
) {
|
|
throw new Error(
|
|
'Agent package contains an unsafe or duplicate path'
|
|
)
|
|
}
|
|
seen.add(entry.name)
|
|
expandedBytes += entry.size
|
|
if (expandedBytes > maximumExpandedPackageBytes) {
|
|
throw new Error(
|
|
'Agent package expanded payload exceeds its limit'
|
|
)
|
|
}
|
|
const local = readExactZipBytes(
|
|
handle,
|
|
30,
|
|
entry.localOffset,
|
|
'Agent package local file header'
|
|
)
|
|
const localNameLength = local.readUInt16LE(26)
|
|
const localExtraLength = local.readUInt16LE(28)
|
|
if (
|
|
local.readUInt32LE(0) !== localFileHeaderSignature ||
|
|
local.readUInt16LE(6) !== entry.flags ||
|
|
local.readUInt16LE(8) !== entry.compression ||
|
|
local.readUInt32LE(14) !== entry.checksum ||
|
|
local.readUInt32LE(18) !== entry.compressedSize ||
|
|
local.readUInt32LE(22) !== entry.size ||
|
|
localNameLength !== entry.nameBytes.length
|
|
) {
|
|
throw new Error('Agent package local file header is invalid')
|
|
}
|
|
const localName = readExactZipBytes(
|
|
handle,
|
|
localNameLength,
|
|
entry.localOffset + 30,
|
|
'Agent package local file name'
|
|
)
|
|
if (!localName.equals(entry.nameBytes)) {
|
|
throw new Error('Agent package local file name is invalid')
|
|
}
|
|
const dataOffset =
|
|
entry.localOffset +
|
|
30 +
|
|
localNameLength +
|
|
localExtraLength
|
|
const recordEnd = dataOffset + entry.compressedSize
|
|
if (recordEnd > parsed.centralOffset) {
|
|
throw new Error('Agent package entry exceeds its data region')
|
|
}
|
|
entries.push({
|
|
checksum: entry.checksum,
|
|
dataOffset,
|
|
name: entry.name,
|
|
recordStart: entry.localOffset,
|
|
recordEnd
|
|
})
|
|
}
|
|
entries.sort(
|
|
(left, right) => left.recordStart - right.recordStart
|
|
)
|
|
for (let index = 1; index < entries.length; index += 1) {
|
|
if (entries[index].recordStart < entries[index - 1].recordEnd) {
|
|
throw new Error('Agent package ZIP entries overlap')
|
|
}
|
|
}
|
|
return entries
|
|
}
|
|
|
|
function sameFileSnapshot(left, right) {
|
|
return (
|
|
left.dev === right.dev &&
|
|
left.ino === right.ino &&
|
|
left.size === right.size &&
|
|
left.mtimeMs === right.mtimeMs &&
|
|
left.ctimeMs === right.ctimeMs
|
|
)
|
|
}
|
|
|
|
function streamPackageMetadata(archivePath) {
|
|
const handle = openSync(archivePath, 'r')
|
|
const values = new Map()
|
|
const files = new Map()
|
|
try {
|
|
const initialStatus = fstatSync(handle)
|
|
if (
|
|
!initialStatus.isFile() ||
|
|
initialStatus.size <= 0 ||
|
|
initialStatus.size > maximumPackageBytes
|
|
) {
|
|
throw new Error(
|
|
'Agent package archive is not a bounded file'
|
|
)
|
|
}
|
|
const entries = centralDirectoryEntries(
|
|
handle,
|
|
initialStatus.size
|
|
)
|
|
const states = entries.map((entry) => {
|
|
const wanted =
|
|
entry.name === 'agent-package.json' ||
|
|
entry.name === 'agent-package.sig'
|
|
const size = entry.recordEnd - entry.dataOffset
|
|
const maximum = entry.name === 'agent-package.json'
|
|
? maximumDescriptorBytes
|
|
: maximumMetadataBytes
|
|
if (wanted && size > maximum) {
|
|
throw new Error('Agent package metadata exceeds its limit')
|
|
}
|
|
return {
|
|
...entry,
|
|
bytes: 0,
|
|
contents: wanted
|
|
? Buffer.allocUnsafe(size)
|
|
: undefined,
|
|
crc32: 0,
|
|
hash: createHash('sha256')
|
|
}
|
|
})
|
|
const archiveHash = createHash('sha256')
|
|
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
|
let position = 0
|
|
let firstEntry = 0
|
|
while (position < initialStatus.size) {
|
|
const bytesRead = readSync(
|
|
handle,
|
|
buffer,
|
|
0,
|
|
Math.min(buffer.length, initialStatus.size - position),
|
|
position
|
|
)
|
|
if (bytesRead <= 0) {
|
|
throw new Error('Agent package archive is truncated')
|
|
}
|
|
const chunk = buffer.subarray(0, bytesRead)
|
|
const chunkEnd = position + bytesRead
|
|
archiveHash.update(chunk)
|
|
while (
|
|
firstEntry < states.length &&
|
|
states[firstEntry].recordEnd <= position
|
|
) {
|
|
firstEntry += 1
|
|
}
|
|
for (
|
|
let index = firstEntry;
|
|
index < states.length &&
|
|
states[index].dataOffset < chunkEnd;
|
|
index += 1
|
|
) {
|
|
const state = states[index]
|
|
const dataStart = Math.max(position, state.dataOffset)
|
|
const dataEnd = Math.min(chunkEnd, state.recordEnd)
|
|
if (dataStart >= dataEnd) {
|
|
continue
|
|
}
|
|
const data = chunk.subarray(
|
|
dataStart - position,
|
|
dataEnd - position
|
|
)
|
|
state.hash.update(data)
|
|
state.crc32 = crc32(data, state.crc32)
|
|
if (state.contents) {
|
|
data.copy(
|
|
state.contents,
|
|
dataStart - state.dataOffset
|
|
)
|
|
}
|
|
state.bytes += data.byteLength
|
|
}
|
|
position = chunkEnd
|
|
}
|
|
if (!sameFileSnapshot(initialStatus, fstatSync(handle))) {
|
|
throw new Error(
|
|
'Agent package archive changed during verification'
|
|
)
|
|
}
|
|
for (const state of states) {
|
|
const size = state.recordEnd - state.dataOffset
|
|
if (
|
|
state.bytes !== size ||
|
|
(state.crc32 >>> 0) !== state.checksum
|
|
) {
|
|
throw new Error(
|
|
`Agent package ZIP checksum verification failed: ${state.name}`
|
|
)
|
|
}
|
|
files.set(state.name, {
|
|
size,
|
|
sha256: state.hash.digest('hex')
|
|
})
|
|
if (state.contents) {
|
|
values.set(state.name, state.contents)
|
|
}
|
|
}
|
|
return {
|
|
descriptor: values.get('agent-package.json'),
|
|
signature: values.get('agent-package.sig'),
|
|
files,
|
|
sha256: archiveHash.digest('hex'),
|
|
size: initialStatus.size
|
|
}
|
|
} finally {
|
|
closeSync(handle)
|
|
}
|
|
}
|
|
|
|
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 ?? ''
|
|
) ||
|
|
!supportedTarget(entry.platform, entry.architecture) ||
|
|
!['x64', 'arm64'].includes(entry.architecture) ||
|
|
entry.archive !==
|
|
`goodbuddy-agent-${entry.version}` +
|
|
`-${entry.platform}-${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.platform}:${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),
|
|
...(options.darwinArm64Package ? [readPackageMetadata(options.darwinArm64Package, 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
|
|
).slice(0, 2)).size !== 2 ||
|
|
first.descriptor.platform !== 'linux' ||
|
|
second.descriptor.platform !== 'linux'
|
|
) {
|
|
throw new Error(
|
|
'Agent package matrix does not describe one coherent release'
|
|
)
|
|
}
|
|
if (packages[2]) {
|
|
const mac = packages[2].descriptor
|
|
const { bundleDigest: _digest, ...runtimeIdentity } = mac.remoteRuntime
|
|
void _digest
|
|
if (
|
|
mac.platform !== 'darwin' || mac.architecture !== 'arm64' ||
|
|
mac.version !== first.descriptor.version ||
|
|
mac.minimumDesktopVersion !== first.descriptor.minimumDesktopVersion ||
|
|
canonicalJson(mac.agentProtocol) !== canonicalJson(first.descriptor.agentProtocol) ||
|
|
canonicalJson(runtimeIdentity) !== canonicalJson(firstRuntimeIdentity)
|
|
) 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.platform}:${entry.architecture}`, entry)
|
|
}
|
|
for (const metadata of packages) {
|
|
const entry = catalogEntry(metadata)
|
|
const identity = `${entry.version}:${entry.platform}:${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.platform.localeCompare(right.platform, 'en') ||
|
|
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',
|
|
'--darwin-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['--darwin-arm64-package'] ? {
|
|
darwinArm64Package: resolve(options['--darwin-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
|
|
}
|
|
}
|