chore: prepare GoodBuddy 0.8.2
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
This commit is contained in:
@@ -1,7 +1,15 @@
|
||||
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, vi } from 'vitest'
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest'
|
||||
import { setIntranetCompatibilityReader } from '../intranet-compatibility-policy'
|
||||
import {
|
||||
CapabilityService,
|
||||
type CapabilityCipher,
|
||||
@@ -15,6 +23,10 @@ import { CapabilityDiagnostics } from './capability-diagnostics'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
setIntranetCompatibilityReader(() => false)
|
||||
})
|
||||
|
||||
const cipher: CapabilityCipher = {
|
||||
isAvailable: () => true,
|
||||
encrypt: (value) => Buffer.from(`encrypted:${value}`),
|
||||
@@ -113,6 +125,7 @@ async function createService(
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
delete process.env.GOODBUDDY_CAPABILITY_SERVICE_SECRET
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
@@ -325,6 +338,111 @@ describe('CapabilityService', () => {
|
||||
).rejects.toThrow('只能通过 HTTPS')
|
||||
})
|
||||
|
||||
it('allows bearer tokens over the full IPv4 loopback range', async () => {
|
||||
const { service } = await createService()
|
||||
|
||||
await expect(
|
||||
service.saveMcpServer(undefined, {
|
||||
name: 'Loopback MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
assignments: ['model'],
|
||||
secret: { action: 'replace', value: 'secret-token-value' },
|
||||
transport: 'http',
|
||||
url: 'http://127.0.0.2/mcp'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
mcpServers: [
|
||||
expect.objectContaining({
|
||||
name: 'Loopback MCP',
|
||||
url: 'http://127.0.0.2/mcp'
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('allows bearer tokens over HTTP in intranet compatibility mode', async () => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
const { service } = await createService()
|
||||
|
||||
const snapshot = await service.saveMcpServer(undefined, {
|
||||
name: 'Intranet MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
assignments: ['model'],
|
||||
secret: { action: 'replace', value: 'secret-token-value' },
|
||||
transport: 'http',
|
||||
url: 'http://mcp.internal/mcp'
|
||||
})
|
||||
expect(snapshot).toMatchObject({
|
||||
mcpServers: [
|
||||
expect.objectContaining({
|
||||
name: 'Intranet MCP',
|
||||
secretConfigured: true,
|
||||
url: 'http://mcp.internal/mcp'
|
||||
})
|
||||
]
|
||||
})
|
||||
const server = snapshot.mcpServers[0]
|
||||
if (!server) {
|
||||
throw new Error('Expected saved intranet MCP server')
|
||||
}
|
||||
await expect(
|
||||
service.getResolvedMcpServer(server.id)
|
||||
).resolves.toMatchObject({ secret: 'secret-token-value' })
|
||||
|
||||
setIntranetCompatibilityReader(() => false)
|
||||
await expect(
|
||||
service.getResolvedMcpServer(server.id)
|
||||
).rejects.toThrow('只能通过 HTTPS')
|
||||
await expect(
|
||||
service.getResolvedMcpServers('model')
|
||||
).resolves.toEqual([])
|
||||
await expect(service.getSnapshot()).resolves.toMatchObject({
|
||||
mcpServers: [
|
||||
expect.objectContaining({
|
||||
id: server.id,
|
||||
enabled: false,
|
||||
secretConfigured: true
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects bearer tokens over public HTTP in intranet compatibility mode', async () => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
const { service } = await createService()
|
||||
|
||||
await expect(
|
||||
service.saveMcpServer(undefined, {
|
||||
name: 'Public plaintext MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
assignments: ['model'],
|
||||
secret: { action: 'replace', value: 'secret-token-value' },
|
||||
transport: 'http',
|
||||
url: 'http://mcp.example.com/mcp'
|
||||
})
|
||||
).rejects.toThrow('只能通过 HTTPS')
|
||||
})
|
||||
|
||||
it('rejects public HTTP MCP servers without bearer tokens', async () => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
const { service } = await createService()
|
||||
|
||||
await expect(
|
||||
service.saveMcpServer(undefined, {
|
||||
name: 'Public plaintext MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
assignments: ['model'],
|
||||
secret: { action: 'clear' },
|
||||
transport: 'http',
|
||||
url: 'http://mcp.example.com/mcp'
|
||||
})
|
||||
).rejects.toThrow('只能通过 HTTPS')
|
||||
})
|
||||
|
||||
it('rejects MCP assignments to Agent Runtimes', async () => {
|
||||
const { service } = await createService()
|
||||
|
||||
|
||||
@@ -51,12 +51,33 @@ import {
|
||||
isComputerCapabilitySupported,
|
||||
type ComputerCapabilityImplementationKind
|
||||
} from './computer-capability-catalog'
|
||||
import { isIntranetCompatibilityEnabled } from '../intranet-compatibility-policy'
|
||||
import {
|
||||
isIntranetHostname,
|
||||
isLoopbackHostname
|
||||
} from '../../shared/intranet-hostname'
|
||||
|
||||
const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024
|
||||
const MAX_SKILL_PACKAGE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_SKILL_PACKAGE_FILES = 128
|
||||
const MAX_SKILL_DEPTH = 6
|
||||
|
||||
function canUseRemoteMcpUrl(url: string): boolean {
|
||||
const parsed = new URL(url)
|
||||
const hostname = parsed.hostname.toLowerCase()
|
||||
return (
|
||||
parsed.protocol === 'https:' ||
|
||||
(
|
||||
parsed.protocol === 'http:' &&
|
||||
(
|
||||
isLoopbackHostname(hostname) ||
|
||||
(isIntranetCompatibilityEnabled() &&
|
||||
isIntranetHostname(hostname))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const skillMetadataSchema = z
|
||||
.object({
|
||||
id: skillIdSchema,
|
||||
@@ -979,14 +1000,10 @@ export class CapabilityService {
|
||||
}
|
||||
if (
|
||||
value.transport !== 'stdio' &&
|
||||
credential &&
|
||||
new URL(value.url).protocol !== 'https:' &&
|
||||
!['localhost', '127.0.0.1', '[::1]'].includes(
|
||||
new URL(value.url).hostname.toLowerCase()
|
||||
)
|
||||
!canUseRemoteMcpUrl(value.url)
|
||||
) {
|
||||
throw new Error(
|
||||
'Bearer Token 只能通过 HTTPS 或本机回环地址发送'
|
||||
'远程 MCP 只能通过 HTTPS、本机回环或已启用兼容模式的内网 HTTP 地址连接'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1064,6 +1081,14 @@ export class CapabilityService {
|
||||
throw new Error('MCP 访问令牌无法解密,请重新配置')
|
||||
}
|
||||
}
|
||||
if (
|
||||
server.transport !== 'stdio' &&
|
||||
!canUseRemoteMcpUrl(server.url)
|
||||
) {
|
||||
throw new Error(
|
||||
'远程 MCP 只能通过 HTTPS、本机回环或已启用兼容模式的内网 HTTP 地址连接'
|
||||
)
|
||||
}
|
||||
return {
|
||||
...this.toMcpSummary(server),
|
||||
secret
|
||||
@@ -1105,12 +1130,40 @@ export class CapabilityService {
|
||||
: ''
|
||||
}
|
||||
|
||||
quarantineIncompatibleMcpServers(): Promise<string[]> {
|
||||
return this.queue(async () => {
|
||||
const state = await this.load()
|
||||
const incompatibleIds = state.mcpServers
|
||||
.filter(
|
||||
(server) =>
|
||||
server.enabled &&
|
||||
server.transport !== 'stdio' &&
|
||||
!canUseRemoteMcpUrl(server.url)
|
||||
)
|
||||
.map((server) => server.id)
|
||||
if (incompatibleIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
const incompatible = new Set(incompatibleIds)
|
||||
await this.persist({
|
||||
...state,
|
||||
mcpServers: state.mcpServers.map((server) =>
|
||||
incompatible.has(server.id)
|
||||
? { ...server, enabled: false }
|
||||
: server
|
||||
)
|
||||
})
|
||||
return incompatibleIds
|
||||
})
|
||||
}
|
||||
|
||||
async getResolvedMcpServers(
|
||||
target: RuntimeTarget
|
||||
): Promise<ResolvedMcpServer[]> {
|
||||
if (target !== 'model') {
|
||||
return []
|
||||
}
|
||||
await this.quarantineIncompatibleMcpServers()
|
||||
const state = await this.load()
|
||||
const assigned = state.mcpServers.filter(
|
||||
(server) => server.enabled && server.assignments.includes(target)
|
||||
|
||||
Reference in New Issue
Block a user