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:
mesalogo
2026-08-24 23:02:18 +08:00
parent 3829a871f5
commit 607bb33249
258 changed files with 99449 additions and 2427 deletions
@@ -0,0 +1,706 @@
import { spawn } from 'node:child_process'
import {
closeSync,
constants,
fchmodSync,
lstatSync,
openSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { dirname, resolve } from 'node:path'
import { z } from 'zod'
import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier'
import {
LinuxProcessInspector,
type LinuxProcessIdentity
} from './linux-process-identity'
import {
assertAbsoluteManagedPath,
assertPrivateRegularFile,
ensurePrivateDirectory,
readPrivateFile,
writePrivateFileAtomic
} from './managed-paths'
import type { AttachWelcome } from '../shared/agent-protocol'
const DEFAULT_READINESS_TIMEOUT_MS = 15_000
const DEFAULT_STOP_TIMEOUT_MS = 5_000
const POLL_INTERVAL_MS = 50
const processIdentitySchema = z
.object({
pid: z.number().int().positive().safe(),
starttime: z.string().regex(/^(?:0|[1-9][0-9]*)$/u),
executablePath: z.string().min(1)
})
.strict()
const bootstrapLockRecordSchema = z
.object({
formatVersion: z.literal(1),
installationId: z.string().min(1).max(128),
process: processIdentitySchema
})
.strict()
export const detachedAgentLifecycleRecordSchema = z
.object({
formatVersion: z.literal(1),
installationId: z.string().min(1).max(128),
binaryDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
protocol: z
.object({
major: z.number().int().min(0).max(65_535),
minor: z.number().int().min(0).max(65_535)
})
.strict(),
process: processIdentitySchema,
daemonBootId: z.string().min(1).max(128).nullable(),
createdAt: z.number().int().nonnegative().safe()
})
.strict()
export type DetachedAgentLifecycleRecord = z.infer<
typeof detachedAgentLifecycleRecordSchema
>
export type DetachedLifecycleStatus =
| { state: 'absent' }
| {
state: 'starting' | 'ready'
record: DetachedAgentLifecycleRecord
}
| {
state: 'stale'
reason: string
record: DetachedAgentLifecycleRecord
}
export type DetachedSpawn = (
executable: string,
argv: readonly string[],
options: {
env: Readonly<NodeJS.ProcessEnv>
detached: true
shell: false
stdio: 'ignore'
cwd: string
}
) => {
pid?: number
unref(): void
}
export interface DetachedSocketInspector {
removeStale(path: string): boolean
}
export type DetachedAgentLifecycleOptions = {
installationId: string
executablePath: string
stateDirectory: string
socketPath: string
verifyInstallation: () => Promise<VerifiedInstalledAgentBundle>
processInspector?: LinuxProcessInspector
socketInspector?: DetachedSocketInspector
spawnDetached?: DetachedSpawn
currentPid?: number
now?: () => number
sleep?: (milliseconds: number) => Promise<void>
readinessTimeoutMs?: number
stopTimeoutMs?: number
environment?: Readonly<NodeJS.ProcessEnv>
probeEndpoint?: (
verified: VerifiedInstalledAgentBundle
) => Promise<Pick<AttachWelcome, 'daemonBootId'>>
retireLegacyInstallation?: (
verified: VerifiedInstalledAgentBundle
) => void | Promise<void>
}
export class DetachedAgentLifecycle {
readonly #installationId: string
readonly #executablePath: string
readonly #stateDirectory: string
readonly #socketPath: string
readonly #lifecyclePath: string
readonly #lockPath: string
readonly #verifyInstallation: () => Promise<VerifiedInstalledAgentBundle>
readonly #processInspector: LinuxProcessInspector
readonly #socketInspector: DetachedSocketInspector
readonly #spawnDetached: DetachedSpawn
readonly #currentPid: number
readonly #now: () => number
readonly #sleep: (milliseconds: number) => Promise<void>
readonly #readinessTimeoutMs: number
readonly #stopTimeoutMs: number
readonly #environment: Readonly<NodeJS.ProcessEnv>
readonly #probeEndpoint?: DetachedAgentLifecycleOptions['probeEndpoint']
readonly #retireLegacyInstallation?: (
verified: VerifiedInstalledAgentBundle
) => void | Promise<void>
constructor(options: DetachedAgentLifecycleOptions) {
this.#installationId = validateInstallationId(options.installationId)
this.#executablePath = assertAbsoluteManagedPath(resolve(options.executablePath))
this.#stateDirectory = assertAbsoluteManagedPath(resolve(options.stateDirectory))
this.#socketPath = assertAbsoluteManagedPath(resolve(options.socketPath))
this.#lifecyclePath = resolve(this.#stateDirectory, 'detached-lifecycle.json')
this.#lockPath = resolve(this.#stateDirectory, 'detached-bootstrap.lock')
this.#verifyInstallation = options.verifyInstallation
this.#processInspector =
options.processInspector ?? new LinuxProcessInspector()
this.#socketInspector =
options.socketInspector ?? new FileSystemSocketInspector()
this.#spawnDetached = options.spawnDetached ?? spawnDetachedProcess
this.#currentPid = positivePid(options.currentPid ?? process.pid)
this.#now = options.now ?? Date.now
this.#sleep =
options.sleep ??
(async (milliseconds) =>
await new Promise<void>((resolveSleep) =>
setTimeout(resolveSleep, milliseconds)
))
this.#readinessTimeoutMs = boundedTimeout(
options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS
)
this.#stopTimeoutMs = boundedTimeout(
options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS
)
this.#environment = options.environment ?? process.env
this.#probeEndpoint = options.probeEndpoint
this.#retireLegacyInstallation = options.retireLegacyInstallation
}
async status(): Promise<DetachedLifecycleStatus> {
return this.#statusAfterVerification(await this.#verifyAndBind())
}
async health(): Promise<DetachedAgentLifecycleRecord> {
const verified = await this.#verifyAndBind()
const status = this.#statusAfterVerification(verified)
if (status.state !== 'ready') {
throw new Error(`Detached Agent is not healthy: ${status.state}`)
}
await this.#assertEndpointReady(status.record, verified)
return status.record
}
async bootstrap(): Promise<DetachedAgentLifecycleRecord> {
const verified = await this.#verifyAndBind()
ensurePrivateDirectory(dirname(this.#stateDirectory))
ensurePrivateDirectory(this.#stateDirectory)
ensurePrivateDirectory(dirname(this.#socketPath))
const releaseLock = await this.#acquireBootstrapLock()
try {
await this.#retireLegacyInstallation?.(verified)
const existing = this.#statusAfterVerification(verified)
if (existing.state === 'ready') {
try {
await this.#assertEndpointReady(existing.record, verified)
return existing.record
} catch {
await this.#retireLiveRecord(existing.record)
}
} else if (existing.state === 'starting') {
const ready = await this.#waitForReady(verified)
if (ready !== undefined) {
return ready
}
await this.#retireLiveRecord(existing.record)
} else if (existing.state === 'stale') {
this.#cleanupRecord(existing.record)
}
const launchArgv = ['daemon', '--installation-id', this.#installationId]
const runtimePath = expectedRuntimePath(verified)
const child = this.#spawnDetached(this.#executablePath, launchArgv, {
env: detachedEnvironment(this.#environment),
detached: true,
shell: false,
stdio: 'ignore',
cwd: dirname(this.#executablePath)
})
if (child.pid === undefined) {
throw new Error('Detached Agent spawn did not return a process ID')
}
child.unref()
const processIdentity = await this.#waitForProcessIdentity(
child.pid,
runtimePath
)
const starting = detachedAgentLifecycleRecordSchema.parse({
formatVersion: 1,
installationId: this.#installationId,
binaryDigest: verified.binaryDigest,
protocol: verified.manifest.protocol,
process: processIdentity,
daemonBootId: null,
createdAt: this.#now()
})
this.#writeRecord(starting)
const ready = await this.#waitForReady(verified)
if (ready === undefined) {
await this.#retireLiveRecord(starting)
throw new Error('Detached Agent readiness timed out')
}
return ready
} finally {
releaseLock()
}
}
async recordCurrentDaemonReady(
daemonBootId: string
): Promise<DetachedAgentLifecycleRecord> {
const verified = await this.#verifyAndBind()
const current = this.#processInspector.inspect(this.#currentPid)
if (current === undefined) {
throw new Error('Current detached Agent process identity is unavailable')
}
const deadline = this.#now() + this.#readinessTimeoutMs
while (this.#now() <= deadline) {
const record = this.#readRecord()
if (
record !== undefined &&
record.daemonBootId === null &&
sameProcessIdentity(record.process, current)
) {
this.#assertRecordBinding(record, verified)
const ready = detachedAgentLifecycleRecordSchema.parse({
...record,
daemonBootId
})
this.#writeRecord(ready)
return ready
}
await this.#sleep(POLL_INTERVAL_MS)
}
throw new Error('Detached Agent bootstrap record is unavailable')
}
async stop(): Promise<DetachedLifecycleStatus> {
const verified = await this.#verifyAndBind()
ensurePrivateDirectory(dirname(this.#stateDirectory))
ensurePrivateDirectory(this.#stateDirectory)
const releaseLock = await this.#acquireBootstrapLock()
try {
const status = this.#statusAfterVerification(verified)
if (status.state === 'absent') {
return status
}
if (status.state === 'stale') {
this.#cleanupRecord(status.record)
} else {
await this.#retireLiveRecord(status.record)
}
return { state: 'absent' }
} finally {
releaseLock()
}
}
async retire(): Promise<DetachedLifecycleStatus> {
return await this.stop()
}
#statusAfterVerification(
verified: VerifiedInstalledAgentBundle
): DetachedLifecycleStatus {
const record = this.#readRecord()
if (record === undefined) {
return { state: 'absent' }
}
this.#assertRecordBinding(record, verified)
if (!this.#processInspector.matches(record.process)) {
return {
state: 'stale',
reason: 'process-identity-mismatch',
record
}
}
return {
state: record.daemonBootId === null ? 'starting' : 'ready',
record
}
}
async #verifyAndBind(): Promise<VerifiedInstalledAgentBundle> {
const verified = await this.#verifyInstallation()
if (
verified.installationId !== this.#installationId ||
resolve(verified.executablePath) !== this.#executablePath
) {
throw new Error(
'Signed Agent verification does not match the lifecycle installation'
)
}
return verified
}
#assertRecordBinding(
record: DetachedAgentLifecycleRecord,
verified: VerifiedInstalledAgentBundle
): void {
if (
record.installationId !== this.#installationId ||
record.binaryDigest !== verified.binaryDigest ||
record.protocol.major !== verified.manifest.protocol.major ||
record.protocol.minor !== verified.manifest.protocol.minor ||
record.process.executablePath !== expectedRuntimePath(verified)
) {
throw new Error(
'Detached Agent lifecycle record does not match the signed installation'
)
}
}
#readRecord(): DetachedAgentLifecycleRecord | undefined {
try {
assertPrivateRegularFile(this.#lifecyclePath)
return detachedAgentLifecycleRecordSchema.parse(
JSON.parse(readBoundedPrivateFile(this.#lifecyclePath))
)
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return undefined
}
throw new Error('Detached Agent lifecycle record is corrupt', {
cause: error
})
}
}
#writeRecord(record: DetachedAgentLifecycleRecord): void {
writePrivateFileAtomic(
this.#lifecyclePath,
`${JSON.stringify(detachedAgentLifecycleRecordSchema.parse(record), null, 2)}\n`
)
}
async #waitForReady(
verified: VerifiedInstalledAgentBundle
): Promise<DetachedAgentLifecycleRecord | undefined> {
const deadline = this.#now() + this.#readinessTimeoutMs
while (this.#now() <= deadline) {
const status = this.#statusAfterVerification(verified)
if (status.state === 'ready') {
try {
await this.#assertEndpointReady(status.record, verified)
return status.record
} catch {
// The process may have written readiness just before listen completed.
}
} else if (status.state === 'absent' || status.state === 'stale') {
return undefined
}
await this.#sleep(POLL_INTERVAL_MS)
}
return undefined
}
async #assertEndpointReady(
record: DetachedAgentLifecycleRecord,
verified: VerifiedInstalledAgentBundle
): Promise<void> {
if (this.#probeEndpoint === undefined) {
return
}
const welcome = await this.#probeEndpoint(verified)
if (welcome.daemonBootId !== record.daemonBootId) {
throw new Error(
'Detached Agent endpoint boot identity does not match lifecycle state'
)
}
}
async #waitForProcessIdentity(
pid: number,
executablePath: string
): Promise<LinuxProcessIdentity> {
const deadline = this.#now() + this.#readinessTimeoutMs
while (this.#now() <= deadline) {
const identity = this.#processInspector.inspect(pid)
if (
identity !== undefined &&
identity.executablePath === executablePath
) {
return identity
}
await this.#sleep(POLL_INTERVAL_MS)
}
throw new Error('Detached Agent process identity could not be established')
}
async #waitForExit(
identity: LinuxProcessIdentity,
timeoutMs: number
): Promise<boolean> {
const deadline = this.#now() + timeoutMs
while (this.#now() <= deadline) {
if (!this.#processInspector.matches(identity)) {
return true
}
await this.#sleep(POLL_INTERVAL_MS)
}
return !this.#processInspector.matches(identity)
}
async #retireLiveRecord(
record: DetachedAgentLifecycleRecord
): Promise<void> {
if (this.#processInspector.matches(record.process)) {
this.#processInspector.signal(record.process, 'SIGTERM')
if (!(await this.#waitForExit(record.process, this.#stopTimeoutMs))) {
this.#processInspector.signal(record.process, 'SIGKILL')
await this.#waitForExit(record.process, this.#stopTimeoutMs)
}
}
if (this.#processInspector.matches(record.process)) {
throw new Error('Detached Agent did not stop')
}
this.#cleanupRecord(record)
}
#cleanupRecord(record: DetachedAgentLifecycleRecord): void {
if (this.#processInspector.matches(record.process)) {
throw new Error('Refusing to clean a live detached Agent lifecycle')
}
assertPrivateRegularFile(this.#lifecyclePath)
const current = detachedAgentLifecycleRecordSchema.parse(
JSON.parse(readBoundedPrivateFile(this.#lifecyclePath))
)
if (!sameLifecycleRecord(current, record)) {
throw new Error('Detached Agent lifecycle changed during cleanup')
}
this.#socketInspector.removeStale(this.#socketPath)
unlinkSync(this.#lifecyclePath)
}
async #acquireBootstrapLock(): Promise<() => void> {
const deadline = this.#now() + this.#readinessTimeoutMs
while (this.#now() <= deadline) {
const descriptor = tryCreateLock(this.#lockPath)
if (descriptor !== undefined) {
const owner = this.#processInspector.inspect(this.#currentPid)
if (owner === undefined) {
closeSync(descriptor)
unlinkSync(this.#lockPath)
throw new Error(
'Current bootstrap lock process identity is unavailable'
)
}
try {
writeFileSync(
descriptor,
`${JSON.stringify({
formatVersion: 1,
installationId: this.#installationId,
process: owner
})}\n`,
'utf8'
)
if (process.platform !== 'win32') {
fchmodSync(descriptor, 0o600)
}
} finally {
closeSync(descriptor)
}
return () => this.#releaseBootstrapLock(owner)
}
const lock = this.#readLock()
if (lock === undefined) {
continue
}
if (!this.#processInspector.matches(lock.process)) {
const current = this.#readLock()
if (
current !== undefined &&
sameProcessIdentity(current.process, lock.process)
) {
unlinkSync(this.#lockPath)
}
continue
}
await this.#sleep(POLL_INTERVAL_MS)
}
throw new Error('Timed out waiting for detached Agent bootstrap lock')
}
#readLock(): z.infer<typeof bootstrapLockRecordSchema> | undefined {
try {
assertPrivateRegularFile(this.#lockPath)
const lock = bootstrapLockRecordSchema.parse(
JSON.parse(readBoundedPrivateFile(this.#lockPath))
)
if (lock.installationId !== this.#installationId) {
throw new Error(
'Detached Agent bootstrap lock belongs to another installation'
)
}
return lock
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return undefined
}
throw new Error('Detached Agent bootstrap lock is corrupt', {
cause: error
})
}
}
#releaseBootstrapLock(owner: LinuxProcessIdentity): void {
const lock = this.#readLock()
if (
lock !== undefined &&
sameProcessIdentity(lock.process, owner)
) {
unlinkSync(this.#lockPath)
}
}
}
function spawnDetachedProcess(
executable: string,
argv: readonly string[],
options: {
env: Readonly<NodeJS.ProcessEnv>
detached: true
shell: false
stdio: 'ignore'
cwd: string
}
): ReturnType<DetachedSpawn> {
return spawn(executable, [...argv], options)
}
function detachedEnvironment(
source: Readonly<NodeJS.ProcessEnv>
): Readonly<NodeJS.ProcessEnv> {
const allowed = [
'HOME',
'LANG',
'LC_ALL',
'LC_CTYPE',
'LOGNAME',
'PATH',
'TMPDIR',
'TZ',
'USER'
] as const
const env: NodeJS.ProcessEnv = {}
for (const key of allowed) {
const value = source[key]
if (value !== undefined && !value.includes('\0')) {
env[key] = value
}
}
return env
}
function expectedRuntimePath(
verified: VerifiedInstalledAgentBundle
): string {
return assertAbsoluteManagedPath(
resolve(
verified.installationDirectory,
verified.manifest.entrypoint.runtimePath
)
)
}
class FileSystemSocketInspector implements DetachedSocketInspector {
removeStale(path: string): boolean {
try {
const stat = lstatSync(path)
const uid = process.getuid?.()
if (
!stat.isSocket() ||
stat.isSymbolicLink() ||
(uid !== undefined && stat.uid !== uid)
) {
throw new Error('Refusing to remove an unrelated endpoint path')
}
unlinkSync(path)
return true
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return false
}
throw error
}
}
}
function tryCreateLock(path: string): number | undefined {
try {
return openSync(
path,
constants.O_CREAT |
constants.O_EXCL |
constants.O_WRONLY |
(constants.O_NOFOLLOW ?? 0),
0o600
)
} catch (error) {
if (isNodeError(error) && error.code === 'EEXIST') {
return undefined
}
throw error
}
}
function readBoundedPrivateFile(path: string): string {
return readPrivateFile(path, 64 * 1024).toString('utf8')
}
function sameProcessIdentity(
left: LinuxProcessIdentity,
right: LinuxProcessIdentity
): boolean {
return (
left.pid === right.pid &&
left.starttime === right.starttime &&
left.executablePath === right.executablePath
)
}
function sameLifecycleRecord(
left: DetachedAgentLifecycleRecord,
right: DetachedAgentLifecycleRecord
): boolean {
return (
sameProcessIdentity(left.process, right.process) &&
left.daemonBootId === right.daemonBootId &&
left.createdAt === right.createdAt
)
}
function validateInstallationId(value: string): string {
if (
value.length > 128 ||
!/^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,126}[A-Za-z0-9])?$/u.test(value)
) {
throw new Error('Invalid Agent installation ID')
}
return value
}
function boundedTimeout(value: number): number {
if (!Number.isSafeInteger(value) || value < 100 || value > 120_000) {
throw new Error('Detached Agent timeout is invalid')
}
return value
}
function positivePid(value: number): number {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error('Detached Agent process ID is invalid')
}
return value
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error
}