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
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
Valid Agent packages could be rejected when packaged trusted-key JSON used CRLF, and Settings could not show whether a newer compatible Agent existed. Registry formatting is now parsed independently of cryptographic trust, and Settings reads only the signed catalog to report local and latest versions. New installations use a credential-free local Ollama-compatible profile. The v20 migration changes only untouched historical defaults, while explicit profiles and legacy environment aliases remain compatible. Magic Notes count badges no longer shift navigation rows. Release note: 修复 Agent 下载兼容性并在设置中显示更新状态;新安装默认连接本地模型,保留显式用户配置;魔法笔记待办数字不再改变侧栏菜单高度。
308 lines
7.9 KiB
TypeScript
308 lines
7.9 KiB
TypeScript
import { z } from 'zod'
|
|
|
|
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: z.literal('linux'),
|
|
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: z.literal('24.19.0'),
|
|
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
|
|
})
|
|
.strict()
|
|
})
|
|
.strict(),
|
|
koffi: z
|
|
.object({
|
|
version: z.literal('3.1.4')
|
|
})
|
|
.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)
|
|
})
|
|
}
|