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:
@@ -1,8 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
import { magicNoteCommentFormatSchema } from './magic-notes-contracts'
|
||||
import { localToolEnvironmentSettingsSchema } from './local-tool-environment-contracts'
|
||||
import { modelDownloadSourceSchema } from './model-download-contracts'
|
||||
import { settingsWarningsSchema } from './settings-warning-contracts'
|
||||
|
||||
export {
|
||||
artifactDownloadSourceSchema,
|
||||
defaultLocalToolEnvironmentSettings,
|
||||
localToolEnvironmentSettingsSchema,
|
||||
localToolExecutablePathSchema,
|
||||
localToolRuntimeSelectionSchema,
|
||||
type ArtifactDownloadSource,
|
||||
type LocalToolEnvironmentSettings,
|
||||
type LocalToolRuntimeSelection
|
||||
} from './local-tool-environment-contracts'
|
||||
export {
|
||||
modelDownloadSourceSchema,
|
||||
type ModelDownloadSource
|
||||
@@ -14,14 +25,9 @@ export const magicNoteCommentModeSchema = z.enum([
|
||||
'after-save-manual'
|
||||
])
|
||||
|
||||
export type MagicNoteCommentMode = z.infer<
|
||||
typeof magicNoteCommentModeSchema
|
||||
>
|
||||
export type MagicNoteCommentMode = z.infer<typeof magicNoteCommentModeSchema>
|
||||
|
||||
export const updateSourceSchema = z.enum([
|
||||
'github',
|
||||
'mirror'
|
||||
])
|
||||
export const updateSourceSchema = z.enum(['github', 'mirror'])
|
||||
export type UpdateSource = z.infer<typeof updateSourceSchema>
|
||||
|
||||
const applicationPreferencesSchema = z
|
||||
@@ -29,6 +35,7 @@ const applicationPreferencesSchema = z
|
||||
checkUpdatesOnStartup: z.boolean(),
|
||||
updateSource: updateSourceSchema,
|
||||
modelDownloadSource: modelDownloadSourceSchema,
|
||||
localToolEnvironment: localToolEnvironmentSettingsSchema,
|
||||
remoteProjectsEnabled: z.boolean(),
|
||||
magicNotesEnabled: z.boolean(),
|
||||
magicNotesShowIncompleteTodoCount: z.boolean(),
|
||||
@@ -49,15 +56,12 @@ export const applicationSettingsUpdateSchema = applicationPreferencesSchema
|
||||
message: 'At least one application setting is required'
|
||||
})
|
||||
|
||||
export type ApplicationSettings = z.infer<
|
||||
typeof applicationSettingsSchema
|
||||
>
|
||||
export type ApplicationSettings = z.infer<typeof applicationSettingsSchema>
|
||||
|
||||
export type ApplicationSettingsUpdate = z.infer<
|
||||
typeof applicationSettingsUpdateSchema
|
||||
>
|
||||
|
||||
|
||||
export type VersionCheckFile = {
|
||||
name: string
|
||||
size: number
|
||||
|
||||
@@ -84,6 +84,13 @@ import type {
|
||||
ModelDownloadSource,
|
||||
VersionCheckResult
|
||||
} from './application-settings-contracts'
|
||||
import type {
|
||||
LocalToolDiagnoseTarget,
|
||||
LocalToolEnvironmentProgress,
|
||||
LocalToolEnvironmentSettings,
|
||||
LocalToolEnvironmentSnapshot,
|
||||
LocalToolKind
|
||||
} from './local-tool-environment-contracts'
|
||||
import type {
|
||||
GlobalShortcutRegistrationStatus,
|
||||
GlobalShortcutSettings,
|
||||
@@ -1682,6 +1689,25 @@ export type DesktopApi = {
|
||||
listener: (result: VersionCheckResult) => void
|
||||
) => () => void
|
||||
}
|
||||
localToolEnvironment?: {
|
||||
getSnapshot: () => Promise<LocalToolEnvironmentSnapshot>
|
||||
updateSettings: (
|
||||
settings: LocalToolEnvironmentSettings
|
||||
) => Promise<LocalToolEnvironmentSnapshot>
|
||||
refreshCandidates: () => Promise<LocalToolEnvironmentSnapshot>
|
||||
selectExecutable: (
|
||||
kind: LocalToolKind
|
||||
) => Promise<LocalToolEnvironmentSnapshot>
|
||||
diagnose: (
|
||||
kind: LocalToolDiagnoseTarget
|
||||
) => Promise<LocalToolEnvironmentSnapshot>
|
||||
installPython: () => Promise<LocalToolEnvironmentSnapshot>
|
||||
cancelPython: () => Promise<boolean>
|
||||
removePython: () => Promise<LocalToolEnvironmentSnapshot>
|
||||
onProgress: (
|
||||
listener: (progress: LocalToolEnvironmentProgress) => void
|
||||
) => () => void
|
||||
}
|
||||
feedback: {
|
||||
submit: (
|
||||
input: FeedbackSubmitInput
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
goodbuddyConfigToolByName,
|
||||
goodbuddyConfigTools
|
||||
} from './goodbuddy-config-tools'
|
||||
import { defaultLocalToolEnvironmentSettings } from './local-tool-environment-contracts'
|
||||
|
||||
describe('GoodBuddy configuration contracts', () => {
|
||||
it('publishes one valid generated example for every operation', () => {
|
||||
@@ -135,6 +136,7 @@ describe('GoodBuddy configuration contracts', () => {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
magicNotesShowIncompleteTodoCount: true,
|
||||
|
||||
@@ -89,6 +89,19 @@ export const ipcChannels = {
|
||||
remoteChannelActivity: 'channels:remote-activity',
|
||||
applicationSettingsGet: 'settings:application:get',
|
||||
applicationSettingsUpdate: 'settings:application:update',
|
||||
localToolEnvironmentGet: 'settings:local-tools:get',
|
||||
localToolEnvironmentUpdate: 'settings:local-tools:update',
|
||||
localToolEnvironmentRefresh: 'settings:local-tools:refresh',
|
||||
localToolEnvironmentSelectExecutable:
|
||||
'settings:local-tools:select-executable',
|
||||
localToolEnvironmentDiagnose: 'settings:local-tools:diagnose',
|
||||
localToolEnvironmentInstallPython:
|
||||
'settings:local-tools:python:install',
|
||||
localToolEnvironmentCancelPython:
|
||||
'settings:local-tools:python:cancel',
|
||||
localToolEnvironmentRemovePython:
|
||||
'settings:local-tools:python:remove',
|
||||
localToolEnvironmentProgress: 'settings:local-tools:progress',
|
||||
shortcutSettingsGet: 'settings:shortcut:get',
|
||||
shortcutSettingsUpdate: 'settings:shortcut:update',
|
||||
versionCheck: 'application:update:check',
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
artifactDownloadSourceSchema,
|
||||
defaultLocalToolEnvironmentSettings,
|
||||
localToolCandidateSchema,
|
||||
localToolEnvironmentSettingsSchema,
|
||||
localToolPythonOperationSchema,
|
||||
localToolRuntimeSelectionSchema
|
||||
} from './local-tool-environment-contracts'
|
||||
|
||||
describe('local tool environment contracts', () => {
|
||||
it('accepts managed defaults and both download sources', () => {
|
||||
expect(
|
||||
localToolEnvironmentSettingsSchema.parse(
|
||||
defaultLocalToolEnvironmentSettings
|
||||
)
|
||||
).toEqual(defaultLocalToolEnvironmentSettings)
|
||||
expect(artifactDownloadSourceSchema.parse('oss')).toBe('oss')
|
||||
})
|
||||
|
||||
it('accepts cross-platform absolute custom executable paths', () => {
|
||||
for (const executablePath of [
|
||||
'/usr/local/bin/node',
|
||||
'C:\\Program Files\\nodejs\\node.exe',
|
||||
'\\\\server\\tools\\python.exe'
|
||||
]) {
|
||||
expect(
|
||||
localToolRuntimeSelectionSchema.parse({
|
||||
source: 'custom',
|
||||
executablePath
|
||||
})
|
||||
).toEqual({ source: 'custom', executablePath })
|
||||
}
|
||||
})
|
||||
|
||||
it('strictly rejects relative, unbounded, and mismatched selections', () => {
|
||||
for (const input of [
|
||||
{ source: 'custom', executablePath: 'bin/node' },
|
||||
{ source: 'custom', executablePath: '/bin/node ' },
|
||||
{ source: 'custom', executablePath: `/${'x'.repeat(4_096)}` },
|
||||
{ source: 'custom' },
|
||||
{ source: 'managed', executablePath: '/bin/node' },
|
||||
{ source: 'automatic' }
|
||||
]) {
|
||||
expect(localToolRuntimeSelectionSchema.safeParse(input).success).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('strictly rejects incomplete and unknown settings', () => {
|
||||
expect(
|
||||
localToolEnvironmentSettingsSchema.safeParse({
|
||||
...defaultLocalToolEnvironmentSettings,
|
||||
extra: true
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
localToolEnvironmentSettingsSchema.safeParse({
|
||||
node: { source: 'managed' },
|
||||
python: { source: 'managed' }
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(artifactDownloadSourceSchema.safeParse('mirror').success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires validated candidate version and architecture metadata', () => {
|
||||
expect(
|
||||
localToolCandidateSchema.parse({
|
||||
kind: 'node',
|
||||
executablePath: 'C:\\Tools\\node.exe',
|
||||
version: '22.14.0',
|
||||
architecture: 'x64'
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'node',
|
||||
executablePath: 'C:\\Tools\\node.exe',
|
||||
version: '22.14.0',
|
||||
architecture: 'x64'
|
||||
})
|
||||
expect(
|
||||
localToolCandidateSchema.safeParse({
|
||||
kind: 'python',
|
||||
executablePath: '/usr/bin/python3'
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('freezes the download source into Python operation snapshots', () => {
|
||||
expect(
|
||||
localToolPythonOperationSchema.parse({
|
||||
source: 'oss',
|
||||
phase: 'downloading',
|
||||
receivedBytes: 10,
|
||||
totalBytes: 100
|
||||
})
|
||||
).toEqual({
|
||||
source: 'oss',
|
||||
phase: 'downloading',
|
||||
receivedBytes: 10,
|
||||
totalBytes: 100
|
||||
})
|
||||
expect(
|
||||
localToolPythonOperationSchema.safeParse({
|
||||
phase: 'validating'
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const absoluteExecutablePathPattern =
|
||||
/^(?:\/|[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+(?:[\\/]|$))/u
|
||||
|
||||
export const localToolExecutablePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(4_096)
|
||||
.refine((value) => value.trim() === value, {
|
||||
message: 'Executable path must not have surrounding whitespace'
|
||||
})
|
||||
.refine((value) => !value.includes('\0'), {
|
||||
message: 'Executable path must not contain null characters'
|
||||
})
|
||||
.regex(absoluteExecutablePathPattern, 'Executable path must be absolute')
|
||||
|
||||
export const localToolRuntimeSelectionSchema = z.discriminatedUnion('source', [
|
||||
z.object({ source: z.literal('managed') }).strict(),
|
||||
z
|
||||
.object({
|
||||
source: z.literal('custom'),
|
||||
executablePath: localToolExecutablePathSchema
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
export const artifactDownloadSourceSchema = z.enum(['native', 'oss'])
|
||||
|
||||
export const localToolEnvironmentSettingsSchema = z
|
||||
.object({
|
||||
node: localToolRuntimeSelectionSchema,
|
||||
python: localToolRuntimeSelectionSchema,
|
||||
artifactDownloadSource: artifactDownloadSourceSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const defaultLocalToolEnvironmentSettings: LocalToolEnvironmentSettings =
|
||||
{
|
||||
node: { source: 'managed' },
|
||||
python: { source: 'managed' },
|
||||
artifactDownloadSource: 'native'
|
||||
}
|
||||
|
||||
export type LocalToolRuntimeSelection = z.infer<
|
||||
typeof localToolRuntimeSelectionSchema
|
||||
>
|
||||
export type ArtifactDownloadSource = z.infer<
|
||||
typeof artifactDownloadSourceSchema
|
||||
>
|
||||
export type LocalToolEnvironmentSettings = z.infer<
|
||||
typeof localToolEnvironmentSettingsSchema
|
||||
>
|
||||
|
||||
export const localToolKindSchema = z.enum(['node', 'python'])
|
||||
export type LocalToolKind = z.infer<typeof localToolKindSchema>
|
||||
|
||||
export const localToolDiagnoseTargetSchema = z.enum([
|
||||
'node',
|
||||
'python',
|
||||
'all'
|
||||
])
|
||||
export type LocalToolDiagnoseTarget = z.infer<
|
||||
typeof localToolDiagnoseTargetSchema
|
||||
>
|
||||
|
||||
export const localToolKindInputSchema = z
|
||||
.object({ kind: localToolKindSchema })
|
||||
.strict()
|
||||
export const localToolDiagnoseInputSchema = z
|
||||
.object({ kind: localToolDiagnoseTargetSchema })
|
||||
.strict()
|
||||
|
||||
export const localToolCandidateSchema = z
|
||||
.object({
|
||||
kind: localToolKindSchema,
|
||||
executablePath: localToolExecutablePathSchema,
|
||||
version: z
|
||||
.string()
|
||||
.regex(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u),
|
||||
architecture: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[0-9A-Za-z][0-9A-Za-z._-]*$/u)
|
||||
})
|
||||
.strict()
|
||||
export type LocalToolCandidate = z.infer<typeof localToolCandidateSchema>
|
||||
|
||||
export const localToolDiagnosticSchema = z
|
||||
.object({
|
||||
available: z.boolean(),
|
||||
source: z.enum(['managed', 'custom']),
|
||||
version: z.string().max(160).optional(),
|
||||
executablePath: localToolExecutablePathSchema.optional(),
|
||||
detail: z.string().max(512)
|
||||
})
|
||||
.strict()
|
||||
export type LocalToolDiagnosticSnapshot = z.infer<
|
||||
typeof localToolDiagnosticSchema
|
||||
>
|
||||
|
||||
export const localToolPythonOperationSchema = z
|
||||
.object({
|
||||
source: artifactDownloadSourceSchema,
|
||||
phase: z.enum([
|
||||
'downloading',
|
||||
'extracting',
|
||||
'validating',
|
||||
'publishing'
|
||||
]),
|
||||
receivedBytes: z.number().int().nonnegative().safe().optional(),
|
||||
totalBytes: z.number().int().positive().safe().optional()
|
||||
})
|
||||
.strict()
|
||||
export type LocalToolPythonOperation = z.infer<
|
||||
typeof localToolPythonOperationSchema
|
||||
>
|
||||
|
||||
export const localToolEnvironmentSnapshotSchema = z
|
||||
.object({
|
||||
settings: localToolEnvironmentSettingsSchema,
|
||||
candidates: z.array(localToolCandidateSchema).max(40),
|
||||
diagnostics: z
|
||||
.object({
|
||||
node: localToolDiagnosticSchema.optional(),
|
||||
npm: localToolDiagnosticSchema.optional(),
|
||||
npx: localToolDiagnosticSchema.optional(),
|
||||
python: localToolDiagnosticSchema.optional(),
|
||||
python3: localToolDiagnosticSchema.optional(),
|
||||
pip: localToolDiagnosticSchema.optional()
|
||||
})
|
||||
.strict(),
|
||||
managedPython: z
|
||||
.object({
|
||||
version: z.string().regex(/^\d+\.\d+\.\d+$/u),
|
||||
installed: z.boolean(),
|
||||
executablePath: localToolExecutablePathSchema.optional(),
|
||||
operation: localToolPythonOperationSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
.strict()
|
||||
export type LocalToolEnvironmentSnapshot = z.infer<
|
||||
typeof localToolEnvironmentSnapshotSchema
|
||||
>
|
||||
|
||||
export const localToolEnvironmentProgressSchema = z
|
||||
.object({
|
||||
snapshot: localToolEnvironmentSnapshotSchema
|
||||
})
|
||||
.strict()
|
||||
export type LocalToolEnvironmentProgress = z.infer<
|
||||
typeof localToolEnvironmentProgressSchema
|
||||
>
|
||||
Reference in New Issue
Block a user