Files
goodbuddy/build/agent-package.cjs
T
mesalogo fcedaf578e
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
fix: recover managed SSH Agent package release
Linux .gbagent extraction inherited permissive directory modes, so compound packages failed secure Runtime verification on POSIX runners. Package roots and nested bundle directories now use private modes.

The production workflow now uses one GoodBuddy Agent signing identity for the Agent, bundled Runtime, outer package, and catalog. The unpublished 0.11.2 candidate is carried forward as desktop and Agent 0.11.3 without a LoongArch preview.

Release note: 修复 Linux 上 Agent 包解压后无法通过权限验证的问题,并将复合包各层统一为一组 GoodBuddy Agent 发布密钥。
2026-08-25 20:53:28 +08:00

548 lines
14 KiB
JavaScript

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 ??
options.signingIdentity ??
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 }
: { signingIdentity })
})
const runtime = buildRuntimeBundle({
projectRoot,
architecture: options.architecture,
runtimeArchive: options.runtimeArchive,
outputRoot: runtimeRoot,
registry,
...(options.runtimeLock ? { lock: options.runtimeLock } : {}),
...(options.testSigningIdentity
? { testSigningIdentity: options.testSigningIdentity }
: { signingIdentity })
})
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
}
}