feat: add computer control and managed browser

This commit is contained in:
lofyer
2026-08-05 12:55:24 +08:00
parent 2f549387a6
commit 38ac2206f2
92 changed files with 21028 additions and 766 deletions
+472 -17
View File
@@ -14,19 +14,43 @@ import { basename, dirname, join } from 'node:path'
import { parse as parseYaml } from 'yaml'
import { z } from 'zod'
import {
browserProfileIdSchema,
browserProfileNameSchema,
browserProfilesSummarySchema,
capabilityDiagnosticReportSchema,
capabilityAssignmentsSchema,
computerCapabilityConfigSummarySchema,
computerCapabilityIdSchema,
mcpServerIdSchema,
mcpServerInputSchema,
mcpServerSummarySchema,
skillIdSchema,
skillSummarySchema,
type CapabilityAssignments,
type CapabilityDiagnosticReport,
type CapabilitySnapshot,
type BrowserProfilesSummary,
type ComputerCapabilityId,
type McpServerInput,
type McpServerSummary,
type RuntimeTarget,
type SkillSummary
} from '../../shared/capability-contracts'
import {
BrowserProfileService,
FileBrowserProfileStore,
type BrowserProfileState
} from './browser-profile-service'
import {
CapabilityDiagnostics,
type CapabilityDiagnosticCheck
} from './capability-diagnostics'
import {
computerCapabilityCatalog,
getComputerCapability,
isComputerCapabilitySupported,
type ComputerCapabilityImplementationKind
} from './computer-capability-catalog'
const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024
const MAX_SKILL_PACKAGE_BYTES = 10 * 1024 * 1024
@@ -92,7 +116,7 @@ const storedMcpServerSchema = z.discriminatedUnion('transport', [
.strict()
])
const storedCapabilitiesSchema = z
const storedCapabilitiesV1Schema = z
.object({
version: z.literal(1),
skills: z.record(skillIdSchema, skillStateSchema),
@@ -100,6 +124,28 @@ const storedCapabilitiesSchema = z
})
.strict()
const computerCapabilityStateSchema = z
.object({
enabled: z.boolean(),
browserProfileId: browserProfileIdSchema.nullable()
})
.strict()
const storedCapabilitiesSchema = z
.object({
version: z.literal(2),
skills: z.record(skillIdSchema, skillStateSchema),
mcpServers: z.array(storedMcpServerSchema).max(64),
computerCapabilities: z
.object({
'host-browser-control': computerCapabilityStateSchema,
'linux-desktop-control': computerCapabilityStateSchema
})
.strict()
})
.strict()
type StoredCapabilitiesV1 = z.infer<typeof storedCapabilitiesV1Schema>
type StoredCapabilities = z.infer<typeof storedCapabilitiesSchema>
type StoredMcpServer = z.infer<typeof storedMcpServerSchema>
@@ -121,6 +167,37 @@ export type ResolvedMcpServer = McpServerSummary & {
secret?: string
}
export type CapabilityServiceOptions = Readonly<{
platform?: NodeJS.Platform
architecture?: string
electronTarget?: boolean
browserProfiles?: BrowserProfileService
diagnostics?: CapabilityDiagnostics
availableComputerCapabilityImplementations?: readonly ComputerCapabilityImplementationKind[]
}>
function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabilities'] {
return {
'host-browser-control': {
enabled: false,
browserProfileId: null
},
'linux-desktop-control': {
enabled: false,
browserProfileId: null
}
}
}
function emptyStoredCapabilities(): StoredCapabilities {
return {
version: 2,
skills: {},
mcpServers: [],
computerCapabilities: defaultComputerCapabilityStates()
}
}
function defaultSkillState(): z.infer<typeof skillStateSchema> {
return {
enabled: true,
@@ -235,24 +312,109 @@ async function copySkillPackage(
export class CapabilityService {
private state?: StoredCapabilities
private loadPromise?: Promise<StoredCapabilities>
private updateQueue: Promise<void> = Promise.resolve()
private readonly platform: NodeJS.Platform
private readonly architecture: string
private readonly electronTarget: boolean
private readonly browserProfiles: BrowserProfileService
private readonly diagnostics: CapabilityDiagnostics
private readonly availableComputerCapabilityImplementations: ReadonlySet<ComputerCapabilityImplementationKind>
constructor(
private readonly filePath: string,
private readonly builtinSkillsRoot: string,
private readonly importedSkillsRoot: string,
private readonly cipher: CapabilityCipher
) {}
private async load(): Promise<StoredCapabilities> {
if (this.state) {
return this.state
}
let loaded: StoredCapabilities
try {
loaded = storedCapabilitiesSchema.parse(
JSON.parse(await readFile(this.filePath, 'utf8'))
private readonly cipher: CapabilityCipher,
options: CapabilityServiceOptions = {}
) {
this.platform = options.platform ?? process.platform
this.architecture = options.architecture ?? process.arch
this.electronTarget =
options.electronTarget ?? Boolean(process.versions.electron)
this.availableComputerCapabilityImplementations = new Set(
options.availableComputerCapabilityImplementations ?? [
'managed-browser-driver'
]
)
this.browserProfiles =
options.browserProfiles ??
new BrowserProfileService(
new FileBrowserProfileStore(
join(dirname(this.filePath), 'browser-profiles')
)
)
const checks: CapabilityDiagnosticCheck[] = [
{
id: 'browser-executable',
run: async () =>
this.electronTarget
? {
status: 'available',
summary: 'GoodBuddy 的受管 Electron 浏览器核心可用。'
}
: {
status: 'unavailable',
summary: '当前进程不是受支持的 Electron 桌面目标。',
remedy: '请从 GoodBuddy 桌面应用运行此诊断。'
}
},
{
id: 'managed-profile-root',
run: async () => {
await this.browserProfiles.getSnapshot()
return {
status: 'available',
summary: '隔离的托管浏览器配置存储可用。'
}
}
}
]
this.diagnostics =
options.diagnostics ?? new CapabilityDiagnostics(checks)
}
private load(): Promise<StoredCapabilities> {
if (this.state) {
return Promise.resolve(this.state)
}
if (this.loadPromise) {
return this.loadPromise
}
const pending = this.loadUncached()
const tracked = pending.catch((error: unknown) => {
if (this.loadPromise === tracked) {
this.loadPromise = undefined
this.state = undefined
}
throw error
})
this.loadPromise = tracked
return tracked
}
private async loadUncached(): Promise<StoredCapabilities> {
let loaded: StoredCapabilities
let shouldPersist = false
try {
const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown
const version = z
.object({ version: z.union([z.literal(1), z.literal(2)]) })
.passthrough()
.parse(raw).version
if (version === 1) {
const legacy: StoredCapabilitiesV1 =
storedCapabilitiesV1Schema.parse(raw)
loaded = {
version: 2,
skills: legacy.skills,
mcpServers: legacy.mcpServers,
computerCapabilities: defaultComputerCapabilityStates()
}
shouldPersist = true
} else {
loaded = storedCapabilitiesSchema.parse(raw)
}
} catch (error) {
if (
error &&
@@ -260,13 +422,13 @@ export class CapabilityService {
'code' in error &&
error.code === 'ENOENT'
) {
loaded = { version: 1, skills: {}, mcpServers: [] }
loaded = emptyStoredCapabilities()
} else {
await rename(
this.filePath,
`${this.filePath}.corrupt-${Date.now()}`
).catch(() => undefined)
loaded = { version: 1, skills: {}, mcpServers: [] }
loaded = emptyStoredCapabilities()
}
}
const migrateMcpAssignments = loaded.mcpServers.some((server) =>
@@ -284,12 +446,30 @@ export class CapabilityService {
}
: loaded
this.state = storedCapabilitiesSchema.parse(migrated)
if (migrateMcpAssignments) {
await this.validateBrowserProfileReferences(this.state)
if (shouldPersist || migrateMcpAssignments) {
await this.persist(this.state)
}
return this.state
}
private async validateBrowserProfileReferences(
state: StoredCapabilities
): Promise<void> {
const profiles = await this.browserProfiles.getSnapshot()
const profileIds = new Set(profiles.profiles.map((profile) => profile.id))
for (const capability of computerCapabilityCatalog) {
const profileId =
state.computerCapabilities[capability.id].browserProfileId
if (profileId && !profileIds.has(profileId)) {
throw new Error('电脑控制能力引用了不存在的浏览器配置')
}
if (capability.id !== 'host-browser-control' && profileId) {
throw new Error('此电脑控制能力不支持浏览器配置')
}
}
}
private queue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.updateQueue.then(operation)
this.updateQueue = result.then(
@@ -339,9 +519,10 @@ export class CapabilityService {
}
async getSnapshot(): Promise<CapabilitySnapshot> {
const [state, catalog] = await Promise.all([
const [state, catalog, browserProfileState] = await Promise.all([
this.load(),
this.getSkillCatalog()
this.getSkillCatalog(),
this.browserProfiles.getSnapshot()
])
return {
skills: catalog
@@ -358,10 +539,284 @@ export class CapabilityService {
),
mcpServers: state.mcpServers.map((server) =>
this.toMcpSummary(server)
),
computerCapabilities: computerCapabilityCatalog.map((capability) =>
computerCapabilityConfigSummarySchema.parse({
id: capability.id,
name: capability.name,
description: capability.description,
enabled: state.computerCapabilities[capability.id].enabled,
supported: isComputerCapabilitySupported(
capability,
this.platform,
this.architecture,
this.availableComputerCapabilityImplementations
),
browserProfileId:
state.computerCapabilities[capability.id].browserProfileId,
riskSummary: capability.riskSummary
})
),
browserProfiles: this.toBrowserProfilesSummary(browserProfileState)
}
}
async getComputerCapabilityStatus(
capabilityId: ComputerCapabilityId
): Promise<{ enabled: boolean; supported: boolean }> {
const id = computerCapabilityIdSchema.parse(capabilityId)
const capability = getComputerCapability(id)
const state = await this.load()
return {
enabled: state.computerCapabilities[id].enabled,
supported: isComputerCapabilitySupported(
capability,
this.platform,
this.architecture,
this.availableComputerCapabilityImplementations
)
}
}
private toBrowserProfilesSummary(
state: BrowserProfileState
): BrowserProfilesSummary {
return browserProfilesSummarySchema.parse({
profiles: state.profiles.map((profile) => ({
id: profile.id,
name: profile.name,
mode: profile.mode
})),
defaultProfileId: state.defaultProfileId
})
}
setComputerCapabilityEnabled(
capabilityId: ComputerCapabilityId,
enabled: boolean
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = computerCapabilityIdSchema.parse(capabilityId)
const capability = getComputerCapability(id)
if (
enabled &&
!isComputerCapabilitySupported(
capability,
this.platform,
this.architecture,
this.availableComputerCapabilityImplementations
)
) {
throw new Error('当前操作系统或处理器架构不支持此能力')
}
if (enabled) {
const report = await this.diagnoseComputerCapabilityState(id, true)
if (report.status === 'unavailable') {
throw new Error('能力诊断不可用,未启用此能力')
}
}
const state = await this.load()
await this.persist({
...state,
computerCapabilities: {
...state.computerCapabilities,
[id]: {
...state.computerCapabilities[id],
enabled
}
}
})
return this.getSnapshot()
})
}
setComputerCapabilityBrowserProfile(
capabilityId: ComputerCapabilityId,
browserProfileId: string | null
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = computerCapabilityIdSchema.parse(capabilityId)
const profileId = browserProfileIdSchema.nullable().parse(
browserProfileId
)
if (id !== 'host-browser-control' && profileId) {
throw new Error('此电脑控制能力不支持浏览器配置')
}
const profiles = await this.browserProfiles.getSnapshot()
if (
profileId &&
!profiles.profiles.some((profile) => profile.id === profileId)
) {
throw new Error('浏览器配置不存在')
}
const state = await this.load()
const previousProfileId =
state.computerCapabilities[id].browserProfileId
if (profileId === previousProfileId) {
return this.getSnapshot()
}
const reference = { kind: 'capability' as const, id }
if (profileId) {
try {
await this.browserProfiles.addReference(profileId, reference)
} catch (error) {
try {
await this.browserProfiles.removeReference(profileId, reference)
} catch (compensationError) {
throw new AggregateError(
[error, compensationError],
'浏览器配置引用添加失败,且补偿清理未完成',
{ cause: compensationError }
)
}
throw error
}
}
const nextState: StoredCapabilities = {
...state,
computerCapabilities: {
...state.computerCapabilities,
[id]: {
...state.computerCapabilities[id],
browserProfileId: profileId
}
}
}
try {
await this.persist(nextState)
} catch (error) {
if (profileId) {
try {
await this.browserProfiles.removeReference(profileId, reference)
} catch (compensationError) {
throw new AggregateError(
[error, compensationError],
'电脑控制配置保存失败,且新增引用补偿清理未完成',
{ cause: compensationError }
)
}
}
throw error
}
if (previousProfileId) {
try {
await this.browserProfiles.removeReference(
previousProfileId,
reference
)
} catch (error) {
try {
await this.browserProfiles.addReference(
previousProfileId,
reference
)
await this.persist(state)
if (profileId) {
await this.browserProfiles.removeReference(
profileId,
reference
)
}
} catch (compensationError) {
throw new AggregateError(
[error, compensationError],
'旧浏览器配置引用移除失败,且回滚未完成',
{ cause: compensationError }
)
}
throw error
}
}
return this.getSnapshot()
})
}
async diagnoseComputerCapability(
capabilityId: ComputerCapabilityId
): Promise<CapabilityDiagnosticReport> {
const id = computerCapabilityIdSchema.parse(capabilityId)
const state = await this.load()
return this.diagnoseComputerCapabilityState(
id,
state.computerCapabilities[id].enabled
)
}
private async diagnoseComputerCapabilityState(
id: ComputerCapabilityId,
enabled: boolean
): Promise<CapabilityDiagnosticReport> {
if (
id === 'host-browser-control' &&
enabled &&
!this.electronTarget
) {
return capabilityDiagnosticReportSchema.parse({
capabilityId: id,
status: 'unavailable',
checkedAt: new Date().toISOString(),
checks: [
{
id: 'electron-target',
status: 'unavailable',
summary: '当前进程不是受支持的 Electron 桌面目标。',
remedy: '请从 GoodBuddy 桌面应用运行此诊断。'
}
]
})
}
return capabilityDiagnosticReportSchema.parse(
await this.diagnostics.diagnose({
capabilityId: id,
enabled,
platform: this.platform,
architecture: this.architecture,
availableImplementationKinds:
this.availableComputerCapabilityImplementations
})
)
}
createBrowserProfile(name: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
await this.browserProfiles.createProfile(
browserProfileNameSchema.parse(name)
)
return this.getSnapshot()
})
}
renameBrowserProfile(
profileId: string,
name: string
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
await this.browserProfiles.renameProfile(
browserProfileIdSchema.parse(profileId),
browserProfileNameSchema.parse(name)
)
return this.getSnapshot()
})
}
setDefaultBrowserProfile(profileId: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
await this.browserProfiles.setDefaultProfile(
browserProfileIdSchema.parse(profileId)
)
return this.getSnapshot()
})
}
removeBrowserProfile(profileId: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
await this.browserProfiles.deleteProfile(
browserProfileIdSchema.parse(profileId)
)
return this.getSnapshot()
})
}
importSkill(sourcePath: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const canonicalSource = await realpath(sourcePath)