Files
goodbuddy/src/shared/agent-installation-contracts.ts
T
mesalogo 1698d9fd3e fix: keep valid older agent packages usable after desktop updates
Accept package-declared OpenCode, Node and koffi versions instead of desktop release pins. Preserve signature, hash, protocol and package-internal version consistency checks.

Validate both cached 0.11.18 packages and Linux x64 installation, bootstrap, health and stop. Related regression suite: 326 passed, 1 skipped; unrelated full-workspace check failures are recorded in the remote Host design.

Release note: Desktop updates no longer reject intact older Agent packages solely because their bundled runtime versions differ.
2026-09-16 19:04:54 +08:00

310 lines
8.1 KiB
TypeScript

import { z } from 'zod'
import { agentPlatformSchema } from './agent-target'
const versionPattern =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+((?:[0-9a-zA-Z-]+)(?:\.[0-9a-zA-Z-]+)*))?$/u
const sha256Pattern = /^[a-f0-9]{64}$/u
const keyIdPattern = /^[a-z0-9][a-z0-9._-]{0,63}$/u
const canonicalBase64Pattern =
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u
const canonicalDoublePaddingCharacterPattern = /[AQgw]==$/u
const canonicalSinglePaddingCharacterPattern =
/[AEIMQUYcgkosw048]=$/u
function hasCanonicalBase64Padding(value: string): boolean {
if (value.endsWith('==')) {
return canonicalDoublePaddingCharacterPattern.test(value)
}
if (value.endsWith('=')) {
return canonicalSinglePaddingCharacterPattern.test(value)
}
return true
}
export const agentArchitectureSchema = z.enum(['x64', 'arm64'])
export const componentVersionSchema = z
.string()
.max(128)
.regex(versionPattern)
export type AgentArchitecture = z.infer<
typeof agentArchitectureSchema
>
export const agentProtocolVersionSchema = z
.object({
major: z.number().int().min(0).max(65_535).safe(),
minor: z.number().int().min(0).max(65_535).safe()
})
.strict()
export const agentManifestPathSchema = z
.string()
.min(1)
.max(240)
.refine(isSafeAgentManifestPath, 'Unsafe Agent manifest path')
export const agentManifestFileSchema = z
.object({
path: agentManifestPathSchema,
size: z.number().int().min(0).safe(),
sha256: z.string().regex(sha256Pattern),
mode: z.enum(['0644', '0755'])
})
.strict()
.superRefine((file, context) => {
const expectedMode = expectedAgentManifestMode(file.path)
if (file.mode !== expectedMode) {
context.addIssue({
code: 'custom',
path: ['mode'],
message: `Agent payload mode must be ${expectedMode}`
})
}
})
export type AgentManifestFile = z.infer<
typeof agentManifestFileSchema
>
export const agentManifestLicenseSchema = z
.object({
package: z.string().trim().min(1).max(200),
version: z.string().trim().min(1).max(128),
spdx: z.string().trim().min(1).max(128),
path: agentManifestPathSchema
})
.strict()
export const agentBundleManifestSchema = z
.object({
formatVersion: z.literal(1),
product: z.literal('GoodBuddy'),
agentVersion: componentVersionSchema,
platform: agentPlatformSchema,
arch: agentArchitectureSchema,
protocol: agentProtocolVersionSchema,
signingKeyId: z.string().regex(keyIdPattern),
entrypoint: z
.object({
path: z.literal('goodbuddy-agent'),
runtimePath: z.literal('node'),
scriptPath: z.literal('lib/agent.cjs')
})
.strict(),
files: z.array(agentManifestFileSchema).min(1).max(10_000),
licenses: z.array(agentManifestLicenseSchema).min(1).max(1_000)
})
.strict()
.superRefine((manifest, context) => {
const declaredPaths = new Set<string>()
manifest.files.forEach((file, index) => {
if (
file.path === 'manifest.json' ||
file.path === 'manifest.sig'
) {
context.addIssue({
code: 'custom',
path: ['files', index, 'path'],
message: 'Agent payload cannot declare signature metadata'
})
}
if (declaredPaths.has(file.path)) {
context.addIssue({
code: 'custom',
path: ['files', index, 'path'],
message: 'Agent manifest file paths must be unique'
})
}
declaredPaths.add(file.path)
})
const licensedPaths = new Set<string>()
manifest.licenses.forEach((license, index) => {
if (licensedPaths.has(license.path)) {
context.addIssue({
code: 'custom',
path: ['licenses', index, 'path'],
message: 'Agent manifest license paths must be unique'
})
}
if (!declaredPaths.has(license.path)) {
context.addIssue({
code: 'custom',
path: ['licenses', index, 'path'],
message: 'Agent manifest license must name a declared file'
})
}
licensedPaths.add(license.path)
})
for (const path of [
manifest.entrypoint.path,
manifest.entrypoint.runtimePath,
manifest.entrypoint.scriptPath
]) {
if (!declaredPaths.has(path)) {
context.addIssue({
code: 'custom',
path: ['entrypoint'],
message: `Agent entrypoint payload is missing: ${path}`
})
}
}
})
export type AgentBundleManifest = z.infer<
typeof agentBundleManifestSchema
>
export const agentReleaseKeySchema = z
.object({
keyId: z.string().regex(keyIdPattern),
publicKeySpkiBase64: z
.string()
.min(4)
.max(4_096)
.regex(canonicalBase64Pattern)
.refine(
hasCanonicalBase64Padding,
'Agent public key must use canonical Base64'
),
environment: z.enum(['production', 'test'])
})
.strict()
export type AgentReleaseKey = z.infer<typeof agentReleaseKeySchema>
export const agentReleaseKeyRevocationSchema = z
.object({
keyId: z.string().regex(keyIdPattern)
})
.strict()
export const agentReleaseKeyRegistrySchema = z
.object({
formatVersion: z.literal(1),
keys: z.array(agentReleaseKeySchema).max(1_000),
revocations: z.array(agentReleaseKeyRevocationSchema).max(1_000)
})
.strict()
.superRefine((registry, context) => {
addDuplicateKeyIssues(
registry.keys,
'keys',
context
)
addDuplicateKeyIssues(
registry.revocations,
'revocations',
context
)
})
export type AgentReleaseKeyRegistry = z.infer<
typeof agentReleaseKeyRegistrySchema
>
const lockedRuntimePathSchema = z
.string()
.min(1)
.max(512)
.refine(isSafeAgentManifestPath, 'Unsafe locked runtime path')
const lockedNodeTargetSchema = z
.object({
archive: z
.string()
.min(1)
.max(240)
.refine(
(value) =>
!value.includes('/') &&
!value.includes('\\') &&
value !== '.' &&
value !== '..',
'Locked Node archive name is invalid'
),
sha256: z.string().regex(sha256Pattern),
binaryPath: lockedRuntimePathSchema,
licensePath: lockedRuntimePathSchema
})
.strict()
export const agentRuntimeLockSchema = z
.object({
formatVersion: z.literal(1),
agentVersion: componentVersionSchema,
protocol: agentProtocolVersionSchema,
node: z
.object({
version: componentVersionSchema,
source: z
.url()
.refine(
(value) => new URL(value).protocol === 'https:',
'Locked Node source must use HTTPS'
),
targets: z
.object({
'linux-x64': lockedNodeTargetSchema,
'linux-arm64': lockedNodeTargetSchema,
'darwin-arm64': lockedNodeTargetSchema.optional()
})
.strict()
})
.strict(),
koffi: z
.object({
version: componentVersionSchema
})
.strict()
})
.strict()
export type AgentRuntimeLock = z.infer<typeof agentRuntimeLockSchema>
export function isSafeAgentManifestPath(filePath: string): boolean {
return (
filePath.length > 0 &&
filePath.length <= 512 &&
!filePath.includes('\\') &&
!filePath.includes('\0') &&
!filePath.startsWith('/') &&
!filePath
.split('/')
.some(
(part) => part.length === 0 || part === '.' || part === '..'
)
)
}
export function expectedAgentManifestMode(
filePath: string
): '0644' | '0755' {
return filePath === 'node' ||
filePath === 'goodbuddy-agent' ||
filePath.startsWith('helpers/')
? '0755'
: '0644'
}
function addDuplicateKeyIssues(
entries: readonly { keyId: string }[],
path: 'keys' | 'revocations',
context: z.RefinementCtx
): void {
const ids = new Set<string>()
entries.forEach((entry, index) => {
if (ids.has(entry.keyId)) {
context.addIssue({
code: 'custom',
path: [path, index, 'keyId'],
message: `Agent ${path} key IDs must be unique`
})
}
ids.add(entry.keyId)
})
}