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.
2241 lines
63 KiB
TypeScript
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
|
|
}
|
|
}
|