feat: add independent managed SSH agent packages
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 安装包。
This commit is contained in:
+10
-28
@@ -32,6 +32,9 @@ const tar = require('tar')
|
||||
const {
|
||||
detectElfArchitecture
|
||||
} = require('./binary-architecture.cjs')
|
||||
const {
|
||||
preflightRegisteredProductionKey
|
||||
} = require('./signing-key-preflight.cjs')
|
||||
|
||||
const root = join(__dirname, '..')
|
||||
const domainSeparator = Buffer.from(
|
||||
@@ -379,36 +382,15 @@ function preflightProductionSigningKey(options = {}) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const environment = options.environment ?? process.env
|
||||
const keyId = options.keyId ?? environment[signingKeyIdEnvironment]
|
||||
if (!keyId) {
|
||||
throw new Error(
|
||||
`${signingKeyIdEnvironment} is not configured; provision the production Agent signing key ID before building a release`
|
||||
)
|
||||
}
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
const signingRecord = registry.keys.find(
|
||||
(key) => key.keyId === keyId
|
||||
)
|
||||
if (!signingRecord) {
|
||||
throw new Error(
|
||||
`Production Agent public signing key ID "${keyId}" is absent from resources/agent-release-keys.json; provision the matching public key before building a release`
|
||||
)
|
||||
}
|
||||
if (signingRecord.environment !== 'production') {
|
||||
throw new Error(
|
||||
`Production Agent signing key ID "${keyId}" is not registered for production`
|
||||
)
|
||||
}
|
||||
if (
|
||||
registry.revocations.some(
|
||||
(revocation) => revocation.keyId === keyId
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Production Agent signing key ID "${keyId}" is revoked`
|
||||
)
|
||||
}
|
||||
return signingRecord
|
||||
return preflightRegisteredProductionKey({
|
||||
component: 'Agent',
|
||||
keyId,
|
||||
registry,
|
||||
missingKeyIdMessage:
|
||||
`${signingKeyIdEnvironment} is not configured; provision the production Agent signing key ID before building a release`
|
||||
})
|
||||
}
|
||||
|
||||
function publicKeySpkiBase64(key) {
|
||||
|
||||
@@ -0,0 +1,811 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
const { generateKeyPairSync } = require('node:crypto')
|
||||
const {
|
||||
mkdirSync,
|
||||
rmSync
|
||||
} = require('node:fs')
|
||||
const {
|
||||
basename,
|
||||
dirname,
|
||||
join,
|
||||
resolve
|
||||
} = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const {
|
||||
buildAgentBundle,
|
||||
publicKeySpkiBase64,
|
||||
readRuntimeLock,
|
||||
supportedArchitectures
|
||||
} = require('./agent-bundle.cjs')
|
||||
const {
|
||||
buildRuntimeBundle,
|
||||
readRemoteRuntimeLock
|
||||
} = require('./remote-runtime-bundle.cjs')
|
||||
const {
|
||||
assembleAgentPackage
|
||||
} = require('./agent-package.cjs')
|
||||
const { sha256File } = require('./file-hash.cjs')
|
||||
|
||||
const ciSigningKeyId = 'goodbuddy-agent-ci-ephemeral'
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = {}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index]
|
||||
if (
|
||||
![
|
||||
'--arch',
|
||||
'--node-archive',
|
||||
'--opencode-archive',
|
||||
'--output-directory',
|
||||
'--archive'
|
||||
].includes(argument)
|
||||
) {
|
||||
throw new Error(`Unknown Agent CI bundle argument: ${argument}`)
|
||||
}
|
||||
const value = argv[index + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${argument} requires a value`)
|
||||
}
|
||||
const key = argument.slice(2).replaceAll('-', '')
|
||||
if (options[key] !== undefined) {
|
||||
throw new Error(`${argument} may be provided only once`)
|
||||
}
|
||||
options[key] = value
|
||||
index += 1
|
||||
}
|
||||
if (!supportedArchitectures.includes(options.arch)) {
|
||||
throw new Error('--arch must be x64 or arm64')
|
||||
}
|
||||
for (const key of [
|
||||
'nodearchive',
|
||||
'opencodearchive',
|
||||
'outputdirectory',
|
||||
'archive'
|
||||
]) {
|
||||
if (!options[key]) {
|
||||
throw new Error(
|
||||
'Usage: agent-ci-bundle.cjs --arch <x64|arm64> --node-archive <path> --opencode-archive <path> --output-directory <path> --archive <path>'
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
arch: options.arch,
|
||||
nodeArchive: resolve(options.nodearchive),
|
||||
opencodeArchive: resolve(options.opencodearchive),
|
||||
outputDirectory: resolve(options.outputdirectory),
|
||||
archive: resolve(options.archive)
|
||||
}
|
||||
}
|
||||
|
||||
function ephemeralSigningIdentity() {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
return {
|
||||
privateKey,
|
||||
registry: {
|
||||
formatVersion: 1,
|
||||
keys: [
|
||||
{
|
||||
keyId: ciSigningKeyId,
|
||||
publicKeySpkiBase64: publicKeySpkiBase64(publicKey),
|
||||
environment: 'test'
|
||||
}
|
||||
],
|
||||
revocations: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildCiAgentBundle(options) {
|
||||
const projectRoot = resolve(__dirname, '..')
|
||||
const lock = readRuntimeLock(projectRoot)
|
||||
const runtimeLock = readRemoteRuntimeLock(projectRoot)
|
||||
const signing = ephemeralSigningIdentity()
|
||||
const firstArchiveRoot = resolve(
|
||||
join(
|
||||
dirname(options.archive),
|
||||
`.agent-ci-first-${process.pid}`
|
||||
)
|
||||
)
|
||||
mkdirSync(firstArchiveRoot, { recursive: true })
|
||||
const firstArchive = join(
|
||||
firstArchiveRoot,
|
||||
basename(options.archive)
|
||||
)
|
||||
const runtimeRoot = resolve(
|
||||
`${options.outputDirectory}-runtime`
|
||||
)
|
||||
try {
|
||||
const identity = {
|
||||
keyId: ciSigningKeyId,
|
||||
privateKey: signing.privateKey
|
||||
}
|
||||
rmSync(options.outputDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
buildAgentBundle({
|
||||
projectRoot,
|
||||
arch: options.arch,
|
||||
runtimeArchive: options.nodeArchive,
|
||||
outputDirectory: options.outputDirectory,
|
||||
registry: signing.registry,
|
||||
testSigningIdentity: identity
|
||||
})
|
||||
const runtime = buildRuntimeBundle({
|
||||
projectRoot,
|
||||
architecture: options.arch,
|
||||
runtimeArchive: options.opencodeArchive,
|
||||
outputRoot: runtimeRoot,
|
||||
registry: signing.registry,
|
||||
testSigningIdentity: identity
|
||||
})
|
||||
const assemble = (archive) =>
|
||||
assembleAgentPackage({
|
||||
projectRoot,
|
||||
architecture: options.arch,
|
||||
minimumDesktopVersion:
|
||||
require('../package.json').version,
|
||||
output: archive,
|
||||
agentBundle: options.outputDirectory,
|
||||
runtimeBundle: runtime.bundleDirectory,
|
||||
agentLock: lock,
|
||||
runtimeLock,
|
||||
registry: signing.registry,
|
||||
testSigningIdentity: identity
|
||||
})
|
||||
|
||||
assemble(firstArchive)
|
||||
const firstDigest = await sha256File(firstArchive)
|
||||
const second = assemble(options.archive)
|
||||
const archiveDigest = await sha256File(options.archive)
|
||||
if (firstDigest !== archiveDigest) {
|
||||
throw new Error('Agent CI archives are not deterministic')
|
||||
}
|
||||
|
||||
const smoke = spawnSync(
|
||||
resolve(options.outputDirectory, 'goodbuddy-agent'),
|
||||
['ci-invalid-command'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (
|
||||
smoke.error ||
|
||||
smoke.status !== 2 ||
|
||||
!smoke.stderr.includes(
|
||||
'Expected daemon, attach-or-bootstrap, doctor'
|
||||
)
|
||||
) {
|
||||
throw new Error('Built Agent native launch smoke failed')
|
||||
}
|
||||
|
||||
return {
|
||||
agentVersion: lock.agentVersion,
|
||||
remoteRuntimeVersion:
|
||||
runtimeLock.runtimes.opencode.version,
|
||||
architecture: options.arch,
|
||||
archive: second.package.archive,
|
||||
archiveSha256: archiveDigest
|
||||
}
|
||||
} finally {
|
||||
rmSync(runtimeRoot, { recursive: true, force: true })
|
||||
rmSync(firstArchive, { force: true })
|
||||
rmSync(firstArchiveRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
const result = await buildCiAgentBundle(parseArguments(argv))
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(result)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`
|
||||
)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildCiAgentBundle,
|
||||
parseArguments
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
const {
|
||||
createHash,
|
||||
sign
|
||||
} = require('node:crypto')
|
||||
const {
|
||||
closeSync,
|
||||
copyFileSync,
|
||||
fsyncSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeSync,
|
||||
writeFileSync
|
||||
} = require('node:fs')
|
||||
const { basename, dirname, join, relative, resolve, sep } = require('node:path')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { Zip, ZipPassThrough } = require('fflate')
|
||||
const {
|
||||
buildAgentBundle,
|
||||
preflightProductionSigningKey,
|
||||
productionSigningKey,
|
||||
publicKeySpkiBase64,
|
||||
readRuntimeLock,
|
||||
readTrustedKeyRegistry,
|
||||
redactSecrets,
|
||||
verifyLockedBundle
|
||||
} = require('./agent-bundle.cjs')
|
||||
const {
|
||||
buildRuntimeBundle,
|
||||
canonicalJson,
|
||||
readRemoteRuntimeLock,
|
||||
verifyBundleDirectory: verifyRuntimeBundle
|
||||
} = require('./remote-runtime-bundle.cjs')
|
||||
|
||||
const root = join(__dirname, '..')
|
||||
const packageDescriptorName = 'agent-package.json'
|
||||
const packageSignatureName = 'agent-package.sig'
|
||||
const signatureDomain = Buffer.from(
|
||||
'GoodBuddy Agent Package Descriptor Signature v1\0',
|
||||
'utf8'
|
||||
)
|
||||
const archiveEpoch = new Date('1980-01-01T00:00:00.000Z')
|
||||
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
|
||||
|
||||
function descriptorBytes(descriptor) {
|
||||
return Buffer.from(`${JSON.stringify(descriptor, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
function descriptorContentDigest(descriptor) {
|
||||
const { contentDigest: _contentDigest, ...content } = descriptor
|
||||
void _contentDigest
|
||||
return `sha256:${createHash('sha256')
|
||||
.update(canonicalJson(content))
|
||||
.digest('hex')}`
|
||||
}
|
||||
|
||||
function listFiles(directory) {
|
||||
const rootPath = resolve(directory)
|
||||
const output = []
|
||||
const visit = (current) => {
|
||||
const currentStat = lstatSync(current)
|
||||
if (currentStat.isSymbolicLink()) {
|
||||
throw new Error(`Agent package source contains a symlink: ${current}`)
|
||||
}
|
||||
if (currentStat.isFile()) {
|
||||
output.push(current)
|
||||
return
|
||||
}
|
||||
if (!currentStat.isDirectory()) {
|
||||
throw new Error(
|
||||
`Agent package source contains an unsupported entry: ${current}`
|
||||
)
|
||||
}
|
||||
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||
visit(join(current, entry.name))
|
||||
}
|
||||
}
|
||||
visit(rootPath)
|
||||
return output.sort((left, right) =>
|
||||
Buffer.from(relative(rootPath, left).split(sep).join('/')).compare(
|
||||
Buffer.from(relative(rootPath, right).split(sep).join('/'))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function sha256File(filePath) {
|
||||
const hash = createHash('sha256')
|
||||
const handle = openSync(filePath, 'r')
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
||||
try {
|
||||
while (true) {
|
||||
const bytesRead = readSync(
|
||||
handle,
|
||||
buffer,
|
||||
0,
|
||||
buffer.length,
|
||||
null
|
||||
)
|
||||
if (bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
hash.update(buffer.subarray(0, bytesRead))
|
||||
}
|
||||
} finally {
|
||||
closeSync(handle)
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
function copyTree(source, destination) {
|
||||
for (const filePath of listFiles(source)) {
|
||||
const name = relative(source, filePath).split(sep).join('/')
|
||||
const target = join(destination, ...name.split('/'))
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
copyFileSync(filePath, target)
|
||||
}
|
||||
}
|
||||
|
||||
function stagePackagePayload(options) {
|
||||
const staging = options.staging
|
||||
copyTree(options.agentBundle, join(staging, 'agent'))
|
||||
copyTree(
|
||||
options.runtimeBundle,
|
||||
join(
|
||||
staging,
|
||||
'runtime',
|
||||
'opencode',
|
||||
basename(options.runtimeBundle)
|
||||
)
|
||||
)
|
||||
for (const [value, destination] of [
|
||||
[
|
||||
options.registry,
|
||||
'agent-release-keys.json'
|
||||
],
|
||||
[
|
||||
options.agentLock,
|
||||
'agent-runtime-lock.json'
|
||||
],
|
||||
[
|
||||
options.runtimeLock,
|
||||
'remote-runtime-lock.json'
|
||||
]
|
||||
]) {
|
||||
writeFileSync(
|
||||
join(staging, destination),
|
||||
`${JSON.stringify(value, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function payloadFiles(staging) {
|
||||
return listFiles(staging)
|
||||
.map((filePath) => {
|
||||
const path = relative(staging, filePath).split(sep).join('/')
|
||||
const size = statSync(filePath).size
|
||||
return {
|
||||
path,
|
||||
size,
|
||||
sha256: sha256File(filePath),
|
||||
mode:
|
||||
path === 'agent/goodbuddy-agent' ||
|
||||
path === 'agent/node' ||
|
||||
/\/bin\/opencode$/u.test(path)
|
||||
? '0755'
|
||||
: '0644'
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(file) =>
|
||||
file.path !== packageDescriptorName &&
|
||||
file.path !== packageSignatureName
|
||||
)
|
||||
}
|
||||
|
||||
function createPackageArchive(staging, output) {
|
||||
mkdirSync(dirname(output), { recursive: true })
|
||||
const outputHandle = openSync(output, 'w')
|
||||
let archiveError
|
||||
let completed = false
|
||||
const archive = new Zip((error, data, final) => {
|
||||
if (error) {
|
||||
archiveError ??= error
|
||||
return
|
||||
}
|
||||
let offset = 0
|
||||
while (offset < data.byteLength) {
|
||||
const written = writeSync(
|
||||
outputHandle,
|
||||
data,
|
||||
offset,
|
||||
data.byteLength - offset
|
||||
)
|
||||
if (written <= 0) {
|
||||
archiveError ??= new Error(
|
||||
'Agent package archive write made no progress'
|
||||
)
|
||||
return
|
||||
}
|
||||
offset += written
|
||||
}
|
||||
completed ||= final
|
||||
})
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
||||
try {
|
||||
for (const filePath of listFiles(staging)) {
|
||||
const name = relative(staging, filePath).split(sep).join('/')
|
||||
const input = new ZipPassThrough(name)
|
||||
input.mtime = archiveEpoch
|
||||
archive.add(input)
|
||||
const inputHandle = openSync(filePath, 'r')
|
||||
const fileSize = statSync(filePath).size
|
||||
try {
|
||||
let position = 0
|
||||
if (fileSize === 0) {
|
||||
input.push(new Uint8Array(), true)
|
||||
}
|
||||
while (position < fileSize) {
|
||||
const bytesRead = readSync(
|
||||
inputHandle,
|
||||
buffer,
|
||||
0,
|
||||
buffer.length,
|
||||
null
|
||||
)
|
||||
if (bytesRead <= 0) {
|
||||
throw new Error(
|
||||
`Agent package source read made no progress: ${name}`
|
||||
)
|
||||
}
|
||||
position += bytesRead
|
||||
input.push(
|
||||
Uint8Array.from(buffer.subarray(0, bytesRead)),
|
||||
position === fileSize
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
closeSync(inputHandle)
|
||||
}
|
||||
if (archiveError) {
|
||||
throw archiveError
|
||||
}
|
||||
}
|
||||
archive.end()
|
||||
if (archiveError) {
|
||||
throw archiveError
|
||||
}
|
||||
if (!completed) {
|
||||
throw new Error('Agent package archive did not finish')
|
||||
}
|
||||
fsyncSync(outputHandle)
|
||||
} catch (error) {
|
||||
archive.terminate()
|
||||
throw error
|
||||
} finally {
|
||||
closeSync(outputHandle)
|
||||
}
|
||||
}
|
||||
|
||||
function assembleAgentPackage(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const architecture = options.architecture
|
||||
if (
|
||||
!['x64', 'arm64'].includes(architecture) ||
|
||||
!semanticVersionPattern.test(options.minimumDesktopVersion ?? '') ||
|
||||
typeof options.output !== 'string'
|
||||
) {
|
||||
throw new Error('Agent package identity is invalid')
|
||||
}
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
const agentLock =
|
||||
options.agentLock ?? readRuntimeLock(projectRoot)
|
||||
const runtimeLock =
|
||||
options.runtimeLock ?? readRemoteRuntimeLock(projectRoot)
|
||||
const signingIdentity =
|
||||
options.testSigningIdentity ??
|
||||
options.signingIdentity ??
|
||||
productionSigningKey(options.environment ?? process.env)
|
||||
const signingRecord = options.testSigningIdentity
|
||||
? registry.keys.find(
|
||||
(key) => key.keyId === signingIdentity.keyId
|
||||
)
|
||||
: preflightProductionSigningKey({
|
||||
projectRoot,
|
||||
registry,
|
||||
keyId: signingIdentity.keyId
|
||||
})
|
||||
if (!signingRecord) {
|
||||
throw new Error('Agent package signing key is not registered')
|
||||
}
|
||||
if (
|
||||
publicKeySpkiBase64(signingIdentity.privateKey) !==
|
||||
signingRecord.publicKeySpkiBase64
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package signing private key does not match the trusted registry'
|
||||
)
|
||||
}
|
||||
const agent = verifyLockedBundle(
|
||||
options.agentBundle,
|
||||
architecture,
|
||||
{
|
||||
projectRoot,
|
||||
registry,
|
||||
lock: agentLock,
|
||||
verificationEnvironment: options.testSigningIdentity
|
||||
? 'test'
|
||||
: 'production'
|
||||
}
|
||||
)
|
||||
const runtime = verifyRuntimeBundle(options.runtimeBundle, {
|
||||
projectRoot,
|
||||
architecture,
|
||||
registry,
|
||||
lock: runtimeLock,
|
||||
verificationEnvironment: options.testSigningIdentity
|
||||
? 'test'
|
||||
: 'production'
|
||||
})
|
||||
const expectedArchiveName =
|
||||
`goodbuddy-agent-${agent.manifest.agentVersion}` +
|
||||
`-linux-${architecture}.gbagent`
|
||||
if (basename(options.output) !== expectedArchiveName) {
|
||||
throw new Error(
|
||||
`Agent package output must be named ${expectedArchiveName}`
|
||||
)
|
||||
}
|
||||
const staging = mkdtempSync(
|
||||
join(tmpdir(), `goodbuddy-agent-package-linux-${architecture}-`)
|
||||
)
|
||||
try {
|
||||
stagePackagePayload({
|
||||
staging,
|
||||
registry,
|
||||
agentLock,
|
||||
runtimeLock,
|
||||
agentBundle: resolve(options.agentBundle),
|
||||
runtimeBundle: runtime.bundleDirectory
|
||||
})
|
||||
const initial = {
|
||||
format: 'goodbuddy-agent-package',
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
component: 'agent',
|
||||
version: agent.manifest.agentVersion,
|
||||
minimumDesktopVersion: options.minimumDesktopVersion,
|
||||
platform: 'linux',
|
||||
architecture,
|
||||
signingKeyId: signingIdentity.keyId,
|
||||
agentProtocol: agent.manifest.protocol,
|
||||
remoteRuntime: {
|
||||
runtimeId: runtime.manifest.runtimeId,
|
||||
provider: runtime.manifest.provider,
|
||||
version: runtime.manifest.runtimeVersion,
|
||||
bundleDigest: runtime.manifest.bundleDigest,
|
||||
protocol: runtime.manifest.protocol
|
||||
},
|
||||
contentDigest: `sha256:${'0'.repeat(64)}`,
|
||||
files: payloadFiles(staging)
|
||||
}
|
||||
const descriptor = {
|
||||
...initial,
|
||||
contentDigest: descriptorContentDigest(initial)
|
||||
}
|
||||
const bytes = descriptorBytes(descriptor)
|
||||
const signature = sign(
|
||||
null,
|
||||
Buffer.concat([signatureDomain, bytes]),
|
||||
signingIdentity.privateKey
|
||||
)
|
||||
writeFileSync(join(staging, packageDescriptorName), bytes)
|
||||
writeFileSync(
|
||||
join(staging, packageSignatureName),
|
||||
`${signature.toString('base64')}\n`,
|
||||
'utf8'
|
||||
)
|
||||
createPackageArchive(staging, options.output)
|
||||
return {
|
||||
descriptor,
|
||||
archive: basename(options.output),
|
||||
size: statSync(options.output).size,
|
||||
sha256: sha256File(options.output)
|
||||
}
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function buildAgentPackage(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
const signingIdentity =
|
||||
options.testSigningIdentity ??
|
||||
productionSigningKey(options.environment ?? process.env)
|
||||
const agentRoot = mkdtempSync(
|
||||
join(tmpdir(), `goodbuddy-agent-build-${options.architecture}-`)
|
||||
)
|
||||
const runtimeRoot = mkdtempSync(
|
||||
join(tmpdir(), `goodbuddy-runtime-build-${options.architecture}-`)
|
||||
)
|
||||
try {
|
||||
const agentBundle = buildAgentBundle({
|
||||
projectRoot,
|
||||
arch: options.architecture,
|
||||
runtimeArchive: options.nodeArchive,
|
||||
outputDirectory: join(agentRoot, 'agent'),
|
||||
registry,
|
||||
...(options.agentLock ? { lock: options.agentLock } : {}),
|
||||
...(options.testSigningIdentity
|
||||
? { testSigningIdentity: options.testSigningIdentity }
|
||||
: {})
|
||||
})
|
||||
const runtime = buildRuntimeBundle({
|
||||
projectRoot,
|
||||
architecture: options.architecture,
|
||||
runtimeArchive: options.runtimeArchive,
|
||||
outputRoot: runtimeRoot,
|
||||
registry,
|
||||
...(options.runtimeLock ? { lock: options.runtimeLock } : {}),
|
||||
...(options.testSigningIdentity
|
||||
? { testSigningIdentity: options.testSigningIdentity }
|
||||
: {})
|
||||
})
|
||||
return assembleAgentPackage({
|
||||
...options,
|
||||
projectRoot,
|
||||
registry,
|
||||
...(options.testSigningIdentity
|
||||
? {}
|
||||
: { signingIdentity }),
|
||||
agentBundle,
|
||||
runtimeBundle: runtime.bundleDirectory
|
||||
})
|
||||
} finally {
|
||||
rmSync(agentRoot, { recursive: true, force: true })
|
||||
rmSync(runtimeRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const command = argv[0]
|
||||
if (!['build', 'assemble'].includes(command)) {
|
||||
throw new Error(
|
||||
'Usage: agent-package.cjs <build|assemble> --arch <x64|arm64> --minimum-desktop-version <version> --output <archive>'
|
||||
)
|
||||
}
|
||||
const options = {}
|
||||
for (let index = 1; index < argv.length; index += 2) {
|
||||
const flag = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (
|
||||
![
|
||||
'--arch',
|
||||
'--minimum-desktop-version',
|
||||
'--node-archive',
|
||||
'--runtime-archive',
|
||||
'--agent-bundle',
|
||||
'--runtime-bundle',
|
||||
'--output'
|
||||
].includes(flag) ||
|
||||
!value
|
||||
) {
|
||||
throw new Error('Invalid Agent package argument')
|
||||
}
|
||||
options[
|
||||
flag.slice(2).replace(/-([a-z])/gu, (_match, valuePart) =>
|
||||
valuePart.toUpperCase()
|
||||
)
|
||||
] = value
|
||||
}
|
||||
if (
|
||||
!['x64', 'arm64'].includes(options.arch) ||
|
||||
!semanticVersionPattern.test(
|
||||
options.minimumDesktopVersion ?? ''
|
||||
) ||
|
||||
!options.output
|
||||
) {
|
||||
throw new Error('Agent package arguments are incomplete')
|
||||
}
|
||||
const common = {
|
||||
architecture: options.arch,
|
||||
minimumDesktopVersion: options.minimumDesktopVersion,
|
||||
output: resolve(options.output)
|
||||
}
|
||||
if (command === 'build') {
|
||||
if (!options.nodeArchive || !options.runtimeArchive) {
|
||||
throw new Error('Agent package build inputs are incomplete')
|
||||
}
|
||||
return {
|
||||
command,
|
||||
...common,
|
||||
nodeArchive: resolve(options.nodeArchive),
|
||||
runtimeArchive: resolve(options.runtimeArchive)
|
||||
}
|
||||
}
|
||||
if (!options.agentBundle || !options.runtimeBundle) {
|
||||
throw new Error('Agent package assembly inputs are incomplete')
|
||||
}
|
||||
return {
|
||||
command,
|
||||
...common,
|
||||
agentBundle: resolve(options.agentBundle),
|
||||
runtimeBundle: resolve(options.runtimeBundle)
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArguments(argv)
|
||||
const result =
|
||||
options.command === 'build'
|
||||
? buildAgentPackage(options)
|
||||
: assembleAgentPackage(options)
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assembleAgentPackage,
|
||||
buildAgentPackage,
|
||||
canonicalJson,
|
||||
descriptorBytes,
|
||||
descriptorContentDigest,
|
||||
parseArguments,
|
||||
signatureDomain
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${redactSecrets(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)}\n`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,8 @@ const targetDefinitions = [
|
||||
{ platform: 'windows', arch: 'arm64', formats: ['nsis', 'portable'] },
|
||||
{ platform: 'macos', arch: 'x64', formats: ['dmg', 'zip'] },
|
||||
{ platform: 'macos', arch: 'arm64', formats: ['dmg', 'zip'] },
|
||||
{ platform: 'linux', arch: 'x64', formats: ['AppImage', 'deb'] },
|
||||
{ platform: 'linux', arch: 'arm64', formats: ['AppImage', 'deb'] }
|
||||
{ platform: 'linux', arch: 'x64', formats: ['AppImage', 'deb', 'rpm'] },
|
||||
{ platform: 'linux', arch: 'arm64', formats: ['AppImage', 'deb', 'rpm'] }
|
||||
]
|
||||
const allowedExtensions = {
|
||||
nsis: '.exe',
|
||||
@@ -30,7 +30,8 @@ const allowedExtensions = {
|
||||
dmg: '.dmg',
|
||||
zip: '.zip',
|
||||
AppImage: '.AppImage',
|
||||
deb: '.deb'
|
||||
deb: '.deb',
|
||||
rpm: '.rpm'
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
@@ -319,6 +320,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
module.exports = {
|
||||
aggregateRelease,
|
||||
assertSafeName,
|
||||
expectedFormatForFile,
|
||||
parseArguments,
|
||||
targetDefinitions
|
||||
}
|
||||
|
||||
+9
-147
@@ -11,11 +11,6 @@ const {
|
||||
writeFileSync
|
||||
} = require('node:fs')
|
||||
const { dirname, join, parse, resolve } = require('node:path')
|
||||
const {
|
||||
supportedArchitectures,
|
||||
targetName,
|
||||
verifyLockedBundle
|
||||
} = require('./agent-bundle.cjs')
|
||||
|
||||
const root = join(__dirname, '..')
|
||||
const packageJson = JSON.parse(
|
||||
@@ -130,100 +125,7 @@ function pruneLocales(directory) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseRequiredAgentArchitectures(value) {
|
||||
if (value === undefined) {
|
||||
return []
|
||||
}
|
||||
const architectures = value
|
||||
.split(',')
|
||||
.map((architecture) => architecture.trim())
|
||||
if (
|
||||
architectures.length === 0 ||
|
||||
architectures.some(
|
||||
(architecture) =>
|
||||
!supportedArchitectures.includes(architecture)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'GOODBUDDY_PORTABLE_REQUIRE_AGENT_ARCHS 仅支持逗号分隔的 x64 和 arm64'
|
||||
)
|
||||
}
|
||||
return [...new Set(architectures)]
|
||||
}
|
||||
|
||||
function discoverLocalAgentResources(projectRoot = root) {
|
||||
return supportedArchitectures.filter((architecture) =>
|
||||
statSync(
|
||||
join(
|
||||
projectRoot,
|
||||
'.agent-resources',
|
||||
targetName(architecture)
|
||||
),
|
||||
{ throwIfNoEntry: false }
|
||||
)?.isDirectory()
|
||||
)
|
||||
}
|
||||
|
||||
function embedPortableAgentResources(directory, options = {}) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const requiredArchitectures =
|
||||
options.requiredArchitectures ??
|
||||
parseRequiredAgentArchitectures(
|
||||
process.env.GOODBUDDY_PORTABLE_REQUIRE_AGENT_ARCHS
|
||||
)
|
||||
const verifyBundle =
|
||||
options.verifyLockedBundle ?? verifyLockedBundle
|
||||
const availableArchitectures =
|
||||
discoverLocalAgentResources(projectRoot)
|
||||
const missingArchitectures = requiredArchitectures.filter(
|
||||
(architecture) =>
|
||||
!availableArchitectures.includes(architecture)
|
||||
)
|
||||
if (missingArchitectures.length > 0) {
|
||||
throw new Error(
|
||||
`Portable 构建缺少要求的 Agent 资源:${missingArchitectures
|
||||
.map(targetName)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
for (const architecture of availableArchitectures) {
|
||||
verifyBundle(
|
||||
join(
|
||||
projectRoot,
|
||||
'.agent-resources',
|
||||
targetName(architecture)
|
||||
),
|
||||
architecture,
|
||||
{ projectRoot }
|
||||
)
|
||||
}
|
||||
for (const architecture of availableArchitectures) {
|
||||
const target = targetName(architecture)
|
||||
const source = join(
|
||||
projectRoot,
|
||||
'.agent-resources',
|
||||
target
|
||||
)
|
||||
const destination = join(
|
||||
directory,
|
||||
'resources',
|
||||
'agents',
|
||||
target
|
||||
)
|
||||
rmSync(destination, { recursive: true, force: true })
|
||||
cpSync(source, destination, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
}
|
||||
return availableArchitectures
|
||||
}
|
||||
|
||||
function portableRequiredPaths(
|
||||
directory,
|
||||
agentArchitectures = []
|
||||
) {
|
||||
function portableRequiredPaths(directory) {
|
||||
return [
|
||||
join(directory, 'GoodBuddy.exe'),
|
||||
join(directory, 'resources', 'app.asar'),
|
||||
@@ -245,33 +147,12 @@ function portableRequiredPaths(
|
||||
'runtimes',
|
||||
'continue',
|
||||
'package.json'
|
||||
),
|
||||
...agentArchitectures.flatMap((architecture) => {
|
||||
const agentDirectory = join(
|
||||
directory,
|
||||
'resources',
|
||||
'agents',
|
||||
targetName(architecture)
|
||||
)
|
||||
return [
|
||||
join(agentDirectory, 'manifest.json'),
|
||||
join(agentDirectory, 'manifest.sig'),
|
||||
join(agentDirectory, 'goodbuddy-agent'),
|
||||
join(agentDirectory, 'node'),
|
||||
join(agentDirectory, 'lib', 'agent.cjs')
|
||||
]
|
||||
})
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
function assertPortableOutput(
|
||||
directory,
|
||||
agentArchitectures = []
|
||||
) {
|
||||
const requiredPaths = portableRequiredPaths(
|
||||
directory,
|
||||
agentArchitectures
|
||||
)
|
||||
function assertPortableOutput(directory) {
|
||||
const requiredPaths = portableRequiredPaths(directory)
|
||||
const missing = requiredPaths.filter(
|
||||
(filePath) => !statSync(filePath, { throwIfNoEntry: false })?.isFile()
|
||||
)
|
||||
@@ -330,8 +211,7 @@ function writePortableMarker(directory) {
|
||||
|
||||
function replacePortableOutput(
|
||||
source,
|
||||
destination,
|
||||
agentArchitectures = []
|
||||
destination
|
||||
) {
|
||||
const replacement = `${destination}.replacement-${process.pid}`
|
||||
const backup = `${destination}.previous-${process.pid}`
|
||||
@@ -341,7 +221,7 @@ function replacePortableOutput(
|
||||
copyDirectoryContents(source, replacement)
|
||||
try {
|
||||
writePortableMarker(replacement)
|
||||
assertPortableOutput(replacement, agentArchitectures)
|
||||
assertPortableOutput(replacement)
|
||||
|
||||
if (!existsSync(destination)) {
|
||||
renameSync(replacement, destination)
|
||||
@@ -363,7 +243,7 @@ function replacePortableOutput(
|
||||
}
|
||||
renameSync(preservedData, destinationData)
|
||||
}
|
||||
assertPortableOutput(destination, agentArchitectures)
|
||||
assertPortableOutput(destination)
|
||||
} catch (error) {
|
||||
try {
|
||||
const movedData = join(destination, 'data')
|
||||
@@ -405,10 +285,6 @@ function main() {
|
||||
`拒绝使用不安全的输出目录:${portablePath}`
|
||||
)
|
||||
}
|
||||
const requiredAgentArchitectures =
|
||||
parseRequiredAgentArchitectures(
|
||||
process.env.GOODBUDDY_PORTABLE_REQUIRE_AGENT_ARCHS
|
||||
)
|
||||
const electronDist = ensureElectronRuntime()
|
||||
mkdirSync(outputRoot, { recursive: true })
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
@@ -493,19 +369,8 @@ function main() {
|
||||
}
|
||||
try {
|
||||
pruneLocales(unpackedPath)
|
||||
const embeddedAgentArchitectures =
|
||||
embedPortableAgentResources(unpackedPath, {
|
||||
requiredArchitectures: requiredAgentArchitectures
|
||||
})
|
||||
assertPortableOutput(
|
||||
unpackedPath,
|
||||
embeddedAgentArchitectures
|
||||
)
|
||||
replacePortableOutput(
|
||||
unpackedPath,
|
||||
portablePath,
|
||||
embeddedAgentArchitectures
|
||||
)
|
||||
assertPortableOutput(unpackedPath)
|
||||
replacePortableOutput(unpackedPath, portablePath)
|
||||
} finally {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
}
|
||||
@@ -516,9 +381,6 @@ function main() {
|
||||
|
||||
module.exports = {
|
||||
assertPortableOutput,
|
||||
discoverLocalAgentResources,
|
||||
embedPortableAgentResources,
|
||||
parseRequiredAgentArchitectures,
|
||||
portableRequiredPaths
|
||||
}
|
||||
|
||||
|
||||
+11
-130
@@ -5,7 +5,6 @@ const {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
closeSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
@@ -39,13 +38,6 @@ const { sha256File } = require('./file-hash.cjs')
|
||||
const {
|
||||
detectBinaryArchitecture
|
||||
} = require('./binary-architecture.cjs')
|
||||
const {
|
||||
verifyAgentBundleMatrix
|
||||
} = require('./agent-bundle.cjs')
|
||||
const {
|
||||
supportedArchitectures: remoteRuntimeArchitectures,
|
||||
verifyBundleDirectory: verifyRemoteRuntimeBundle
|
||||
} = require('./remote-runtime-bundle.cjs')
|
||||
|
||||
const root = join(__dirname, '..')
|
||||
const packageJson = JSON.parse(
|
||||
@@ -135,8 +127,8 @@ const platformDefinitions = {
|
||||
},
|
||||
linux: {
|
||||
builderFlag: '--linux',
|
||||
defaultFormats: ['AppImage', 'deb'],
|
||||
supportedFormats: ['AppImage', 'deb'],
|
||||
defaultFormats: ['AppImage', 'deb', 'rpm'],
|
||||
supportedFormats: ['AppImage', 'deb', 'rpm'],
|
||||
unpackedPattern: /^linux(?:-.+)?-unpacked$/u,
|
||||
executable: [packageJson.name],
|
||||
runtimeExecutable: 'opencode'
|
||||
@@ -148,7 +140,8 @@ const formatExtensions = {
|
||||
dmg: '.dmg',
|
||||
zip: '.zip',
|
||||
AppImage: '.AppImage',
|
||||
deb: '.deb'
|
||||
deb: '.deb',
|
||||
rpm: '.rpm'
|
||||
}
|
||||
|
||||
function normalizePlatform(value) {
|
||||
@@ -400,103 +393,6 @@ function electronBuilderEnvironment(
|
||||
return builderEnvironment
|
||||
}
|
||||
|
||||
function verifyReleaseAgentResources(
|
||||
projectRoot = root,
|
||||
verifyBundleMatrix = verifyAgentBundleMatrix
|
||||
) {
|
||||
verifyBundleMatrix(
|
||||
join(projectRoot, '.agent-resources'),
|
||||
{ projectRoot }
|
||||
)
|
||||
}
|
||||
|
||||
function verifyRemoteRuntimeBundleMatrix(
|
||||
resourcesRoot,
|
||||
options = {}
|
||||
) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const verifyBundleDirectory =
|
||||
options.verifyBundleDirectory ?? verifyRemoteRuntimeBundle
|
||||
const registry = readJsonFile(
|
||||
options.registryPath ??
|
||||
join(projectRoot, 'resources', 'agent-release-keys.json'),
|
||||
'Remote Runtime trusted key registry'
|
||||
)
|
||||
const lock = readJsonFile(
|
||||
options.lockPath ??
|
||||
join(projectRoot, 'remote-runtime-lock.json'),
|
||||
'Remote Runtime lock'
|
||||
)
|
||||
for (const architecture of remoteRuntimeArchitectures) {
|
||||
const runtimeRoot = join(
|
||||
resourcesRoot,
|
||||
`linux-${architecture}`,
|
||||
'opencode'
|
||||
)
|
||||
let rootStat
|
||||
try {
|
||||
rootStat = lstatSync(runtimeRoot)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Remote Runtime bundle root is missing for ${architecture}: ${runtimeRoot}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
||||
throw new Error(
|
||||
`Remote Runtime bundle root must be a real directory for ${architecture}: ${runtimeRoot}`
|
||||
)
|
||||
}
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(runtimeRoot, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Remote Runtime bundle root cannot be read for ${architecture}: ${runtimeRoot}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!/^[a-f0-9]{64}$/u.test(entry.name)) {
|
||||
throw new Error(
|
||||
`Remote Runtime bundle root contains an unexpected entry for ${architecture}: ${entry.name}`
|
||||
)
|
||||
}
|
||||
const entryStat = lstatSync(join(runtimeRoot, entry.name))
|
||||
if (entryStat.isSymbolicLink() || !entryStat.isDirectory()) {
|
||||
throw new Error(
|
||||
`Remote Runtime bundle entry must be a real directory for ${architecture}: ${entry.name}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (entries.length !== 1) {
|
||||
throw new Error(
|
||||
`Remote Runtime bundle root must contain exactly one digest directory for ${architecture}; found ${entries.length}`
|
||||
)
|
||||
}
|
||||
verifyBundleDirectory(
|
||||
join(runtimeRoot, entries[0].name),
|
||||
{
|
||||
projectRoot,
|
||||
architecture,
|
||||
registry,
|
||||
lock,
|
||||
verificationEnvironment: 'production'
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function verifyReleaseRemoteRuntimeResources(
|
||||
projectRoot = root,
|
||||
verifyBundleMatrix = verifyRemoteRuntimeBundleMatrix
|
||||
) {
|
||||
verifyBundleMatrix(
|
||||
join(projectRoot, '.remote-runtime-resources'),
|
||||
{ projectRoot }
|
||||
)
|
||||
}
|
||||
|
||||
function binaryArchitecture(filePath) {
|
||||
return detectBinaryArchitecture(readChunk(filePath, 4096))
|
||||
}
|
||||
@@ -1154,26 +1050,10 @@ function verifyUnpackedOutput(directory, options) {
|
||||
join(resources, 'agent-runtime-lock.json'),
|
||||
'Agent Runtime 锁定清单'
|
||||
)
|
||||
verifyAgentBundleMatrix(join(resources, 'agents'), {
|
||||
projectRoot: root,
|
||||
registryPath: join(resources, 'agent-release-keys.json'),
|
||||
lockPath: join(resources, 'agent-runtime-lock.json')
|
||||
})
|
||||
assertFile(
|
||||
join(resources, 'remote-runtime-lock.json'),
|
||||
'Remote Runtime 锁定清单'
|
||||
)
|
||||
verifyRemoteRuntimeBundleMatrix(
|
||||
join(resources, 'remote-runtimes'),
|
||||
{
|
||||
projectRoot: root,
|
||||
registryPath: join(
|
||||
resources,
|
||||
'agent-release-keys.json'
|
||||
),
|
||||
lockPath: join(resources, 'remote-runtime-lock.json')
|
||||
}
|
||||
)
|
||||
assertFile(runtimeExecutable, 'OpenCode Runtime')
|
||||
assertFile(
|
||||
join(resources, 'runtimes', 'continue', 'dist', 'index.js'),
|
||||
@@ -1570,6 +1450,12 @@ function verifyArtifactSignature(filePath, format, arch) {
|
||||
) {
|
||||
throw new Error('deb 产物不是有效的 ar 归档')
|
||||
}
|
||||
if (
|
||||
format === 'rpm' &&
|
||||
readChunk(filePath, 4).toString('hex') !== 'edabeedb'
|
||||
) {
|
||||
throw new Error('rpm 产物缺少 RPM lead magic')
|
||||
}
|
||||
}
|
||||
|
||||
async function writeManifest(directory, options) {
|
||||
@@ -1703,7 +1589,7 @@ function printHelp() {
|
||||
默认格式:
|
||||
windows: nsis, portable (ZIP)
|
||||
macos: dmg, zip
|
||||
linux: AppImage, deb`)
|
||||
linux: AppImage, deb, rpm`)
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
@@ -1743,8 +1629,6 @@ async function main(argv = process.argv.slice(2)) {
|
||||
)
|
||||
}
|
||||
|
||||
verifyReleaseAgentResources()
|
||||
verifyReleaseRemoteRuntimeResources()
|
||||
rmSync(stagingDirectory, { recursive: true, force: true })
|
||||
let cleanupTargetDependencies = () => undefined
|
||||
try {
|
||||
@@ -1814,9 +1698,6 @@ module.exports = {
|
||||
verifyArtifacts,
|
||||
verifyArtifactSignature,
|
||||
verifyPortableZip,
|
||||
verifyReleaseAgentResources,
|
||||
verifyReleaseRemoteRuntimeResources,
|
||||
verifyRemoteRuntimeBundleMatrix,
|
||||
writeManifest
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,17 @@ const {
|
||||
writeFileSync
|
||||
} = require('node:fs')
|
||||
const { dirname, resolve } = require('node:path')
|
||||
const {
|
||||
expectedFormatForFile,
|
||||
targetDefinitions
|
||||
} = require('./aggregate-release.cjs')
|
||||
|
||||
const supportedTargets = new Map([
|
||||
['windows-x64', ['nsis', 'portable']],
|
||||
['windows-arm64', ['nsis', 'portable']],
|
||||
['macos-x64', ['dmg', 'zip']],
|
||||
['macos-arm64', ['dmg', 'zip']],
|
||||
['linux-x64', ['AppImage', 'deb']],
|
||||
['linux-arm64', ['AppImage', 'deb']]
|
||||
])
|
||||
const supportedTargets = new Map(
|
||||
targetDefinitions.map((target) => [
|
||||
`${target.platform}-${target.arch}`,
|
||||
target
|
||||
])
|
||||
)
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = {}
|
||||
@@ -81,32 +83,6 @@ function assertFile(file, targetKey) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatForFile(fileName, platform) {
|
||||
if (platform === 'windows') {
|
||||
if (/-setup\.exe$/u.test(fileName)) {
|
||||
return 'nsis'
|
||||
}
|
||||
if (/-portable\.zip$/u.test(fileName)) {
|
||||
return 'portable'
|
||||
}
|
||||
} else if (platform === 'macos') {
|
||||
if (fileName.endsWith('.dmg')) {
|
||||
return 'dmg'
|
||||
}
|
||||
if (fileName.endsWith('.zip')) {
|
||||
return 'zip'
|
||||
}
|
||||
} else if (platform === 'linux') {
|
||||
if (fileName.endsWith('.AppImage')) {
|
||||
return 'AppImage'
|
||||
}
|
||||
if (fileName.endsWith('.deb')) {
|
||||
return 'deb'
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function createSiteRelease(manifest, baseUrlValue) {
|
||||
if (
|
||||
!manifest ||
|
||||
@@ -123,7 +99,8 @@ function createSiteRelease(manifest, baseUrlValue) {
|
||||
const targets = {}
|
||||
for (const target of manifest.targets) {
|
||||
const key = `${target?.platform}-${target?.arch}`
|
||||
const expectedFormats = supportedTargets.get(key)
|
||||
const expectedTarget = supportedTargets.get(key)
|
||||
const expectedFormats = expectedTarget?.formats
|
||||
if (
|
||||
!expectedFormats ||
|
||||
seenTargets.has(key) ||
|
||||
@@ -140,7 +117,10 @@ function createSiteRelease(manifest, baseUrlValue) {
|
||||
const files = {}
|
||||
for (const file of target.files) {
|
||||
assertFile(file, key)
|
||||
const format = formatForFile(file.name, target.platform)
|
||||
const format = expectedFormatForFile(
|
||||
file.name,
|
||||
expectedTarget
|
||||
)
|
||||
if (!format || !expectedFormats.includes(format) || files[format]) {
|
||||
throw new Error(`发布文件格式无效:${file.name}`)
|
||||
}
|
||||
|
||||
@@ -1,36 +1 @@
|
||||
const packageJson = require('../package.json')
|
||||
const {
|
||||
supportedArchitectures,
|
||||
targetName
|
||||
} = require('./agent-bundle.cjs')
|
||||
|
||||
const remoteRuntimeResources = supportedArchitectures.map(
|
||||
(architecture) => {
|
||||
const target = targetName(architecture)
|
||||
return {
|
||||
from: `.remote-runtime-resources/${target}`,
|
||||
to: `remote-runtimes/${target}`,
|
||||
filter: ['**/*']
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const agentResources = [
|
||||
...supportedArchitectures.map((architecture) => {
|
||||
const target = targetName(architecture)
|
||||
return {
|
||||
from: `.agent-resources/${target}`,
|
||||
to: `agents/${target}`,
|
||||
filter: ['**/*']
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
module.exports = {
|
||||
...packageJson.build,
|
||||
extraResources: [
|
||||
...packageJson.build.extraResources,
|
||||
...agentResources,
|
||||
...remoteRuntimeResources
|
||||
]
|
||||
}
|
||||
module.exports = require('../package.json').build
|
||||
|
||||
@@ -35,6 +35,9 @@ const tar = require('tar')
|
||||
const {
|
||||
detectElfArchitecture
|
||||
} = require('./binary-architecture.cjs')
|
||||
const {
|
||||
preflightRegisteredProductionKey
|
||||
} = require('./signing-key-preflight.cjs')
|
||||
|
||||
const root = join(__dirname, '..')
|
||||
const supportedArchitectures = Object.freeze(['x64', 'arm64'])
|
||||
@@ -310,15 +313,17 @@ function trustedKeyForSigning(
|
||||
function preflightProductionSigningKey(options = {}) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const environment = options.environment ?? process.env
|
||||
const keyId =
|
||||
options.keyId ?? environment[signingKeyIdEnvironment]
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
const identity = options.signingIdentity ??
|
||||
productionSigningIdentity(environment)
|
||||
return trustedKeyForSigning(
|
||||
identity,
|
||||
return preflightRegisteredProductionKey({
|
||||
component: 'Runtime',
|
||||
keyId,
|
||||
registry,
|
||||
'production'
|
||||
)
|
||||
missingKeyIdMessage:
|
||||
`${signingKeyIdEnvironment} is not configured; provision the production Runtime signing key ID before building a release`
|
||||
})
|
||||
}
|
||||
|
||||
function assertSafeManifestPath(filePath) {
|
||||
@@ -640,30 +645,39 @@ function verifyBundleDirectory(bundleDirectoryInput, options = {}) {
|
||||
}
|
||||
|
||||
function lockedArchive(lock, architecture, archivePath) {
|
||||
targetName(architecture)
|
||||
const runtime = lock.runtimes.opencode
|
||||
const target = runtime.targets[architecture]
|
||||
const input = resolveLockedRuntimeInput(lock, architecture)
|
||||
if (!archivePath || !existsSync(archivePath)) {
|
||||
throw new Error(
|
||||
`Locked OpenCode archive is required: ${target.package}-${runtime.version}.tgz`
|
||||
`Locked OpenCode archive is required: ${input.archive}`
|
||||
)
|
||||
}
|
||||
if (!statSync(archivePath).isFile()) {
|
||||
throw new Error(`Runtime archive is not a file: ${archivePath}`)
|
||||
}
|
||||
const expectedName = `${target.package}-${runtime.version}.tgz`
|
||||
if (basename(archivePath) !== expectedName) {
|
||||
if (basename(archivePath) !== input.archive) {
|
||||
throw new Error(
|
||||
`Runtime archive filename mismatch: expected ${expectedName}`
|
||||
`Runtime archive filename mismatch: expected ${input.archive}`
|
||||
)
|
||||
}
|
||||
const actualIntegrity = sha512FileIntegrity(archivePath)
|
||||
if (actualIntegrity !== target.integrity) {
|
||||
if (actualIntegrity !== input.integrity) {
|
||||
throw new Error(
|
||||
`Runtime archive integrity mismatch for ${targetName(architecture)}`
|
||||
)
|
||||
}
|
||||
return target
|
||||
return lock.runtimes.opencode.targets[architecture]
|
||||
}
|
||||
|
||||
function resolveLockedRuntimeInput(lock, architecture) {
|
||||
targetName(architecture)
|
||||
const runtime = lock.runtimes.opencode
|
||||
const target = runtime.targets[architecture]
|
||||
return {
|
||||
packageName: target.package,
|
||||
version: runtime.version,
|
||||
integrity: target.integrity,
|
||||
archive: `${target.package}-${runtime.version}.tgz`
|
||||
}
|
||||
}
|
||||
|
||||
function createManifest(bundleDirectory, metadata) {
|
||||
@@ -1102,6 +1116,7 @@ module.exports = {
|
||||
readRemoteRuntimeLock,
|
||||
readTrustedKeyRegistry,
|
||||
redactSecrets,
|
||||
resolveLockedRuntimeInput,
|
||||
signaturePayload,
|
||||
supportedArchitectures,
|
||||
targetName,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
function preflightRegisteredProductionKey(options) {
|
||||
const keyId = options.keyId
|
||||
if (!keyId) {
|
||||
throw new Error(options.missingKeyIdMessage)
|
||||
}
|
||||
const signingRecord = options.registry.keys.find(
|
||||
(key) => key.keyId === keyId
|
||||
)
|
||||
if (!signingRecord) {
|
||||
throw new Error(
|
||||
`Production ${options.component} public signing key ID "${keyId}" is absent from resources/agent-release-keys.json; provision the matching public key before building a release`
|
||||
)
|
||||
}
|
||||
if (signingRecord.environment !== 'production') {
|
||||
throw new Error(
|
||||
`Production ${options.component} signing key ID "${keyId}" is not registered for production`
|
||||
)
|
||||
}
|
||||
if (
|
||||
options.registry.revocations.some(
|
||||
(revocation) => revocation.keyId === keyId
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Production ${options.component} signing key ID "${keyId}" is revoked`
|
||||
)
|
||||
}
|
||||
return signingRecord
|
||||
}
|
||||
|
||||
module.exports = { preflightRegisteredProductionKey }
|
||||
@@ -1,5 +1,13 @@
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { resolve } = require('node:path')
|
||||
const {
|
||||
targetDefinitions
|
||||
} = require('./aggregate-release.cjs')
|
||||
|
||||
const expectedInstallerCount = targetDefinitions.reduce(
|
||||
(count, target) => count + target.formats.length,
|
||||
0
|
||||
)
|
||||
|
||||
function parseArguments(argv) {
|
||||
if (argv.length !== 2 || argv[0] !== '--manifest' || !argv[1]) {
|
||||
@@ -12,7 +20,7 @@ async function verifySiteRelease(manifest, request = fetch) {
|
||||
const files = Object.values(manifest?.targets ?? {}).flatMap(
|
||||
(target) => Object.values(target?.files ?? {})
|
||||
)
|
||||
if (files.length !== 12) {
|
||||
if (files.length !== expectedInstallerCount) {
|
||||
throw new Error(`官网发布文件数量错误:${files.length}`)
|
||||
}
|
||||
const urls = new Set()
|
||||
|
||||
Reference in New Issue
Block a user