feat: add managed local tool environments

GoodBuddy can now provide managed Node.js and on-demand Python to local Skills and stdio MCP servers, or validate an explicitly selected custom interpreter. The Capabilities and tools settings page exposes source selection, diagnostics, installation progress, cancellation, and removal while new processes receive immutable PATH snapshots.

Application settings migrate to v10. Local terminal and remote Host environments remain unchanged. Windows x64 native-source Node and Python paths are validated; OSS mirror publication, redistributed license resources, and the remaining native platform probes are recorded as release blockers.

Release note: 新增工具执行环境,可为本机 Skills 和 stdio MCP 使用 GoodBuddy 托管的 Node.js、按需安装的 Python,或经过验证的自定义解释器。
This commit is contained in:
mesalogo
2026-09-02 10:36:56 +08:00
parent 1530fa116c
commit be39665ca9
69 changed files with 6958 additions and 264 deletions
@@ -77,6 +77,85 @@ describe('curated MCP launches', () => {
})
})
it('injects a fresh filtered tool environment into generic stdio', () => {
const previousKey = process.env.OPENAI_API_KEY
const previousElectronRunAsNode =
process.env.ELECTRON_RUN_AS_NODE
process.env.OPENAI_API_KEY = 'parent-secret'
process.env.ELECTRON_RUN_AS_NODE = '1'
let generation = 0
try {
const server = {
transport: 'stdio',
command: 'custom-mcp',
args: ['--serve']
} as ResolvedMcpServer
const provider = () =>
Object.freeze({
PATH: `/managed-${++generation}:/system`,
OPENAI_API_KEY: 'provider-secret',
ELECTRON_RUN_AS_NODE: '1'
})
createMcpTransport(server, provider)
createMcpTransport(server, provider)
const calls = transportMocks.stdio.mock.calls.slice(-2) as Array<
[{ env: NodeJS.ProcessEnv }]
>
expect(calls[0]?.[0]).toMatchObject({
env: { PATH: '/managed-1:/system' }
})
expect(calls[1]?.[0]).toMatchObject({
env: { PATH: '/managed-2:/system' }
})
expect(calls[0]?.[0].env).not.toHaveProperty('OPENAI_API_KEY')
expect(calls[0]?.[0].env).not.toHaveProperty(
'ELECTRON_RUN_AS_NODE'
)
} finally {
if (previousKey === undefined) {
delete process.env.OPENAI_API_KEY
} else {
process.env.OPENAI_API_KEY = previousKey
}
if (previousElectronRunAsNode === undefined) {
delete process.env.ELECTRON_RUN_AS_NODE
} else {
process.env.ELECTRON_RUN_AS_NODE =
previousElectronRunAsNode
}
}
})
it('keeps curated explicit env authoritative with a provider', async () => {
const descriptor = await createCuratedMcpLaunch(
{
executable,
args: ['--stdio'],
cwd,
ownedRoots: [root],
ownerUid: 1000,
allowedEnvironmentNames: ['LANG'],
environment: { LANG: 'zh_CN.UTF-8' }
},
{ fileSystem: fileSystem() }
)
createMcpTransport(descriptor, () =>
Object.freeze({ PATH: '/managed:/system' })
)
expect(transportMocks.stdio).toHaveBeenLastCalledWith({
command: executable,
args: ['--stdio'],
cwd,
env: { LANG: 'zh_CN.UTF-8', PATH: '/managed:/system' },
stderr: 'ignore',
maxBufferSize: 2 * 1024 * 1024
})
})
it('passes only validated values from an opaque curated descriptor', async () => {
const validateLinuxDesktopEnvironment = vi.fn(async () => ({
DISPLAY: ':1',
+33 -2
View File
@@ -7,16 +7,33 @@ import {
isCuratedMcpLaunchDescriptor,
type CuratedMcpLaunchDescriptor
} from './curated-mcp-launch'
import {
applyLaunchEnvironmentPath,
buildCredentialFilteredUserEnvironment
} from '../agent/process-environment'
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
export function createMcpTransport(
server: ResolvedMcpServer | CuratedMcpLaunchDescriptor
server: ResolvedMcpServer | CuratedMcpLaunchDescriptor,
launchEnvironmentProvider?: LaunchEnvironmentProvider
): Transport {
if (isCuratedMcpLaunchDescriptor(server)) {
const environment = Object.fromEntries(
Object.entries(
applyLaunchEnvironmentPath(
{ ...server.env },
launchEnvironmentProvider
)
).filter(
(entry): entry is [string, string] =>
entry[1] !== undefined
)
)
return new StdioClientTransport({
command: server.command,
args: [...server.args],
cwd: server.cwd,
env: { ...server.env },
env: environment,
stderr: 'ignore',
maxBufferSize: 2 * 1024 * 1024
})
@@ -27,9 +44,23 @@ export function createMcpTransport(
}
if (server.transport === 'stdio') {
const env = launchEnvironmentProvider
? Object.fromEntries(
Object.entries(
applyLaunchEnvironmentPath(
buildCredentialFilteredUserEnvironment(),
launchEnvironmentProvider
)
).filter(
(entry): entry is [string, string] =>
entry[1] !== undefined
)
)
: undefined
return new StdioClientTransport({
command: server.command,
args: server.args,
...(env ? { env } : {}),
stderr: 'ignore',
maxBufferSize: 2 * 1024 * 1024
})
+26
View File
@@ -85,6 +85,32 @@ describe('testMcpServer', () => {
mocks.client.close.mockResolvedValue(undefined)
})
it('uses the current launch environment for a new stdio test', async () => {
const launchEnvironmentProvider = vi.fn(() =>
Object.freeze({ PATH: '/managed-tools:/usr/bin' })
)
await testMcpServer(
{
...common,
transport: 'stdio',
command: 'node',
args: ['server.js']
},
undefined,
launchEnvironmentProvider
)
expect(launchEnvironmentProvider).toHaveBeenCalledTimes(1)
expect(mocks.StdioClientTransport).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
PATH: '/managed-tools:/usr/bin'
})
})
)
})
it('uses separated stdio command arguments and closes the client', async () => {
const result = await testMcpServer({
...common,
+7 -2
View File
@@ -2,19 +2,24 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import type { McpServerTestResult } from '../../shared/capability-contracts'
import type { ResolvedMcpServer } from './capability-service'
import { createMcpTransport } from './mcp-client-transport'
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
const MCP_TEST_TOTAL_TIMEOUT_MS = 12_000
const MCP_TEST_INACTIVITY_TIMEOUT_MS = 8_000
export async function testMcpServer(
server: ResolvedMcpServer,
signal?: AbortSignal
signal?: AbortSignal,
launchEnvironmentProvider?: LaunchEnvironmentProvider
): Promise<McpServerTestResult> {
const client = new Client({
name: 'goodbuddy',
version: '0.1.0'
})
const transport = createMcpTransport(server)
const transport = createMcpTransport(
server,
launchEnvironmentProvider
)
const controller = new AbortController()
let timedOut = false
const abortFromCaller = (): void => {