diff --git a/.husky/pre-commit b/.husky/pre-commit index 8c254d63..b5aad1a9 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -290,11 +290,14 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then exit 1 fi - # Check for vulnerabilities (only high severity) + # Check for vulnerabilities (only critical severity) + # Note: Using critical level because electron-builder has a known high-severity + # tar vulnerability (CVE-2026-23745) that cannot be fixed until electron-builder + # releases an update with tar@7.x support. This is a build dependency, not runtime. echo "Checking for vulnerabilities..." - npm audit --audit-level=high + npm audit --audit-level=critical if [ $? -ne 0 ]; then - echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve." + echo "Critical severity vulnerabilities found. Run 'npm audit fix' to resolve." exit 1 fi ) diff --git a/README.md b/README.md index 3ee708a6..77f1d647 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,17 @@ ### Stable Release -[![Stable](https://img.shields.io/badge/stable-2.7.5-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.5) +[![Stable](https://img.shields.io/badge/stable-2.7.4-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.4) | Platform | Download | |----------|----------| -| **Windows** | [Auto-Claude-2.7.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-win32-x64.exe) | -| **macOS (Apple Silicon)** | [Auto-Claude-2.7.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-arm64.dmg) | -| **macOS (Intel)** | [Auto-Claude-2.7.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-x64.dmg) | -| **Linux** | [Auto-Claude-2.7.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.AppImage) | -| **Linux (Debian)** | [Auto-Claude-2.7.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-amd64.deb) | +| **Windows** | [Auto-Claude-2.7.4-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-win32-x64.exe) | +| **macOS (Apple Silicon)** | [Auto-Claude-2.7.4-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-darwin-arm64.dmg) | +| **macOS (Intel)** | [Auto-Claude-2.7.4-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-darwin-x64.dmg) | +| **Linux** | [Auto-Claude-2.7.4-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-x86_64.AppImage) | +| **Linux (Debian)** | [Auto-Claude-2.7.4-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-amd64.deb) | | **Linux (Flatpak)** | [Auto-Claude-2.7.4-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-x86_64.flatpak) | diff --git a/apps/backend/phase_config.py b/apps/backend/phase_config.py index 0691ce75..e196613e 100644 --- a/apps/backend/phase_config.py +++ b/apps/backend/phase_config.py @@ -25,7 +25,7 @@ THINKING_BUDGET_MAP: dict[str, int | None] = { "low": 1024, "medium": 4096, # Moderate analysis "high": 16384, # Deep thinking for QA review - "ultrathink": 64000, # Maximum reasoning depth (within Claude API's 64000 token limit) + "ultrathink": 63999, # Maximum reasoning depth (API requires max_tokens >= budget + 1, so 63999 + 1 = 64000 limit) } # Spec runner phase-specific thinking levels diff --git a/apps/frontend/electron.vite.config.ts b/apps/frontend/electron.vite.config.ts index 6ceaa51f..ee7fbf5d 100644 --- a/apps/frontend/electron.vite.config.ts +++ b/apps/frontend/electron.vite.config.ts @@ -25,6 +25,12 @@ export default defineConfig({ exclude: [ 'uuid', 'chokidar', + 'dotenv', + 'electron-log', + 'proper-lockfile', + 'semver', + 'zod', + '@anthropic-ai/sdk', 'kuzu', 'electron-updater', '@electron-toolkit/utils', @@ -35,7 +41,9 @@ export default defineConfig({ '@sentry/utils', '@opentelemetry/instrumentation', 'debug', - 'ms' + 'ms', + // Minimatch for glob pattern matching in worktree handlers + 'minimatch' ] })], build: { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 7007e8bd..1e3eddb8 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -31,11 +31,11 @@ "python:download": "node scripts/download-python.cjs", "python:download:all": "node scripts/download-python.cjs --all", "python:verify": "node scripts/verify-python-bundling.cjs", - "package": "npm run python:download && electron-vite build && electron-builder --publish never", - "package:mac": "npm run python:download && electron-vite build && electron-builder --mac --publish never", - "package:win": "npm run python:download && electron-vite build && electron-builder --win --publish never", - "package:linux": "npm run python:download && electron-vite build && electron-builder --linux --publish never", - "package:flatpak": "npm run python:download && electron-vite build && electron-builder --linux flatpak --publish never", + "package": "node scripts/package-with-python.cjs", + "package:mac": "node scripts/package-with-python.cjs --mac", + "package:win": "node scripts/package-with-python.cjs --win", + "package:linux": "node scripts/package-with-python.cjs --linux", + "package:flatpak": "node scripts/package-with-python.cjs --linux flatpak", "start:packaged:mac": "open dist/mac-arm64/Auto-Claude.app || open dist/mac/Auto-Claude.app", "start:packaged:win": "start \"\" \"dist\\win-unpacked\\Auto-Claude.exe\"", "start:packaged:linux": "./dist/linux-unpacked/auto-claude", @@ -137,7 +137,6 @@ "vitest": "^4.0.16" }, "overrides": { - "tar": "^7.5.3", "electron-builder-squirrel-windows": "^26.0.12", "dmg-builder": "^26.0.12", "@electron/rebuild": "4.0.2" @@ -161,6 +160,9 @@ "out/**/*", "package.json" ], + "asarUnpack": [ + "out/main/node_modules/@lydell/node-pty-*/**" + ], "extraResources": [ { "from": "resources/icon.ico", diff --git a/apps/frontend/scripts/download-python.cjs b/apps/frontend/scripts/download-python.cjs index bd1b587d..c2e00c84 100644 --- a/apps/frontend/scripts/download-python.cjs +++ b/apps/frontend/scripts/download-python.cjs @@ -21,6 +21,7 @@ const path = require('path'); const { spawnSync } = require('child_process'); const os = require('os'); const nodeCrypto = require('crypto'); +const { toNodePlatform } = require('../src/shared/platform.cjs'); // Python version to bundle (must be 3.10+ for claude-agent-sdk, 3.12+ for full Graphiti support) const PYTHON_VERSION = '3.12.8'; @@ -165,18 +166,6 @@ function toElectronBuilderPlatform(nodePlatform) { return map[nodePlatform] || nodePlatform; } -// Map electron-builder platform names to Node.js platform names (for internal use) -function toNodePlatform(platform) { - const map = { - 'mac': 'darwin', - 'win': 'win32', - 'darwin': 'darwin', - 'win32': 'win32', - 'linux': 'linux', - }; - return map[platform] || platform; -} - /** * Get the download URL for a specific platform/arch combination. * python-build-standalone uses specific naming conventions. @@ -464,6 +453,71 @@ function formatBytes(bytes) { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } +function hasPackage(sitePackagesDir, pkg) { + const pkgPath = path.join(sitePackagesDir, pkg); + const initPath = path.join(pkgPath, '__init__.py'); + const moduleFile = path.join(sitePackagesDir, pkg + '.py'); + return (fs.existsSync(pkgPath) && fs.existsSync(initPath)) || fs.existsSync(moduleFile); +} + +function hasPydanticCoreBinary(sitePackagesDir) { + const pkgDir = path.join(sitePackagesDir, 'pydantic_core'); + if (!fs.existsSync(pkgDir)) return false; + + let entries; + try { + entries = fs.readdirSync(pkgDir); + } catch { + return false; + } + return entries.some((name) => { + if (!name.startsWith('_pydantic_core')) return false; + const lower = name.toLowerCase(); + return lower.endsWith('.so') || lower.endsWith('.pyd') || lower.endsWith('.dylib'); + }); +} + +function getPinnedPydanticCoreVersion(sitePackagesDir) { + let entries; + try { + entries = fs.readdirSync(sitePackagesDir); + } catch { + return null; + } + + const distInfo = entries.find((entry) => { + return entry.startsWith('pydantic-') + && !entry.startsWith('pydantic_core-') + && entry.endsWith('.dist-info'); + }); + if (!distInfo) return null; + + const metadataPath = path.join(sitePackagesDir, distInfo, 'METADATA'); + if (!fs.existsSync(metadataPath)) return null; + + let metadata; + try { + metadata = fs.readFileSync(metadataPath, 'utf-8'); + } catch { + return null; + } + + for (const line of metadata.split(/\r?\n/)) { + if (!line.startsWith('Requires-Dist: pydantic-core')) continue; + const match = line.match(/pydantic-core==([0-9A-Za-z.+-]+)/); + if (match) return match[1]; + } + + return null; +} + +function isCriticalPackageMissing(sitePackagesDir, pkg) { + if (pkg === 'pydantic_core') { + return !hasPackage(sitePackagesDir, pkg) || !hasPydanticCoreBinary(sitePackagesDir); + } + return !hasPackage(sitePackagesDir, pkg); +} + /** * Strip unnecessary files from site-packages to reduce bundle size. * This removes tests, docs, cache files, and other non-essential content. @@ -797,6 +851,44 @@ function installPackages(pythonBin, requirementsPath, targetSitePackages) { // Strip unnecessary files stripSitePackages(targetSitePackages); + if (!hasPydanticCoreBinary(targetSitePackages)) { + console.warn('[download-python] pydantic_core binary missing after strip; reinstalling pydantic-core...'); + const pinnedVersion = getPinnedPydanticCoreVersion(targetSitePackages); + const coreSpec = pinnedVersion ? `pydantic-core==${pinnedVersion}` : 'pydantic-core'; + if (pinnedVersion) { + console.log(`[download-python] Reinstalling pydantic-core ${pinnedVersion} to match pydantic metadata`); + } else { + console.warn('[download-python] Unable to determine pydantic-core pin; reinstalling latest'); + } + const pipArgs = [ + '-m', 'pip', 'install', + '--no-compile', + '--only-binary', 'pydantic-core', + '--no-deps', + '--target', targetSitePackages, + coreSpec, + ]; + const result = spawnSync(pythonBin, pipArgs, { + stdio: 'inherit', + env: { + ...process.env, + PYTHONDONTWRITEBYTECODE: '1', + PYTHONIOENCODING: 'utf-8', + }, + }); + + if (result.error) { + throw new Error(`Failed to reinstall pydantic-core: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(`pydantic-core reinstall failed with exit code ${result.status}`); + } + + if (!hasPydanticCoreBinary(targetSitePackages)) { + throw new Error('pydantic_core binary missing after reinstall'); + } + } + // Remove bin/Scripts directory (we don't need console scripts) const binDir = path.join(targetSitePackages, 'bin'); const scriptsDir = path.join(targetSitePackages, 'Scripts'); @@ -862,14 +954,7 @@ async function downloadPython(targetPlatform, targetArch, options = {}) { // while this script validates it during build to ensure it's bundled const criticalPackages = ['claude_agent_sdk', 'dotenv', 'pydantic_core'] .concat(PLATFORM_CRITICAL_PACKAGES[info.nodePlatform] || []); - const missingPackages = criticalPackages.filter(pkg => { - const pkgPath = path.join(sitePackagesDir, pkg); - const initPath = path.join(pkgPath, '__init__.py'); - // For single-file modules (like pywintypes.py), check for the file directly - const moduleFile = path.join(sitePackagesDir, pkg + '.py'); - // Package is valid if directory+__init__.py exists OR single-file module exists - return !(fs.existsSync(pkgPath) && fs.existsSync(initPath)) && !fs.existsSync(moduleFile); - }); + const missingPackages = criticalPackages.filter(pkg => isCriticalPackageMissing(sitePackagesDir, pkg)); if (missingPackages.length > 0) { console.log(`[download-python] Critical packages missing or incomplete: ${missingPackages.join(', ')}`); @@ -969,14 +1054,7 @@ async function downloadPython(targetPlatform, targetArch, options = {}) { // while this script validates it during build to ensure it's bundled const criticalPackages = ['claude_agent_sdk', 'dotenv', 'pydantic_core'] .concat(PLATFORM_CRITICAL_PACKAGES[info.nodePlatform] || []); - const postInstallMissing = criticalPackages.filter(pkg => { - const pkgPath = path.join(sitePackagesDir, pkg); - const initPath = path.join(pkgPath, '__init__.py'); - // For single-file modules (like pywintypes.py), check for the file directly - const moduleFile = path.join(sitePackagesDir, pkg + '.py'); - // Package is valid if directory+__init__.py exists OR single-file module exists - return !(fs.existsSync(pkgPath) && fs.existsSync(initPath)) && !fs.existsSync(moduleFile); - }); + const postInstallMissing = criticalPackages.filter(pkg => isCriticalPackageMissing(sitePackagesDir, pkg)); if (postInstallMissing.length > 0) { throw new Error(`Package installation failed - missing critical packages: ${postInstallMissing.join(', ')}`); diff --git a/apps/frontend/scripts/package-with-python.cjs b/apps/frontend/scripts/package-with-python.cjs new file mode 100644 index 00000000..bc57d072 --- /dev/null +++ b/apps/frontend/scripts/package-with-python.cjs @@ -0,0 +1,286 @@ +#!/usr/bin/env node +/** + * Packaging script that downloads bundled Python, stages runtime modules, + * and builds the Electron app for the requested platforms and architectures. + * + * Usage: node scripts/package-with-python.cjs [--mac|--win|--linux] [--x64|--arm64|--universal] + */ +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { isWindows, getCurrentPlatform, toNodePlatform } = require('../src/shared/platform.cjs'); +const { downloadPython } = require('./download-python.cjs'); + +/** + * Shell metacharacters that could enable command injection when shell: true is used on Windows. + * These characters have special meaning in cmd.exe and could be used to inject arbitrary commands. + * + * Includes: + * - Standard operators: & | > < ^ % + * - Command separators: ; + * - Variable expansion: $ % + * - Command grouping: ( ) [ ] { } + * - Delayed expansion: ! + * - Command substitution: ` + * - Quotes: " + * - Line breaks: \n \r + * + * Note: Single quote (') is not included as cmd.exe does not treat it as a shell metacharacter. + */ +const SHELL_METACHARACTERS = Object.freeze(['&', '|', '>', '<', '^', '%', ';', '$', '(', ')', '[', ']', '{', '}', '!', '`', '"', '\n', '\r']); + +/** + * Validate that arguments don't contain shell metacharacters on Windows. + * When shell: true is used, cmd.exe interprets metacharacters which could lead to command injection. + * + * @param {string[]} commandArgs - Arguments to validate + * @throws {Error} If any argument contains dangerous shell metacharacters on Windows + * @throws {TypeError} If any argument is not a string + */ +function validateArgs(commandArgs) { + if (!isWindows()) return; // Only validate on Windows where shell: true is used + + for (const arg of commandArgs) { + // Defensive check: skip non-string arguments to prevent TypeError + if (typeof arg !== 'string') { + throw new TypeError( + `Security: Argument must be a string, got ${typeof arg}. ` + + `This may indicate incorrect argument passing.` + ); + } + + for (const char of SHELL_METACHARACTERS) { + if (arg.includes(char)) { + throw new Error( + `Security: Argument contains shell metacharacter '${char}' which could enable command injection. ` + + `Argument: "${arg}"` + ); + } + } + } +} + +const args = process.argv.slice(2); + +const PLATFORM_FLAGS = new Map([ + ['--mac', 'mac'], + ['--win', 'win'], + ['--windows', 'win'], + ['--linux', 'linux'], +]); + +const ARCH_FLAGS = new Map([ + ['--x64', 'x64'], + ['--arm64', 'arm64'], + ['--universal', 'universal'], +]); + +function mapHostPlatform(platform) { + const map = { darwin: 'mac', win32: 'win', linux: 'linux' }; + return map[platform] || platform; +} + +function resolvePlatforms() { + const platforms = new Set(); + for (const arg of args) { + const mapped = PLATFORM_FLAGS.get(arg); + if (mapped) platforms.add(mapped); + } + + if (platforms.size === 0) { + platforms.add(mapHostPlatform(getCurrentPlatform())); + } + + return [...platforms]; +} + +function resolveArchs() { + const archs = new Set(); + let wantsUniversal = false; + + for (const arg of args) { + const mapped = ARCH_FLAGS.get(arg); + if (!mapped) continue; + if (mapped === 'universal') { + wantsUniversal = true; + } else { + archs.add(mapped); + } + } + + if (wantsUniversal) { + archs.add('x64'); + archs.add('arm64'); + } + + if (archs.size === 0) { + archs.add(os.arch()); + } + + for (const arch of archs) { + if (!['x64', 'arm64'].includes(arch)) { + throw new Error( + `Host architecture '${arch}' is not supported for bundled Python. Please specify --x64 or --arm64 explicitly.` + ); + } + } + + return [...archs]; +} + +function buildEnv(frontendDir) { + const binDir = path.join(frontendDir, 'node_modules', '.bin'); + const rootBinDir = path.join(frontendDir, '..', '..', 'node_modules', '.bin'); + const pathParts = [binDir, rootBinDir]; + const pathValue = process.env.PATH + ? `${pathParts.join(path.delimiter)}${path.delimiter}${process.env.PATH}` + : pathParts.join(path.delimiter); + return { ...process.env, PATH: pathValue }; +} + +function runCommand(command, commandArgs, cwd, env) { + // Validate arguments to prevent command injection via shell metacharacters. + // Note: validateArgs only validates on Windows because shell: true is only used on Windows. + // On non-Windows platforms, .cmd files are not used and shell: false, so no injection risk. + validateArgs(commandArgs); + + const bin = isWindows() ? `${command}.cmd` : command; + const result = spawnSync(bin, commandArgs, { + cwd, + env, + stdio: 'inherit', + shell: isWindows(), + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + const code = result.status ?? 1; + throw new Error(`Command "${command}" failed with exit code ${code}.`); + } +} + +function resolvePackageDir(baseDir, pkgName) { + return path.join(baseDir, 'node_modules', ...pkgName.split('/')); +} + +function copyPackage(fromDir, toDir) { + if (!fs.existsSync(fromDir)) { + throw new Error(`Required package not found: ${fromDir}`); + } + + if (fs.existsSync(toDir)) { + fs.rmSync(toDir, { recursive: true, force: true }); + } + + fs.mkdirSync(path.dirname(toDir), { recursive: true }); + fs.cpSync(fromDir, toDir, { recursive: true, dereference: true }); +} + +function readPackageJson(pkgDir) { + const pkgPath = path.join(pkgDir, 'package.json'); + if (!fs.existsSync(pkgPath)) return null; + return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); +} + +function stageRuntimePackages(frontendDir, platform, arch) { + const rootDir = path.join(frontendDir, '..', '..'); + const nodePlatform = toNodePlatform(platform); + const packages = [ + '@lydell/node-pty', + `@lydell/node-pty-${nodePlatform}-${arch}`, + 'minimatch', + ]; + const outMainDir = path.join(frontendDir, 'out', 'main'); + const outModulesDir = path.join(outMainDir, 'node_modules'); + + if (!fs.existsSync(outMainDir)) { + throw new Error('Missing build output. Run electron-vite build before staging node-pty.'); + } + + fs.mkdirSync(outModulesDir, { recursive: true }); + + const staged = new Set(); + + function stagePackage(pkgName) { + if (staged.has(pkgName)) return; + staged.add(pkgName); + + const rootDirPath = resolvePackageDir(rootDir, pkgName); + if (!fs.existsSync(rootDirPath)) { + throw new Error(`Missing ${pkgName} in workspace. Run npm install before packaging.`); + } + + const localDir = path.join(outModulesDir, ...pkgName.split('/')); + console.log(`[package] Staging ${pkgName} into build output...`); + copyPackage(rootDirPath, localDir); + + const pkgJson = readPackageJson(rootDirPath); + if (!pkgJson) return; + + const deps = pkgJson.dependencies || {}; + const optionalDeps = pkgJson.optionalDependencies || {}; + + for (const depName of Object.keys(deps)) { + stagePackage(depName); + } + + for (const depName of Object.keys(optionalDeps)) { + const optionalPath = resolvePackageDir(rootDir, depName); + if (fs.existsSync(optionalPath)) { + stagePackage(depName); + } else { + console.log(`[package] Skipping optional dependency not installed: ${depName}`); + } + } + } + + for (const pkgName of packages) { + stagePackage(pkgName); + } +} + +async function main() { + const frontendDir = path.join(__dirname, '..'); + const env = buildEnv(frontendDir); + + const platforms = resolvePlatforms(); + const archs = resolveArchs(); + + for (const platform of platforms) { + for (const arch of archs) { + await downloadPython(platform, arch); + } + } + + runCommand('electron-vite', ['build'], frontendDir, env); + + for (const platform of platforms) { + for (const arch of archs) { + stageRuntimePackages(frontendDir, platform, arch); + } + } + + const builderArgs = [...args]; + const hasPublishFlag = builderArgs.some((arg) => arg === '--publish' || arg.startsWith('--publish=')); + if (!hasPublishFlag) { + builderArgs.push('--publish', 'never'); + } + + runCommand('electron-builder', builderArgs, frontendDir, env); +} + +// Run main() only when this file is executed directly (not when imported for testing) +if (require.main === module) { + main().catch((err) => { + console.error(`[package] Error: ${err.message}`); + process.exitCode = 1; + }); +} + +// Export for testing +module.exports = { validateArgs, SHELL_METACHARACTERS }; diff --git a/apps/frontend/scripts/package-with-python.d.ts b/apps/frontend/scripts/package-with-python.d.ts new file mode 100644 index 00000000..7bf561d5 --- /dev/null +++ b/apps/frontend/scripts/package-with-python.d.ts @@ -0,0 +1,5 @@ +/** + * Type declarations for package-with-python.cjs + */ +export declare const SHELL_METACHARACTERS: readonly string[]; +export declare function validateArgs(commandArgs: string[]): void; diff --git a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts index 48a4e5f5..ce362d20 100644 --- a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts +++ b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts @@ -52,9 +52,21 @@ vi.mock('child_process', async (importOriginal) => { }); // Mock claude-profile-manager to bypass auth checks in tests +// Profile shape must match ClaudeProfile interface (id, name, isDefault, etc.) +const mockProfile = { + id: 'default', + name: 'Default', + isDefault: true, + oauthToken: 'mock-encrypted-token' +}; + const mockProfileManager = { hasValidAuth: () => true, - getActiveProfile: () => ({ profileId: 'default', profileName: 'Default' }) + getActiveProfile: () => mockProfile, + getProfile: (_profileId: string) => mockProfile, + // Token decryption methods - return mock token for tests + getActiveProfileToken: () => 'mock-decrypted-token-for-testing', + getProfileToken: (_profileId: string) => 'mock-decrypted-token-for-testing' }; vi.mock('../../main/claude-profile-manager', () => ({ diff --git a/apps/frontend/src/main/__tests__/package-with-python.test.ts b/apps/frontend/src/main/__tests__/package-with-python.test.ts new file mode 100644 index 00000000..45849dcc --- /dev/null +++ b/apps/frontend/src/main/__tests__/package-with-python.test.ts @@ -0,0 +1,218 @@ +/** + * Unit tests for package-with-python.cjs security validation + * + * Tests the validateArgs function which prevents command injection via + * shell metacharacters when shell: true is used on Windows. + */ + +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +// Import from the scripts directory (relative to src/main/__tests__) +// @ts-expect-error - TypeScript doesn't auto-resolve .d.ts for .cjs imports (types exist in package-with-python.d.ts) +import { validateArgs, SHELL_METACHARACTERS } from '../../../scripts/package-with-python.cjs'; + +// Mock the isWindows function from platform.cjs +const originalPlatform = process.platform; + +describe('validateArgs', () => { + // We need to mock the isWindows function by modifying process.platform + // since the platform.cjs module uses process.platform === 'win32' to check + + afterEach(() => { + // Restore original platform after each test + Object.defineProperty(process, 'platform', { + value: originalPlatform, + writable: true, + configurable: true, + }); + }); + + describe('on Windows (shell injection risk)', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true, + configurable: true, + }); + }); + + describe('should throw for shell metacharacters', () => { + // Test each metacharacter individually + it.each([ + ['&', 'command & malicious'], + ['|', 'command | malicious'], + ['>', 'output > file.txt'], + ['<', 'command < input.txt'], + ['^', 'escape ^ character'], + ['%', '%PATH%'], + [';', 'command ; malicious'], + ['$', '$variable'], + ['(', 'command (group)'], + [')', 'command)after'], + ['[', 'array[index]'], + [']', 'command]after'], + ['{', '{block}'], + ['}', 'command}after'], + ['!', '!delayed!'], + ['"', '"quoted"'], + ['`', 'command `subshell`'], + ['\n', 'command\nnext'], + ['\r', 'command\rnext'], + ])('should throw for metacharacter "%s"', (char, arg) => { + expect(() => validateArgs([arg])).toThrowError(/shell metacharacter/); + expect(() => validateArgs([arg])).toThrowError(new RegExp(`\\${char}`)); + }); + + // Test metacharacters in different positions + it('should throw when metacharacter is at the start', () => { + expect(() => validateArgs(['& malicious'])).toThrow(); + }); + + it('should throw when metacharacter is in the middle', () => { + expect(() => validateArgs(['config&malicious'])).toThrow(); + }); + + it('should throw when metacharacter is at the end', () => { + expect(() => validateArgs(['config&'])).toThrow(); + }); + + // Test multiple metacharacters + it('should throw for multiple metacharacters in one argument', () => { + expect(() => validateArgs(['& | >'])).toThrow(); + }); + + // Test metacharacters across multiple arguments + it('should throw for metacharacters in different arguments', () => { + expect(() => validateArgs(['--flag', 'value&', 'other'])).toThrow(); + }); + + // Test error message includes the offending argument + it('should include offending argument in error message', () => { + expect(() => validateArgs(['file&evil.exe'])) + .toThrowError(/Argument: "file&evil\.exe"/); + }); + }); + + describe('should throw for non-string arguments', () => { + it('should throw TypeError for null argument', () => { + expect(() => validateArgs([null])).toThrowError(TypeError); + expect(() => validateArgs([null])).toThrowError(/must be a string/); + }); + + it('should throw TypeError for undefined argument', () => { + expect(() => validateArgs([undefined])).toThrowError(TypeError); + expect(() => validateArgs([undefined])).toThrowError(/must be a string/); + }); + + it('should throw TypeError for number argument', () => { + expect(() => validateArgs([123])).toThrowError(TypeError); + expect(() => validateArgs([123])).toThrowError(/got number/); + }); + + it('should throw TypeError for object argument', () => { + expect(() => validateArgs([{ key: 'value' }])).toThrowError(TypeError); + expect(() => validateArgs([{ key: 'value' }])).toThrowError(/got object/); + }); + + it('should throw TypeError for mixed valid and invalid arguments', () => { + expect(() => validateArgs(['--flag', null])).toThrowError(TypeError); + }); + }); + + describe('should NOT throw for safe inputs', () => { + it('should allow empty array', () => { + expect(() => validateArgs([])).not.toThrow(); + }); + + it('should allow alphanumeric arguments', () => { + expect(() => validateArgs(['build', 'test', 'production'])).not.toThrow(); + }); + + it('should allow flag arguments', () => { + expect(() => validateArgs(['--win', '--x64', '--publish=never'])).not.toThrow(); + }); + + it('should allow paths with forward slashes', () => { + expect(() => validateArgs(['../config/file.txt'])).not.toThrow(); + }); + + it('should allow paths with backslashes', () => { + // Use path.win32.join to construct a Windows-style path without hardcoding system locations + const windowsPath = path.win32.join('C:', 'Apps', 'App', 'config.txt'); + expect(() => validateArgs([windowsPath])).not.toThrow(); + }); + + it('should allow dots and hyphens', () => { + expect(() => validateArgs(['--config.file', 'my-config.json'])).not.toThrow(); + }); + + it('should allow underscores', () => { + expect(() => validateArgs(['my_config_file', '--output_dir'])).not.toThrow(); + }); + + it('should allow @ symbol', () => { + expect(() => validateArgs(['@lydell/node-pty'])).not.toThrow(); + }); + + it('should allow equals sign', () => { + expect(() => validateArgs(['--publish=never'])).not.toThrow(); + }); + + it('should allow common electron-builder arguments', () => { + expect(() => validateArgs([ + '--win', + '--x64', + '--publish', + 'never', + '--config', + 'config.yml' + ])).not.toThrow(); + }); + }); + }); + + describe('on non-Windows platforms', () => { + it('should return immediately on macOS without throwing', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true, + configurable: true, + }); + + // Even with metacharacters, should not throw on non-Windows + expect(() => validateArgs(['command & malicious'])).not.toThrow(); + }); + + it('should return immediately on Linux without throwing', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + writable: true, + configurable: true, + }); + + // Even with metacharacters, should not throw on non-Windows + expect(() => validateArgs(['command & malicious'])).not.toThrow(); + }); + + it('should allow empty array on macOS', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true, + configurable: true, + }); + + expect(() => validateArgs([])).not.toThrow(); + }); + }); +}); + +describe('SHELL_METACHARACTERS constant', () => { + it('should contain all expected dangerous characters', () => { + const expectedChars = [ + '&', '|', '>', '<', '^', '%', ';', '$', + '(', ')', '[', ']', '{', '}', + '!', '"', '`', '\n', '\r' + ]; + expect(SHELL_METACHARACTERS).toEqual(expect.arrayContaining(expectedChars)); + }); +}); diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 28b653cb..1171b837 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -2,11 +2,9 @@ import { ipcMain, BrowserWindow, shell, app } from 'electron'; import { IPC_CHANNELS, AUTO_BUILD_PATHS, DEFAULT_APP_SETTINGS, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING, MODEL_ID_MAP, THINKING_BUDGET_MAP, getSpecsDir } from '../../../shared/constants'; import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, WorktreeMergeResult, WorktreeDiscardResult, WorktreeListResult, WorktreeListItem, WorktreeCreatePROptions, WorktreeCreatePRResult, SupportedIDE, SupportedTerminal, AppSettings } from '../../../shared/types'; import path from 'path'; +import { minimatch } from 'minimatch'; import { existsSync, readdirSync, statSync, readFileSync } from 'fs'; import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process'; -import { createRequire } from 'module'; -const require = createRequire(import.meta.url); -const { minimatch } = require('minimatch'); import { projectStore } from '../../project-store'; import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager'; import { getEffectiveSourcePath } from '../../updater/path-resolver'; diff --git a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts index 0e985a55..b83012b1 100644 --- a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts +++ b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts @@ -77,6 +77,12 @@ vi.mock('../session-handler', () => ({ releaseSessionId: mockReleaseSessionId, })); +// Mock PtyManager.writeToPty - the implementation now uses this instead of terminal.pty.write +const mockWriteToPty = vi.fn(); +vi.mock('../pty-manager', () => ({ + writeToPty: mockWriteToPty, +})); + vi.mock('os', async (importOriginal) => { const actual = await importOriginal(); return { @@ -199,6 +205,7 @@ describe('claude-integration-handler', () => { mockGetClaudeProfileManager.mockClear(); mockPersistSession.mockClear(); mockReleaseSessionId.mockClear(); + mockWriteToPty.mockClear(); vi.mocked(writeFileSync).mockClear(); }); @@ -225,7 +232,7 @@ describe('claude-integration-handler', () => { const { invokeClaude } = await import('../claude-integration-handler'); invokeClaude(terminal, '/tmp/project', undefined, () => null, vi.fn()); - const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + const written = mockWriteToPty.mock.calls[0][1] as string; expect(written).toContain(buildCdCommand('/tmp/project')); expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin')); expect(written).toContain(getQuotedCommand(platform, "/opt/claude bin/claude's")); @@ -268,7 +275,7 @@ describe('claude-integration-handler', () => { expect(tokenPath).toMatch(new RegExp(`^${escapeForRegex(tokenPrefix)}[0-9a-f]{16}${escapeForRegex(tokenExt)}$`)); expect(tokenContents).toBe(getTokenFileContent(platform, 'token-value')); - const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + const written = mockWriteToPty.mock.calls[0][1] as string; const clearCmd = getClearCommand(platform); const histPrefix = getHistoryPrefix(platform); const cmdQuote = platform === 'win32' ? '"' : "'"; @@ -318,7 +325,7 @@ describe('claude-integration-handler', () => { expect(tokenPath).toMatch(new RegExp(`^${escapeForRegex(tokenPrefix)}[0-9a-f]{16}${escapeForRegex(tokenExt)}$`)); expect(tokenContents).toBe(getTokenFileContent(platform, 'token-value')); - const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + const written = mockWriteToPty.mock.calls[0][1] as string; expect(written).toContain(getTempFileInvocation(platform, tokenPath)); expect(written).toContain(getTempFileCleanup(platform, tokenPath)); expect(written).toContain(getQuotedCommand(platform, command)); @@ -350,7 +357,7 @@ describe('claude-integration-handler', () => { const { invokeClaude } = await import('../claude-integration-handler'); invokeClaude(terminal, '/tmp/project', 'missing', () => null, vi.fn()); - const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + const written = mockWriteToPty.mock.calls[0][1] as string; expect(written).toContain(getQuotedCommand(platform, command)); expect(profileManager.getProfile).toHaveBeenCalledWith('missing'); expect(profileManager.markProfileUsed).not.toHaveBeenCalled(); @@ -381,7 +388,7 @@ describe('claude-integration-handler', () => { const { invokeClaude } = await import('../claude-integration-handler'); invokeClaude(terminal, '/tmp/project', 'prof-2', () => null, vi.fn()); - const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + const written = mockWriteToPty.mock.calls[0][1] as string; const clearCmd = getClearCommand(platform); const histPrefix = getHistoryPrefix(platform); const configDir = getConfigDirCommand(platform, '/tmp/claude-config'); @@ -420,7 +427,7 @@ describe('claude-integration-handler', () => { const { invokeClaude } = await import('../claude-integration-handler'); invokeClaude(terminal, '/tmp/project', 'prof-3', () => null, vi.fn()); - const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + const written = mockWriteToPty.mock.calls[0][1] as string; expect(written).toContain(getQuotedCommand(platform, command)); expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin')); expect(profileManager.getProfile).toHaveBeenCalledWith('prof-3'); @@ -445,7 +452,7 @@ describe('claude-integration-handler', () => { // Even when sessionId is passed, it should be ignored and --continue used resumeClaude(terminal, 'abc123', () => null); - const resumeCall = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + const resumeCall = mockWriteToPty.mock.calls[0][1] as string; expect(resumeCall).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin')); expect(resumeCall).toContain(getQuotedCommand(platform, '/opt/claude/bin/claude') + ' --continue'); expect(resumeCall).not.toContain('--resume'); @@ -454,12 +461,12 @@ describe('claude-integration-handler', () => { expect(terminal.isClaudeMode).toBe(true); expect(mockPersistSession).toHaveBeenCalledWith(terminal); - vi.mocked(terminal.pty.write).mockClear(); + mockWriteToPty.mockClear(); mockPersistSession.mockClear(); terminal.projectPath = undefined; terminal.isClaudeMode = false; resumeClaude(terminal, undefined, () => null); - const continueCall = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + const continueCall = mockWriteToPty.mock.calls[0][1] as string; expect(continueCall).toContain(getQuotedCommand(platform, '/opt/claude/bin/claude') + ' --continue'); expect(terminal.isClaudeMode).toBe(true); expect(terminal.claudeSessionId).toBeUndefined(); @@ -484,7 +491,7 @@ describe('claude-integration-handler', () => { const { invokeClaude } = await import('../claude-integration-handler'); expect(() => invokeClaude(terminal, '/tmp/project', undefined, () => null, vi.fn())).toThrow('boom'); expect(mockReleaseSessionId).toHaveBeenCalledWith('term-err'); - expect(terminal.pty.write).not.toHaveBeenCalled(); + expect(mockWriteToPty).not.toHaveBeenCalled(); }); it('throws when resumeClaude cannot resolve the CLI invocation', async () => { @@ -500,7 +507,7 @@ describe('claude-integration-handler', () => { const { resumeClaude } = await import('../claude-integration-handler'); expect(() => resumeClaude(terminal, 'abc123', () => null)).toThrow('boom'); - expect(terminal.pty.write).not.toHaveBeenCalled(); + expect(mockWriteToPty).not.toHaveBeenCalled(); }); it('throws when writing the OAuth token temp file fails', async () => { @@ -528,7 +535,7 @@ describe('claude-integration-handler', () => { const { invokeClaude } = await import('../claude-integration-handler'); expect(() => invokeClaude(terminal, '/tmp/project', 'prof-err', () => null, vi.fn())).toThrow('disk full'); - expect(terminal.pty.write).not.toHaveBeenCalled(); + expect(mockWriteToPty).not.toHaveBeenCalled(); }); }); diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index cdc81fd1..ccf6a647 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -12,6 +12,7 @@ import { IPC_CHANNELS } from '../../shared/constants'; import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager'; import * as OutputParser from './output-parser'; import * as SessionHandler from './session-handler'; +import * as PtyManager from './pty-manager'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { escapeShellArg, escapeForWindowsDoubleQuote, buildCdCommand } from '../../shared/utils/shell-escape'; import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils'; @@ -599,7 +600,8 @@ export function invokeClaude( const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', tempFile }, extraFlags); debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)'); - terminal.pty.write(command); + // Use PtyManager.writeToPty for safer write with error handling + PtyManager.writeToPty(terminal, command); profileManager.markProfileUsed(activeProfile.id); finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (temp file) =========='); @@ -607,7 +609,8 @@ export function invokeClaude( } else if (activeProfile.configDir) { const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', configDir: activeProfile.configDir }, extraFlags); debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)'); - terminal.pty.write(command); + // Use PtyManager.writeToPty for safer write with error handling + PtyManager.writeToPty(terminal, command); profileManager.markProfileUsed(activeProfile.id); finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (configDir) =========='); @@ -623,7 +626,8 @@ export function invokeClaude( const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }, extraFlags); debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command); - terminal.pty.write(command); + // Use PtyManager.writeToPty for safer write with error handling + PtyManager.writeToPty(terminal, command); if (activeProfile) { profileManager.markProfileUsed(activeProfile.id); @@ -690,7 +694,8 @@ export function resumeClaude( const command = `${pathPrefix}${escapedClaudeCmd} --continue`; - terminal.pty.write(`${command}\r`); + // Use PtyManager.writeToPty for safer write with error handling + PtyManager.writeToPty(terminal, `${command}\r`); // Only auto-rename if terminal has default name // This preserves user-customized names and prevents renaming on every resume @@ -817,7 +822,8 @@ export async function invokeClaudeAsync( const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', tempFile }, extraFlags); debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (temp file method, history-safe)'); - terminal.pty.write(command); + // Use PtyManager.writeToPty for safer write with error handling + PtyManager.writeToPty(terminal, command); profileManager.markProfileUsed(activeProfile.id); finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (temp file) =========='); @@ -825,7 +831,8 @@ export async function invokeClaudeAsync( } else if (activeProfile.configDir) { const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', configDir: activeProfile.configDir }, extraFlags); debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (configDir method, history-safe)'); - terminal.pty.write(command); + // Use PtyManager.writeToPty for safer write with error handling + PtyManager.writeToPty(terminal, command); profileManager.markProfileUsed(activeProfile.id); finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (configDir) =========='); @@ -841,7 +848,8 @@ export async function invokeClaudeAsync( const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }, extraFlags); debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (default method):', command); - terminal.pty.write(command); + // Use PtyManager.writeToPty for safer write with error handling + PtyManager.writeToPty(terminal, command); if (activeProfile) { profileManager.markProfileUsed(activeProfile.id); @@ -917,7 +925,8 @@ export async function resumeClaudeAsync( const command = `${pathPrefix}${escapedClaudeCmd} --continue`; - terminal.pty.write(`${command}\r`); + // Use PtyManager.writeToPty for safer write with error handling + PtyManager.writeToPty(terminal, `${command}\r`); // Only auto-rename if terminal has default name // This preserves user-customized names and prevents renaming on every resume @@ -1091,14 +1100,16 @@ export async function switchClaudeProfile( // Send Ctrl+C to interrupt any ongoing operation debugLog('[ClaudeIntegration:switchClaudeProfile] Sending Ctrl+C (\\x03)'); - terminal.pty.write('\x03'); + // Use PtyManager.writeToPty for safer write with error handling + PtyManager.writeToPty(terminal, '\x03'); // Wait briefly for Ctrl+C to take effect before sending /exit await new Promise(resolve => setTimeout(resolve, 100)); // Send /exit command debugLog('[ClaudeIntegration:switchClaudeProfile] Sending /exit command'); - terminal.pty.write('/exit\r'); + // Use PtyManager.writeToPty for safer write with error handling + PtyManager.writeToPty(terminal, '/exit\r'); // Wait for Claude to actually exit by monitoring for shell prompt const exitResult = await waitForClaudeExit(terminal, { timeout: 5000, pollInterval: 100 }); diff --git a/apps/frontend/src/shared/constants/models.ts b/apps/frontend/src/shared/constants/models.ts index bcfcf8ce..72e76a0c 100644 --- a/apps/frontend/src/shared/constants/models.ts +++ b/apps/frontend/src/shared/constants/models.ts @@ -28,7 +28,7 @@ export const THINKING_BUDGET_MAP: Record = { low: 1024, medium: 4096, high: 16384, - ultrathink: 64000 // Maximum reasoning depth (Claude API hard limit) + ultrathink: 63999 // Maximum reasoning depth (API requires max_tokens >= budget + 1, so 63999 + 1 = 64000 limit) } as const; // ============================================ diff --git a/apps/frontend/src/shared/platform.cjs b/apps/frontend/src/shared/platform.cjs new file mode 100644 index 00000000..acf3ebfc --- /dev/null +++ b/apps/frontend/src/shared/platform.cjs @@ -0,0 +1,46 @@ +// CommonJS wrapper for build scripts that cannot import TypeScript directly. +'use strict'; + +function getCurrentPlatform() { + const p = process.platform; + if (p === 'win32' || p === 'darwin' || p === 'linux') { + return p; + } + return 'unknown'; +} + +function isWindows() { + return getCurrentPlatform() === 'win32'; +} + +function isMacOS() { + return getCurrentPlatform() === 'darwin'; +} + +function isLinux() { + return getCurrentPlatform() === 'linux'; +} + +function isUnix() { + return isMacOS() || isLinux(); +} + +function toNodePlatform(platform) { + const map = { + mac: 'darwin', + win: 'win32', + darwin: 'darwin', + win32: 'win32', + linux: 'linux', + }; + return map[platform] || platform; +} + +module.exports = { + getCurrentPlatform, + isWindows, + isMacOS, + isLinux, + isUnix, + toNodePlatform, +}; diff --git a/package-lock.json b/package-lock.json index fba8b960..784844f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -196,16 +196,6 @@ "lru-cache": "^11.2.4" } }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@asamuzakjp/dom-selector": { "version": "6.7.6", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", @@ -220,16 +210,6 @@ "lru-cache": "^11.2.4" } }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", @@ -337,6 +317,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -347,6 +337,13 @@ "semver": "bin/semver.js" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -6165,6 +6162,16 @@ "balanced-match": "^1.0.0" } }, + "node_modules/cacache/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/cacache/node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -6209,6 +6216,33 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/cacache/node_modules/tar": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz", + "integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cacache/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -6253,9 +6287,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001765", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", - "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "version": "1.0.30001764", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", + "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", "dev": true, "funding": [ { @@ -6366,13 +6400,13 @@ } }, "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", "engines": { - "node": ">=18" + "node": ">=10" } }, "node_modules/chromium-pickle-js": { @@ -6735,16 +6769,6 @@ "node": ">=20" } }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -8408,13 +8432,6 @@ "node": ">=10" } }, - "node_modules/hosted-git-info/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -9510,13 +9527,13 @@ } }, "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/lucide-react": { @@ -10642,13 +10659,6 @@ "node": ">=8" } }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/minipass-pipeline": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", @@ -10675,13 +10685,6 @@ "node": ">=8" } }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/minipass-sized": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", @@ -10708,13 +10711,6 @@ "node": ">=8" } }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/minizlib": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", @@ -10729,17 +10725,16 @@ } }, "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "minimist": "^1.2.6" - }, "bin": { "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/module-details-from-path": { @@ -10893,6 +10888,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/node-gyp/node_modules/isexe": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", @@ -10903,6 +10908,23 @@ "node": ">=16" } }, + "node_modules/node-gyp/node_modules/tar": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz", + "integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/node-gyp/node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -10919,6 +10941,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -12740,30 +12772,84 @@ } }, "node_modules/tar": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz", - "integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" }, "engines": { - "node": ">=18" + "node": ">=10" } }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, "engines": { - "node": ">=18" + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/temp": { @@ -12830,6 +12916,20 @@ "node": ">= 10.0.0" } }, + "node_modules/temp/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -14331,9 +14431,9 @@ } }, "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, "license": "ISC" }, diff --git a/package.json b/package.json index 7b625f3d..2b70b2a1 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "jsdom": "^27.4.0" }, "overrides": { - "tar": "^7.5.3", "@electron/rebuild": "4.0.2" } } diff --git a/tests/test_thinking_level_validation.py b/tests/test_thinking_level_validation.py index 3f00d795..34da1a51 100644 --- a/tests/test_thinking_level_validation.py +++ b/tests/test_thinking_level_validation.py @@ -32,8 +32,8 @@ class TestThinkingLevelValidation: assert get_thinking_budget("none") is None def test_ultrathink_max_budget(self): - """Test that 'ultrathink' returns maximum budget (Claude API's 64000 token limit).""" - assert get_thinking_budget("ultrathink") == 64000 + """Test that 'ultrathink' returns maximum budget (63999 so max_tokens = 63999 + 1 = 64000 limit).""" + assert get_thinking_budget("ultrathink") == 63999 def test_invalid_level_logs_warning(self, caplog): """Test that invalid thinking level logs a warning.""" @@ -89,4 +89,4 @@ class TestThinkingLevelValidation: assert get_thinking_budget("low") == 1024 assert get_thinking_budget("medium") == 4096 assert get_thinking_budget("high") == 16384 - assert get_thinking_budget("ultrathink") == 64000 + assert get_thinking_budget("ultrathink") == 63999