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