diff --git a/apps/backend/core/auth.py b/apps/backend/core/auth.py index 01dab6ad7d..c6a4952fbb 100644 --- a/apps/backend/core/auth.py +++ b/apps/backend/core/auth.py @@ -4,12 +4,16 @@ Provides centralized authentication token resolution with fallback support for multiple environment variables, and SDK environment variable passthrough for custom API endpoints. + +Extended to support reading env configuration from active profile's settings.json +for third-party API providers (e.g., Minimax, OpenRouter). """ import json import os import platform import subprocess +from pathlib import Path # Priority order for auth token resolution # NOTE: We intentionally do NOT fall back to ANTHROPIC_API_KEY. @@ -23,21 +27,125 @@ # Environment variables to pass through to SDK subprocess # NOTE: ANTHROPIC_API_KEY is intentionally excluded to prevent silent API billing SDK_ENV_VARS = [ - # API endpoint configuration "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", - # Model overrides (from API Profile custom model mappings) - "ANTHROPIC_MODEL", - "ANTHROPIC_DEFAULT_HAIKU_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL", - "ANTHROPIC_DEFAULT_OPUS_MODEL", - # SDK behavior configuration "NO_PROXY", "DISABLE_TELEMETRY", "DISABLE_COST_WARNINGS", "API_TIMEOUT_MS", ] +# Additional env vars that can be loaded from profile settings.json +PROFILE_ENV_VARS = [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "API_TIMEOUT_MS", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", +] + + +def _get_auto_claude_ui_config_dir() -> Path | None: + """Get the Auto Claude UI config directory based on platform.""" + system = platform.system() + if system == "Darwin": + return Path.home() / "Library" / "Application Support" / "auto-claude-ui" / "config" + elif system == "Windows": + appdata = os.environ.get("APPDATA", "") + if appdata: + return Path(appdata) / "auto-claude-ui" / "config" + elif system == "Linux": + xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) + return Path(xdg_config) / "auto-claude-ui" / "config" + return None + + +def _get_active_profile() -> dict | None: + """ + Get the active profile from claude-profiles.json. + + Returns: + Active profile dict with id, name, configDir, etc., or None if not found + """ + config_dir = _get_auto_claude_ui_config_dir() + if not config_dir: + return None + + profiles_file = config_dir / "claude-profiles.json" + if not profiles_file.exists(): + return None + + try: + with open(profiles_file, encoding="utf-8") as f: + data = json.load(f) + + active_id = data.get("activeProfileId", "default") + profiles = data.get("profiles", []) + + for profile in profiles: + if profile.get("id") == active_id: + return profile + + # Fallback to first profile if active not found + if profiles: + return profiles[0] + + except (json.JSONDecodeError, KeyError, Exception): + pass + + return None + + +def _get_profile_env_vars() -> dict[str, str]: + """ + Get environment variables from the active profile's settings.json. + + Reads the 'env' section from the profile's configDir/settings.json file. + This enables support for third-party API providers like Minimax. + + Returns: + Dict of env var name -> value from profile settings + """ + profile = _get_active_profile() + if not profile: + return {} + + config_dir = profile.get("configDir") + if not config_dir: + return {} + + settings_file = Path(config_dir) / "settings.json" + if not settings_file.exists(): + return {} + + try: + with open(settings_file, encoding="utf-8") as f: + settings = json.load(f) + + env_section = settings.get("env", {}) + if not isinstance(env_section, dict): + return {} + + # Only include allowed env vars for security + result = {} + for var in PROFILE_ENV_VARS: + if var in env_section: + value = env_section[var] + # Convert non-string values to string + if isinstance(value, bool): + result[var] = "1" if value else "0" + elif value is not None: + result[var] = str(value) + + return result + + except (json.JSONDecodeError, KeyError, Exception): + return {} + def get_token_from_keychain() -> str | None: """ @@ -136,7 +244,8 @@ def get_auth_token() -> str | None: Checks multiple sources in priority order: 1. CLAUDE_CODE_OAUTH_TOKEN (env var) 2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups) - 3. System credential store (macOS Keychain, Windows Credential Manager) + 3. Profile settings.json env section (for third-party providers) + 4. System credential store (macOS Keychain, Windows Credential Manager) NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent silent billing to user's API credits when OAuth is misconfigured. @@ -150,6 +259,11 @@ def get_auth_token() -> str | None: if token: return token + # Check profile settings for ANTHROPIC_AUTH_TOKEN (for third-party providers) + profile_env = _get_profile_env_vars() + if "ANTHROPIC_AUTH_TOKEN" in profile_env: + return profile_env["ANTHROPIC_AUTH_TOKEN"] + # Fallback to system credential store return get_token_from_keychain() @@ -161,6 +275,13 @@ def get_auth_token_source() -> str | None: if os.environ.get(var): return var + # Check profile settings + profile_env = _get_profile_env_vars() + if "ANTHROPIC_AUTH_TOKEN" in profile_env: + profile = _get_active_profile() + if profile: + return f"Profile: {profile.get('name', profile.get('id', 'unknown'))}" + # Check if token came from system credential store if get_token_from_keychain(): system = platform.system() @@ -222,14 +343,30 @@ def get_sdk_env_vars() -> dict[str, str]: Collects relevant env vars (ANTHROPIC_BASE_URL, etc.) that should be passed through to the claude-agent-sdk subprocess. + Priority order: + 1. System environment variables (os.environ) + 2. Active profile's settings.json env section + + This allows using third-party API providers (Minimax, OpenRouter, etc.) + by configuring them in the profile's settings.json. + Returns: Dict of env var name -> value for non-empty vars """ env = {} + + # First, load from active profile settings (lower priority) + profile_env = _get_profile_env_vars() + for var, value in profile_env.items(): + if value: + env[var] = value + + # Then, override with system environment variables (higher priority) for var in SDK_ENV_VARS: value = os.environ.get(var) if value: env[var] = value + return env @@ -246,3 +383,26 @@ def ensure_claude_code_oauth_token() -> None: token = get_auth_token() if token: os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token + + +def get_active_profile_info() -> dict | None: + """ + Get information about the currently active profile. + + Returns: + Dict with profile info (id, name, configDir, isThirdParty) or None + """ + profile = _get_active_profile() + if not profile: + return None + + profile_env = _get_profile_env_vars() + is_third_party = bool(profile_env.get("ANTHROPIC_BASE_URL")) + + return { + "id": profile.get("id"), + "name": profile.get("name"), + "configDir": profile.get("configDir"), + "isThirdParty": is_third_party, + "baseUrl": profile_env.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com"), + } diff --git a/apps/backend/core/worktree.py b/apps/backend/core/worktree.py index ab3b89e3b3..5ce46b6ba1 100644 --- a/apps/backend/core/worktree.py +++ b/apps/backend/core/worktree.py @@ -23,6 +23,43 @@ from pathlib import Path +def _load_env_file(env_path: Path) -> dict[str, str]: + """ + Load environment variables from a .env file. + + Args: + env_path: Path to the .env file + + Returns: + Dict of env var name -> value + """ + env_vars = {} + if not env_path.exists(): + return env_vars + + try: + with open(env_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + # Skip empty lines and comments + if not line or line.startswith("#"): + continue + # Parse KEY=value format + if "=" in line: + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + # Remove surrounding quotes if present + if value and value[0] in ('"', "'") and value[-1] == value[0]: + value = value[1:-1] + if key: + env_vars[key] = value + except Exception: + pass + + return env_vars + + class WorktreeError(Exception): """Error during worktree operations.""" @@ -63,15 +100,23 @@ def _detect_base_branch(self) -> str: Detect the base branch for worktree creation. Priority order: - 1. DEFAULT_BRANCH environment variable - 2. Auto-detect main/master (if they exist) - 3. Fall back to current branch (with warning) + 1. DEFAULT_BRANCH environment variable (system) + 2. DEFAULT_BRANCH from project's .auto-claude/.env file + 3. Auto-detect main/master (if they exist) + 4. Fall back to current branch (with warning) Returns: The detected base branch name """ - # 1. Check for DEFAULT_BRANCH env var + # 1. Check for DEFAULT_BRANCH env var (system environment) env_branch = os.getenv("DEFAULT_BRANCH") + + # 2. If not in system env, check project's .auto-claude/.env file + if not env_branch: + project_env_file = self.project_dir / ".auto-claude" / ".env" + project_env = _load_env_file(project_env_file) + env_branch = project_env.get("DEFAULT_BRANCH") + if env_branch: # Verify the branch exists result = subprocess.run(