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
@@ -397,6 +397,13 @@ describe('ContinueHostAdapter', () => {
let isolatedGlobalDirectory = ''
let registeredSkill = ''
let registeredSkillFile = ''
const launchEnvironmentProvider = vi.fn(() =>
Object.freeze({
PATH: 'C:\\GoodBuddy\\tools;C:\\System',
OPENAI_API_KEY: 'must-not-leak',
ELECTRON_RUN_AS_NODE: '1'
})
)
const launchHost: ContinueHostLauncher = (
entryPath,
args,
@@ -500,6 +507,7 @@ describe('ContinueHostAdapter', () => {
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost,
launchEnvironmentProvider,
mode: 'chat',
skillPackages: [
{
@@ -556,6 +564,11 @@ describe('ContinueHostAdapter', () => {
expect(launch?.env.GOODBUDDY_CONTINUE_HOST_TOKEN).toEqual(
expect.any(String)
)
expect(launchEnvironmentProvider).toHaveBeenCalledOnce()
expect(launch?.env.PATH).toBe(
'C:\\GoodBuddy\\tools;C:\\System'
)
expect(launch?.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE')
expect(launch?.env).toMatchObject({
CONTINUE_CLI_AUTO_UPDATED: '1',
CONTINUE_CLI_ENABLE_TELEMETRY: '0',
+8 -1
View File
@@ -39,10 +39,12 @@ import type { ResolvedModelProfile } from '../runtime-settings-store'
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
import { getAvailableLoopbackPort } from './loopback-port'
import {
applyLaunchEnvironmentPath,
buildExplicitProfileRuntimeEnvironment,
buildRuntimeEnvironment,
runtimePrivacyEnvironment
} from './process-environment'
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
import { createOpenAIApiBaseUrl } from './openai-endpoint'
import {
@@ -242,6 +244,7 @@ export type ContinueHostAdapterOptions = {
launchHost?: ContinueHostLauncher
modelProfile?: ResolvedModelProfile
skillPackages?: RuntimeSkillPackage[]
launchEnvironmentProvider?: LaunchEnvironmentProvider
}
export type ContinueHostAdapterDependencies = {
@@ -1511,7 +1514,7 @@ export class ContinueHostAdapter {
GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1'
}
const profile = this.options.modelProfile
const environment = profile
let environment = profile
? buildExplicitProfileRuntimeEnvironment(
environmentOverrides,
profile.authentication === 'api-key' && profile.apiKey
@@ -1525,6 +1528,10 @@ export class ContinueHostAdapter {
: undefined
)
: buildRuntimeEnvironment(environmentOverrides)
environment = applyLaunchEnvironmentPath(
environment,
this.options.launchEnvironmentProvider
)
signal.throwIfAborted()
let child: ContinueHostChild
try {
+5
View File
@@ -425,6 +425,9 @@ describe('createAgentRuntime model compatibility', () => {
it('rejects remote process runtimes before invoking any local launcher', () => {
const launch = vi.fn()
const launchEnvironmentProvider = vi.fn(() =>
Object.freeze({ PATH: '/local-tools:/usr/bin' })
)
const workspaceAccess = {
dispose: vi.fn(async () => undefined)
} as unknown as WorkspaceAccess
@@ -437,6 +440,7 @@ describe('createAgentRuntime model compatibility', () => {
}),
{
deepseekHarnessLauncher: launch,
launchEnvironmentProvider,
executionSpace: {
kind: 'ssh',
hostId: 'host-one',
@@ -449,5 +453,6 @@ describe('createAgentRuntime model compatibility', () => {
)
).toThrow('远程执行空间尚不可用')
expect(launch).not.toHaveBeenCalled()
expect(launchEnvironmentProvider).not.toHaveBeenCalled()
})
})
+23 -4
View File
@@ -24,7 +24,10 @@ import type {
RuntimeSkillPackage
} from '../capabilities/capability-service'
import type { BundledRuntimePaths } from './bundled-runtimes'
import type { ContinueHostLauncher } from './continue-host-adapter'
import {
ContinueHostAdapter,
type ContinueHostLauncher
} from './continue-host-adapter'
import type { BrowserToolService } from '../browser/browser-model-tools'
import type { ModelToolProviderLike } from './model-tool-provider'
import type { SubagentScheduler } from '../assistant/subagent-scheduler'
@@ -39,6 +42,7 @@ import {
REMOTE_EXECUTION_SPACE_UNAVAILABLE,
type ExecutionSpaceDescriptor
} from '../execution-space'
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
const noSubagentTools: ModelToolProviderLike = {
listTools: async () => [],
@@ -67,6 +71,7 @@ export type AgentCapabilityContext = {
executionSpace?: ExecutionSpaceDescriptor
workspaceAccess?: WorkspaceAccess
directModelSubagentScheduler?: SubagentScheduler
launchEnvironmentProvider?: LaunchEnvironmentProvider
}
function resolveContextCompression(
@@ -217,7 +222,9 @@ export function createAgentRuntime(
capabilities.mcpServers,
undefined,
capabilities.knowledgeGateway,
capabilities.webSearchEnabled === true
capabilities.webSearchEnabled === true,
{},
capabilities.launchEnvironmentProvider
)
})
}
@@ -253,6 +260,14 @@ export function createAgentRuntime(
process.env.GOODBUDDY_CONTINUE_HOST_CACHE?.trim() ??
'',
launchHost: capabilities.continueHostLauncher,
createHostAdapter: capabilities.launchEnvironmentProvider
? (options) =>
new ContinueHostAdapter({
...options,
launchEnvironmentProvider:
capabilities.launchEnvironmentProvider
})
: undefined,
knowledgeGateway: capabilities.knowledgeGateway,
mcpServers: capabilities.mcpServers,
customization: settings?.runtimeCustomization.continue
@@ -288,7 +303,9 @@ export function createAgentRuntime(
defaultWorkspace: workspace,
knowledgeGateway: capabilities.knowledgeGateway,
mcpServers: capabilities.mcpServers,
customization: settings?.runtimeCustomization.opencode
customization: settings?.runtimeCustomization.opencode,
launchEnvironmentProvider:
capabilities.launchEnvironmentProvider
})
}
@@ -348,7 +365,9 @@ export function createAgentRuntime(
: undefined,
workspaceAccess: getWorkspaceAccess(),
directModelSubagentScheduler:
capabilities.directModelSubagentScheduler
capabilities.directModelSubagentScheduler,
launchEnvironmentProvider:
capabilities.launchEnvironmentProvider
})
}
@@ -9,7 +9,8 @@ import {
DEEPSEEK_HARNESS_CONTROL_VERSION,
DEEPSEEK_HARNESS_CREDENTIAL_REF,
createDeepSeekHarnessUtilityLauncher,
parseHarnessControlMessage
parseHarnessControlMessage,
type DeepSeekHarnessFork
} from './deepseek-harness-utility-launcher'
class FakeUtility extends EventEmitter {
@@ -129,6 +130,63 @@ describe('DeepSeek Harness utility launcher', () => {
)
})
it('refreshes the filtered tool PATH for every Host root', async () => {
const { dshHome, hostPath, launchOptions } = await fixture()
const utilities = [new FakeUtility(), new FakeUtility()]
const fork = vi.fn<DeepSeekHarnessFork>(
() => utilities.shift() as never
)
let generation = 0
const launcher = createDeepSeekHarnessUtilityLauncher({
bundledHostPath: hostPath,
dshHome,
environment: {
PATH: 'C:\\System'
},
launchEnvironmentProvider: () =>
Object.freeze({
PATH: `C:\\GoodBuddy\\tools-${++generation};C:\\System`,
OPENAI_API_KEY: 'provider-secret',
ELECTRON_RUN_AS_NODE: '1'
}),
fork
})
for (let index = 0; index < 2; index += 1) {
const launching = launcher(launchOptions)
await vi.waitFor(() =>
expect(fork).toHaveBeenCalledTimes(index + 1)
)
const launchedUtility = (
fork.mock.results[index]?.value
) as unknown as FakeUtility
await vi.waitFor(() =>
expect(launchedUtility.messages).toHaveLength(1)
)
launchedUtility.emit('message', {
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
type: 'ready',
failedExtensionIds: []
})
await launching
}
const environments = fork.mock.calls.map(
(call) => call[2]?.env as NodeJS.ProcessEnv
)
expect(environments[0]?.PATH).toBe(
'C:\\GoodBuddy\\tools-1;C:\\System'
)
expect(environments[1]?.PATH).toBe(
'C:\\GoodBuddy\\tools-2;C:\\System'
)
for (const environment of environments) {
expect(environment).not.toHaveProperty('OPENAI_API_KEY')
expect(environment).not.toHaveProperty('ELECTRON_RUN_AS_NODE')
}
})
it('persists extension startup failures before exposing the child', async () => {
const { root, dshHome, hostPath, launchOptions } =
await fixture()
@@ -19,6 +19,8 @@ import {
parseHarnessControlMessage,
type DeepSeekHarnessControlMessage as HarnessControlMessage
} from './deepseek-harness-control-protocol'
import { applyLaunchEnvironmentPath } from './process-environment'
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
export {
controlledHarnessHostConfigSchema,
@@ -49,6 +51,7 @@ export type DeepSeekHarnessUtilityLauncherOptions = {
bundledHostPath: string
dshHome: string
environment: NodeJS.ProcessEnv
launchEnvironmentProvider?: LaunchEnvironmentProvider
fork: DeepSeekHarnessFork
terminateProcess?: (utility: UtilityProcess) => void
onExtensionStartupFailures?: (
@@ -162,7 +165,10 @@ export function createDeepSeekHarnessUtilityLauncher(
options.signal.throwIfAborted()
const utility = launcherOptions.fork(canonicalHostPath, [], {
cwd: canonicalWorkspace,
env: launcherOptions.environment,
env: applyLaunchEnvironmentPath(
{ ...launcherOptions.environment },
launcherOptions.launchEnvironmentProvider
),
serviceName: 'GoodBuddy DeepSeek Harness Host',
stdio: ['ignore', 'ignore', 'pipe']
})
+9 -2
View File
@@ -63,6 +63,7 @@ import {
listAllMcpTools,
normalizeMcpToolSchema
} from './mcp-tool-utils'
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
const MAX_REQUEST_BODY_BYTES = 64 * 1024
const MAX_RESULT_BYTES = 128 * 1024
@@ -215,6 +216,7 @@ export type KnowledgeMcpGatewayOptions = {
now?: () => number
magicNotesDatabase?: MagicNotesDatabase
configService?: GoodBuddyConfigService
launchEnvironmentProvider?: LaunchEnvironmentProvider
}
function toMagicNoteToolSummary(
@@ -339,6 +341,7 @@ export class KnowledgeMcpGateway {
private readonly maximumBodyBytes: number
private readonly magicNotesDatabase?: MagicNotesDatabase
private readonly configService?: GoodBuddyConfigService
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
private server?: Server
private endpoint?: string
@@ -360,6 +363,7 @@ export class KnowledgeMcpGateway {
this.now = options.now ?? Date.now
this.magicNotesDatabase = options.magicNotesDatabase
this.configService = options.configService
this.launchEnvironmentProvider = options.launchEnvironmentProvider
}
async start(): Promise<void> {
@@ -888,10 +892,13 @@ export class KnowledgeMcpGateway {
capability.brokerController.signal
])
try {
await client.connect(createMcpTransport(server), {
await client.connect(
createMcpTransport(server, this.launchEnvironmentProvider),
{
timeout: CUSTOM_MCP_TIMEOUT_MS,
signal
})
}
)
const listedAtChangeVersion = dynamicToolsChangeVersion
const tools = await this.listAllCustomMcpTools(
client,
+4 -1
View File
@@ -11,6 +11,7 @@ import type {
import { defaultAnthropicMaximumOutputTokens } from '../../shared/contracts'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { BrowserToolService } from '../browser/browser-model-tools'
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
import {
LocalWorkspaceAccess,
type WorkspaceAccess
@@ -250,6 +251,7 @@ export type ModelRuntimeOptions = {
}
maximumOutputTokens?: number
directModelSubagentScheduler?: SubagentScheduler
launchEnvironmentProvider?: LaunchEnvironmentProvider
}
function getErrorMessage(value: unknown): string | undefined {
@@ -1585,7 +1587,8 @@ export class ModelAgentRuntime implements AgentRuntime {
{
processService: new LocalDirectModelProcessService(),
subagentService: directModelSubagentService
}
},
options.launchEnvironmentProvider
)
}
}
@@ -152,6 +152,31 @@ describe('ModelToolProvider', () => {
)
})
it('passes the launch environment provider to new stdio MCP connections', async () => {
const workspace = await createWorkspace()
const launchEnvironmentProvider = vi.fn(() =>
Object.freeze({ PATH: '/managed-tools:/usr/bin' })
)
const provider = new ModelToolProvider(
workspace,
[createMcpServer()],
undefined,
undefined,
false,
{},
launchEnvironmentProvider
)
await provider.listTools(toolContext, new AbortController().signal)
expect(mocks.createMcpTransport).toHaveBeenCalledWith(
expect.objectContaining({ transport: 'stdio' }),
launchEnvironmentProvider
)
expect(launchEnvironmentProvider).not.toHaveBeenCalled()
await provider.dispose()
})
it('provides bounded workspace read, list, and atomic write tools', async () => {
const workspace = await createWorkspace()
await mkdir(join(workspace, 'docs'))
+8 -3
View File
@@ -43,6 +43,7 @@ import {
type DirectModelSubagentUsageEvent,
directModelSubagentInputSchema
} from '../assistant/direct-model-subagent-service'
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
const MAX_MODEL_TOOLS = 100
const MAX_MCP_SERVERS = 16
@@ -618,7 +619,8 @@ export class ModelToolProvider implements ModelToolProviderLike {
private readonly knowledgeGateway?: KnowledgeMcpGateway,
private readonly webSearchEnabled = false,
private readonly programming:
ModelToolProviderProgrammingOptions = {}
ModelToolProviderProgrammingOptions = {},
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
) {
this.workspaceAccess =
typeof workspace === 'string'
@@ -909,10 +911,13 @@ export class ModelToolProvider implements ModelToolProviderLike {
this.clients.add(client)
clientScope.add(client)
try {
await client.connect(createMcpTransport(server), {
await client.connect(
createMcpTransport(server, this.launchEnvironmentProvider),
{
timeout: MCP_TIMEOUT_MS,
signal
})
}
)
const listedAtChangeVersion = dynamicToolsChangeVersion
const tools = await listAllMcpTools(
client,
+13 -1
View File
@@ -312,7 +312,13 @@ describe('OpenCodeRuntime embedded launcher', () => {
const runtime = new OpenCodeRuntime(
options({
binaryPath: 'C:\\Configured\\opencode.exe',
configPath
configPath,
launchEnvironmentProvider: () =>
Object.freeze({
PATH: 'C:\\GoodBuddy\\tools;C:\\Windows',
OPENAI_API_KEY: 'must-not-leak',
ELECTRON_RUN_AS_NODE: '1'
})
}),
{
spawn: spawnMock as unknown as typeof spawn,
@@ -399,6 +405,12 @@ describe('OpenCodeRuntime embedded launcher', () => {
'goodbuddy'
)
expect(spawnOptions?.env?.OPENCODE_SERVER_PASSWORD).toBeTruthy()
expect(spawnOptions?.env?.PATH).toBe(
'C:\\GoodBuddy\\tools;C:\\Windows'
)
expect(spawnOptions?.env).not.toHaveProperty(
'ELECTRON_RUN_AS_NODE'
)
expect(
Buffer.from(
clientOptions?.headers?.Authorization?.slice(6) ?? '',
+8 -1
View File
@@ -47,10 +47,12 @@ import { detectRuntimeBinary } from './runtime-discovery'
import { getAvailableLoopbackPort } from './loopback-port'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import {
applyLaunchEnvironmentPath,
buildExplicitProfileRuntimeEnvironment,
buildRuntimeEnvironment,
runtimePrivacyEnvironment
} from './process-environment'
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
import {
boundedToolDetail,
safeToolErrorDetail
@@ -489,6 +491,7 @@ export type OpenCodeRuntimeOptions = {
knowledgeGateway?: KnowledgeMcpGateway
mcpServers?: ResolvedMcpServer[]
customization?: RuntimeCustomizationSettings['opencode']
launchEnvironmentProvider?: LaunchEnvironmentProvider
}
function boundedNativeIdentifier(value: unknown): string | undefined {
@@ -904,7 +907,7 @@ export class OpenCodeRuntime implements AgentRuntime {
throw new Error('OpenCode Server 启动已取消')
}
const profile = this.options.modelProfile
const env = profile
let env = profile
? buildExplicitProfileRuntimeEnvironment(
runtimePrivacyEnvironment,
profile.authentication === 'api-key' && profile.apiKey
@@ -918,6 +921,10 @@ export class OpenCodeRuntime implements AgentRuntime {
: undefined
)
: buildRuntimeEnvironment(runtimePrivacyEnvironment)
env = applyLaunchEnvironmentPath(
env,
this.options.launchEnvironmentProvider
)
delete env.OPENCODE_CONFIG
delete env.OPENCODE_CONFIG_CONTENT
delete env.OPENCODE_CONFIG_DIR
@@ -1,11 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
applyLaunchEnvironmentPath,
buildCredentialFilteredUserEnvironment,
buildExplicitProfileRuntimeEnvironment,
buildControlledHarnessEnvironment,
buildRuntimeEnvironment
} from './process-environment'
describe('buildRuntimeEnvironment', () => {
it('uses a fresh tool PATH snapshot without importing credentials', () => {
let generation = 0
const provider = () =>
Object.freeze({
PATH: `/managed-${++generation}:/system`,
OPENAI_API_KEY: 'must-not-leak',
ELECTRON_RUN_AS_NODE: '1'
})
const source = {
PATH: '/system',
OPENAI_API_KEY: 'inherited-secret',
ELECTRON_RUN_AS_NODE: '1',
HOME: '/home/user'
}
expect(
applyLaunchEnvironmentPath(
buildCredentialFilteredUserEnvironment(source),
provider
)
).toEqual({
PATH: '/managed-1:/system',
HOME: '/home/user'
})
expect(
applyLaunchEnvironmentPath(
buildCredentialFilteredUserEnvironment(source),
provider
).PATH
).toBe('/managed-2:/system')
})
it('preserves one inherited PATH casing without duplicate keys', () => {
expect(
applyLaunchEnvironmentPath(
{ Path: 'C:\\Windows', PATH: 'duplicate', TEMP: 'C:\\Temp' },
() => Object.freeze({ PATH: 'C:\\GoodBuddy;C:\\Windows' })
)
).toEqual({
Path: 'C:\\GoodBuddy;C:\\Windows',
TEMP: 'C:\\Temp'
})
})
it('keeps required runtime values and excludes unrelated parent secrets', () => {
const environment = buildRuntimeEnvironment(
{
+36
View File
@@ -1,3 +1,5 @@
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
export const runtimeProviderEnvironmentNames = [
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
@@ -35,6 +37,7 @@ export function buildCredentialFilteredUserEnvironment(
const upperName = name.toUpperCase()
if (
value !== undefined &&
upperName !== 'ELECTRON_RUN_AS_NODE' &&
!upperName.startsWith('GOODBUDDY_') &&
!upperName.startsWith('FACTORY_') &&
!userProcessCredentialEnvironmentNames.has(upperName)
@@ -45,6 +48,39 @@ export function buildCredentialFilteredUserEnvironment(
return environment
}
/**
* Applies only the PATH prepared by the local tool environment. In
* particular, arbitrary values from that snapshot must not bypass the
* credential filtering performed by the caller.
*/
export function applyLaunchEnvironmentPath(
environment: NodeJS.ProcessEnv,
provider?: LaunchEnvironmentProvider
): NodeJS.ProcessEnv {
if (!provider) {
return environment
}
const launchEnvironment = provider()
const path =
launchEnvironment.PATH ??
launchEnvironment.Path ??
launchEnvironment.path
if (path === undefined) {
return environment
}
const result = { ...environment }
const existingPathName = Object.keys(environment).find(
(name) => name.toLowerCase() === 'path'
)
for (const name of Object.keys(result)) {
if (name.toLowerCase() === 'path') {
delete result[name]
}
}
result[existingPathName ?? 'PATH'] = path
return result
}
const runtimeEnvironmentAllowlist = [
'PATH',
'Path',