feat: add computer control and managed browser
This commit is contained in:
@@ -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
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user