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:
mesalogo
2026-08-25 18:28:29 +08:00
parent 88658aceaa
commit e5aebaa67d
101 changed files with 11543 additions and 2168 deletions
+10 -28
View File
@@ -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) {
+811
View File
@@ -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
}
}
+218
View File
@@ -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
}
+546
View File
@@ -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
}
}
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+16 -36
View File
@@ -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
View File
@@ -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
+30 -15
View File
@@ -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,
+31
View File
@@ -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 }
+9 -1
View File
@@ -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()