Files
goodbuddy/src/main/remote-agent/agent-package-manager.ts
T
mesalogo d80d42b032 feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2026-10-06 15:40:18 +08:00

2241 lines
63 KiB
TypeScript

import {
createHash,
createPublicKey,
randomUUID,
verify
} from 'node:crypto'
import { constants } from 'node:fs'
import fs, {
lstat,
mkdir,
open,
readFile,
readdir,
rename,
rm,
writeFile
} from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { isDeepStrictEqual } from 'node:util'
import { agentPlatformSchema, type AgentPlatform } from '../../shared/agent-target'
import {
agentPackageArchiveName,
agentPackageCatalogSchema,
agentPackageDownloadProgressSchema,
agentPackageInventorySchema,
type AgentPackageCatalog,
type AgentPackageCatalogEntry,
type AgentPackageDescriptor,
type AgentPackageDownloadProgress,
type AgentPackageInventory
} from '../../shared/agent-package-contracts'
import type { UpdateSource } from '../../shared/application-settings-contracts'
import {
AGENT_PROTOCOL_VERSION
} from '../../shared/agent-protocol/contracts'
import {
agentArchitectureSchema,
type AgentArchitecture,
type AgentReleaseKeyRegistry
} from '../../shared/agent-installation-contracts'
import {
fetchModelDownloadResponse,
isDownloadRedirectStatus,
validateSecureDownloadUrl
} from '../model-download-transport'
import {
compareSemanticVersions,
extractAndVerifyAgentPackage,
verifyExtractedAgentPackage,
type VerifiedAgentPackage
} from './agent-package-verifier'
import {
readAgentReleaseKeyRegistry
} from './agent-bundle-verifier'
import { isMissingPathError } from './path-errors'
const CATALOG_NAME = 'agent-catalog.json'
const CATALOG_SIGNATURE_NAME = 'agent-catalog.sig'
const CATALOG_SIGNATURE_DOMAIN = Buffer.from(
'GoodBuddy Agent Package Catalog Signature v1\0',
'utf8'
)
const MIRROR_ROOT =
'https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases/'
const GITHUB_RELEASES_API =
'https://api.github.com/repos/mesalogo/goodbuddy/releases?per_page=10'
const GITHUB_RELEASE_ROOT =
'https://github.com/mesalogo/goodbuddy/releases/download/'
const MAXIMUM_CATALOG_BYTES = 1024 * 1024
const MAXIMUM_PACKAGE_BYTES = 512 * 1024 * 1024
const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000
const REMOTE_CANDIDATE_TIMEOUT_MS = 30_000
const MAXIMUM_REMOTE_REDIRECTS = 3
const MAXIMUM_REMOTE_URL_BYTES = 4_096
const GITHUB_REDIRECT_HOSTS = [
'objects.githubusercontent.com',
'release-assets.githubusercontent.com'
] as const
type InstalledRecord = {
directory: string
archivePath: string
archiveSha256: string
verified: VerifiedAgentPackage
}
type CatalogState =
| {
state: 'available'
checkedAt: string
catalog: AgentPackageCatalog
}
| {
state: 'unavailable'
checkedAt: string
error: string
}
export type AgentPackageManagerOptions = {
platform?: AgentPlatform
userDataPath: string
desktopVersion: string
keyRegistryPath: string
getUpdateSource: () => Promise<UpdateSource>
fetch?: typeof fetch
now?: () => Date
}
export type VerifiedRemoteAgentInstallCandidate = {
additionalRuntimes?: AgentPackageDescriptor['additionalRuntimes']
source: UpdateSource
platform: AgentPlatform
architecture: AgentArchitecture
version: string
minimumDesktopVersion: string
agentProtocol: {
major: number
minor: number
}
remoteRuntime: {
runtimeId: 'opencode' | 'continue'
provider: 'opencode' | 'continue'
version: string
bundleDigest: string
protocol: {
major: number
minor: number
}
}
archive: string
size: number
sha256: string
/**
* The canonical signed URL followed by each manually verified redirect.
* The remote bootstrap must require this exact sequence.
*/
urls: readonly string[]
}
export type VerifiedRemoteAgentEnvironmentCatalog = {
expected: {
agent: { version: string }
runtimes: Array<{
runtimeId: 'opencode' | 'continue'
provider: 'opencode' | 'continue'
version: string
}>
}
candidate: VerifiedRemoteAgentInstallCandidate | null
candidateFailure?: {
reason: 'package-unavailable' | 'probe-failed'
source: UpdateSource | null
packageSize: number | null
}
}
export type AgentPackageArchiveLease = {
candidate: VerifiedRemoteAgentInstallCandidate
path: string
size: number
sha256: string
nodePath: string
nodeSize: number
nodeSha256: string
release: () => void
}
export type AcquireInstallArchiveOptions = {
platform?: AgentPlatform
signal?: AbortSignal
onProgress?: (progress: AgentPackageDownloadProgress) => void
}
function expectedCatalog(
agentVersion: string,
runtimeVersion: string,
runtimeId: 'opencode' | 'continue' = 'opencode',
additionalRuntimes: NonNullable<AgentPackageDescriptor['additionalRuntimes']> = []
): VerifiedRemoteAgentEnvironmentCatalog['expected'] {
return {
agent: { version: agentVersion },
runtimes: [{
runtimeId,
provider: runtimeId,
version: runtimeVersion
}, ...additionalRuntimes.map(({ runtimeId, provider, version }) => ({ runtimeId, provider, version }))]
}
}
function candidateFromCatalogEntry(
entry: AgentPackageCatalogEntry,
source: UpdateSource
): VerifiedRemoteAgentInstallCandidate {
return {
source,
platform: entry.platform,
architecture: entry.architecture,
version: entry.version,
minimumDesktopVersion: entry.minimumDesktopVersion,
agentProtocol: { ...entry.agentProtocol },
...(entry.additionalRuntimes ? { additionalRuntimes: structuredClone(entry.additionalRuntimes) } : {}),
remoteRuntime: {
...entry.remoteRuntime,
protocol: { ...entry.remoteRuntime.protocol }
},
archive: entry.archive,
size: entry.size,
sha256: entry.sha256,
urls: []
}
}
function candidateFromVerifiedRecord(
record: InstalledRecord,
source: UpdateSource,
size: number
): VerifiedRemoteAgentInstallCandidate {
const descriptor = record.verified.descriptor
return {
source,
platform: descriptor.platform,
architecture: descriptor.architecture,
version: descriptor.version,
minimumDesktopVersion: descriptor.minimumDesktopVersion,
agentProtocol: { ...descriptor.agentProtocol },
...(descriptor.additionalRuntimes ? { additionalRuntimes: structuredClone(descriptor.additionalRuntimes) } : {}),
remoteRuntime: {
...descriptor.remoteRuntime,
protocol: { ...descriptor.remoteRuntime.protocol }
},
archive: agentPackageArchiveName(
descriptor.version,
descriptor.architecture,
descriptor.platform
),
size,
sha256: record.archiveSha256,
urls: []
}
}
export class AgentPackageManager {
readonly #options: AgentPackageManagerOptions
readonly #platform: AgentPlatform
readonly #platformManagers = new Map<AgentPlatform, AgentPackageManager>()
readonly #rootDirectory: string
readonly #desktopVersion: string
readonly #keyRegistryPath: string
readonly #getUpdateSource: () => Promise<UpdateSource>
readonly #fetch: typeof fetch
readonly #now: () => Date
#trustedRegistry?: Promise<AgentReleaseKeyRegistry>
readonly #installed = new Map<
AgentArchitecture,
Promise<InstalledRecord | undefined>
>()
readonly #operations = new Map<
AgentArchitecture,
Promise<unknown>
>()
readonly #leaseCounts = new Map<string, number>()
readonly #pendingRemovals = new Set<string>()
readonly #startupCleanup: Promise<void>
#catalogState?: CatalogState
constructor(options: AgentPackageManagerOptions) {
this.#options = options
this.#platform = agentPlatformSchema.parse(options.platform ?? 'linux')
this.#rootDirectory = resolve(
options.userDataPath,
'remote-components',
'agent-packages'
)
this.#desktopVersion = options.desktopVersion
this.#keyRegistryPath = resolve(options.keyRegistryPath)
this.#getUpdateSource = options.getUpdateSource
this.#fetch = options.fetch ?? fetch
this.#now = options.now ?? (() => new Date())
this.#startupCleanup = this.#cleanupInterruptedArtifacts()
}
forPlatform(platform: AgentPlatform): AgentPackageManager {
if (platform === this.#platform) return this
let manager = this.#platformManagers.get(platform)
if (!manager) {
manager = new AgentPackageManager({ ...this.#options, platform })
this.#platformManagers.set(platform, manager)
}
return manager
}
async getAllSnapshot(options: { refresh?: boolean } = {}): Promise<AgentPackageInventory> {
const linux = await this.forPlatform('linux').getSnapshot(options)
const darwin = this.forPlatform('darwin')
darwin.#catalogState = this.forPlatform('linux').#catalogState
const mac = await darwin.getInventory(options)
return agentPackageInventorySchema.parse({
...linux,
entries: [...linux.entries, ...mac.entries]
})
}
#select(catalog: AgentPackageCatalog, architecture: AgentArchitecture, desktopVersion: string) {
return selectLatestCompatibleEntry(catalog, architecture, desktopVersion, this.#platform)
}
async getInventory(
options: { refresh?: boolean } = {}
): Promise<AgentPackageInventory> {
for (const directory of this.#pendingRemovals) {
await this.#removeWhenUnused(directory).catch(() => undefined)
}
if (options.refresh) {
this.#installed.clear()
}
const entries = await Promise.all(
agentArchitectureSchema.options.filter(architecture =>
this.#platform === 'linux' || architecture === 'arm64'
).map(async (architecture) => {
let record: InstalledRecord | undefined
let state: 'not-downloaded' | 'verified' | 'invalid'
try {
record = await this.#loadInstalled(architecture)
state = record ? 'verified' : await this.#hasEntries(architecture)
? 'invalid'
: 'not-downloaded'
} catch {
state = 'invalid'
}
const latestVersion = this.#latestVersion(architecture)
return {
platform: this.#platform,
architecture,
state,
version: record?.verified.descriptor.version ?? null,
latestVersion,
updateAvailable: this.#updateAvailable(
latestVersion,
record?.verified.descriptor.version ?? null
),
remoteRuntimeVersion:
record?.verified.descriptor.remoteRuntime.version ?? null,
agentProtocol:
record?.verified.descriptor.agentProtocol ?? null
}
})
)
return agentPackageInventorySchema.parse({
checkedAt: this.#now().toISOString(),
catalog: this.#publicCatalogState(),
entries
})
}
async getSnapshot(
options: { refresh?: boolean } = {}
): Promise<AgentPackageInventory> {
const catalogCheck = this.#checkCatalog()
const inventory = await this.getInventory(options)
await catalogCheck
return agentPackageInventorySchema.parse({
...inventory,
checkedAt: this.#now().toISOString(),
catalog: this.#publicCatalogState(),
entries: inventory.entries.map((entry) => {
const latestVersion = this.#latestVersion(
entry.architecture
)
return {
...entry,
latestVersion,
updateAvailable: this.#updateAvailable(
latestVersion,
entry.version
)
}
})
})
}
async getRemoteEnvironmentCatalog(
architecture: AgentArchitecture,
options: { signal?: AbortSignal; platform?: AgentPlatform } = {}
): Promise<VerifiedRemoteAgentEnvironmentCatalog> {
if (options.platform && options.platform !== this.#platform) {
return this.forPlatform(options.platform).getRemoteEnvironmentCatalog(architecture, options)
}
options.signal?.throwIfAborted()
let entry: AgentPackageCatalogEntry
let source: UpdateSource | undefined
try {
source = await this.#getUpdateSource()
options.signal?.throwIfAborted()
const catalog = await this.#loadCatalogAndRemember(
source,
options.signal
)
entry = this.#select(
catalog,
architecture,
this.#desktopVersion
)
} catch (onlineError) {
options.signal?.throwIfAborted()
const installed = await this.#loadInstalled(architecture)
if (!installed) {
throw onlineError
}
return {
expected: expectedCatalog(
installed.verified.descriptor.version,
installed.verified.descriptor.remoteRuntime.version,
installed.verified.descriptor.remoteRuntime.runtimeId,
installed.verified.descriptor.additionalRuntimes
),
candidate: null,
candidateFailure: {
reason:
onlineError instanceof NoCompatibleAgentPackageError
? 'package-unavailable'
: 'probe-failed',
source: source ?? null,
packageSize: null
}
}
}
const expected = expectedCatalog(
entry.version,
entry.remoteRuntime.version,
entry.remoteRuntime.runtimeId,
entry.additionalRuntimes
)
try {
return {
expected,
candidate: await this.#remoteInstallCandidate(
entry,
source,
options.signal
)
}
} catch {
options.signal?.throwIfAborted()
return {
expected,
candidate: null,
candidateFailure: {
reason: 'probe-failed',
source,
packageSize: entry.size
}
}
}
}
/**
* Returns a Main-only, signed and compatibility-checked remote installation
* candidate. No local .gbagent cache is required.
*/
async getRemoteInstallCandidate(
architecture: AgentArchitecture,
options: { signal?: AbortSignal; platform?: AgentPlatform } = {}
): Promise<VerifiedRemoteAgentInstallCandidate> {
if (options.platform && options.platform !== this.#platform) {
return this.forPlatform(options.platform).getRemoteInstallCandidate(architecture, options)
}
options.signal?.throwIfAborted()
const source = await this.#getUpdateSource()
options.signal?.throwIfAborted()
const catalog = await this.#loadCatalogAndRemember(
source,
options.signal
)
const entry = this.#select(
catalog,
architecture,
this.#desktopVersion
)
return this.#remoteInstallCandidate(
entry,
source,
options.signal
)
}
async #remoteInstallCandidate(
entry: AgentPackageCatalogEntry,
source: UpdateSource,
signal?: AbortSignal
): Promise<VerifiedRemoteAgentInstallCandidate> {
assertCanonicalDownload(entry, source)
const urls = await this.#resolveRemoteDownloadUrls(
entry,
source,
signal
)
return {
...candidateFromCatalogEntry(entry, source),
urls
}
}
download(
architecture: AgentArchitecture,
onProgress?: (progress: AgentPackageDownloadProgress) => void,
signal?: AbortSignal
): Promise<AgentPackageInventory> {
return this.#runExclusive(architecture, async () => {
signal?.throwIfAborted()
this.#emit(onProgress, {
architecture,
phase: 'catalog',
completedBytes: 0,
totalBytes: null
})
const source = await this.#getUpdateSource()
signal?.throwIfAborted()
const catalog = await this.#loadCatalogAndRemember(
source,
signal
)
const entry = this.#select(
catalog,
architecture,
this.#desktopVersion
)
await this.#ensureInstalledCandidate(
architecture,
entry,
source,
onProgress,
signal
)
return this.getInventory()
})
}
async acquireInstallArchive(
architecture: AgentArchitecture,
expectedCandidate: VerifiedRemoteAgentInstallCandidate,
options: AcquireInstallArchiveOptions = {}
): Promise<AgentPackageArchiveLease> {
if (expectedCandidate.platform !== this.#platform) {
return this.forPlatform(expectedCandidate.platform).acquireInstallArchive(
architecture, expectedCandidate, options
)
}
return this.#runExclusive(
architecture,
async () => {
options.signal?.throwIfAborted()
const current = await this.#loadInstalled(architecture)
if (
current &&
await this.#recordMatchesRemoteCandidate(
current,
expectedCandidate
)
) {
return this.#archiveLease(
current,
expectedCandidate
)
}
const source = await this.#getUpdateSource()
options.signal?.throwIfAborted()
const catalog = await this.#loadCatalogAndRemember(
source,
options.signal
)
const entry = this.#select(
catalog,
architecture,
this.#desktopVersion
)
assertCandidateMatchesCatalogEntry(
expectedCandidate,
entry,
source
)
const record = await this.#ensureInstalledCandidate(
architecture,
entry,
source,
options.onProgress,
options.signal
)
options.signal?.throwIfAborted()
return this.#archiveLease(record, expectedCandidate)
}
)
}
async acquireGoodBuddyInstallArchive(
architecture: AgentArchitecture,
options: AcquireInstallArchiveOptions = {}
): Promise<AgentPackageArchiveLease> {
if (options.platform && options.platform !== this.#platform) {
return this.forPlatform(options.platform).acquireGoodBuddyInstallArchive(architecture, options)
}
return this.#runExclusive(
architecture,
async () => {
options.signal?.throwIfAborted()
const source = await this.#getUpdateSource()
options.signal?.throwIfAborted()
const record = await this.#loadInstalled(architecture)
const leaseRecord = async (
installed: InstalledRecord
): Promise<AgentPackageArchiveLease> => {
const status = await lstat(installed.archivePath)
return this.#archiveLease(
installed,
candidateFromVerifiedRecord(
installed,
source,
status.size
)
)
}
if (
record &&
this.#catalogState?.state !== 'available'
) {
return leaseRecord(record)
}
let entry: AgentPackageCatalogEntry
if (this.#catalogState?.state === 'available') {
entry = this.#select(
this.#catalogState.catalog,
architecture,
this.#desktopVersion
)
if (
record &&
compareSemanticVersions(
record.verified.descriptor.version,
entry.version
) >= 0
) {
return leaseRecord(record)
}
} else {
if (this.#catalogState?.state === 'unavailable') {
throw new Error(this.#catalogState.error)
}
const catalog = await this.#loadCatalogAndRemember(
source,
options.signal
)
entry = this.#select(
catalog,
architecture,
this.#desktopVersion
)
}
const candidate = candidateFromCatalogEntry(
entry,
source
)
const installed = await this.#ensureInstalledCandidate(
architecture,
entry,
source,
options.onProgress,
options.signal
)
return this.#archiveLease(installed, candidate)
}
)
}
async #archiveLease(
record: InstalledRecord,
candidate: VerifiedRemoteAgentInstallCandidate
): Promise<AgentPackageArchiveLease> {
const archiveStatus = await lstat(record.archivePath)
if (
!archiveStatus.isFile() ||
archiveStatus.isSymbolicLink() ||
archiveStatus.size !== candidate.size ||
record.archiveSha256 !== candidate.sha256
) {
throw new Error(
'Cached Agent archive does not match the signed installation candidate'
)
}
const node = record.verified.descriptor.files.find(
(file) => file.path === 'agent/node'
)
if (!node) {
throw new Error(
'Verified Agent package does not contain agent/node'
)
}
const release = this.#lease(record)
return {
candidate,
path: record.archivePath,
size: archiveStatus.size,
sha256: record.archiveSha256,
nodePath: join(
record.verified.rootDirectory,
...node.path.split('/')
),
nodeSize: node.size,
nodeSha256: node.sha256,
release
}
}
async #recordMatchesRemoteCandidate(
record: InstalledRecord,
candidate: VerifiedRemoteAgentInstallCandidate
): Promise<boolean> {
const descriptor = record.verified.descriptor
if (
descriptor.platform === candidate.platform &&
descriptor.architecture === candidate.architecture &&
descriptor.version === candidate.version &&
descriptor.minimumDesktopVersion ===
candidate.minimumDesktopVersion &&
descriptor.agentProtocol.major ===
candidate.agentProtocol.major &&
descriptor.agentProtocol.minor ===
candidate.agentProtocol.minor &&
isDeepStrictEqual(descriptor.additionalRuntimes ?? [], candidate.additionalRuntimes ?? []) &&
descriptor.remoteRuntime.runtimeId ===
candidate.remoteRuntime.runtimeId &&
descriptor.remoteRuntime.provider ===
candidate.remoteRuntime.provider &&
descriptor.remoteRuntime.version ===
candidate.remoteRuntime.version &&
descriptor.remoteRuntime.bundleDigest ===
candidate.remoteRuntime.bundleDigest &&
descriptor.remoteRuntime.protocol.major ===
candidate.remoteRuntime.protocol.major &&
descriptor.remoteRuntime.protocol.minor ===
candidate.remoteRuntime.protocol.minor &&
record.archiveSha256 === candidate.sha256
) {
try {
const status = await lstat(record.archivePath)
return (
status.isFile() &&
!status.isSymbolicLink() &&
status.size === candidate.size
)
} catch {
return false
}
}
return false
}
async #ensureInstalledCandidate(
architecture: AgentArchitecture,
entry: AgentPackageCatalogEntry,
source: UpdateSource,
onProgress?: (progress: AgentPackageDownloadProgress) => void,
signal?: AbortSignal
): Promise<InstalledRecord> {
signal?.throwIfAborted()
const current = await this.#loadInstalled(architecture)
const comparison = current
? compareSemanticVersions(
entry.version,
current.verified.descriptor.version
)
: 1
if (current && comparison < 0) {
throw new Error(
'Agent 发布目录不能降级已安装的兼容版本'
)
}
if (
current &&
comparison === 0 &&
await this.#recordMatchesCatalogEntry(current, entry)
) {
return current
}
const staging = await this.#createStaging(architecture)
const archivePath = join(staging, entry.archive)
try {
await this.#downloadArchive(
entry,
source,
archivePath,
onProgress,
signal
)
signal?.throwIfAborted()
this.#emit(onProgress, {
architecture,
phase: 'verifying',
completedBytes: entry.size,
totalBytes: entry.size
})
const installed = await this.#installArchive(
archivePath,
architecture,
staging,
entry.sha256
)
if (!await this.#recordMatchesCatalogEntry(
installed,
entry
)) {
throw new Error(
'Downloaded Agent package does not match its catalog entry'
)
}
signal?.throwIfAborted()
this.#emit(onProgress, {
architecture,
phase: 'installing',
completedBytes: entry.size,
totalBytes: entry.size
})
const published = await this.#publishInstalled(
installed,
architecture,
entry
)
if (!await this.#recordMatchesCatalogEntry(published, entry)) {
throw new Error(
'Published Agent package does not match its catalog entry'
)
}
return published
} finally {
await rm(staging, { recursive: true, force: true })
}
}
async #recordMatchesCatalogEntry(
record: InstalledRecord,
entry: AgentPackageCatalogEntry
): Promise<boolean> {
const descriptor = record.verified.descriptor
if (
descriptor.version !== entry.version ||
descriptor.minimumDesktopVersion !==
entry.minimumDesktopVersion ||
descriptor.platform !== entry.platform ||
descriptor.architecture !== entry.architecture ||
descriptor.agentProtocol.major !==
entry.agentProtocol.major ||
descriptor.agentProtocol.minor !==
entry.agentProtocol.minor ||
!isDeepStrictEqual(descriptor.additionalRuntimes ?? [], entry.additionalRuntimes ?? []) ||
descriptor.remoteRuntime.runtimeId !==
entry.remoteRuntime.runtimeId ||
descriptor.remoteRuntime.provider !==
entry.remoteRuntime.provider ||
descriptor.remoteRuntime.version !==
entry.remoteRuntime.version ||
descriptor.remoteRuntime.bundleDigest !==
entry.remoteRuntime.bundleDigest ||
descriptor.remoteRuntime.protocol.major !==
entry.remoteRuntime.protocol.major ||
descriptor.remoteRuntime.protocol.minor !==
entry.remoteRuntime.protocol.minor ||
record.archiveSha256 !== entry.sha256
) {
return false
}
try {
const status = await lstat(record.archivePath)
return (
status.isFile() &&
!status.isSymbolicLink() &&
status.size === entry.size
)
} catch {
return false
}
}
async importArchive(
archivePath: string
): Promise<AgentPackageInventory> {
const staging = await this.#createStaging('import')
try {
const copiedArchive = join(staging, basename(archivePath))
const archiveSha256 = await copyAndHashBoundedRegularFile(
resolve(archivePath),
copiedArchive,
MAXIMUM_PACKAGE_BYTES
)
const installed = await this.#installArchive(
copiedArchive,
undefined,
staging,
archiveSha256
)
const architecture =
installed.verified.descriptor.architecture
const manager = this.forPlatform(installed.verified.descriptor.platform)
return await manager.#runExclusive(
architecture,
async () => {
await manager.#publishInstalled(installed, architecture)
return manager.getInventory()
}
)
} finally {
await rm(staging, { recursive: true, force: true })
}
}
async exportArchive(
architecture: AgentArchitecture,
destinationPath: string
): Promise<void> {
const installed = await this.#requireInstalled(architecture)
const release = this.#lease(installed)
const destination = resolve(destinationPath)
const partial = `${destination}.${randomUUID()}.partial`
const backup = `${destination}.${randomUUID()}.backup`
try {
const copiedSha256 =
await copyAndHashBoundedRegularFile(
installed.archivePath,
partial,
MAXIMUM_PACKAGE_BYTES
)
if (copiedSha256 !== installed.archiveSha256) {
throw new Error(
'Cached Agent archive does not match its installed package'
)
}
} catch (error) {
await rm(partial, { force: true })
release()
throw error
}
let replacedExisting = false
let backupRestorationFailed = false
try {
try {
const existing = await lstat(destination)
if (!existing.isFile() || existing.isSymbolicLink()) {
throw new Error(
'Agent 包导出目标必须是普通文件或不存在的路径'
)
}
await rename(destination, backup)
replacedExisting = true
} catch (error) {
if (!isMissingPathError(error)) {
throw error
}
}
await rename(partial, destination)
} catch (error) {
await rm(partial, { force: true })
if (replacedExisting) {
try {
await rename(backup, destination)
replacedExisting = false
} catch (restoreError) {
backupRestorationFailed = true
throw new Error(
`Agent 包导出失败,原文件保留在 ${backup}`,
{ cause: restoreError }
)
}
}
throw error
} finally {
if (replacedExisting && !backupRestorationFailed) {
await rm(backup, { force: true }).catch(() => undefined)
}
release()
}
}
async getExportArchiveName(
architecture: AgentArchitecture
): Promise<string> {
const descriptor =
(await this.#requireInstalled(architecture))
.verified.descriptor
return agentPackageArchiveName(
descriptor.version,
descriptor.architecture,
descriptor.platform
)
}
async #requireInstalled(
architecture: AgentArchitecture
): Promise<InstalledRecord> {
const installed = await this.#loadInstalled(architecture)
if (!installed) {
throw new Error(
`未下载 Linux ${architecture} Agent 包,请先在“设置 > 平台功能 > 远程项目”下载或导入`
)
}
return installed
}
async #loadInstalled(
architecture: AgentArchitecture
): Promise<InstalledRecord | undefined> {
await this.#startupCleanup
let promise = this.#installed.get(architecture)
if (!promise) {
promise = this.#scanInstalled(architecture)
this.#installed.set(architecture, promise)
}
return promise
}
async #scanInstalled(
architecture: AgentArchitecture
): Promise<InstalledRecord | undefined> {
const architectureRoot = this.#architectureRoot(architecture)
let entries
try {
entries = await readdir(architectureRoot, {
withFileTypes: true
})
} catch (error) {
if (isMissingPathError(error)) {
return undefined
}
throw error
}
const verified: InstalledRecord[] = []
for (const entry of entries) {
if (
!entry.isDirectory() ||
entry.isSymbolicLink() ||
entry.name.startsWith('.')
) {
continue
}
const directory = join(architectureRoot, entry.name)
try {
const packageContent = await verifyExtractedAgentPackage({
rootDirectory: join(directory, 'content'),
architecture,
desktopVersion: this.#desktopVersion,
trustedRegistry: await this.#loadTrustedRegistry()
})
if (
packageContent.descriptor.version !== entry.name ||
packageContent.descriptor.platform !== this.#platform
) {
continue
}
const archivePath = join(directory, 'package.gbagent')
const archiveStat = await lstat(archivePath)
if (!archiveStat.isFile() || archiveStat.isSymbolicLink()) {
continue
}
const archiveSha256 = (
await readFile(join(directory, 'archive.sha256'), 'utf8')
).trim()
if (
!/^[a-f0-9]{64}$/u.test(archiveSha256) ||
await sha256File(archivePath) !== archiveSha256
) {
continue
}
verified.push({
directory,
archivePath,
archiveSha256,
verified: packageContent
})
} catch {
// Invalid cache entries remain visible as an invalid inventory state.
}
}
const [current, ...obsolete] = verified.sort((left, right) =>
compareSemanticVersions(
right.verified.descriptor.version,
left.verified.descriptor.version
)
)
for (const record of obsolete) {
await this.#removeWhenUnused(record.directory).catch(() => undefined)
}
return current
}
async #installArchive(
archivePath: string,
architecture: AgentArchitecture | undefined,
staging: string,
verifiedArchiveSha256?: string
): Promise<InstalledRecord> {
const content = join(staging, 'content')
const verified = await extractAndVerifyAgentPackage({
archivePath,
destinationDirectory: content,
architecture,
desktopVersion: this.#desktopVersion,
trustedRegistry: await this.#loadTrustedRegistry()
})
const canonicalArchive = join(staging, 'package.gbagent')
if (resolve(archivePath) !== resolve(canonicalArchive)) {
await rename(archivePath, canonicalArchive)
}
const archiveSha256 =
verifiedArchiveSha256 ?? await sha256File(canonicalArchive)
await writeFile(
join(staging, 'archive.sha256'),
`${archiveSha256}\n`,
{ encoding: 'utf8', flag: 'wx', mode: 0o600 }
)
return {
directory: staging,
archivePath: canonicalArchive,
archiveSha256,
verified
}
}
async #publishInstalled(
installed: InstalledRecord,
architecture: AgentArchitecture,
catalogEntry?: AgentPackageCatalogEntry
): Promise<InstalledRecord> {
if (
installed.verified.descriptor.platform !== this.#platform ||
installed.verified.descriptor.architecture !== architecture
) throw new Error('Agent package does not match the selected platform and architecture')
const destination = join(
this.#architectureRoot(architecture),
installed.verified.descriptor.version
)
await mkdir(this.#architectureRoot(architecture), {
recursive: true
})
const current = await this.#loadInstalled(architecture)
if (
current?.verified.descriptor.version ===
installed.verified.descriptor.version
) {
if (
current.verified.descriptor.contentDigest !==
installed.verified.descriptor.contentDigest ||
current.archiveSha256 !== installed.archiveSha256
) {
if (
!catalogEntry ||
!await this.#recordMatchesCatalogEntry(
installed,
catalogEntry
)
) {
throw new Error(
'Agent package version identity is immutable'
)
}
} else {
return current
}
}
const backup = join(
this.#architectureRoot(architecture),
`.backup-${randomUUID()}`
)
let replacedExisting = false
try {
try {
await lstat(destination)
if ((this.#leaseCounts.get(destination) ?? 0) > 0) {
throw new Error(
'Agent package version is currently in use'
)
}
await rename(destination, backup)
replacedExisting = true
} catch (error) {
if (!isMissingPathError(error)) {
throw error
}
}
await rename(installed.directory, destination)
} catch (error) {
if (replacedExisting) {
await rename(backup, destination).catch(() => undefined)
}
throw error
}
const published: InstalledRecord = {
...installed,
directory: destination,
archivePath: join(destination, 'package.gbagent'),
verified: {
...installed.verified,
rootDirectory: join(destination, 'content')
}
}
this.#installed.set(
architecture,
Promise.resolve(published)
)
if (replacedExisting) {
await rm(backup, { recursive: true, force: true }).catch(
() => undefined
)
}
await this.#removeOtherVersions(
architecture,
installed.verified.descriptor.version
)
return published
}
async #removeOtherVersions(
architecture: AgentArchitecture,
retainedVersion: string
): Promise<void> {
const root = this.#architectureRoot(architecture)
const entries = await readdir(root, { withFileTypes: true })
await Promise.all(
entries
.filter(
(entry) =>
entry.isDirectory() &&
!entry.isSymbolicLink() &&
!entry.name.startsWith('.') &&
entry.name !== retainedVersion
)
.map((entry) =>
this.#removeWhenUnused(join(root, entry.name))
)
)
}
async #downloadArchive(
entry: AgentPackageCatalogEntry,
source: UpdateSource,
destination: string,
onProgress?: (progress: AgentPackageDownloadProgress) => void,
signal?: AbortSignal
): Promise<void> {
assertCanonicalDownload(entry, source)
const controller = new AbortController()
const abortFromCaller = (): void => {
controller.abort(signal?.reason)
}
signal?.addEventListener('abort', abortFromCaller, {
once: true
})
if (signal?.aborted) {
abortFromCaller()
}
const timeout = setTimeout(
() => controller.abort(
new DOMException(
'Agent package download timed out',
'TimeoutError'
)
),
DOWNLOAD_TIMEOUT_MS
)
timeout.unref?.()
try {
const response = await fetchModelDownloadResponse({
transport: this.#fetch,
initialUrl: entry.downloads[source].url,
redirectHosts:
source === 'github' ? GITHUB_REDIRECT_HOSTS : [],
signal: controller.signal,
modelLabel: 'Agent 包'
})
if (!response.ok) {
await response.body?.cancel().catch(() => undefined)
throw new Error(
`Agent 包下载失败:HTTP ${response.status}`
)
}
if (!response.body) {
throw new Error('Agent 包下载响应没有内容')
}
const reader = response.body.getReader()
const hash = createHash('sha256')
let written = 0
try {
const declared = response.headers.get('content-length')
if (declared && Number(declared) !== entry.size) {
throw new Error('Agent 包下载大小与目录不一致')
}
const handle = await open(destination, 'wx')
try {
while (true) {
controller.signal.throwIfAborted()
const result = await reader.read()
if (result.done) {
break
}
written += result.value.byteLength
if (
written > entry.size ||
written > MAXIMUM_PACKAGE_BYTES
) {
throw new Error('Agent 包下载超出大小限制')
}
hash.update(result.value)
await writeAll(handle, result.value)
this.#emit(onProgress, {
architecture: entry.architecture,
phase: 'downloading',
completedBytes: written,
totalBytes: entry.size
})
}
await handle.sync()
} finally {
await handle.close()
}
} finally {
await reader.cancel().catch(() => undefined)
}
if (
written !== entry.size ||
hash.digest('hex') !== entry.sha256
) {
throw new Error('Agent 包下载完整性校验失败')
}
} finally {
clearTimeout(timeout)
signal?.removeEventListener('abort', abortFromCaller)
}
}
async #loadCatalog(
source: UpdateSource,
signal?: AbortSignal
): Promise<AgentPackageCatalog> {
signal?.throwIfAborted()
const registry = await this.#loadTrustedRegistry()
signal?.throwIfAborted()
const [catalogBytes, signatureBytes] =
source === 'mirror'
? await this.#fetchMirrorCatalog(signal)
: await this.#fetchGithubCatalog(signal)
const catalog = parseCanonicalCatalog(catalogBytes)
verifyCatalogSignature(
catalog,
catalogBytes,
signatureBytes,
registry
)
for (const entry of catalog.entries) {
assertCanonicalDownload(entry, 'github')
assertCanonicalDownload(entry, 'mirror')
}
return catalog
}
async #loadCatalogAndRemember(
source: UpdateSource,
signal?: AbortSignal
): Promise<AgentPackageCatalog> {
const checkedAt = this.#now().toISOString()
try {
const catalog = await this.#loadCatalog(source, signal)
this.#catalogState = {
state: 'available',
checkedAt,
catalog
}
return catalog
} catch (error) {
this.#catalogState = {
state: 'unavailable',
checkedAt,
error: boundedErrorMessage(error)
}
throw error
}
}
async #checkCatalog(): Promise<void> {
try {
await this.#loadCatalogAndRemember(
await this.#getUpdateSource()
)
} catch {
// The failed catalog state is already recorded. Local packages remain usable.
}
}
#latestVersion(
architecture: AgentArchitecture
): string | null {
if (this.#catalogState?.state !== 'available') {
return null
}
try {
return this.#select(
this.#catalogState.catalog,
architecture,
this.#desktopVersion
).version
} catch {
return null
}
}
#updateAvailable(
latestVersion: string | null,
installedVersion: string | null
): boolean {
return installedVersion !== null &&
latestVersion !== null &&
compareSemanticVersions(
latestVersion,
installedVersion
) > 0
}
#publicCatalogState():
AgentPackageInventory['catalog'] {
if (this.#catalogState === undefined) {
return {
state: 'not-checked',
checkedAt: null,
error: null
}
}
if (this.#catalogState.state === 'available') {
return {
state: 'available',
checkedAt: this.#catalogState.checkedAt,
error: null
}
}
return {
state: 'unavailable',
checkedAt: this.#catalogState.checkedAt,
error: this.#catalogState.error
}
}
async #fetchMirrorCatalog(
signal?: AbortSignal
): Promise<[Buffer, Buffer]> {
const pointerBytes = await this.#fetchBounded(
`${MIRROR_ROOT}latest.json`,
[],
signal,
true
)
const pointer = parseCanonicalMirrorPointer(pointerBytes)
return Promise.all([
this.#fetchBounded(
new URL(pointer.catalog, MIRROR_ROOT).href,
[],
signal,
true
),
this.#fetchBounded(
new URL(pointer.signature, MIRROR_ROOT).href,
[],
signal,
true
)
])
}
async *#githubReleases(signal?: AbortSignal): AsyncGenerator<unknown> {
// Keep the existing 100-release search window without downloading all
// historical release bodies and asset metadata in one response.
for (let page = 1; page <= 10; page += 1) {
signal?.throwIfAborted()
const releases = await this.#fetchJson(
`${GITHUB_RELEASES_API}&page=${page}`,
signal
)
if (!Array.isArray(releases)) {
throw new Error('GitHub Agent 发布目录无效')
}
yield* releases.slice(0, 10)
if (releases.length < 10) {
return
}
}
}
async #fetchGithubCatalog(
signal?: AbortSignal
): Promise<[Buffer, Buffer]> {
for await (const untrusted of this.#githubReleases(signal)) {
if (
typeof untrusted !== 'object' ||
untrusted === null ||
!('tag_name' in untrusted) ||
typeof untrusted.tag_name !== 'string' ||
!/^agent-v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(
untrusted.tag_name
) ||
!('draft' in untrusted) ||
untrusted.draft !== false ||
!('prerelease' in untrusted) ||
untrusted.prerelease !== false ||
!('assets' in untrusted) ||
!Array.isArray(untrusted.assets)
) {
continue
}
const assets = new Map<string, string>()
for (const asset of untrusted.assets) {
if (
typeof asset === 'object' &&
asset !== null &&
'name' in asset &&
typeof asset.name === 'string' &&
'browser_download_url' in asset &&
typeof asset.browser_download_url === 'string'
) {
assets.set(asset.name, asset.browser_download_url)
}
}
const catalogUrl = assets.get(CATALOG_NAME)
const signatureUrl = assets.get(CATALOG_SIGNATURE_NAME)
if (catalogUrl && signatureUrl) {
const releaseRoot =
`${GITHUB_RELEASE_ROOT}${untrusted.tag_name}/`
if (
catalogUrl !== `${releaseRoot}${CATALOG_NAME}` ||
signatureUrl !==
`${releaseRoot}${CATALOG_SIGNATURE_NAME}`
) {
continue
}
return Promise.all([
this.#fetchBounded(
catalogUrl,
GITHUB_REDIRECT_HOSTS,
signal
),
this.#fetchBounded(
signatureUrl,
GITHUB_REDIRECT_HOSTS,
signal
)
])
}
}
throw new Error('GitHub 中没有可用的 Agent 发布目录')
}
async #fetchJson(
url: string,
signal?: AbortSignal
): Promise<unknown> {
const bytes = await this.#fetchBounded(
url,
[],
signal,
true
)
return JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
) as unknown
}
async #fetchBounded(
url: string,
redirectHosts: readonly string[] = [],
signal?: AbortSignal,
rejectRedirects = false
): Promise<Buffer> {
const controller = new AbortController()
const abortFromCaller = (): void => {
controller.abort(signal?.reason)
}
signal?.addEventListener('abort', abortFromCaller, {
once: true
})
if (signal?.aborted) {
abortFromCaller()
}
const timeout = setTimeout(
() => controller.abort(
new DOMException(
'Agent catalog request timed out',
'TimeoutError'
)
),
30_000
)
timeout.unref?.()
try {
const response = rejectRedirects
? await this.#fetchExactResponse(url, controller.signal)
: await fetchModelDownloadResponse({
transport: this.#fetch,
initialUrl: url,
redirectHosts,
signal: controller.signal,
modelLabel: 'Agent 发布目录'
})
if (!response.ok) {
await response.body?.cancel().catch(() => undefined)
throw new Error(
`Agent 发布目录请求失败:HTTP ${response.status}`
)
}
if (!response.body) {
throw new Error('Agent 发布目录响应没有内容')
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
try {
while (true) {
const result = await reader.read()
if (result.done) {
break
}
total += result.value.byteLength
if (total > MAXIMUM_CATALOG_BYTES) {
throw new Error('Agent 发布目录超出大小限制')
}
chunks.push(result.value)
}
} finally {
await reader.cancel().catch(() => undefined)
}
return Buffer.concat(chunks, total)
} finally {
clearTimeout(timeout)
signal?.removeEventListener('abort', abortFromCaller)
}
}
async #fetchExactResponse(
url: string,
signal: AbortSignal
): Promise<Response> {
validateSecureDownloadUrl(url, 'Agent 发布目录')
const response = await this.#fetch(url, {
method: 'GET',
redirect: 'manual',
credentials: 'omit',
cache: 'no-store',
signal
})
if (isDownloadRedirectStatus(response.status)) {
await response.body?.cancel().catch(() => undefined)
throw new Error('Agent 发布目录不允许重定向')
}
return response
}
async #resolveRemoteDownloadUrls(
entry: AgentPackageCatalogEntry,
source: UpdateSource,
signal?: AbortSignal
): Promise<readonly string[]> {
const controller = new AbortController()
const abortFromCaller = (): void => {
controller.abort(signal?.reason)
}
signal?.addEventListener('abort', abortFromCaller, {
once: true
})
if (signal?.aborted) {
abortFromCaller()
}
const timeout = setTimeout(
() => controller.abort(
new DOMException(
'Agent remote candidate resolution timed out',
'TimeoutError'
)
),
REMOTE_CANDIDATE_TIMEOUT_MS
)
timeout.unref?.()
const urls: string[] = []
let current = validateSecureDownloadUrl(
entry.downloads[source].url,
'Agent 包'
)
try {
for (
let redirectCount = 0;
redirectCount <= MAXIMUM_REMOTE_REDIRECTS;
redirectCount += 1
) {
controller.signal.throwIfAborted()
const currentUrl = current.href
if (
Buffer.byteLength(currentUrl, 'utf8') >
MAXIMUM_REMOTE_URL_BYTES
) {
throw new Error('Agent 包下载地址超出长度限制')
}
urls.push(currentUrl)
let response = await this.#fetch(current, {
method: 'HEAD',
redirect: 'manual',
credentials: 'omit',
cache: 'no-store',
signal: controller.signal
})
if (response.status === 405 || response.status === 501) {
await response.body?.cancel().catch(() => undefined)
response = await this.#fetch(current, {
method: 'GET',
redirect: 'manual',
credentials: 'omit',
cache: 'no-store',
signal: controller.signal
})
}
if (!isDownloadRedirectStatus(response.status)) {
if (!response.ok) {
await response.body?.cancel().catch(() => undefined)
throw new Error(
`Agent 包下载地址检查失败:HTTP ${response.status}`
)
}
const declared = response.headers.get('content-length')
await response.body?.cancel().catch(() => undefined)
if (
declared !== null &&
(!/^(?:0|[1-9]\d*)$/u.test(declared) ||
Number(declared) !== entry.size)
) {
throw new Error('Agent 包下载大小与目录不一致')
}
return Object.freeze([...urls])
}
if (
source === 'mirror' ||
redirectCount === MAXIMUM_REMOTE_REDIRECTS
) {
await response.body?.cancel().catch(() => undefined)
throw new Error(
source === 'mirror'
? 'Agent 镜像下载地址不允许重定向'
: 'Agent 包下载重定向次数过多'
)
}
const location = response.headers.get('location')
await response.body?.cancel().catch(() => undefined)
if (!location) {
throw new Error('Agent 包下载重定向缺少地址')
}
const next = validateSecureDownloadUrl(
new URL(location, current).href,
'Agent 包'
)
if (
!GITHUB_REDIRECT_HOSTS.includes(
next.hostname as (typeof GITHUB_REDIRECT_HOSTS)[number]
)
) {
throw new Error('Agent 包下载重定向到不受信任的主机')
}
current = next
}
throw new Error('Agent 包下载重定向次数过多')
} finally {
clearTimeout(timeout)
signal?.removeEventListener('abort', abortFromCaller)
}
}
async #hasEntries(
architecture: AgentArchitecture
): Promise<boolean> {
try {
return (
await readdir(this.#architectureRoot(architecture))
).some((name) => !name.startsWith('.'))
} catch (error) {
if (isMissingPathError(error)) {
return false
}
throw error
}
}
async #createStaging(
architecture: AgentArchitecture | 'import'
): Promise<string> {
await this.#startupCleanup
await mkdir(this.#rootDirectory, { recursive: true })
const path = join(
this.#rootDirectory,
`.stage-${this.#platform}-${architecture}-${randomUUID()}`
)
await mkdir(path)
return path
}
async #cleanupInterruptedArtifacts(): Promise<void> {
await this.#removeOwnedTemporaryDirectories(
this.#rootDirectory,
new RegExp(`^\\.stage-${this.#platform}-(?:x64|arm64|import)-[0-9a-f-]{36}$`, 'u')
)
await Promise.all(
agentArchitectureSchema.options.map((architecture) =>
this.#removeOwnedTemporaryDirectories(
this.#architectureRoot(architecture),
/^\.backup-[0-9a-f-]{36}$/u
)
)
)
}
async #removeOwnedTemporaryDirectories(
root: string,
pattern: RegExp
): Promise<void> {
let entries
try {
entries = await readdir(root, { withFileTypes: true })
} catch (error) {
if (isMissingPathError(error)) {
return
}
throw error
}
await Promise.all(
entries
.filter(
(entry) =>
entry.isDirectory() &&
!entry.isSymbolicLink() &&
pattern.test(entry.name)
)
.slice(0, 512)
.map((entry) =>
rm(join(root, entry.name), {
recursive: true,
force: true
}).catch(() => undefined)
)
)
}
#architectureRoot(
architecture: AgentArchitecture
): string {
return join(this.#rootDirectory, `${this.#platform}-${architecture}`)
}
#loadTrustedRegistry(): Promise<AgentReleaseKeyRegistry> {
this.#trustedRegistry ??= readAgentReleaseKeyRegistry(
this.#keyRegistryPath
)
return this.#trustedRegistry
}
#lease(record: InstalledRecord): () => void {
const key = record.directory
this.#leaseCounts.set(
key,
(this.#leaseCounts.get(key) ?? 0) + 1
)
let active = true
return () => {
if (!active) {
return
}
active = false
const remaining = (this.#leaseCounts.get(key) ?? 1) - 1
if (remaining > 0) {
this.#leaseCounts.set(key, remaining)
return
}
this.#leaseCounts.delete(key)
if (this.#pendingRemovals.has(key)) {
void this.#removeWhenUnused(key).catch(() => undefined)
}
}
}
async #removeWhenUnused(directory: string): Promise<void> {
this.#pendingRemovals.add(directory)
if ((this.#leaseCounts.get(directory) ?? 0) > 0) {
return
}
await fs.rm(directory, { recursive: true, force: true })
this.#pendingRemovals.delete(directory)
}
#runExclusive<T>(
architecture: AgentArchitecture,
operation: () => Promise<T>
): Promise<T> {
const existing = this.#operations.get(architecture)
if (existing) {
return existing.then(
() => this.#runExclusive(architecture, operation),
() => this.#runExclusive(architecture, operation)
)
}
const promise: Promise<T> = operation().finally(() => {
if (this.#operations.get(architecture) === promise) {
this.#operations.delete(architecture)
}
})
this.#operations.set(architecture, promise)
return promise
}
#emit(
observer:
| ((progress: AgentPackageDownloadProgress) => void)
| undefined,
progress: Omit<AgentPackageDownloadProgress, 'platform'>
): void {
try {
observer?.(agentPackageDownloadProgressSchema.parse({ ...progress, platform: this.#platform }))
} catch {
// Progress observers cannot alter the package operation.
}
}
}
export function selectLatestCompatibleEntry(
catalog: AgentPackageCatalog,
architecture: AgentArchitecture,
desktopVersion: string,
platform: AgentPlatform = 'linux'
): AgentPackageCatalogEntry {
const entry = catalog.entries
.filter(
(candidate) =>
candidate.platform === platform &&
candidate.architecture === architecture &&
compareSemanticVersions(
desktopVersion,
candidate.minimumDesktopVersion
) >= 0 &&
candidate.agentProtocol.major ===
AGENT_PROTOCOL_VERSION.major &&
candidate.agentProtocol.minor <=
AGENT_PROTOCOL_VERSION.minor
)
.sort((left, right) =>
compareSemanticVersions(right.version, left.version)
)[0]
if (!entry) {
throw new NoCompatibleAgentPackageError(
`没有与当前 GoodBuddy 兼容的 Linux ${architecture} Agent 包`
)
}
return entry
}
class NoCompatibleAgentPackageError extends Error {
constructor(message: string) {
super(message)
this.name = 'NoCompatibleAgentPackageError'
}
}
function assertCanonicalDownload(
entry: AgentPackageCatalogEntry,
source: UpdateSource
): void {
const expected =
source === 'mirror'
? new URL(
`v${entry.version}/${entry.archive}`,
MIRROR_ROOT
).href
: new URL(
`agent-v${entry.version}/${entry.archive}`,
GITHUB_RELEASE_ROOT
).href
if (entry.downloads[source].url !== expected) {
throw new Error('Agent 包下载地址不受信任')
}
}
function assertCandidateMatchesCatalogEntry(
candidate: VerifiedRemoteAgentInstallCandidate,
entry: AgentPackageCatalogEntry,
source: UpdateSource
): void {
const canonicalUrl = entry.downloads[source].url
if (
candidate.source !== source ||
candidate.platform !== entry.platform ||
candidate.architecture !== entry.architecture ||
candidate.version !== entry.version ||
candidate.minimumDesktopVersion !==
entry.minimumDesktopVersion ||
candidate.agentProtocol.major !==
entry.agentProtocol.major ||
candidate.agentProtocol.minor !==
entry.agentProtocol.minor ||
!isDeepStrictEqual(candidate.additionalRuntimes ?? [], entry.additionalRuntimes ?? []) ||
candidate.remoteRuntime.runtimeId !==
entry.remoteRuntime.runtimeId ||
candidate.remoteRuntime.provider !==
entry.remoteRuntime.provider ||
candidate.remoteRuntime.version !==
entry.remoteRuntime.version ||
candidate.remoteRuntime.bundleDigest !==
entry.remoteRuntime.bundleDigest ||
candidate.remoteRuntime.protocol.major !==
entry.remoteRuntime.protocol.major ||
candidate.remoteRuntime.protocol.minor !==
entry.remoteRuntime.protocol.minor ||
candidate.archive !== entry.archive ||
candidate.size !== entry.size ||
candidate.sha256 !== entry.sha256 ||
candidate.urls.length < 1 ||
candidate.urls[0] !== canonicalUrl
) {
throw new Error(
'Agent install candidate does not match the current signed catalog'
)
}
}
function parseCanonicalCatalog(bytes: Buffer): AgentPackageCatalog {
const catalog = agentPackageCatalogSchema.parse(
JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
)
)
if (
!Buffer.from(
`${JSON.stringify(catalog, null, 2)}\n`,
'utf8'
).equals(bytes)
) {
throw new Error('Agent 发布目录不是规范格式')
}
return catalog
}
function parseCanonicalMirrorPointer(bytes: Buffer): {
catalog: string
signature: string
} {
const value = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
) as unknown
if (
typeof value !== 'object' ||
value === null ||
Object.keys(value).sort().join(',') !==
'catalog,formatVersion,signature,version' ||
!('formatVersion' in value) ||
value.formatVersion !== 1 ||
!('version' in value) ||
typeof value.version !== 'string' ||
!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/u.test(
value.version
) ||
!('catalog' in value) ||
typeof value.catalog !== 'string' ||
value.catalog !==
`v${value.version}/${CATALOG_NAME}` ||
!('signature' in value) ||
typeof value.signature !== 'string' ||
value.signature !==
`v${value.version}/${CATALOG_SIGNATURE_NAME}` ||
!Buffer.from(
`${JSON.stringify(value, null, 2)}\n`,
'utf8'
).equals(bytes)
) {
throw new Error('Agent 镜像目录指针无效')
}
return {
catalog: value.catalog,
signature: value.signature
}
}
function verifyCatalogSignature(
catalog: AgentPackageCatalog,
bytes: Buffer,
signatureText: Buffer,
registry: AgentReleaseKeyRegistry
): void {
const key = registry.keys.find(
(candidate) =>
candidate.keyId === catalog.signingKeyId &&
candidate.environment === 'production'
)
if (
!key ||
registry.revocations.some(
(revocation) => revocation.keyId === key.keyId
)
) {
throw new Error('Agent 发布目录签名密钥不受信任')
}
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([CATALOG_SIGNATURE_DOMAIN, bytes]),
publicKey,
signature
)
) {
throw new Error('Agent 发布目录签名校验失败')
}
}
function boundedErrorMessage(error: unknown): string {
const message = error instanceof Error
? error.message
: String(error)
return (
message.trim().slice(0, 2_000) ||
'Agent 发布目录检查失败'
)
}
async function copyAndHashBoundedRegularFile(
sourcePath: string,
destinationPath: string,
maximumBytes: number
): Promise<string> {
const sourceStatus = await lstat(sourcePath)
if (
!sourceStatus.isFile() ||
sourceStatus.isSymbolicLink() ||
sourceStatus.size <= 0 ||
sourceStatus.size > maximumBytes
) {
throw new Error('Agent package must be a bounded regular file')
}
const source = await open(
sourcePath,
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
)
let destination:
| Awaited<ReturnType<typeof open>>
| undefined
const hash = createHash('sha256')
const buffer = Buffer.allocUnsafe(1024 * 1024)
let copied = 0
try {
const openedStatus = await source.stat()
if (
!openedStatus.isFile() ||
openedStatus.dev !== sourceStatus.dev ||
openedStatus.ino !== sourceStatus.ino ||
openedStatus.size !== sourceStatus.size
) {
throw new Error(
'Agent package changed while it was being opened'
)
}
destination = await open(destinationPath, 'wx', 0o600)
while (true) {
const { bytesRead } = await source.read(
buffer,
0,
buffer.length
)
if (bytesRead === 0) {
break
}
copied += bytesRead
if (copied > maximumBytes) {
throw new Error('Agent package exceeds its size limit')
}
const chunk = buffer.subarray(0, bytesRead)
hash.update(chunk)
await writeAll(destination, chunk)
}
if (copied !== sourceStatus.size) {
throw new Error('Agent package changed while it was copied')
}
await destination.sync()
} finally {
await Promise.allSettled([
source.close(),
destination?.close() ?? Promise.resolve()
])
}
return hash.digest('hex')
}
async function sha256File(filePath: string): Promise<string> {
const hash = createHash('sha256')
const handle = await open(filePath, 'r')
const buffer = Buffer.allocUnsafe(1024 * 1024)
try {
while (true) {
const { bytesRead } = await handle.read(
buffer,
0,
buffer.length
)
if (bytesRead === 0) {
break
}
hash.update(buffer.subarray(0, bytesRead))
}
} finally {
await handle.close()
}
return hash.digest('hex')
}
async function writeAll(
handle: Awaited<ReturnType<typeof open>>,
bytes: Uint8Array
): Promise<void> {
let offset = 0
while (offset < bytes.byteLength) {
const { bytesWritten } = await handle.write(
bytes,
offset,
bytes.byteLength - offset
)
if (bytesWritten <= 0) {
throw new Error('Agent 包下载写入未取得进展')
}
offset += bytesWritten
}
}