feat: add managed local tool environments

GoodBuddy can now provide managed Node.js and on-demand Python to local Skills and stdio MCP servers, or validate an explicitly selected custom interpreter. The Capabilities and tools settings page exposes source selection, diagnostics, installation progress, cancellation, and removal while new processes receive immutable PATH snapshots.

Application settings migrate to v10. Local terminal and remote Host environments remain unchanged. Windows x64 native-source Node and Python paths are validated; OSS mirror publication, redistributed license resources, and the remaining native platform probes are recorded as release blockers.

Release note: 新增工具执行环境,可为本机 Skills 和 stdio MCP 使用 GoodBuddy 托管的 Node.js、按需安装的 Python,或经过验证的自定义解释器。
This commit is contained in:
mesalogo
2026-09-02 10:36:56 +08:00
parent 1530fa116c
commit be39665ca9
69 changed files with 6958 additions and 264 deletions
+21
View File
@@ -0,0 +1,21 @@
export {
createLocalToolEnvironment,
resolveNpmCliPaths
} from './local-tool-environment'
export type {
LocalToolDiagnostic,
LocalToolEnvironment,
LocalToolEnvironmentDependencies,
LocalToolEnvironmentDiagnostics,
LocalToolEnvironmentOptions,
LocalToolName,
LocalToolProbeProcess,
LocalToolProbeSpawn,
LocalToolSource
} from './local-tool-environment'
export { LocalToolEnvironmentService } from './local-tool-environment-service'
export type {
LocalToolEnvironmentServiceDependencies,
LocalToolEnvironmentServiceOptions
} from './local-tool-environment-service'
export type { LaunchEnvironmentProvider } from './launch-environment-provider'
@@ -0,0 +1,5 @@
/**
* Supplies a fresh, immutable snapshot of the environment prepared for local
* tool launches. Consumers must only copy the values they explicitly support.
*/
export type LaunchEnvironmentProvider = () => Readonly<NodeJS.ProcessEnv>
@@ -0,0 +1,311 @@
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
defaultLocalToolEnvironmentSettings,
type LocalToolEnvironmentSettings
} from '../../shared/local-tool-environment-contracts'
import {
LocalToolEnvironmentService,
validateManagedPython
} from './local-tool-environment-service'
import type { LocalToolEnvironmentServiceOptions } from './local-tool-environment-service'
import type {
LocalToolProbeProcess,
LocalToolProbeSpawn
} from './local-tool-environment'
const roots: string[] = []
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))
)
})
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-local-tools-'))
roots.push(root)
const node = join(root, 'node.exe')
const python = join(root, 'python.exe')
await Promise.all([writeFile(node, ''), writeFile(python, '')])
let settings: LocalToolEnvironmentSettings = {
...defaultLocalToolEnvironmentSettings
}
const settingsStore = {
get: vi.fn(async () => ({ localToolEnvironment: settings })),
update: vi.fn(async (input: { localToolEnvironment: LocalToolEnvironmentSettings }) => {
settings = input.localToolEnvironment
return { localToolEnvironment: settings }
})
}
const options: LocalToolEnvironmentServiceOptions = {
settingsStore: settingsStore as never,
binDirectory: join(root, 'bin'),
managedPythonRoot: join(root, 'managed-python'),
pythonArtifactCatalogPath: join(
process.cwd(),
'resources',
'tool-environment',
'managed-python-artifacts.json'
),
packagedNpmCliPath: join(root, 'npm-cli.js'),
packagedNpxCliPath: join(root, 'npx-cli.js'),
selectExecutable: vi.fn(async () => python),
baseEnvironment: { PATH: root },
platform: 'win32',
arch: 'x64'
}
const service = new LocalToolEnvironmentService(options, {
toolEnvironment: {
platform: 'win32',
spawnProcess: fakeInspectionSpawn()
}
})
return { service, settingsStore, node, python, options }
}
function fakeInspectionSpawn(
invalidPath?: string
): LocalToolProbeSpawn {
return (command, args) => {
const child = new EventEmitter() as LocalToolProbeProcess & EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
}
child.exitCode = null
child.killed = false
child.pid = 1234
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = vi.fn(() => true)
queueMicrotask(() => {
const invalid = command === invalidPath
const python = args[0] === '-I'
if (!invalid) {
child.stdout.emit(
'data',
JSON.stringify({
version: python ? '3.12.8' : '22.14.0',
architecture: 'x64'
})
)
}
child.exitCode = invalid ? 1 : 0
child.emit('close', child.exitCode)
})
return child
}
}
describe('LocalToolEnvironmentService', () => {
it('does not discover PATH candidates until explicitly refreshed', async () => {
const { service, node, python } = await fixture()
expect((await service.getSnapshot()).candidates).toEqual([])
const snapshot = await service.refreshCandidates()
expect(snapshot.candidates).toEqual([
{
kind: 'node',
executablePath: node,
version: '22.14.0',
architecture: 'x64'
},
{
kind: 'python',
executablePath: python,
version: '3.12.8',
architecture: 'x64'
}
])
})
it('persists the complete nested settings object through one store authority', async () => {
const { service, settingsStore, node } = await fixture()
const next: LocalToolEnvironmentSettings = {
node: { source: 'custom', executablePath: node },
python: { source: 'managed' },
artifactDownloadSource: 'oss'
}
await service.updateSettings(next)
expect(settingsStore.update).toHaveBeenCalledWith({
localToolEnvironment: next
})
await expect(
service.updateSettings({ ...next, command: 'node' })
).rejects.toThrow()
})
it('publishes immutable snapshots and replaces them only after rebuilding', async () => {
const { service, node } = await fixture()
await service.initialize()
const initial = service.launchEnvironmentProvider()
const initialPath = initial.PATH
expect(Object.isFrozen(initial)).toBe(true)
expect(initial.PATH).toContain('bin')
await service.updateSettings({
...defaultLocalToolEnvironmentSettings,
node: { source: 'custom', executablePath: node }
})
const updated = service.launchEnvironmentProvider()
expect(updated).not.toBe(initial)
expect(service.launchEnvironmentProvider()).toBe(updated)
expect(updated.PATH).not.toBe(initial.PATH)
expect(initial.PATH).toBe(initialPath)
expect(Object.isFrozen(updated)).toBe(true)
})
it('keeps executable paths inside the Main-owned picker callback', async () => {
const { service, settingsStore, python } = await fixture()
const snapshot = await service.selectExecutable('python')
expect(settingsStore.update).toHaveBeenCalledWith({
localToolEnvironment: {
...defaultLocalToolEnvironmentSettings,
python: { source: 'custom', executablePath: python }
}
})
expect(snapshot.settings.python).toEqual({
source: 'custom',
executablePath: python
})
})
it('rejects an invalid new custom selection without persisting it', async () => {
const { settingsStore, node, options } = await fixture()
const invalid = join(node, '..', 'invalid-node.exe')
await writeFile(invalid, '')
const customService = new LocalToolEnvironmentService(
{
...options,
selectExecutable: async () => invalid
},
{
toolEnvironment: {
platform: 'win32',
spawnProcess: fakeInspectionSpawn(invalid)
}
}
)
await expect(customService.selectExecutable('node')).rejects.toThrow(
'Selected node executable is invalid'
)
expect(settingsStore.update).not.toHaveBeenCalled()
expect((await customService.getSnapshot()).settings.node).toEqual({
source: 'managed'
})
})
it('restores persisted settings when launch environment publication fails', async () => {
const { settingsStore, node, options } = await fixture()
const blockedBinDirectory = join(node, '..', 'blocked-bin')
await writeFile(blockedBinDirectory, '')
const customService = new LocalToolEnvironmentService(
{
...options,
binDirectory: blockedBinDirectory
},
{
toolEnvironment: {
platform: 'win32',
spawnProcess: fakeInspectionSpawn()
}
}
)
await expect(
customService.updateSettings({
...defaultLocalToolEnvironmentSettings,
node: { source: 'custom', executablePath: node }
})
).rejects.toThrow()
expect(settingsStore.update).toHaveBeenLastCalledWith({
localToolEnvironment: defaultLocalToolEnvironmentSettings
})
expect((await customService.getSnapshot()).settings).toEqual(
defaultLocalToolEnvironmentSettings
)
})
it('validates pip and a disposable venv with separated bounded process calls', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-python-validation-'))
roots.push(root)
const python = join(root, 'python.exe')
const staging = join(root, 'stage')
const validationVenv = join(staging, '.validation-venv')
await Promise.all([writeFile(python, ''), mkdir(staging)])
const calls: Array<{
command: string
args: string[]
options: Parameters<LocalToolProbeSpawn>[2]
}> = []
const spawnProcess: LocalToolProbeSpawn = (command, args, options) => {
calls.push({ command, args, options })
const child = new EventEmitter() as LocalToolProbeProcess &
EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
}
child.exitCode = null
child.killed = false
child.pid = 4321
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = vi.fn(() => true)
queueMicrotask(() => {
if (args[0] === '-m' && args[1] === 'pip') {
child.stdout.emit('data', 'pip 24.3 from managed Python\n')
} else if (args[0] === '-m' && args[1] === 'venv') {
mkdirSync(validationVenv, { recursive: true })
} else {
child.stdout.emit(
'data',
JSON.stringify({
version: '3.12.8',
architecture: 'AMD64',
ssl: 'OpenSSL 3.0.15',
stdlib: join(root, 'Lib')
})
)
}
child.exitCode = 0
child.emit('close', 0)
})
return child
}
await validateManagedPython(
python,
staging,
'3.12.8',
'x64',
new AbortController().signal,
'win32',
{ platform: 'win32', spawnProcess }
)
expect(calls).toHaveLength(3)
expect(calls[0]).toMatchObject({
command: python,
args: ['-m', 'pip', '--version']
})
expect(calls[1]).toMatchObject({
command: python,
args: ['-m', 'venv', '--without-pip', validationVenv]
})
expect(calls[2]).toMatchObject({
command: join(validationVenv, 'Scripts', 'python.exe')
})
expect(calls[2]?.args.slice(0, 2)).toEqual(['-I', '-c'])
expect(calls.every(({ options }) => options.shell === false)).toBe(true)
await expect(access(validationVenv)).rejects.toMatchObject({
code: 'ENOENT'
})
})
})
@@ -0,0 +1,665 @@
import { randomUUID } from 'node:crypto'
import { mkdir, realpath, rm, stat } from 'node:fs/promises'
import { delimiter, join } from 'node:path'
import spawn from 'cross-spawn'
import type { ApplicationSettingsStore } from '../application-settings-store'
import {
localToolEnvironmentSettingsSchema,
type ArtifactDownloadSource,
type LocalToolDiagnoseTarget,
type LocalToolEnvironmentProgress,
type LocalToolEnvironmentSettings,
type LocalToolEnvironmentSnapshot,
type LocalToolKind,
type LocalToolPythonOperation
} from '../../shared/local-tool-environment-contracts'
import {
createLocalToolEnvironment,
inspectLocalToolExecutable,
type LocalToolEnvironment,
type LocalToolEnvironmentDependencies
} from './local-tool-environment'
import {
cleanupManagedPythonOperations,
installManagedPython,
removeManagedPython
} from './managed-python-install'
import {
loadPythonArtifactCatalog,
selectPythonArtifact,
type PythonArtifactArch,
type PythonArtifactPlatform
} from './python-artifact-catalog'
import { downloadPythonArtifact } from './python-artifact-download'
import { extractPythonArtifact } from './python-artifact-extract'
import type { LaunchEnvironmentProvider } from './launch-environment-provider'
import {
terminateProcessTreeAndWait,
type WaitableProcessTreeChild
} from '../agent/child-process-termination'
const candidateLimit = 40
const pathDirectoryLimit = 32
const validationOutputLimit = 8 * 1024
const validationTimeoutMs = 15_000
const validationTerminationWaitMs = 500
export type LocalToolEnvironmentServiceOptions = {
settingsStore: ApplicationSettingsStore
binDirectory: string
managedPythonRoot: string
pythonArtifactCatalogPath: string
packagedNpmCliPath: string
packagedNpxCliPath: string
selectExecutable: (
kind: LocalToolKind
) => Promise<string | undefined>
electronExecutablePath?: string
baseEnvironment?: NodeJS.ProcessEnv
platform?: PythonArtifactPlatform
arch?: PythonArtifactArch
}
export type LocalToolEnvironmentServiceDependencies = {
toolEnvironment?: LocalToolEnvironmentDependencies
transport?: typeof fetch
}
type ProgressListener = (progress: LocalToolEnvironmentProgress) => void
function pythonExecutable(
directory: string,
platform: PythonArtifactPlatform
): string {
return join(directory, 'python', platform === 'win32' ? 'python.exe' : 'bin/python3')
}
async function existingFile(path: string): Promise<string | undefined> {
try {
const canonical = await realpath(path)
return (await stat(canonical)).isFile() ? canonical : undefined
} catch {
return undefined
}
}
async function discoverCandidates(
environment: NodeJS.ProcessEnv,
platform: PythonArtifactPlatform,
savedSettings: LocalToolEnvironmentSettings,
dependencies: LocalToolEnvironmentDependencies
): Promise<LocalToolEnvironmentSnapshot['candidates']> {
const value = environment.PATH ?? environment.Path ?? environment.path ?? ''
const directories = value.split(delimiter).filter(Boolean).slice(0, pathDirectoryLimit)
const names: Readonly<Record<LocalToolKind, readonly string[]>> =
platform === 'win32'
? { node: ['node.exe'], python: ['python.exe', 'python3.exe'] }
: { node: ['node'], python: ['python3', 'python'] }
const paths: Array<{ kind: LocalToolKind; executablePath: string }> = []
for (const kind of ['node', 'python'] as const) {
const selection = savedSettings[kind]
if (selection.source === 'custom') {
paths.push({ kind, executablePath: selection.executablePath })
}
}
const seen = new Set<string>()
for (const directory of directories) {
for (const kind of ['node', 'python'] as const) {
for (const name of names[kind]) {
if (paths.length >= candidateLimit) {
break
}
const path = await existingFile(join(directory, name))
const key = path
? `${kind}:${platform === 'win32' ? path.toLocaleLowerCase() : path}`
: undefined
if (path && key && !seen.has(key)) {
seen.add(key)
paths.push({ kind, executablePath: path })
}
}
}
}
const inspected = await Promise.all(
paths.slice(0, candidateLimit).map(({ kind, executablePath }) =>
inspectLocalToolExecutable(
kind,
executablePath,
{ baseEnvironment: environment },
dependencies
)
)
)
const candidates: LocalToolEnvironmentSnapshot['candidates'] = []
const canonicalSeen = new Set<string>()
for (const candidate of inspected) {
if (!candidate) continue
const key = `${candidate.kind}:${
platform === 'win32'
? candidate.executablePath.toLocaleLowerCase()
: candidate.executablePath
}`
if (!canonicalSeen.has(key)) {
canonicalSeen.add(key)
candidates.push(candidate)
}
}
return candidates
}
type ValidationProcess = WaitableProcessTreeChild & {
stdout?: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null
stderr?: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null
}
function runValidationCommand(
command: string,
args: string[],
signal: AbortSignal,
platform: PythonArtifactPlatform,
dependencies: LocalToolEnvironmentDependencies
): Promise<string> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason)
return
}
let child: ValidationProcess
try {
child = (dependencies.spawnProcess ?? spawn)(command, args, {
detached: platform !== 'win32',
env: process.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
} catch (error) {
reject(error)
return
}
let output = ''
let outputBytes = 0
let settled = false
let terminating = false
const finish = (error?: unknown): void => {
if (settled || terminating) return
settled = true
clearTimeout(timeout)
signal.removeEventListener('abort', onAbort)
if (error !== undefined) reject(error)
else resolve(output)
}
const terminate = (error: unknown): void => {
if (settled || terminating) return
terminating = true
clearTimeout(timeout)
signal.removeEventListener('abort', onAbort)
void (
dependencies.terminateProcessTree ?? terminateProcessTreeAndWait
)(child, {
platform,
processGroup: platform !== 'win32',
signal: 'SIGKILL',
waitMs: validationTerminationWaitMs
}).then(
() => {
settled = true
reject(error)
},
() => {
settled = true
reject(error)
}
)
}
const onAbort = (): void =>
terminate(
signal.reason ??
new DOMException('Managed Python operation cancelled', 'AbortError')
)
const timeout = setTimeout(
() => terminate(new Error('Managed Python validation timed out')),
validationTimeoutMs
)
signal.addEventListener('abort', onAbort, { once: true })
const collect = (chunk: Buffer | string): void => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
outputBytes += value.byteLength
if (outputBytes > validationOutputLimit) {
terminate(new Error('Managed Python validation output exceeded its limit'))
return
}
output += value.toString('utf8')
}
child.stdout?.on('data', collect)
child.stderr?.on('data', collect)
child.once('error', (error: unknown) => finish(error))
child.once('close', (code: unknown) => {
if (!terminating) {
finish(
code === 0
? undefined
: new Error(`Managed Python validation exited with code ${String(code)}`)
)
}
})
})
}
export async function validateManagedPython(
executablePath: string,
stagingDirectory: string,
version: string,
arch: PythonArtifactArch,
signal: AbortSignal,
platform: PythonArtifactPlatform,
dependencies: LocalToolEnvironmentDependencies = {}
): Promise<void> {
const validationVenv = join(stagingDirectory, '.validation-venv')
try {
const pipOutput = await runValidationCommand(
executablePath,
['-m', 'pip', '--version'],
signal,
platform,
dependencies
)
if (!/\bpip\s+\d/u.test(pipOutput)) {
throw new Error('Managed Python pip is invalid')
}
await runValidationCommand(
executablePath,
['-m', 'venv', '--without-pip', validationVenv],
signal,
platform,
dependencies
)
const venvPython =
platform === 'win32'
? join(validationVenv, 'Scripts', 'python.exe')
: join(validationVenv, 'bin', 'python3')
const script = [
'import json, platform, ssl, sysconfig',
'print(json.dumps({"version": platform.python_version(), "architecture": platform.machine(), "ssl": ssl.OPENSSL_VERSION, "stdlib": sysconfig.get_path("stdlib")}))'
].join(';')
const output = await runValidationCommand(
venvPython,
['-I', '-c', script],
signal,
platform,
dependencies
)
const result = JSON.parse(output.trim()) as {
version?: unknown
architecture?: unknown
ssl?: unknown
stdlib?: unknown
}
const expectedMachines =
arch === 'x64' ? ['x86_64', 'amd64', 'x64'] : ['aarch64', 'arm64']
if (
result.version !== version ||
typeof result.architecture !== 'string' ||
!expectedMachines.includes(result.architecture.toLocaleLowerCase()) ||
typeof result.ssl !== 'string' ||
result.ssl.length === 0 ||
typeof result.stdlib !== 'string' ||
result.stdlib.length === 0
) {
throw new Error('Managed Python runtime validation failed')
}
} catch (error) {
if (signal.aborted) {
throw signal.reason ?? error
}
throw error
} finally {
await rm(validationVenv, { recursive: true, force: true }).catch(() => undefined)
}
}
export class LocalToolEnvironmentService {
private readonly platform: PythonArtifactPlatform
private readonly arch: PythonArtifactArch
private candidates: LocalToolEnvironmentSnapshot['candidates'] = []
private diagnostics: LocalToolEnvironmentSnapshot['diagnostics'] = {}
private operation?: LocalToolPythonOperation
private operationController?: AbortController
private installOperation?: Promise<LocalToolEnvironmentSnapshot>
private launchEnvironment: Readonly<NodeJS.ProcessEnv> = Object.freeze({})
private listeners = new Set<ProgressListener>()
readonly launchEnvironmentProvider: LaunchEnvironmentProvider = () =>
this.launchEnvironment
constructor(
private readonly options: LocalToolEnvironmentServiceOptions,
private readonly dependencies: LocalToolEnvironmentServiceDependencies = {}
) {
this.platform = options.platform ?? (process.platform as PythonArtifactPlatform)
this.arch = options.arch ?? (process.arch as PythonArtifactArch)
if (!['win32', 'darwin', 'linux'].includes(this.platform)) {
throw new Error(`Managed Python is unsupported on ${this.platform}`)
}
if (!['x64', 'arm64'].includes(this.arch)) {
throw new Error(`Managed Python is unsupported on ${this.arch}`)
}
}
onProgress(listener: ProgressListener): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private managedDirectory(version: string): string {
return join(this.options.managedPythonRoot, `python-${version}`)
}
private async installedPython(version: string): Promise<string | undefined> {
return existingFile(pythonExecutable(this.managedDirectory(version), this.platform))
}
private async settings(): Promise<LocalToolEnvironmentSettings> {
return (await this.options.settingsStore.get()).localToolEnvironment
}
private async rebuildLaunchEnvironment(
signal?: AbortSignal
): Promise<LocalToolEnvironment> {
const settings = await this.settings()
const catalog = await loadPythonArtifactCatalog(
this.options.pythonArtifactCatalogPath
)
const generationDirectory = join(
this.options.binDirectory,
`generation-${randomUUID()}`
)
try {
const environment = await createLocalToolEnvironment(
{
binDirectory: generationDirectory,
nodeSelection: settings.node,
pythonSelection: settings.python,
packagedNpmCliPath: this.options.packagedNpmCliPath,
packagedNpxCliPath: this.options.packagedNpxCliPath,
managedPythonExecutablePath: await this.installedPython(
catalog.pythonVersion
),
baseEnvironment: this.options.baseEnvironment,
...(signal ? { signal } : {})
},
{
...this.dependencies.toolEnvironment,
...(this.options.electronExecutablePath
? { electronExecutablePath: this.options.electronExecutablePath }
: {})
}
)
this.launchEnvironment = Object.freeze({ ...environment.environment })
return environment
} catch (error) {
await rm(generationDirectory, {
recursive: true,
force: true
}).catch(() => undefined)
throw error
}
}
async initialize(): Promise<void> {
await rm(this.options.binDirectory, {
recursive: true,
force: true
})
await this.rebuildLaunchEnvironment()
}
async getSnapshot(): Promise<LocalToolEnvironmentSnapshot> {
const catalog = await loadPythonArtifactCatalog(
this.options.pythonArtifactCatalogPath
)
const executablePath = await this.installedPython(catalog.pythonVersion)
return {
settings: await this.settings(),
candidates: [...this.candidates],
diagnostics: { ...this.diagnostics },
managedPython: {
version: catalog.pythonVersion,
installed: executablePath !== undefined,
...(executablePath ? { executablePath } : {}),
...(this.operation ? { operation: { ...this.operation } } : {})
}
}
}
private async emit(): Promise<void> {
const progress = { snapshot: await this.getSnapshot() }
for (const listener of this.listeners) listener(progress)
}
async updateSettings(input: unknown): Promise<LocalToolEnvironmentSnapshot> {
let settings = localToolEnvironmentSettingsSchema.parse(input)
const current = await this.settings()
for (const kind of ['node', 'python'] as const) {
const proposed = settings[kind]
const persisted = current[kind]
if (
proposed.source === 'custom' &&
(persisted.source !== 'custom' ||
persisted.executablePath !== proposed.executablePath)
) {
const candidate = await inspectLocalToolExecutable(
kind,
proposed.executablePath,
{
baseEnvironment: this.options.baseEnvironment ?? process.env
},
this.dependencies.toolEnvironment
)
if (!candidate) {
throw new Error(`Selected ${kind} executable is invalid`)
}
settings = {
...settings,
[kind]: {
source: 'custom',
executablePath: candidate.executablePath
}
}
}
}
await this.options.settingsStore.update({
localToolEnvironment: settings
})
this.diagnostics = {}
try {
await this.rebuildLaunchEnvironment()
} catch (error) {
await this.options.settingsStore.update({
localToolEnvironment: current
})
await this.rebuildLaunchEnvironment().catch(() => undefined)
throw error
}
return this.getSnapshot()
}
async refreshCandidates(): Promise<LocalToolEnvironmentSnapshot> {
this.candidates = await discoverCandidates(
this.options.baseEnvironment ?? process.env,
this.platform,
await this.settings(),
this.dependencies.toolEnvironment ?? {}
)
return this.getSnapshot()
}
async selectExecutable(kind: LocalToolKind): Promise<LocalToolEnvironmentSnapshot> {
const executablePath = await this.options.selectExecutable(kind)
if (!executablePath) return this.getSnapshot()
const current = await this.settings()
return this.updateSettings({
...current,
[kind]: { source: 'custom', executablePath }
})
}
async diagnose(target: LocalToolDiagnoseTarget): Promise<LocalToolEnvironmentSnapshot> {
const environment = await this.rebuildLaunchEnvironment(
this.operationController?.signal
)
const resolved = Object.fromEntries(
Object.entries(environment.diagnostics.tools).map(([name, diagnostic]) => [
name,
{
available: diagnostic.available,
source: diagnostic.source,
...(diagnostic.version ? { version: diagnostic.version } : {}),
...(diagnostic.executablePath
? { executablePath: diagnostic.executablePath }
: {}),
detail: diagnostic.detail
}
])
)
const names =
target === 'node'
? ['node', 'npm', 'npx']
: target === 'python'
? ['python', 'python3', 'pip']
: ['node', 'npm', 'npx', 'python', 'python3', 'pip']
this.diagnostics = {
...this.diagnostics,
...Object.fromEntries(names.map((name) => [name, resolved[name]]))
}
return this.getSnapshot()
}
private async setOperation(operation: LocalToolPythonOperation): Promise<void> {
this.operation = operation
await this.emit()
}
installPython(): Promise<LocalToolEnvironmentSnapshot> {
if (this.operationController) throw new Error('A Managed Python operation is already running')
const operation = this.runPythonInstall()
this.installOperation = operation
void operation.finally(() => {
if (this.installOperation === operation) {
this.installOperation = undefined
}
}).catch(() => undefined)
return operation
}
private async runPythonInstall(): Promise<LocalToolEnvironmentSnapshot> {
const controller = new AbortController()
this.operationController = controller
let archivePath: string | undefined
try {
const settings = await this.settings()
const source: ArtifactDownloadSource = settings.artifactDownloadSource
const catalog = await loadPythonArtifactCatalog(this.options.pythonArtifactCatalogPath)
const artifact = selectPythonArtifact({
catalog,
platform: this.platform,
arch: this.arch,
source
})
await mkdir(this.options.managedPythonRoot, { recursive: true })
await cleanupManagedPythonOperations(this.options.managedPythonRoot)
archivePath = join(
this.options.managedPythonRoot,
`.managed-python-download-${randomUUID()}`
)
await this.setOperation({
source,
phase: 'downloading',
receivedBytes: 0,
totalBytes: artifact.size
})
await downloadPythonArtifact({
artifact,
destinationPath: archivePath,
transport: this.dependencies.transport,
signal: controller.signal,
onProgress: (receivedBytes, totalBytes) => {
this.operation = {
source,
phase: 'downloading',
receivedBytes,
totalBytes
}
void this.emit().catch(() => undefined)
}
})
const downloadedArchive = archivePath
const destination = await installManagedPython({
rootDirectory: this.options.managedPythonRoot,
version: catalog.pythonVersion,
signal: controller.signal,
stage: async (stagingDirectory) => {
await this.setOperation({ source, phase: 'extracting' })
await extractPythonArtifact({
artifact,
archivePath: downloadedArchive,
destinationDirectory: join(stagingDirectory, 'python'),
signal: controller.signal
})
},
validate: async (stagingDirectory) => {
await this.setOperation({ source, phase: 'validating' })
await validateManagedPython(
pythonExecutable(stagingDirectory, this.platform),
stagingDirectory,
catalog.pythonVersion,
this.arch,
controller.signal,
this.platform,
this.dependencies.toolEnvironment
)
await this.setOperation({ source, phase: 'publishing' })
}
})
if (!(await existingFile(pythonExecutable(destination, this.platform)))) {
throw new Error('Managed Python publish did not produce an executable')
}
} finally {
if (archivePath) await rm(archivePath, { force: true }).catch(() => undefined)
this.operation = undefined
this.operationController = undefined
await this.emit()
}
return this.diagnose('python')
}
cancelPython(): boolean {
if (!this.operationController) return false
this.operationController.abort(
new DOMException('Managed Python operation cancelled', 'AbortError')
)
return true
}
async removePython(): Promise<LocalToolEnvironmentSnapshot> {
if (this.operationController) throw new Error('A Managed Python operation is running')
const catalog = await loadPythonArtifactCatalog(this.options.pythonArtifactCatalogPath)
await removeManagedPython({
rootDirectory: this.options.managedPythonRoot,
version: catalog.pythonVersion
})
this.diagnostics = {}
await this.rebuildLaunchEnvironment()
await this.emit()
return this.getSnapshot()
}
async dispose(): Promise<void> {
this.cancelPython()
await this.installOperation?.catch(() => undefined)
await rm(this.options.binDirectory, {
recursive: true,
force: true
})
}
}
@@ -0,0 +1,571 @@
import { EventEmitter } from 'node:events'
import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createLocalToolEnvironment,
inspectLocalToolExecutable,
resolveNpmCliPaths,
type LocalToolProbeProcess,
type LocalToolProbeSpawn
} from './local-tool-environment'
const temporaryDirectories: string[] = []
async function temporaryDirectory(name: string): Promise<string> {
const path = join(
tmpdir(),
`goodbuddy-${name}-${process.pid}-${Math.random().toString(16).slice(2)}`
)
await mkdir(path, { recursive: true })
temporaryDirectories.push(path)
return path
}
afterEach(async () => {
for (const directory of temporaryDirectories.splice(0)) {
const { rm } = await import('node:fs/promises')
await rm(directory, { recursive: true, force: true })
}
})
type FakeProbe = LocalToolProbeProcess & EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
}
function probeSpawn(
outputFor: (command: string, args: string[]) => {
code?: number
stderr?: string
stdout?: string
}
): LocalToolProbeSpawn {
return (command, args) => {
const child = new EventEmitter() as unknown as FakeProbe
child.exitCode = null
child.killed = false
child.pid = 1234
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = vi.fn(() => {
child.killed = true
return true
})
queueMicrotask(() => {
const result = outputFor(command, args)
if (result.stdout) {
child.stdout.emit('data', Buffer.from(result.stdout))
}
if (result.stderr) {
child.stderr.emit('data', Buffer.from(result.stderr))
}
child.exitCode = result.code ?? 0
child.emit('close', child.exitCode)
})
return child
}
}
async function fixtureFiles(root: string): Promise<{
electron: string
managedNpm: string
managedNpx: string
managedPython: string
}> {
const electron = join(root, 'Electron Runtime')
const managedNpm = join(root, 'packaged npm-cli.js')
const managedNpx = join(root, 'packaged npx-cli.js')
const managedPython = join(root, 'Managed Python')
await Promise.all(
[electron, managedNpm, managedNpx, managedPython].map((path) =>
writeFile(path, 'fixture')
)
)
await Promise.all(
[electron, managedPython].map((path) => chmod(path, 0o700))
)
return { electron, managedNpm, managedNpx, managedPython }
}
describe('local tool environment', () => {
it('creates managed shims and an immutable PATH snapshot', async () => {
const root = await temporaryDirectory("local-tools'posix")
const paths = await fixtureFiles(root)
const source = {
PATH: '/original/bin',
CUSTOM: 'kept',
ELECTRON_RUN_AS_NODE: 'inherited'
}
const spawnProcess = vi.fn(
probeSpawn((command, args) => {
if (args[0] === '-e') {
return {
stdout: '{"version":"22.14.0","architecture":"x64"}\n'
}
}
if (args[0] === '-I') {
return {
stdout: '{"version":"3.13.2","architecture":"x86_64"}\n'
}
}
if (args[0] === paths.managedNpm || args[0] === paths.managedNpx) {
return { stdout: '11.19.0\n' }
}
if (args.join(' ') === '-m pip --version') {
return { stdout: 'pip 25.0 from anywhere (python 3.13)\n' }
}
return {
stderr:
command === paths.managedPython
? 'Python 3.13.2\n'
: 'v22.14.0\n'
}
})
)
const bin = join(root, 'private bin')
const result = await createLocalToolEnvironment(
{
binDirectory: bin,
nodeSelection: { source: 'managed' },
pythonSelection: { source: 'managed' },
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
managedPythonExecutablePath: paths.managedPython,
baseEnvironment: source
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess
}
)
expect(result.environment.PATH).toBe(`${bin}:${source.PATH}`)
expect(result.environment.Path).toBeUndefined()
expect(result.environment.ELECTRON_RUN_AS_NODE).toBeUndefined()
expect(result.environment.OPENAI_API_KEY).toBeUndefined()
expect(Object.isFrozen(result.environment)).toBe(true)
expect(source.ELECTRON_RUN_AS_NODE).toBe('inherited')
for (const name of ['node', 'npm', 'npx'] as const) {
expect(result.diagnostics.tools[name]).toMatchObject({
available: true,
source: 'managed'
})
}
for (const name of ['python', 'python3', 'pip'] as const) {
expect(result.diagnostics.tools[name]).toMatchObject({
available: true,
source: 'managed'
})
}
const nodeShim = await readFile(join(bin, 'node'), 'utf8')
const npmShim = await readFile(join(bin, 'npm'), 'utf8')
const pipShim = await readFile(join(bin, 'pip'), 'utf8')
expect(nodeShim).toContain(
`ELECTRON_RUN_AS_NODE=1 exec '${paths.electron.replaceAll("'", "'\\''")}' "$@"`
)
expect(npmShim).toContain(
`npm_execpath='${paths.managedNpm.replaceAll("'", "'\\''")}'`
)
expect(pipShim).toContain(" '-m' 'pip' \"$@\"")
if (process.platform !== 'win32') {
expect((await stat(join(bin, 'node'))).mode & 0o700).toBe(0o700)
}
expect(
spawnProcess.mock.calls.find(([command]) => command === paths.electron)?.[2]
.env.ELECTRON_RUN_AS_NODE
).toBe('1')
})
it('keeps invalid custom selections unavailable without managed or PATH fallback', async () => {
const root = await temporaryDirectory('local-tools-no-fallback')
const paths = await fixtureFiles(root)
const spawnProcess = vi.fn(probeSpawn(() => ({ stdout: 'unexpected' })))
const missingNode = join(root, 'missing-node')
const missingPython = join(root, 'missing-python')
const result = await createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: {
source: 'custom',
executablePath: missingNode
},
pythonSelection: {
source: 'custom',
executablePath: missingPython
},
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
managedPythonExecutablePath: paths.managedPython,
baseEnvironment: { PATH: dirnameForPath(paths.electron) }
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess
}
)
expect(spawnProcess).not.toHaveBeenCalled()
for (const diagnostic of Object.values(result.diagnostics.tools)) {
expect(diagnostic).toMatchObject({
available: false,
source: 'custom'
})
}
expect(result.diagnostics.tools.node.executablePath).toBe(missingNode)
expect(result.diagnostics.tools.python.executablePath).toBe(missingPython)
})
it('does not replace custom runtimes that fail their probes', async () => {
const root = await temporaryDirectory('local-tools-probe-failure')
const paths = await fixtureFiles(root)
const customNode = join(root, 'custom-node')
const customPython = join(root, 'custom-python')
await Promise.all(
[customNode, customPython].map((path) => writeFile(path, 'fixture'))
)
const spawnProcess = vi.fn(
probeSpawn(() => ({ code: 1, stderr: 'probe failed' }))
)
const result = await createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: {
source: 'custom',
executablePath: customNode
},
pythonSelection: {
source: 'custom',
executablePath: customPython
},
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
managedPythonExecutablePath: paths.managedPython
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess
}
)
expect(spawnProcess).toHaveBeenCalledTimes(2)
expect(spawnProcess.mock.calls.map(([command]) => command).sort()).toEqual(
[customNode, customPython].sort()
)
expect(result.diagnostics.tools.node).toMatchObject({
available: false,
source: 'custom',
executablePath: customNode
})
expect(result.diagnostics.tools.python).toMatchObject({
available: false,
source: 'custom',
executablePath: customPython
})
})
it('does not mix packaged npm or npx into a custom Node selection', async () => {
const root = await temporaryDirectory('local-tools-no-mixing')
const paths = await fixtureFiles(root)
const customDirectory = join(root, 'custom-node')
await mkdir(customDirectory)
const customNode = join(customDirectory, 'node')
await writeFile(customNode, 'fixture')
const spawnProcess = vi.fn(
probeSpawn((command) => ({
stdout:
command === customNode
? '{"version":"20.18.1","architecture":"x64"}\n'
: 'unexpected\n'
}))
)
const result = await createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: {
source: 'custom',
executablePath: customNode
},
pythonSelection: { source: 'managed' },
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
baseEnvironment: { PATH: root }
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess
}
)
expect(result.diagnostics.tools.node).toMatchObject({
available: true,
source: 'custom',
executablePath: customNode
})
expect(result.diagnostics.tools.npm).toMatchObject({
available: false,
source: 'custom'
})
expect(result.diagnostics.tools.npx).toMatchObject({
available: false,
source: 'custom'
})
expect(spawnProcess).toHaveBeenCalledTimes(1)
expect(spawnProcess).not.toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([paths.managedNpm]),
expect.anything()
)
})
it('uses only verified adjacent package managers with custom Node', async () => {
const root = await temporaryDirectory('local-tools-custom-adjacent')
const paths = await fixtureFiles(root)
const customDirectory = join(root, 'custom-node')
await mkdir(customDirectory)
const customNode = join(customDirectory, 'node')
const adjacentNpm = join(customDirectory, 'npm-cli.js')
const adjacentNpx = join(customDirectory, 'npx-cli.js')
await Promise.all(
[customNode, adjacentNpm, adjacentNpx].map((path) =>
writeFile(path, 'fixture')
)
)
const result = await createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: {
source: 'custom',
executablePath: customNode
},
pythonSelection: { source: 'managed' },
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess: probeSpawn((_command, args) => ({
stdout:
args[0] === adjacentNpm || args[0] === adjacentNpx
? '10.8.0\n'
: '{"version":"20.18.1","architecture":"x64"}\n'
}))
}
)
expect(result.diagnostics.tools.npm).toMatchObject({
available: true,
source: 'custom',
executablePath: adjacentNpm
})
expect(result.diagnostics.tools.npx).toMatchObject({
available: true,
source: 'custom',
executablePath: adjacentNpx
})
expect(await readFile(join(root, 'bin', 'npm'), 'utf8')).toContain(
`'${adjacentNpm}'`
)
})
it('leaves an uninstalled managed Python unavailable without PATH probing', async () => {
const root = await temporaryDirectory('local-tools-python-uninstalled')
const paths = await fixtureFiles(root)
const spawnProcess = vi.fn(
probeSpawn((command, args) => ({
stdout:
command === paths.electron && args[0] === '-e'
? '{"version":"22.14.0","architecture":"x64"}\n'
: '11.19.0\n'
}))
)
const result = await createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: { source: 'managed' },
pythonSelection: { source: 'managed' },
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
baseEnvironment: { PATH: dirnameForPath(paths.managedPython) }
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess
}
)
for (const name of ['python', 'python3', 'pip'] as const) {
expect(result.diagnostics.tools[name]).toMatchObject({
available: false,
source: 'managed'
})
}
expect(spawnProcess.mock.calls.every(([command]) => command !== paths.managedPython)).toBe(true)
})
it('writes Windows wrappers with wrapper-local Electron variables', async () => {
const root = await temporaryDirectory('local-tools-%-win')
const paths = await fixtureFiles(root)
const result = await createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: { source: 'managed' },
pythonSelection: {
source: 'custom',
executablePath: paths.managedPython
},
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
baseEnvironment: { Path: 'C:\\Windows\\System32' }
},
{
platform: 'win32',
electronExecutablePath: paths.electron,
spawnProcess: probeSpawn((command, args) => ({
stdout:
command === paths.managedPython
? args.includes('pip')
? 'pip 25.0\n'
: '{"version":"3.13.2","architecture":"amd64"}\n'
: args[0] === '-e'
? '{"version":"22.14.0","architecture":"x64"}\n'
: '11.19.0\n'
}))
}
)
const nodeShim = await readFile(join(root, 'bin', 'node.cmd'), 'utf8')
const npmShim = await readFile(join(root, 'bin', 'npm.cmd'), 'utf8')
expect(nodeShim).toContain('set "ELECTRON_RUN_AS_NODE=1"')
expect(npmShim).toContain(
`set "npm_node_execpath=${paths.electron.replaceAll('%', '%%')}"`
)
expect(result.environment.PATH).toBe(
`${join(root, 'bin')};C:\\Windows\\System32`
)
})
it('cancels probes through process-tree cleanup', async () => {
const root = await temporaryDirectory('local-tools-cancel')
const paths = await fixtureFiles(root)
const terminate = vi.fn(async (child: LocalToolProbeProcess) => {
child.exitCode = 1
})
const hangingSpawn = vi.fn<LocalToolProbeSpawn>(() => {
const child = new EventEmitter() as unknown as FakeProbe
child.exitCode = null
child.killed = false
child.pid = 4321
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = vi.fn()
return child
})
const controller = new AbortController()
const operation = createLocalToolEnvironment(
{
binDirectory: join(root, 'bin'),
nodeSelection: { source: 'managed' },
pythonSelection: { source: 'managed' },
packagedNpmCliPath: paths.managedNpm,
packagedNpxCliPath: paths.managedNpx,
managedPythonExecutablePath: paths.managedPython,
signal: controller.signal,
probeTimeoutMs: 60_000
},
{
platform: 'linux',
electronExecutablePath: paths.electron,
spawnProcess: hangingSpawn,
terminateProcessTree: terminate
}
)
await vi.waitFor(() => expect(hangingSpawn).toHaveBeenCalledTimes(2))
controller.abort(new Error('cancelled by test'))
await expect(operation).rejects.toThrow('cancelled by test')
expect(terminate).toHaveBeenCalledTimes(2)
})
it('inspects executed runtime metadata and rejects Python 2', async () => {
const root = await temporaryDirectory('local-tools-inspection')
const executable = join(root, 'runtime')
await writeFile(executable, 'fixture')
await expect(
inspectLocalToolExecutable(
'node',
executable,
{ baseEnvironment: { PATH: root, NODE_OPTIONS: '--inspect' } },
{
platform: 'linux',
spawnProcess: probeSpawn((_command, args) => ({
stdout: args.includes('-e')
? '{"version":"22.14.0","architecture":"arm64"}\n'
: ''
}))
}
)
).resolves.toEqual({
kind: 'node',
executablePath: await import('node:fs/promises').then(({ realpath }) =>
realpath(executable)
),
version: '22.14.0',
architecture: 'arm64'
})
await expect(
inspectLocalToolExecutable(
'python',
executable,
{ baseEnvironment: { PATH: root } },
{
platform: 'linux',
spawnProcess: probeSpawn(() => ({
stdout: '{"version":"2.7.18","architecture":"x86_64"}\n'
}))
}
)
).resolves.toBeUndefined()
})
it('resolves development and packaged npm CLI locations', () => {
expect(
resolveNpmCliPaths({
appPath: '/app',
resourcesPath: '/resources',
packaged: false
})
).toEqual({
npmCliPath: join('/app', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
npxCliPath: join('/app', 'node_modules', 'npm', 'bin', 'npx-cli.js')
})
expect(
resolveNpmCliPaths({
appPath: '/app',
resourcesPath: '/resources',
packaged: true
})
).toEqual({
npmCliPath: join('/resources', 'runtimes', 'npm', 'bin', 'npm-cli.js'),
npxCliPath: join('/resources', 'runtimes', 'npm', 'bin', 'npx-cli.js')
})
})
})
function dirnameForPath(path: string): string {
return path.slice(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')))
}
@@ -0,0 +1,815 @@
import { chmod, mkdir, realpath, stat, unlink, writeFile } from 'node:fs/promises'
import { dirname, isAbsolute, join } from 'node:path'
import spawn from 'cross-spawn'
import type {
LocalToolCandidate,
LocalToolKind,
LocalToolRuntimeSelection
} from '../../shared/local-tool-environment-contracts'
import {
terminateProcessTreeAndWait,
type WaitableProcessTreeChild
} from '../agent/child-process-termination'
const DEFAULT_PROBE_TIMEOUT_MS = 3_000
const DEFAULT_PROBE_OUTPUT_LIMIT = 8 * 1024
const DEFAULT_TERMINATION_WAIT_MS = 500
export type LocalToolName =
| 'node'
| 'npm'
| 'npx'
| 'python'
| 'python3'
| 'pip'
export type LocalToolSource = LocalToolRuntimeSelection['source']
export type LocalToolDiagnostic = {
available: boolean
source: LocalToolSource
version?: string
executablePath?: string
shimPath?: string
detail: string
}
export type LocalToolEnvironmentDiagnostics = {
binDirectory: string
tools: Readonly<Record<LocalToolName, LocalToolDiagnostic>>
warnings: readonly string[]
}
export type LocalToolEnvironment = {
binDirectory: string
environment: Readonly<NodeJS.ProcessEnv>
diagnostics: LocalToolEnvironmentDiagnostics
}
type ProbeOutput = {
on(event: 'data', listener: (chunk: Buffer | string) => void): unknown
}
export type LocalToolProbeProcess = WaitableProcessTreeChild & {
stdout?: ProbeOutput | null
stderr?: ProbeOutput | null
}
export type LocalToolProbeSpawn = (
command: string,
args: string[],
options: {
detached: boolean
env: NodeJS.ProcessEnv
shell: false
stdio: ['ignore', 'pipe', 'pipe']
windowsHide: true
}
) => LocalToolProbeProcess
export type LocalToolEnvironmentOptions = {
binDirectory: string
nodeSelection: LocalToolRuntimeSelection
pythonSelection: LocalToolRuntimeSelection
packagedNpmCliPath: string
packagedNpxCliPath: string
managedPythonExecutablePath?: string
baseEnvironment?: NodeJS.ProcessEnv
signal?: AbortSignal
probeTimeoutMs?: number
probeOutputLimit?: number
terminationWaitMs?: number
}
export type LocalToolEnvironmentDependencies = {
platform?: NodeJS.Platform
electronExecutablePath?: string
spawnProcess?: LocalToolProbeSpawn
terminateProcessTree?: typeof terminateProcessTreeAndWait
}
type ProbeResult =
| { ok: true; output: string }
| {
ok: false
reason: 'cancelled' | 'exit' | 'output-limit' | 'spawn' | 'timeout'
}
type ResolvedRuntime = {
path: string
source: LocalToolSource
managedElectron?: boolean
version?: string
}
type PackageManagerInvocation = {
command: string
argsPrefix: readonly string[]
path: string
source: LocalToolSource
managedElectron?: boolean
version?: string
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`
}
function quoteCmd(value: string): string {
return `"${value.replaceAll('%', '%%')}"`
}
function stripUnsafeCharacters(value: string): string {
let result = ''
let inEscapeSequence = false
for (const character of value) {
const point = character.codePointAt(0) ?? 0
if (inEscapeSequence) {
if (point >= 64 && point <= 126) {
inEscapeSequence = false
}
continue
}
if (point === 27) {
inEscapeSequence = true
} else if (point >= 32 && point !== 127) {
result += character
}
}
return result
}
function safeVersion(output: string): string | undefined {
const firstLine = output
.split(/\r?\n/u)
.map((line) => stripUnsafeCharacters(line).trim())
.find(Boolean)
if (!firstLine) {
return undefined
}
const version = firstLine.match(
/\bv?(\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)\b/u
)
return (version?.[1] ?? firstLine).slice(0, 160)
}
function abortError(signal: AbortSignal): unknown {
return signal.reason ?? new DOMException('Operation aborted', 'AbortError')
}
async function canonicalFile(filePath: string): Promise<string | undefined> {
if (!isAbsolute(filePath)) {
return undefined
}
try {
const path = await realpath(filePath)
return (await stat(path)).isFile() ? path : undefined
} catch {
return undefined
}
}
function probeExecutable(
command: string,
args: string[],
environment: NodeJS.ProcessEnv,
signal: AbortSignal | undefined,
dependencies: LocalToolEnvironmentDependencies,
options: LocalToolEnvironmentOptions
): Promise<ProbeResult> {
return new Promise((resolve) => {
if (signal?.aborted) {
resolve({ ok: false, reason: 'cancelled' })
return
}
const platform = dependencies.platform ?? process.platform
let child: LocalToolProbeProcess
try {
child = (dependencies.spawnProcess ?? spawn)(command, args, {
detached: platform !== 'win32',
env: environment,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
} catch {
resolve({ ok: false, reason: 'spawn' })
return
}
let settled = false
let cleaning = false
let output = ''
let outputBytes = 0
const finish = (result: ProbeResult): void => {
if (settled || cleaning) {
return
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', onAbort)
resolve(result)
}
const failWithCleanup = (
reason: Extract<ProbeResult, { ok: false }>['reason']
): void => {
if (settled || cleaning) {
return
}
cleaning = true
clearTimeout(timeout)
signal?.removeEventListener('abort', onAbort)
void (
dependencies.terminateProcessTree ?? terminateProcessTreeAndWait
)(child, {
platform,
processGroup: platform !== 'win32',
signal: 'SIGKILL',
waitMs: options.terminationWaitMs ?? DEFAULT_TERMINATION_WAIT_MS
}).then(
() => {
settled = true
resolve({ ok: false, reason })
},
() => {
settled = true
resolve({ ok: false, reason })
}
)
}
const onAbort = (): void => failWithCleanup('cancelled')
const timeout = setTimeout(
() => failWithCleanup('timeout'),
options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS
)
signal?.addEventListener('abort', onAbort, { once: true })
const collect = (chunk: Buffer | string): void => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
outputBytes += value.byteLength
if (outputBytes > (options.probeOutputLimit ?? DEFAULT_PROBE_OUTPUT_LIMIT)) {
failWithCleanup('output-limit')
return
}
output += value.toString('utf8')
}
child.stdout?.on('data', collect)
child.stderr?.on('data', collect)
child.once('error', () => finish({ ok: false, reason: 'spawn' }))
child.once('close', (code: unknown) => {
if (cleaning) {
return
}
finish(
code === 0
? { ok: true, output }
: { ok: false, reason: 'exit' }
)
})
})
}
export type LocalToolExecutableInspectionOptions = {
baseEnvironment: NodeJS.ProcessEnv
managedElectron?: boolean
signal?: AbortSignal
probeTimeoutMs?: number
probeOutputLimit?: number
terminationWaitMs?: number
}
export async function inspectLocalToolExecutable(
kind: LocalToolKind,
executablePath: string,
options: LocalToolExecutableInspectionOptions,
dependencies: LocalToolEnvironmentDependencies = {}
): Promise<LocalToolCandidate | undefined> {
const path = await canonicalFile(executablePath)
if (!path) {
return undefined
}
const script =
kind === 'node'
? 'console.log(JSON.stringify({version:process.versions.node,architecture:process.arch}))'
: [
'import json, platform, sys',
'print(json.dumps({"version": platform.python_version(), "architecture": platform.machine()}))'
].join(';')
const probe = await probeExecutable(
path,
kind === 'node' ? ['-e', script] : ['-I', '-c', script],
runtimeProbeEnvironment(
options.baseEnvironment,
options.managedElectron === true
),
options.signal,
dependencies,
{
binDirectory: dirname(path),
nodeSelection: { source: 'managed' },
pythonSelection: { source: 'managed' },
packagedNpmCliPath: path,
packagedNpxCliPath: path,
...options
}
)
if (!probe.ok) {
if (probe.reason === 'cancelled') {
throw abortError(options.signal!)
}
return undefined
}
try {
const parsed = JSON.parse(probe.output.trim()) as {
version?: unknown
architecture?: unknown
}
if (
typeof parsed.version !== 'string' ||
!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(parsed.version) ||
(kind === 'python' && !parsed.version.startsWith('3.')) ||
typeof parsed.architecture !== 'string'
) {
return undefined
}
const architecture = parsed.architecture.trim().toLocaleLowerCase()
if (!/^[0-9a-z][0-9a-z._-]{0,63}$/u.test(architecture)) {
return undefined
}
return {
kind,
executablePath: path,
version: parsed.version,
architecture
}
} catch {
return undefined
}
}
function runtimeProbeEnvironment(
base: NodeJS.ProcessEnv,
managedElectron: boolean
): NodeJS.ProcessEnv {
const environment = { ...base }
for (const key of Object.keys(environment)) {
if (
[
'electron_run_as_node',
'node_options',
'node_path',
'pythonhome',
'pythoninspect',
'pythonpath',
'pythonstartup',
'pythonwarnings'
].includes(key.toLocaleLowerCase())
) {
delete environment[key]
}
}
if (managedElectron) {
environment.ELECTRON_RUN_AS_NODE = '1'
}
return environment
}
async function resolveRuntime(
kind: LocalToolKind,
selection: LocalToolRuntimeSelection,
managedExecutablePath: string | undefined,
managedElectron: boolean,
options: LocalToolEnvironmentOptions,
dependencies: LocalToolEnvironmentDependencies,
base: NodeJS.ProcessEnv
): Promise<ResolvedRuntime | undefined> {
const selectedPath =
selection.source === 'custom'
? selection.executablePath
: managedExecutablePath
if (!selectedPath) {
return undefined
}
const inspected = await inspectLocalToolExecutable(
kind,
selectedPath,
{
baseEnvironment: base,
managedElectron,
...(options.signal ? { signal: options.signal } : {}),
...(options.probeTimeoutMs
? { probeTimeoutMs: options.probeTimeoutMs }
: {}),
...(options.probeOutputLimit
? { probeOutputLimit: options.probeOutputLimit }
: {}),
...(options.terminationWaitMs
? { terminationWaitMs: options.terminationWaitMs }
: {})
},
dependencies
)
if (!inspected) {
return undefined
}
return {
path: inspected.executablePath,
source: selection.source,
managedElectron,
version: inspected.version
}
}
function adjacentPackageManagerNames(
name: 'npm' | 'npx',
platform: NodeJS.Platform
): readonly string[] {
return platform === 'win32'
? [`${name}.cmd`, `${name}.exe`, `${name}-cli.js`]
: [name, `${name}-cli.js`]
}
async function resolvePackageManager(
name: 'npm' | 'npx',
node: ResolvedRuntime,
packagedCliPath: string,
options: LocalToolEnvironmentOptions,
dependencies: LocalToolEnvironmentDependencies,
base: NodeJS.ProcessEnv
): Promise<PackageManagerInvocation | undefined> {
const platform = dependencies.platform ?? process.platform
const candidates =
node.source === 'managed'
? [{ path: packagedCliPath, cli: true }]
: adjacentPackageManagerNames(name, platform).map((fileName) => ({
path: join(dirname(node.path), fileName),
cli: fileName.endsWith('-cli.js')
}))
for (const candidate of candidates) {
const path = await canonicalFile(candidate.path)
if (!path) {
continue
}
const command = candidate.cli ? node.path : path
const argsPrefix = candidate.cli ? [path] : []
const probe = await probeExecutable(
command,
[...argsPrefix, '--version'],
runtimeProbeEnvironment(base, node.managedElectron === true),
options.signal,
dependencies,
options
)
if (!probe.ok) {
if (probe.reason === 'cancelled') {
throw abortError(options.signal!)
}
continue
}
return {
command,
argsPrefix,
path,
source: node.source,
managedElectron: node.managedElectron,
version: safeVersion(probe.output)
}
}
return undefined
}
function shimFileName(name: LocalToolName, platform: NodeJS.Platform): string {
return platform === 'win32' ? `${name}.cmd` : name
}
function commandLines(input: {
platform: NodeJS.Platform
command: string
argsPrefix?: readonly string[]
managedElectron?: boolean
packageManagerPath?: string
nodePath?: string
}): string {
const prefix = input.argsPrefix ?? []
if (input.platform === 'win32') {
const lines = ['@echo off', 'setlocal']
if (input.managedElectron) {
lines.push('set "ELECTRON_RUN_AS_NODE=1"')
}
if (input.packageManagerPath && input.nodePath) {
lines.push(
`set "npm_execpath=${input.packageManagerPath.replaceAll('%', '%%')}"`
)
lines.push(
`set "npm_node_execpath=${input.nodePath.replaceAll('%', '%%')}"`
)
}
lines.push(
[quoteCmd(input.command), ...prefix.map(quoteCmd), '%*'].join(' ')
)
return `${lines.join('\r\n')}\r\n`
}
const assignments: string[] = []
if (input.managedElectron) {
assignments.push('ELECTRON_RUN_AS_NODE=1')
}
if (input.packageManagerPath && input.nodePath) {
assignments.push(`npm_execpath=${quotePosix(input.packageManagerPath)}`)
assignments.push(`npm_node_execpath=${quotePosix(input.nodePath)}`)
}
return [
'#!/bin/sh',
`${assignments.length ? `${assignments.join(' ')} ` : ''}exec ${quotePosix(
input.command
)}${prefix.length ? ` ${prefix.map(quotePosix).join(' ')}` : ''} "$@"`,
''
].join('\n')
}
async function writeShim(
path: string,
content: string,
platform: NodeJS.Platform
): Promise<void> {
await writeFile(path, content, { encoding: 'utf8', mode: 0o700 })
if (platform !== 'win32') {
await chmod(path, 0o700)
}
}
function unavailable(
source: LocalToolSource,
detail: string,
executablePath?: string
): LocalToolDiagnostic {
return { available: false, source, executablePath, detail }
}
function available(
source: LocalToolSource,
executablePath: string,
version: string | undefined,
shimPath: string
): LocalToolDiagnostic {
return {
available: true,
source,
version,
executablePath,
shimPath,
detail: `${source} tool is ready${version ? ` (${version})` : ''}.`
}
}
function selectedPath(
selection: LocalToolRuntimeSelection,
managedPath: string | undefined
): string | undefined {
return selection.source === 'custom'
? selection.executablePath
: managedPath
}
function launchEnvironmentSnapshot(
binDirectory: string,
source: NodeJS.ProcessEnv,
platform: NodeJS.Platform
): Readonly<NodeJS.ProcessEnv> {
const inheritedPath =
source.PATH ?? source.Path ?? source.path ?? ''
const path = inheritedPath
? `${binDirectory}${platform === 'win32' ? ';' : ':'}${inheritedPath}`
: binDirectory
return Object.freeze({ PATH: path })
}
export function resolveNpmCliPaths(input: {
appPath: string
resourcesPath: string
packaged: boolean
}): { npmCliPath: string; npxCliPath: string } {
const bin = input.packaged
? join(input.resourcesPath, 'runtimes', 'npm', 'bin')
: join(input.appPath, 'node_modules', 'npm', 'bin')
return {
npmCliPath: join(bin, 'npm-cli.js'),
npxCliPath: join(bin, 'npx-cli.js')
}
}
export async function createLocalToolEnvironment(
options: LocalToolEnvironmentOptions,
dependencies: LocalToolEnvironmentDependencies = {}
): Promise<LocalToolEnvironment> {
if (!isAbsolute(options.binDirectory)) {
throw new Error('Local tool bin directory must be absolute.')
}
options.signal?.throwIfAborted()
const platform = dependencies.platform ?? process.platform
const base = { ...(options.baseEnvironment ?? process.env) }
delete base.ELECTRON_RUN_AS_NODE
const warnings: string[] = []
await mkdir(options.binDirectory, { recursive: true, mode: 0o700 })
if (platform !== 'win32') {
await chmod(options.binDirectory, 0o700)
}
const shimPaths = Object.fromEntries(
(['node', 'npm', 'npx', 'python', 'python3', 'pip'] as const).map(
(name) => [name, join(options.binDirectory, shimFileName(name, platform))]
)
) as Record<LocalToolName, string>
await Promise.all(
Object.values(shimPaths).map((path) =>
unlink(path).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') {
throw error
}
})
)
)
const managedNodePath =
dependencies.electronExecutablePath ?? process.execPath
const [node, python] = await Promise.all([
resolveRuntime(
'node',
options.nodeSelection,
managedNodePath,
options.nodeSelection.source === 'managed',
options,
dependencies,
base
),
resolveRuntime(
'python',
options.pythonSelection,
options.managedPythonExecutablePath,
false,
options,
dependencies,
base
)
])
options.signal?.throwIfAborted()
const nodeSelectedPath = selectedPath(
options.nodeSelection,
managedNodePath
)
const pythonSelectedPath = selectedPath(
options.pythonSelection,
options.managedPythonExecutablePath
)
const diagnostics: Record<LocalToolName, LocalToolDiagnostic> = {
node: unavailable(
options.nodeSelection.source,
'The selected Node runtime is unavailable or failed its version probe.',
nodeSelectedPath
),
npm: unavailable(
options.nodeSelection.source,
'No verified npm from the selected Node installation is available.'
),
npx: unavailable(
options.nodeSelection.source,
'No verified npx from the selected Node installation is available.'
),
python: unavailable(
options.pythonSelection.source,
'The selected Python runtime is not installed, invalid, or failed its version probe.',
pythonSelectedPath
),
python3: unavailable(
options.pythonSelection.source,
'The selected Python runtime is not installed, invalid, or failed its version probe.',
pythonSelectedPath
),
pip: unavailable(
options.pythonSelection.source,
'The selected Python runtime does not provide a verified pip module.',
pythonSelectedPath
)
}
if (node) {
await writeShim(
shimPaths.node,
commandLines({
platform,
command: node.path,
managedElectron: node.managedElectron
}),
platform
)
diagnostics.node = available(
node.source,
node.path,
node.version,
shimPaths.node
)
const [npm, npx] = await Promise.all([
resolvePackageManager(
'npm',
node,
options.packagedNpmCliPath,
options,
dependencies,
base
),
resolvePackageManager(
'npx',
node,
options.packagedNpxCliPath,
options,
dependencies,
base
)
])
options.signal?.throwIfAborted()
for (const [name, manager] of [
['npm', npm],
['npx', npx]
] as const) {
if (!manager) {
continue
}
await writeShim(
shimPaths[name],
commandLines({
platform,
command: manager.command,
argsPrefix: manager.argsPrefix,
managedElectron: manager.managedElectron,
packageManagerPath: manager.path,
nodePath: node.path
}),
platform
)
diagnostics[name] = available(
manager.source,
manager.path,
manager.version,
shimPaths[name]
)
}
}
if (python) {
for (const name of ['python', 'python3'] as const) {
await writeShim(
shimPaths[name],
commandLines({ platform, command: python.path }),
platform
)
diagnostics[name] = available(
python.source,
python.path,
python.version,
shimPaths[name]
)
}
const pipProbe = await probeExecutable(
python.path,
['-m', 'pip', '--version'],
base,
options.signal,
dependencies,
options
)
options.signal?.throwIfAborted()
if (pipProbe.ok) {
await writeShim(
shimPaths.pip,
commandLines({
platform,
command: python.path,
argsPrefix: ['-m', 'pip']
}),
platform
)
diagnostics.pip = available(
python.source,
python.path,
safeVersion(pipProbe.output),
shimPaths.pip
)
}
}
return {
binDirectory: options.binDirectory,
environment: launchEnvironmentSnapshot(
options.binDirectory,
base,
platform
),
diagnostics: {
binDirectory: options.binDirectory,
tools: Object.freeze(diagnostics),
warnings: Object.freeze(warnings)
}
}
}
@@ -0,0 +1,87 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
installManagedPython,
removeManagedPython
} from './managed-python-install'
const temporaryDirectories: string[] = []
async function root(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-python-install-'))
temporaryDirectories.push(directory)
return join(directory, 'managed')
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
))
})
describe('Managed Python staged operations', () => {
it('publishes only after validation succeeds', async () => {
const rootDirectory = await root()
const installed = await installManagedPython({
rootDirectory,
version: '3.13.15',
stage: async (directory) => {
await writeFile(join(directory, 'python'), 'new')
},
validate: async (directory) => {
expect(await readFile(join(directory, 'python'), 'utf8')).toBe('new')
}
})
expect(await readFile(join(installed, 'python'), 'utf8')).toBe('new')
})
it('preserves an old same-version install after failed validation', async () => {
const rootDirectory = await root()
await installManagedPython({
rootDirectory,
version: '3.13.15',
stage: (directory) => writeFile(join(directory, 'python'), 'old'),
validate: async () => undefined
})
await expect(installManagedPython({
rootDirectory,
version: '3.13.15',
stage: (directory) => writeFile(join(directory, 'python'), 'bad'),
validate: async () => {
throw new Error('probe failed')
}
})).rejects.toThrow('probe failed')
expect(await readFile(
join(rootDirectory, 'python-3.13.15', 'python'), 'utf8'
)).toBe('old')
})
it('refuses to adopt or remove content outside an owned root', async () => {
const rootDirectory = await root()
await mkdir(rootDirectory)
await writeFile(join(rootDirectory, 'unrelated'), 'keep')
await expect(installManagedPython({
rootDirectory,
version: '3.13.15',
stage: async () => undefined,
validate: async () => undefined
})).rejects.toThrow(/non-empty/u)
expect(await readFile(join(rootDirectory, 'unrelated'), 'utf8')).toBe('keep')
})
it('removes only a selected managed version', async () => {
const rootDirectory = await root()
await installManagedPython({
rootDirectory,
version: '3.13.15',
stage: (directory) => writeFile(join(directory, 'python'), 'ok'),
validate: async () => undefined
})
await removeManagedPython({ rootDirectory, version: '3.13.15' })
await expect(readFile(
join(rootDirectory, 'python-3.13.15', 'python')
)).rejects.toMatchObject({ code: 'ENOENT' })
})
})
@@ -0,0 +1,182 @@
import { randomUUID } from 'node:crypto'
import {
lstat,
mkdir,
open,
readdir,
readFile,
rename,
rm
} from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
const OWNER_MARKER = '.goodbuddy-managed-python'
const OWNER_VALUE = 'GoodBuddy managed Python root\n'
const versionPattern = /^python-(\d+\.\d+\.\d+)$/u
const stagingPattern = /^\.managed-python-stage-[0-9a-f-]{36}$/u
const backupPattern = /^\.managed-python-backup-[0-9a-f-]{36}$/u
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted', 'AbortError')
}
}
function child(root: string, name: string): string {
const path = resolve(root, name)
if (dirname(path) !== resolve(root)) {
throw new Error('Managed Python path escaped its owned root')
}
return path
}
function versionName(version: string): string {
if (!/^\d+\.\d+\.\d+$/u.test(version)) {
throw new Error('Managed Python version is invalid')
}
return `python-${version}`
}
async function ensureOwnedRoot(root: string): Promise<void> {
await mkdir(root, { recursive: true })
const marker = child(root, OWNER_MARKER)
try {
const value = await readFile(marker, 'utf8')
if (value !== OWNER_VALUE) {
throw new Error('Managed Python root has an invalid ownership marker')
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
const entries = await readdir(root)
if (entries.length !== 0) {
throw new Error('Refusing to adopt a non-empty Managed Python root', {
cause: error
})
}
const handle = await open(marker, 'wx')
try {
await handle.writeFile(OWNER_VALUE)
await handle.sync()
} finally {
await handle.close()
}
}
}
export async function cleanupManagedPythonOperations(root: string): Promise<void> {
await ensureOwnedRoot(root)
for (const entry of await readdir(root, { withFileTypes: true })) {
if (
(stagingPattern.test(entry.name) || backupPattern.test(entry.name)) &&
entry.isDirectory() &&
!entry.isSymbolicLink()
) {
await rm(child(root, entry.name), { recursive: true, force: true })
}
}
}
async function pruneVersions(
root: string,
activeName: string,
keepVersions: number
): Promise<void> {
const candidates: { name: string; mtimeMs: number }[] = []
for (const entry of await readdir(root, { withFileTypes: true })) {
if (
entry.name !== activeName &&
versionPattern.test(entry.name) &&
entry.isDirectory() &&
!entry.isSymbolicLink()
) {
const status = await lstat(child(root, entry.name))
candidates.push({ name: entry.name, mtimeMs: status.mtimeMs })
}
}
candidates.sort((left, right) => right.mtimeMs - left.mtimeMs)
for (const candidate of candidates.slice(Math.max(0, keepVersions - 1))) {
await rm(child(root, candidate.name), { recursive: true, force: true })
}
}
export async function installManagedPython(options: {
rootDirectory: string
version: string
stage: (stagingDirectory: string) => Promise<void>
validate: (stagingDirectory: string) => Promise<void>
signal?: AbortSignal
keepVersions?: number
}): Promise<string> {
const root = resolve(options.rootDirectory)
const keepVersions = options.keepVersions ?? 1
if (!Number.isInteger(keepVersions) || keepVersions < 1 || keepVersions > 3) {
throw new Error('Managed Python retained version count must be between 1 and 3')
}
await ensureOwnedRoot(root)
ensureNotAborted(options.signal)
const destinationName = versionName(options.version)
const destination = child(root, destinationName)
const staging = child(root, `.managed-python-stage-${randomUUID()}`)
const backup = child(root, `.managed-python-backup-${randomUUID()}`)
await mkdir(staging)
let movedExisting = false
try {
await options.stage(staging)
ensureNotAborted(options.signal)
await options.validate(staging)
ensureNotAborted(options.signal)
try {
const status = await lstat(destination)
if (!status.isDirectory() || status.isSymbolicLink()) {
throw new Error('Managed Python destination is not an owned directory')
}
await rename(destination, backup)
movedExisting = true
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
}
try {
await rename(staging, destination)
} catch (error) {
if (movedExisting) {
await rename(backup, destination).catch(() => undefined)
}
throw error
}
if (movedExisting) {
await rm(backup, { recursive: true, force: true })
}
await pruneVersions(root, destinationName, keepVersions)
return destination
} catch (error) {
await rm(staging, { recursive: true, force: true }).catch(() => undefined)
throw error
}
}
export async function removeManagedPython(options: {
rootDirectory: string
version: string
}): Promise<void> {
const root = resolve(options.rootDirectory)
await ensureOwnedRoot(root)
const destination = child(root, versionName(options.version))
try {
const status = await lstat(destination)
if (!status.isDirectory() || status.isSymbolicLink()) {
throw new Error('Refusing to remove a non-directory Managed Python path')
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return
}
throw error
}
await rm(destination, { recursive: true, force: true })
}
@@ -0,0 +1,73 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
parsePythonArtifactCatalog,
selectPythonArtifact
} from './python-artifact-catalog'
async function catalogValue(): Promise<unknown> {
return JSON.parse(await readFile(resolve(
'resources/tool-environment/managed-python-artifacts.json'
), 'utf8')) as unknown
}
describe('managed Python artifact catalog', () => {
it('loads exactly one pinned artifact for all six desktop targets', async () => {
const catalog = parsePythonArtifactCatalog(await catalogValue())
expect(catalog.pythonVersion).toBe('3.13.15')
expect(catalog.artifacts.map((artifact) =>
`${artifact.platform}/${artifact.arch}`
)).toEqual([
'win32/x64',
'win32/arm64',
'darwin/x64',
'darwin/arm64',
'linux/x64',
'linux/arm64'
])
expect(catalog.artifacts.map(({ size, sha256 }) => ({ size, sha256 })))
.toEqual([
{ size: 14391248, sha256: '05357887df50d3153efc681bdf432c321d3e2f9ce5788f99f4515b27e8fda0ac' },
{ size: 13762836, sha256: '3c1b1fdf56adc14634165df922d447520aefdc4a8411a34c34d8a062a4edf494' },
{ size: 24921256, sha256: 'd33d61f7f4982c94216e14a43599c75657b7d0839277fc72bc6dbac53e8229bc' },
{ size: 25140257, sha256: '149038dd0c194c25d4616d7e42a35f67f2edee96412788f74115819b6a4c8548' },
{ size: 34813635, sha256: '8af9a8214c71b2dd698005e39fab87aad02a994330508857da4e6d1ba7e6ddb6' },
{ size: 29222461, sha256: 'e5d0df1a6070a8614d808496e5ea28c727480e40ffcce1a94697a067f1690aa8' }
])
})
it('freezes the selected source without fallback', async () => {
const catalog = parsePythonArtifactCatalog(await catalogValue())
const native = selectPythonArtifact({
catalog, platform: 'linux', arch: 'arm64', source: 'native'
})
const oss = selectPythonArtifact({
catalog, platform: 'linux', arch: 'arm64', source: 'oss'
})
expect(native.source).toBe('native')
expect(native.url).toContain('github.com/')
expect(oss.source).toBe('oss')
expect(oss.url).toContain(`/${oss.sha256}/`)
expect(oss.redirectHosts).toEqual([
'goodbuddy.oss-cn-beijing.aliyuncs.com'
])
})
it('rejects unknown fields and duplicate target rows', async () => {
const value = await catalogValue() as {
artifacts: Record<string, unknown>[]
}
value.artifacts[0]!.surprise = true
expect(() => parsePythonArtifactCatalog(value)).toThrow()
const duplicate = await catalogValue() as {
artifacts: Record<string, unknown>[]
}
duplicate.artifacts[1]!.platform = 'win32'
duplicate.artifacts[1]!.arch = 'x64'
expect(() => parsePythonArtifactCatalog(duplicate)).toThrow(
/duplicate or unsupported target/u
)
})
})
@@ -0,0 +1,206 @@
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
const platformSchema = z.enum(['win32', 'darwin', 'linux'])
const archSchema = z.enum(['x64', 'arm64'])
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u)
const httpsUrlSchema = z.string().url().superRefine((value, context) => {
const url = new URL(value)
if (
url.protocol !== 'https:' ||
(url.port !== '' && url.port !== '443') ||
url.username ||
url.password ||
url.hash
) {
context.addIssue({ code: 'custom', message: 'must be a plain HTTPS URL' })
}
})
const pinnedArtifacts: Record<string, {
fileName: string
size: number
sha256: string
}> = {
'win32/x64': {
fileName: 'python.3.13.15.nupkg',
size: 14391248,
sha256: '05357887df50d3153efc681bdf432c321d3e2f9ce5788f99f4515b27e8fda0ac'
},
'win32/arm64': {
fileName: 'pythonarm64.3.13.15.nupkg',
size: 13762836,
sha256: '3c1b1fdf56adc14634165df922d447520aefdc4a8411a34c34d8a062a4edf494'
},
'darwin/x64': {
fileName: 'cpython-3.13.15+20260825-x86_64-apple-darwin-install_only_stripped.tar.gz',
size: 24921256,
sha256: 'd33d61f7f4982c94216e14a43599c75657b7d0839277fc72bc6dbac53e8229bc'
},
'darwin/arm64': {
fileName: 'cpython-3.13.15+20260825-aarch64-apple-darwin-install_only_stripped.tar.gz',
size: 25140257,
sha256: '149038dd0c194c25d4616d7e42a35f67f2edee96412788f74115819b6a4c8548'
},
'linux/x64': {
fileName: 'cpython-3.13.15+20260825-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz',
size: 34813635,
sha256: '8af9a8214c71b2dd698005e39fab87aad02a994330508857da4e6d1ba7e6ddb6'
},
'linux/arm64': {
fileName: 'cpython-3.13.15+20260825-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz',
size: 29222461,
sha256: 'e5d0df1a6070a8614d808496e5ea28c727480e40ffcce1a94697a067f1690aa8'
}
}
const artifactSchema = z.object({
platform: platformSchema,
arch: archSchema,
archiveFormat: z.enum(['nuget-zip', 'tar.gz']),
payloadRoot: z.enum(['tools', 'python']),
fileName: z.string().min(1).max(255).regex(/^[^/\\\0]+$/u),
size: z.number().int().positive().safe(),
sha256: sha256Schema,
nativeUrl: httpsUrlSchema,
ossUrl: httpsUrlSchema
}).strict().superRefine((artifact, context) => {
const windows = artifact.platform === 'win32'
const key = `${artifact.platform}/${artifact.arch}`
const pinned = pinnedArtifacts[key]
if (
!pinned ||
artifact.fileName !== pinned.fileName ||
artifact.size !== pinned.size ||
artifact.sha256 !== pinned.sha256
) {
context.addIssue({
code: 'custom',
message: `artifact does not match the CPython 3.13.15 pin for ${key}`
})
}
if (
(windows && (
artifact.archiveFormat !== 'nuget-zip' ||
artifact.payloadRoot !== 'tools' ||
!artifact.fileName.endsWith('.nupkg')
)) ||
(!windows && (
artifact.archiveFormat !== 'tar.gz' ||
artifact.payloadRoot !== 'python' ||
!artifact.fileName.endsWith('.tar.gz')
))
) {
context.addIssue({
code: 'custom',
message: 'archive format does not match the platform'
})
}
const expectedOssPath =
`/tool-artifacts/python/3.13.15/${artifact.sha256}/` +
encodeURIComponent(artifact.fileName).replaceAll('%2F', '/')
const oss = new URL(artifact.ossUrl)
if (
oss.hostname !== 'goodbuddy.oss-cn-beijing.aliyuncs.com' ||
oss.pathname !== expectedOssPath
) {
context.addIssue({ code: 'custom', message: 'OSS key is not immutable' })
}
const expectedNativeUrl = windows
? `https://api.nuget.org/v3-flatcontainer/${
artifact.arch === 'x64' ? 'python' : 'pythonarm64'
}/3.13.15/${artifact.fileName}`
: 'https://github.com/astral-sh/python-build-standalone/releases/' +
`download/20260825/${encodeURIComponent(artifact.fileName)}`
if (artifact.nativeUrl !== expectedNativeUrl) {
context.addIssue({
code: 'custom',
message: 'native URL does not match the pinned upstream release'
})
}
})
const catalogSchema = z.object({
formatVersion: z.literal(1),
pythonVersion: z.literal('3.13.15'),
artifacts: z.array(artifactSchema).length(6)
}).strict().superRefine((catalog, context) => {
const expected = new Set([
'win32/x64', 'win32/arm64',
'darwin/x64', 'darwin/arm64',
'linux/x64', 'linux/arm64'
])
for (const artifact of catalog.artifacts) {
const key = `${artifact.platform}/${artifact.arch}`
if (!expected.delete(key)) {
context.addIssue({
code: 'custom',
path: ['artifacts'],
message: `duplicate or unsupported target: ${key}`
})
}
}
if (expected.size > 0) {
context.addIssue({
code: 'custom',
path: ['artifacts'],
message: `missing targets: ${[...expected].join(', ')}`
})
}
})
export type PythonArtifactSource = 'native' | 'oss'
export type PythonArtifactPlatform = z.infer<typeof platformSchema>
export type PythonArtifactArch = z.infer<typeof archSchema>
export type PythonArtifactCatalog = z.infer<typeof catalogSchema>
export type PythonArtifact = z.infer<typeof artifactSchema> & {
source: PythonArtifactSource
url: string
redirectHosts: readonly string[]
}
export function parsePythonArtifactCatalog(value: unknown): PythonArtifactCatalog {
return catalogSchema.parse(value)
}
export async function loadPythonArtifactCatalog(
catalogPath: string
): Promise<PythonArtifactCatalog> {
const text = await readFile(catalogPath, 'utf8')
let value: unknown
try {
value = JSON.parse(text)
} catch (error) {
throw new Error('Managed Python artifact catalog is not valid JSON', {
cause: error
})
}
return parsePythonArtifactCatalog(value)
}
export function selectPythonArtifact(options: {
catalog: PythonArtifactCatalog
platform: PythonArtifactPlatform
arch: PythonArtifactArch
source: PythonArtifactSource
}): PythonArtifact {
const artifact = options.catalog.artifacts.find(
(candidate) =>
candidate.platform === options.platform &&
candidate.arch === options.arch
)
if (!artifact) {
throw new Error(`Managed Python is unavailable for ${options.platform}/${options.arch}`)
}
const url = options.source === 'native'
? artifact.nativeUrl
: artifact.ossUrl
const host = new URL(url).hostname
const redirectHosts =
options.source === 'oss'
? [host]
: artifact.platform === 'win32'
? [host, 'globalcdn.nuget.org']
: [host, 'objects.githubusercontent.com', 'release-assets.githubusercontent.com']
return { ...artifact, source: options.source, url, redirectHosts }
}
@@ -0,0 +1,94 @@
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PythonArtifact } from './python-artifact-catalog'
import { downloadPythonArtifact } from './python-artifact-download'
const temporaryDirectories: string[] = []
async function temporaryPath(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-python-download-'))
temporaryDirectories.push(directory)
return join(directory, 'artifact')
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
))
})
function artifact(bytes: Uint8Array): PythonArtifact {
return {
platform: 'linux',
arch: 'x64',
archiveFormat: 'tar.gz',
payloadRoot: 'python',
fileName: 'python.tar.gz',
size: bytes.byteLength,
sha256: createHash('sha256').update(bytes).digest('hex'),
nativeUrl: 'https://download.example/python.tar.gz',
ossUrl: 'https://oss.example/python.tar.gz',
source: 'native',
url: 'https://download.example/python.tar.gz',
redirectHosts: ['download.example', 'cdn.example']
}
}
describe('Managed Python download', () => {
it('follows only bounded declared redirects and verifies bytes', async () => {
const bytes = Buffer.from('verified Python archive')
const transport = vi.fn<typeof fetch>()
.mockResolvedValueOnce(new Response(null, {
status: 302,
headers: { location: 'https://cdn.example/python.tar.gz' }
}))
.mockResolvedValueOnce(new Response(bytes, {
status: 200,
headers: { 'content-length': String(bytes.byteLength) }
}))
const destinationPath = await temporaryPath()
await downloadPythonArtifact({
artifact: artifact(bytes),
destinationPath,
transport
})
expect(await readFile(destinationPath)).toEqual(bytes)
expect(transport).toHaveBeenCalledTimes(2)
})
it('removes a partial on hash mismatch', async () => {
const bytes = Buffer.from('wrong bytes')
const expected = artifact(Buffer.from('right bytes'))
expected.size = bytes.byteLength
const destinationPath = await temporaryPath()
await expect(downloadPythonArtifact({
artifact: expected,
destinationPath,
transport: vi.fn<typeof fetch>().mockResolvedValue(
new Response(bytes, { status: 200 })
)
})).rejects.toThrow(/integrity/u)
await expect(readFile(destinationPath)).rejects.toMatchObject({
code: 'ENOENT'
})
})
it('rejects an undeclared redirect host without a fallback request', async () => {
const bytes = Buffer.from('archive')
const transport = vi.fn<typeof fetch>().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: 'https://attacker.example/archive' }
})
)
await expect(downloadPythonArtifact({
artifact: artifact(bytes),
destinationPath: await temporaryPath(),
transport
})).rejects.toThrow(/undeclared host/u)
expect(transport).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,136 @@
import { createHash } from 'node:crypto'
import { open, rm } from 'node:fs/promises'
import type { FileHandle } from 'node:fs/promises'
import type { PythonArtifact } from './python-artifact-catalog'
const redirectStatuses = new Set([301, 302, 303, 307, 308])
const MAX_REDIRECTS = 3
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted', 'AbortError')
}
}
function secureUrl(value: string): URL {
const url = new URL(value)
if (
url.protocol !== 'https:' ||
(url.port !== '' && url.port !== '443') ||
url.username ||
url.password ||
url.hash
) {
throw new Error('Managed Python download URL must be plain HTTPS')
}
return url
}
async function close(handle: FileHandle | undefined): Promise<void> {
await handle?.close().catch(() => undefined)
}
export async function downloadPythonArtifact(options: {
artifact: PythonArtifact
destinationPath: string
transport?: typeof fetch
signal?: AbortSignal
onProgress?: (receivedBytes: number, totalBytes: number) => void
}): Promise<void> {
const transport = options.transport ?? fetch
const allowedHosts = new Set(options.artifact.redirectHosts)
let url = secureUrl(options.artifact.url)
if (!allowedHosts.has(url.hostname)) {
throw new Error('Managed Python initial download host is not declared')
}
let response: Response | undefined
for (let redirects = 0; ; redirects += 1) {
ensureNotAborted(options.signal)
response = await transport(url, {
method: 'GET',
redirect: 'manual',
credentials: 'omit',
cache: 'no-store',
signal: options.signal
})
if (!redirectStatuses.has(response.status)) {
break
}
if (redirects >= MAX_REDIRECTS) {
await response.body?.cancel().catch(() => undefined)
throw new Error('Managed Python download has too many redirects')
}
const location = response.headers.get('location')
await response.body?.cancel().catch(() => undefined)
if (!location) {
throw new Error('Managed Python redirect has no location')
}
const next = secureUrl(new URL(location, url).toString())
if (!allowedHosts.has(next.hostname)) {
throw new Error('Managed Python download redirected to an undeclared host')
}
url = next
}
if (!response.ok || !response.body) {
await response.body?.cancel().catch(() => undefined)
throw new Error(`Managed Python download failed with HTTP ${response.status}`)
}
const declaredLength = response.headers.get('content-length')
if (
declaredLength !== null &&
(!/^\d+$/u.test(declaredLength) ||
Number(declaredLength) !== options.artifact.size)
) {
await response.body.cancel().catch(() => undefined)
throw new Error('Managed Python download size does not match the catalog')
}
let output: FileHandle | undefined
const hash = createHash('sha256')
let received = 0
try {
output = await open(options.destinationPath, 'wx')
const reader = response.body.getReader()
while (true) {
ensureNotAborted(options.signal)
const { done, value } = await reader.read()
if (done) {
break
}
received += value.byteLength
if (received > options.artifact.size) {
await reader.cancel().catch(() => undefined)
throw new Error('Managed Python download exceeds the catalog size')
}
hash.update(value)
let offset = 0
while (offset < value.byteLength) {
const { bytesWritten } = await output.write(
value,
offset,
value.byteLength - offset
)
if (bytesWritten <= 0) {
throw new Error('Managed Python artifact write was incomplete')
}
offset += bytesWritten
}
options.onProgress?.(received, options.artifact.size)
}
if (
received !== options.artifact.size ||
hash.digest('hex') !== options.artifact.sha256
) {
throw new Error('Managed Python artifact integrity check failed')
}
await output.sync()
await close(output)
output = undefined
} catch (error) {
await close(output)
await rm(options.destinationPath, { force: true }).catch(() => undefined)
throw error
}
}
@@ -0,0 +1,168 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { gzipSync, zipSync } from 'fflate'
import { create } from 'tar'
import { afterEach, describe, expect, it } from 'vitest'
import { extractPythonArtifact } from './python-artifact-extract'
const temporaryDirectories: string[] = []
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-python-extract-'))
temporaryDirectories.push(directory)
return directory
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
))
})
function tarField(value: string | number, length: number): Buffer {
const text = typeof value === 'number' ? value.toString(8) : value
const result = Buffer.alloc(length)
result.write(text.slice(0, length - 1), 0, 'ascii')
return result
}
function tarArchive(entries: {
path: string
type?: '0' | '2'
content?: string
linkPath?: string
mode?: number
}[]): Buffer {
const parts: Buffer[] = []
for (const item of entries) {
const content = Buffer.from(item.content ?? '')
const header = Buffer.alloc(512)
tarField(item.path, 100).copy(header, 0)
tarField(item.mode ?? 0o755, 8).copy(header, 100)
tarField(0, 8).copy(header, 108)
tarField(0, 8).copy(header, 116)
tarField(content.byteLength, 12).copy(header, 124)
tarField(0, 12).copy(header, 136)
header.fill(0x20, 148, 156)
header.write(item.type ?? '0', 156, 'ascii')
tarField(item.linkPath ?? '', 100).copy(header, 157)
header.write('ustar\0', 257, 'ascii')
header.write('00', 263, 'ascii')
let checksum = 0
for (const value of header) {
checksum += value
}
const checksumText = checksum.toString(8).padStart(6, '0')
header.write(`${checksumText}\0 `, 148, 'ascii')
parts.push(header, content)
const padding = (512 - content.byteLength % 512) % 512
if (padding > 0) {
parts.push(Buffer.alloc(padding))
}
}
parts.push(Buffer.alloc(1024))
return Buffer.concat(parts)
}
describe('Managed Python archive extraction', () => {
it('extracts only the tools payload root from a NuGet ZIP', async () => {
const directory = await temporaryDirectory()
const archivePath = join(directory, 'python.nupkg')
await writeFile(archivePath, zipSync({
'[Content_Types].xml': Buffer.from('<Types />'),
'package.nuspec': Buffer.from('<package />'),
'tools/python.exe': Buffer.from('python'),
'tools/Lib/site.py': Buffer.from('site')
}))
const destinationDirectory = join(directory, 'install')
await extractPythonArtifact({
artifact: { archiveFormat: 'nuget-zip', payloadRoot: 'tools' },
archivePath,
destinationDirectory
})
expect(await readFile(join(destinationDirectory, 'python.exe'), 'utf8'))
.toBe('python')
expect(await readFile(join(destinationDirectory, 'Lib/site.py'), 'utf8'))
.toBe('site')
await expect(
readFile(join(destinationDirectory, 'package.nuspec'))
).rejects.toMatchObject({ code: 'ENOENT' })
})
it('rejects ZIP traversal before writing payload files', async () => {
const directory = await temporaryDirectory()
const archivePath = join(directory, 'bad.nupkg')
await writeFile(archivePath, zipSync({
'tools/python.exe': Buffer.from('python'),
'tools/../escaped': Buffer.from('bad')
}))
await expect(extractPythonArtifact({
artifact: { archiveFormat: 'nuget-zip', payloadRoot: 'tools' },
archivePath,
destinationDirectory: join(directory, 'install')
})).rejects.toThrow(/Unsafe archive path/u)
await expect(readFile(join(directory, 'escaped'))).rejects.toMatchObject({
code: 'ENOENT'
})
})
it('extracts an Astral-style python TAR root', async () => {
const directory = await temporaryDirectory()
const source = join(directory, 'source')
await mkdir(join(source, 'python/bin'), { recursive: true })
await writeFile(join(source, 'python/bin/python3'), 'python')
const archivePath = join(directory, 'python.tar.gz')
await create({
cwd: source,
file: archivePath,
gzip: true,
portable: true
}, ['python'])
const destinationDirectory = join(directory, 'install')
await extractPythonArtifact({
artifact: { archiveFormat: 'tar.gz', payloadRoot: 'python' },
archivePath,
destinationDirectory
})
expect(await readFile(join(destinationDirectory, 'bin/python3'), 'utf8'))
.toBe('python')
})
it('validates relative symlinks and rejects escaping links', async () => {
const directory = await temporaryDirectory()
if (process.platform !== 'win32') {
const safeArchive = join(directory, 'safe.tar.gz')
await writeFile(safeArchive, gzipSync(tarArchive([
{ path: 'python/bin/python3', content: 'python' },
{
path: 'python/bin/python',
type: '2',
linkPath: 'python3'
}
])))
const destinationDirectory = join(directory, 'safe-install')
await extractPythonArtifact({
artifact: { archiveFormat: 'tar.gz', payloadRoot: 'python' },
archivePath: safeArchive,
destinationDirectory
})
expect(await readFile(join(destinationDirectory, 'bin/python'), 'utf8'))
.toBe('python')
}
const unsafeArchive = join(directory, 'unsafe.tar.gz')
await writeFile(unsafeArchive, gzipSync(tarArchive([
{
path: 'python/bin/python',
type: '2',
linkPath: '../../../outside'
}
])))
await expect(extractPythonArtifact({
artifact: { archiveFormat: 'tar.gz', payloadRoot: 'python' },
archivePath: unsafeArchive,
destinationDirectory: join(directory, 'unsafe-install')
})).rejects.toThrow(/escapes payload root/u)
})
})
@@ -0,0 +1,302 @@
import { chmod, lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, posix, resolve, sep } from 'node:path'
import { unzipSync } from 'fflate'
import { extract, list, type ReadEntry } from 'tar'
import type { PythonArtifact } from './python-artifact-catalog'
const MAX_ENTRIES = 20_000
const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024
const zipSignature = 0x02014b50
type SafeEntry = {
archivePath: string
outputPath: string
type: 'file' | 'directory' | 'symlink'
mode?: number
linkPath?: string
size: number
}
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted', 'AbortError')
}
}
function safePayloadPath(
path: string,
root: string,
ignoreOutsideRoot = false
): string | undefined {
if (
path.includes('\\') ||
path.includes('\0') ||
path.startsWith('/') ||
/^[a-zA-Z]:/u.test(path)
) {
throw new Error(`Unsafe archive path: ${path}`)
}
const normalized = posix.normalize(path)
if (
normalized === '..' ||
normalized.startsWith('../') ||
normalized !== path.replace(/\/+$/u, '')
) {
throw new Error(`Unsafe archive path: ${path}`)
}
if (normalized === root) {
return undefined
}
const prefix = `${root}/`
if (!normalized.startsWith(prefix)) {
if (ignoreOutsideRoot) {
return undefined
}
throw new Error(`Archive entry is outside payload root ${root}: ${path}`)
}
const relative = normalized.slice(prefix.length)
if (!relative) {
return undefined
}
return relative
}
function destinationPath(root: string, relative: string): string {
const destination = resolve(root, ...relative.split('/'))
const prefix = `${resolve(root)}${sep}`
if (!destination.startsWith(prefix)) {
throw new Error(`Archive path escaped destination: ${relative}`)
}
return destination
}
function ensureSafeSymlink(relative: string, target: string): void {
if (
!target ||
target.includes('\\') ||
target.includes('\0') ||
target.startsWith('/') ||
/^[a-zA-Z]:/u.test(target)
) {
throw new Error(`Unsafe archive symlink: ${relative}`)
}
const resolved = posix.normalize(posix.join(posix.dirname(relative), target))
if (resolved === '..' || resolved.startsWith('../') || posix.isAbsolute(resolved)) {
throw new Error(`Archive symlink escapes payload root: ${relative}`)
}
}
function verifyEntrySet(entries: SafeEntry[]): void {
if (entries.length === 0 || entries.length > MAX_ENTRIES) {
throw new Error('Archive has an invalid number of payload entries')
}
let expanded = 0
const seen = new Set<string>()
for (const entry of entries) {
const key = entry.outputPath.toLowerCase()
if (seen.has(key)) {
throw new Error(`Archive contains duplicate path: ${entry.outputPath}`)
}
seen.add(key)
expanded += entry.size
if (!Number.isSafeInteger(expanded) || expanded > MAX_EXPANDED_BYTES) {
throw new Error('Archive expanded size exceeds the limit')
}
}
}
function readZipEntries(data: Buffer, root: string): SafeEntry[] {
const entries: SafeEntry[] = []
for (let offset = 0; offset + 46 <= data.length;) {
if (data.readUInt32LE(offset) !== zipSignature) {
offset += 1
continue
}
const madeBy = data.readUInt16LE(offset + 4)
const flags = data.readUInt16LE(offset + 8)
const size = data.readUInt32LE(offset + 24)
const nameLength = data.readUInt16LE(offset + 28)
const extraLength = data.readUInt16LE(offset + 30)
const commentLength = data.readUInt16LE(offset + 32)
const attrs = data.readUInt32LE(offset + 38)
const end = offset + 46 + nameLength + extraLength + commentLength
if (end > data.length || (flags & 1) !== 0) {
throw new Error('ZIP central directory is invalid or encrypted')
}
const archivePath = data.subarray(offset + 46, offset + 46 + nameLength)
.toString((flags & 0x800) !== 0 ? 'utf8' : 'latin1')
const relative = safePayloadPath(archivePath, root, true)
if (relative) {
const unixMode = (madeBy >>> 8) === 3 ? (attrs >>> 16) & 0xffff : undefined
const fileType = unixMode === undefined ? 0 : unixMode & 0xf000
if (fileType !== 0 && fileType !== 0x8000 && fileType !== 0x4000) {
throw new Error(`ZIP contains a special file or link: ${archivePath}`)
}
entries.push({
archivePath,
outputPath: relative,
type: archivePath.endsWith('/') || fileType === 0x4000
? 'directory'
: 'file',
mode: unixMode === undefined ? undefined : unixMode & 0o777,
size
})
}
offset = end
}
verifyEntrySet(entries)
return entries
}
async function extractNuget(
archivePath: string,
destination: string,
root: string,
signal?: AbortSignal
): Promise<void> {
ensureNotAborted(signal)
const data = await readFile(archivePath)
const entries = readZipEntries(data, root)
const payloadPaths = new Set(entries.map((entry) => entry.archivePath))
const unzipped = unzipSync(data, {
filter: ({ name }) => payloadPaths.has(name)
})
for (const entry of entries.sort((left, right) =>
left.type === 'directory' && right.type !== 'directory' ? -1 : 0
)) {
ensureNotAborted(signal)
const output = destinationPath(destination, entry.outputPath)
if (entry.type === 'directory') {
await mkdir(output, { recursive: true, mode: entry.mode })
continue
}
const content = unzipped[entry.archivePath]
if (!content || content.byteLength !== entry.size) {
throw new Error(`ZIP payload is missing or corrupt: ${entry.archivePath}`)
}
await mkdir(dirname(output), { recursive: true })
await writeFile(output, content, { flag: 'wx', mode: entry.mode })
if (entry.mode !== undefined && process.platform !== 'win32') {
await chmod(output, entry.mode)
}
}
}
function tarEntry(entry: ReadEntry, root: string): SafeEntry | undefined {
const relative = safePayloadPath(entry.path.replace(/\/+$/u, ''), root)
if (!relative) {
return undefined
}
if (entry.type === 'SymbolicLink') {
const linkPath = entry.linkpath ?? ''
ensureSafeSymlink(relative, linkPath)
return {
archivePath: entry.path,
outputPath: relative,
type: 'symlink',
mode: entry.mode === undefined ? undefined : entry.mode & 0o777,
linkPath,
size: 0
}
}
if (entry.type !== 'File' && entry.type !== 'Directory') {
throw new Error(`TAR contains unsupported entry type ${entry.type}: ${entry.path}`)
}
return {
archivePath: entry.path,
outputPath: relative,
type: entry.type === 'Directory' ? 'directory' : 'file',
mode: entry.mode === undefined ? undefined : entry.mode & 0o777,
size: entry.size
}
}
async function extractTar(
archivePath: string,
destination: string,
root: string,
signal?: AbortSignal
): Promise<void> {
const entries: SafeEntry[] = []
let validationError: Error | undefined
await list({
file: archivePath,
gzip: true,
strict: true,
onReadEntry: (entry) => {
try {
ensureNotAborted(signal)
const safe = tarEntry(entry, root)
if (safe) {
entries.push(safe)
}
} catch (error) {
validationError ??= error instanceof Error
? error
: new Error('TAR validation failed')
entry.resume()
}
}
})
if (validationError) {
throw validationError
}
verifyEntrySet(entries)
const allowed = new Map(entries.map((entry) => [entry.archivePath, entry]))
await extract({
file: archivePath,
cwd: destination,
gzip: true,
strip: 1,
strict: true,
preservePaths: false,
noChmod: false,
filter: (path, entry) => {
ensureNotAborted(signal)
const expected = allowed.get(path)
if (!expected) {
return path.replace(/\/+$/u, '') === root
}
const checked = tarEntry(entry as ReadEntry, root)
return checked?.outputPath === expected.outputPath &&
checked.type === expected.type
}
})
for (const entry of entries) {
const output = destinationPath(destination, entry.outputPath)
const status = await lstat(output)
if (
(entry.type === 'file' && !status.isFile()) ||
(entry.type === 'directory' && !status.isDirectory()) ||
(entry.type === 'symlink' && !status.isSymbolicLink())
) {
throw new Error(`Extracted TAR entry has the wrong type: ${entry.outputPath}`)
}
}
}
export async function extractPythonArtifact(options: {
artifact: Pick<PythonArtifact, 'archiveFormat' | 'payloadRoot'>
archivePath: string
destinationDirectory: string
signal?: AbortSignal
}): Promise<void> {
await mkdir(options.destinationDirectory, { recursive: false })
if (options.artifact.archiveFormat === 'nuget-zip') {
await extractNuget(
options.archivePath,
options.destinationDirectory,
options.artifact.payloadRoot,
options.signal
)
} else {
await extractTar(
options.archivePath,
options.destinationDirectory,
options.artifact.payloadRoot,
options.signal
)
}
}