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
@@ -0,0 +1,173 @@
import {
mkdtemp,
mkdir,
readFile,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
BrowserProfileService,
FileBrowserProfileStore,
MemoryBrowserProfileStore
} from './browser-profile-service'
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('BrowserProfileService', () => {
it('defaults new profiles to isolated managed mode and migrates version 1', async () => {
const legacyId = 'af6774e4-39e0-4479-b81b-42ec0f85c353'
const store = new MemoryBrowserProfileStore({
version: 1,
profiles: [{ id: legacyId, name: '旧配置' }]
})
const migrated = await new BrowserProfileService(store).getSnapshot()
expect(migrated).toEqual({
version: 2,
profiles: [
{
id: legacyId,
name: '旧配置',
mode: 'managed-isolated',
references: []
}
],
defaultProfileId: legacyId
})
const created = await new BrowserProfileService(
new MemoryBrowserProfileStore()
).createProfile('隔离浏览器')
expect(created.profiles[0]).toMatchObject({
name: '隔离浏览器',
mode: 'managed-isolated',
references: []
})
expect(created.defaultProfileId).toBe(created.profiles[0]?.id)
})
it('persists loaded state only when migration changes it', async () => {
const profileId = 'af6774e4-39e0-4479-b81b-42ec0f85c353'
const currentStore = {
load: vi.fn(async () => ({
version: 2,
profiles: [
{
id: profileId,
name: '当前配置',
mode: 'managed-isolated',
references: []
}
],
defaultProfileId: profileId
})),
save: vi.fn(async () => undefined)
}
const current = new BrowserProfileService(currentStore)
await current.getSnapshot()
await current.getSnapshot()
expect(currentStore.load).toHaveBeenCalledOnce()
expect(currentStore.save).not.toHaveBeenCalled()
const legacyStore = {
load: vi.fn(async () => ({
version: 1,
profiles: [{ id: profileId, name: '旧配置' }]
})),
save: vi.fn(async () => undefined)
}
await new BrowserProfileService(legacyStore).getSnapshot()
expect(legacyStore.save).toHaveBeenCalledOnce()
expect(legacyStore.save).toHaveBeenCalledWith(
expect.objectContaining({ version: 2 })
)
})
it('persists only strict browser metadata without arguments or environment', async () => {
const service = new BrowserProfileService(new MemoryBrowserProfileStore())
const state = await service.createProfile('浏览器')
const id = state.profiles[0]?.id
if (!id) {
throw new Error('Expected browser profile')
}
await expect(
service.selectBrowser(id, {
executablePath: resolve(process.execPath),
displayName: 'Selected browser',
source: 'user-selected',
args: ['--remote-debugging-port=1']
} as never)
).rejects.toThrow()
await expect(
service.selectBrowser(id, {
executablePath: resolve(process.execPath),
displayName: 'Selected browser',
source: 'user-selected',
env: { TOKEN: 'secret' }
} as never)
).rejects.toThrow()
})
it('blocks deletion while a profile is referenced', async () => {
const service = new BrowserProfileService(new MemoryBrowserProfileStore())
const created = await service.createProfile('自动化配置')
const id = created.profiles[0]?.id
if (!id) {
throw new Error('Expected browser profile')
}
const reference = { kind: 'automation' as const, id: 'job:daily-check' }
await service.addReference(id, reference)
await expect(service.deleteProfile(id)).rejects.toThrow('Referenced')
await service.removeReference(id, reference)
await expect(service.deleteProfile(id)).resolves.toMatchObject({
profiles: [],
defaultProfileId: null
})
})
it('uses atomic files under the owned root and rejects a symlink root', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-browser-store-'))
temporaryDirectories.push(directory)
const root = join(directory, 'owned')
const outsideRoot = join(directory, 'outside')
const outside = join(outsideRoot, 'browser-profiles.json')
await mkdir(outsideRoot)
await writeFile(outside, '{"sentinel":true}', 'utf8')
await symlink(outsideRoot, root, 'junction')
const service = new BrowserProfileService(
new FileBrowserProfileStore(root)
)
await expect(service.getSnapshot()).rejects.toThrow('real directory')
await expect(readFile(outside, 'utf8')).resolves.toBe('{"sentinel":true}')
})
it('rejects invalid defaults and unknown persisted properties', async () => {
const unknownId = 'b7f29e4c-1c4a-4aa0-ac58-5165451dde07'
const service = new BrowserProfileService(
new MemoryBrowserProfileStore({
version: 2,
profiles: [],
defaultProfileId: unknownId,
args: ['--unsafe']
})
)
await expect(service.getSnapshot()).rejects.toThrow()
})
})
@@ -0,0 +1,424 @@
import { randomUUID } from 'node:crypto'
import {
lstat,
mkdir,
readFile,
realpath,
rename,
rm,
writeFile
} from 'node:fs/promises'
import { isAbsolute, join, relative, resolve } from 'node:path'
import { z } from 'zod'
import {
browserProfileIdSchema,
browserProfileNameSchema
} from '../../shared/capability-contracts'
const MAX_PROFILES = 32
const MAX_REFERENCES = 64
const MAX_STORE_BYTES = 256 * 1024
const boundedPathSchema = z
.string()
.trim()
.min(1)
.max(2_048)
.refine((value) => isAbsolute(value), 'Browser executable path must be absolute')
.refine(
(value) => resolve(value) === value,
'Browser executable path must be normalized'
)
export const browserExecutableMetadataSchema = z
.object({
executablePath: boundedPathSchema,
displayName: browserProfileNameSchema,
source: z.literal('user-selected')
})
.strict()
export const browserProfileReferenceSchema = z
.object({
kind: z.enum(['capability', 'automation']),
id: z.string().trim().min(1).max(128).regex(/^[a-zA-Z0-9._:-]+$/u)
})
.strict()
export const browserProfileSchema = z
.object({
id: browserProfileIdSchema,
name: browserProfileNameSchema,
mode: z.literal('managed-isolated'),
browser: browserExecutableMetadataSchema.optional(),
references: z.array(browserProfileReferenceSchema).max(MAX_REFERENCES)
})
.strict()
export const browserProfileStateSchema = z
.object({
version: z.literal(2),
profiles: z.array(browserProfileSchema).max(MAX_PROFILES),
defaultProfileId: browserProfileIdSchema.nullable()
})
.strict()
.superRefine((state, context) => {
const ids = new Set<string>()
for (const [index, profile] of state.profiles.entries()) {
if (ids.has(profile.id)) {
context.addIssue({
code: 'custom',
path: ['profiles', index, 'id'],
message: 'Browser profile IDs must be unique'
})
}
ids.add(profile.id)
}
if (state.defaultProfileId && !ids.has(state.defaultProfileId)) {
context.addIssue({
code: 'custom',
path: ['defaultProfileId'],
message: 'Default browser profile must exist'
})
}
})
const version1ProfileSchema = z
.object({
id: browserProfileIdSchema,
name: browserProfileNameSchema,
browser: browserExecutableMetadataSchema.optional()
})
.strict()
const version1StateSchema = z
.object({
version: z.literal(1),
profiles: z.array(version1ProfileSchema).max(MAX_PROFILES),
defaultProfileId: browserProfileIdSchema.nullable().optional()
})
.strict()
export type BrowserExecutableMetadata = z.infer<
typeof browserExecutableMetadataSchema
>
export type BrowserProfileReference = z.infer<
typeof browserProfileReferenceSchema
>
export type BrowserProfile = z.infer<typeof browserProfileSchema>
export type BrowserProfileState = z.infer<typeof browserProfileStateSchema>
const emptyState = (): BrowserProfileState => ({
version: 2,
profiles: [],
defaultProfileId: null
})
function migrateState(
value: unknown
): { state: BrowserProfileState; migrated: boolean } {
const version = z
.object({ version: z.union([z.literal(1), z.literal(2)]) })
.passthrough()
.parse(value).version
if (version === 2) {
return {
state: browserProfileStateSchema.parse(value),
migrated: false
}
}
const legacy = version1StateSchema.parse(value)
return {
state: browserProfileStateSchema.parse({
version: 2,
profiles: legacy.profiles.map((profile) => ({
...profile,
mode: 'managed-isolated',
references: []
})),
defaultProfileId:
legacy.defaultProfileId ?? legacy.profiles[0]?.id ?? null
}),
migrated: true
}
}
export interface BrowserProfileStore {
load(): Promise<unknown | undefined>
save(state: BrowserProfileState): Promise<void>
}
export class MemoryBrowserProfileStore implements BrowserProfileStore {
private value: unknown
constructor(initialValue?: unknown) {
this.value = initialValue
}
async load(): Promise<unknown | undefined> {
return structuredClone(this.value)
}
async save(state: BrowserProfileState): Promise<void> {
this.value = structuredClone(state)
}
}
export class FileBrowserProfileStore implements BrowserProfileStore {
private readonly fileName = 'browser-profiles.json'
constructor(private readonly ownedRoot: string) {
if (!isAbsolute(ownedRoot)) {
throw new Error('Browser profile storage root must be absolute')
}
}
private async prepareRoot(): Promise<{ root: string; filePath: string }> {
await mkdir(this.ownedRoot, { recursive: true, mode: 0o700 })
const rootDetails = await lstat(this.ownedRoot)
if (!rootDetails.isDirectory() || rootDetails.isSymbolicLink()) {
throw new Error('Browser profile storage root must be a real directory')
}
const root = await realpath(this.ownedRoot)
const filePath = join(root, this.fileName)
const fromRoot = relative(root, filePath)
if (
fromRoot.startsWith('..') ||
isAbsolute(fromRoot) ||
fromRoot === ''
) {
throw new Error('Browser profile storage path escapes its owned root')
}
return { root, filePath }
}
async load(): Promise<unknown | undefined> {
const { filePath } = await this.prepareRoot()
try {
const details = await lstat(filePath)
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error('Browser profile storage file must be a regular file')
}
if (details.size > MAX_STORE_BYTES) {
throw new Error('Browser profile storage file is too large')
}
return JSON.parse(await readFile(filePath, 'utf8')) as unknown
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return undefined
}
throw error
}
}
async save(state: BrowserProfileState): Promise<void> {
const { root, filePath } = await this.prepareRoot()
try {
const targetDetails = await lstat(filePath)
if (targetDetails.isSymbolicLink() || !targetDetails.isFile()) {
throw new Error('Browser profile storage file must be a regular file')
}
} catch (error) {
if (
!(
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
) {
throw error
}
}
const temporaryPath = join(root, `.${this.fileName}.${randomUUID()}.tmp`)
try {
await writeFile(
temporaryPath,
`${JSON.stringify(browserProfileStateSchema.parse(state), null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600, flag: 'wx' }
)
await rename(temporaryPath, filePath)
} finally {
await rm(temporaryPath, { force: true })
}
}
}
export class BrowserProfileService {
private state?: BrowserProfileState
private updateQueue: Promise<void> = Promise.resolve()
constructor(private readonly store: BrowserProfileStore) {}
private async getState(): Promise<BrowserProfileState> {
if (!this.state) {
const loaded = await this.store.load()
const result =
loaded === undefined
? { state: emptyState(), migrated: false }
: migrateState(loaded)
if (result.migrated) {
await this.store.save(result.state)
}
this.state = result.state
}
return this.state
}
private async update(
operation: (state: BrowserProfileState) => BrowserProfileState
): Promise<BrowserProfileState> {
let result: BrowserProfileState | undefined
const queued = this.updateQueue.then(async () => {
const next = browserProfileStateSchema.parse(
operation(structuredClone(await this.getState()))
)
await this.store.save(next)
this.state = next
result = next
})
this.updateQueue = queued.catch(() => undefined)
await queued
if (!result) {
throw new Error('Browser profile update failed')
}
return structuredClone(result)
}
async getSnapshot(): Promise<BrowserProfileState> {
return structuredClone(await this.getState())
}
async createProfile(name: string): Promise<BrowserProfileState> {
const parsedName = browserProfileNameSchema.parse(name)
return this.update((state) => {
if (state.profiles.length >= MAX_PROFILES) {
throw new Error('Browser profile limit reached')
}
const profile: BrowserProfile = {
id: randomUUID(),
name: parsedName,
mode: 'managed-isolated',
references: []
}
state.profiles.push(profile)
state.defaultProfileId ??= profile.id
return state
})
}
async renameProfile(
profileId: string,
name: string
): Promise<BrowserProfileState> {
const id = browserProfileIdSchema.parse(profileId)
const parsedName = browserProfileNameSchema.parse(name)
return this.update((state) => {
const profile = state.profiles.find((candidate) => candidate.id === id)
if (!profile) {
throw new Error('Browser profile not found')
}
profile.name = parsedName
return state
})
}
async selectBrowser(
profileId: string,
browser?: BrowserExecutableMetadata
): Promise<BrowserProfileState> {
const id = browserProfileIdSchema.parse(profileId)
const parsedBrowser = browserExecutableMetadataSchema
.optional()
.parse(browser)
return this.update((state) => {
const profile = state.profiles.find((candidate) => candidate.id === id)
if (!profile) {
throw new Error('Browser profile not found')
}
profile.browser = parsedBrowser
return state
})
}
async setDefaultProfile(profileId: string): Promise<BrowserProfileState> {
const id = browserProfileIdSchema.parse(profileId)
return this.update((state) => {
if (!state.profiles.some((profile) => profile.id === id)) {
throw new Error('Browser profile not found')
}
state.defaultProfileId = id
return state
})
}
async addReference(
profileId: string,
reference: BrowserProfileReference
): Promise<BrowserProfileState> {
const id = browserProfileIdSchema.parse(profileId)
const parsedReference = browserProfileReferenceSchema.parse(reference)
return this.update((state) => {
const profile = state.profiles.find((candidate) => candidate.id === id)
if (!profile) {
throw new Error('Browser profile not found')
}
if (
!profile.references.some(
(candidate) =>
candidate.kind === parsedReference.kind &&
candidate.id === parsedReference.id
)
) {
profile.references.push(parsedReference)
}
return state
})
}
async removeReference(
profileId: string,
reference: BrowserProfileReference
): Promise<BrowserProfileState> {
const id = browserProfileIdSchema.parse(profileId)
const parsedReference = browserProfileReferenceSchema.parse(reference)
return this.update((state) => {
const profile = state.profiles.find((candidate) => candidate.id === id)
if (!profile) {
throw new Error('Browser profile not found')
}
profile.references = profile.references.filter(
(candidate) =>
candidate.kind !== parsedReference.kind ||
candidate.id !== parsedReference.id
)
return state
})
}
async deleteProfile(profileId: string): Promise<BrowserProfileState> {
const id = browserProfileIdSchema.parse(profileId)
return this.update((state) => {
const profile = state.profiles.find((candidate) => candidate.id === id)
if (!profile) {
throw new Error('Browser profile not found')
}
if (profile.references.length > 0) {
throw new Error('Referenced browser profiles cannot be deleted')
}
state.profiles = state.profiles.filter(
(candidate) => candidate.id !== id
)
if (state.defaultProfileId === id) {
state.defaultProfileId = state.profiles[0]?.id ?? null
}
return state
})
}
}
@@ -0,0 +1,188 @@
import { afterEach, describe, expect, it } from 'vitest'
import { capabilityDiagnosticReportSchema } from '../../shared/capability-contracts'
import {
CapabilityDiagnostics,
type CapabilityDiagnosticCheck
} from './capability-diagnostics'
const originalDiagnosticSecret = process.env.GOODBUDDY_DIAGNOSTIC_TEST_SECRET
afterEach(() => {
if (originalDiagnosticSecret === undefined) {
delete process.env.GOODBUDDY_DIAGNOSTIC_TEST_SECRET
} else {
process.env.GOODBUDDY_DIAGNOSTIC_TEST_SECRET = originalDiagnosticSecret
}
})
function check(
id: string,
status: 'available' | 'degraded' | 'unavailable',
summary = `${id} result`
): CapabilityDiagnosticCheck {
return {
id,
run: async () => ({ status, summary })
}
}
describe('CapabilityDiagnostics', () => {
it('aggregates required checks with unavailable taking precedence', async () => {
const diagnostics = new CapabilityDiagnostics(
[
check('browser-executable', 'degraded'),
check('managed-profile-root', 'unavailable')
],
{ now: () => new Date('2026-08-05T12:00:00.000Z') }
)
await expect(
diagnostics.diagnose({
capabilityId: 'host-browser-control',
enabled: true,
platform: 'win32',
architecture: 'x64'
})
).resolves.toMatchObject({
status: 'unavailable',
checkedAt: '2026-08-05T12:00:00.000Z',
checks: [
{ id: 'browser-executable', status: 'degraded' },
{ id: 'managed-profile-root', status: 'unavailable' }
]
})
})
it('reports available, degraded, disabled and unsupported states', async () => {
const available = new CapabilityDiagnostics([
check('browser-executable', 'available'),
check('managed-profile-root', 'available')
])
const degraded = new CapabilityDiagnostics([
check('browser-executable', 'available'),
check('managed-profile-root', 'degraded')
])
const request = {
capabilityId: 'host-browser-control' as const,
enabled: true,
platform: 'linux' as const,
architecture: 'arm64'
}
await expect(available.diagnose(request)).resolves.toMatchObject({
status: 'available'
})
await expect(degraded.diagnose(request)).resolves.toMatchObject({
status: 'degraded'
})
await expect(
available.diagnose({ ...request, enabled: false })
).resolves.toMatchObject({ status: 'disabled', checks: [] })
await expect(
available.diagnose({
...request,
platform: 'freebsd'
})
).resolves.toMatchObject({
status: 'unavailable',
checks: [{ id: 'platform-support', status: 'unavailable' }]
})
})
it('redacts environment values, credentials and sensitive paths', async () => {
process.env.GOODBUDDY_DIAGNOSTIC_TEST_SECRET =
'environment-secret-value-12345'
const diagnostics = new CapabilityDiagnostics([
{
id: 'browser-executable',
run: async () => ({
status: 'degraded',
summary:
'token=top-secret-token Bearer abc.def.ghi environment-secret-value-12345 /home/alice/private/browser',
remedy:
'apiKey=sk-abcdefghijklmnop at C:\\Users\\Alice\\AppData\\browser'
})
},
check('managed-profile-root', 'available')
])
const report = await diagnostics.diagnose({
capabilityId: 'host-browser-control',
enabled: true,
platform: 'linux',
architecture: 'x64'
})
const serialized = JSON.stringify(report)
expect(capabilityDiagnosticReportSchema.parse(report)).toEqual(report)
expect(serialized).not.toContain('top-secret-token')
expect(serialized).not.toContain('abc.def.ghi')
expect(serialized).not.toContain('environment-secret-value-12345')
expect(serialized).not.toContain('/home/alice')
expect(serialized).not.toContain('C:\\\\Users\\\\Alice')
expect(serialized).toContain('[redacted')
})
it('fails closed when a check times out', async () => {
const diagnostics = new CapabilityDiagnostics(
[
{
id: 'browser-executable',
run: async () => new Promise(() => undefined)
},
check('managed-profile-root', 'available')
],
{ timeoutMs: 10 }
)
const report = await diagnostics.diagnose({
capabilityId: 'host-browser-control',
enabled: true,
platform: 'win32',
architecture: 'x64'
})
expect(report.status).toBe('unavailable')
expect(report.checks[0]).toMatchObject({
id: 'browser-executable',
status: 'unavailable',
summary: '诊断检查超时。'
})
})
it('fails closed promptly when cancelled', async () => {
const controller = new AbortController()
const diagnostics = new CapabilityDiagnostics(
[
{
id: 'browser-executable',
run: async () => new Promise(() => undefined)
},
check('managed-profile-root', 'available')
],
{ timeoutMs: 1_000 }
)
const pending = diagnostics.diagnose({
capabilityId: 'host-browser-control',
enabled: true,
platform: 'darwin',
architecture: 'arm64',
signal: controller.signal
})
controller.abort()
await expect(pending).resolves.toMatchObject({
status: 'unavailable',
checks: [
{
id: 'browser-executable',
status: 'unavailable',
summary: '诊断检查已取消。'
},
{
id: 'managed-profile-root',
status: 'unavailable',
summary: '诊断检查已取消。'
}
]
})
})
})
@@ -0,0 +1,262 @@
import { z } from 'zod'
import {
capabilityDiagnosticCheckSchema,
capabilityDiagnosticCheckStatusSchema,
capabilityDiagnosticReportSchema,
type CapabilityDiagnosticReport,
type ComputerCapabilityId
} from '../../shared/capability-contracts'
export type { CapabilityDiagnosticReport } from '../../shared/capability-contracts'
import { redactSensitiveText } from '../agent/approval-summary'
import {
getComputerCapability,
isComputerCapabilitySupported,
type ComputerCapabilityImplementationKind
} from './computer-capability-catalog'
const MAX_SUMMARY_LENGTH = 240
const MAX_REMEDY_LENGTH = 400
const diagnosticCheckIdSchema = capabilityDiagnosticCheckSchema.shape.id
export const diagnosticCheckResultSchema = z
.object({
status: capabilityDiagnosticCheckStatusSchema,
summary: z.string().min(1).max(4_096),
remedy: z.string().max(4_096).optional()
})
.strict()
export type DiagnosticCheckResult = z.infer<
typeof diagnosticCheckResultSchema
>
export type CapabilityDiagnosticCheck = Readonly<{
id: string
run: (signal: AbortSignal) => Promise<DiagnosticCheckResult>
}>
export type CapabilityDiagnosticRequest = Readonly<{
capabilityId: ComputerCapabilityId
enabled: boolean
platform: NodeJS.Platform
architecture: string
availableImplementationKinds?: ReadonlySet<ComputerCapabilityImplementationKind>
signal?: AbortSignal
}>
function redactText(value: string, maximumLength: number): string {
let redacted = redactSensitiveText(value)
const environmentValues = Object.values(process.env)
.filter((candidate): candidate is string => Boolean(candidate?.length && candidate.length >= 8))
.sort((left, right) => right.length - left.length)
for (const environmentValue of environmentValues) {
redacted = redacted.split(environmentValue).join('[redacted-env]')
}
redacted = redacted
.replace(
/(?:[a-zA-Z]:\\(?:Users|Documents and Settings)\\|\/(?:Users|home|root|private|var\/folders|tmp)\/)[^\s"'<>]+/gu,
'[redacted-path]'
)
.replace(/[\r\n\t]+/gu, ' ')
.trim()
return redacted.slice(0, maximumLength)
}
function unavailableCheck(
id: string,
summary: string,
remedy?: string
): CapabilityDiagnosticReport['checks'][number] {
return {
id,
status: 'unavailable',
summary: redactText(summary, MAX_SUMMARY_LENGTH),
...(remedy
? { remedy: redactText(remedy, MAX_REMEDY_LENGTH) }
: {})
}
}
export class CapabilityDiagnostics {
private readonly checks: ReadonlyMap<string, CapabilityDiagnosticCheck>
private readonly timeoutMs: number
private readonly now: () => Date
constructor(
checks: readonly CapabilityDiagnosticCheck[],
options: Readonly<{ timeoutMs?: number; now?: () => Date }> = {}
) {
const mapped = new Map<string, CapabilityDiagnosticCheck>()
for (const check of checks) {
const id = diagnosticCheckIdSchema.parse(check.id)
if (mapped.has(id)) {
throw new Error(`Duplicate capability diagnostic check: ${id}`)
}
mapped.set(id, Object.freeze({ id, run: check.run }))
}
this.checks = mapped
this.timeoutMs = z
.number()
.int()
.min(10)
.max(30_000)
.parse(options.timeoutMs ?? 5_000)
this.now = options.now ?? (() => new Date())
}
private checkedAt(): string {
const value = this.now()
if (Number.isNaN(value.getTime())) {
throw new Error('Diagnostic clock returned an invalid date')
}
return value.toISOString()
}
private async runCheck(
id: string,
parentSignal?: AbortSignal
): Promise<CapabilityDiagnosticReport['checks'][number]> {
const check = this.checks.get(id)
if (!check) {
return unavailableCheck(
id,
'缺少必需的诊断检查。',
'请重新安装或修复此能力的受管组件。'
)
}
if (parentSignal?.aborted) {
return unavailableCheck(id, '诊断检查已取消。', '请重试诊断。')
}
const controller = new AbortController()
let timedOut = false
let cancelled = false
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
const timeoutResult = new Promise<'timeout'>((resolveTimeout) => {
timeoutHandle = setTimeout(() => {
timedOut = true
controller.abort(new Error('Diagnostic check timed out'))
resolveTimeout('timeout')
}, this.timeoutMs)
})
let resolveCancellation: (() => void) | undefined
const cancellation = new Promise<'cancelled'>((resolveCancelled) => {
resolveCancellation = () => resolveCancelled('cancelled')
})
const abortFromParent = (): void => {
cancelled = true
controller.abort(parentSignal?.reason)
resolveCancellation?.()
}
parentSignal?.addEventListener('abort', abortFromParent, { once: true })
const operation = Promise.resolve()
.then(() => check.run(controller.signal))
.then(
(result) => ({ kind: 'result' as const, result }),
(error: unknown) => ({ kind: 'error' as const, error })
)
try {
const outcome = await Promise.race([
operation,
timeoutResult.then((kind) => ({ kind })),
cancellation.then((kind) => ({ kind }))
])
if (outcome.kind === 'timeout' || timedOut) {
return unavailableCheck(
id,
'诊断检查超时。',
'请确认受管组件可响应后重试。'
)
}
if (outcome.kind === 'cancelled' || cancelled) {
return unavailableCheck(id, '诊断检查已取消。', '请重试诊断。')
}
if (outcome.kind === 'error') {
const message =
outcome.error instanceof Error
? outcome.error.message
: '未知诊断错误'
return unavailableCheck(
id,
`诊断检查失败:${message}`,
'请修复本地环境后重试。'
)
}
try {
const result = diagnosticCheckResultSchema.parse(outcome.result)
return {
id,
status: result.status,
summary: redactText(result.summary, MAX_SUMMARY_LENGTH),
...(result.remedy
? { remedy: redactText(result.remedy, MAX_REMEDY_LENGTH) }
: {})
}
} catch {
return unavailableCheck(
id,
'诊断检查返回了无效结果。',
'请重新安装或修复此能力的受管组件。'
)
}
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
parentSignal?.removeEventListener('abort', abortFromParent)
}
}
async diagnose(
request: CapabilityDiagnosticRequest
): Promise<CapabilityDiagnosticReport> {
const capability = getComputerCapability(request.capabilityId)
const checkedAt = this.checkedAt()
if (!request.enabled) {
return capabilityDiagnosticReportSchema.parse({
capabilityId: capability.id,
status: 'disabled',
checkedAt,
checks: []
})
}
if (
!isComputerCapabilitySupported(
capability,
request.platform,
request.architecture,
request.availableImplementationKinds
)
) {
return capabilityDiagnosticReportSchema.parse({
capabilityId: capability.id,
status: 'unavailable',
checkedAt,
checks: [
unavailableCheck(
'platform-support',
'当前操作系统或处理器架构不受支持。',
'请在能力目录列出的受支持平台上使用此能力。'
)
]
})
}
const checks = []
for (const id of capability.requiredDiagnostics) {
checks.push(await this.runCheck(id, request.signal))
}
const status = checks.some((check) => check.status === 'unavailable')
? 'unavailable'
: checks.some((check) => check.status === 'degraded')
? 'degraded'
: 'available'
return capabilityDiagnosticReportSchema.parse({
capabilityId: capability.id,
status,
checkedAt,
checks
})
}
}
@@ -1,11 +1,17 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
CapabilityService,
type CapabilityCipher
type CapabilityCipher,
type CapabilityServiceOptions
} from './capability-service'
import {
BrowserProfileService,
MemoryBrowserProfileStore
} from './browser-profile-service'
import { CapabilityDiagnostics } from './capability-diagnostics'
const temporaryDirectories: string[] = []
@@ -15,6 +21,33 @@ const cipher: CapabilityCipher = {
decrypt: (value) => value.toString().replace(/^encrypted:/u, '')
}
class FailingBrowserProfileService extends BrowserProfileService {
failNextAddAfterSave = false
failNextRemoveAfterSave = false
override async addReference(
...args: Parameters<BrowserProfileService['addReference']>
): ReturnType<BrowserProfileService['addReference']> {
const result = await super.addReference(...args)
if (this.failNextAddAfterSave) {
this.failNextAddAfterSave = false
throw new Error('Injected add reference failure')
}
return result
}
override async removeReference(
...args: Parameters<BrowserProfileService['removeReference']>
): ReturnType<BrowserProfileService['removeReference']> {
const result = await super.removeReference(...args)
if (this.failNextRemoveAfterSave) {
this.failNextRemoveAfterSave = false
throw new Error('Injected remove reference failure')
}
return result
}
}
async function writeSkill(
root: string,
id: string,
@@ -42,7 +75,16 @@ async function writeSkill(
)
}
async function createService(): Promise<{
async function createService(
options: CapabilityServiceOptions = {
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
)
}
): Promise<{
directory: string
filePath: string
builtinRoot: string
@@ -64,12 +106,14 @@ async function createService(): Promise<{
filePath,
builtinRoot,
importedRoot,
cipher
cipher,
options
)
}
}
afterEach(async () => {
delete process.env.GOODBUDDY_CAPABILITY_SERVICE_SECRET
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
@@ -78,6 +122,77 @@ afterEach(async () => {
})
describe('CapabilityService', () => {
it('memoizes concurrent loads and retries after a failed load', async () => {
const initialStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => undefined)
}
const created = await createService({
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles: new BrowserProfileService(initialStore)
})
await Promise.all([
created.service.getComputerCapabilityStatus('host-browser-control'),
created.service.getComputerCapabilityStatus('host-browser-control'),
created.service.getComputerCapabilityStatus('host-browser-control')
])
expect(initialStore.load).toHaveBeenCalledOnce()
let profileLoads = 0
const browserProfiles = new BrowserProfileService({
load: vi.fn(async () => {
profileLoads += 1
if (profileLoads === 1) {
throw new Error('Injected profile load failure')
}
return undefined
}),
save: vi.fn(async () => undefined)
})
const retrying = new CapabilityService(
created.filePath,
created.builtinRoot,
created.importedRoot,
cipher,
{
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles
}
)
await expect(
Promise.all([
retrying.getComputerCapabilityStatus('host-browser-control'),
retrying.getComputerCapabilityStatus('host-browser-control')
])
).rejects.toThrow('Injected profile load failure')
await expect(
retrying.getComputerCapabilityStatus('host-browser-control')
).resolves.toEqual({ enabled: false, supported: true })
expect(profileLoads).toBe(2)
})
it('reports safe enabled and supported capability status', async () => {
const { service } = await createService()
await expect(
service.getComputerCapabilityStatus('host-browser-control')
).resolves.toEqual({ enabled: false, supported: true })
await service.setComputerCapabilityEnabled(
'host-browser-control',
true
)
await expect(
service.getComputerCapabilityStatus('host-browser-control')
).resolves.toEqual({ enabled: true, supported: true })
})
it('discovers built-in skills and persists enablement and assignments', async () => {
const { filePath, builtinRoot, importedRoot, service } =
await createService()
@@ -267,4 +382,375 @@ describe('CapabilityService', () => {
await expect(service.getResolvedMcpServers('opencode')).resolves.toEqual([])
await expect(service.getResolvedMcpServers('model')).resolves.toHaveLength(1)
})
it('migrates v1 to v2 without losing skills, MCP configuration, or encrypted secrets', async () => {
const { filePath, builtinRoot, importedRoot } = await createService()
const credential = Buffer.from(
'encrypted:{"version":1,"serverId":"d2ef774b-146c-4467-a909-6feb112a9c2c","secret":"preserved-secret"}'
).toString('base64')
await writeFile(
filePath,
JSON.stringify({
version: 1,
skills: {
'document-writing': {
enabled: false,
assignments: ['model']
}
},
mcpServers: [
{
id: 'd2ef774b-146c-4467-a909-6feb112a9c2c',
name: 'Preserved MCP',
description: 'migration',
enabled: true,
assignments: ['model'],
credential: {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: credential
},
transport: 'http',
url: 'https://mcp.example.com/mcp'
}
]
}),
'utf8'
)
const service = new CapabilityService(
filePath,
builtinRoot,
importedRoot,
cipher,
{
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
)
}
)
await expect(service.getSnapshot()).resolves.toMatchObject({
skills: [
expect.objectContaining({
id: 'document-writing',
enabled: false,
assignments: ['model']
})
],
mcpServers: [
expect.objectContaining({
name: 'Preserved MCP',
secretConfigured: true
})
],
computerCapabilities: [
expect.objectContaining({
id: 'host-browser-control',
enabled: false
}),
expect.objectContaining({
id: 'linux-desktop-control',
enabled: false
})
]
})
const persisted = await readFile(filePath, 'utf8')
expect(persisted).toContain('"version": 2')
expect(persisted).toContain(credential)
expect(persisted).not.toContain('preserved-secret')
})
it('gates enablement on the supported platform and architecture', async () => {
const { service } = await createService({
platform: 'darwin',
architecture: 'arm64',
electronTarget: true,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
)
})
await expect(
service.setComputerCapabilityEnabled(
'linux-desktop-control',
true
)
).rejects.toThrow('不支持')
const snapshot = await service.setComputerCapabilityEnabled(
'host-browser-control',
true
)
expect(snapshot.computerCapabilities).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'host-browser-control',
enabled: true
})
])
)
})
it('maintains browser profile references and rejects unknown profiles', async () => {
const { service } = await createService()
await expect(
service.setComputerCapabilityBrowserProfile(
'host-browser-control',
'b7f29e4c-1c4a-4aa0-ac58-5165451dde07'
)
).rejects.toThrow('不存在')
const created = await service.createBrowserProfile('隔离工作配置')
const profileId = created.browserProfiles?.profiles[0]?.id
if (!profileId) {
throw new Error('Expected browser profile')
}
await service.setComputerCapabilityBrowserProfile(
'host-browser-control',
profileId
)
await expect(service.removeBrowserProfile(profileId)).rejects.toThrow(
'Referenced'
)
await service.setComputerCapabilityBrowserProfile(
'host-browser-control',
null
)
await expect(service.removeBrowserProfile(profileId)).resolves.toMatchObject(
{
browserProfiles: { profiles: [], defaultProfileId: null }
}
)
})
it('compensates browser profile references when add or capability persistence fails', async () => {
const addProfiles = new FailingBrowserProfileService(
new MemoryBrowserProfileStore()
)
const addCase = await createService({
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles: addProfiles
})
const addCreated = await addCase.service.createBrowserProfile(
'添加失败配置'
)
const addProfileId = addCreated.browserProfiles?.profiles[0]?.id
if (!addProfileId) {
throw new Error('Expected add-failure browser profile')
}
addProfiles.failNextAddAfterSave = true
await expect(
addCase.service.setComputerCapabilityBrowserProfile(
'host-browser-control',
addProfileId
)
).rejects.toThrow('Injected add reference failure')
await expect(
addCase.service.removeBrowserProfile(addProfileId)
).resolves.toMatchObject({
browserProfiles: { profiles: [], defaultProfileId: null }
})
const persistProfiles = new BrowserProfileService(
new MemoryBrowserProfileStore()
)
const persistCase = await createService({
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles: persistProfiles
})
const persistCreated =
await persistCase.service.createBrowserProfile('保存失败配置')
const persistProfileId =
persistCreated.browserProfiles?.profiles[0]?.id
if (!persistProfileId) {
throw new Error('Expected persistence-failure browser profile')
}
await mkdir(persistCase.filePath)
await expect(
persistCase.service.setComputerCapabilityBrowserProfile(
'host-browser-control',
persistProfileId
)
).rejects.toThrow()
await expect(
persistCase.service.removeBrowserProfile(persistProfileId)
).resolves.toMatchObject({
browserProfiles: { profiles: [], defaultProfileId: null }
})
})
it('rolls back capability and profile stores when old-reference removal fails', async () => {
const browserProfiles = new FailingBrowserProfileService(
new MemoryBrowserProfileStore()
)
const { service } = await createService({
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles
})
const first = await service.createBrowserProfile('原配置')
const firstId = first.browserProfiles?.profiles[0]?.id
const second = await service.createBrowserProfile('新配置')
const secondId = second.browserProfiles?.profiles[1]?.id
if (!firstId || !secondId) {
throw new Error('Expected two browser profiles')
}
await service.setComputerCapabilityBrowserProfile(
'host-browser-control',
firstId
)
browserProfiles.failNextRemoveAfterSave = true
await expect(
service.setComputerCapabilityBrowserProfile(
'host-browser-control',
secondId
)
).rejects.toThrow('Injected remove reference failure')
await expect(service.getSnapshot()).resolves.toMatchObject({
computerCapabilities: expect.arrayContaining([
expect.objectContaining({
id: 'host-browser-control',
browserProfileId: firstId
})
])
})
await expect(service.removeBrowserProfile(secondId)).resolves.toBeDefined()
await expect(service.removeBrowserProfile(firstId)).rejects.toThrow(
'Referenced'
)
})
it('redacts injected diagnostics and never claims browser availability outside Electron', async () => {
process.env.GOODBUDDY_CAPABILITY_SERVICE_SECRET =
'service-diagnostic-secret-value'
const diagnostics = new CapabilityDiagnostics([
{
id: 'browser-executable',
run: async () => ({
status: 'available',
summary:
'token=visible-token service-diagnostic-secret-value C:\\Users\\Alice\\browser'
})
},
{
id: 'managed-profile-root',
run: async () => ({
status: 'available',
summary: 'managed storage ready'
})
}
])
const { service } = await createService({
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
),
diagnostics
})
await service.setComputerCapabilityEnabled('host-browser-control', true)
const report = await service.diagnoseComputerCapability(
'host-browser-control'
)
expect(report.status).toBe('available')
expect(JSON.stringify(report)).not.toContain('visible-token')
expect(JSON.stringify(report)).not.toContain(
'service-diagnostic-secret-value'
)
const outsideElectron = await createService({
platform: 'win32',
architecture: 'x64',
electronTarget: false,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
),
diagnostics
})
await expect(
outsideElectron.service.setComputerCapabilityEnabled(
'host-browser-control',
true
)
).rejects.toThrow('诊断不可用')
await expect(
outsideElectron.service.getComputerCapabilityStatus(
'host-browser-control'
)
).resolves.toEqual({ enabled: false, supported: true })
delete process.env.GOODBUDDY_CAPABILITY_SERVICE_SECRET
})
it('keeps Linux desktop unavailable without a registered native adapter', async () => {
const { service } = await createService({
platform: 'linux',
architecture: 'x64',
electronTarget: true,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
)
})
await expect(
service.setComputerCapabilityEnabled(
'linux-desktop-control',
true
)
).rejects.toThrow('不支持')
await expect(
service.getComputerCapabilityStatus('linux-desktop-control')
).resolves.toEqual({ enabled: false, supported: false })
})
it('allows Linux desktop enablement with available injected diagnostics', async () => {
const diagnostics = new CapabilityDiagnostics(
['linux-session', 'desktop-driver', 'desktop-permissions'].map(
(id) => ({
id,
run: async () => ({
status: 'available' as const,
summary: `${id} ready`
})
})
)
)
const { service } = await createService({
platform: 'linux',
architecture: 'arm64',
electronTarget: true,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
),
diagnostics,
availableComputerCapabilityImplementations: [
'managed-browser-driver',
'managed-linux-desktop-driver'
]
})
await expect(
service.setComputerCapabilityEnabled(
'linux-desktop-control',
true
)
).resolves.toMatchObject({
computerCapabilities: expect.arrayContaining([
expect.objectContaining({
id: 'linux-desktop-control',
enabled: true
})
])
})
})
})
+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)
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
computerCapabilityCatalog,
getComputerCapability,
isComputerCapabilitySupported
} from './computer-capability-catalog'
describe('computer capability catalog', () => {
it('defines immutable, disabled curated capabilities without launch overrides', () => {
expect(computerCapabilityCatalog.map(({ id }) => id)).toEqual([
'host-browser-control',
'linux-desktop-control'
])
for (const capability of computerCapabilityCatalog) {
expect(capability.enabledByDefault).toBe(false)
expect(capability.requiredDiagnostics.length).toBeGreaterThan(0)
expect(capability).not.toHaveProperty('executable')
expect(capability).not.toHaveProperty('args')
expect(capability).not.toHaveProperty('env')
}
expect(Object.isFrozen(computerCapabilityCatalog)).toBe(true)
})
it('rejects unsupported platform and architecture combinations', () => {
const browser = getComputerCapability('host-browser-control')
const desktop = getComputerCapability('linux-desktop-control')
expect(isComputerCapabilitySupported(browser, 'win32', 'x64')).toBe(true)
expect(isComputerCapabilitySupported(browser, 'linux', 'arm64')).toBe(true)
expect(isComputerCapabilitySupported(browser, 'win32', 'ia32')).toBe(false)
expect(isComputerCapabilitySupported(browser, 'freebsd', 'x64')).toBe(false)
expect(isComputerCapabilitySupported(desktop, 'darwin', 'arm64')).toBe(
false
)
expect(isComputerCapabilitySupported(desktop, 'linux', 'x64')).toBe(false)
expect(
isComputerCapabilitySupported(
desktop,
'linux',
'x64',
new Set(['managed-linux-desktop-driver'])
)
).toBe(true)
expect(desktop.description).toContain('技术预览')
expect(desktop.riskSummary).toContain('尚未')
})
})
@@ -0,0 +1,111 @@
import {
computerCapabilityIdSchema,
type ComputerCapabilityId
} from '../../shared/capability-contracts'
export const computerCapabilityIds = computerCapabilityIdSchema.options
export type { ComputerCapabilityId }
export type ComputerCapabilityPlatform = 'win32' | 'darwin' | 'linux'
export type ComputerCapabilityArchitecture = 'x64' | 'arm64'
export type ComputerCapabilityImplementationKind =
| 'managed-browser-driver'
| 'managed-linux-desktop-driver'
const PRODUCTION_IMPLEMENTATIONS: ReadonlySet<ComputerCapabilityImplementationKind> =
new Set(['managed-browser-driver'])
export type ComputerCapabilityCatalogEntry = Readonly<{
id: ComputerCapabilityId
name: string
description: string
enabledByDefault: false
implementationKind: ComputerCapabilityImplementationKind
supportedTargets: readonly Readonly<{
platform: ComputerCapabilityPlatform
architectures: readonly ComputerCapabilityArchitecture[]
}>[]
riskSummary: string
requiredDiagnostics: readonly string[]
}>
const ARCHITECTURES = Object.freeze(['x64', 'arm64'] as const)
export const computerCapabilityCatalog: readonly ComputerCapabilityCatalogEntry[] =
Object.freeze([
Object.freeze({
id: 'host-browser-control',
name: '浏览器控制',
description:
'使用临时隔离会话执行网页操作;命名配置当前仅保存未来托管隔离所需的元数据。',
enabledByDefault: false,
implementationKind: 'managed-browser-driver',
supportedTargets: Object.freeze([
Object.freeze({
platform: 'win32',
architectures: ARCHITECTURES
}),
Object.freeze({
platform: 'darwin',
architectures: ARCHITECTURES
}),
Object.freeze({
platform: 'linux',
architectures: ARCHITECTURES
})
]),
riskSummary:
'可读取网页内容并代表用户操作网站;当前执行不会复用命名配置,仍必须保持临时隔离和审批策略。',
requiredDiagnostics: Object.freeze([
'browser-executable',
'managed-profile-root'
])
}),
Object.freeze({
id: 'linux-desktop-control',
name: 'Linux 桌面控制',
description:
'技术预览:保留 Linux 桌面控制核心与诊断,注册真实原生适配器后才可启用。',
enabledByDefault: false,
implementationKind: 'managed-linux-desktop-driver',
supportedTargets: Object.freeze([
Object.freeze({
platform: 'linux',
architectures: ARCHITECTURES
})
]),
riskSummary:
'真实 D-Bus、PipeWire、libei 或 XTest 适配器尚未随产品提供;注册适配器后仍须保持审批、超时和审计边界。',
requiredDiagnostics: Object.freeze([
'linux-session',
'desktop-driver',
'desktop-permissions'
])
})
] satisfies ComputerCapabilityCatalogEntry[])
export function getComputerCapability(
id: ComputerCapabilityId
): ComputerCapabilityCatalogEntry {
const capability = computerCapabilityCatalog.find((entry) => entry.id === id)
if (!capability) {
throw new Error(`Unknown computer capability: ${id}`)
}
return capability
}
export function isComputerCapabilitySupported(
capability: ComputerCapabilityCatalogEntry,
platform: NodeJS.Platform,
architecture: string,
availableImplementations: ReadonlySet<ComputerCapabilityImplementationKind> =
PRODUCTION_IMPLEMENTATIONS
): boolean {
return (
availableImplementations.has(capability.implementationKind) &&
capability.supportedTargets.some(
(target) =>
target.platform === platform &&
target.architectures.some((candidate) => candidate === architecture)
)
)
}
@@ -0,0 +1,183 @@
import { describe, expect, it, vi } from 'vitest'
import type { ResolvedMcpServer } from './capability-service'
import {
createCuratedMcpLaunch,
type CuratedMcpFileSystem,
type CuratedMcpPathMetadata
} from './curated-mcp-launch'
const transportMocks = vi.hoisted(() => ({
stdio: vi.fn(function StdioClientTransport(options: unknown) {
return { kind: 'stdio', options }
}),
http: vi.fn(),
sse: vi.fn()
}))
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
StdioClientTransport: transportMocks.stdio
}))
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
StreamableHTTPClientTransport: transportMocks.http
}))
vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({
SSEClientTransport: transportMocks.sse
}))
import { createMcpTransport } from './mcp-client-transport'
const root = process.platform === 'win32' ? 'C:\\GoodBuddy' : '/opt/goodbuddy'
const executable =
process.platform === 'win32'
? `${root}\\helpers\\curated.exe`
: `${root}/helpers/curated`
const cwd =
process.platform === 'win32'
? `${root}\\helpers`
: `${root}/helpers`
const metadata = (
canonicalPath: string,
kind: 'directory' | 'file',
overrides: Partial<CuratedMcpPathMetadata> = {}
): CuratedMcpPathMetadata => ({
canonicalPath,
uid: 1000,
mode: kind === 'directory' ? 0o40755 : 0o100755,
isDirectory: kind === 'directory',
isFile: kind === 'file',
isSymbolicLink: false,
...overrides
})
const fileSystem = (
overrides: Readonly<Record<string, Partial<CuratedMcpPathMetadata>>> = {}
): CuratedMcpFileSystem => ({
inspect: vi.fn(async (path: string) => {
const kind = path === executable ? 'file' : 'directory'
return metadata(path, kind, overrides[path])
})
})
describe('curated MCP launches', () => {
it('keeps raw custom stdio on the SDK default environment', () => {
createMcpTransport({
transport: 'stdio',
command: 'custom-mcp',
args: ['--serve'],
env: { DISPLAY: ':0', TOKEN: 'secret' },
cwd
} as unknown as ResolvedMcpServer)
expect(transportMocks.stdio).toHaveBeenLastCalledWith({
command: 'custom-mcp',
args: ['--serve'],
stderr: 'ignore',
maxBufferSize: 2 * 1024 * 1024
})
})
it('passes only validated values from an opaque curated descriptor', async () => {
const validateLinuxDesktopEnvironment = vi.fn(async () => ({
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-1',
XDG_RUNTIME_DIR: '/run/user/1000'
}))
const descriptor = await createCuratedMcpLaunch(
{
executable,
args: ['--stdio'],
cwd,
ownedRoots: [root],
ownerUid: 1000,
allowedEnvironmentNames: ['LANG'],
environment: { LANG: 'zh_CN.UTF-8' },
linuxDesktopEnvironment: {
source: {},
uid: 1000,
fileSystem: { inspect: vi.fn() }
}
},
{
fileSystem: fileSystem(),
validateLinuxDesktopEnvironment
}
)
createMcpTransport(descriptor)
expect(validateLinuxDesktopEnvironment).toHaveBeenCalledOnce()
expect(transportMocks.stdio).toHaveBeenLastCalledWith({
command: executable,
args: ['--stdio'],
cwd,
env: {
LANG: 'zh_CN.UTF-8',
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-1',
XDG_RUNTIME_DIR: '/run/user/1000'
},
stderr: 'ignore',
maxBufferSize: 2 * 1024 * 1024
})
})
it('rejects serialized attempts to spoof a curated descriptor', () => {
expect(() =>
createMcpTransport({
transport: 'curated-stdio',
command: executable,
args: [],
cwd,
env: { DISPLAY: ':0' }
} as unknown as ResolvedMcpServer)
).toThrow('无效的精选 MCP 启动描述')
})
it.each([
['symlink executable', { [executable]: { isSymbolicLink: true } }],
[
'non-canonical executable',
{ [executable]: { canonicalPath: `${executable}.real` } }
],
['wrong owner', { [cwd]: { uid: 2000 } }],
['writable root', { [root]: { mode: 0o40777 } }]
])('rejects an unsafe path: %s', async (_name, overrides) => {
await expect(
createCuratedMcpLaunch(
{
executable,
cwd,
ownedRoots: [root],
ownerUid: 1000
},
{ fileSystem: fileSystem(overrides) }
)
).rejects.toThrow(/Unsafe curated MCP/u)
})
it.each([
'LD_PRELOAD',
'NODE_OPTIONS',
'GTK_MODULES',
'QT_PLUGIN_PATH',
'ELECTRON_RUN_AS_NODE',
'CHROME_EXTRA_ARGS',
'HTTPS_PROXY',
'SERVICE_TOKEN'
])('rejects unsafe environment name %s', async (name) => {
await expect(
createCuratedMcpLaunch(
{
executable,
cwd,
ownedRoots: [root],
ownerUid: 1000,
allowedEnvironmentNames: [name],
environment: { [name]: 'unsafe' }
},
{ fileSystem: fileSystem() }
)
).rejects.toThrow(/Unsafe curated MCP environment/u)
})
})
+276
View File
@@ -0,0 +1,276 @@
import { lstat, realpath, stat } from 'node:fs/promises'
import { isAbsolute } from 'node:path'
import {
buildDesktopHelperEnvironment,
type SecurePathMetadata,
type SessionEnvironmentOptions
} from '../linux-desktop/session-environment'
import { isPathInside } from '../workspace-file-access'
const MAX_ARGUMENTS = 64
const MAX_ARGUMENT_LENGTH = 4_096
const MAX_ENVIRONMENT_ENTRIES = 64
const MAX_ENVIRONMENT_VALUE_LENGTH = 8_192
const DESKTOP_ENVIRONMENT_NAMES = new Set([
'DISPLAY',
'WAYLAND_DISPLAY',
'XDG_RUNTIME_DIR',
'DBUS_SESSION_BUS_ADDRESS',
'XAUTHORITY',
'NO_AT_BRIDGE'
])
declare const curatedMcpLaunchBrand: unique symbol
export type CuratedMcpPathMetadata = Readonly<SecurePathMetadata>
export interface CuratedMcpFileSystem {
inspect(path: string): Promise<CuratedMcpPathMetadata>
}
export type CuratedMcpLaunchOptions = Readonly<{
executable: string
args?: readonly string[]
cwd: string
ownedRoots: readonly string[]
ownerUid: number
environment?: Readonly<Record<string, string>>
allowedEnvironmentNames?: readonly string[]
linuxDesktopEnvironment?: SessionEnvironmentOptions
}>
export type CuratedMcpLaunchDescriptor = Readonly<{
readonly [curatedMcpLaunchBrand]: true
readonly transport: 'curated-stdio'
readonly command: string
readonly args: readonly string[]
readonly cwd: string
readonly env: Readonly<Record<string, string>>
}>
export type CuratedMcpLaunchDependencies = Readonly<{
fileSystem?: CuratedMcpFileSystem
validateLinuxDesktopEnvironment?: (
options: SessionEnvironmentOptions
) => Promise<NodeJS.ProcessEnv>
}>
const descriptors = new WeakSet<object>()
const defaultFileSystem: CuratedMcpFileSystem = {
async inspect(path) {
const [linkMetadata, canonicalPath] = await Promise.all([
lstat(path),
realpath(path)
])
const metadata = await stat(canonicalPath)
return {
canonicalPath,
uid: metadata.uid,
mode: metadata.mode,
isDirectory: metadata.isDirectory(),
isFile: metadata.isFile(),
isSymbolicLink: linkMetadata.isSymbolicLink()
}
}
}
const hasControlCharacters = (value: string): boolean =>
[...value].some((character) => {
const code = character.charCodeAt(0)
return code < 32 || code === 127
})
const isSafePathText = (value: string): boolean =>
value.length >= 1 &&
value.length <= MAX_ENVIRONMENT_VALUE_LENGTH &&
!hasControlCharacters(value)
const assertOwnedCanonicalPath = (
requestedPath: string,
metadata: CuratedMcpPathMetadata,
ownerUid: number,
expectedKind: 'directory' | 'file'
): void => {
if (
!isAbsolute(requestedPath) ||
metadata.canonicalPath !== requestedPath ||
metadata.isSymbolicLink ||
metadata.uid !== ownerUid ||
(metadata.mode & 0o022) !== 0 ||
(expectedKind === 'directory'
? !metadata.isDirectory
: !metadata.isFile)
) {
throw new Error(`Unsafe curated MCP ${expectedKind} path`)
}
}
const isRejectedEnvironmentName = (name: string): boolean => {
const upperName = name.toUpperCase()
return (
upperName.startsWith('LD_') ||
upperName === 'NODE_OPTIONS' ||
upperName === 'GTK_MODULES' ||
upperName === 'QT_PLUGIN_PATH' ||
upperName.startsWith('ELECTRON_') ||
upperName.startsWith('CHROME_') ||
upperName.startsWith('CHROMIUM_') ||
upperName === 'HTTP_PROXY' ||
upperName === 'HTTPS_PROXY' ||
upperName === 'ALL_PROXY' ||
upperName === 'NO_PROXY' ||
/(?:CREDENTIAL|PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|ACCESS_KEY|PRIVATE_KEY|AUTHORIZATION|COOKIE)/u.test(
upperName
)
)
}
const copyEnvironment = (
source: Readonly<Record<string, string>>,
allowedNames: ReadonlySet<string>,
target: Record<string, string>
): void => {
for (const [name, value] of Object.entries(source)) {
if (
!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name) ||
!allowedNames.has(name) ||
isRejectedEnvironmentName(name) ||
value.length > MAX_ENVIRONMENT_VALUE_LENGTH ||
hasControlCharacters(value)
) {
throw new Error(`Unsafe curated MCP environment variable: ${name}`)
}
target[name] = value
}
}
export async function createCuratedMcpLaunch(
options: CuratedMcpLaunchOptions,
dependencies: CuratedMcpLaunchDependencies = {}
): Promise<CuratedMcpLaunchDescriptor> {
if (
options.ownedRoots.length === 0 ||
options.ownedRoots.some(
(root) => !isAbsolute(root) || !isSafePathText(root)
)
) {
throw new Error('Curated MCP requires an absolute allowlisted root')
}
if (
!isAbsolute(options.executable) ||
!isAbsolute(options.cwd) ||
!isSafePathText(options.executable) ||
!isSafePathText(options.cwd) ||
(options.args?.length ?? 0) > MAX_ARGUMENTS ||
options.args?.some(
(argument) =>
argument.length > MAX_ARGUMENT_LENGTH ||
hasControlCharacters(argument)
)
) {
throw new Error('Invalid curated MCP launch parameters')
}
const fileSystem = dependencies.fileSystem ?? defaultFileSystem
const rootMetadata = await Promise.all(
options.ownedRoots.map((root) => fileSystem.inspect(root))
)
rootMetadata.forEach((metadata, index) => {
assertOwnedCanonicalPath(
options.ownedRoots[index]!,
metadata,
options.ownerUid,
'directory'
)
})
const [executableMetadata, cwdMetadata] = await Promise.all([
fileSystem.inspect(options.executable),
fileSystem.inspect(options.cwd)
])
assertOwnedCanonicalPath(
options.executable,
executableMetadata,
options.ownerUid,
'file'
)
assertOwnedCanonicalPath(
options.cwd,
cwdMetadata,
options.ownerUid,
'directory'
)
if (
(process.platform !== 'win32' &&
(executableMetadata.mode & 0o111) === 0) ||
!rootMetadata.some((root) =>
isPathInside(root.canonicalPath, executableMetadata.canonicalPath)
) ||
!rootMetadata.some((root) =>
isPathInside(root.canonicalPath, cwdMetadata.canonicalPath)
)
) {
throw new Error('Curated MCP paths are outside the owned allowlist')
}
const allowedNames = new Set(options.allowedEnvironmentNames ?? [])
if (
(options.allowedEnvironmentNames?.length ?? 0) >
MAX_ENVIRONMENT_ENTRIES ||
allowedNames.size > MAX_ENVIRONMENT_ENTRIES ||
[...allowedNames].some(
(name) =>
isRejectedEnvironmentName(name) ||
DESKTOP_ENVIRONMENT_NAMES.has(name.toUpperCase())
)
) {
throw new Error('Unsafe curated MCP environment allowlist')
}
const environment: Record<string, string> = {}
copyEnvironment(options.environment ?? {}, allowedNames, environment)
if (options.linuxDesktopEnvironment) {
const validate =
dependencies.validateLinuxDesktopEnvironment ??
buildDesktopHelperEnvironment
const desktopEnvironment = await validate(
options.linuxDesktopEnvironment
)
copyEnvironment(
desktopEnvironment as Record<string, string>,
new Set([
'PATH',
'LANG',
'LC_ALL',
'LC_CTYPE',
'DISPLAY',
'WAYLAND_DISPLAY',
'XDG_RUNTIME_DIR',
'DBUS_SESSION_BUS_ADDRESS',
'XAUTHORITY',
'NO_AT_BRIDGE'
]),
environment
)
}
const descriptor = Object.freeze({
transport: 'curated-stdio' as const,
command: executableMetadata.canonicalPath,
args: Object.freeze([...(options.args ?? [])]),
cwd: cwdMetadata.canonicalPath,
env: Object.freeze({ ...environment })
}) as CuratedMcpLaunchDescriptor
descriptors.add(descriptor)
return descriptor
}
export function isCuratedMcpLaunchDescriptor(
value: unknown
): value is CuratedMcpLaunchDescriptor {
return (
typeof value === 'object' &&
value !== null &&
descriptors.has(value)
)
}
+20 -1
View File
@@ -6,6 +6,10 @@ import type {
Transport
} from '@modelcontextprotocol/sdk/shared/transport.js'
import type { ResolvedMcpServer } from './capability-service'
import {
isCuratedMcpLaunchDescriptor,
type CuratedMcpLaunchDescriptor
} from './curated-mcp-launch'
function validateRemoteUrl(value: string): URL {
const url = new URL(value)
@@ -34,8 +38,23 @@ function createRestrictedFetch(origin: string): FetchLike {
}
export function createMcpTransport(
server: ResolvedMcpServer
server: ResolvedMcpServer | CuratedMcpLaunchDescriptor
): Transport {
if (isCuratedMcpLaunchDescriptor(server)) {
return new StdioClientTransport({
command: server.command,
args: [...server.args],
cwd: server.cwd,
env: { ...server.env },
stderr: 'ignore',
maxBufferSize: 2 * 1024 * 1024
})
}
if ((server as { transport?: string }).transport === 'curated-stdio') {
throw new Error('无效的精选 MCP 启动描述')
}
if (server.transport === 'stdio') {
return new StdioClientTransport({
command: server.command,
+32
View File
@@ -141,4 +141,36 @@ describe('testMcpServer', () => {
)
expect(mocks.client.close).toHaveBeenCalledOnce()
})
it('accepts cancellation, closes the client, and hides abort details', async () => {
mocks.client.connect.mockImplementation(
async (
_transport: unknown,
options: { signal: AbortSignal }
) =>
new Promise((_resolve, reject) => {
options.signal.addEventListener(
'abort',
() => reject(new Error('sensitive abort reason')),
{ once: true }
)
})
)
const controller = new AbortController()
const pending = testMcpServer(
{
...common,
transport: 'stdio',
command: 'node',
args: ['server.js']
} satisfies ResolvedMcpServer,
controller.signal
)
controller.abort(new Error('caller private context'))
await expect(pending).rejects.toThrow('MCP 连接测试已取消')
expect(mocks.client.listTools).not.toHaveBeenCalled()
expect(mocks.client.close).toHaveBeenCalledOnce()
})
})
+44 -12
View File
@@ -3,10 +3,12 @@ import type { McpServerTestResult } from '../../shared/capability-contracts'
import type { ResolvedMcpServer } from './capability-service'
import { createMcpTransport } from './mcp-client-transport'
const MCP_TEST_TIMEOUT_MS = 12_000
const MCP_TEST_TOTAL_TIMEOUT_MS = 12_000
const MCP_TEST_INACTIVITY_TIMEOUT_MS = 8_000
export async function testMcpServer(
server: ResolvedMcpServer
server: ResolvedMcpServer,
signal?: AbortSignal
): Promise<McpServerTestResult> {
const client = new Client({
name: 'goodbuddy',
@@ -14,19 +16,45 @@ export async function testMcpServer(
})
const transport = createMcpTransport(server)
const controller = new AbortController()
let timedOut = false
const abortFromCaller = (): void => {
controller.abort()
}
signal?.addEventListener('abort', abortFromCaller, { once: true })
if (signal?.aborted) {
abortFromCaller()
}
const timeout = setTimeout(() => {
timedOut = true
controller.abort(new Error('MCP 连接测试超时'))
}, MCP_TEST_TIMEOUT_MS)
}, MCP_TEST_TOTAL_TIMEOUT_MS)
const runWithInactivityLimit = async <T>(
operation: () => Promise<T>
): Promise<T> => {
const inactivityTimeout = setTimeout(() => {
timedOut = true
controller.abort(new Error('MCP 连接测试超时'))
}, MCP_TEST_INACTIVITY_TIMEOUT_MS)
try {
return await operation()
} finally {
clearTimeout(inactivityTimeout)
}
}
try {
await client.connect(transport, {
timeout: MCP_TEST_TIMEOUT_MS,
signal: controller.signal
})
const result = await client.listTools(undefined, {
timeout: MCP_TEST_TIMEOUT_MS,
signal: controller.signal
})
await runWithInactivityLimit(() =>
client.connect(transport, {
timeout: MCP_TEST_INACTIVITY_TIMEOUT_MS,
signal: controller.signal
})
)
const result = await runWithInactivityLimit(() =>
client.listTools(undefined, {
timeout: MCP_TEST_INACTIVITY_TIMEOUT_MS,
signal: controller.signal
})
)
const version = client.getServerVersion()
return {
serverName: version?.name.slice(0, 120),
@@ -38,7 +66,10 @@ export async function testMcpServer(
}))
}
} catch (error) {
if (controller.signal.aborted) {
if (signal?.aborted && !timedOut) {
throw new Error('MCP 连接测试已取消', { cause: error })
}
if (timedOut) {
throw new Error('MCP 连接测试超时', { cause: error })
}
throw new Error(
@@ -50,6 +81,7 @@ export async function testMcpServer(
)
} finally {
clearTimeout(timeout)
signal?.removeEventListener('abort', abortFromCaller)
await client.close().catch(() => undefined)
}
}