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:
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ?? '',
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user