Extend GoodBuddy Agent packaging, installation, SSH attachment, Runtime lifecycle, and Desktop package management to macOS arm64. Linux x64, Linux arm64, and Darwin arm64 share the existing signed catalog. Preserve Ask read-only behavior and Execute account permissions, and stop detached tool descendants during Mac cancellation. Upgrade Desktop before publishing the first mixed-platform catalog because older Linux-only readers reject Darwin entries. Intel Macs are not supported; no launchd service or administrator access is required. This commit does not publish a release. Validation: 3520 tests passed, 57 skipped; typecheck, lint, and development build passed. Real Mac installation, Ask/Execute, cancellation and reconnect passed; final Linux installation, Ask and reconnect passed. Nine real model requests were recorded. Release note: 支持将 Apple Silicon Mac 用作远程项目 Host,可安装 macOS arm64 Agent 并使用 Ask、Execute 和断线重连;与 Linux 共用一个包目录,发布混合目录前需升级 Desktop。
367 lines
11 KiB
TypeScript
367 lines
11 KiB
TypeScript
import type { AgentArchitecture } from '../../shared/agent-installation-contracts'
|
|
import type { AgentBootstrapIncompatibleReason } from '../../shared/ssh-host-contracts'
|
|
import {
|
|
parseRuntimeRegistryState
|
|
} from '../../shared/remote-environment-registry-contracts'
|
|
import {
|
|
remoteRuntimeBundleManifestSchema
|
|
} from '../../shared/remote-runtime-launch-contracts'
|
|
import type { StagedSftp } from '../ssh/bounded-sftp'
|
|
import type { VerifiedAgentInstallationId } from '../ssh/ssh-agent-command'
|
|
import type {
|
|
SshConnectionLease,
|
|
SshConnectionPool,
|
|
SshConnectionPoolTarget
|
|
} from '../ssh/ssh-connection-pool'
|
|
import { HostActivationCoordinator } from './host-activation-coordinator'
|
|
import { boundedDiagnostic } from './bounded-diagnostic'
|
|
import { isMissingPathError } from './path-errors'
|
|
import { remoteHostTargetIdentityKey } from './remote-host-target-identity'
|
|
|
|
const MAXIMUM_METADATA_BYTES = 1024 * 1024
|
|
const RUNTIME_REGISTRY_PATH =
|
|
'.goodbuddy/runtimes/registry.json'
|
|
const OPENCODE_ROOT = '.goodbuddy/runtimes/opencode'
|
|
|
|
export type RemoteRuntimeInstallationPhase =
|
|
| 'inspecting-host'
|
|
| 'verifying-bundle'
|
|
| 'activating-runtime'
|
|
| 'complete'
|
|
|
|
export type RemoteRuntimeInstallationIdentity = {
|
|
runtimeId: string
|
|
runtimeVersion: string
|
|
bundleDigest: string
|
|
manifestDigest: string
|
|
runtimeAdapterDigest: string
|
|
acpCapabilitiesDigest: string
|
|
platform: 'linux' | 'darwin'
|
|
architecture: AgentArchitecture
|
|
}
|
|
|
|
export interface RemoteRuntimeInstallationTargetResolver {
|
|
resolve(hostId: string): Promise<SshConnectionPoolTarget>
|
|
}
|
|
|
|
export type RemoteRuntimeActivationRequestOptions = {
|
|
signal?: AbortSignal
|
|
onProgress?: (phase: RemoteRuntimeInstallationPhase) => void
|
|
agentInstallationId?: VerifiedAgentInstallationId
|
|
}
|
|
|
|
export class RemoteRuntimeInstallationError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly reason:
|
|
| 'incompatible'
|
|
| 'corrupt'
|
|
| 'host-identity-changed'
|
|
| 'capacity'
|
|
| 'activation',
|
|
readonly detail?: AgentBootstrapIncompatibleReason | unknown
|
|
) {
|
|
super(message)
|
|
this.name = 'RemoteRuntimeInstallationError'
|
|
}
|
|
}
|
|
|
|
export type RemoteRuntimeActivator = (
|
|
lease: SshConnectionLease,
|
|
runtimeId: string,
|
|
bundleDigest: string,
|
|
architecture: AgentArchitecture,
|
|
agentInstallationId: VerifiedAgentInstallationId | undefined,
|
|
signal: AbortSignal
|
|
) => Promise<void>
|
|
|
|
export class RemoteRuntimeInstallationManager {
|
|
readonly #resolver: RemoteRuntimeInstallationTargetResolver
|
|
readonly #sshPool: Pick<SshConnectionPool, 'acquire'>
|
|
readonly #activate: RemoteRuntimeActivator
|
|
readonly #coordinator: HostActivationCoordinator<
|
|
RemoteRuntimeInstallationIdentity,
|
|
RemoteRuntimeInstallationPhase
|
|
>
|
|
|
|
constructor(options: {
|
|
resolver: RemoteRuntimeInstallationTargetResolver
|
|
sshPool: Pick<SshConnectionPool, 'acquire'>
|
|
activate: RemoteRuntimeActivator
|
|
maximumConcurrentHosts?: number
|
|
}) {
|
|
this.#resolver = options.resolver
|
|
this.#sshPool = options.sshPool
|
|
this.#activate = options.activate
|
|
this.#coordinator = new HostActivationCoordinator({
|
|
maximumConcurrentHosts:
|
|
options.maximumConcurrentHosts,
|
|
capacityError: () =>
|
|
new RemoteRuntimeInstallationError(
|
|
'Too many Runtime activations are already running',
|
|
'capacity'
|
|
),
|
|
disposeMessage:
|
|
'Remote Runtime activation manager is disposed'
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Loads the current Runtime registry entry and delegates bounded manifest
|
|
* and entrypoint checks to the Agent. Main never traverses Runtime payloads.
|
|
*/
|
|
async activateInstalled(
|
|
hostId: string,
|
|
options: RemoteRuntimeActivationRequestOptions = {}
|
|
): Promise<RemoteRuntimeInstallationIdentity> {
|
|
this.#coordinator.assertAvailable()
|
|
options.signal?.throwIfAborted()
|
|
emitOne(options.onProgress, 'inspecting-host')
|
|
const agentKey = options.agentInstallationId ?? ''
|
|
const earlyCached =
|
|
this.#coordinator.cachedForHost(hostId, agentKey)
|
|
if (earlyCached !== undefined) {
|
|
emitOne(options.onProgress, 'complete')
|
|
return earlyCached
|
|
}
|
|
const target = await this.#resolver.resolve(hostId)
|
|
this.#coordinator.assertAvailable()
|
|
options.signal?.throwIfAborted()
|
|
if (target.host.id !== hostId) {
|
|
throw new RemoteRuntimeInstallationError(
|
|
'Resolved SSH host does not match the Runtime activation request',
|
|
'host-identity-changed'
|
|
)
|
|
}
|
|
|
|
const targetKey = remoteHostTargetIdentityKey(target)
|
|
const cached = this.#coordinator.cached(
|
|
hostId,
|
|
targetKey,
|
|
agentKey
|
|
)
|
|
if (cached !== undefined) {
|
|
emitOne(options.onProgress, 'complete')
|
|
return cached
|
|
}
|
|
return this.#coordinator.run({
|
|
hostId,
|
|
targetKey,
|
|
variantKey: agentKey,
|
|
signal: options.signal,
|
|
onProgress: options.onProgress,
|
|
operation: (signal, progress) =>
|
|
this.#activateRegistered(
|
|
target,
|
|
options.agentInstallationId,
|
|
signal,
|
|
progress
|
|
)
|
|
})
|
|
}
|
|
|
|
invalidateHost(hostId: string): void {
|
|
this.#coordinator.invalidate(hostId)
|
|
}
|
|
|
|
dispose(): Promise<void> {
|
|
return this.#coordinator.dispose()
|
|
}
|
|
|
|
async #activateRegistered(
|
|
target: SshConnectionPoolTarget,
|
|
agentInstallationId: VerifiedAgentInstallationId | undefined,
|
|
signal: AbortSignal,
|
|
progress: (phase: RemoteRuntimeInstallationPhase) => void
|
|
): Promise<RemoteRuntimeInstallationIdentity> {
|
|
let lease: SshConnectionLease | undefined
|
|
let sftp: StagedSftp | undefined
|
|
try {
|
|
lease = await this.#sshPool.acquire(target, signal)
|
|
assertLeaseMatchesTarget(lease, target)
|
|
const probe = await lease.runAgentBootstrapProbe(signal)
|
|
if (!probe.ready) {
|
|
throw new RemoteRuntimeInstallationError(
|
|
`Remote host cannot activate OpenCode Runtime: ${probe.reason}`,
|
|
'incompatible',
|
|
probe.reason
|
|
)
|
|
}
|
|
if (
|
|
(probe.platform !== 'linux' && !(probe.platform === 'darwin' && probe.architecture === 'arm64')) ||
|
|
(
|
|
probe.architecture !== 'x64' &&
|
|
probe.architecture !== 'arm64'
|
|
)
|
|
) {
|
|
throw new RemoteRuntimeInstallationError(
|
|
'Remote host platform or architecture is unsupported',
|
|
'incompatible'
|
|
)
|
|
}
|
|
|
|
progress('verifying-bundle')
|
|
sftp = await lease.openStagedSftp(
|
|
probe.canonicalHomeDirectory,
|
|
{
|
|
maximumFileBytes: MAXIMUM_METADATA_BYTES,
|
|
maximumTotalBytes: MAXIMUM_METADATA_BYTES * 3,
|
|
maximumOperations: 12,
|
|
operationTimeoutMs: 15_000
|
|
},
|
|
signal
|
|
)
|
|
const registry = parseRuntimeRegistryState(
|
|
parseJsonBytes(
|
|
await sftp.readFile(
|
|
RUNTIME_REGISTRY_PATH,
|
|
signal
|
|
),
|
|
'Installed Runtime registry'
|
|
)
|
|
)
|
|
const current = registry.current.find(
|
|
(entry) =>
|
|
entry.runtimeId === 'opencode' &&
|
|
entry.architecture === probe.architecture
|
|
)
|
|
if (current === undefined) {
|
|
throw new RemoteRuntimeInstallationError(
|
|
'The Host has no current OpenCode Runtime for this architecture',
|
|
'incompatible'
|
|
)
|
|
}
|
|
|
|
let runtimeAdapterDigest =
|
|
current.runtimeAdapterDigest
|
|
if (runtimeAdapterDigest === undefined) {
|
|
const manifestPath =
|
|
`${OPENCODE_ROOT}/${digestDirectoryName(
|
|
current.bundleDigest
|
|
)}/manifest.json`
|
|
const manifest =
|
|
remoteRuntimeBundleManifestSchema.parse(
|
|
parseJsonBytes(
|
|
await sftp.readFile(manifestPath, signal),
|
|
'Installed Runtime manifest'
|
|
)
|
|
)
|
|
if (
|
|
manifest.runtimeId !== current.runtimeId ||
|
|
manifest.runtimeVersion !==
|
|
current.runtimeVersion ||
|
|
manifest.bundleDigest !== current.bundleDigest
|
|
) {
|
|
throw new Error(
|
|
'Installed Runtime manifest identity does not match its registry'
|
|
)
|
|
}
|
|
runtimeAdapterDigest = manifest.adapterDigest
|
|
}
|
|
sftp.close()
|
|
sftp = undefined
|
|
|
|
progress('activating-runtime')
|
|
await this.#activate(
|
|
lease,
|
|
current.runtimeId,
|
|
current.bundleDigest,
|
|
current.architecture,
|
|
agentInstallationId,
|
|
signal
|
|
)
|
|
progress('complete')
|
|
return {
|
|
runtimeId: current.runtimeId,
|
|
runtimeVersion: current.runtimeVersion,
|
|
bundleDigest: current.bundleDigest,
|
|
manifestDigest: current.manifestDigest,
|
|
runtimeAdapterDigest,
|
|
acpCapabilitiesDigest:
|
|
current.acpCapabilitiesDigest,
|
|
platform: probe.platform,
|
|
architecture: current.architecture
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof RemoteRuntimeInstallationError) {
|
|
throw error
|
|
}
|
|
if (isMissingPathError(error)) {
|
|
throw new RemoteRuntimeInstallationError(
|
|
'The Host has no current OpenCode Runtime',
|
|
'incompatible',
|
|
error
|
|
)
|
|
}
|
|
throw new RemoteRuntimeInstallationError(
|
|
`The registered Host OpenCode Runtime could not be activated: ${safeErrorDetail(error)}`,
|
|
'corrupt',
|
|
error
|
|
)
|
|
} finally {
|
|
sftp?.close()
|
|
lease?.release()
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
function digestDirectoryName(bundleDigest: string): string {
|
|
if (!/^sha256:[a-f0-9]{64}$/u.test(bundleDigest)) {
|
|
throw new Error('Runtime bundle digest is invalid')
|
|
}
|
|
return bundleDigest.slice('sha256:'.length)
|
|
}
|
|
|
|
function parseJsonBytes(
|
|
value: Buffer,
|
|
description: string
|
|
): unknown {
|
|
try {
|
|
return JSON.parse(value.toString('utf8')) as unknown
|
|
} catch (error) {
|
|
throw new Error(`${description} is not valid JSON`, {
|
|
cause: error
|
|
})
|
|
}
|
|
}
|
|
|
|
function assertLeaseMatchesTarget(
|
|
lease: SshConnectionLease,
|
|
target: SshConnectionPoolTarget
|
|
): void {
|
|
if (
|
|
lease.identity.hostId !== target.host.id ||
|
|
lease.identity.hostRevision !== target.hostRevision ||
|
|
lease.identity.hostKeyGeneration !==
|
|
target.hostKeyGeneration ||
|
|
!/^[a-f0-9]{64}$/u.test(
|
|
lease.identity.authenticationIdentity
|
|
)
|
|
) {
|
|
throw new RemoteRuntimeInstallationError(
|
|
'SSH lease identity changed during Runtime activation',
|
|
'host-identity-changed'
|
|
)
|
|
}
|
|
}
|
|
|
|
function safeErrorDetail(error: unknown): string {
|
|
const value = error instanceof Error
|
|
? error.message
|
|
: String(error)
|
|
return boundedDiagnostic(value)
|
|
}
|
|
function emitOne(
|
|
callback:
|
|
| ((phase: RemoteRuntimeInstallationPhase) => void)
|
|
| undefined,
|
|
phase: RemoteRuntimeInstallationPhase
|
|
): void {
|
|
try {
|
|
callback?.(phase)
|
|
} catch {
|
|
// Progress observers cannot alter activation state.
|
|
}
|
|
}
|