feat: add managed SSH execution spaces
Add managed SSH Hosts and projects with signed side-by-side GoodBuddy Agent and OpenCode Runtime installation, Ask and Execute workspace access, a Main-owned model bridge, detached Agent lifecycle, durable ACP output recovery, and project activation UI. Use protocol v2 with bounded FIFO frames over SSH instead of custom credit, priority, batching, or model-message chunking. Mismatched Host Agents now upgrade automatically, an exactly verified Node runtime is reused on-host, and local Portable builds can embed strictly verified Agent artifacts. Release note: 新增托管 SSH 远程项目,可自动安装或升级签名 Agent/OpenCode Runtime,并在 SSH 断线后继续 Agent 工作和同步未确认输出。
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
import { constants } from 'node:fs'
|
||||
import { access } from 'node:fs/promises'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { join, resolve } from 'node:path'
|
||||
import type {
|
||||
AgentArchitecture,
|
||||
AgentReleaseKeyRegistry
|
||||
} from '../shared/agent-installation-contracts'
|
||||
import type { AgentFrame } from '../shared/agent-protocol'
|
||||
import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts'
|
||||
import type { EventJournal } from './event-journal'
|
||||
import {
|
||||
derivePrivateTemporaryRoot,
|
||||
ensurePrivateDirectory,
|
||||
ensurePrivateDirectoryTree,
|
||||
ensurePrivateTemporaryRoot
|
||||
} from './managed-paths'
|
||||
import { readManagedAgentReleaseKeyRegistry } from './installed-bundle-verifier'
|
||||
import {
|
||||
RuntimeBundleRegistry,
|
||||
createVerifiedRuntimeCapabilitySource
|
||||
} from './runtime-bundle-registry'
|
||||
import {
|
||||
readRemoteRuntimeLock,
|
||||
verifyRuntimeBundle
|
||||
} from './runtime-bundle-verifier'
|
||||
import { RuntimeAcpBackend } from './runtime-acp-backend'
|
||||
import type { ProtocolMethodContext } from './protocol-server'
|
||||
import {
|
||||
launchDirectLinuxStdioProcessOwner,
|
||||
reconcileOrphanedDirectLinuxStdioProcesses
|
||||
} from './direct-linux-stdio-process-owner'
|
||||
import { RuntimeOwnerRegistry } from './runtime-owner-registry'
|
||||
import type { WorkspaceRegistry } from './workspace-registry'
|
||||
|
||||
const BWRAP_EXECUTABLE = '/usr/bin/bwrap'
|
||||
|
||||
export type ProductionRuntimeProtocol = {
|
||||
runtimes: ReturnType<typeof createVerifiedRuntimeCapabilitySource>
|
||||
methods: RuntimeAcpBackend['methods']
|
||||
onAcpFrame: RuntimeAcpBackend['onAcpFrame']
|
||||
onBlobFrame?: RuntimeAcpBackend['onBlobFrame']
|
||||
authorizeBlobFrame?: RuntimeAcpBackend['authorizeBlobFrame']
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
export type ProductionRuntimeCompositionOptions = {
|
||||
runtimeRoot: string
|
||||
stateDirectory: string
|
||||
architecture: AgentArchitecture
|
||||
events: EventJournal
|
||||
workspaces: WorkspaceRegistry
|
||||
outputSink: (
|
||||
frame: AgentFrame,
|
||||
context: {
|
||||
bindingId: string
|
||||
controllerId: string
|
||||
controllerGeneration: number
|
||||
}
|
||||
) => void | Promise<void>
|
||||
blobSink?: (
|
||||
frame: AgentFrame,
|
||||
context: {
|
||||
bindingId: string
|
||||
controllerId: string
|
||||
controllerGeneration: number
|
||||
}
|
||||
) => void | Promise<void>
|
||||
agentExecutablePath?: string
|
||||
installationId?: string
|
||||
agentServiceUnitName?: string
|
||||
releaseKeyRegistry?: AgentReleaseKeyRegistry
|
||||
runtimeLock?: RemoteRuntimeLock
|
||||
registry?: RuntimeBundleRegistry
|
||||
prerequisitesAvailable?: () => boolean | Promise<boolean>
|
||||
launchProcess?: typeof launchDirectLinuxStdioProcessOwner
|
||||
reconcileOrphanedProcesses?: (
|
||||
installationId: string
|
||||
) => Promise<unknown>
|
||||
ownerRegistry?: RuntimeOwnerRegistry
|
||||
verificationEnvironment?: 'production' | 'test'
|
||||
filesystemPlatform?: NodeJS.Platform
|
||||
reportError?: (message: string, error: unknown) => void
|
||||
}
|
||||
|
||||
export async function createProductionRuntimeProtocol(
|
||||
options: ProductionRuntimeCompositionOptions
|
||||
): Promise<ProductionRuntimeProtocol> {
|
||||
const runtimeRoot = resolve(options.runtimeRoot)
|
||||
const stateDirectory = resolve(options.stateDirectory)
|
||||
const verificationEnvironment =
|
||||
options.verificationEnvironment ?? 'production'
|
||||
const installationId =
|
||||
options.installationId ??
|
||||
(verificationEnvironment === 'test'
|
||||
? 'test-installation'
|
||||
: undefined)
|
||||
if (installationId === undefined) {
|
||||
throw new Error(
|
||||
'Production Runtime composition requires its Agent installation identity'
|
||||
)
|
||||
}
|
||||
ensurePrivateDirectory(stateDirectory, { create: false })
|
||||
const ownerRegistry =
|
||||
options.ownerRegistry ??
|
||||
new RuntimeOwnerRegistry(join(stateDirectory, 'runtime-owners.sqlite'))
|
||||
let registry = options.registry
|
||||
const currentRegistry = (): RuntimeBundleRegistry => {
|
||||
if (registry === undefined) {
|
||||
registry = new RuntimeBundleRegistry({ runtimeRoot })
|
||||
}
|
||||
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
|
||||
}
|
||||
const verifiedCapabilities = async () => {
|
||||
const source = createVerifiedRuntimeCapabilitySource({
|
||||
registry: currentRegistry(),
|
||||
...(await currentVerificationOptions()),
|
||||
reportError: options.reportError
|
||||
})
|
||||
return await source()
|
||||
}
|
||||
const prerequisites =
|
||||
options.prerequisitesAvailable ??
|
||||
productionRuntimePrerequisitesAvailable
|
||||
const launchProcess =
|
||||
options.launchProcess ?? launchDirectLinuxStdioProcessOwner
|
||||
let backend: RuntimeAcpBackend
|
||||
try {
|
||||
if (
|
||||
verificationEnvironment === 'production' &&
|
||||
process.platform === 'linux'
|
||||
) {
|
||||
await (
|
||||
options.reconcileOrphanedProcesses ??
|
||||
(async (currentInstallationId: string) =>
|
||||
await reconcileOrphanedDirectLinuxStdioProcesses({
|
||||
installationId: currentInstallationId,
|
||||
registry: ownerRegistry
|
||||
}))
|
||||
)(installationId)
|
||||
}
|
||||
ensurePrivateDirectoryTree(
|
||||
join(stateDirectory, 'runtime-scratch'),
|
||||
stateDirectory
|
||||
)
|
||||
ensurePrivateDirectoryTree(
|
||||
join(stateDirectory, 'model-bridges'),
|
||||
stateDirectory
|
||||
)
|
||||
backend = new RuntimeAcpBackend({
|
||||
journal: options.events,
|
||||
resolveRuntimeBundle: (runtimeId, bundleDigest) =>
|
||||
currentRegistry().resolve(
|
||||
runtimeId,
|
||||
bundleDigest,
|
||||
options.architecture
|
||||
),
|
||||
reverifyRuntimeBundle: async (resolved) => {
|
||||
const verified = await verifyRuntimeBundle(
|
||||
resolved.bundleDirectory,
|
||||
await currentVerificationOptions()
|
||||
)
|
||||
if (
|
||||
verified.manifestDigest !== resolved.entry.manifestDigest ||
|
||||
verified.manifest.bundleDigest !==
|
||||
resolved.entry.bundleDigest
|
||||
) {
|
||||
throw new Error(
|
||||
'Runtime bundle no longer matches its registered identity'
|
||||
)
|
||||
}
|
||||
return verified
|
||||
},
|
||||
resolveWorkspace: async (preparation, context) =>
|
||||
resolveCurrentWorkspace(
|
||||
options.workspaces,
|
||||
stateDirectory,
|
||||
preparation,
|
||||
context
|
||||
),
|
||||
launchProcess: async (launch) =>
|
||||
await launchProcess({
|
||||
manifest: launch.manifest,
|
||||
profileInput: {
|
||||
bundleDirectory: launch.bundle.bundleDirectory,
|
||||
workspaceDirectory: launch.workspace.workspaceDirectory,
|
||||
scratchDirectory: launch.scratch,
|
||||
workMode: launch.workMode,
|
||||
...(launch.modelBridge === undefined ||
|
||||
options.agentExecutablePath === undefined
|
||||
? {}
|
||||
: {
|
||||
modelBridge: {
|
||||
agentExecutablePath: resolve(
|
||||
options.agentExecutablePath
|
||||
),
|
||||
bridgeDirectory:
|
||||
launch.workspace.bridgeDirectory!,
|
||||
socketPath: launch.modelBridge.socketPath,
|
||||
policy: launch.modelBridge.policy
|
||||
}
|
||||
})
|
||||
},
|
||||
identity: {
|
||||
launchId: `launch-${randomUUID()}`,
|
||||
processId: `process-${randomUUID()}`
|
||||
},
|
||||
installationId,
|
||||
registry: ownerRegistry,
|
||||
deadlineAt: launch.deadlineAt,
|
||||
maximumInputBytes: launch.budget.maximumInputBytes
|
||||
}),
|
||||
outputSink: options.outputSink,
|
||||
...(options.blobSink === undefined
|
||||
? {}
|
||||
: { blobSink: options.blobSink })
|
||||
})
|
||||
} catch (error) {
|
||||
if (options.ownerRegistry === undefined) {
|
||||
ownerRegistry.close()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
runtimes: async () => {
|
||||
try {
|
||||
if (
|
||||
options.blobSink === undefined ||
|
||||
options.agentExecutablePath === undefined ||
|
||||
!(await executableAvailable(
|
||||
resolve(options.agentExecutablePath)
|
||||
)) ||
|
||||
!(await prerequisites())
|
||||
) {
|
||||
return []
|
||||
}
|
||||
} catch (error) {
|
||||
options.reportError?.(
|
||||
'Agent Runtime prerequisites are unavailable',
|
||||
error
|
||||
)
|
||||
return []
|
||||
}
|
||||
try {
|
||||
return await verifiedCapabilities()
|
||||
} catch (error) {
|
||||
options.reportError?.(
|
||||
'Agent Runtime metadata is unavailable; Runtime remains disabled',
|
||||
error
|
||||
)
|
||||
return []
|
||||
}
|
||||
},
|
||||
methods: backend.methods,
|
||||
onAcpFrame: backend.onAcpFrame,
|
||||
...(options.blobSink === undefined ||
|
||||
options.agentExecutablePath === undefined
|
||||
? {}
|
||||
: {
|
||||
onBlobFrame: backend.onBlobFrame,
|
||||
authorizeBlobFrame: backend.authorizeBlobFrame
|
||||
}),
|
||||
dispose: async () => {
|
||||
await backend.dispose()
|
||||
if (options.ownerRegistry === undefined) {
|
||||
ownerRegistry.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function executableAvailable(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCurrentWorkspace(
|
||||
workspaces: WorkspaceRegistry,
|
||||
stateDirectory: string,
|
||||
preparation: {
|
||||
bindingId: string
|
||||
runtimeBundleDigest: string
|
||||
workspaceIdentity: string
|
||||
},
|
||||
context: ProtocolMethodContext
|
||||
): Promise<{
|
||||
workspaceIdentity: string
|
||||
workspaceDirectory: string
|
||||
scratchDirectory: string
|
||||
bridgeDirectory?: string
|
||||
}> {
|
||||
const lease = await workspaces.getCurrentByIdentity(
|
||||
preparation.workspaceIdentity,
|
||||
context.controller,
|
||||
{ signal: context.signal }
|
||||
)
|
||||
const scratchDirectory = derivePrivateRuntimeScratchDirectory(
|
||||
stateDirectory,
|
||||
{
|
||||
bindingId: preparation.bindingId,
|
||||
runtimeBundleDigest: preparation.runtimeBundleDigest,
|
||||
workspaceIdentity: preparation.workspaceIdentity
|
||||
}
|
||||
)
|
||||
const bridgeDirectory =
|
||||
preparation.modelBridge === undefined
|
||||
? undefined
|
||||
: derivePrivateModelBridgeDirectory(
|
||||
stateDirectory,
|
||||
{
|
||||
bindingId: preparation.bindingId,
|
||||
runtimeBundleDigest:
|
||||
preparation.runtimeBundleDigest,
|
||||
workspaceIdentity:
|
||||
preparation.workspaceIdentity
|
||||
}
|
||||
)
|
||||
return {
|
||||
workspaceIdentity: lease.handle.workspaceIdentity,
|
||||
workspaceDirectory: lease.access.root.canonicalPath,
|
||||
scratchDirectory,
|
||||
...(bridgeDirectory === undefined
|
||||
? {}
|
||||
: { bridgeDirectory })
|
||||
}
|
||||
}
|
||||
|
||||
export function derivePrivateModelBridgeDirectory(
|
||||
stateDirectoryInput: string,
|
||||
binding: {
|
||||
bindingId: string
|
||||
runtimeBundleDigest: string
|
||||
workspaceIdentity: string
|
||||
controllerId?: string
|
||||
controllerGeneration?: number
|
||||
}
|
||||
): string {
|
||||
if (process.platform === 'linux') {
|
||||
const stateDirectory = resolve(stateDirectoryInput)
|
||||
ensurePrivateDirectory(stateDirectory, { create: false })
|
||||
const userRoot = ensurePrivateTemporaryRoot(
|
||||
derivePrivateTemporaryRoot()
|
||||
)
|
||||
const bridgeRoot = join(userRoot, 'mb')
|
||||
ensurePrivateDirectoryTree(bridgeRoot, userRoot)
|
||||
const bindingDirectory = join(
|
||||
bridgeRoot,
|
||||
createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify([
|
||||
'goodbuddy-model-bridge-v2',
|
||||
stateDirectory,
|
||||
binding.bindingId,
|
||||
binding.runtimeBundleDigest,
|
||||
binding.workspaceIdentity
|
||||
])
|
||||
)
|
||||
.digest('hex')
|
||||
.slice(0, 32)
|
||||
)
|
||||
ensurePrivateDirectoryTree(bindingDirectory, bridgeRoot)
|
||||
return bindingDirectory
|
||||
}
|
||||
return derivePrivateBindingDirectory(
|
||||
stateDirectoryInput,
|
||||
'model-bridges',
|
||||
'goodbuddy-model-bridge-v1',
|
||||
binding
|
||||
)
|
||||
}
|
||||
|
||||
export function derivePrivateRuntimeScratchDirectory(
|
||||
stateDirectoryInput: string,
|
||||
binding: {
|
||||
bindingId: string
|
||||
runtimeBundleDigest: string
|
||||
workspaceIdentity: string
|
||||
controllerId?: string
|
||||
controllerGeneration?: number
|
||||
}
|
||||
): string {
|
||||
return derivePrivateBindingDirectory(
|
||||
stateDirectoryInput,
|
||||
'runtime-scratch',
|
||||
'goodbuddy-runtime-scratch-v1',
|
||||
binding
|
||||
)
|
||||
}
|
||||
|
||||
function derivePrivateBindingDirectory(
|
||||
stateDirectoryInput: string,
|
||||
rootName: 'model-bridges' | 'runtime-scratch',
|
||||
digestDomain:
|
||||
| 'goodbuddy-model-bridge-v1'
|
||||
| 'goodbuddy-runtime-scratch-v1',
|
||||
binding: {
|
||||
bindingId: string
|
||||
runtimeBundleDigest: string
|
||||
workspaceIdentity: string
|
||||
}
|
||||
): string {
|
||||
const stateDirectory = resolve(stateDirectoryInput)
|
||||
ensurePrivateDirectory(stateDirectory, { create: false })
|
||||
const bindingRoot = join(stateDirectory, rootName)
|
||||
ensurePrivateDirectoryTree(bindingRoot, stateDirectory)
|
||||
const bindingDigest = bindingDirectoryDigest(
|
||||
digestDomain,
|
||||
binding
|
||||
)
|
||||
const bindingDirectory = join(bindingRoot, bindingDigest)
|
||||
ensurePrivateDirectoryTree(bindingDirectory, bindingRoot)
|
||||
return bindingDirectory
|
||||
}
|
||||
|
||||
function bindingDirectoryDigest(
|
||||
digestDomain:
|
||||
| 'goodbuddy-model-bridge-v1'
|
||||
| 'goodbuddy-runtime-scratch-v1',
|
||||
binding: {
|
||||
bindingId: string
|
||||
runtimeBundleDigest: string
|
||||
workspaceIdentity: string
|
||||
}
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([
|
||||
digestDomain,
|
||||
binding.bindingId,
|
||||
binding.runtimeBundleDigest,
|
||||
binding.workspaceIdentity
|
||||
]))
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
async function productionRuntimePrerequisitesAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await access(BWRAP_EXECUTABLE, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user