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',
+142 -62
View File
@@ -1,10 +1,4 @@
import {
mkdtemp,
readFile,
readdir,
rm,
writeFile
} from 'node:fs/promises'
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -16,6 +10,13 @@ import {
} from './application-settings-store'
const temporaryDirectories: string[] = []
const managedMigrationResolver = async () => ({})
function createApplicationSettingsStore(
filePath: string
): ApplicationSettingsStore {
return new ApplicationSettingsStore(filePath, managedMigrationResolver)
}
async function createStore(): Promise<{
directory: string
@@ -30,15 +31,15 @@ async function createStore(): Promise<{
return {
directory,
filePath,
store: new ApplicationSettingsStore(filePath)
store: createApplicationSettingsStore(filePath)
}
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
)
})
@@ -46,9 +47,7 @@ describe('ApplicationSettingsStore', () => {
it('returns defaults without creating a settings file', async () => {
const { directory, store } = await createStore()
await expect(store.get()).resolves.toEqual(
defaultApplicationSettings
)
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
await expect(readdir(directory)).resolves.toEqual([])
})
@@ -65,6 +64,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -75,6 +75,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -82,10 +83,11 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 9,
version: 10,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -108,13 +110,13 @@ describe('ApplicationSettingsStore', () => {
remoteProjectsEnabled: true
})
await expect(
new ApplicationSettingsStore(filePath).get()
createApplicationSettingsStore(filePath).get()
).resolves.toEqual({
...defaultApplicationSettings,
remoteProjectsEnabled: true
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 9,
version: 10,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings,
remoteProjectsEnabled: true
@@ -124,18 +126,19 @@ describe('ApplicationSettingsStore', () => {
it('creates the parent directory and can reload persisted settings', async () => {
const { directory } = await createStore()
const filePath = join(directory, 'nested', 'application-settings.json')
const store = new ApplicationSettingsStore(filePath)
const store = createApplicationSettingsStore(filePath)
await store.update({
checkUpdatesOnStartup: false,
magicNotesEnabled: false
})
await expect(
new ApplicationSettingsStore(filePath).get()
createApplicationSettingsStore(filePath).get()
).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -161,6 +164,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -186,6 +190,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -211,6 +216,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -236,13 +242,14 @@ describe('ApplicationSettingsStore', () => {
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBeNull()
await store.setLastSeenReleaseNotesVersion('0.8.18')
await expect(
new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
createApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 9,
version: 10,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -271,18 +278,17 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-auto',
magicNoteCommentFormat: 'structured'
})
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe(
'0.8.18'
)
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18')
})
it('lazily migrates version 6 to the default ModelScope source', async () => {
it('migrates version 6 to the default ModelScope source', async () => {
const { filePath, store } = await createStore()
const versionSix = {
version: 6,
@@ -299,21 +305,28 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-auto',
magicNoteCommentFormat: 'structured'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(
versionSix
)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSix,
version: 10,
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
await store.update({ modelDownloadSource: 'hugging-face' })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSix,
version: 9,
version: 10,
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
@@ -337,18 +350,23 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(
versionSeven
)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSeven,
version: 10,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
})
it('lazily migrates version 8 with Remote Projects disabled', async () => {
it('migrates version 8 with Remote Projects disabled', async () => {
const { filePath, store } = await createStore()
const versionEight = {
version: 8,
@@ -364,36 +382,102 @@ describe('ApplicationSettingsStore', () => {
}
await writeFile(filePath, JSON.stringify(versionEight), 'utf8')
await expect(store.get()).resolves.toEqual({
const migratedSettings = await store.get()
expect(migratedSettings).toEqual({
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative'
})
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe(
'0.8.18'
)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(
versionEight
)
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionEight,
version: 10,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false
})
await store.update({ checkUpdatesOnStartup: true })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionEight,
version: 9,
version: 10,
checkUpdatesOnStartup: true,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false
})
})
it('persists independently resolved version 9 runtime selections exactly once', async () => {
const { filePath } = await createStore()
const versionNine = {
version: 9,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
remoteProjectsEnabled: true,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative',
lastSeenReleaseNotesVersion: '0.8.18'
}
await writeFile(filePath, JSON.stringify(versionNine), 'utf8')
let resolverCalls = 0
const store = new ApplicationSettingsStore(filePath, async () => {
resolverCalls += 1
return {
nodeExecutablePath: 'C:\\Tools\\node.exe',
pythonExecutablePath: 'relative/python'
}
})
await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
remoteProjectsEnabled: true,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative',
localToolEnvironment: {
node: {
source: 'custom',
executablePath: 'C:\\Tools\\node.exe'
},
python: { source: 'managed' },
artifactDownloadSource: 'native'
}
})
expect(resolverCalls).toBe(1)
const persisted = JSON.parse(await readFile(filePath, 'utf8'))
expect(persisted).toEqual({
...versionNine,
version: 10,
localToolEnvironment: {
node: {
source: 'custom',
executablePath: 'C:\\Tools\\node.exe'
},
python: { source: 'managed' },
artifactDownloadSource: 'native'
}
})
const reloaded = new ApplicationSettingsStore(filePath, async () => {
throw new Error('A persisted migration must not resolve PATH again')
})
await expect(reloaded.get()).resolves.toEqual(await store.get())
})
it('strictly rejects incomplete full settings', () => {
expect(
applicationSettingsSchema.safeParse(defaultApplicationSettings)
.success
applicationSettingsSchema.safeParse(defaultApplicationSettings).success
).toBe(true)
for (const input of [
{},
@@ -406,9 +490,7 @@ describe('ApplicationSettingsStore', () => {
{ checkUpdatesOnStartup: true },
null
]) {
expect(applicationSettingsSchema.safeParse(input).success).toBe(
false
)
expect(applicationSettingsSchema.safeParse(input).success).toBe(false)
}
})
@@ -422,9 +504,9 @@ describe('ApplicationSettingsStore', () => {
{ anotherSetting: true },
null
]) {
expect(
applicationSettingsUpdateSchema.safeParse(input).success
).toBe(false)
expect(applicationSettingsUpdateSchema.safeParse(input).success).toBe(
false
)
await expect(store.update(input)).rejects.toThrow()
}
await expect(readdir(directory)).resolves.toEqual([])
@@ -440,6 +522,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -456,7 +539,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'mirror'
})
await expect(
new ApplicationSettingsStore(filePath).get()
createApplicationSettingsStore(filePath).get()
).resolves.toEqual({
...defaultApplicationSettings,
updateSource: 'mirror'
@@ -494,9 +577,7 @@ describe('ApplicationSettingsStore', () => {
expect(entries[0]).toMatch(
/^application-settings\.json\.corrupt-\d+-[a-f0-9]{12}$/u
)
expect(await readFile(join(directory, entries[0] ?? ''), 'utf8')).toBe(
data
)
expect(await readFile(join(directory, entries[0] ?? ''), 'utf8')).toBe(data)
})
it('preserves settings created by a newer unsupported version', async () => {
@@ -507,9 +588,7 @@ describe('ApplicationSettingsStore', () => {
})
await writeFile(filePath, futureSettings, 'utf8')
await expect(store.get()).rejects.toThrow(
'不支持应用设置版本 99'
)
await expect(store.get()).rejects.toThrow('不支持应用设置版本 99')
expect(await readFile(filePath, 'utf8')).toBe(futureSettings)
expect(
(await readdir(directory)).some((name) =>
@@ -521,17 +600,15 @@ describe('ApplicationSettingsStore', () => {
it('does not classify an I/O failure as corrupt settings', async () => {
const { directory } = await createStore()
const filePath = join(directory, 'settings-directory')
const store = new ApplicationSettingsStore(filePath)
const store = createApplicationSettingsStore(filePath)
await writeFile(join(directory, 'sentinel'), 'unchanged', 'utf8')
const directoryStore = new ApplicationSettingsStore(directory)
const directoryStore = createApplicationSettingsStore(directory)
await expect(directoryStore.get()).rejects.toThrow(
'Application settings could not be read'
)
expect(await readdir(directory)).toEqual(['sentinel'])
await expect(store.get()).resolves.toEqual(
defaultApplicationSettings
)
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
})
it('serializes concurrent updates and leaves complete JSON', async () => {
@@ -556,6 +633,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -563,10 +641,11 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 9,
version: 10,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -594,6 +673,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
+176 -54
View File
@@ -1,10 +1,17 @@
import { readFile } from 'node:fs/promises'
import { readFile, realpath, stat } from 'node:fs/promises'
import { delimiter, extname, isAbsolute, join } from 'node:path'
import { z } from 'zod'
import {
applicationSettingsSchema,
applicationSettingsUpdateSchema,
type ApplicationSettings
} from '../shared/application-settings-contracts'
import {
defaultLocalToolEnvironmentSettings,
localToolExecutablePathSchema,
type LocalToolEnvironmentSettings
} from '../shared/local-tool-environment-contracts'
import { inspectLocalToolExecutable } from './local-tool-environment/local-tool-environment'
import { releaseVersionSchema } from '../shared/release-notes-contracts'
import type { SettingsWarning } from '../shared/settings-warning-contracts'
import {
@@ -20,7 +27,7 @@ export {
} from '../shared/application-settings-contracts'
export type { ApplicationSettings } from '../shared/application-settings-contracts'
const CURRENT_SETTINGS_VERSION = 9
const CURRENT_SETTINGS_VERSION = 10
const legacyStoredApplicationSettingsSchema = z
.object({
@@ -50,6 +57,7 @@ const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -62,6 +70,7 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -74,6 +83,7 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
modelDownloadSource: true,
localToolEnvironment: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -83,26 +93,36 @@ const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
})
.strict()
const versionSevenStoredApplicationSettingsSchema =
applicationSettingsSchema
.omit({
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
version: z.literal(7),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
version: z.literal(7),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionEightStoredApplicationSettingsSchema =
applicationSettingsSchema
.omit({ remoteProjectsEnabled: true })
.extend({
version: z.literal(8),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionEightStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
remoteProjectsEnabled: true
})
.extend({
version: z.literal(8),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionNineStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({ localToolEnvironment: true })
.extend({
version: z.literal(9),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const storedApplicationSettingsSchema = applicationSettingsSchema
.extend({
@@ -111,14 +131,90 @@ const storedApplicationSettingsSchema = applicationSettingsSchema
})
.strict()
type StoredApplicationSettings = z.infer<
typeof storedApplicationSettingsSchema
>
type StoredApplicationSettings = z.infer<typeof storedApplicationSettingsSchema>
export type LegacyLocalToolEnvironmentPaths = {
nodeExecutablePath?: string
pythonExecutablePath?: string
}
export type LegacyLocalToolEnvironmentResolver =
() => Promise<LegacyLocalToolEnvironmentPaths>
async function resolveExecutable(
kind: 'node' | 'python',
binaryNames: readonly string[]
): Promise<string | undefined> {
const extensions =
process.platform === 'win32'
? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
.split(';')
.filter((extension) => /^\.[A-Za-z0-9]+$/u.test(extension))
: ['']
const directories = (
process.env.PATH ??
process.env.Path ??
process.env.path ??
''
)
.split(delimiter)
.map((directory) => directory.trim())
.filter(isAbsolute)
for (const directory of directories) {
for (const binaryName of binaryNames) {
const names =
process.platform === 'win32' && !extname(binaryName)
? extensions.map((extension) => `${binaryName}${extension}`)
: [binaryName]
for (const name of names) {
try {
const executablePath = await realpath(join(directory, name))
const metadata = await stat(executablePath)
if (
metadata.isFile() &&
localToolExecutablePathSchema.safeParse(executablePath).success
) {
const candidate = await inspectLocalToolExecutable(
kind,
executablePath,
{ baseEnvironment: process.env }
)
if (candidate) {
return candidate.executablePath
}
}
} catch {
// Continue through PATH candidates.
}
}
}
}
return undefined
}
export const resolveLegacyLocalToolEnvironmentPaths: LegacyLocalToolEnvironmentResolver =
async () => {
const [nodeExecutablePath, pythonExecutablePath] = await Promise.all([
resolveExecutable('node', ['node']),
resolveExecutable(
'python',
process.platform === 'win32'
? ['python', 'python3']
: ['python3', 'python']
)
])
return {
...(nodeExecutablePath ? { nodeExecutablePath } : {}),
...(pythonExecutablePath ? { pythonExecutablePath } : {})
}
}
export const defaultApplicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -132,7 +228,37 @@ export class ApplicationSettingsStore {
private warnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {}
constructor(
private readonly filePath: string,
private readonly legacyLocalToolEnvironmentResolver: LegacyLocalToolEnvironmentResolver = resolveLegacyLocalToolEnvironmentPaths
) {}
private async migrateLegacy(
settings: Omit<
StoredApplicationSettings,
'localToolEnvironment' | 'version'
> & { version: number }
): Promise<StoredApplicationSettings> {
const resolved = await this.legacyLocalToolEnvironmentResolver()
const selection = (
executablePath: string | undefined
): LocalToolEnvironmentSettings['node'] =>
executablePath &&
localToolExecutablePathSchema.safeParse(executablePath).success
? { source: 'custom', executablePath }
: { source: 'managed' }
const next: StoredApplicationSettings = {
...settings,
version: CURRENT_SETTINGS_VERSION,
localToolEnvironment: {
node: selection(resolved.nodeExecutablePath),
python: selection(resolved.pythonExecutablePath),
artifactDownloadSource: 'native'
}
}
await this.persist(next)
return next
}
private async isolateCorruptFile(): Promise<void> {
await isolateCorruptSettingsFile(
@@ -177,56 +303,57 @@ export class ApplicationSettingsStore {
)
const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) {
const versionNineResult =
versionNineStoredApplicationSettingsSchema.safeParse(parsed)
if (versionNineResult.success) {
return this.migrateLegacy(versionNineResult.data)
}
const versionEightResult =
versionEightStoredApplicationSettingsSchema.safeParse(parsed)
if (versionEightResult.success) {
this.settings = {
return this.migrateLegacy({
...versionEightResult.data,
version: CURRENT_SETTINGS_VERSION,
remoteProjectsEnabled: false
}
return this.settings
})
}
const versionSevenResult =
versionSevenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionSevenResult.success) {
this.settings = {
return this.migrateLegacy({
...versionSevenResult.data,
version: CURRENT_SETTINGS_VERSION,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
}
return this.settings
})
}
const versionSixResult =
versionSixStoredApplicationSettingsSchema.safeParse(parsed)
if (versionSixResult.success) {
this.settings = {
return this.migrateLegacy({
...versionSixResult.data,
version: CURRENT_SETTINGS_VERSION,
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
}
return this.settings
})
}
const versionFiveResult =
versionFiveStoredApplicationSettingsSchema.safeParse(parsed)
if (versionFiveResult.success) {
this.settings = {
return this.migrateLegacy({
...versionFiveResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
}
return this.settings
})
}
const versionFourResult =
versionFourStoredApplicationSettingsSchema.safeParse(parsed)
if (versionFourResult.success) {
this.settings = {
return this.migrateLegacy({
...versionFourResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
@@ -234,13 +361,12 @@ export class ApplicationSettingsStore {
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true,
lastSeenReleaseNotesVersion: null
}
return this.settings
})
}
const versionThreeResult =
versionThreeStoredApplicationSettingsSchema.safeParse(parsed)
if (versionThreeResult.success) {
this.settings = {
return this.migrateLegacy({
...versionThreeResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
@@ -249,13 +375,12 @@ export class ApplicationSettingsStore {
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
}
return this.settings
})
}
const versionTwoResult =
versionTwoStoredApplicationSettingsSchema.safeParse(parsed)
if (versionTwoResult.success) {
this.settings = {
return this.migrateLegacy({
...versionTwoResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
@@ -265,16 +390,14 @@ export class ApplicationSettingsStore {
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
}
return this.settings
})
}
const legacyResult =
legacyStoredApplicationSettingsSchema.safeParse(parsed)
if (legacyResult.success) {
this.settings = {
return this.migrateLegacy({
version: CURRENT_SETTINGS_VERSION,
checkUpdatesOnStartup:
legacyResult.data.checkUpdatesOnStartup,
checkUpdatesOnStartup: legacyResult.data.checkUpdatesOnStartup,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
@@ -283,8 +406,7 @@ export class ApplicationSettingsStore {
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
}
return this.settings
})
}
await this.isolateCorruptFile()
this.warnings = [{ code: 'application-settings-recovered' }]
@@ -320,15 +442,14 @@ export class ApplicationSettingsStore {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
localToolEnvironment: stored.localToolEnvironment,
remoteProjectsEnabled: stored.remoteProjectsEnabled,
magicNotesEnabled: stored.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
stored.magicNotesShowIncompleteTodoCount,
magicNoteCommentMode: stored.magicNoteCommentMode,
magicNoteCommentFormat: stored.magicNoteCommentFormat,
...(this.warnings.length > 0
? { warnings: [...this.warnings] }
: {})
...(this.warnings.length > 0 ? { warnings: [...this.warnings] } : {})
}
}
@@ -356,6 +477,7 @@ export class ApplicationSettingsStore {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
localToolEnvironment: next.localToolEnvironment,
remoteProjectsEnabled: next.remoteProjectsEnabled,
magicNotesEnabled: next.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
@@ -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 => {
+80 -4
View File
@@ -98,7 +98,10 @@ import {
createDeepSeekHarnessUtilityLauncher,
type DeepSeekHarnessFork
} from './agent/deepseek-harness-utility-launcher'
import { buildControlledHarnessEnvironment } from './agent/process-environment'
import {
buildControlledHarnessEnvironment,
buildCredentialFilteredUserEnvironment
} from './agent/process-environment'
import {
createStartupFailureDiagnostic,
formatStartupFailureMessage,
@@ -158,6 +161,10 @@ import {
type DesktopDiagnosticFailureObserver
} from './desktop-diagnostics'
import { TerminalSessionManager } from './terminal/terminal-session-manager'
import {
LocalToolEnvironmentService,
resolveNpmCliPaths
} from './local-tool-environment'
const legacyDefaultShortcut =
defaultGlobalShortcutSettings.accelerator
@@ -222,6 +229,7 @@ let managedRemoteExecutionServices:
| ManagedRemoteExecutionServices
| undefined
let directModelSubagentScheduler: SubagentScheduler | undefined
let localToolEnvironmentService: LocalToolEnvironmentService | undefined
type ManagedEmbeddingProvider = EmbeddingProvider & {
dispose?: () => void | Promise<void>
@@ -665,6 +673,66 @@ if (hasSingleInstanceLock) {
const applicationSettingsStore = new ApplicationSettingsStore(
join(app.getPath('userData'), 'application-settings.json')
)
const toolEnvironmentRoot = join(
app.getPath('userData'),
'tool-environment'
)
const npmCliPaths = resolveNpmCliPaths({
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
packaged: app.isPackaged
})
const startupLocalToolEnvironmentService =
new LocalToolEnvironmentService({
settingsStore: applicationSettingsStore,
binDirectory: join(toolEnvironmentRoot, 'bin'),
managedPythonRoot: join(
toolEnvironmentRoot,
'managed-python'
),
pythonArtifactCatalogPath: app.isPackaged
? join(
process.resourcesPath,
'tool-environment',
'managed-python-artifacts.json'
)
: join(
app.getAppPath(),
'resources',
'tool-environment',
'managed-python-artifacts.json'
),
packagedNpmCliPath: npmCliPaths.npmCliPath,
packagedNpxCliPath: npmCliPaths.npxCliPath,
electronExecutablePath: process.execPath,
selectExecutable: async (kind) => {
const result = await dialog.showOpenDialog(mainWindow!, {
title:
kind === 'python'
? '选择 Python 可执行文件'
: '选择 Node.js 可执行文件',
properties: ['openFile'],
filters:
process.platform === 'win32'
? [
{
name:
kind === 'python'
? 'Python 可执行文件'
: 'Node.js 可执行文件',
extensions: ['exe', 'cmd', 'bat']
},
{ name: '所有文件', extensions: ['*'] }
]
: [{ name: '所有文件', extensions: ['*'] }]
})
return result.canceled ? undefined : result.filePaths[0]
},
baseEnvironment: buildCredentialFilteredUserEnvironment()
})
await startupLocalToolEnvironmentService.initialize()
localToolEnvironmentService =
startupLocalToolEnvironmentService
const sshHostStore = new SshHostStore(
join(app.getPath('userData'), 'ssh-hosts.json'),
secureCipher
@@ -886,6 +954,8 @@ if (hasSingleInstanceLock) {
environment: buildControlledHarnessEnvironment(
deepSeekHarnessHome
),
launchEnvironmentProvider:
startupLocalToolEnvironmentService.launchEnvironmentProvider,
fork: forkDeepSeekHarness,
terminateProcess: terminateHarnessUtilityProcess,
onExtensionStartupFailures: (extensionIds) =>
@@ -939,7 +1009,9 @@ if (hasSingleInstanceLock) {
startupKnowledgeService,
{
magicNotesDatabase: startupAssistantDatabase,
configService: goodbuddyConfigService
configService: goodbuddyConfigService,
launchEnvironmentProvider:
startupLocalToolEnvironmentService.launchEnvironmentProvider
}
)
knowledgeGateway = startupKnowledgeGateway
@@ -990,7 +1062,9 @@ if (hasSingleInstanceLock) {
webSearchEnabled: webSearchCapability?.enabled,
executionSpace,
workspaceAccess: executionSpace?.workspaceAccess,
directModelSubagentScheduler
directModelSubagentScheduler,
launchEnvironmentProvider:
startupLocalToolEnvironmentService.launchEnvironmentProvider
})
}
const createConfiguredRuntime = async (
@@ -1389,7 +1463,8 @@ if (hasSingleInstanceLock) {
remoteEnvironmentUpdateService,
agentPackageManager,
startupRemoteAgentServices.connectionManager,
terminalSessionManager
terminalSessionManager,
startupLocalToolEnvironmentService
)
removeFeedbackIpcHandler = registerFeedbackIpcHandler(
mainWindow,
@@ -1508,6 +1583,7 @@ app.on('before-quit', (event) => {
() => documentOcrBroker?.dispose()
],
[() => terminalSessionManager?.dispose()],
[() => localToolEnvironmentService?.dispose()],
[() => managedRemoteExecutionServices?.dispose()],
[() => remoteAgentServices?.dispose()],
[() => knowledgeGateway?.dispose()],
+21
View File
@@ -56,6 +56,27 @@ describe('embedding IPC boundary', () => {
})
})
describe('local tool environment IPC boundary', () => {
it('trust-checks every operation and accepts only shared bounded schemas', async () => {
const source = await readFile(
join(process.cwd(), 'src', 'main', 'ipc.ts'),
'utf8'
)
const region =
source.match(
/const requireLocalToolEnvironmentService[\s\S]*?registerHandler\(ipcChannels\.shortcutSettingsGet/u
)?.[0] ?? ''
expect(region.match(/assertTrustedSender\(event, window\)/gu)).toHaveLength(8)
expect(region).toContain('localToolEnvironmentSettingsSchema.parse(input)')
expect(region).toContain('localToolKindInputSchema.parse(input)')
expect(region).toContain('localToolDiagnoseInputSchema.parse(input)')
expect(region.match(/z\.undefined\(\)\.parse\(input\)/gu)).toHaveLength(5)
expect(region).not.toMatch(
/\b(?:command|args|env|url|hash|installDirectory|shimContents)\b/u
)
})
})
describe('terminal IPC boundary', () => {
afterEach(() => {
electronMocks.handlers.clear()
+99 -2
View File
@@ -124,6 +124,12 @@ import {
weComChannelSettingsInputSchema
} from '../shared/channel-settings-contracts'
import { applicationSettingsUpdateSchema } from '../shared/application-settings-contracts'
import {
localToolDiagnoseInputSchema,
localToolEnvironmentProgressSchema,
localToolEnvironmentSettingsSchema,
localToolKindInputSchema
} from '../shared/local-tool-environment-contracts'
import {
agentPackageArchitectureRequestSchema,
agentPackageDownloadProgressSchema,
@@ -324,6 +330,7 @@ import {
SqliteChannelOutbox
} from './channels/sqlite-channel-state'
import type { ApplicationSettingsStore } from './application-settings-store'
import type { LocalToolEnvironmentService } from './local-tool-environment'
import {
getUpdateDownloadPage,
type VersionChecker
@@ -1117,7 +1124,8 @@ export function registerIpcHandlers(
RemoteAgentConnectionManager,
'getHostConnectionState' | 'onHostConnectionStateChange'
>,
terminalSessionManager?: TerminalSessionManager
terminalSessionManager?: TerminalSessionManager,
localToolEnvironmentService?: LocalToolEnvironmentService
): () => Promise<void> {
type ActiveRequestLease = {
controller: AbortController
@@ -1216,6 +1224,17 @@ export function registerIpcHandlers(
: result
})
}
const removeLocalToolEnvironmentProgressListener =
localToolEnvironmentService?.onProgress((progress) => {
if (!window.webContents.isDestroyed()) {
sendValidatedProgress(
window.webContents,
ipcChannels.localToolEnvironmentProgress,
localToolEnvironmentProgressSchema,
progress
)
}
})
const resolveRequestRuntime = async (
request: Pick<
AgentRequest,
@@ -5367,6 +5386,80 @@ export function registerIpcHandlers(
}
)
const requireLocalToolEnvironmentService =
(): LocalToolEnvironmentService => {
if (!localToolEnvironmentService) {
throw new Error('本地工具环境服务不可用')
}
return localToolEnvironmentService
}
registerHandler(
ipcChannels.localToolEnvironmentGet,
(event, input: unknown) => {
assertTrustedSender(event, window)
z.undefined().parse(input)
return requireLocalToolEnvironmentService().getSnapshot()
}
)
registerHandler(
ipcChannels.localToolEnvironmentUpdate,
(event, input: unknown) => {
assertTrustedSender(event, window)
return requireLocalToolEnvironmentService().updateSettings(
localToolEnvironmentSettingsSchema.parse(input)
)
}
)
registerHandler(
ipcChannels.localToolEnvironmentRefresh,
(event, input: unknown) => {
assertTrustedSender(event, window)
z.undefined().parse(input)
return requireLocalToolEnvironmentService().refreshCandidates()
}
)
registerHandler(
ipcChannels.localToolEnvironmentSelectExecutable,
(event, input: unknown) => {
assertTrustedSender(event, window)
const { kind } = localToolKindInputSchema.parse(input)
return requireLocalToolEnvironmentService().selectExecutable(kind)
}
)
registerHandler(
ipcChannels.localToolEnvironmentDiagnose,
(event, input: unknown) => {
assertTrustedSender(event, window)
const { kind } = localToolDiagnoseInputSchema.parse(input)
return requireLocalToolEnvironmentService().diagnose(kind)
}
)
registerHandler(
ipcChannels.localToolEnvironmentInstallPython,
(event, input: unknown) => {
assertTrustedSender(event, window)
z.undefined().parse(input)
return requireLocalToolEnvironmentService().installPython()
}
)
registerHandler(
ipcChannels.localToolEnvironmentCancelPython,
(event, input: unknown) => {
assertTrustedSender(event, window)
z.undefined().parse(input)
return requireLocalToolEnvironmentService().cancelPython()
}
)
registerHandler(
ipcChannels.localToolEnvironmentRemovePython,
(event, input: unknown) => {
assertTrustedSender(event, window)
z.undefined().parse(input)
return requireLocalToolEnvironmentService().removePython()
}
)
registerHandler(ipcChannels.shortcutSettingsGet, (event) => {
assertTrustedSender(event, window)
if (!shortcutSettingsService) {
@@ -6944,7 +7037,9 @@ export function registerIpcHandlers(
return testMcpServer(
await capabilityService.getResolvedMcpServer(
mcpServerIdSchema.parse(input)
)
),
undefined,
localToolEnvironmentService?.launchEnvironmentProvider
)
}
)
@@ -7966,6 +8061,8 @@ export function registerIpcHandlers(
return async () => {
shuttingDown = true
removeLocalToolEnvironmentProgressListener?.()
await localToolEnvironmentService?.dispose()
removeBrowserStateListener?.()
lastSentBrowserFrames.clear()
removeRemoteAgentConnectionStatusListener?.()
+21
View File
@@ -0,0 +1,21 @@
export {
createLocalToolEnvironment,
resolveNpmCliPaths
} from './local-tool-environment'
export type {
LocalToolDiagnostic,
LocalToolEnvironment,
LocalToolEnvironmentDependencies,
LocalToolEnvironmentDiagnostics,
LocalToolEnvironmentOptions,
LocalToolName,
LocalToolProbeProcess,
LocalToolProbeSpawn,
LocalToolSource
} from './local-tool-environment'
export { LocalToolEnvironmentService } from './local-tool-environment-service'
export type {
LocalToolEnvironmentServiceDependencies,
LocalToolEnvironmentServiceOptions
} from './local-tool-environment-service'
export type { LaunchEnvironmentProvider } from './launch-environment-provider'
@@ -0,0 +1,5 @@
/**
* Supplies a fresh, immutable snapshot of the environment prepared for local
* tool launches. Consumers must only copy the values they explicitly support.
*/
export type LaunchEnvironmentProvider = () => Readonly<NodeJS.ProcessEnv>
@@ -0,0 +1,311 @@
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
defaultLocalToolEnvironmentSettings,
type LocalToolEnvironmentSettings
} from '../../shared/local-tool-environment-contracts'
import {
LocalToolEnvironmentService,
validateManagedPython
} from './local-tool-environment-service'
import type { LocalToolEnvironmentServiceOptions } from './local-tool-environment-service'
import type {
LocalToolProbeProcess,
LocalToolProbeSpawn
} from './local-tool-environment'
const roots: string[] = []
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))
)
})
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-local-tools-'))
roots.push(root)
const node = join(root, 'node.exe')
const python = join(root, 'python.exe')
await Promise.all([writeFile(node, ''), writeFile(python, '')])
let settings: LocalToolEnvironmentSettings = {
...defaultLocalToolEnvironmentSettings
}
const settingsStore = {
get: vi.fn(async () => ({ localToolEnvironment: settings })),
update: vi.fn(async (input: { localToolEnvironment: LocalToolEnvironmentSettings }) => {
settings = input.localToolEnvironment
return { localToolEnvironment: settings }
})
}
const options: LocalToolEnvironmentServiceOptions = {
settingsStore: settingsStore as never,
binDirectory: join(root, 'bin'),
managedPythonRoot: join(root, 'managed-python'),
pythonArtifactCatalogPath: join(
process.cwd(),
'resources',
'tool-environment',
'managed-python-artifacts.json'
),
packagedNpmCliPath: join(root, 'npm-cli.js'),
packagedNpxCliPath: join(root, 'npx-cli.js'),
selectExecutable: vi.fn(async () => python),
baseEnvironment: { PATH: root },
platform: 'win32',
arch: 'x64'
}
const service = new LocalToolEnvironmentService(options, {
toolEnvironment: {
platform: 'win32',
spawnProcess: fakeInspectionSpawn()
}
})
return { service, settingsStore, node, python, options }
}
function fakeInspectionSpawn(
invalidPath?: string
): LocalToolProbeSpawn {
return (command, args) => {
const child = new EventEmitter() as LocalToolProbeProcess & EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
}
child.exitCode = null
child.killed = false
child.pid = 1234
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = vi.fn(() => true)
queueMicrotask(() => {
const invalid = command === invalidPath
const python = args[0] === '-I'
if (!invalid) {
child.stdout.emit(
'data',
JSON.stringify({
version: python ? '3.12.8' : '22.14.0',
architecture: 'x64'
})
)
}
child.exitCode = invalid ? 1 : 0
child.emit('close', child.exitCode)
})
return child
}
}
describe('LocalToolEnvironmentService', () => {
it('does not discover PATH candidates until explicitly refreshed', async () => {
const { service, node, python } = await fixture()
expect((await service.getSnapshot()).candidates).toEqual([])
const snapshot = await service.refreshCandidates()
expect(snapshot.candidates).toEqual([
{
kind: 'node',
executablePath: node,
version: '22.14.0',
architecture: 'x64'
},
{
kind: 'python',
executablePath: python,
version: '3.12.8',
architecture: 'x64'
}
])
})
it('persists the complete nested settings object through one store authority', async () => {
const { service, settingsStore, node } = await fixture()
const next: LocalToolEnvironmentSettings = {
node: { source: 'custom', executablePath: node },
python: { source: 'managed' },
artifactDownloadSource: 'oss'
}
await service.updateSettings(next)
expect(settingsStore.update).toHaveBeenCalledWith({
localToolEnvironment: next
})
await expect(
service.updateSettings({ ...next, command: 'node' })
).rejects.toThrow()
})
it('publishes immutable snapshots and replaces them only after rebuilding', async () => {
const { service, node } = await fixture()
await service.initialize()
const initial = service.launchEnvironmentProvider()
const initialPath = initial.PATH
expect(Object.isFrozen(initial)).toBe(true)
expect(initial.PATH).toContain('bin')
await service.updateSettings({
...defaultLocalToolEnvironmentSettings,
node: { source: 'custom', executablePath: node }
})
const updated = service.launchEnvironmentProvider()
expect(updated).not.toBe(initial)
expect(service.launchEnvironmentProvider()).toBe(updated)
expect(updated.PATH).not.toBe(initial.PATH)
expect(initial.PATH).toBe(initialPath)
expect(Object.isFrozen(updated)).toBe(true)
})
it('keeps executable paths inside the Main-owned picker callback', async () => {
const { service, settingsStore, python } = await fixture()
const snapshot = await service.selectExecutable('python')
expect(settingsStore.update).toHaveBeenCalledWith({
localToolEnvironment: {
...defaultLocalToolEnvironmentSettings,
python: { source: 'custom', executablePath: python }
}
})
expect(snapshot.settings.python).toEqual({
source: 'custom',
executablePath: python
})
})
it('rejects an invalid new custom selection without persisting it', async () => {
const { settingsStore, node, options } = await fixture()
const invalid = join(node, '..', 'invalid-node.exe')
await writeFile(invalid, '')
const customService = new LocalToolEnvironmentService(
{
...options,
selectExecutable: async () => invalid
},
{
toolEnvironment: {
platform: 'win32',
spawnProcess: fakeInspectionSpawn(invalid)
}
}
)
await expect(customService.selectExecutable('node')).rejects.toThrow(
'Selected node executable is invalid'
)
expect(settingsStore.update).not.toHaveBeenCalled()
expect((await customService.getSnapshot()).settings.node).toEqual({
source: 'managed'
})
})
it('restores persisted settings when launch environment publication fails', async () => {
const { settingsStore, node, options } = await fixture()
const blockedBinDirectory = join(node, '..', 'blocked-bin')
await writeFile(blockedBinDirectory, '')
const customService = new LocalToolEnvironmentService(
{
...options,
binDirectory: blockedBinDirectory
},
{
toolEnvironment: {
platform: 'win32',
spawnProcess: fakeInspectionSpawn()
}
}
)
await expect(
customService.updateSettings({
...defaultLocalToolEnvironmentSettings,
node: { source: 'custom', executablePath: node }
})
).rejects.toThrow()
expect(settingsStore.update).toHaveBeenLastCalledWith({
localToolEnvironment: defaultLocalToolEnvironmentSettings
})
expect((await customService.getSnapshot()).settings).toEqual(
defaultLocalToolEnvironmentSettings
)
})
it('validates pip and a disposable venv with separated bounded process calls', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-python-validation-'))
roots.push(root)
const python = join(root, 'python.exe')
const staging = join(root, 'stage')
const validationVenv = join(staging, '.validation-venv')
await Promise.all([writeFile(python, ''), mkdir(staging)])
const calls: Array<{
command: string
args: string[]
options: Parameters<LocalToolProbeSpawn>[2]
}> = []
const spawnProcess: LocalToolProbeSpawn = (command, args, options) => {
calls.push({ command, args, options })
const child = new EventEmitter() as LocalToolProbeProcess &
EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
}
child.exitCode = null
child.killed = false
child.pid = 4321
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = vi.fn(() => true)
queueMicrotask(() => {
if (args[0] === '-m' && args[1] === 'pip') {
child.stdout.emit('data', 'pip 24.3 from managed Python\n')
} else if (args[0] === '-m' && args[1] === 'venv') {
mkdirSync(validationVenv, { recursive: true })
} else {
child.stdout.emit(
'data',
JSON.stringify({
version: '3.12.8',
architecture: 'AMD64',
ssl: 'OpenSSL 3.0.15',
stdlib: join(root, 'Lib')
})
)
}
child.exitCode = 0
child.emit('close', 0)
})
return child
}
await validateManagedPython(
python,
staging,
'3.12.8',
'x64',
new AbortController().signal,
'win32',
{ platform: 'win32', spawnProcess }
)
expect(calls).toHaveLength(3)
expect(calls[0]).toMatchObject({
command: python,
args: ['-m', 'pip', '--version']
})
expect(calls[1]).toMatchObject({
command: python,
args: ['-m', 'venv', '--without-pip', validationVenv]
})
expect(calls[2]).toMatchObject({
command: join(validationVenv, 'Scripts', 'python.exe')
})
expect(calls[2]?.args.slice(0, 2)).toEqual(['-I', '-c'])
expect(calls.every(({ options }) => options.shell === false)).toBe(true)
await expect(access(validationVenv)).rejects.toMatchObject({
code: 'ENOENT'
})
})
})
@@ -0,0 +1,665 @@
import { randomUUID } from 'node:crypto'
import { mkdir, realpath, rm, stat } from 'node:fs/promises'
import { delimiter, join } from 'node:path'
import spawn from 'cross-spawn'
import type { ApplicationSettingsStore } from '../application-settings-store'
import {
localToolEnvironmentSettingsSchema,
type ArtifactDownloadSource,
type LocalToolDiagnoseTarget,
type LocalToolEnvironmentProgress,
type LocalToolEnvironmentSettings,
type LocalToolEnvironmentSnapshot,
type LocalToolKind,
type LocalToolPythonOperation
} from '../../shared/local-tool-environment-contracts'
import {
createLocalToolEnvironment,
inspectLocalToolExecutable,
type LocalToolEnvironment,
type LocalToolEnvironmentDependencies
} from './local-tool-environment'
import {
cleanupManagedPythonOperations,
installManagedPython,
removeManagedPython
} from './managed-python-install'
import {
loadPythonArtifactCatalog,
selectPythonArtifact,
type PythonArtifactArch,
type PythonArtifactPlatform
} from './python-artifact-catalog'
import { downloadPythonArtifact } from './python-artifact-download'
import { extractPythonArtifact } from './python-artifact-extract'
import type { LaunchEnvironmentProvider } from './launch-environment-provider'
import {
terminateProcessTreeAndWait,
type WaitableProcessTreeChild
} from '../agent/child-process-termination'
const candidateLimit = 40
const pathDirectoryLimit = 32
const validationOutputLimit = 8 * 1024
const validationTimeoutMs = 15_000
const validationTerminationWaitMs = 500
export type LocalToolEnvironmentServiceOptions = {
settingsStore: ApplicationSettingsStore
binDirectory: string
managedPythonRoot: string
pythonArtifactCatalogPath: string
packagedNpmCliPath: string
packagedNpxCliPath: string
selectExecutable: (
kind: LocalToolKind
) => Promise<string | undefined>
electronExecutablePath?: string
baseEnvironment?: NodeJS.ProcessEnv
platform?: PythonArtifactPlatform
arch?: PythonArtifactArch
}
export type LocalToolEnvironmentServiceDependencies = {
toolEnvironment?: LocalToolEnvironmentDependencies
transport?: typeof fetch
}
type ProgressListener = (progress: LocalToolEnvironmentProgress) => void
function pythonExecutable(
directory: string,
platform: PythonArtifactPlatform
): string {
return join(directory, 'python', platform === 'win32' ? 'python.exe' : 'bin/python3')
}
async function existingFile(path: string): Promise<string | undefined> {
try {
const canonical = await realpath(path)
return (await stat(canonical)).isFile() ? canonical : undefined
} catch {
return undefined
}
}
async function discoverCandidates(
environment: NodeJS.ProcessEnv,
platform: PythonArtifactPlatform,
savedSettings: LocalToolEnvironmentSettings,
dependencies: LocalToolEnvironmentDependencies
): Promise<LocalToolEnvironmentSnapshot['candidates']> {
const value = environment.PATH ?? environment.Path ?? environment.path ?? ''
const directories = value.split(delimiter).filter(Boolean).slice(0, pathDirectoryLimit)
const names: Readonly<Record<LocalToolKind, readonly string[]>> =
platform === 'win32'
? { node: ['node.exe'], python: ['python.exe', 'python3.exe'] }
: { node: ['node'], python: ['python3', 'python'] }
const paths: Array<{ kind: LocalToolKind; executablePath: string }> = []
for (const kind of ['node', 'python'] as const) {
const selection = savedSettings[kind]
if (selection.source === 'custom') {
paths.push({ kind, executablePath: selection.executablePath })
}
}
const seen = new Set<string>()
for (const directory of directories) {
for (const kind of ['node', 'python'] as const) {
for (const name of names[kind]) {
if (paths.length >= candidateLimit) {
break
}
const path = await existingFile(join(directory, name))
const key = path
? `${kind}:${platform === 'win32' ? path.toLocaleLowerCase() : path}`
: undefined
if (path && key && !seen.has(key)) {
seen.add(key)
paths.push({ kind, executablePath: path })
}
}
}
}
const inspected = await Promise.all(
paths.slice(0, candidateLimit).map(({ kind, executablePath }) =>
inspectLocalToolExecutable(
kind,
executablePath,
{ baseEnvironment: environment },
dependencies
)
)
)
const candidates: LocalToolEnvironmentSnapshot['candidates'] = []
const canonicalSeen = new Set<string>()
for (const candidate of inspected) {
if (!candidate) continue
const key = `${candidate.kind}:${
platform === 'win32'
? candidate.executablePath.toLocaleLowerCase()
: candidate.executablePath
}`
if (!canonicalSeen.has(key)) {
canonicalSeen.add(key)
candidates.push(candidate)
}
}
return candidates
}
type ValidationProcess = WaitableProcessTreeChild & {
stdout?: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null
stderr?: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null
}
function runValidationCommand(
command: string,
args: string[],
signal: AbortSignal,
platform: PythonArtifactPlatform,
dependencies: LocalToolEnvironmentDependencies
): Promise<string> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason)
return
}
let child: ValidationProcess
try {
child = (dependencies.spawnProcess ?? spawn)(command, args, {
detached: platform !== 'win32',
env: process.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
} catch (error) {
reject(error)
return
}
let output = ''
let outputBytes = 0
let settled = false
let terminating = false
const finish = (error?: unknown): void => {
if (settled || terminating) return
settled = true
clearTimeout(timeout)
signal.removeEventListener('abort', onAbort)
if (error !== undefined) reject(error)
else resolve(output)
}
const terminate = (error: unknown): void => {
if (settled || terminating) return
terminating = true
clearTimeout(timeout)
signal.removeEventListener('abort', onAbort)
void (
dependencies.terminateProcessTree ?? terminateProcessTreeAndWait
)(child, {
platform,
processGroup: platform !== 'win32',
signal: 'SIGKILL',
waitMs: validationTerminationWaitMs
}).then(
() => {
settled = true
reject(error)
},
() => {
settled = true
reject(error)
}
)
}
const onAbort = (): void =>
terminate(
signal.reason ??
new DOMException('Managed Python operation cancelled', 'AbortError')
)
const timeout = setTimeout(
() => terminate(new Error('Managed Python validation timed out')),
validationTimeoutMs
)
signal.addEventListener('abort', onAbort, { once: true })
const collect = (chunk: Buffer | string): void => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
outputBytes += value.byteLength
if (outputBytes > validationOutputLimit) {
terminate(new Error('Managed Python validation output exceeded its limit'))
return
}
output += value.toString('utf8')
}
child.stdout?.on('data', collect)
child.stderr?.on('data', collect)
child.once('error', (error: unknown) => finish(error))
child.once('close', (code: unknown) => {
if (!terminating) {
finish(
code === 0
? undefined
: new Error(`Managed Python validation exited with code ${String(code)}`)
)
}
})
})
}
export async function validateManagedPython(
executablePath: string,
stagingDirectory: string,
version: string,
arch: PythonArtifactArch,
signal: AbortSignal,
platform: PythonArtifactPlatform,
dependencies: LocalToolEnvironmentDependencies = {}
): Promise<void> {
const validationVenv = join(stagingDirectory, '.validation-venv')
try {
const pipOutput = await runValidationCommand(
executablePath,
['-m', 'pip', '--version'],
signal,
platform,
dependencies
)
if (!/\bpip\s+\d/u.test(pipOutput)) {
throw new Error('Managed Python pip is invalid')
}
await runValidationCommand(
executablePath,
['-m', 'venv', '--without-pip', validationVenv],
signal,
platform,
dependencies
)
const venvPython =
platform === 'win32'
? join(validationVenv, 'Scripts', 'python.exe')
: join(validationVenv, 'bin', 'python3')
const script = [
'import json, platform, ssl, sysconfig',
'print(json.dumps({"version": platform.python_version(), "architecture": platform.machine(), "ssl": ssl.OPENSSL_VERSION, "stdlib": sysconfig.get_path("stdlib")}))'
].join(';')
const output = await runValidationCommand(
venvPython,
['-I', '-c', script],
signal,
platform,
dependencies
)
const result = JSON.parse(output.trim()) as {
version?: unknown
architecture?: unknown
ssl?: unknown
stdlib?: unknown
}
const expectedMachines =
arch === 'x64' ? ['x86_64', 'amd64', 'x64'] : ['aarch64', 'arm64']
if (
result.version !== version ||
typeof result.architecture !== 'string' ||
!expectedMachines.includes(result.architecture.toLocaleLowerCase()) ||
typeof result.ssl !== 'string' ||
result.ssl.length === 0 ||
typeof result.stdlib !== 'string' ||
result.stdlib.length === 0
) {
throw new Error('Managed Python runtime validation failed')
}
} catch (error) {
if (signal.aborted) {
throw signal.reason ?? error
}
throw error
} finally {
await rm(validationVenv, { recursive: true, force: true }).catch(() => undefined)
}
}
export class LocalToolEnvironmentService {
private readonly platform: PythonArtifactPlatform
private readonly arch: PythonArtifactArch
private candidates: LocalToolEnvironmentSnapshot['candidates'] = []
private diagnostics: LocalToolEnvironmentSnapshot['diagnostics'] = {}
private operation?: LocalToolPythonOperation
private operationController?: AbortController
private installOperation?: Promise<LocalToolEnvironmentSnapshot>
private launchEnvironment: Readonly<NodeJS.ProcessEnv> = Object.freeze({})
private listeners = new Set<ProgressListener>()
readonly launchEnvironmentProvider: LaunchEnvironmentProvider = () =>
this.launchEnvironment
constructor(
private readonly options: LocalToolEnvironmentServiceOptions,
private readonly dependencies: LocalToolEnvironmentServiceDependencies = {}
) {
this.platform = options.platform ?? (process.platform as PythonArtifactPlatform)
this.arch = options.arch ?? (process.arch as PythonArtifactArch)
if (!['win32', 'darwin', 'linux'].includes(this.platform)) {
throw new Error(`Managed Python is unsupported on ${this.platform}`)
}
if (!['x64', 'arm64'].includes(this.arch)) {
throw new Error(`Managed Python is unsupported on ${this.arch}`)
}
}
onProgress(listener: ProgressListener): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private managedDirectory(version: string): string {
return join(this.options.managedPythonRoot, `python-${version}`)
}
private async installedPython(version: string): Promise<string | undefined> {
return existingFile(pythonExecutable(this.managedDirectory(version), this.platform))
}
private async settings(): Promise<LocalToolEnvironmentSettings> {
return (await this.options.settingsStore.get()).localToolEnvironment
}
private async rebuildLaunchEnvironment(
signal?: AbortSignal
): Promise<LocalToolEnvironment> {
const settings = await this.settings()
const catalog = await loadPythonArtifactCatalog(
this.options.pythonArtifactCatalogPath
)
const generationDirectory = join(
this.options.binDirectory,
`generation-${randomUUID()}`
)
try {
const environment = await createLocalToolEnvironment(
{
binDirectory: generationDirectory,
nodeSelection: settings.node,
pythonSelection: settings.python,
packagedNpmCliPath: this.options.packagedNpmCliPath,
packagedNpxCliPath: this.options.packagedNpxCliPath,
managedPythonExecutablePath: await this.installedPython(
catalog.pythonVersion
),
baseEnvironment: this.options.baseEnvironment,
...(signal ? { signal } : {})
},
{
...this.dependencies.toolEnvironment,
...(this.options.electronExecutablePath
? { electronExecutablePath: this.options.electronExecutablePath }
: {})
}
)
this.launchEnvironment = Object.freeze({ ...environment.environment })
return environment
} catch (error) {
await rm(generationDirectory, {
recursive: true,
force: true
}).catch(() => undefined)
throw error
}
}
async initialize(): Promise<void> {
await rm(this.options.binDirectory, {
recursive: true,
force: true
})
await this.rebuildLaunchEnvironment()
}
async getSnapshot(): Promise<LocalToolEnvironmentSnapshot> {
const catalog = await loadPythonArtifactCatalog(
this.options.pythonArtifactCatalogPath
)
const executablePath = await this.installedPython(catalog.pythonVersion)
return {
settings: await this.settings(),
candidates: [...this.candidates],
diagnostics: { ...this.diagnostics },
managedPython: {
version: catalog.pythonVersion,
installed: executablePath !== undefined,
...(executablePath ? { executablePath } : {}),
...(this.operation ? { operation: { ...this.operation } } : {})
}
}
}
private async emit(): Promise<void> {
const progress = { snapshot: await this.getSnapshot() }
for (const listener of this.listeners) listener(progress)
}
async updateSettings(input: unknown): Promise<LocalToolEnvironmentSnapshot> {
let settings = localToolEnvironmentSettingsSchema.parse(input)
const current = await this.settings()
for (const kind of ['node', 'python'] as const) {
const proposed = settings[kind]
const persisted = current[kind]
if (
proposed.source === 'custom' &&
(persisted.source !== 'custom' ||
persisted.executablePath !== proposed.executablePath)
) {
const candidate = await inspectLocalToolExecutable(
kind,
proposed.executablePath,
{
baseEnvironment: this.options.baseEnvironment ?? process.env
},
this.dependencies.toolEnvironment
)
if (!candidate) {
throw new Error(`Selected ${kind} executable is invalid`)
}
settings = {
...settings,
[kind]: {
source: 'custom',
executablePath: candidate.executablePath
}
}
}
}
await this.options.settingsStore.update({
localToolEnvironment: settings
})
this.diagnostics = {}
try {
await this.rebuildLaunchEnvironment()
} catch (error) {
await this.options.settingsStore.update({
localToolEnvironment: current
})
await this.rebuildLaunchEnvironment().catch(() => undefined)
throw error
}
return this.getSnapshot()
}
async refreshCandidates(): Promise<LocalToolEnvironmentSnapshot> {
this.candidates = await discoverCandidates(
this.options.baseEnvironment ?? process.env,
this.platform,
await this.settings(),
this.dependencies.toolEnvironment ?? {}
)
return this.getSnapshot()
}
async selectExecutable(kind: LocalToolKind): Promise<LocalToolEnvironmentSnapshot> {
const executablePath = await this.options.selectExecutable(kind)
if (!executablePath) return this.getSnapshot()
const current = await this.settings()
return this.updateSettings({
...current,
[kind]: { source: 'custom', executablePath }
})
}
async diagnose(target: LocalToolDiagnoseTarget): Promise<LocalToolEnvironmentSnapshot> {
const environment = await this.rebuildLaunchEnvironment(
this.operationController?.signal
)
const resolved = Object.fromEntries(
Object.entries(environment.diagnostics.tools).map(([name, diagnostic]) => [
name,
{
available: diagnostic.available,
source: diagnostic.source,
...(diagnostic.version ? { version: diagnostic.version } : {}),
...(diagnostic.executablePath
? { executablePath: diagnostic.executablePath }
: {}),
detail: diagnostic.detail
}
])
)
const names =
target === 'node'
? ['node', 'npm', 'npx']
: target === 'python'
? ['python', 'python3', 'pip']
: ['node', 'npm', 'npx', 'python', 'python3', 'pip']
this.diagnostics = {
...this.diagnostics,
...Object.fromEntries(names.map((name) => [name, resolved[name]]))
}
return this.getSnapshot()
}
private async setOperation(operation: LocalToolPythonOperation): Promise<void> {
this.operation = operation
await this.emit()
}
installPython(): Promise<LocalToolEnvironmentSnapshot> {
if (this.operationController) throw new Error('A Managed Python operation is already running')
const operation = this.runPythonInstall()
this.installOperation = operation
void operation.finally(() => {
if (this.installOperation === operation) {
this.installOperation = undefined
}
}).catch(() => undefined)
return operation
}
private async runPythonInstall(): Promise<LocalToolEnvironmentSnapshot> {
const controller = new AbortController()
this.operationController = controller
let archivePath: string | undefined
try {
const settings = await this.settings()
const source: ArtifactDownloadSource = settings.artifactDownloadSource
const catalog = await loadPythonArtifactCatalog(this.options.pythonArtifactCatalogPath)
const artifact = selectPythonArtifact({
catalog,
platform: this.platform,
arch: this.arch,
source
})
await mkdir(this.options.managedPythonRoot, { recursive: true })
await cleanupManagedPythonOperations(this.options.managedPythonRoot)
archivePath = join(
this.options.managedPythonRoot,
`.managed-python-download-${randomUUID()}`
)
await this.setOperation({
source,
phase: 'downloading',
receivedBytes: 0,
totalBytes: artifact.size
})
await downloadPythonArtifact({
artifact,
destinationPath: archivePath,
transport: this.dependencies.transport,
signal: controller.signal,
onProgress: (receivedBytes, totalBytes) => {
this.operation = {
source,
phase: 'downloading',
receivedBytes,
totalBytes
}
void this.emit().catch(() => undefined)
}
})
const downloadedArchive = archivePath
const destination = await installManagedPython({
rootDirectory: this.options.managedPythonRoot,
version: catalog.pythonVersion,
signal: controller.signal,
stage: async (stagingDirectory) => {
await this.setOperation({ source, phase: 'extracting' })
await extractPythonArtifact({
artifact,
archivePath: downloadedArchive,
destinationDirectory: join(stagingDirectory, 'python'),
signal: controller.signal
})
},
validate: async (stagingDirectory) => {
await this.setOperation({ source, phase: 'validating' })
await validateManagedPython(
pythonExecutable(stagingDirectory, this.platform),
stagingDirectory,
catalog.pythonVersion,
this.arch,
controller.signal,
this.platform,
this.dependencies.toolEnvironment
)
await this.setOperation({ source, phase: 'publishing' })
}
})
if (!(await existingFile(pythonExecutable(destination, this.platform)))) {
throw new Error('Managed Python publish did not produce an executable')
}
} finally {
if (archivePath) await rm(archivePath, { force: true }).catch(() => undefined)
this.operation = undefined
this.operationController = undefined
await this.emit()
}
return this.diagnose('python')
}
cancelPython(): boolean {
if (!this.operationController) return false
this.operationController.abort(
new DOMException('Managed Python operation cancelled', 'AbortError')
)
return true
}
async removePython(): Promise<LocalToolEnvironmentSnapshot> {
if (this.operationController) throw new Error('A Managed Python operation is running')
const catalog = await loadPythonArtifactCatalog(this.options.pythonArtifactCatalogPath)
await removeManagedPython({
rootDirectory: this.options.managedPythonRoot,
version: catalog.pythonVersion
})
this.diagnostics = {}
await this.rebuildLaunchEnvironment()
await this.emit()
return this.getSnapshot()
}
async dispose(): Promise<void> {
this.cancelPython()
await this.installOperation?.catch(() => undefined)
await rm(this.options.binDirectory, {
recursive: true,
force: true
})
}
}
@@ -0,0 +1,571 @@
import { EventEmitter } from 'node:events'
import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createLocalToolEnvironment,
inspectLocalToolExecutable,
resolveNpmCliPaths,
type LocalToolProbeProcess,
type LocalToolProbeSpawn
} from './local-tool-environment'
const temporaryDirectories: string[] = []
async function temporaryDirectory(name: string): Promise<string> {
const path = join(
tmpdir(),
`goodbuddy-${name}-${process.pid}-${Math.random().toString(16).slice(2)}`
)
await mkdir(path, { recursive: true })
temporaryDirectories.push(path)
return path
}
afterEach(async () => {
for (const directory of temporaryDirectories.splice(0)) {
const { rm } = await import('node:fs/promises')
await rm(directory, { recursive: true, force: true })
}
})
type FakeProbe = LocalToolProbeProcess & EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
}
function probeSpawn(
outputFor: (command: string, args: string[]) => {
code?: number
stderr?: string
stdout?: string
}
): LocalToolProbeSpawn {
return (command, args) => {
const child = new EventEmitter() as unknown as FakeProbe
child.exitCode = null
child.killed = false
child.pid = 1234
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = vi.fn(() => {
child.killed = true
return true
})
queueMicrotask(() => {
const result = outputFor(command, args)
if (result.stdout) {
child.stdout.emit('data', Buffer.from(result.stdout))
}
if (result.stderr) {
child.stderr.emit('data', Buffer.from(result.stderr))
}
child.exitCode = result.code ?? 0
child.emit('close', child.exitCode)
})
return child
}
}
async function fixtureFiles(root: string): Promise<{
electron: string
managedNpm: string
managedNpx: string
managedPython: string
}> {
const electron = join(root, 'Electron Runtime')
const managedNpm = join(root, 'packaged npm-cli.js')
const managedNpx = join(root, 'packaged npx-cli.js')
const managedPython = join(root, 'Managed Python')
await Promise.all(
[electron, managedNpm, managedNpx, managedPython].map((path) =>
writeFile(path, 'fixture')
)
)
await Promise.all(
[electron, managedPython].map((path) => chmod(path, 0o700))
)
return { electron, managedNpm, managedNpx, managedPython }
}
describe('local tool environment', () => {
it('creates managed shims and an immutable PATH snapshot', async () => {
const root = await temporaryDirectory("local-tools'posix")
const paths = await fixtureFiles(root)
const source = {
PATH: '/original/bin',
CUSTOM: 'kept',
ELECTRON_RUN_AS_NODE: 'inherited'
}
const spawnProcess = vi.fn(
probeSpawn((command, args) => {
if (args[0] === '-e') {
return {
stdout: '{"version":"22.14.0","architecture":"x64"}\n'
}
}
if (args[0] === '-I') {
return {
stdout: '{"version":"3.13.2","architecture":"x86_64"}\n'
}
}
if (args[0] === paths.managedNpm || args[0] === paths.managedNpx) {
return { stdout: '11.19.0\n' }
}
if (args.join(' ') === '-m pip --version') {
return { stdout: 'pip 25.0 from anywhere (python 3.13)\n' }
}
return {
stderr:
command === paths.managedPython
? 'Python 3.13.2\n'
: 'v22.14.0\n'
}
})
)
const bin = join(root, 'private bin')
const result = await createLocalToolEnvironment(
{
binDirectory: bin,
nodeSelection: { source: 'managed' },
pythonSelection: { source: 'managed' },
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
managedPythonExecutablePath: paths.managedPython,
baseEnvironment: source
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess
}
)
expect(result.environment.PATH).toBe(`${bin}:${source.PATH}`)
expect(result.environment.Path).toBeUndefined()
expect(result.environment.ELECTRON_RUN_AS_NODE).toBeUndefined()
expect(result.environment.OPENAI_API_KEY).toBeUndefined()
expect(Object.isFrozen(result.environment)).toBe(true)
expect(source.ELECTRON_RUN_AS_NODE).toBe('inherited')
for (const name of ['node', 'npm', 'npx'] as const) {
expect(result.diagnostics.tools[name]).toMatchObject({
available: true,
source: 'managed'
})
}
for (const name of ['python', 'python3', 'pip'] as const) {
expect(result.diagnostics.tools[name]).toMatchObject({
available: true,
source: 'managed'
})
}
const nodeShim = await readFile(join(bin, 'node'), 'utf8')
const npmShim = await readFile(join(bin, 'npm'), 'utf8')
const pipShim = await readFile(join(bin, 'pip'), 'utf8')
expect(nodeShim).toContain(
`ELECTRON_RUN_AS_NODE=1 exec '${paths.electron.replaceAll("'", "'\\''")}' "$@"`
)
expect(npmShim).toContain(
`npm_execpath='${paths.managedNpm.replaceAll("'", "'\\''")}'`
)
expect(pipShim).toContain(" '-m' 'pip' \"$@\"")
if (process.platform !== 'win32') {
expect((await stat(join(bin, 'node'))).mode & 0o700).toBe(0o700)
}
expect(
spawnProcess.mock.calls.find(([command]) => command === paths.electron)?.[2]
.env.ELECTRON_RUN_AS_NODE
).toBe('1')
})
it('keeps invalid custom selections unavailable without managed or PATH fallback', async () => {
const root = await temporaryDirectory('local-tools-no-fallback')
const paths = await fixtureFiles(root)
const spawnProcess = vi.fn(probeSpawn(() => ({ stdout: 'unexpected' })))
const missingNode = join(root, 'missing-node')
const missingPython = join(root, 'missing-python')
const result = await createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: {
source: 'custom',
executablePath: missingNode
},
pythonSelection: {
source: 'custom',
executablePath: missingPython
},
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
managedPythonExecutablePath: paths.managedPython,
baseEnvironment: { PATH: dirnameForPath(paths.electron) }
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess
}
)
expect(spawnProcess).not.toHaveBeenCalled()
for (const diagnostic of Object.values(result.diagnostics.tools)) {
expect(diagnostic).toMatchObject({
available: false,
source: 'custom'
})
}
expect(result.diagnostics.tools.node.executablePath).toBe(missingNode)
expect(result.diagnostics.tools.python.executablePath).toBe(missingPython)
})
it('does not replace custom runtimes that fail their probes', async () => {
const root = await temporaryDirectory('local-tools-probe-failure')
const paths = await fixtureFiles(root)
const customNode = join(root, 'custom-node')
const customPython = join(root, 'custom-python')
await Promise.all(
[customNode, customPython].map((path) => writeFile(path, 'fixture'))
)
const spawnProcess = vi.fn(
probeSpawn(() => ({ code: 1, stderr: 'probe failed' }))
)
const result = await createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: {
source: 'custom',
executablePath: customNode
},
pythonSelection: {
source: 'custom',
executablePath: customPython
},
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
managedPythonExecutablePath: paths.managedPython
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess
}
)
expect(spawnProcess).toHaveBeenCalledTimes(2)
expect(spawnProcess.mock.calls.map(([command]) => command).sort()).toEqual(
[customNode, customPython].sort()
)
expect(result.diagnostics.tools.node).toMatchObject({
available: false,
source: 'custom',
executablePath: customNode
})
expect(result.diagnostics.tools.python).toMatchObject({
available: false,
source: 'custom',
executablePath: customPython
})
})
it('does not mix packaged npm or npx into a custom Node selection', async () => {
const root = await temporaryDirectory('local-tools-no-mixing')
const paths = await fixtureFiles(root)
const customDirectory = join(root, 'custom-node')
await mkdir(customDirectory)
const customNode = join(customDirectory, 'node')
await writeFile(customNode, 'fixture')
const spawnProcess = vi.fn(
probeSpawn((command) => ({
stdout:
command === customNode
? '{"version":"20.18.1","architecture":"x64"}\n'
: 'unexpected\n'
}))
)
const result = await createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: {
source: 'custom',
executablePath: customNode
},
pythonSelection: { source: 'managed' },
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
baseEnvironment: { PATH: root }
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess
}
)
expect(result.diagnostics.tools.node).toMatchObject({
available: true,
source: 'custom',
executablePath: customNode
})
expect(result.diagnostics.tools.npm).toMatchObject({
available: false,
source: 'custom'
})
expect(result.diagnostics.tools.npx).toMatchObject({
available: false,
source: 'custom'
})
expect(spawnProcess).toHaveBeenCalledTimes(1)
expect(spawnProcess).not.toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([paths.managedNpm]),
expect.anything()
)
})
it('uses only verified adjacent package managers with custom Node', async () => {
const root = await temporaryDirectory('local-tools-custom-adjacent')
const paths = await fixtureFiles(root)
const customDirectory = join(root, 'custom-node')
await mkdir(customDirectory)
const customNode = join(customDirectory, 'node')
const adjacentNpm = join(customDirectory, 'npm-cli.js')
const adjacentNpx = join(customDirectory, 'npx-cli.js')
await Promise.all(
[customNode, adjacentNpm, adjacentNpx].map((path) =>
writeFile(path, 'fixture')
)
)
const result = await createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: {
source: 'custom',
executablePath: customNode
},
pythonSelection: { source: 'managed' },
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess: probeSpawn((_command, args) => ({
stdout:
args[0] === adjacentNpm || args[0] === adjacentNpx
? '10.8.0\n'
: '{"version":"20.18.1","architecture":"x64"}\n'
}))
}
)
expect(result.diagnostics.tools.npm).toMatchObject({
available: true,
source: 'custom',
executablePath: adjacentNpm
})
expect(result.diagnostics.tools.npx).toMatchObject({
available: true,
source: 'custom',
executablePath: adjacentNpx
})
expect(await readFile(join(root, 'bin', 'npm'), 'utf8')).toContain(
`'${adjacentNpm}'`
)
})
it('leaves an uninstalled managed Python unavailable without PATH probing', async () => {
const root = await temporaryDirectory('local-tools-python-uninstalled')
const paths = await fixtureFiles(root)
const spawnProcess = vi.fn(
probeSpawn((command, args) => ({
stdout:
command === paths.electron && args[0] === '-e'
? '{"version":"22.14.0","architecture":"x64"}\n'
: '11.19.0\n'
}))
)
const result = await createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: { source: 'managed' },
pythonSelection: { source: 'managed' },
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
baseEnvironment: { PATH: dirnameForPath(paths.managedPython) }
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess
}
)
for (const name of ['python', 'python3', 'pip'] as const) {
expect(result.diagnostics.tools[name]).toMatchObject({
available: false,
source: 'managed'
})
}
expect(spawnProcess.mock.calls.every(([command]) => command !== paths.managedPython)).toBe(true)
})
it('writes Windows wrappers with wrapper-local Electron variables', async () => {
const root = await temporaryDirectory('local-tools-%-win')
const paths = await fixtureFiles(root)
const result = await createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: { source: 'managed' },
pythonSelection: {
source: 'custom',
executablePath: paths.managedPython
},
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
baseEnvironment: { Path: 'C:\\Windows\\System32' }
},
{
platform: 'win32',
electronExecutablePath: paths.electron,
spawnProcess: probeSpawn((command, args) => ({
stdout:
command === paths.managedPython
? args.includes('pip')
? 'pip 25.0\n'
: '{"version":"3.13.2","architecture":"amd64"}\n'
: args[0] === '-e'
? '{"version":"22.14.0","architecture":"x64"}\n'
: '11.19.0\n'
}))
}
)
const nodeShim = await readFile(join(root, 'bin', 'node.cmd'), 'utf8')
const npmShim = await readFile(join(root, 'bin', 'npm.cmd'), 'utf8')
expect(nodeShim).toContain('set "ELECTRON_RUN_AS_NODE=1"')
expect(npmShim).toContain(
`set "npm_node_execpath=${paths.electron.replaceAll('%', '%%')}"`
)
expect(result.environment.PATH).toBe(
`${join(root, 'bin')};C:\\Windows\\System32`
)
})
it('cancels probes through process-tree cleanup', async () => {
const root = await temporaryDirectory('local-tools-cancel')
const paths = await fixtureFiles(root)
const terminate = vi.fn(async (child: LocalToolProbeProcess) => {
child.exitCode = 1
})
const hangingSpawn = vi.fn<LocalToolProbeSpawn>(() => {
const child = new EventEmitter() as unknown as FakeProbe
child.exitCode = null
child.killed = false
child.pid = 4321
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = vi.fn()
return child
})
const controller = new AbortController()
const operation = createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: { source: 'managed' },
pythonSelection: { source: 'managed' },
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
managedPythonExecutablePath: paths.managedPython,
signal: controller.signal,
probeTimeoutMs: 60_000
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess: hangingSpawn,
terminateProcessTree: terminate
}
)
await vi.waitFor(() => expect(hangingSpawn).toHaveBeenCalledTimes(2))
controller.abort(new Error('cancelled by test'))
await expect(operation).rejects.toThrow('cancelled by test')
expect(terminate).toHaveBeenCalledTimes(2)
})
it('inspects executed runtime metadata and rejects Python 2', async () => {
const root = await temporaryDirectory('local-tools-inspection')
const executable = join(root, 'runtime')
await writeFile(executable, 'fixture')
await expect(
inspectLocalToolExecutable(
'node',
executable,
{ baseEnvironment: { PATH: root, NODE_OPTIONS: '--inspect' } },
{
platform: 'linux',
spawnProcess: probeSpawn((_command, args) => ({
stdout: args.includes('-e')
? '{"version":"22.14.0","architecture":"arm64"}\n'
: ''
}))
}
)
).resolves.toEqual({
kind: 'node',
executablePath: await import('node:fs/promises').then(({ realpath }) =>
realpath(executable)
),
version: '22.14.0',
architecture: 'arm64'
})
await expect(
inspectLocalToolExecutable(
'python',
executable,
{ baseEnvironment: { PATH: root } },
{
platform: 'linux',
spawnProcess: probeSpawn(() => ({
stdout: '{"version":"2.7.18","architecture":"x86_64"}\n'
}))
}
)
).resolves.toBeUndefined()
})
it('resolves development and packaged npm CLI locations', () => {
expect(
resolveNpmCliPaths({
appPath: '/app',
resourcesPath: '/resources',
packaged: false
})
).toEqual({
npmCliPath: join('/app', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
npxCliPath: join('/app', 'node_modules', 'npm', 'bin', 'npx-cli.js')
})
expect(
resolveNpmCliPaths({
appPath: '/app',
resourcesPath: '/resources',
packaged: true
})
).toEqual({
npmCliPath: join('/resources', 'runtimes', 'npm', 'bin', 'npm-cli.js'),
npxCliPath: join('/resources', 'runtimes', 'npm', 'bin', 'npx-cli.js')
})
})
})
function dirnameForPath(path: string): string {
return path.slice(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')))
}
@@ -0,0 +1,815 @@
import { chmod, mkdir, realpath, stat, unlink, writeFile } from 'node:fs/promises'
import { dirname, isAbsolute, join } from 'node:path'
import spawn from 'cross-spawn'
import type {
LocalToolCandidate,
LocalToolKind,
LocalToolRuntimeSelection
} from '../../shared/local-tool-environment-contracts'
import {
terminateProcessTreeAndWait,
type WaitableProcessTreeChild
} from '../agent/child-process-termination'
const DEFAULT_PROBE_TIMEOUT_MS = 3_000
const DEFAULT_PROBE_OUTPUT_LIMIT = 8 * 1024
const DEFAULT_TERMINATION_WAIT_MS = 500
export type LocalToolName =
| 'node'
| 'npm'
| 'npx'
| 'python'
| 'python3'
| 'pip'
export type LocalToolSource = LocalToolRuntimeSelection['source']
export type LocalToolDiagnostic = {
available: boolean
source: LocalToolSource
version?: string
executablePath?: string
shimPath?: string
detail: string
}
export type LocalToolEnvironmentDiagnostics = {
binDirectory: string
tools: Readonly<Record<LocalToolName, LocalToolDiagnostic>>
warnings: readonly string[]
}
export type LocalToolEnvironment = {
binDirectory: string
environment: Readonly<NodeJS.ProcessEnv>
diagnostics: LocalToolEnvironmentDiagnostics
}
type ProbeOutput = {
on(event: 'data', listener: (chunk: Buffer | string) => void): unknown
}
export type LocalToolProbeProcess = WaitableProcessTreeChild & {
stdout?: ProbeOutput | null
stderr?: ProbeOutput | null
}
export type LocalToolProbeSpawn = (
command: string,
args: string[],
options: {
detached: boolean
env: NodeJS.ProcessEnv
shell: false
stdio: ['ignore', 'pipe', 'pipe']
windowsHide: true
}
) => LocalToolProbeProcess
export type LocalToolEnvironmentOptions = {
binDirectory: string
nodeSelection: LocalToolRuntimeSelection
pythonSelection: LocalToolRuntimeSelection
packagedNpmCliPath: string
packagedNpxCliPath: string
managedPythonExecutablePath?: string
baseEnvironment?: NodeJS.ProcessEnv
signal?: AbortSignal
probeTimeoutMs?: number
probeOutputLimit?: number
terminationWaitMs?: number
}
export type LocalToolEnvironmentDependencies = {
platform?: NodeJS.Platform
electronExecutablePath?: string
spawnProcess?: LocalToolProbeSpawn
terminateProcessTree?: typeof terminateProcessTreeAndWait
}
type ProbeResult =
| { ok: true; output: string }
| {
ok: false
reason: 'cancelled' | 'exit' | 'output-limit' | 'spawn' | 'timeout'
}
type ResolvedRuntime = {
path: string
source: LocalToolSource
managedElectron?: boolean
version?: string
}
type PackageManagerInvocation = {
command: string
argsPrefix: readonly string[]
path: string
source: LocalToolSource
managedElectron?: boolean
version?: string
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`
}
function quoteCmd(value: string): string {
return `"${value.replaceAll('%', '%%')}"`
}
function stripUnsafeCharacters(value: string): string {
let result = ''
let inEscapeSequence = false
for (const character of value) {
const point = character.codePointAt(0) ?? 0
if (inEscapeSequence) {
if (point >= 64 && point <= 126) {
inEscapeSequence = false
}
continue
}
if (point === 27) {
inEscapeSequence = true
} else if (point >= 32 && point !== 127) {
result += character
}
}
return result
}
function safeVersion(output: string): string | undefined {
const firstLine = output
.split(/\r?\n/u)
.map((line) => stripUnsafeCharacters(line).trim())
.find(Boolean)
if (!firstLine) {
return undefined
}
const version = firstLine.match(
/\bv?(\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)\b/u
)
return (version?.[1] ?? firstLine).slice(0, 160)
}
function abortError(signal: AbortSignal): unknown {
return signal.reason ?? new DOMException('Operation aborted', 'AbortError')
}
async function canonicalFile(filePath: string): Promise<string | undefined> {
if (!isAbsolute(filePath)) {
return undefined
}
try {
const path = await realpath(filePath)
return (await stat(path)).isFile() ? path : undefined
} catch {
return undefined
}
}
function probeExecutable(
command: string,
args: string[],
environment: NodeJS.ProcessEnv,
signal: AbortSignal | undefined,
dependencies: LocalToolEnvironmentDependencies,
options: LocalToolEnvironmentOptions
): Promise<ProbeResult> {
return new Promise((resolve) => {
if (signal?.aborted) {
resolve({ ok: false, reason: 'cancelled' })
return
}
const platform = dependencies.platform ?? process.platform
let child: LocalToolProbeProcess
try {
child = (dependencies.spawnProcess ?? spawn)(command, args, {
detached: platform !== 'win32',
env: environment,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
} catch {
resolve({ ok: false, reason: 'spawn' })
return
}
let settled = false
let cleaning = false
let output = ''
let outputBytes = 0
const finish = (result: ProbeResult): void => {
if (settled || cleaning) {
return
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', onAbort)
resolve(result)
}
const failWithCleanup = (
reason: Extract<ProbeResult, { ok: false }>['reason']
): void => {
if (settled || cleaning) {
return
}
cleaning = true
clearTimeout(timeout)
signal?.removeEventListener('abort', onAbort)
void (
dependencies.terminateProcessTree ?? terminateProcessTreeAndWait
)(child, {
platform,
processGroup: platform !== 'win32',
signal: 'SIGKILL',
waitMs: options.terminationWaitMs ?? DEFAULT_TERMINATION_WAIT_MS
}).then(
() => {
settled = true
resolve({ ok: false, reason })
},
() => {
settled = true
resolve({ ok: false, reason })
}
)
}
const onAbort = (): void => failWithCleanup('cancelled')
const timeout = setTimeout(
() => failWithCleanup('timeout'),
options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS
)
signal?.addEventListener('abort', onAbort, { once: true })
const collect = (chunk: Buffer | string): void => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
outputBytes += value.byteLength
if (outputBytes > (options.probeOutputLimit ?? DEFAULT_PROBE_OUTPUT_LIMIT)) {
failWithCleanup('output-limit')
return
}
output += value.toString('utf8')
}
child.stdout?.on('data', collect)
child.stderr?.on('data', collect)
child.once('error', () => finish({ ok: false, reason: 'spawn' }))
child.once('close', (code: unknown) => {
if (cleaning) {
return
}
finish(
code === 0
? { ok: true, output }
: { ok: false, reason: 'exit' }
)
})
})
}
export type LocalToolExecutableInspectionOptions = {
baseEnvironment: NodeJS.ProcessEnv
managedElectron?: boolean
signal?: AbortSignal
probeTimeoutMs?: number
probeOutputLimit?: number
terminationWaitMs?: number
}
export async function inspectLocalToolExecutable(
kind: LocalToolKind,
executablePath: string,
options: LocalToolExecutableInspectionOptions,
dependencies: LocalToolEnvironmentDependencies = {}
): Promise<LocalToolCandidate | undefined> {
const path = await canonicalFile(executablePath)
if (!path) {
return undefined
}
const script =
kind === 'node'
? 'console.log(JSON.stringify({version:process.versions.node,architecture:process.arch}))'
: [
'import json, platform, sys',
'print(json.dumps({"version": platform.python_version(), "architecture": platform.machine()}))'
].join(';')
const probe = await probeExecutable(
path,
kind === 'node' ? ['-e', script] : ['-I', '-c', script],
runtimeProbeEnvironment(
options.baseEnvironment,
options.managedElectron === true
),
options.signal,
dependencies,
{
binDirectory: dirname(path),
nodeSelection: { source: 'managed' },
pythonSelection: { source: 'managed' },
packagedNpmCliPath: path,
packagedNpxCliPath: path,
...options
}
)
if (!probe.ok) {
if (probe.reason === 'cancelled') {
throw abortError(options.signal!)
}
return undefined
}
try {
const parsed = JSON.parse(probe.output.trim()) as {
version?: unknown
architecture?: unknown
}
if (
typeof parsed.version !== 'string' ||
!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(parsed.version) ||
(kind === 'python' && !parsed.version.startsWith('3.')) ||
typeof parsed.architecture !== 'string'
) {
return undefined
}
const architecture = parsed.architecture.trim().toLocaleLowerCase()
if (!/^[0-9a-z][0-9a-z._-]{0,63}$/u.test(architecture)) {
return undefined
}
return {
kind,
executablePath: path,
version: parsed.version,
architecture
}
} catch {
return undefined
}
}
function runtimeProbeEnvironment(
base: NodeJS.ProcessEnv,
managedElectron: boolean
): NodeJS.ProcessEnv {
const environment = { ...base }
for (const key of Object.keys(environment)) {
if (
[
'electron_run_as_node',
'node_options',
'node_path',
'pythonhome',
'pythoninspect',
'pythonpath',
'pythonstartup',
'pythonwarnings'
].includes(key.toLocaleLowerCase())
) {
delete environment[key]
}
}
if (managedElectron) {
environment.ELECTRON_RUN_AS_NODE = '1'
}
return environment
}
async function resolveRuntime(
kind: LocalToolKind,
selection: LocalToolRuntimeSelection,
managedExecutablePath: string | undefined,
managedElectron: boolean,
options: LocalToolEnvironmentOptions,
dependencies: LocalToolEnvironmentDependencies,
base: NodeJS.ProcessEnv
): Promise<ResolvedRuntime | undefined> {
const selectedPath =
selection.source === 'custom'
? selection.executablePath
: managedExecutablePath
if (!selectedPath) {
return undefined
}
const inspected = await inspectLocalToolExecutable(
kind,
selectedPath,
{
baseEnvironment: base,
managedElectron,
...(options.signal ? { signal: options.signal } : {}),
...(options.probeTimeoutMs
? { probeTimeoutMs: options.probeTimeoutMs }
: {}),
...(options.probeOutputLimit
? { probeOutputLimit: options.probeOutputLimit }
: {}),
...(options.terminationWaitMs
? { terminationWaitMs: options.terminationWaitMs }
: {})
},
dependencies
)
if (!inspected) {
return undefined
}
return {
path: inspected.executablePath,
source: selection.source,
managedElectron,
version: inspected.version
}
}
function adjacentPackageManagerNames(
name: 'npm' | 'npx',
platform: NodeJS.Platform
): readonly string[] {
return platform === 'win32'
? [`${name}.cmd`, `${name}.exe`, `${name}-cli.js`]
: [name, `${name}-cli.js`]
}
async function resolvePackageManager(
name: 'npm' | 'npx',
node: ResolvedRuntime,
packagedCliPath: string,
options: LocalToolEnvironmentOptions,
dependencies: LocalToolEnvironmentDependencies,
base: NodeJS.ProcessEnv
): Promise<PackageManagerInvocation | undefined> {
const platform = dependencies.platform ?? process.platform
const candidates =
node.source === 'managed'
? [{ path: packagedCliPath, cli: true }]
: adjacentPackageManagerNames(name, platform).map((fileName) => ({
path: join(dirname(node.path), fileName),
cli: fileName.endsWith('-cli.js')
}))
for (const candidate of candidates) {
const path = await canonicalFile(candidate.path)
if (!path) {
continue
}
const command = candidate.cli ? node.path : path
const argsPrefix = candidate.cli ? [path] : []
const probe = await probeExecutable(
command,
[...argsPrefix, '--version'],
runtimeProbeEnvironment(base, node.managedElectron === true),
options.signal,
dependencies,
options
)
if (!probe.ok) {
if (probe.reason === 'cancelled') {
throw abortError(options.signal!)
}
continue
}
return {
command,
argsPrefix,
path,
source: node.source,
managedElectron: node.managedElectron,
version: safeVersion(probe.output)
}
}
return undefined
}
function shimFileName(name: LocalToolName, platform: NodeJS.Platform): string {
return platform === 'win32' ? `${name}.cmd` : name
}
function commandLines(input: {
platform: NodeJS.Platform
command: string
argsPrefix?: readonly string[]
managedElectron?: boolean
packageManagerPath?: string
nodePath?: string
}): string {
const prefix = input.argsPrefix ?? []
if (input.platform === 'win32') {
const lines = ['@echo off', 'setlocal']
if (input.managedElectron) {
lines.push('set "ELECTRON_RUN_AS_NODE=1"')
}
if (input.packageManagerPath && input.nodePath) {
lines.push(
`set "npm_execpath=${input.packageManagerPath.replaceAll('%', '%%')}"`
)
lines.push(
`set "npm_node_execpath=${input.nodePath.replaceAll('%', '%%')}"`
)
}
lines.push(
[quoteCmd(input.command), ...prefix.map(quoteCmd), '%*'].join(' ')
)
return `${lines.join('\r\n')}\r\n`
}
const assignments: string[] = []
if (input.managedElectron) {
assignments.push('ELECTRON_RUN_AS_NODE=1')
}
if (input.packageManagerPath && input.nodePath) {
assignments.push(`npm_execpath=${quotePosix(input.packageManagerPath)}`)
assignments.push(`npm_node_execpath=${quotePosix(input.nodePath)}`)
}
return [
'#!/bin/sh',
`${assignments.length ? `${assignments.join(' ')} ` : ''}exec ${quotePosix(
input.command
)}${prefix.length ? ` ${prefix.map(quotePosix).join(' ')}` : ''} "$@"`,
''
].join('\n')
}
async function writeShim(
path: string,
content: string,
platform: NodeJS.Platform
): Promise<void> {
await writeFile(path, content, { encoding: 'utf8', mode: 0o700 })
if (platform !== 'win32') {
await chmod(path, 0o700)
}
}
function unavailable(
source: LocalToolSource,
detail: string,
executablePath?: string
): LocalToolDiagnostic {
return { available: false, source, executablePath, detail }
}
function available(
source: LocalToolSource,
executablePath: string,
version: string | undefined,
shimPath: string
): LocalToolDiagnostic {
return {
available: true,
source,
version,
executablePath,
shimPath,
detail: `${source} tool is ready${version ? ` (${version})` : ''}.`
}
}
function selectedPath(
selection: LocalToolRuntimeSelection,
managedPath: string | undefined
): string | undefined {
return selection.source === 'custom'
? selection.executablePath
: managedPath
}
function launchEnvironmentSnapshot(
binDirectory: string,
source: NodeJS.ProcessEnv,
platform: NodeJS.Platform
): Readonly<NodeJS.ProcessEnv> {
const inheritedPath =
source.PATH ?? source.Path ?? source.path ?? ''
const path = inheritedPath
? `${binDirectory}${platform === 'win32' ? ';' : ':'}${inheritedPath}`
: binDirectory
return Object.freeze({ PATH: path })
}
export function resolveNpmCliPaths(input: {
appPath: string
resourcesPath: string
packaged: boolean
}): { npmCliPath: string; npxCliPath: string } {
const bin = input.packaged
? join(input.resourcesPath, 'runtimes', 'npm', 'bin')
: join(input.appPath, 'node_modules', 'npm', 'bin')
return {
npmCliPath: join(bin, 'npm-cli.js'),
npxCliPath: join(bin, 'npx-cli.js')
}
}
export async function createLocalToolEnvironment(
options: LocalToolEnvironmentOptions,
dependencies: LocalToolEnvironmentDependencies = {}
): Promise<LocalToolEnvironment> {
if (!isAbsolute(options.binDirectory)) {
throw new Error('Local tool bin directory must be absolute.')
}
options.signal?.throwIfAborted()
const platform = dependencies.platform ?? process.platform
const base = { ...(options.baseEnvironment ?? process.env) }
delete base.ELECTRON_RUN_AS_NODE
const warnings: string[] = []
await mkdir(options.binDirectory, { recursive: true, mode: 0o700 })
if (platform !== 'win32') {
await chmod(options.binDirectory, 0o700)
}
const shimPaths = Object.fromEntries(
(['node', 'npm', 'npx', 'python', 'python3', 'pip'] as const).map(
(name) => [name, join(options.binDirectory, shimFileName(name, platform))]
)
) as Record<LocalToolName, string>
await Promise.all(
Object.values(shimPaths).map((path) =>
unlink(path).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') {
throw error
}
})
)
)
const managedNodePath =
dependencies.electronExecutablePath ?? process.execPath
const [node, python] = await Promise.all([
resolveRuntime(
'node',
options.nodeSelection,
managedNodePath,
options.nodeSelection.source === 'managed',
options,
dependencies,
base
),
resolveRuntime(
'python',
options.pythonSelection,
options.managedPythonExecutablePath,
false,
options,
dependencies,
base
)
])
options.signal?.throwIfAborted()
const nodeSelectedPath = selectedPath(
options.nodeSelection,
managedNodePath
)
const pythonSelectedPath = selectedPath(
options.pythonSelection,
options.managedPythonExecutablePath
)
const diagnostics: Record<LocalToolName, LocalToolDiagnostic> = {
node: unavailable(
options.nodeSelection.source,
'The selected Node runtime is unavailable or failed its version probe.',
nodeSelectedPath
),
npm: unavailable(
options.nodeSelection.source,
'No verified npm from the selected Node installation is available.'
),
npx: unavailable(
options.nodeSelection.source,
'No verified npx from the selected Node installation is available.'
),
python: unavailable(
options.pythonSelection.source,
'The selected Python runtime is not installed, invalid, or failed its version probe.',
pythonSelectedPath
),
python3: unavailable(
options.pythonSelection.source,
'The selected Python runtime is not installed, invalid, or failed its version probe.',
pythonSelectedPath
),
pip: unavailable(
options.pythonSelection.source,
'The selected Python runtime does not provide a verified pip module.',
pythonSelectedPath
)
}
if (node) {
await writeShim(
shimPaths.node,
commandLines({
platform,
command: node.path,
managedElectron: node.managedElectron
}),
platform
)
diagnostics.node = available(
node.source,
node.path,
node.version,
shimPaths.node
)
const [npm, npx] = await Promise.all([
resolvePackageManager(
'npm',
node,
options.packagedNpmCliPath,
options,
dependencies,
base
),
resolvePackageManager(
'npx',
node,
options.packagedNpxCliPath,
options,
dependencies,
base
)
])
options.signal?.throwIfAborted()
for (const [name, manager] of [
['npm', npm],
['npx', npx]
] as const) {
if (!manager) {
continue
}
await writeShim(
shimPaths[name],
commandLines({
platform,
command: manager.command,
argsPrefix: manager.argsPrefix,
managedElectron: manager.managedElectron,
packageManagerPath: manager.path,
nodePath: node.path
}),
platform
)
diagnostics[name] = available(
manager.source,
manager.path,
manager.version,
shimPaths[name]
)
}
}
if (python) {
for (const name of ['python', 'python3'] as const) {
await writeShim(
shimPaths[name],
commandLines({ platform, command: python.path }),
platform
)
diagnostics[name] = available(
python.source,
python.path,
python.version,
shimPaths[name]
)
}
const pipProbe = await probeExecutable(
python.path,
['-m', 'pip', '--version'],
base,
options.signal,
dependencies,
options
)
options.signal?.throwIfAborted()
if (pipProbe.ok) {
await writeShim(
shimPaths.pip,
commandLines({
platform,
command: python.path,
argsPrefix: ['-m', 'pip']
}),
platform
)
diagnostics.pip = available(
python.source,
python.path,
safeVersion(pipProbe.output),
shimPaths.pip
)
}
}
return {
binDirectory: options.binDirectory,
environment: launchEnvironmentSnapshot(
options.binDirectory,
base,
platform
),
diagnostics: {
binDirectory: options.binDirectory,
tools: Object.freeze(diagnostics),
warnings: Object.freeze(warnings)
}
}
}
@@ -0,0 +1,87 @@
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 } from 'vitest'
import {
installManagedPython,
removeManagedPython
} from './managed-python-install'
const temporaryDirectories: string[] = []
async function root(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-python-install-'))
temporaryDirectories.push(directory)
return join(directory, 'managed')
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
))
})
describe('Managed Python staged operations', () => {
it('publishes only after validation succeeds', async () => {
const rootDirectory = await root()
const installed = await installManagedPython({
rootDirectory,
version: '3.13.15',
stage: async (directory) => {
await writeFile(join(directory, 'python'), 'new')
},
validate: async (directory) => {
expect(await readFile(join(directory, 'python'), 'utf8')).toBe('new')
}
})
expect(await readFile(join(installed, 'python'), 'utf8')).toBe('new')
})
it('preserves an old same-version install after failed validation', async () => {
const rootDirectory = await root()
await installManagedPython({
rootDirectory,
version: '3.13.15',
stage: (directory) => writeFile(join(directory, 'python'), 'old'),
validate: async () => undefined
})
await expect(installManagedPython({
rootDirectory,
version: '3.13.15',
stage: (directory) => writeFile(join(directory, 'python'), 'bad'),
validate: async () => {
throw new Error('probe failed')
}
})).rejects.toThrow('probe failed')
expect(await readFile(
join(rootDirectory, 'python-3.13.15', 'python'), 'utf8'
)).toBe('old')
})
it('refuses to adopt or remove content outside an owned root', async () => {
const rootDirectory = await root()
await mkdir(rootDirectory)
await writeFile(join(rootDirectory, 'unrelated'), 'keep')
await expect(installManagedPython({
rootDirectory,
version: '3.13.15',
stage: async () => undefined,
validate: async () => undefined
})).rejects.toThrow(/non-empty/u)
expect(await readFile(join(rootDirectory, 'unrelated'), 'utf8')).toBe('keep')
})
it('removes only a selected managed version', async () => {
const rootDirectory = await root()
await installManagedPython({
rootDirectory,
version: '3.13.15',
stage: (directory) => writeFile(join(directory, 'python'), 'ok'),
validate: async () => undefined
})
await removeManagedPython({ rootDirectory, version: '3.13.15' })
await expect(readFile(
join(rootDirectory, 'python-3.13.15', 'python')
)).rejects.toMatchObject({ code: 'ENOENT' })
})
})
@@ -0,0 +1,182 @@
import { randomUUID } from 'node:crypto'
import {
lstat,
mkdir,
open,
readdir,
readFile,
rename,
rm
} from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
const OWNER_MARKER = '.goodbuddy-managed-python'
const OWNER_VALUE = 'GoodBuddy managed Python root\n'
const versionPattern = /^python-(\d+\.\d+\.\d+)$/u
const stagingPattern = /^\.managed-python-stage-[0-9a-f-]{36}$/u
const backupPattern = /^\.managed-python-backup-[0-9a-f-]{36}$/u
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted', 'AbortError')
}
}
function child(root: string, name: string): string {
const path = resolve(root, name)
if (dirname(path) !== resolve(root)) {
throw new Error('Managed Python path escaped its owned root')
}
return path
}
function versionName(version: string): string {
if (!/^\d+\.\d+\.\d+$/u.test(version)) {
throw new Error('Managed Python version is invalid')
}
return `python-${version}`
}
async function ensureOwnedRoot(root: string): Promise<void> {
await mkdir(root, { recursive: true })
const marker = child(root, OWNER_MARKER)
try {
const value = await readFile(marker, 'utf8')
if (value !== OWNER_VALUE) {
throw new Error('Managed Python root has an invalid ownership marker')
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
const entries = await readdir(root)
if (entries.length !== 0) {
throw new Error('Refusing to adopt a non-empty Managed Python root', {
cause: error
})
}
const handle = await open(marker, 'wx')
try {
await handle.writeFile(OWNER_VALUE)
await handle.sync()
} finally {
await handle.close()
}
}
}
export async function cleanupManagedPythonOperations(root: string): Promise<void> {
await ensureOwnedRoot(root)
for (const entry of await readdir(root, { withFileTypes: true })) {
if (
(stagingPattern.test(entry.name) || backupPattern.test(entry.name)) &&
entry.isDirectory() &&
!entry.isSymbolicLink()
) {
await rm(child(root, entry.name), { recursive: true, force: true })
}
}
}
async function pruneVersions(
root: string,
activeName: string,
keepVersions: number
): Promise<void> {
const candidates: { name: string; mtimeMs: number }[] = []
for (const entry of await readdir(root, { withFileTypes: true })) {
if (
entry.name !== activeName &&
versionPattern.test(entry.name) &&
entry.isDirectory() &&
!entry.isSymbolicLink()
) {
const status = await lstat(child(root, entry.name))
candidates.push({ name: entry.name, mtimeMs: status.mtimeMs })
}
}
candidates.sort((left, right) => right.mtimeMs - left.mtimeMs)
for (const candidate of candidates.slice(Math.max(0, keepVersions - 1))) {
await rm(child(root, candidate.name), { recursive: true, force: true })
}
}
export async function installManagedPython(options: {
rootDirectory: string
version: string
stage: (stagingDirectory: string) => Promise<void>
validate: (stagingDirectory: string) => Promise<void>
signal?: AbortSignal
keepVersions?: number
}): Promise<string> {
const root = resolve(options.rootDirectory)
const keepVersions = options.keepVersions ?? 1
if (!Number.isInteger(keepVersions) || keepVersions < 1 || keepVersions > 3) {
throw new Error('Managed Python retained version count must be between 1 and 3')
}
await ensureOwnedRoot(root)
ensureNotAborted(options.signal)
const destinationName = versionName(options.version)
const destination = child(root, destinationName)
const staging = child(root, `.managed-python-stage-${randomUUID()}`)
const backup = child(root, `.managed-python-backup-${randomUUID()}`)
await mkdir(staging)
let movedExisting = false
try {
await options.stage(staging)
ensureNotAborted(options.signal)
await options.validate(staging)
ensureNotAborted(options.signal)
try {
const status = await lstat(destination)
if (!status.isDirectory() || status.isSymbolicLink()) {
throw new Error('Managed Python destination is not an owned directory')
}
await rename(destination, backup)
movedExisting = true
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
}
try {
await rename(staging, destination)
} catch (error) {
if (movedExisting) {
await rename(backup, destination).catch(() => undefined)
}
throw error
}
if (movedExisting) {
await rm(backup, { recursive: true, force: true })
}
await pruneVersions(root, destinationName, keepVersions)
return destination
} catch (error) {
await rm(staging, { recursive: true, force: true }).catch(() => undefined)
throw error
}
}
export async function removeManagedPython(options: {
rootDirectory: string
version: string
}): Promise<void> {
const root = resolve(options.rootDirectory)
await ensureOwnedRoot(root)
const destination = child(root, versionName(options.version))
try {
const status = await lstat(destination)
if (!status.isDirectory() || status.isSymbolicLink()) {
throw new Error('Refusing to remove a non-directory Managed Python path')
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return
}
throw error
}
await rm(destination, { recursive: true, force: true })
}
@@ -0,0 +1,73 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
parsePythonArtifactCatalog,
selectPythonArtifact
} from './python-artifact-catalog'
async function catalogValue(): Promise<unknown> {
return JSON.parse(await readFile(resolve(
'resources/tool-environment/managed-python-artifacts.json'
), 'utf8')) as unknown
}
describe('managed Python artifact catalog', () => {
it('loads exactly one pinned artifact for all six desktop targets', async () => {
const catalog = parsePythonArtifactCatalog(await catalogValue())
expect(catalog.pythonVersion).toBe('3.13.15')
expect(catalog.artifacts.map((artifact) =>
`${artifact.platform}/${artifact.arch}`
)).toEqual([
'win32/x64',
'win32/arm64',
'darwin/x64',
'darwin/arm64',
'linux/x64',
'linux/arm64'
])
expect(catalog.artifacts.map(({ size, sha256 }) => ({ size, sha256 })))
.toEqual([
{ size: 14391248, sha256: '05357887df50d3153efc681bdf432c321d3e2f9ce5788f99f4515b27e8fda0ac' },
{ size: 13762836, sha256: '3c1b1fdf56adc14634165df922d447520aefdc4a8411a34c34d8a062a4edf494' },
{ size: 24921256, sha256: 'd33d61f7f4982c94216e14a43599c75657b7d0839277fc72bc6dbac53e8229bc' },
{ size: 25140257, sha256: '149038dd0c194c25d4616d7e42a35f67f2edee96412788f74115819b6a4c8548' },
{ size: 34813635, sha256: '8af9a8214c71b2dd698005e39fab87aad02a994330508857da4e6d1ba7e6ddb6' },
{ size: 29222461, sha256: 'e5d0df1a6070a8614d808496e5ea28c727480e40ffcce1a94697a067f1690aa8' }
])
})
it('freezes the selected source without fallback', async () => {
const catalog = parsePythonArtifactCatalog(await catalogValue())
const native = selectPythonArtifact({
catalog, platform: 'linux', arch: 'arm64', source: 'native'
})
const oss = selectPythonArtifact({
catalog, platform: 'linux', arch: 'arm64', source: 'oss'
})
expect(native.source).toBe('native')
expect(native.url).toContain('github.com/')
expect(oss.source).toBe('oss')
expect(oss.url).toContain(`/${oss.sha256}/`)
expect(oss.redirectHosts).toEqual([
'goodbuddy.oss-cn-beijing.aliyuncs.com'
])
})
it('rejects unknown fields and duplicate target rows', async () => {
const value = await catalogValue() as {
artifacts: Record<string, unknown>[]
}
value.artifacts[0]!.surprise = true
expect(() => parsePythonArtifactCatalog(value)).toThrow()
const duplicate = await catalogValue() as {
artifacts: Record<string, unknown>[]
}
duplicate.artifacts[1]!.platform = 'win32'
duplicate.artifacts[1]!.arch = 'x64'
expect(() => parsePythonArtifactCatalog(duplicate)).toThrow(
/duplicate or unsupported target/u
)
})
})
@@ -0,0 +1,206 @@
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
const platformSchema = z.enum(['win32', 'darwin', 'linux'])
const archSchema = z.enum(['x64', 'arm64'])
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u)
const httpsUrlSchema = z.string().url().superRefine((value, context) => {
const url = new URL(value)
if (
url.protocol !== 'https:' ||
(url.port !== '' && url.port !== '443') ||
url.username ||
url.password ||
url.hash
) {
context.addIssue({ code: 'custom', message: 'must be a plain HTTPS URL' })
}
})
const pinnedArtifacts: Record<string, {
fileName: string
size: number
sha256: string
}> = {
'win32/x64': {
fileName: 'python.3.13.15.nupkg',
size: 14391248,
sha256: '05357887df50d3153efc681bdf432c321d3e2f9ce5788f99f4515b27e8fda0ac'
},
'win32/arm64': {
fileName: 'pythonarm64.3.13.15.nupkg',
size: 13762836,
sha256: '3c1b1fdf56adc14634165df922d447520aefdc4a8411a34c34d8a062a4edf494'
},
'darwin/x64': {
fileName: 'cpython-3.13.15+20260825-x86_64-apple-darwin-install_only_stripped.tar.gz',
size: 24921256,
sha256: 'd33d61f7f4982c94216e14a43599c75657b7d0839277fc72bc6dbac53e8229bc'
},
'darwin/arm64': {
fileName: 'cpython-3.13.15+20260825-aarch64-apple-darwin-install_only_stripped.tar.gz',
size: 25140257,
sha256: '149038dd0c194c25d4616d7e42a35f67f2edee96412788f74115819b6a4c8548'
},
'linux/x64': {
fileName: 'cpython-3.13.15+20260825-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz',
size: 34813635,
sha256: '8af9a8214c71b2dd698005e39fab87aad02a994330508857da4e6d1ba7e6ddb6'
},
'linux/arm64': {
fileName: 'cpython-3.13.15+20260825-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz',
size: 29222461,
sha256: 'e5d0df1a6070a8614d808496e5ea28c727480e40ffcce1a94697a067f1690aa8'
}
}
const artifactSchema = z.object({
platform: platformSchema,
arch: archSchema,
archiveFormat: z.enum(['nuget-zip', 'tar.gz']),
payloadRoot: z.enum(['tools', 'python']),
fileName: z.string().min(1).max(255).regex(/^[^/\\\0]+$/u),
size: z.number().int().positive().safe(),
sha256: sha256Schema,
nativeUrl: httpsUrlSchema,
ossUrl: httpsUrlSchema
}).strict().superRefine((artifact, context) => {
const windows = artifact.platform === 'win32'
const key = `${artifact.platform}/${artifact.arch}`
const pinned = pinnedArtifacts[key]
if (
!pinned ||
artifact.fileName !== pinned.fileName ||
artifact.size !== pinned.size ||
artifact.sha256 !== pinned.sha256
) {
context.addIssue({
code: 'custom',
message: `artifact does not match the CPython 3.13.15 pin for ${key}`
})
}
if (
(windows && (
artifact.archiveFormat !== 'nuget-zip' ||
artifact.payloadRoot !== 'tools' ||
!artifact.fileName.endsWith('.nupkg')
)) ||
(!windows && (
artifact.archiveFormat !== 'tar.gz' ||
artifact.payloadRoot !== 'python' ||
!artifact.fileName.endsWith('.tar.gz')
))
) {
context.addIssue({
code: 'custom',
message: 'archive format does not match the platform'
})
}
const expectedOssPath =
`/tool-artifacts/python/3.13.15/${artifact.sha256}/` +
encodeURIComponent(artifact.fileName).replaceAll('%2F', '/')
const oss = new URL(artifact.ossUrl)
if (
oss.hostname !== 'goodbuddy.oss-cn-beijing.aliyuncs.com' ||
oss.pathname !== expectedOssPath
) {
context.addIssue({ code: 'custom', message: 'OSS key is not immutable' })
}
const expectedNativeUrl = windows
? `https://api.nuget.org/v3-flatcontainer/${
artifact.arch === 'x64' ? 'python' : 'pythonarm64'
}/3.13.15/${artifact.fileName}`
: 'https://github.com/astral-sh/python-build-standalone/releases/' +
`download/20260825/${encodeURIComponent(artifact.fileName)}`
if (artifact.nativeUrl !== expectedNativeUrl) {
context.addIssue({
code: 'custom',
message: 'native URL does not match the pinned upstream release'
})
}
})
const catalogSchema = z.object({
formatVersion: z.literal(1),
pythonVersion: z.literal('3.13.15'),
artifacts: z.array(artifactSchema).length(6)
}).strict().superRefine((catalog, context) => {
const expected = new Set([
'win32/x64', 'win32/arm64',
'darwin/x64', 'darwin/arm64',
'linux/x64', 'linux/arm64'
])
for (const artifact of catalog.artifacts) {
const key = `${artifact.platform}/${artifact.arch}`
if (!expected.delete(key)) {
context.addIssue({
code: 'custom',
path: ['artifacts'],
message: `duplicate or unsupported target: ${key}`
})
}
}
if (expected.size > 0) {
context.addIssue({
code: 'custom',
path: ['artifacts'],
message: `missing targets: ${[...expected].join(', ')}`
})
}
})
export type PythonArtifactSource = 'native' | 'oss'
export type PythonArtifactPlatform = z.infer<typeof platformSchema>
export type PythonArtifactArch = z.infer<typeof archSchema>
export type PythonArtifactCatalog = z.infer<typeof catalogSchema>
export type PythonArtifact = z.infer<typeof artifactSchema> & {
source: PythonArtifactSource
url: string
redirectHosts: readonly string[]
}
export function parsePythonArtifactCatalog(value: unknown): PythonArtifactCatalog {
return catalogSchema.parse(value)
}
export async function loadPythonArtifactCatalog(
catalogPath: string
): Promise<PythonArtifactCatalog> {
const text = await readFile(catalogPath, 'utf8')
let value: unknown
try {
value = JSON.parse(text)
} catch (error) {
throw new Error('Managed Python artifact catalog is not valid JSON', {
cause: error
})
}
return parsePythonArtifactCatalog(value)
}
export function selectPythonArtifact(options: {
catalog: PythonArtifactCatalog
platform: PythonArtifactPlatform
arch: PythonArtifactArch
source: PythonArtifactSource
}): PythonArtifact {
const artifact = options.catalog.artifacts.find(
(candidate) =>
candidate.platform === options.platform &&
candidate.arch === options.arch
)
if (!artifact) {
throw new Error(`Managed Python is unavailable for ${options.platform}/${options.arch}`)
}
const url = options.source === 'native'
? artifact.nativeUrl
: artifact.ossUrl
const host = new URL(url).hostname
const redirectHosts =
options.source === 'oss'
? [host]
: artifact.platform === 'win32'
? [host, 'globalcdn.nuget.org']
: [host, 'objects.githubusercontent.com', 'release-assets.githubusercontent.com']
return { ...artifact, source: options.source, url, redirectHosts }
}
@@ -0,0 +1,94 @@
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PythonArtifact } from './python-artifact-catalog'
import { downloadPythonArtifact } from './python-artifact-download'
const temporaryDirectories: string[] = []
async function temporaryPath(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-python-download-'))
temporaryDirectories.push(directory)
return join(directory, 'artifact')
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
))
})
function artifact(bytes: Uint8Array): PythonArtifact {
return {
platform: 'linux',
arch: 'x64',
archiveFormat: 'tar.gz',
payloadRoot: 'python',
fileName: 'python.tar.gz',
size: bytes.byteLength,
sha256: createHash('sha256').update(bytes).digest('hex'),
nativeUrl: 'https://download.example/python.tar.gz',
ossUrl: 'https://oss.example/python.tar.gz',
source: 'native',
url: 'https://download.example/python.tar.gz',
redirectHosts: ['download.example', 'cdn.example']
}
}
describe('Managed Python download', () => {
it('follows only bounded declared redirects and verifies bytes', async () => {
const bytes = Buffer.from('verified Python archive')
const transport = vi.fn<typeof fetch>()
.mockResolvedValueOnce(new Response(null, {
status: 302,
headers: { location: 'https://cdn.example/python.tar.gz' }
}))
.mockResolvedValueOnce(new Response(bytes, {
status: 200,
headers: { 'content-length': String(bytes.byteLength) }
}))
const destinationPath = await temporaryPath()
await downloadPythonArtifact({
artifact: artifact(bytes),
destinationPath,
transport
})
expect(await readFile(destinationPath)).toEqual(bytes)
expect(transport).toHaveBeenCalledTimes(2)
})
it('removes a partial on hash mismatch', async () => {
const bytes = Buffer.from('wrong bytes')
const expected = artifact(Buffer.from('right bytes'))
expected.size = bytes.byteLength
const destinationPath = await temporaryPath()
await expect(downloadPythonArtifact({
artifact: expected,
destinationPath,
transport: vi.fn<typeof fetch>().mockResolvedValue(
new Response(bytes, { status: 200 })
)
})).rejects.toThrow(/integrity/u)
await expect(readFile(destinationPath)).rejects.toMatchObject({
code: 'ENOENT'
})
})
it('rejects an undeclared redirect host without a fallback request', async () => {
const bytes = Buffer.from('archive')
const transport = vi.fn<typeof fetch>().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: 'https://attacker.example/archive' }
})
)
await expect(downloadPythonArtifact({
artifact: artifact(bytes),
destinationPath: await temporaryPath(),
transport
})).rejects.toThrow(/undeclared host/u)
expect(transport).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,136 @@
import { createHash } from 'node:crypto'
import { open, rm } from 'node:fs/promises'
import type { FileHandle } from 'node:fs/promises'
import type { PythonArtifact } from './python-artifact-catalog'
const redirectStatuses = new Set([301, 302, 303, 307, 308])
const MAX_REDIRECTS = 3
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted', 'AbortError')
}
}
function secureUrl(value: string): URL {
const url = new URL(value)
if (
url.protocol !== 'https:' ||
(url.port !== '' && url.port !== '443') ||
url.username ||
url.password ||
url.hash
) {
throw new Error('Managed Python download URL must be plain HTTPS')
}
return url
}
async function close(handle: FileHandle | undefined): Promise<void> {
await handle?.close().catch(() => undefined)
}
export async function downloadPythonArtifact(options: {
artifact: PythonArtifact
destinationPath: string
transport?: typeof fetch
signal?: AbortSignal
onProgress?: (receivedBytes: number, totalBytes: number) => void
}): Promise<void> {
const transport = options.transport ?? fetch
const allowedHosts = new Set(options.artifact.redirectHosts)
let url = secureUrl(options.artifact.url)
if (!allowedHosts.has(url.hostname)) {
throw new Error('Managed Python initial download host is not declared')
}
let response: Response | undefined
for (let redirects = 0; ; redirects += 1) {
ensureNotAborted(options.signal)
response = await transport(url, {
method: 'GET',
redirect: 'manual',
credentials: 'omit',
cache: 'no-store',
signal: options.signal
})
if (!redirectStatuses.has(response.status)) {
break
}
if (redirects >= MAX_REDIRECTS) {
await response.body?.cancel().catch(() => undefined)
throw new Error('Managed Python download has too many redirects')
}
const location = response.headers.get('location')
await response.body?.cancel().catch(() => undefined)
if (!location) {
throw new Error('Managed Python redirect has no location')
}
const next = secureUrl(new URL(location, url).toString())
if (!allowedHosts.has(next.hostname)) {
throw new Error('Managed Python download redirected to an undeclared host')
}
url = next
}
if (!response.ok || !response.body) {
await response.body?.cancel().catch(() => undefined)
throw new Error(`Managed Python download failed with HTTP ${response.status}`)
}
const declaredLength = response.headers.get('content-length')
if (
declaredLength !== null &&
(!/^\d+$/u.test(declaredLength) ||
Number(declaredLength) !== options.artifact.size)
) {
await response.body.cancel().catch(() => undefined)
throw new Error('Managed Python download size does not match the catalog')
}
let output: FileHandle | undefined
const hash = createHash('sha256')
let received = 0
try {
output = await open(options.destinationPath, 'wx')
const reader = response.body.getReader()
while (true) {
ensureNotAborted(options.signal)
const { done, value } = await reader.read()
if (done) {
break
}
received += value.byteLength
if (received > options.artifact.size) {
await reader.cancel().catch(() => undefined)
throw new Error('Managed Python download exceeds the catalog size')
}
hash.update(value)
let offset = 0
while (offset < value.byteLength) {
const { bytesWritten } = await output.write(
value,
offset,
value.byteLength - offset
)
if (bytesWritten <= 0) {
throw new Error('Managed Python artifact write was incomplete')
}
offset += bytesWritten
}
options.onProgress?.(received, options.artifact.size)
}
if (
received !== options.artifact.size ||
hash.digest('hex') !== options.artifact.sha256
) {
throw new Error('Managed Python artifact integrity check failed')
}
await output.sync()
await close(output)
output = undefined
} catch (error) {
await close(output)
await rm(options.destinationPath, { force: true }).catch(() => undefined)
throw error
}
}
@@ -0,0 +1,168 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { gzipSync, zipSync } from 'fflate'
import { create } from 'tar'
import { afterEach, describe, expect, it } from 'vitest'
import { extractPythonArtifact } from './python-artifact-extract'
const temporaryDirectories: string[] = []
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-python-extract-'))
temporaryDirectories.push(directory)
return directory
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
))
})
function tarField(value: string | number, length: number): Buffer {
const text = typeof value === 'number' ? value.toString(8) : value
const result = Buffer.alloc(length)
result.write(text.slice(0, length - 1), 0, 'ascii')
return result
}
function tarArchive(entries: {
path: string
type?: '0' | '2'
content?: string
linkPath?: string
mode?: number
}[]): Buffer {
const parts: Buffer[] = []
for (const item of entries) {
const content = Buffer.from(item.content ?? '')
const header = Buffer.alloc(512)
tarField(item.path, 100).copy(header, 0)
tarField(item.mode ?? 0o755, 8).copy(header, 100)
tarField(0, 8).copy(header, 108)
tarField(0, 8).copy(header, 116)
tarField(content.byteLength, 12).copy(header, 124)
tarField(0, 12).copy(header, 136)
header.fill(0x20, 148, 156)
header.write(item.type ?? '0', 156, 'ascii')
tarField(item.linkPath ?? '', 100).copy(header, 157)
header.write('ustar\0', 257, 'ascii')
header.write('00', 263, 'ascii')
let checksum = 0
for (const value of header) {
checksum += value
}
const checksumText = checksum.toString(8).padStart(6, '0')
header.write(`${checksumText}\0 `, 148, 'ascii')
parts.push(header, content)
const padding = (512 - content.byteLength % 512) % 512
if (padding > 0) {
parts.push(Buffer.alloc(padding))
}
}
parts.push(Buffer.alloc(1024))
return Buffer.concat(parts)
}
describe('Managed Python archive extraction', () => {
it('extracts only the tools payload root from a NuGet ZIP', async () => {
const directory = await temporaryDirectory()
const archivePath = join(directory, 'python.nupkg')
await writeFile(archivePath, zipSync({
'[Content_Types].xml': Buffer.from('<Types />'),
'package.nuspec': Buffer.from('<package />'),
'tools/python.exe': Buffer.from('python'),
'tools/Lib/site.py': Buffer.from('site')
}))
const destinationDirectory = join(directory, 'install')
await extractPythonArtifact({
artifact: { archiveFormat: 'nuget-zip', payloadRoot: 'tools' },
archivePath,
destinationDirectory
})
expect(await readFile(join(destinationDirectory, 'python.exe'), 'utf8'))
.toBe('python')
expect(await readFile(join(destinationDirectory, 'Lib/site.py'), 'utf8'))
.toBe('site')
await expect(
readFile(join(destinationDirectory, 'package.nuspec'))
).rejects.toMatchObject({ code: 'ENOENT' })
})
it('rejects ZIP traversal before writing payload files', async () => {
const directory = await temporaryDirectory()
const archivePath = join(directory, 'bad.nupkg')
await writeFile(archivePath, zipSync({
'tools/python.exe': Buffer.from('python'),
'tools/../escaped': Buffer.from('bad')
}))
await expect(extractPythonArtifact({
artifact: { archiveFormat: 'nuget-zip', payloadRoot: 'tools' },
archivePath,
destinationDirectory: join(directory, 'install')
})).rejects.toThrow(/Unsafe archive path/u)
await expect(readFile(join(directory, 'escaped'))).rejects.toMatchObject({
code: 'ENOENT'
})
})
it('extracts an Astral-style python TAR root', async () => {
const directory = await temporaryDirectory()
const source = join(directory, 'source')
await mkdir(join(source, 'python/bin'), { recursive: true })
await writeFile(join(source, 'python/bin/python3'), 'python')
const archivePath = join(directory, 'python.tar.gz')
await create({
cwd: source,
file: archivePath,
gzip: true,
portable: true
}, ['python'])
const destinationDirectory = join(directory, 'install')
await extractPythonArtifact({
artifact: { archiveFormat: 'tar.gz', payloadRoot: 'python' },
archivePath,
destinationDirectory
})
expect(await readFile(join(destinationDirectory, 'bin/python3'), 'utf8'))
.toBe('python')
})
it('validates relative symlinks and rejects escaping links', async () => {
const directory = await temporaryDirectory()
if (process.platform !== 'win32') {
const safeArchive = join(directory, 'safe.tar.gz')
await writeFile(safeArchive, gzipSync(tarArchive([
{ path: 'python/bin/python3', content: 'python' },
{
path: 'python/bin/python',
type: '2',
linkPath: 'python3'
}
])))
const destinationDirectory = join(directory, 'safe-install')
await extractPythonArtifact({
artifact: { archiveFormat: 'tar.gz', payloadRoot: 'python' },
archivePath: safeArchive,
destinationDirectory
})
expect(await readFile(join(destinationDirectory, 'bin/python'), 'utf8'))
.toBe('python')
}
const unsafeArchive = join(directory, 'unsafe.tar.gz')
await writeFile(unsafeArchive, gzipSync(tarArchive([
{
path: 'python/bin/python',
type: '2',
linkPath: '../../../outside'
}
])))
await expect(extractPythonArtifact({
artifact: { archiveFormat: 'tar.gz', payloadRoot: 'python' },
archivePath: unsafeArchive,
destinationDirectory: join(directory, 'unsafe-install')
})).rejects.toThrow(/escapes payload root/u)
})
})
@@ -0,0 +1,302 @@
import { chmod, lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, posix, resolve, sep } from 'node:path'
import { unzipSync } from 'fflate'
import { extract, list, type ReadEntry } from 'tar'
import type { PythonArtifact } from './python-artifact-catalog'
const MAX_ENTRIES = 20_000
const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024
const zipSignature = 0x02014b50
type SafeEntry = {
archivePath: string
outputPath: string
type: 'file' | 'directory' | 'symlink'
mode?: number
linkPath?: string
size: number
}
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted', 'AbortError')
}
}
function safePayloadPath(
path: string,
root: string,
ignoreOutsideRoot = false
): string | undefined {
if (
path.includes('\\') ||
path.includes('\0') ||
path.startsWith('/') ||
/^[a-zA-Z]:/u.test(path)
) {
throw new Error(`Unsafe archive path: ${path}`)
}
const normalized = posix.normalize(path)
if (
normalized === '..' ||
normalized.startsWith('../') ||
normalized !== path.replace(/\/+$/u, '')
) {
throw new Error(`Unsafe archive path: ${path}`)
}
if (normalized === root) {
return undefined
}
const prefix = `${root}/`
if (!normalized.startsWith(prefix)) {
if (ignoreOutsideRoot) {
return undefined
}
throw new Error(`Archive entry is outside payload root ${root}: ${path}`)
}
const relative = normalized.slice(prefix.length)
if (!relative) {
return undefined
}
return relative
}
function destinationPath(root: string, relative: string): string {
const destination = resolve(root, ...relative.split('/'))
const prefix = `${resolve(root)}${sep}`
if (!destination.startsWith(prefix)) {
throw new Error(`Archive path escaped destination: ${relative}`)
}
return destination
}
function ensureSafeSymlink(relative: string, target: string): void {
if (
!target ||
target.includes('\\') ||
target.includes('\0') ||
target.startsWith('/') ||
/^[a-zA-Z]:/u.test(target)
) {
throw new Error(`Unsafe archive symlink: ${relative}`)
}
const resolved = posix.normalize(posix.join(posix.dirname(relative), target))
if (resolved === '..' || resolved.startsWith('../') || posix.isAbsolute(resolved)) {
throw new Error(`Archive symlink escapes payload root: ${relative}`)
}
}
function verifyEntrySet(entries: SafeEntry[]): void {
if (entries.length === 0 || entries.length > MAX_ENTRIES) {
throw new Error('Archive has an invalid number of payload entries')
}
let expanded = 0
const seen = new Set<string>()
for (const entry of entries) {
const key = entry.outputPath.toLowerCase()
if (seen.has(key)) {
throw new Error(`Archive contains duplicate path: ${entry.outputPath}`)
}
seen.add(key)
expanded += entry.size
if (!Number.isSafeInteger(expanded) || expanded > MAX_EXPANDED_BYTES) {
throw new Error('Archive expanded size exceeds the limit')
}
}
}
function readZipEntries(data: Buffer, root: string): SafeEntry[] {
const entries: SafeEntry[] = []
for (let offset = 0; offset + 46 <= data.length;) {
if (data.readUInt32LE(offset) !== zipSignature) {
offset += 1
continue
}
const madeBy = data.readUInt16LE(offset + 4)
const flags = data.readUInt16LE(offset + 8)
const size = data.readUInt32LE(offset + 24)
const nameLength = data.readUInt16LE(offset + 28)
const extraLength = data.readUInt16LE(offset + 30)
const commentLength = data.readUInt16LE(offset + 32)
const attrs = data.readUInt32LE(offset + 38)
const end = offset + 46 + nameLength + extraLength + commentLength
if (end > data.length || (flags & 1) !== 0) {
throw new Error('ZIP central directory is invalid or encrypted')
}
const archivePath = data.subarray(offset + 46, offset + 46 + nameLength)
.toString((flags & 0x800) !== 0 ? 'utf8' : 'latin1')
const relative = safePayloadPath(archivePath, root, true)
if (relative) {
const unixMode = (madeBy >>> 8) === 3 ? (attrs >>> 16) & 0xffff : undefined
const fileType = unixMode === undefined ? 0 : unixMode & 0xf000
if (fileType !== 0 && fileType !== 0x8000 && fileType !== 0x4000) {
throw new Error(`ZIP contains a special file or link: ${archivePath}`)
}
entries.push({
archivePath,
outputPath: relative,
type: archivePath.endsWith('/') || fileType === 0x4000
? 'directory'
: 'file',
mode: unixMode === undefined ? undefined : unixMode & 0o777,
size
})
}
offset = end
}
verifyEntrySet(entries)
return entries
}
async function extractNuget(
archivePath: string,
destination: string,
root: string,
signal?: AbortSignal
): Promise<void> {
ensureNotAborted(signal)
const data = await readFile(archivePath)
const entries = readZipEntries(data, root)
const payloadPaths = new Set(entries.map((entry) => entry.archivePath))
const unzipped = unzipSync(data, {
filter: ({ name }) => payloadPaths.has(name)
})
for (const entry of entries.sort((left, right) =>
left.type === 'directory' && right.type !== 'directory' ? -1 : 0
)) {
ensureNotAborted(signal)
const output = destinationPath(destination, entry.outputPath)
if (entry.type === 'directory') {
await mkdir(output, { recursive: true, mode: entry.mode })
continue
}
const content = unzipped[entry.archivePath]
if (!content || content.byteLength !== entry.size) {
throw new Error(`ZIP payload is missing or corrupt: ${entry.archivePath}`)
}
await mkdir(dirname(output), { recursive: true })
await writeFile(output, content, { flag: 'wx', mode: entry.mode })
if (entry.mode !== undefined && process.platform !== 'win32') {
await chmod(output, entry.mode)
}
}
}
function tarEntry(entry: ReadEntry, root: string): SafeEntry | undefined {
const relative = safePayloadPath(entry.path.replace(/\/+$/u, ''), root)
if (!relative) {
return undefined
}
if (entry.type === 'SymbolicLink') {
const linkPath = entry.linkpath ?? ''
ensureSafeSymlink(relative, linkPath)
return {
archivePath: entry.path,
outputPath: relative,
type: 'symlink',
mode: entry.mode === undefined ? undefined : entry.mode & 0o777,
linkPath,
size: 0
}
}
if (entry.type !== 'File' && entry.type !== 'Directory') {
throw new Error(`TAR contains unsupported entry type ${entry.type}: ${entry.path}`)
}
return {
archivePath: entry.path,
outputPath: relative,
type: entry.type === 'Directory' ? 'directory' : 'file',
mode: entry.mode === undefined ? undefined : entry.mode & 0o777,
size: entry.size
}
}
async function extractTar(
archivePath: string,
destination: string,
root: string,
signal?: AbortSignal
): Promise<void> {
const entries: SafeEntry[] = []
let validationError: Error | undefined
await list({
file: archivePath,
gzip: true,
strict: true,
onReadEntry: (entry) => {
try {
ensureNotAborted(signal)
const safe = tarEntry(entry, root)
if (safe) {
entries.push(safe)
}
} catch (error) {
validationError ??= error instanceof Error
? error
: new Error('TAR validation failed')
entry.resume()
}
}
})
if (validationError) {
throw validationError
}
verifyEntrySet(entries)
const allowed = new Map(entries.map((entry) => [entry.archivePath, entry]))
await extract({
file: archivePath,
cwd: destination,
gzip: true,
strip: 1,
strict: true,
preservePaths: false,
noChmod: false,
filter: (path, entry) => {
ensureNotAborted(signal)
const expected = allowed.get(path)
if (!expected) {
return path.replace(/\/+$/u, '') === root
}
const checked = tarEntry(entry as ReadEntry, root)
return checked?.outputPath === expected.outputPath &&
checked.type === expected.type
}
})
for (const entry of entries) {
const output = destinationPath(destination, entry.outputPath)
const status = await lstat(output)
if (
(entry.type === 'file' && !status.isFile()) ||
(entry.type === 'directory' && !status.isDirectory()) ||
(entry.type === 'symlink' && !status.isSymbolicLink())
) {
throw new Error(`Extracted TAR entry has the wrong type: ${entry.outputPath}`)
}
}
}
export async function extractPythonArtifact(options: {
artifact: Pick<PythonArtifact, 'archiveFormat' | 'payloadRoot'>
archivePath: string
destinationDirectory: string
signal?: AbortSignal
}): Promise<void> {
await mkdir(options.destinationDirectory, { recursive: false })
if (options.artifact.archiveFormat === 'nuget-zip') {
await extractNuget(
options.archivePath,
options.destinationDirectory,
options.artifact.payloadRoot,
options.signal
)
} else {
await extractTar(
options.archivePath,
options.destinationDirectory,
options.artifact.payloadRoot,
options.signal
)
}
}