fix: make Host environment updates fast and recoverable
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
Published Host updates could fail while the desktop reverified Runtime against its bundled lock and repeatedly traversed remote payloads. Host preparation now verifies and extracts once, adopts Agent and Runtime on one pinned connection, and uses registered metadata for normal health, capability, and prompt paths. Interrupted commits and adoption retain bounded recovery and rollback state. Desktop and Agent versions are 0.11.8. Remote Projects remain a technical preview; the Agent targets Linux x64 and arm64, with no LoongArch preview. Release note: 修复正式环境 Host 更新失败并移除重复远端包体扫描;中断恢复与清理失败不再破坏已健康环境。
This commit is contained in:
@@ -192,11 +192,13 @@ describe('Agent CLI fixed command contract', () => {
|
||||
state: 'ready' as const,
|
||||
installationId
|
||||
}))
|
||||
const verifyInstallation = vi.fn(async () => verified)
|
||||
const dependencies: AgentCliDependencies = {
|
||||
installationPaths: () => installationPaths,
|
||||
releaseKeyRegistry,
|
||||
installationRegistry,
|
||||
verifyInstallation: async () => verified,
|
||||
verifyInstallation,
|
||||
loadRegisteredInstallation: async () => verified,
|
||||
createLifecycle: (() => ({
|
||||
status,
|
||||
bootstrap
|
||||
@@ -227,6 +229,21 @@ describe('Agent CLI fixed command contract', () => {
|
||||
).toBe(0)
|
||||
expect(status).toHaveBeenCalledOnce()
|
||||
expect(bootstrap).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
installationRegistry.snapshot().current?.installationId
|
||||
).toBe(installationId)
|
||||
|
||||
expect(
|
||||
await runAgentCli(
|
||||
buildFixedAgentCliArgv(installationId, {
|
||||
kind: 'lifecycle',
|
||||
action: 'adopt'
|
||||
}),
|
||||
{ ...dependencies, io: cliIo() }
|
||||
)
|
||||
).toBe(0)
|
||||
expect(verifyInstallation).toHaveBeenCalledOnce()
|
||||
expect(bootstrap).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('dispatches generated attach argv using only derived managed paths', async () => {
|
||||
@@ -469,6 +486,31 @@ describe('Agent CLI fixed command contract', () => {
|
||||
architecture: 'x64',
|
||||
runtimeVersion: '1.18.9'
|
||||
})
|
||||
|
||||
expect(
|
||||
await runAgentCli(
|
||||
buildFixedAgentCliArgv(
|
||||
verifyAgentInstallationId('install-1'),
|
||||
{
|
||||
kind: 'runtime-activate',
|
||||
runtimeId: 'opencode',
|
||||
bundleDigest,
|
||||
architecture: 'x64',
|
||||
forceVerification: true
|
||||
}
|
||||
),
|
||||
{
|
||||
runtimePaths: () => paths,
|
||||
runtimeReleaseKeyRegistry: releaseKeyRegistry,
|
||||
runtimeLock: {} as RemoteRuntimeLock,
|
||||
runtimeRegistry: registry,
|
||||
verifyRuntime,
|
||||
currentArchitecture: () => 'x64',
|
||||
io: cliIo()
|
||||
}
|
||||
)
|
||||
).toBe(0)
|
||||
expect(verifyRuntime).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('derives Runtime paths and rejects activation argv injection', async () => {
|
||||
|
||||
+183
-40
@@ -39,6 +39,7 @@ import {
|
||||
RuntimeBundleRegistry
|
||||
} from './runtime-bundle-registry'
|
||||
import {
|
||||
loadRegisteredRuntimeBundle,
|
||||
readRemoteRuntimeLock,
|
||||
verifyRuntimeBundle,
|
||||
type VerifiedRuntimeBundle
|
||||
@@ -111,6 +112,7 @@ export type ManagedRuntimePaths = {
|
||||
}
|
||||
|
||||
const LIFECYCLE_ACTIONS = new Set([
|
||||
'adopt',
|
||||
'bootstrap',
|
||||
'status',
|
||||
'health',
|
||||
@@ -225,7 +227,8 @@ async function runRuntime(
|
||||
'installation-id',
|
||||
'runtime-id',
|
||||
'bundle-digest',
|
||||
'architecture'
|
||||
'architecture',
|
||||
'force-verification'
|
||||
])
|
||||
requireOptions(options, [
|
||||
'installation-id',
|
||||
@@ -243,6 +246,16 @@ async function runRuntime(
|
||||
const architecture = validateRuntimeArchitecture(
|
||||
options.architecture!
|
||||
)
|
||||
const forceVerification =
|
||||
options['force-verification'] === 'true'
|
||||
if (
|
||||
options['force-verification'] !== undefined &&
|
||||
!forceVerification
|
||||
) {
|
||||
throw new Error(
|
||||
'Invalid Runtime force-verification option'
|
||||
)
|
||||
}
|
||||
const hostArchitecture =
|
||||
dependencies.currentArchitecture?.() ??
|
||||
currentAgentArchitecture()
|
||||
@@ -271,15 +284,30 @@ async function runRuntime(
|
||||
runtimeRoot: paths.runtimeRoot,
|
||||
storagePath: paths.registryPath
|
||||
})
|
||||
const verified = await (
|
||||
dependencies.verifyRuntime ?? verifyRuntimeBundle
|
||||
)(paths.bundleDirectory, {
|
||||
const verificationOptions = {
|
||||
architecture,
|
||||
releaseKeyRegistry,
|
||||
runtimeLock,
|
||||
verificationEnvironment:
|
||||
dependencies.runtimeVerificationEnvironment ?? 'production'
|
||||
})
|
||||
} as const
|
||||
const registered = registry.find(
|
||||
runtimeId,
|
||||
bundleDigest,
|
||||
architecture
|
||||
)
|
||||
const verified =
|
||||
registered === undefined || forceVerification
|
||||
? await (
|
||||
dependencies.verifyRuntime ?? verifyRuntimeBundle
|
||||
)(paths.bundleDirectory, verificationOptions)
|
||||
: await loadRegisteredRuntimeBundle(
|
||||
registered.bundleDirectory,
|
||||
{
|
||||
...verificationOptions,
|
||||
registered: registered.entry
|
||||
}
|
||||
)
|
||||
assertActivatedRuntimeIdentity(
|
||||
verified,
|
||||
runtimeId,
|
||||
@@ -352,7 +380,8 @@ async function runDaemon(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies,
|
||||
'registered'
|
||||
'registered',
|
||||
verified
|
||||
).recordCurrentDaemonReady(daemon.status().daemonBootId)
|
||||
await (dependencies.waitForShutdown?.() ?? waitForShutdownSignal())
|
||||
} finally {
|
||||
@@ -374,7 +403,7 @@ async function runAttach(
|
||||
installationId,
|
||||
dependencies
|
||||
)
|
||||
await loadManagedInstallation(
|
||||
const { verified } = await loadManagedInstallation(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies
|
||||
@@ -399,7 +428,8 @@ async function runAttach(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies,
|
||||
'registered'
|
||||
'registered',
|
||||
verified
|
||||
).bootstrap()
|
||||
}
|
||||
})
|
||||
@@ -451,10 +481,17 @@ async function runDoctor(
|
||||
options['installation-id']!
|
||||
)
|
||||
const paths = resolveInstallationPaths(installationId, dependencies)
|
||||
const status = await createDetachedLifecycle(
|
||||
const { verified } = await loadManagedInstallation(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies
|
||||
)
|
||||
const status = await createDetachedLifecycle(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies,
|
||||
'registered',
|
||||
verified
|
||||
).status()
|
||||
io.output.write(`${JSON.stringify({
|
||||
platform: process.platform,
|
||||
@@ -479,48 +516,150 @@ async function runLifecycle(
|
||||
options['installation-id']!
|
||||
)
|
||||
const paths = resolveInstallationPaths(installationId, dependencies)
|
||||
const { verified, registry } = await verifyManagedInstallation(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies
|
||||
)
|
||||
const lifecycle = createDetachedLifecycle(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies
|
||||
)
|
||||
switch (action) {
|
||||
case 'bootstrap': {
|
||||
registry.stageCandidate(verified)
|
||||
prepareManagedSocketDirectory(paths)
|
||||
const status = await lifecycle.bootstrap()
|
||||
io.output.write(`${JSON.stringify(status)}\n`)
|
||||
break
|
||||
}
|
||||
case 'status': {
|
||||
registry.assertVerifiedRole(verified, ['current', 'candidate'])
|
||||
io.output.write(`${JSON.stringify(await lifecycle.status())}\n`)
|
||||
break
|
||||
}
|
||||
case 'health': {
|
||||
registry.assertVerifiedRole(verified, ['current', 'candidate'])
|
||||
const status = await lifecycle.health()
|
||||
if (
|
||||
registry.snapshot().candidate?.installationId ===
|
||||
case 'adopt': {
|
||||
const { verified, registry } =
|
||||
await verifyManagedInstallation(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies
|
||||
)
|
||||
const alreadyCurrent =
|
||||
registry.snapshot().current?.installationId ===
|
||||
installationId
|
||||
) {
|
||||
if (!alreadyCurrent) {
|
||||
registry.stageCandidate(verified)
|
||||
}
|
||||
prepareManagedSocketDirectory(paths)
|
||||
const status = await createDetachedLifecycle(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies,
|
||||
'verified',
|
||||
verified
|
||||
).bootstrap()
|
||||
if (!alreadyCurrent) {
|
||||
registry.promoteCandidate(installationId)
|
||||
}
|
||||
io.output.write(`${JSON.stringify(status)}\n`)
|
||||
break
|
||||
}
|
||||
case 'bootstrap': {
|
||||
const agentRoot = dirname(
|
||||
dirname(dirname(paths.executablePath))
|
||||
)
|
||||
const registry =
|
||||
dependencies.installationRegistry ??
|
||||
new InstallationRegistry({
|
||||
storagePath: resolve(agentRoot, 'registry.json')
|
||||
})
|
||||
const snapshot = registry.snapshot()
|
||||
const current = snapshot.current?.installationId ===
|
||||
installationId
|
||||
? snapshot.current
|
||||
: undefined
|
||||
const candidate = snapshot.candidate?.installationId ===
|
||||
installationId
|
||||
? snapshot.candidate
|
||||
: undefined
|
||||
const registered = current ?? candidate
|
||||
const isNewInstallation = registered === undefined
|
||||
const verified = isNewInstallation
|
||||
? (
|
||||
await verifyManagedInstallation(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies
|
||||
)
|
||||
).verified
|
||||
: await (
|
||||
dependencies.loadRegisteredInstallation ??
|
||||
loadRegisteredAgentBundle
|
||||
)(dirname(paths.executablePath), {
|
||||
installationId,
|
||||
architecture: currentAgentArchitecture(),
|
||||
registered
|
||||
})
|
||||
if (isNewInstallation) {
|
||||
registry.stageCandidate(verified)
|
||||
}
|
||||
prepareManagedSocketDirectory(paths)
|
||||
const lifecycle = createDetachedLifecycle(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies,
|
||||
'verified',
|
||||
verified
|
||||
)
|
||||
const status = await lifecycle.bootstrap()
|
||||
if (current === undefined) {
|
||||
registry.promoteCandidate(installationId)
|
||||
}
|
||||
io.output.write(`${JSON.stringify(status)}\n`)
|
||||
break
|
||||
}
|
||||
case 'status': {
|
||||
const { verified } = await loadManagedInstallation(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies
|
||||
)
|
||||
const lifecycle = createDetachedLifecycle(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies,
|
||||
'registered',
|
||||
verified
|
||||
)
|
||||
io.output.write(`${JSON.stringify(await lifecycle.status())}\n`)
|
||||
break
|
||||
}
|
||||
case 'health': {
|
||||
const { verified } = await loadManagedInstallation(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies
|
||||
)
|
||||
const lifecycle = createDetachedLifecycle(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies,
|
||||
'registered',
|
||||
verified
|
||||
)
|
||||
const status = await lifecycle.health()
|
||||
io.output.write(`${JSON.stringify(status)}\n`)
|
||||
break
|
||||
}
|
||||
case 'stop': {
|
||||
registry.assertVerifiedRole(verified, ['current', 'candidate'])
|
||||
const { verified } = await loadManagedInstallation(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies
|
||||
)
|
||||
const lifecycle = createDetachedLifecycle(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies,
|
||||
'registered',
|
||||
verified
|
||||
)
|
||||
io.output.write(`${JSON.stringify(await lifecycle.stop())}\n`)
|
||||
break
|
||||
}
|
||||
case 'retire': {
|
||||
registry.assertVerifiedRole(verified, ['current', 'candidate'])
|
||||
const { verified } = await loadManagedInstallation(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies
|
||||
)
|
||||
const lifecycle = createDetachedLifecycle(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies,
|
||||
'registered',
|
||||
verified
|
||||
)
|
||||
io.output.write(`${JSON.stringify(await lifecycle.retire())}\n`)
|
||||
break
|
||||
}
|
||||
@@ -531,7 +670,8 @@ function createDetachedLifecycle(
|
||||
installationId: string,
|
||||
paths: ManagedInstallationPaths,
|
||||
dependencies: AgentCliDependencies,
|
||||
identitySource: 'verified' | 'registered' = 'verified'
|
||||
identitySource: 'verified' | 'registered' = 'verified',
|
||||
verifiedInstallation?: VerifiedInstalledAgentBundle
|
||||
): DetachedAgentLifecycle {
|
||||
const options: DetachedAgentLifecycleOptions = {
|
||||
installationId,
|
||||
@@ -539,6 +679,9 @@ function createDetachedLifecycle(
|
||||
stateDirectory: paths.stateDirectory,
|
||||
socketPath: paths.socketPath,
|
||||
verifyInstallation: async () => {
|
||||
if (verifiedInstallation !== undefined) {
|
||||
return verifiedInstallation
|
||||
}
|
||||
if (identitySource === 'registered') {
|
||||
return (
|
||||
await loadManagedInstallation(
|
||||
|
||||
@@ -116,7 +116,8 @@ describe('Agent installation registry', () => {
|
||||
installationId: 'install-a',
|
||||
agentVersion: '0.11.0',
|
||||
manifestSha256: 'a'.repeat(64),
|
||||
arch: 'x64'
|
||||
arch: 'x64',
|
||||
protocol: { major: 1, minor: 0 }
|
||||
}
|
||||
})
|
||||
expect(readFileSync(storagePath, 'utf8')).toBe(legacyBytes)
|
||||
|
||||
@@ -221,7 +221,8 @@ function entryFromVerified(
|
||||
installationId: verified.installationId,
|
||||
agentVersion: verified.manifest.agentVersion,
|
||||
manifestSha256: verified.manifestSha256,
|
||||
arch: verified.manifest.arch
|
||||
arch: verified.manifest.arch,
|
||||
protocol: verified.manifest.protocol
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -310,7 +310,16 @@ async function loadRegisteredAgentBundleUnchecked(
|
||||
manifestBytes
|
||||
) ||
|
||||
manifest.agentVersion !== registered.agentVersion ||
|
||||
manifest.arch !== registered.arch
|
||||
manifest.arch !== registered.arch ||
|
||||
(
|
||||
registered.protocol !== undefined &&
|
||||
(
|
||||
manifest.protocol.major !==
|
||||
registered.protocol.major ||
|
||||
manifest.protocol.minor !==
|
||||
registered.protocol.minor
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent manifest does not match the Host-managed registry'
|
||||
|
||||
@@ -268,7 +268,7 @@ function harness(input: {
|
||||
const backend = new RuntimeAcpBackend({
|
||||
journal,
|
||||
resolveRuntimeBundle: vi.fn(async () => resolved),
|
||||
reverifyRuntimeBundle: vi.fn(async () => verifiedBundle()),
|
||||
loadRegisteredRuntimeBundle: vi.fn(async () => verifiedBundle()),
|
||||
resolveWorkspace: vi.fn(async (preparation) => ({
|
||||
workspaceIdentity: preparation.workspaceIdentity,
|
||||
workspaceDirectory: '/workspace',
|
||||
|
||||
@@ -227,7 +227,7 @@ export type RuntimeAcpBackendOptions = {
|
||||
runtimeId: string,
|
||||
runtimeBundleDigest: string
|
||||
): RuntimeAcpResolvedBundle | Promise<RuntimeAcpResolvedBundle>
|
||||
reverifyRuntimeBundle(
|
||||
loadRegisteredRuntimeBundle(
|
||||
resolved: RuntimeAcpResolvedBundle,
|
||||
preparation: RemotePromptOperationPreparation
|
||||
): RuntimeAcpVerifiedBundle | Promise<RuntimeAcpVerifiedBundle>
|
||||
@@ -947,7 +947,7 @@ export class RuntimeAcpBackend {
|
||||
|
||||
let verified: RuntimeAcpVerifiedBundle
|
||||
try {
|
||||
verified = await this.#options.reverifyRuntimeBundle(
|
||||
verified = await this.#options.loadRegisteredRuntimeBundle(
|
||||
binding.resolvedBundle,
|
||||
preparation
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from './managed-paths'
|
||||
import type { InstalledBundleVerificationEnvironment } from './installed-bundle-verifier'
|
||||
import {
|
||||
verifyRuntimeBundle,
|
||||
loadRegisteredRuntimeBundle,
|
||||
type VerifiedRuntimeBundle
|
||||
} from './runtime-bundle-verifier'
|
||||
|
||||
@@ -120,6 +120,27 @@ export class RuntimeBundleRegistry {
|
||||
entry: RuntimeRegistryEntry
|
||||
bundleDirectory: string
|
||||
} {
|
||||
const resolved = this.find(
|
||||
runtimeIdInput,
|
||||
bundleDigestInput,
|
||||
architecture
|
||||
)
|
||||
if (resolved === undefined) {
|
||||
throw new Error(
|
||||
'Runtime bundle is not the verified current bundle'
|
||||
)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
find(
|
||||
runtimeIdInput: string,
|
||||
bundleDigestInput: string,
|
||||
architecture: AgentArchitecture
|
||||
): {
|
||||
entry: RuntimeRegistryEntry
|
||||
bundleDirectory: string
|
||||
} | undefined {
|
||||
const runtimeId = agentIdentifierSchema.parse(runtimeIdInput)
|
||||
const bundleDigest = sha256DigestSchema.parse(bundleDigestInput)
|
||||
this.#state = this.#load({ createIfMissing: false })
|
||||
@@ -130,9 +151,7 @@ export class RuntimeBundleRegistry {
|
||||
candidate.architecture === architecture
|
||||
)
|
||||
if (entry === undefined) {
|
||||
throw new Error(
|
||||
'Runtime bundle is not the verified current bundle'
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
entry: { ...entry },
|
||||
@@ -231,7 +250,7 @@ export class RuntimeBundleRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
export function createVerifiedRuntimeCapabilitySource(options: {
|
||||
type VerifiedRuntimeCapabilitySourceOptions = {
|
||||
registry: RuntimeBundleRegistry
|
||||
architecture: AgentArchitecture
|
||||
releaseKeyRegistry: AgentReleaseKeyRegistry
|
||||
@@ -241,53 +260,65 @@ export function createVerifiedRuntimeCapabilitySource(options: {
|
||||
filesystemPlatform?: NodeJS.Platform
|
||||
uid?: number
|
||||
reportError?: (message: string, error: unknown) => void
|
||||
}): () => Promise<DaemonCapabilities['runtimes']> {
|
||||
loadRegistered?: (
|
||||
entry: RuntimeRegistryEntry,
|
||||
bundleDirectory: string
|
||||
) => Promise<VerifiedRuntimeBundle>
|
||||
}
|
||||
|
||||
export function createVerifiedRuntimeCapabilitySource(
|
||||
options: VerifiedRuntimeCapabilitySourceOptions
|
||||
): () => Promise<DaemonCapabilities['runtimes']> {
|
||||
return async () => await loadVerifiedRuntimeCapabilities(options)
|
||||
}
|
||||
|
||||
async function loadVerifiedRuntimeCapabilities(options: {
|
||||
registry: RuntimeBundleRegistry
|
||||
architecture: AgentArchitecture
|
||||
releaseKeyRegistry: AgentReleaseKeyRegistry
|
||||
runtimeLock: RemoteRuntimeLock
|
||||
verificationEnvironment?: InstalledBundleVerificationEnvironment
|
||||
enforceFilesystemMode?: boolean
|
||||
filesystemPlatform?: NodeJS.Platform
|
||||
uid?: number
|
||||
reportError?: (message: string, error: unknown) => void
|
||||
}): Promise<DaemonCapabilities['runtimes']> {
|
||||
async function loadVerifiedRuntimeCapabilities(
|
||||
options: VerifiedRuntimeCapabilitySourceOptions
|
||||
): Promise<DaemonCapabilities['runtimes']> {
|
||||
const capabilities: DaemonCapabilities['runtimes'] = []
|
||||
for (const entry of options.registry.current(options.architecture)) {
|
||||
try {
|
||||
const verified = await verifyRuntimeBundle(
|
||||
const bundleDirectory =
|
||||
options.registry.bundleDirectory(
|
||||
entry.runtimeId,
|
||||
entry.bundleDigest
|
||||
),
|
||||
{
|
||||
architecture: options.architecture,
|
||||
releaseKeyRegistry: options.releaseKeyRegistry,
|
||||
runtimeLock: options.runtimeLock,
|
||||
...(options.verificationEnvironment === undefined
|
||||
? {}
|
||||
: {
|
||||
verificationEnvironment:
|
||||
options.verificationEnvironment
|
||||
}),
|
||||
...(options.enforceFilesystemMode === undefined
|
||||
? {}
|
||||
: {
|
||||
enforceFilesystemMode:
|
||||
options.enforceFilesystemMode
|
||||
}),
|
||||
...(options.filesystemPlatform === undefined
|
||||
? {}
|
||||
: {
|
||||
filesystemPlatform: options.filesystemPlatform
|
||||
}),
|
||||
...(options.uid === undefined ? {} : { uid: options.uid })
|
||||
}
|
||||
)
|
||||
)
|
||||
const verified = options.loadRegistered === undefined
|
||||
? await loadRegisteredRuntimeBundle(
|
||||
bundleDirectory,
|
||||
{
|
||||
registered: entry,
|
||||
architecture: options.architecture,
|
||||
releaseKeyRegistry:
|
||||
options.releaseKeyRegistry,
|
||||
runtimeLock: options.runtimeLock,
|
||||
...(options.verificationEnvironment === undefined
|
||||
? {}
|
||||
: {
|
||||
verificationEnvironment:
|
||||
options.verificationEnvironment
|
||||
}),
|
||||
...(options.enforceFilesystemMode === undefined
|
||||
? {}
|
||||
: {
|
||||
enforceFilesystemMode:
|
||||
options.enforceFilesystemMode
|
||||
}),
|
||||
...(options.filesystemPlatform === undefined
|
||||
? {}
|
||||
: {
|
||||
filesystemPlatform:
|
||||
options.filesystemPlatform
|
||||
}),
|
||||
...(options.uid === undefined
|
||||
? {}
|
||||
: { uid: options.uid })
|
||||
}
|
||||
)
|
||||
: await options.loadRegistered(
|
||||
entry,
|
||||
bundleDirectory
|
||||
)
|
||||
if (
|
||||
verified.manifest.runtimeVersion !== entry.runtimeVersion ||
|
||||
verified.manifestDigest !== entry.manifestDigest ||
|
||||
@@ -326,6 +357,7 @@ function entryFromVerified(
|
||||
architecture: verified.manifest.architecture,
|
||||
bundleDigest: verified.manifest.bundleDigest,
|
||||
manifestDigest: verified.manifestDigest,
|
||||
runtimeAdapterDigest: verified.manifest.adapterDigest,
|
||||
acpCapabilitiesDigest:
|
||||
verified.manifest.acpCapabilitiesDigest
|
||||
})
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('Remote Runtime bundle verifier', () => {
|
||||
).rejects.toThrow(/managed directory/iu)
|
||||
})
|
||||
|
||||
it('advertises only a bundle that still verifies against its registry', async () => {
|
||||
it('uses registered metadata without rereading Runtime payload files', async () => {
|
||||
const fixture = await createBundle()
|
||||
const runtimeRoot = dirname(dirname(fixture.bundleDirectory))
|
||||
const registry = new RuntimeBundleRegistry({
|
||||
@@ -198,6 +198,18 @@ describe('Remote Runtime bundle verifier', () => {
|
||||
join(fixture.bundleDirectory, 'licenses', 'opencode.txt'),
|
||||
'mutated'
|
||||
)
|
||||
await expect(capabilities()).resolves.toHaveLength(1)
|
||||
expect(reportError).not.toHaveBeenCalled()
|
||||
|
||||
writeFileSync(
|
||||
join(fixture.bundleDirectory, 'bin', 'opencode'),
|
||||
Buffer.concat([
|
||||
readFileSync(
|
||||
join(fixture.bundleDirectory, 'bin', 'opencode')
|
||||
),
|
||||
Buffer.from('changed')
|
||||
])
|
||||
)
|
||||
await expect(capabilities()).resolves.toEqual([])
|
||||
expect(reportError).toHaveBeenCalledWith(
|
||||
'Verified Runtime unavailable: opencode',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
canonicalRuntimeManifestBytes,
|
||||
loadRegisteredRuntimeBundle,
|
||||
readRemoteRuntimeLock,
|
||||
runtimeManifestSignaturePayload,
|
||||
verifyRuntimeBundle,
|
||||
@@ -8,5 +9,6 @@ export {
|
||||
|
||||
export type {
|
||||
VerifiedRuntimeBundle,
|
||||
LoadRegisteredRuntimeBundleOptions,
|
||||
VerifyRuntimeBundleOptions
|
||||
} from '../shared/node/runtime-bundle-verifier'
|
||||
|
||||
@@ -37,7 +37,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('production Runtime composition', () => {
|
||||
it('keeps ACP live and reloads managed metadata installed after daemon startup', async () => {
|
||||
it('loads metadata installed after startup and caches the registered Runtime identity', async () => {
|
||||
const fixture = await createRuntimeBundleTestFixture()
|
||||
const root = fixture.root
|
||||
temporaryPaths.push(root)
|
||||
@@ -121,11 +121,8 @@ describe('production Runtime composition', () => {
|
||||
}, null, 2)}\n`,
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
await expect(protocol.runtimes()).resolves.toEqual([])
|
||||
expect(reportError).toHaveBeenCalledWith(
|
||||
'Verified Runtime unavailable: opencode',
|
||||
expect.any(Error)
|
||||
)
|
||||
await expect(protocol.runtimes()).resolves.toHaveLength(1)
|
||||
expect(reportError).not.toHaveBeenCalled()
|
||||
|
||||
await expect(protocol.dispose()).resolves.toBeUndefined()
|
||||
events.close()
|
||||
|
||||
@@ -7,6 +7,9 @@ import type {
|
||||
AgentReleaseKeyRegistry
|
||||
} from '../shared/agent-installation-contracts'
|
||||
import type { AgentFrame } from '../shared/agent-protocol'
|
||||
import type {
|
||||
RuntimeRegistryEntry
|
||||
} from '../shared/remote-environment-registry-contracts'
|
||||
import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts'
|
||||
import type { EventJournal } from './event-journal'
|
||||
import {
|
||||
@@ -22,7 +25,8 @@ import {
|
||||
} from './runtime-bundle-registry'
|
||||
import {
|
||||
readRemoteRuntimeLock,
|
||||
verifyRuntimeBundle
|
||||
loadRegisteredRuntimeBundle,
|
||||
type VerifiedRuntimeBundle
|
||||
} from './runtime-bundle-verifier'
|
||||
import { RuntimeAcpBackend } from './runtime-acp-backend'
|
||||
import type { ProtocolMethodContext } from './protocol-server'
|
||||
@@ -111,37 +115,90 @@ export async function createProductionRuntimeProtocol(
|
||||
}
|
||||
return registry
|
||||
}
|
||||
const currentVerificationOptions = async () => {
|
||||
const metadataOptions =
|
||||
options.filesystemPlatform === undefined
|
||||
? {}
|
||||
: { filesystemPlatform: options.filesystemPlatform }
|
||||
const [releaseKeyRegistry, runtimeLock] = await Promise.all([
|
||||
options.releaseKeyRegistry ??
|
||||
readManagedAgentReleaseKeyRegistry(
|
||||
join(runtimeRoot, 'release-keys.json'),
|
||||
metadataOptions
|
||||
),
|
||||
options.runtimeLock ??
|
||||
readRemoteRuntimeLock(
|
||||
join(runtimeRoot, 'remote-runtime-lock.json'),
|
||||
metadataOptions
|
||||
)
|
||||
])
|
||||
return {
|
||||
architecture: options.architecture,
|
||||
releaseKeyRegistry,
|
||||
runtimeLock,
|
||||
verificationEnvironment,
|
||||
...(options.filesystemPlatform === undefined
|
||||
? {}
|
||||
: { filesystemPlatform: options.filesystemPlatform })
|
||||
} as const
|
||||
let verificationOptionsPromise:
|
||||
| Promise<{
|
||||
architecture: AgentArchitecture
|
||||
releaseKeyRegistry: AgentReleaseKeyRegistry
|
||||
runtimeLock: RemoteRuntimeLock
|
||||
verificationEnvironment: 'production' | 'test'
|
||||
filesystemPlatform?: NodeJS.Platform
|
||||
}>
|
||||
| undefined
|
||||
const currentVerificationOptions = () => {
|
||||
verificationOptionsPromise ??= (async () => {
|
||||
const metadataOptions =
|
||||
options.filesystemPlatform === undefined
|
||||
? {}
|
||||
: {
|
||||
filesystemPlatform:
|
||||
options.filesystemPlatform
|
||||
}
|
||||
const [releaseKeyRegistry, runtimeLock] =
|
||||
await Promise.all([
|
||||
options.releaseKeyRegistry ??
|
||||
readManagedAgentReleaseKeyRegistry(
|
||||
join(runtimeRoot, 'release-keys.json'),
|
||||
metadataOptions
|
||||
),
|
||||
options.runtimeLock ??
|
||||
readRemoteRuntimeLock(
|
||||
join(
|
||||
runtimeRoot,
|
||||
'remote-runtime-lock.json'
|
||||
),
|
||||
metadataOptions
|
||||
)
|
||||
])
|
||||
return {
|
||||
architecture: options.architecture,
|
||||
releaseKeyRegistry,
|
||||
runtimeLock,
|
||||
verificationEnvironment,
|
||||
...(options.filesystemPlatform === undefined
|
||||
? {}
|
||||
: {
|
||||
filesystemPlatform:
|
||||
options.filesystemPlatform
|
||||
})
|
||||
}
|
||||
})()
|
||||
void verificationOptionsPromise.catch(() => {
|
||||
verificationOptionsPromise = undefined
|
||||
})
|
||||
return verificationOptionsPromise
|
||||
}
|
||||
const verifiedRuntimeCache =
|
||||
new Map<string, Promise<VerifiedRuntimeBundle>>()
|
||||
const loadRegistered = (
|
||||
entry: RuntimeRegistryEntry,
|
||||
bundleDirectory: string
|
||||
): Promise<VerifiedRuntimeBundle> => {
|
||||
const key = `${bundleDirectory}\0${JSON.stringify(entry)}`
|
||||
const cached = verifiedRuntimeCache.get(key)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const loading = (async () =>
|
||||
await loadRegisteredRuntimeBundle(
|
||||
bundleDirectory,
|
||||
{
|
||||
...(await currentVerificationOptions()),
|
||||
registered: entry
|
||||
}
|
||||
))()
|
||||
verifiedRuntimeCache.set(key, loading)
|
||||
void loading.catch(() => {
|
||||
if (verifiedRuntimeCache.get(key) === loading) {
|
||||
verifiedRuntimeCache.delete(key)
|
||||
}
|
||||
})
|
||||
return loading
|
||||
}
|
||||
const verifiedCapabilities = async () => {
|
||||
const source = createVerifiedRuntimeCapabilitySource({
|
||||
registry: currentRegistry(),
|
||||
...(await currentVerificationOptions()),
|
||||
loadRegistered,
|
||||
reportError: options.reportError
|
||||
})
|
||||
return await source()
|
||||
@@ -182,10 +239,10 @@ export async function createProductionRuntimeProtocol(
|
||||
bundleDigest,
|
||||
options.architecture
|
||||
),
|
||||
reverifyRuntimeBundle: async (resolved) => {
|
||||
const verified = await verifyRuntimeBundle(
|
||||
resolved.bundleDirectory,
|
||||
await currentVerificationOptions()
|
||||
loadRegisteredRuntimeBundle: async (resolved) => {
|
||||
const verified = await loadRegistered(
|
||||
resolved.entry,
|
||||
resolved.bundleDirectory
|
||||
)
|
||||
if (
|
||||
verified.manifestDigest !== resolved.entry.manifestDigest ||
|
||||
|
||||
@@ -1260,11 +1260,6 @@ if (hasSingleInstanceLock) {
|
||||
resolver: startupRemoteAgentServices.targetResolver,
|
||||
sshPool: startupRemoteAgentServices.sshPool,
|
||||
agentPackageManager,
|
||||
agentInstallationManager:
|
||||
startupRemoteAgentServices.installationManager,
|
||||
runtimeInstallationManager:
|
||||
startupManagedRemoteExecutionServices
|
||||
.runtimeInstallationManager,
|
||||
operationStore:
|
||||
new RemoteEnvironmentOperationStore(
|
||||
app.getPath('userData')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
export function boundedDiagnostic(
|
||||
value: string,
|
||||
maximumCharacters = 800
|
||||
): string {
|
||||
let printable = ''
|
||||
for (const character of value) {
|
||||
const code = character.charCodeAt(0)
|
||||
if (
|
||||
code === 9 ||
|
||||
code === 10 ||
|
||||
code === 13 ||
|
||||
(code >= 32 && code !== 127)
|
||||
) {
|
||||
printable += character
|
||||
}
|
||||
}
|
||||
return printable
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim()
|
||||
.slice(0, maximumCharacters)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
import { settleBoundedly } from './bounded-settlement'
|
||||
|
||||
const DISPOSE_WAIT_TIMEOUT_MS = 5_000
|
||||
|
||||
type ActiveActivation<TValue, TPhase extends string> = {
|
||||
promise: Promise<TValue>
|
||||
controller: AbortController
|
||||
progressCallbacks: Set<(phase: TPhase) => void>
|
||||
waiters: number
|
||||
settled: boolean
|
||||
}
|
||||
|
||||
export class HostActivationCoordinator<
|
||||
TValue,
|
||||
TPhase extends string
|
||||
> {
|
||||
readonly #maximumConcurrentHosts: number
|
||||
readonly #capacityError: () => Error
|
||||
readonly #disposeMessage: string
|
||||
readonly #active =
|
||||
new Map<string, ActiveActivation<TValue, TPhase>>()
|
||||
readonly #current = new Map<
|
||||
string,
|
||||
{
|
||||
targetKey: string
|
||||
variantKey: string
|
||||
value: TValue
|
||||
}
|
||||
>()
|
||||
readonly #cacheGenerations = new Map<string, number>()
|
||||
#closed = false
|
||||
#disposePromise: Promise<void> | undefined
|
||||
|
||||
constructor(options: {
|
||||
maximumConcurrentHosts?: number
|
||||
capacityError: () => Error
|
||||
disposeMessage: string
|
||||
}) {
|
||||
this.#maximumConcurrentHosts =
|
||||
options.maximumConcurrentHosts ?? 8
|
||||
this.#capacityError = options.capacityError
|
||||
this.#disposeMessage = options.disposeMessage
|
||||
if (
|
||||
!Number.isSafeInteger(this.#maximumConcurrentHosts) ||
|
||||
this.#maximumConcurrentHosts <= 0 ||
|
||||
this.#maximumConcurrentHosts > 32
|
||||
) {
|
||||
throw new Error(
|
||||
'Host activation concurrency limit is invalid'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
assertAvailable(): void {
|
||||
if (this.#closed) {
|
||||
throw new Error(this.#disposeMessage)
|
||||
}
|
||||
}
|
||||
|
||||
cached(
|
||||
hostId: string,
|
||||
targetKey: string,
|
||||
variantKey: string
|
||||
): TValue | undefined {
|
||||
this.assertAvailable()
|
||||
const cached = this.#current.get(hostId)
|
||||
return (
|
||||
cached?.targetKey === targetKey &&
|
||||
cached.variantKey === variantKey
|
||||
)
|
||||
? cached.value
|
||||
: undefined
|
||||
}
|
||||
|
||||
cachedForHost(
|
||||
hostId: string,
|
||||
variantKey: string
|
||||
): TValue | undefined {
|
||||
this.assertAvailable()
|
||||
const cached = this.#current.get(hostId)
|
||||
return cached?.variantKey === variantKey
|
||||
? cached.value
|
||||
: undefined
|
||||
}
|
||||
|
||||
run(options: {
|
||||
hostId: string
|
||||
targetKey: string
|
||||
variantKey: string
|
||||
signal?: AbortSignal
|
||||
onProgress?: (phase: TPhase) => void
|
||||
operation: (
|
||||
signal: AbortSignal,
|
||||
progress: (phase: TPhase) => void
|
||||
) => Promise<TValue>
|
||||
}): Promise<TValue> {
|
||||
this.assertAvailable()
|
||||
options.signal?.throwIfAborted()
|
||||
const operationKey =
|
||||
`${options.targetKey}\0${options.variantKey}`
|
||||
const existing = this.#active.get(operationKey)
|
||||
if (existing) {
|
||||
return this.#waitForActive(existing, options)
|
||||
}
|
||||
if (this.#active.size >= this.#maximumConcurrentHosts) {
|
||||
throw this.#capacityError()
|
||||
}
|
||||
|
||||
const cacheGeneration =
|
||||
this.#cacheGenerations.get(options.hostId) ?? 0
|
||||
const progressCallbacks = new Set<
|
||||
(phase: TPhase) => void
|
||||
>()
|
||||
if (options.onProgress) {
|
||||
progressCallbacks.add(options.onProgress)
|
||||
}
|
||||
const progress = (phase: TPhase): void => {
|
||||
for (const callback of progressCallbacks) {
|
||||
emitProgress(callback, phase)
|
||||
}
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const promise = options.operation(
|
||||
controller.signal,
|
||||
progress
|
||||
).then((value) => {
|
||||
if (
|
||||
(this.#cacheGenerations.get(options.hostId) ?? 0) ===
|
||||
cacheGeneration
|
||||
) {
|
||||
this.#current.set(options.hostId, {
|
||||
targetKey: options.targetKey,
|
||||
variantKey: options.variantKey,
|
||||
value
|
||||
})
|
||||
}
|
||||
return value
|
||||
})
|
||||
const active: ActiveActivation<TValue, TPhase> = {
|
||||
promise,
|
||||
controller,
|
||||
progressCallbacks,
|
||||
waiters: 0,
|
||||
settled: false
|
||||
}
|
||||
this.#active.set(operationKey, active)
|
||||
void promise.finally(() => {
|
||||
active.settled = true
|
||||
if (this.#active.get(operationKey) === active) {
|
||||
this.#active.delete(operationKey)
|
||||
}
|
||||
}).catch(() => undefined)
|
||||
return this.#waitForActive(active, options)
|
||||
}
|
||||
|
||||
invalidate(hostId: string): void {
|
||||
this.#current.delete(hostId)
|
||||
this.#cacheGenerations.set(
|
||||
hostId,
|
||||
(this.#cacheGenerations.get(hostId) ?? 0) + 1
|
||||
)
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
if (this.#disposePromise) {
|
||||
return this.#disposePromise
|
||||
}
|
||||
this.#closed = true
|
||||
this.#current.clear()
|
||||
this.#cacheGenerations.clear()
|
||||
const operations = [...this.#active.values()]
|
||||
const reason = new DOMException(
|
||||
this.#disposeMessage,
|
||||
'AbortError'
|
||||
)
|
||||
for (const operation of operations) {
|
||||
operation.controller.abort(reason)
|
||||
}
|
||||
this.#disposePromise = settleBoundedly(
|
||||
operations.map((operation) => operation.promise),
|
||||
DISPOSE_WAIT_TIMEOUT_MS
|
||||
)
|
||||
return this.#disposePromise
|
||||
}
|
||||
|
||||
async #waitForActive(
|
||||
active: ActiveActivation<TValue, TPhase>,
|
||||
options: {
|
||||
signal?: AbortSignal
|
||||
onProgress?: (phase: TPhase) => void
|
||||
}
|
||||
): Promise<TValue> {
|
||||
active.waiters += 1
|
||||
if (options.onProgress) {
|
||||
active.progressCallbacks.add(options.onProgress)
|
||||
}
|
||||
try {
|
||||
return await waitForOperation(
|
||||
active.promise,
|
||||
options.signal
|
||||
)
|
||||
} finally {
|
||||
if (options.onProgress) {
|
||||
active.progressCallbacks.delete(options.onProgress)
|
||||
}
|
||||
active.waiters -= 1
|
||||
if (
|
||||
active.waiters === 0 &&
|
||||
!active.settled &&
|
||||
!active.controller.signal.aborted
|
||||
) {
|
||||
active.controller.abort(
|
||||
new DOMException(
|
||||
'Host activation has no remaining waiters',
|
||||
'AbortError'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emitProgress<TPhase extends string>(
|
||||
callback: (phase: TPhase) => void,
|
||||
phase: TPhase
|
||||
): void {
|
||||
try {
|
||||
callback(phase)
|
||||
} catch {
|
||||
// Progress observers cannot alter activation state.
|
||||
}
|
||||
}
|
||||
|
||||
function waitForOperation<T>(
|
||||
operation: Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
if (!signal) {
|
||||
return operation
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const abort = (): void => reject(
|
||||
signal.reason ??
|
||||
new DOMException(
|
||||
'The operation was aborted',
|
||||
'AbortError'
|
||||
)
|
||||
)
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
void operation.then(resolve, reject).finally(() => {
|
||||
signal.removeEventListener('abort', abort)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -21,13 +21,11 @@ import {
|
||||
RemoteRuntimeInstallationManager,
|
||||
type RemoteRuntimeActivator
|
||||
} from './remote-runtime-installation-manager'
|
||||
import {
|
||||
loadRemoteRuntimeVerificationMetadata
|
||||
} from './remote-runtime-resource-loader'
|
||||
import {
|
||||
createManagedModelBridge,
|
||||
reconcileStartupModelCalls
|
||||
} from './managed-model-bridge'
|
||||
import { boundedDiagnostic } from './bounded-diagnostic'
|
||||
|
||||
const RUNTIME_BINDING_DATABASE_NAME = 'remote-runtime-bindings.sqlite'
|
||||
const MODEL_CALL_DATABASE_NAME = 'remote-model-calls-v2.sqlite'
|
||||
@@ -101,19 +99,9 @@ export class ManagedRemoteExecutionServices {
|
||||
)
|
||||
).installationId
|
||||
signal.throwIfAborted()
|
||||
const current = await options.agentServices.targetResolver.resolve(
|
||||
lease.identity.hostId
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
if (
|
||||
runtimeId !== 'opencode' ||
|
||||
current.host.id !== lease.identity.hostId ||
|
||||
current.hostRevision !== lease.identity.hostRevision ||
|
||||
current.hostKeyGeneration !==
|
||||
lease.identity.hostKeyGeneration
|
||||
) {
|
||||
if (runtimeId !== 'opencode') {
|
||||
throw new Error(
|
||||
'Remote Agent or SSH host identity changed before Runtime activation'
|
||||
'Remote Runtime identity is unsupported'
|
||||
)
|
||||
}
|
||||
const result = await lease.runAgentRuntimeAction(
|
||||
@@ -127,17 +115,20 @@ export class ManagedRemoteExecutionServices {
|
||||
signal
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error('Remote Runtime activation failed')
|
||||
const detail = boundedDiagnostic(
|
||||
`${result.stderr}\n${result.stdout}`
|
||||
)
|
||||
throw new Error(
|
||||
detail.length > 0
|
||||
? `Remote Runtime activation failed: ${detail}`
|
||||
: `Remote Runtime activation failed (exit ${String(result.exitCode)})`
|
||||
)
|
||||
}
|
||||
}
|
||||
this.runtimeInstallationManager =
|
||||
new RemoteRuntimeInstallationManager({
|
||||
resolver: options.agentServices.targetResolver,
|
||||
sshPool: options.agentServices.sshPool,
|
||||
loadVerificationMetadata: () =>
|
||||
loadRemoteRuntimeVerificationMetadata(
|
||||
this.runtimeResourcePaths
|
||||
),
|
||||
activate
|
||||
})
|
||||
this.runtimeValidator = new ManagedRemoteProjectRuntimeValidator({
|
||||
|
||||
@@ -42,6 +42,39 @@ describe('RemoteEnvironmentOperationStore', () => {
|
||||
await expect(store.load(operation.hostId)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('persists bounded metadata snapshots for post-commit recovery', async () => {
|
||||
const root = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-environment-operation-')
|
||||
)
|
||||
temporaryDirectories.push(root)
|
||||
const store = new RemoteEnvironmentOperationStore(root)
|
||||
const recovered: PendingRemoteEnvironmentOperation = {
|
||||
...operation,
|
||||
version: 2,
|
||||
metadataSnapshots: [
|
||||
'.goodbuddy/agent/release-keys.json',
|
||||
'.goodbuddy/agent/registry.json',
|
||||
'.goodbuddy/runtimes/release-keys.json',
|
||||
'.goodbuddy/runtimes/remote-runtime-lock.json',
|
||||
'.goodbuddy/runtimes/registry.json'
|
||||
].map((path, index) => ({
|
||||
path: path as never,
|
||||
uid: 1000,
|
||||
...(index === 1
|
||||
? {}
|
||||
: {
|
||||
contentsBase64:
|
||||
Buffer.from(`metadata-${index}`).toString('base64')
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
await store.save(recovered)
|
||||
await expect(store.load(operation.hostId)).resolves.toEqual(
|
||||
recovered
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a mismatched or oversized recovery record', async () => {
|
||||
const root = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-environment-operation-')
|
||||
@@ -58,7 +91,7 @@ describe('RemoteEnvironmentOperationStore', () => {
|
||||
const [record] = await readdir(files)
|
||||
await writeFile(
|
||||
join(files, record!),
|
||||
Buffer.alloc(64 * 1024 + 1)
|
||||
Buffer.alloc(8 * 1024 * 1024 + 1)
|
||||
)
|
||||
|
||||
await expect(store.load(operation.hostId)).rejects.toThrow(
|
||||
|
||||
@@ -6,13 +6,12 @@ import {
|
||||
writeJsonFileAtomically
|
||||
} from '../settings-file-utils'
|
||||
|
||||
const MAXIMUM_RECORD_BYTES = 64 * 1024
|
||||
const MAXIMUM_RECORD_BYTES = 8 * 1024 * 1024
|
||||
const OPERATION_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/u
|
||||
|
||||
export type PendingRemoteEnvironmentOperation = {
|
||||
version: 1
|
||||
type PendingRemoteEnvironmentOperationBase = {
|
||||
hostId: string
|
||||
targetIdentity: string
|
||||
operationId: string
|
||||
@@ -21,6 +20,28 @@ export type PendingRemoteEnvironmentOperation = {
|
||||
urls: readonly string[]
|
||||
}
|
||||
|
||||
export type PersistedRemoteEnvironmentMetadataSnapshot = {
|
||||
path:
|
||||
| '.goodbuddy/agent/release-keys.json'
|
||||
| '.goodbuddy/agent/registry.json'
|
||||
| '.goodbuddy/runtimes/release-keys.json'
|
||||
| '.goodbuddy/runtimes/remote-runtime-lock.json'
|
||||
| '.goodbuddy/runtimes/registry.json'
|
||||
contentsBase64?: string
|
||||
uid: number
|
||||
}
|
||||
|
||||
export type PendingRemoteEnvironmentOperation =
|
||||
PendingRemoteEnvironmentOperationBase &
|
||||
(
|
||||
| { version: 1 }
|
||||
| {
|
||||
version: 2
|
||||
metadataSnapshots:
|
||||
readonly PersistedRemoteEnvironmentMetadataSnapshot[]
|
||||
}
|
||||
)
|
||||
|
||||
export class RemoteEnvironmentOperationStore {
|
||||
readonly #directory: string
|
||||
|
||||
@@ -91,11 +112,23 @@ function parsePendingOperation(
|
||||
value: unknown,
|
||||
expectedHostId: string
|
||||
): PendingRemoteEnvironmentOperation {
|
||||
if (
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
[
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error('远程环境恢复记录无效')
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const version = record.version
|
||||
const expectedKeys = version === 2
|
||||
? [
|
||||
'hostId',
|
||||
'metadataSnapshots',
|
||||
'operationId',
|
||||
'sha256',
|
||||
'size',
|
||||
'targetIdentity',
|
||||
'urls',
|
||||
'version'
|
||||
]
|
||||
: [
|
||||
'hostId',
|
||||
'operationId',
|
||||
'sha256',
|
||||
@@ -103,13 +136,15 @@ function parsePendingOperation(
|
||||
'targetIdentity',
|
||||
'urls',
|
||||
'version'
|
||||
].sort().join('\0')
|
||||
]
|
||||
if (
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
expectedKeys.sort().join('\0')
|
||||
) {
|
||||
throw new Error('远程环境恢复记录无效')
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
if (
|
||||
record.version !== 1 ||
|
||||
(version !== 1 && version !== 2) ||
|
||||
record.hostId !== expectedHostId ||
|
||||
typeof record.targetIdentity !== 'string' ||
|
||||
!SHA256_PATTERN.test(record.targetIdentity) ||
|
||||
@@ -129,8 +164,7 @@ function parsePendingOperation(
|
||||
) {
|
||||
throw new Error('远程环境恢复记录无效')
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
const base: PendingRemoteEnvironmentOperationBase = {
|
||||
hostId: expectedHostId,
|
||||
targetIdentity: record.targetIdentity,
|
||||
operationId: record.operationId,
|
||||
@@ -138,4 +172,100 @@ function parsePendingOperation(
|
||||
sha256: record.sha256,
|
||||
urls: [...record.urls] as string[]
|
||||
}
|
||||
if (version === 1) {
|
||||
return { version: 1, ...base }
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
...base,
|
||||
metadataSnapshots: parseMetadataSnapshots(
|
||||
record.metadataSnapshots
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const metadataPaths =
|
||||
new Set<PersistedRemoteEnvironmentMetadataSnapshot['path']>([
|
||||
'.goodbuddy/agent/release-keys.json',
|
||||
'.goodbuddy/agent/registry.json',
|
||||
'.goodbuddy/runtimes/release-keys.json',
|
||||
'.goodbuddy/runtimes/remote-runtime-lock.json',
|
||||
'.goodbuddy/runtimes/registry.json'
|
||||
])
|
||||
|
||||
function parseMetadataSnapshots(
|
||||
value: unknown
|
||||
): readonly PersistedRemoteEnvironmentMetadataSnapshot[] {
|
||||
if (!Array.isArray(value) || value.length !== metadataPaths.size) {
|
||||
throw new Error('远程环境恢复记录无效')
|
||||
}
|
||||
const paths = new Set<string>()
|
||||
let totalBytes = 0
|
||||
const snapshots = value.map((item) => {
|
||||
if (
|
||||
typeof item !== 'object' ||
|
||||
item === null ||
|
||||
!(
|
||||
Object.keys(item).sort().join('\0') ===
|
||||
['path', 'uid'].sort().join('\0') ||
|
||||
Object.keys(item).sort().join('\0') ===
|
||||
['contentsBase64', 'path', 'uid'].sort().join('\0')
|
||||
)
|
||||
) {
|
||||
throw new Error('远程环境恢复记录无效')
|
||||
}
|
||||
const snapshot = item as Record<string, unknown>
|
||||
if (
|
||||
typeof snapshot.path !== 'string' ||
|
||||
!metadataPaths.has(
|
||||
snapshot.path as PersistedRemoteEnvironmentMetadataSnapshot['path']
|
||||
) ||
|
||||
paths.has(snapshot.path) ||
|
||||
!Number.isSafeInteger(snapshot.uid) ||
|
||||
Number(snapshot.uid) < 0
|
||||
) {
|
||||
throw new Error('远程环境恢复记录无效')
|
||||
}
|
||||
paths.add(snapshot.path)
|
||||
if (snapshot.contentsBase64 === undefined) {
|
||||
return {
|
||||
path:
|
||||
snapshot.path as PersistedRemoteEnvironmentMetadataSnapshot['path'],
|
||||
uid: Number(snapshot.uid)
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof snapshot.contentsBase64 !== 'string' ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u
|
||||
.test(snapshot.contentsBase64)
|
||||
) {
|
||||
throw new Error('远程环境恢复记录无效')
|
||||
}
|
||||
const contents = Buffer.from(
|
||||
snapshot.contentsBase64,
|
||||
'base64'
|
||||
)
|
||||
if (
|
||||
contents.toString('base64') !==
|
||||
snapshot.contentsBase64 ||
|
||||
contents.byteLength > 1024 * 1024
|
||||
) {
|
||||
throw new Error('远程环境恢复记录无效')
|
||||
}
|
||||
totalBytes += contents.byteLength
|
||||
return {
|
||||
path:
|
||||
snapshot.path as PersistedRemoteEnvironmentMetadataSnapshot['path'],
|
||||
contentsBase64: snapshot.contentsBase64,
|
||||
uid: Number(snapshot.uid)
|
||||
}
|
||||
})
|
||||
if (
|
||||
paths.size !== metadataPaths.size ||
|
||||
[...metadataPaths].some((path) => !paths.has(path)) ||
|
||||
totalBytes > 5 * 1024 * 1024
|
||||
) {
|
||||
throw new Error('远程环境恢复记录无效')
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
@@ -100,6 +100,8 @@ type FixtureOptions = {
|
||||
prepare?: ReturnType<typeof vi.fn>
|
||||
prepareUploaded?: ReturnType<typeof vi.fn>
|
||||
commit?: ReturnType<typeof vi.fn>
|
||||
commitStatus?: ReturnType<typeof vi.fn>
|
||||
cleanup?: ReturnType<typeof vi.fn>
|
||||
uploadFile?: ReturnType<typeof vi.fn>
|
||||
activateAgent?: ReturnType<typeof vi.fn>
|
||||
activateRuntime?: ReturnType<typeof vi.fn>
|
||||
@@ -200,9 +202,22 @@ function fixture(options: FixtureOptions = {}) {
|
||||
close: vi.fn(() => calls.push('close-upload'))
|
||||
}
|
||||
const regularRelease = vi.fn(() => calls.push('release-probe'))
|
||||
const stopCandidate = vi.fn(async () => {
|
||||
const stopCandidate = vi.fn(async (
|
||||
_installationId?: string,
|
||||
_action?: string
|
||||
) => {
|
||||
void _installationId
|
||||
void _action
|
||||
calls.push('stop-candidate')
|
||||
})
|
||||
const activateAgent = options.activateAgent ?? vi.fn(async () => {
|
||||
calls.push('agent')
|
||||
return { exitCode: 0, stdout: '{}', stderr: '' }
|
||||
})
|
||||
const activateRuntime = options.activateRuntime ?? vi.fn(async () => {
|
||||
calls.push('runtime')
|
||||
return { exitCode: 0, stdout: '{}', stderr: '' }
|
||||
})
|
||||
const regularLease = {
|
||||
identity: leaseIdentity,
|
||||
isUsable: vi.fn(() => true),
|
||||
@@ -226,7 +241,26 @@ function fixture(options: FixtureOptions = {}) {
|
||||
calls.push('open-metadata')
|
||||
return metadata
|
||||
}),
|
||||
runAgentLifecycleAction: stopCandidate,
|
||||
runAgentLifecycleAction: vi.fn(async (
|
||||
installationId: string,
|
||||
action: string
|
||||
) => {
|
||||
if (action === 'stop') {
|
||||
await stopCandidate(installationId, action)
|
||||
return { exitCode: 0, stdout: '{}', stderr: '' }
|
||||
}
|
||||
return (activateAgent as (
|
||||
installationId: string,
|
||||
action: string
|
||||
) => Promise<unknown>)(installationId, action)
|
||||
}),
|
||||
runAgentRuntimeAction: vi.fn(async (
|
||||
installationId: string,
|
||||
action: unknown
|
||||
) => (activateRuntime as (
|
||||
installationId: string,
|
||||
action: unknown
|
||||
) => Promise<unknown>)(installationId, action)),
|
||||
release: regularRelease
|
||||
}
|
||||
const prepare = options.prepare ?? vi.fn(async (
|
||||
@@ -264,11 +298,11 @@ function fixture(options: FixtureOptions = {}) {
|
||||
identity: structuredClone(identity)
|
||||
}
|
||||
})
|
||||
const cleanup = vi.fn(async (operationId: string) => {
|
||||
const cleanup = options.cleanup ?? vi.fn(async (operationId: string) => {
|
||||
calls.push('cleanup')
|
||||
return { cleaned: true, operationId }
|
||||
})
|
||||
const commitStatus = vi.fn(async (
|
||||
const commitStatus = options.commitStatus ?? vi.fn(async (
|
||||
input: { operationId: string }
|
||||
) => ({
|
||||
committed: true,
|
||||
@@ -326,13 +360,6 @@ function fixture(options: FixtureOptions = {}) {
|
||||
calls.push('acquire-archive')
|
||||
return archive
|
||||
})
|
||||
const activateAgent = options.activateAgent ?? vi.fn(async () => {
|
||||
calls.push('agent')
|
||||
return identity.agent
|
||||
})
|
||||
const activateRuntime = options.activateRuntime ?? vi.fn(async () => {
|
||||
calls.push('runtime')
|
||||
})
|
||||
let pendingOperation = options.pendingOperation
|
||||
const operationStore = {
|
||||
load: vi.fn(async () => pendingOperation),
|
||||
@@ -367,12 +394,6 @@ function fixture(options: FixtureOptions = {}) {
|
||||
acquireInstallArchive,
|
||||
acquireGoodBuddyInstallArchive
|
||||
},
|
||||
agentInstallationManager: {
|
||||
activatePublished: activateAgent
|
||||
} as never,
|
||||
runtimeInstallationManager: {
|
||||
activatePublished: activateRuntime
|
||||
} as never,
|
||||
operationStore
|
||||
})
|
||||
return {
|
||||
@@ -425,13 +446,11 @@ describe('RemoteEnvironmentPreparer', () => {
|
||||
'save-operation',
|
||||
'prepare',
|
||||
'open-metadata',
|
||||
'save-operation',
|
||||
'resolve',
|
||||
'commit',
|
||||
'resolve',
|
||||
'agent',
|
||||
'resolve',
|
||||
'runtime',
|
||||
'resolve',
|
||||
'cleanup',
|
||||
'remove-operation',
|
||||
'close-metadata',
|
||||
@@ -447,23 +466,24 @@ describe('RemoteEnvironmentPreparer', () => {
|
||||
expect(remoteCandidate.operationId).toMatch(/^[0-9a-f-]{36}$/u)
|
||||
expect(value.commit.mock.calls[0]?.[0]).toEqual(remoteCandidate)
|
||||
expect(value.activateAgent).toHaveBeenCalledWith(
|
||||
HOST_ID,
|
||||
identity.agent,
|
||||
expect.objectContaining({ signal: controller.signal })
|
||||
identity.agent.installationId,
|
||||
'adopt'
|
||||
)
|
||||
expect(value.activateRuntime).toHaveBeenCalledWith(
|
||||
HOST_ID,
|
||||
identity.runtime,
|
||||
expect.objectContaining({
|
||||
agentInstallationId: identity.agent.installationId,
|
||||
signal: controller.signal
|
||||
})
|
||||
identity.agent.installationId,
|
||||
{
|
||||
kind: 'runtime-activate',
|
||||
runtimeId: identity.runtime.runtimeId,
|
||||
bundleDigest: identity.runtime.bundleDigest,
|
||||
architecture: identity.runtime.architecture,
|
||||
forceVerification: true
|
||||
}
|
||||
)
|
||||
expect(value.cleanup).toHaveBeenCalledWith(
|
||||
remoteCandidate.operationId,
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
expect(value.operationStore.save).toHaveBeenCalledOnce()
|
||||
expect(value.operationStore.save).toHaveBeenCalledTimes(2)
|
||||
expect(value.operationStore.remove).toHaveBeenCalledOnce()
|
||||
expect(progress.some((event) =>
|
||||
event.method === 'remote-download' &&
|
||||
@@ -495,6 +515,27 @@ describe('RemoteEnvironmentPreparer', () => {
|
||||
expect(value.archiveRelease).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps an adopted environment healthy when staging cleanup must be retried', async () => {
|
||||
const value = fixture({
|
||||
cleanup: vi.fn(async (operationId: string) => ({
|
||||
cleaned: false,
|
||||
operationId,
|
||||
reason: 'cleanup-failed'
|
||||
}))
|
||||
})
|
||||
|
||||
await expect(value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)).resolves.toBeUndefined()
|
||||
expect(value.activateAgent).toHaveBeenCalledOnce()
|
||||
expect(value.activateRuntime).toHaveBeenCalledOnce()
|
||||
expect(value.stopCandidate).not.toHaveBeenCalled()
|
||||
expect(value.operationStore.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the archive and cleans the operation when a GoodBuddy upload fails', async () => {
|
||||
const uploadFailure = new Error('upload failed')
|
||||
const value = fixture({
|
||||
@@ -684,12 +725,39 @@ describe('RemoteEnvironmentPreparer', () => {
|
||||
new AbortController().signal
|
||||
)).rejects.toBeInstanceOf(SshRemotePackageCommitIndeterminateError)
|
||||
expect(value.cleanup).not.toHaveBeenCalled()
|
||||
expect(value.operationStore.save).toHaveBeenCalledOnce()
|
||||
expect(value.operationStore.save).toHaveBeenCalledTimes(2)
|
||||
expect(value.operationStore.remove).not.toHaveBeenCalled()
|
||||
expect(value.activateAgent).not.toHaveBeenCalled()
|
||||
expect(value.activateRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls metadata back when cancellation arrives with a successful commit result', async () => {
|
||||
const controller = new AbortController()
|
||||
const cancellation =
|
||||
new DOMException('cancelled after commit', 'AbortError')
|
||||
const value = fixture({
|
||||
commit: vi.fn(async (input: { operationId: string }) => {
|
||||
controller.abort(cancellation)
|
||||
return {
|
||||
committed: true,
|
||||
operationId: input.operationId,
|
||||
identity
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await expect(value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
controller.signal
|
||||
)).rejects.toBe(cancellation)
|
||||
expect(value.stopCandidate).toHaveBeenCalledOnce()
|
||||
expect(value.cleanup).toHaveBeenCalledOnce()
|
||||
expect(value.operationStore.remove).toHaveBeenCalledOnce()
|
||||
expect(value.activateAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps rollback-incomplete commits recoverable without cleanup', async () => {
|
||||
const value = fixture({
|
||||
commit: vi.fn(async () => ({
|
||||
@@ -710,7 +778,7 @@ describe('RemoteEnvironmentPreparer', () => {
|
||||
expect(value.operationStore.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconciles and cleans a pending commit before starting new work', async () => {
|
||||
it('resumes adoption for a committed operation instead of reinstalling it', async () => {
|
||||
const pendingOperationId =
|
||||
'00000000-0000-4000-8000-000000000777'
|
||||
const originalTarget = target()
|
||||
@@ -724,14 +792,21 @@ describe('RemoteEnvironmentPreparer', () => {
|
||||
const value = fixture({
|
||||
targets: [renamedTarget],
|
||||
pendingOperation: {
|
||||
version: 1,
|
||||
version: 2,
|
||||
hostId: HOST_ID,
|
||||
targetIdentity:
|
||||
remoteHostRecoveryIdentityKey(originalTarget),
|
||||
operationId: pendingOperationId,
|
||||
size: candidate.size,
|
||||
sha256: candidate.sha256,
|
||||
urls: candidate.urls
|
||||
urls: candidate.urls,
|
||||
metadataSnapshots:
|
||||
REMOTE_ENVIRONMENT_METADATA_PATHS.map(
|
||||
(path) => ({
|
||||
path,
|
||||
uid: 1000
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -751,10 +826,158 @@ describe('RemoteEnvironmentPreparer', () => {
|
||||
expect(value.cleanup.mock.calls[0]?.[0]).toBe(
|
||||
pendingOperationId
|
||||
)
|
||||
expect(value.activateAgent).toHaveBeenCalledOnce()
|
||||
expect(value.activateRuntime).toHaveBeenCalledOnce()
|
||||
expect(value.prepare).not.toHaveBeenCalled()
|
||||
expect(value.operationStore.remove).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cleans a legacy pending operation and starts a fresh authenticated prepare', async () => {
|
||||
const originalTarget = target()
|
||||
const pendingOperationId =
|
||||
'00000000-0000-4000-8000-000000000780'
|
||||
const value = fixture({
|
||||
targets: [originalTarget],
|
||||
pendingOperation: {
|
||||
version: 1,
|
||||
hostId: HOST_ID,
|
||||
targetIdentity:
|
||||
remoteHostRecoveryIdentityKey(originalTarget),
|
||||
operationId: pendingOperationId,
|
||||
size: candidate.size,
|
||||
sha256: candidate.sha256,
|
||||
urls: candidate.urls
|
||||
}
|
||||
})
|
||||
|
||||
await value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(value.commitStatus).not.toHaveBeenCalled()
|
||||
expect(value.cleanup.mock.calls[0]?.[0]).toBe(
|
||||
pendingOperationId
|
||||
)
|
||||
expect(value.prepare).toHaveBeenCalledOnce()
|
||||
expect(value.operationStore.remove).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('restores the durable snapshot when resumed commit does not complete', async () => {
|
||||
const originalTarget = target()
|
||||
const originals = new Map<string, Buffer>(
|
||||
REMOTE_ENVIRONMENT_METADATA_PATHS.map(
|
||||
(path, index) => [
|
||||
path,
|
||||
Buffer.from(`before-commit-${index}`)
|
||||
]
|
||||
)
|
||||
)
|
||||
const files = new Map<string, Buffer>(
|
||||
REMOTE_ENVIRONMENT_METADATA_PATHS.map(
|
||||
(path) => [path, Buffer.from('partial-commit')]
|
||||
)
|
||||
)
|
||||
const value = fixture({
|
||||
targets: [originalTarget],
|
||||
metadataFiles: files,
|
||||
pendingOperation: {
|
||||
version: 2,
|
||||
hostId: HOST_ID,
|
||||
targetIdentity:
|
||||
remoteHostRecoveryIdentityKey(originalTarget),
|
||||
operationId:
|
||||
'00000000-0000-4000-8000-000000000781',
|
||||
size: candidate.size,
|
||||
sha256: candidate.sha256,
|
||||
urls: candidate.urls,
|
||||
metadataSnapshots: [...originals].map(
|
||||
([path, contents]) => ({
|
||||
path: path as never,
|
||||
uid: 1000,
|
||||
contentsBase64: contents.toString('base64')
|
||||
})
|
||||
)
|
||||
},
|
||||
commitStatus: vi.fn(async () => ({
|
||||
committed: false,
|
||||
reason: 'installer-failed'
|
||||
}))
|
||||
})
|
||||
|
||||
await value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect([...files]).toEqual([...originals])
|
||||
expect(value.prepare).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('restores persisted pre-commit metadata when resumed Runtime adoption fails', async () => {
|
||||
const originalTarget = target()
|
||||
const originals = new Map<string, Buffer>(
|
||||
REMOTE_ENVIRONMENT_METADATA_PATHS.map(
|
||||
(path, index) => [
|
||||
path,
|
||||
Buffer.from(`original-${index}`)
|
||||
]
|
||||
)
|
||||
)
|
||||
const files = new Map<string, Buffer>(
|
||||
REMOTE_ENVIRONMENT_METADATA_PATHS.map(
|
||||
(path) => [path, Buffer.from('published')]
|
||||
)
|
||||
)
|
||||
const runtimeFailure = new Error('runtime recovery failed')
|
||||
const value = fixture({
|
||||
targets: [originalTarget],
|
||||
metadataFiles: files,
|
||||
pendingOperation: {
|
||||
version: 2,
|
||||
hostId: HOST_ID,
|
||||
targetIdentity:
|
||||
remoteHostRecoveryIdentityKey(originalTarget),
|
||||
operationId:
|
||||
'00000000-0000-4000-8000-000000000779',
|
||||
size: candidate.size,
|
||||
sha256: candidate.sha256,
|
||||
urls: candidate.urls,
|
||||
metadataSnapshots: [...originals].map(
|
||||
([path, contents]) => ({
|
||||
path: path as never,
|
||||
uid: 1000,
|
||||
contentsBase64: contents.toString('base64')
|
||||
})
|
||||
)
|
||||
},
|
||||
activateAgent: vi.fn(async () => {
|
||||
for (const path of REMOTE_ENVIRONMENT_METADATA_PATHS) {
|
||||
files.set(path, Buffer.from('adopting'))
|
||||
}
|
||||
return { exitCode: 0, stdout: '{}', stderr: '' }
|
||||
}),
|
||||
activateRuntime: vi.fn(async () => {
|
||||
throw runtimeFailure
|
||||
})
|
||||
})
|
||||
|
||||
await expect(value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)).rejects.toThrow('runtime recovery failed')
|
||||
expect([...files]).toEqual([...originals])
|
||||
expect(value.stopCandidate).toHaveBeenCalledOnce()
|
||||
expect(value.cleanup).not.toHaveBeenCalled()
|
||||
expect(value.operationStore.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains pending recovery evidence when the real SSH target changes', async () => {
|
||||
const originalTarget = target()
|
||||
const changedTarget = target({
|
||||
@@ -802,12 +1025,12 @@ describe('RemoteEnvironmentPreparer', () => {
|
||||
const value = fixture({
|
||||
metadataFiles: files,
|
||||
activateAgent: vi.fn(async () => {
|
||||
value.calls.push('agent')
|
||||
value.metadataFiles.clear()
|
||||
for (const path of REMOTE_ENVIRONMENT_METADATA_PATHS) {
|
||||
value.metadataFiles.set(path, Buffer.from('new'))
|
||||
}
|
||||
return identity.agent
|
||||
value.calls.push('agent')
|
||||
return { exitCode: 0, stdout: '{}', stderr: '' }
|
||||
}),
|
||||
activateRuntime: vi.fn(async () => {
|
||||
throw runtimeFailure
|
||||
@@ -838,7 +1061,7 @@ describe('RemoteEnvironmentPreparer', () => {
|
||||
metadataReplaceFailure: rollbackFailure,
|
||||
activateAgent: vi.fn(async () => {
|
||||
files.set(path, Buffer.from('changed'))
|
||||
return identity.agent
|
||||
return { exitCode: 0, stdout: '{}', stderr: '' }
|
||||
}),
|
||||
activateRuntime: vi.fn(async () => {
|
||||
throw runtimeFailure
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import type { StagedSftp } from '../ssh/bounded-sftp'
|
||||
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
|
||||
import type {
|
||||
AgentDiagnosticResult,
|
||||
SshConnectionLease,
|
||||
SshConnectionPool,
|
||||
SshRemotePackageBootstrapLease
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
type SshRemotePackageIdentity
|
||||
} from '../ssh/ssh-remote-package-bootstrap'
|
||||
import type {
|
||||
AgentInstallationManager,
|
||||
AgentInstallationTargetResolver
|
||||
} from './agent-installation-manager'
|
||||
import type {
|
||||
@@ -38,26 +38,41 @@ import {
|
||||
snapshotRemoteEnvironmentMetadata,
|
||||
type RemoteEnvironmentMetadataSnapshot
|
||||
} from './remote-environment-metadata'
|
||||
import {
|
||||
mapAgentInstallationPhase,
|
||||
mapRuntimeInstallationPhase
|
||||
} from './remote-environment-progress'
|
||||
import type {
|
||||
PersistedRemoteEnvironmentMetadataSnapshot,
|
||||
RemoteEnvironmentOperationStore
|
||||
} from './remote-environment-operation-store'
|
||||
import type {
|
||||
RemoteEnvironmentPreparer as Preparer,
|
||||
RemoteEnvironmentUpdateProgressObserver
|
||||
} from './remote-environment-update-service'
|
||||
import type {
|
||||
RemoteRuntimeInstallationManager
|
||||
} from './remote-runtime-installation-manager'
|
||||
import {
|
||||
remoteHostRecoveryIdentityKey
|
||||
} from './remote-host-target-identity'
|
||||
import { boundedDiagnostic } from './bounded-diagnostic'
|
||||
|
||||
const MAXIMUM_PACKAGE_BYTES = 512 * 1024 * 1024
|
||||
const MAXIMUM_METADATA_BYTES = 1024 * 1024
|
||||
const MAXIMUM_DIAGNOSTIC_CHARACTERS = 800
|
||||
|
||||
function assertAgentCommandSucceeded(
|
||||
result: AgentDiagnosticResult,
|
||||
label: string
|
||||
): void {
|
||||
if (result.exitCode === 0) {
|
||||
return
|
||||
}
|
||||
const detail = `${result.stderr}\n${result.stdout}`
|
||||
const bounded = boundedDiagnostic(
|
||||
detail,
|
||||
MAXIMUM_DIAGNOSTIC_CHARACTERS
|
||||
)
|
||||
throw new Error(
|
||||
bounded.length > 0
|
||||
? `${label}失败:${bounded}`
|
||||
: `${label}失败,退出码 ${String(result.exitCode)}`
|
||||
)
|
||||
}
|
||||
|
||||
export type RemoteEnvironmentPreparerOptions = {
|
||||
resolver: AgentInstallationTargetResolver
|
||||
@@ -71,14 +86,6 @@ export type RemoteEnvironmentPreparerOptions = {
|
||||
| 'acquireInstallArchive'
|
||||
| 'acquireGoodBuddyInstallArchive'
|
||||
>
|
||||
agentInstallationManager: Pick<
|
||||
AgentInstallationManager,
|
||||
'activatePublished'
|
||||
>
|
||||
runtimeInstallationManager: Pick<
|
||||
RemoteRuntimeInstallationManager,
|
||||
'activatePublished'
|
||||
>
|
||||
operationStore: Pick<
|
||||
RemoteEnvironmentOperationStore,
|
||||
'load' | 'save' | 'remove'
|
||||
@@ -95,10 +102,6 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
readonly #sshPool: RemoteEnvironmentPreparerOptions['sshPool']
|
||||
readonly #agentPackageManager:
|
||||
RemoteEnvironmentPreparerOptions['agentPackageManager']
|
||||
readonly #agentInstallationManager:
|
||||
RemoteEnvironmentPreparerOptions['agentInstallationManager']
|
||||
readonly #runtimeInstallationManager:
|
||||
RemoteEnvironmentPreparerOptions['runtimeInstallationManager']
|
||||
readonly #operationStore:
|
||||
RemoteEnvironmentPreparerOptions['operationStore']
|
||||
|
||||
@@ -106,10 +109,6 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
this.#resolver = options.resolver
|
||||
this.#sshPool = options.sshPool
|
||||
this.#agentPackageManager = options.agentPackageManager
|
||||
this.#agentInstallationManager =
|
||||
options.agentInstallationManager
|
||||
this.#runtimeInstallationManager =
|
||||
options.runtimeInstallationManager
|
||||
this.#operationStore = options.operationStore
|
||||
}
|
||||
|
||||
@@ -135,12 +134,15 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
const target = await this.#resolver.resolve(hostId)
|
||||
signal.throwIfAborted()
|
||||
assertValidSshConnectionTarget(hostId, target)
|
||||
await this.#reconcilePendingOperation(
|
||||
const recovered = await this.#reconcilePendingOperation(
|
||||
hostId,
|
||||
target,
|
||||
emit,
|
||||
signal
|
||||
)
|
||||
if (recovered) {
|
||||
return
|
||||
}
|
||||
|
||||
let probeLease: SshConnectionLease | undefined
|
||||
let bootstrapLease:
|
||||
@@ -304,6 +306,17 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
probe.uid,
|
||||
signal
|
||||
)
|
||||
await this.#operationStore.save({
|
||||
version: 2,
|
||||
hostId,
|
||||
targetIdentity: remoteHostRecoveryIdentityKey(target),
|
||||
operationId,
|
||||
size: remoteCandidate.size,
|
||||
sha256: remoteCandidate.sha256,
|
||||
urls: [...remoteCandidate.urls],
|
||||
metadataSnapshots:
|
||||
persistMetadataSnapshots(metadataSnapshots)
|
||||
})
|
||||
|
||||
// Keep the full target re-resolution immediately adjacent to commit.
|
||||
await assertTargetCurrent(this.#resolver, target, signal)
|
||||
@@ -319,7 +332,6 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
}
|
||||
}
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
if (!committed.committed) {
|
||||
if (
|
||||
committed.reason ===
|
||||
@@ -330,7 +342,7 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`远端安装提交失败:${committed.reason}`
|
||||
`远端安装提交失败:${committed.detail ?? committed.reason}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
@@ -344,52 +356,34 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
}
|
||||
assertPackageIdentity(committed.identity, candidate)
|
||||
committedIdentity = committed.identity
|
||||
|
||||
await assertTargetCurrent(this.#resolver, target, signal)
|
||||
const activeAgent =
|
||||
await this.#agentInstallationManager.activatePublished(
|
||||
hostId,
|
||||
committed.identity.agent,
|
||||
{
|
||||
signal,
|
||||
onProgress: (phase) => {
|
||||
const mapped = mapAgentInstallationPhase(phase)
|
||||
if (mapped) {
|
||||
emit(method, mapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
await assertTargetCurrent(this.#resolver, target, signal)
|
||||
|
||||
await this.#runtimeInstallationManager.activatePublished(
|
||||
hostId,
|
||||
committed.identity.runtime,
|
||||
{
|
||||
agentInstallationId: activeAgent.installationId,
|
||||
signal,
|
||||
onProgress: (phase) => {
|
||||
const mapped = mapRuntimeInstallationPhase(phase)
|
||||
if (mapped) {
|
||||
emit(method, mapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
await assertTargetCurrent(this.#resolver, target, signal)
|
||||
|
||||
const cleanup = await bootstrapLease.cleanup(operationId, {
|
||||
await this.#adoptCommittedEnvironment(
|
||||
probeLease,
|
||||
committed.identity,
|
||||
method,
|
||||
emit,
|
||||
signal
|
||||
})
|
||||
if (!cleanup.cleaned) {
|
||||
throw new Error(
|
||||
`远程环境已安装,但暂存文件清理失败:${cleanup.reason}`
|
||||
)
|
||||
|
||||
emit(method, 'finalizing')
|
||||
try {
|
||||
const cleanup = await bootstrapLease.cleanup(
|
||||
operationId,
|
||||
{ signal }
|
||||
)
|
||||
if (cleanup.cleaned) {
|
||||
operationCleaned = true
|
||||
try {
|
||||
await this.#operationStore.remove(hostId)
|
||||
} catch {
|
||||
// A stale local receipt is harmless after remote cleanup.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// The environment is already healthy. Leave the bounded
|
||||
// operation for a later maintenance retry.
|
||||
}
|
||||
operationCleaned = true
|
||||
await this.#operationStore.remove(hostId)
|
||||
} catch (error) {
|
||||
let rollbackError: unknown
|
||||
if (
|
||||
@@ -440,6 +434,48 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
}
|
||||
}
|
||||
|
||||
async #adoptCommittedEnvironment(
|
||||
lease: SshConnectionLease,
|
||||
identity: SshRemotePackageIdentity,
|
||||
method: RemoteEnvironmentPreparationMethod,
|
||||
emit: (
|
||||
method: RemoteEnvironmentPreparationMethod,
|
||||
phase: RemoteEnvironmentUpdateProgress['phase']
|
||||
) => void,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const installationId = verifyAgentInstallationId(
|
||||
identity.agent.installationId
|
||||
)
|
||||
emit(method, 'installing-agent')
|
||||
assertAgentCommandSucceeded(
|
||||
await lease.runAgentLifecycleAction(
|
||||
installationId,
|
||||
'adopt',
|
||||
signal
|
||||
),
|
||||
'GoodBuddy Agent 启动与健康检查'
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
|
||||
emit(method, 'installing-runtime')
|
||||
assertAgentCommandSucceeded(
|
||||
await lease.runAgentRuntimeAction(
|
||||
installationId,
|
||||
{
|
||||
kind: 'runtime-activate',
|
||||
runtimeId: identity.runtime.runtimeId,
|
||||
bundleDigest: identity.runtime.bundleDigest,
|
||||
architecture: identity.runtime.architecture,
|
||||
forceVerification: true
|
||||
},
|
||||
signal
|
||||
),
|
||||
'OpenCode Runtime 激活'
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
|
||||
async #prepareUploadedArchive(options: {
|
||||
probeLease: SshConnectionLease
|
||||
bootstrapLease: SshRemotePackageBootstrapLease
|
||||
@@ -567,10 +603,10 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
phase: RemoteEnvironmentUpdateProgress['phase']
|
||||
) => void,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
const pending = await this.#operationStore.load(hostId)
|
||||
if (!pending) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (
|
||||
pending.targetIdentity !==
|
||||
@@ -587,16 +623,42 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
sha256: pending.sha256,
|
||||
urls: pending.urls
|
||||
}
|
||||
const lease =
|
||||
const bootstrapLease =
|
||||
await this.#sshPool.acquireRemotePackageBootstrap(
|
||||
target,
|
||||
signal
|
||||
)
|
||||
let commandLease: SshConnectionLease | undefined
|
||||
let recoveryMetadataSftp: StagedSftp | undefined
|
||||
try {
|
||||
assertLeaseMatchesTarget(lease, target)
|
||||
const status = await lease.commitStatus(candidate, {
|
||||
signal
|
||||
})
|
||||
assertLeaseMatchesTarget(bootstrapLease, target)
|
||||
if (pending.version === 1) {
|
||||
const cleanup = await bootstrapLease.cleanup(
|
||||
pending.operationId,
|
||||
{ signal }
|
||||
)
|
||||
if (
|
||||
!cleanup.cleaned &&
|
||||
cleanup.reason !== 'operation-unavailable'
|
||||
) {
|
||||
throw new Error(
|
||||
`旧版远程环境暂存清理失败:${cleanup.reason}`
|
||||
)
|
||||
}
|
||||
await this.#operationStore.remove(hostId)
|
||||
return false
|
||||
}
|
||||
const status = await bootstrapLease.commitStatus(
|
||||
candidate,
|
||||
{ signal }
|
||||
)
|
||||
if (
|
||||
!status.committed &&
|
||||
status.reason === 'operation-unavailable'
|
||||
) {
|
||||
await this.#operationStore.remove(hostId)
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!status.committed &&
|
||||
status.reason === 'installer-rollback-incomplete'
|
||||
@@ -605,19 +667,83 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
'上一次远程环境发布失败且回滚结果不完整,请检查 Host 状态'
|
||||
)
|
||||
}
|
||||
const cleanup = await lease.cleanup(
|
||||
commandLease = await this.#sshPool.acquire(
|
||||
target,
|
||||
signal
|
||||
)
|
||||
assertLeaseMatchesTarget(commandLease, target)
|
||||
const probe =
|
||||
await commandLease.runAgentBootstrapProbe(signal)
|
||||
if (!probe.ready) {
|
||||
throw new Error(
|
||||
`远端系统不能恢复 GoodBuddy 环境:${probe.reason}`
|
||||
)
|
||||
}
|
||||
recoveryMetadataSftp =
|
||||
await commandLease.openStagedSftp(
|
||||
probe.canonicalHomeDirectory,
|
||||
{
|
||||
maximumFileBytes: MAXIMUM_METADATA_BYTES,
|
||||
maximumTotalBytes:
|
||||
MAXIMUM_METADATA_BYTES * 24,
|
||||
maximumOperations: 128
|
||||
},
|
||||
signal
|
||||
)
|
||||
const recoverySnapshots =
|
||||
restorePersistedMetadataSnapshots(
|
||||
pending.metadataSnapshots,
|
||||
probe.uid
|
||||
)
|
||||
if (status.committed) {
|
||||
try {
|
||||
await this.#adoptCommittedEnvironment(
|
||||
commandLease,
|
||||
status.identity,
|
||||
'auto',
|
||||
emit,
|
||||
signal
|
||||
)
|
||||
} catch (error) {
|
||||
await stopCandidateBestEffort(
|
||||
commandLease,
|
||||
status.identity.agent.installationId
|
||||
)
|
||||
try {
|
||||
await restoreRemoteEnvironmentMetadata(
|
||||
recoveryMetadataSftp,
|
||||
recoverySnapshots
|
||||
)
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
'上一次远程环境恢复失败,且原有环境元数据未能完整恢复',
|
||||
{ cause: rollbackError }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
await restoreRemoteEnvironmentMetadata(
|
||||
recoveryMetadataSftp,
|
||||
recoverySnapshots
|
||||
)
|
||||
}
|
||||
const cleanup = await bootstrapLease.cleanup(
|
||||
pending.operationId,
|
||||
{ signal }
|
||||
)
|
||||
if (
|
||||
!cleanup.cleaned &&
|
||||
cleanup.reason !== 'operation-unavailable'
|
||||
cleanup.cleaned ||
|
||||
cleanup.reason === 'operation-unavailable'
|
||||
) {
|
||||
await this.#operationStore.remove(hostId)
|
||||
} else if (!status.committed) {
|
||||
throw new Error(
|
||||
`上一次远程环境暂存清理失败:${cleanup.reason}`
|
||||
`上一次未提交的远程环境暂存清理失败:${cleanup.reason}`
|
||||
)
|
||||
}
|
||||
await this.#operationStore.remove(hostId)
|
||||
return status.committed
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
@@ -625,15 +751,59 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
const detail = error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
throw new SshRemotePackageCommitIndeterminateError(
|
||||
'上一次远程环境提交结果仍不确定,请稍后重试'
|
||||
`上一次远程环境恢复失败:${detail}`
|
||||
)
|
||||
} finally {
|
||||
lease.release()
|
||||
recoveryMetadataSftp?.close()
|
||||
commandLease?.release()
|
||||
bootstrapLease.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function persistMetadataSnapshots(
|
||||
snapshots: readonly RemoteEnvironmentMetadataSnapshot[]
|
||||
): readonly PersistedRemoteEnvironmentMetadataSnapshot[] {
|
||||
return snapshots.map((snapshot) => ({
|
||||
path: snapshot.path,
|
||||
uid: snapshot.uid,
|
||||
...(snapshot.contents === undefined
|
||||
? {}
|
||||
: {
|
||||
contentsBase64:
|
||||
snapshot.contents.toString('base64')
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
function restorePersistedMetadataSnapshots(
|
||||
snapshots:
|
||||
readonly PersistedRemoteEnvironmentMetadataSnapshot[],
|
||||
uid: number
|
||||
): readonly RemoteEnvironmentMetadataSnapshot[] {
|
||||
if (snapshots.some((snapshot) => snapshot.uid !== uid)) {
|
||||
throw new Error(
|
||||
'远程环境恢复元数据与当前 SSH 用户不匹配'
|
||||
)
|
||||
}
|
||||
return snapshots.map((snapshot) => ({
|
||||
path: snapshot.path,
|
||||
uid: snapshot.uid,
|
||||
...(snapshot.contentsBase64 === undefined
|
||||
? {}
|
||||
: {
|
||||
contents: Buffer.from(
|
||||
snapshot.contentsBase64,
|
||||
'base64'
|
||||
)
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
async function resolvePreparationMethod(
|
||||
requested: RemoteEnvironmentPreparationMethod,
|
||||
lease: SshRemotePackageBootstrapLease,
|
||||
@@ -660,7 +830,7 @@ function assertPrepared(
|
||||
throw new Error(
|
||||
prepared.unavailable
|
||||
? `远端环境准备不可用:${prepared.reason}`
|
||||
: `远端环境准备失败:${prepared.reason}`
|
||||
: `远端环境准备失败:${prepared.detail ?? prepared.reason}`
|
||||
)
|
||||
}
|
||||
if (prepared.operationId !== operationId) {
|
||||
@@ -809,7 +979,6 @@ function mapBootstrapPhase(
|
||||
case 'verifying-zip':
|
||||
case 'verifying-payload':
|
||||
case 'validating-prepared-state':
|
||||
case 'revalidating-archive':
|
||||
return 'verifying'
|
||||
case 'extracting':
|
||||
case 'preparing':
|
||||
@@ -819,6 +988,7 @@ function mapBootstrapPhase(
|
||||
case 'persisting-prepared-state':
|
||||
case 'extracting-payload':
|
||||
case 'publishing-content':
|
||||
case 'publishing-metadata':
|
||||
case 'cleaning':
|
||||
return 'applying'
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { RemoteEnvironmentUpdateProgress } from '../../shared/ssh-host-contracts'
|
||||
import type { AgentInstallationPhase } from './agent-installation-manager'
|
||||
import type { RemoteRuntimeInstallationPhase } from './remote-runtime-installation-manager'
|
||||
|
||||
export function mapAgentInstallationPhase(
|
||||
phase: AgentInstallationPhase
|
||||
): RemoteEnvironmentUpdateProgress['phase'] | undefined {
|
||||
switch (phase) {
|
||||
case 'inspecting-host':
|
||||
return 'probing'
|
||||
case 'verifying-bundle':
|
||||
return 'verifying'
|
||||
case 'starting-agent':
|
||||
return 'installing-agent'
|
||||
case 'checking-health':
|
||||
return 'checking-health'
|
||||
case 'complete':
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function mapRuntimeInstallationPhase(
|
||||
phase: RemoteRuntimeInstallationPhase
|
||||
): RemoteEnvironmentUpdateProgress['phase'] | undefined {
|
||||
switch (phase) {
|
||||
case 'inspecting-host':
|
||||
return 'probing'
|
||||
case 'verifying-bundle':
|
||||
return 'verifying'
|
||||
case 'activating-runtime':
|
||||
return 'installing-runtime'
|
||||
case 'complete':
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ export const AGENT_BOOTSTRAP_PROBE_COMMAND = [
|
||||
].join('; ')
|
||||
|
||||
const lifecycleActions = [
|
||||
'adopt',
|
||||
'bootstrap',
|
||||
'health',
|
||||
'status',
|
||||
@@ -48,6 +49,7 @@ export type AgentRuntimeActivationAction = {
|
||||
runtimeId: typeof RUNTIME_ID
|
||||
bundleDigest: string
|
||||
architecture: AgentRuntimeArchitecture
|
||||
forceVerification?: true
|
||||
}
|
||||
|
||||
export type FixedAgentSshAction =
|
||||
@@ -232,7 +234,10 @@ export function buildFixedAgentCliArgv(
|
||||
'--bundle-digest',
|
||||
action.bundleDigest,
|
||||
'--architecture',
|
||||
action.architecture
|
||||
action.architecture,
|
||||
...(action.forceVerification === true
|
||||
? ['--force-verification', 'true']
|
||||
: [])
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,10 +508,13 @@ describe("fixed SSH remote package bootstrap", () => {
|
||||
SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT.match(
|
||||
/\[ "\$installer_output_size" -gt 0 \]/gu,
|
||||
),
|
||||
).toHaveLength(4);
|
||||
).toHaveLength(3);
|
||||
expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain(
|
||||
'[ "$installer_output_size" -le 49152 ]',
|
||||
);
|
||||
expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain(
|
||||
'action=commit',
|
||||
);
|
||||
});
|
||||
|
||||
it("parses bounded split NDJSON progress and a prepared result", async () => {
|
||||
@@ -677,6 +680,7 @@ describe("fixed SSH remote package bootstrap", () => {
|
||||
prepared: false,
|
||||
unavailable: false,
|
||||
reason: "installer-failed",
|
||||
detail: "archive verification failed",
|
||||
});
|
||||
|
||||
const rollbackFailure = createChannel((_payload, active) => {
|
||||
@@ -692,6 +696,7 @@ describe("fixed SSH remote package bootstrap", () => {
|
||||
).resolves.toEqual({
|
||||
committed: false,
|
||||
reason: "installer-rollback-incomplete",
|
||||
detail: "managed destination restore failed",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -46,9 +46,9 @@ const progressPhases = [
|
||||
"verifying-payload",
|
||||
"persisting-prepared-state",
|
||||
"validating-prepared-state",
|
||||
"revalidating-archive",
|
||||
"extracting-payload",
|
||||
"publishing-content",
|
||||
"publishing-metadata",
|
||||
] as const;
|
||||
|
||||
const unavailableReasons = [
|
||||
@@ -166,6 +166,7 @@ export type SshRemotePackageBootstrapPrepareResult =
|
||||
prepared: false;
|
||||
unavailable: false;
|
||||
reason: SshRemotePackageBootstrapFailureReason;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type SshRemotePackageUploadStagingResult =
|
||||
@@ -195,6 +196,7 @@ export type SshRemotePackageBootstrapCommitResult =
|
||||
| {
|
||||
committed: false;
|
||||
reason: SshRemotePackageBootstrapFailureReason;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type SshRemotePackageBootstrapCleanupResult =
|
||||
@@ -268,6 +270,7 @@ type TerminalMessage =
|
||||
type: "result";
|
||||
status: "failed";
|
||||
reason: SshRemotePackageBootstrapFailureReason;
|
||||
detail?: string;
|
||||
}
|
||||
| {
|
||||
type: "result";
|
||||
@@ -467,23 +470,29 @@ if [ "$action" = prepare-status ]; then
|
||||
fi
|
||||
if [ "$action" = commit-status ]; then
|
||||
installer_output="$operation_root/installer-commit.ndjson"
|
||||
if [ ! -d "$operation_root" ] || [ -L "$operation_root" ] ||
|
||||
[ ! -f "$installer_output" ] || [ -L "$installer_output" ]; then
|
||||
stop failed operation-unavailable
|
||||
if [ -d "$operation_root" ] && [ ! -L "$operation_root" ] &&
|
||||
[ -f "$installer_output" ] && [ ! -L "$installer_output" ]; then
|
||||
installer_output_size=$(wc -c < "$installer_output" 2>/dev/null) ||
|
||||
stop failed operation-unavailable
|
||||
set -- $installer_output_size
|
||||
installer_output_size=${SHELL_FIRST_POSITIONAL_OR_EMPTY}
|
||||
case "$installer_output_size" in
|
||||
""|*[!0-9]*) stop failed operation-unavailable ;;
|
||||
esac
|
||||
[ "$installer_output_size" -le 49152 ] ||
|
||||
stop failed operation-unavailable
|
||||
terminal_line=$(tail -n 1 "$installer_output" 2>/dev/null) ||
|
||||
stop failed operation-unavailable
|
||||
if printf %s "$terminal_line" | grep '"type":"result"' >/dev/null 2>&1 ||
|
||||
printf %s "$terminal_line" | grep '"type":"error"' >/dev/null 2>&1; then
|
||||
cat -- "$installer_output" || stop failed operation-unavailable
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
installer_output_size=$(wc -c < "$installer_output" 2>/dev/null) ||
|
||||
stop failed operation-unavailable
|
||||
set -- $installer_output_size
|
||||
installer_output_size=${SHELL_FIRST_POSITIONAL_OR_EMPTY}
|
||||
case "$installer_output_size" in
|
||||
""|*[!0-9]*) stop failed operation-unavailable ;;
|
||||
esac
|
||||
[ "$installer_output_size" -gt 0 ] ||
|
||||
stop failed operation-unavailable
|
||||
[ "$installer_output_size" -le 49152 ] ||
|
||||
stop failed operation-unavailable
|
||||
cat -- "$installer_output" || stop failed operation-unavailable
|
||||
exit 0
|
||||
# A process or channel loss may leave a partial publication without a
|
||||
# terminal line. The installer commit is idempotent and completes it from
|
||||
# the authenticated prepared state.
|
||||
action=commit
|
||||
fi
|
||||
if [ "$action" = commit ]; then
|
||||
if [ ! -d "$operation_root" ] || [ -L "$operation_root" ] ||
|
||||
@@ -1109,6 +1118,7 @@ function parseMessage(
|
||||
message.status === "rollback-incomplete"
|
||||
? "installer-rollback-incomplete"
|
||||
: "installer-failed",
|
||||
detail: message.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1444,6 +1454,9 @@ export function createSshRemotePackageBootstrapExecutor(
|
||||
prepared: false,
|
||||
unavailable: false,
|
||||
reason: result.reason,
|
||||
...(result.detail === undefined
|
||||
? {}
|
||||
: { detail: result.detail }),
|
||||
};
|
||||
}
|
||||
throw new Error("Agent 远程安装准备返回了无效结果");
|
||||
@@ -1473,7 +1486,13 @@ export function createSshRemotePackageBootstrapExecutor(
|
||||
};
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
return { committed: false, reason: result.reason };
|
||||
return {
|
||||
committed: false,
|
||||
reason: result.reason,
|
||||
...(result.detail === undefined
|
||||
? {}
|
||||
: { detail: result.detail }),
|
||||
};
|
||||
}
|
||||
throw new Error("Agent 远程安装提交状态返回了无效结果");
|
||||
};
|
||||
@@ -1582,7 +1601,13 @@ export function createSshRemotePackageBootstrapExecutor(
|
||||
};
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
return { committed: false, reason: result.reason };
|
||||
return {
|
||||
committed: false,
|
||||
reason: result.reason,
|
||||
...(result.detail === undefined
|
||||
? {}
|
||||
: { detail: result.detail }),
|
||||
};
|
||||
}
|
||||
throw new Error("Agent 远程安装提交返回了无效结果");
|
||||
},
|
||||
|
||||
@@ -33,6 +33,10 @@ import {
|
||||
type RemoteRuntimeBundleManifest,
|
||||
type RemoteRuntimeLock
|
||||
} from '../remote-runtime-launch-contracts'
|
||||
import {
|
||||
runtimeRegistryEntrySchema,
|
||||
type RuntimeRegistryEntry
|
||||
} from '../remote-environment-registry-contracts'
|
||||
|
||||
export type InstalledBundleVerificationEnvironment =
|
||||
| 'production'
|
||||
@@ -63,6 +67,11 @@ export type VerifyRuntimeBundleOptions = {
|
||||
uid?: number
|
||||
}
|
||||
|
||||
export type LoadRegisteredRuntimeBundleOptions =
|
||||
VerifyRuntimeBundleOptions & {
|
||||
registered: RuntimeRegistryEntry
|
||||
}
|
||||
|
||||
export async function verifyRuntimeBundle(
|
||||
bundleDirectoryInput: string,
|
||||
options: VerifyRuntimeBundleOptions
|
||||
@@ -179,6 +188,121 @@ export async function verifyRuntimeBundle(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs a Runtime identity that was fully verified when it was
|
||||
* registered. Normal capabilities and prompt launches use this bounded
|
||||
* metadata path instead of hashing the complete Runtime payload again.
|
||||
*/
|
||||
export async function loadRegisteredRuntimeBundle(
|
||||
bundleDirectoryInput: string,
|
||||
options: LoadRegisteredRuntimeBundleOptions
|
||||
): Promise<VerifiedRuntimeBundle> {
|
||||
const bundleDirectory = assertAbsoluteManagedPath(
|
||||
resolve(bundleDirectoryInput)
|
||||
)
|
||||
const registered = runtimeRegistryEntrySchema.parse(
|
||||
options.registered
|
||||
)
|
||||
const rootStat = await lstat(bundleDirectory)
|
||||
assertOwnedDirectory(
|
||||
rootStat,
|
||||
'Runtime bundle directory',
|
||||
options.uid
|
||||
)
|
||||
if (
|
||||
shouldEnforceMode(options) &&
|
||||
modeString(rootStat.mode) !== '0700'
|
||||
) {
|
||||
throw new Error(
|
||||
'Runtime bundle directory permissions must be 0700'
|
||||
)
|
||||
}
|
||||
if (
|
||||
basename(bundleDirectory) !==
|
||||
registered.bundleDigest.slice('sha256:'.length)
|
||||
) {
|
||||
throw new Error(
|
||||
'Registered Runtime digest does not match its managed directory'
|
||||
)
|
||||
}
|
||||
|
||||
const manifestBytes = await readMetadataFile(
|
||||
join(bundleDirectory, MANIFEST_FILE_NAME),
|
||||
'Runtime manifest',
|
||||
options
|
||||
)
|
||||
const signatureBytes = await readSignature(
|
||||
join(bundleDirectory, SIGNATURE_FILE_NAME),
|
||||
options
|
||||
)
|
||||
const manifest = await verifyRuntimeManifestSignature(
|
||||
manifestBytes,
|
||||
signatureBytes,
|
||||
options.releaseKeyRegistry,
|
||||
options.verificationEnvironment ?? 'production'
|
||||
)
|
||||
assertRuntimeManifestMatchesLock(
|
||||
manifest,
|
||||
remoteRuntimeLockSchema.parse(options.runtimeLock),
|
||||
options.architecture
|
||||
)
|
||||
const manifestDigest =
|
||||
await digestRemoteRuntimeBundleManifest(manifest)
|
||||
if (
|
||||
manifest.runtimeId !== registered.runtimeId ||
|
||||
manifest.runtimeVersion !== registered.runtimeVersion ||
|
||||
manifest.architecture !== registered.architecture ||
|
||||
manifest.bundleDigest !== registered.bundleDigest ||
|
||||
manifestDigest !== registered.manifestDigest ||
|
||||
(
|
||||
registered.runtimeAdapterDigest !== undefined &&
|
||||
manifest.adapterDigest !== registered.runtimeAdapterDigest
|
||||
) ||
|
||||
manifest.acpCapabilitiesDigest !==
|
||||
registered.acpCapabilitiesDigest
|
||||
) {
|
||||
throw new Error(
|
||||
'Runtime manifest does not match its registered identity'
|
||||
)
|
||||
}
|
||||
|
||||
const executableRecord = manifest.files.find(
|
||||
(file) => file.path === manifest.entrypoint.path
|
||||
)
|
||||
if (executableRecord === undefined) {
|
||||
throw new Error(
|
||||
'Registered Runtime entrypoint is not declared'
|
||||
)
|
||||
}
|
||||
const executablePath = join(
|
||||
bundleDirectory,
|
||||
...manifest.entrypoint.path.split('/')
|
||||
)
|
||||
const executableStat = await lstat(executablePath)
|
||||
assertOwnedRegularFile(
|
||||
executableStat,
|
||||
'Runtime executable',
|
||||
options.uid
|
||||
)
|
||||
if (
|
||||
executableStat.size !== executableRecord.size ||
|
||||
(
|
||||
shouldEnforceMode(options) &&
|
||||
modeString(executableStat.mode) !== executableRecord.mode
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Registered Runtime entrypoint metadata changed'
|
||||
)
|
||||
}
|
||||
return {
|
||||
bundleDirectory,
|
||||
executablePath,
|
||||
manifest,
|
||||
manifestDigest
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalRuntimeManifestBytes(
|
||||
manifest: RemoteRuntimeBundleManifest
|
||||
): Buffer {
|
||||
|
||||
@@ -16,7 +16,14 @@ export const installationRegistryEntrySchema = z
|
||||
installationId: installationRegistryIdSchema,
|
||||
agentVersion: componentVersionSchema,
|
||||
manifestSha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
arch: z.enum(['x64', 'arm64'])
|
||||
arch: z.enum(['x64', 'arm64']),
|
||||
protocol: z
|
||||
.object({
|
||||
major: z.number().int().min(0).max(65_535),
|
||||
minor: z.number().int().min(0).max(65_535)
|
||||
})
|
||||
.strict()
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -137,6 +144,7 @@ export const runtimeRegistryEntrySchema = z
|
||||
architecture: z.enum(['x64', 'arm64']),
|
||||
bundleDigest: sha256DigestSchema,
|
||||
manifestDigest: sha256DigestSchema,
|
||||
runtimeAdapterDigest: sha256DigestSchema.optional(),
|
||||
acpCapabilitiesDigest: sha256DigestSchema
|
||||
})
|
||||
.strict()
|
||||
@@ -219,7 +227,10 @@ function canonicalInstallationEntry(
|
||||
installationId: entry.installationId,
|
||||
agentVersion: entry.agentVersion,
|
||||
manifestSha256: entry.manifestSha256,
|
||||
arch: entry.arch
|
||||
arch: entry.arch,
|
||||
...(entry.protocol === undefined
|
||||
? {}
|
||||
: { protocol: entry.protocol })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -233,6 +244,12 @@ function canonicalRuntimeEntry(
|
||||
architecture: entry.architecture,
|
||||
bundleDigest: entry.bundleDigest,
|
||||
manifestDigest: entry.manifestDigest,
|
||||
...(entry.runtimeAdapterDigest === undefined
|
||||
? {}
|
||||
: {
|
||||
runtimeAdapterDigest:
|
||||
entry.runtimeAdapterDigest
|
||||
}),
|
||||
acpCapabilitiesDigest: entry.acpCapabilitiesDigest
|
||||
})
|
||||
}
|
||||
@@ -245,6 +262,7 @@ function installationEntryEquals(
|
||||
left.installationId === right.installationId &&
|
||||
left.agentVersion === right.agentVersion &&
|
||||
left.manifestSha256 === right.manifestSha256 &&
|
||||
left.arch === right.arch
|
||||
left.arch === right.arch &&
|
||||
JSON.stringify(left.protocol) === JSON.stringify(right.protocol)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user