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