feat: expand secure runtime and workspace UX

This commit is contained in:
lofyer
2026-08-04 00:57:08 +08:00
parent 09e9fbf5e2
commit 2910d315f7
56 changed files with 6164 additions and 882 deletions
@@ -150,7 +150,7 @@ describe('CapabilityService', () => {
name: 'Remote MCP',
description: 'Remote test server',
enabled: true,
assignments: ['opencode'],
assignments: ['model'],
secret: { action: 'replace', value: 'secret-token-value' },
transport: 'http',
url: 'https://mcp.example.com/mcp'
@@ -181,7 +181,7 @@ describe('CapabilityService', () => {
name: 'Local MCP',
description: '',
enabled: true,
assignments: ['opencode'],
assignments: ['model'],
secret: { action: 'keep' },
transport: 'stdio',
command: 'node',
@@ -202,11 +202,69 @@ describe('CapabilityService', () => {
name: 'Unsafe remote',
description: '',
enabled: true,
assignments: ['opencode'],
assignments: ['model'],
secret: { action: 'replace', value: 'secret-token-value' },
transport: 'http',
url: 'http://mcp.example.com/mcp'
})
).rejects.toThrow('只能通过 HTTPS')
})
it('rejects MCP assignments to Agent Runtimes', async () => {
const { service } = await createService()
await expect(
service.saveMcpServer(undefined, {
name: 'Agent MCP',
description: '',
enabled: true,
assignments: ['opencode'],
secret: { action: 'keep' },
transport: 'stdio',
command: 'node',
args: ['server.js']
})
).rejects.toThrow('只能分配给直连模型')
})
it('migrates legacy OpenCode MCP assignments to the direct model', async () => {
const { filePath, builtinRoot, importedRoot } = await createService()
await writeFile(
filePath,
JSON.stringify({
version: 1,
skills: {},
mcpServers: [
{
id: 'd2ef774b-146c-4467-a909-6feb112a9c2c',
name: 'Legacy MCP',
description: '',
enabled: true,
assignments: ['opencode'],
transport: 'stdio',
command: 'node',
args: ['server.js']
}
]
}),
'utf8'
)
const service = new CapabilityService(
filePath,
builtinRoot,
importedRoot,
cipher
)
await expect(service.getSnapshot()).resolves.toMatchObject({
mcpServers: [
expect.objectContaining({ assignments: ['model'] })
]
})
expect(await readFile(filePath, 'utf8')).toContain(
'"assignments": [\n "model"'
)
await expect(service.getResolvedMcpServers('opencode')).resolves.toEqual([])
await expect(service.getResolvedMcpServers('model')).resolves.toHaveLength(1)
})
})
+27 -5
View File
@@ -248,8 +248,9 @@ export class CapabilityService {
if (this.state) {
return this.state
}
let loaded: StoredCapabilities
try {
this.state = storedCapabilitiesSchema.parse(
loaded = storedCapabilitiesSchema.parse(
JSON.parse(await readFile(this.filePath, 'utf8'))
)
} catch (error) {
@@ -259,15 +260,33 @@ export class CapabilityService {
'code' in error &&
error.code === 'ENOENT'
) {
this.state = { version: 1, skills: {}, mcpServers: [] }
loaded = { version: 1, skills: {}, mcpServers: [] }
} else {
await rename(
this.filePath,
`${this.filePath}.corrupt-${Date.now()}`
).catch(() => undefined)
this.state = { version: 1, skills: {}, mcpServers: [] }
loaded = { version: 1, skills: {}, mcpServers: [] }
}
}
const migrateMcpAssignments = loaded.mcpServers.some((server) =>
server.assignments.includes('opencode')
)
const migrated = migrateMcpAssignments
? {
...loaded,
mcpServers: loaded.mcpServers.map((server) => ({
...server,
assignments: server.assignments.includes('opencode')
? (['model'] as CapabilityAssignments)
: server.assignments
}))
}
: loaded
this.state = storedCapabilitiesSchema.parse(migrated)
if (migrateMcpAssignments) {
await this.persist(this.state)
}
return this.state
}
@@ -455,10 +474,10 @@ export class CapabilityService {
const value = mcpServerInputSchema.parse(input)
if (
value.assignments.some(
(assignment) => assignment !== 'opencode'
(assignment) => assignment !== 'model'
)
) {
throw new Error('当前版本的 MCP Server 只能分配给 OpenCode')
throw new Error('当前版本的 MCP Server 只能分配给直连模型')
}
const state = await this.load()
const id = serverId ? mcpServerIdSchema.parse(serverId) : randomUUID()
@@ -634,6 +653,9 @@ export class CapabilityService {
async getResolvedMcpServers(
target: RuntimeTarget
): Promise<ResolvedMcpServer[]> {
if (target !== 'model') {
return []
}
const state = await this.load()
const assigned = state.mcpServers.filter(
(server) => server.enabled && server.assignments.includes(target)
@@ -0,0 +1,73 @@
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import type {
FetchLike,
Transport
} from '@modelcontextprotocol/sdk/shared/transport.js'
import type { ResolvedMcpServer } from './capability-service'
function validateRemoteUrl(value: string): URL {
const url = new URL(value)
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/gu, '')
if (
hostname === '169.254.169.254' ||
hostname === 'metadata.google.internal' ||
hostname.endsWith('.internal.metadata')
) {
throw new Error('MCP 地址不能指向云平台元数据服务')
}
return url
}
function createRestrictedFetch(origin: string): FetchLike {
return async (input, init) => {
const url = new URL(String(input))
if (url.origin !== origin) {
throw new Error('MCP Server 尝试访问未授权的跨域地址')
}
return fetch(url, {
...init,
redirect: 'error'
})
}
}
export function createMcpTransport(
server: ResolvedMcpServer
): Transport {
if (server.transport === 'stdio') {
return new StdioClientTransport({
command: server.command,
args: server.args,
stderr: 'ignore',
maxBufferSize: 2 * 1024 * 1024
})
}
const url = validateRemoteUrl(server.url)
const requestInit: RequestInit | undefined = server.secret
? {
headers: {
Authorization: `Bearer ${server.secret}`
}
}
: undefined
const safeFetch = createRestrictedFetch(url.origin)
return server.transport === 'http'
? new StreamableHTTPClientTransport(url, {
fetch: safeFetch,
requestInit,
reconnectionOptions: {
initialReconnectionDelay: 500,
maxReconnectionDelay: 2_000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 0
}
})
: new SSEClientTransport(url, {
fetch: safeFetch,
requestInit
})
}
+2 -71
View File
@@ -1,79 +1,10 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import type {
FetchLike,
Transport
} from '@modelcontextprotocol/sdk/shared/transport.js'
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
function validateRemoteUrl(value: string): URL {
const url = new URL(value)
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/gu, '')
if (
hostname === '169.254.169.254' ||
hostname === 'metadata.google.internal' ||
hostname.endsWith('.internal.metadata')
) {
throw new Error('MCP 地址不能指向云平台元数据服务')
}
return url
}
function createRestrictedFetch(origin: string): FetchLike {
return async (input, init) => {
const url = new URL(String(input))
if (url.origin !== origin) {
throw new Error('MCP Server 尝试访问未授权的跨域地址')
}
return fetch(url, {
...init,
redirect: 'error'
})
}
}
function createTransport(server: ResolvedMcpServer): Transport {
if (server.transport === 'stdio') {
return new StdioClientTransport({
command: server.command,
args: server.args,
stderr: 'ignore',
maxBufferSize: 2 * 1024 * 1024
})
}
const url = validateRemoteUrl(server.url)
const requestInit: RequestInit | undefined = server.secret
? {
headers: {
Authorization: `Bearer ${server.secret}`
}
}
: undefined
const safeFetch = createRestrictedFetch(url.origin)
return server.transport === 'http'
? new StreamableHTTPClientTransport(url, {
fetch: safeFetch,
requestInit,
reconnectionOptions: {
initialReconnectionDelay: 500,
maxReconnectionDelay: 2_000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 0
}
})
: new SSEClientTransport(url, {
fetch: safeFetch,
requestInit
})
}
export async function testMcpServer(
server: ResolvedMcpServer
): Promise<McpServerTestResult> {
@@ -81,7 +12,7 @@ export async function testMcpServer(
name: 'goodbuddy',
version: '0.1.0'
})
const transport = createTransport(server)
const transport = createMcpTransport(server)
const controller = new AbortController()
const timeout = setTimeout(() => {
controller.abort(new Error('MCP 连接测试超时'))