Files
goodbuddy/src/agent-daemon/runtime-process-identity.ts
T
mesalogo 9c6d1611e2 feat: support macOS arm64 remote Agent hosts
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。
2026-09-06 16:14:02 +08:00

179 lines
5.7 KiB
TypeScript

import { readFile, readlink } from 'node:fs/promises'
import { posix } from 'node:path'
import { darwinBootId, inspectDarwinProcess } from './darwin-process-identity'
export type LinuxRuntimeProcessIdentity = {
bootId: string
pid: number
startTimeTicks: bigint
processGroupId: number
executablePath: string
}
export type LinuxProcStat = {
pid: number
command: string
state: string
parentPid: number
processGroupId: number
sessionId: number
startTimeTicks: bigint
}
export function parseLinuxProcStat(value: string): LinuxProcStat {
const open = value.indexOf('(')
const close = value.lastIndexOf(')')
if (open < 1 || close <= open || value[close + 1] !== ' ') {
throw new Error('Linux process stat is malformed')
}
const pid = positiveInteger(Number(value.slice(0, open).trim()), 'PID')
const command = value.slice(open + 1, close)
const fields = value.slice(close + 2).trim().split(/\s+/u)
if (fields.length < 20 || !/^[A-Z]$/u.test(fields[0] ?? '')) {
throw new Error('Linux process stat has too few fields')
}
const integerField = (index: number, label: string): number => {
const text = fields[index]
if (text === undefined || !/^-?\d+$/u.test(text)) {
throw new Error(`Linux process ${label} is malformed`)
}
const parsed = Number(text)
if (!Number.isSafeInteger(parsed)) {
throw new Error(`Linux process ${label} is outside the safe range`)
}
return parsed
}
const startTime = fields[19]
if (startTime === undefined || !/^\d+$/u.test(startTime)) {
throw new Error('Linux process start time is malformed')
}
return {
pid,
command,
state: fields[0]!,
parentPid: nonnegativeInteger(integerField(1, 'parent PID'), 'Parent PID'),
processGroupId: positiveInteger(integerField(2, 'process group'), 'Process group'),
sessionId: positiveInteger(integerField(3, 'session'), 'Session'),
startTimeTicks: BigInt(startTime)
}
}
export async function readLinuxBootId(
path = '/proc/sys/kernel/random/boot_id'
): Promise<string> {
const value = (await readFile(path, 'utf8')).trim().toLowerCase()
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(value)) {
throw new Error('Linux boot identity is malformed')
}
return value
}
export async function readLinuxRuntimeProcessIdentity(
pidInput: number,
options: { procRoot?: string; bootId?: string } = {}
): Promise<LinuxRuntimeProcessIdentity> {
const pid = positiveInteger(pidInput, 'PID')
if (process.platform === 'darwin') {
const identity = inspectDarwinProcess(pid)
if (identity === undefined) {
throw Object.assign(new Error('Runtime process no longer exists'), { code: 'ESRCH' })
}
return {
bootId: options.bootId ?? darwinBootId(),
pid,
startTimeTicks: BigInt(identity.starttime),
processGroupId: identity.processGroupId,
executablePath: identity.executablePath
}
}
const procRoot = options.procRoot ?? '/proc'
if (!posix.isAbsolute(procRoot) || procRoot.includes('\0')) {
throw new Error('Linux proc root must be absolute')
}
const processRoot = posix.join(procRoot, String(pid))
const [statText, executablePath] = await Promise.all([
readFile(posix.join(processRoot, 'stat'), 'utf8'),
readlink(posix.join(processRoot, 'exe'))
])
const parsed = parseLinuxProcStat(statText)
if (parsed.pid !== pid) {
throw new Error('Linux process stat PID does not match its path')
}
return {
bootId:
options.bootId ??
await readLinuxBootId(
posix.join(procRoot, 'sys', 'kernel', 'random', 'boot_id')
),
pid,
startTimeTicks: parsed.startTimeTicks,
processGroupId: parsed.processGroupId,
executablePath
}
}
export function sameLinuxRuntimeProcessIdentity(
expected: LinuxRuntimeProcessIdentity,
actual: LinuxRuntimeProcessIdentity
): boolean {
return (
expected.bootId === actual.bootId &&
expected.pid === actual.pid &&
expected.startTimeTicks === actual.startTimeTicks &&
expected.processGroupId === actual.processGroupId &&
expected.executablePath === actual.executablePath
)
}
export function serializeLinuxRuntimeProcessIdentity(
identity: LinuxRuntimeProcessIdentity
): string {
return JSON.stringify({
...identity,
startTimeTicks: identity.startTimeTicks.toString()
})
}
export function parseSerializedLinuxRuntimeProcessIdentity(
value: string
): LinuxRuntimeProcessIdentity {
const parsed: unknown = JSON.parse(value)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Stored Runtime process identity is malformed')
}
const row = parsed as Record<string, unknown>
if (
typeof row.bootId !== 'string' ||
!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(row.bootId) ||
typeof row.pid !== 'number' ||
typeof row.processGroupId !== 'number' ||
typeof row.startTimeTicks !== 'string' ||
!/^\d+$/u.test(row.startTimeTicks) ||
typeof row.executablePath !== 'string' ||
!posix.isAbsolute(row.executablePath)
) {
throw new Error('Stored Runtime process identity is malformed')
}
return {
bootId: row.bootId,
pid: positiveInteger(row.pid, 'PID'),
startTimeTicks: BigInt(row.startTimeTicks),
processGroupId: positiveInteger(row.processGroupId, 'Process group'),
executablePath: row.executablePath
}
}
function positiveInteger(value: number, label: string): number {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${label} must be a positive safe integer`)
}
return value
}
function nonnegativeInteger(value: number, label: string): number {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`${label} must be a nonnegative safe integer`)
}
return value
}