Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 168 additions & 8 deletions apps/backend/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Comment on lines +97 to +98
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a broad except Exception: pass can hide bugs and make debugging difficult. It's better to catch more specific exceptions or at least log any unexpected errors to help with troubleshooting when reading profile data.

Suggested change
except (json.JSONDecodeError, KeyError, Exception):
pass
except (json.JSONDecodeError, KeyError, Exception) as e:
# Consider logging this exception for easier debugging.
# For example: logging.warning(f"Failed to get active profile: {e}")
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 {}
Comment on lines +146 to +147
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Silently catching all exceptions with except Exception can hide underlying issues with settings.json files, such as file permission errors or malformed content. It's recommended to log the exception to make debugging easier.

Suggested change
except (json.JSONDecodeError, KeyError, Exception):
return {}
except (json.JSONDecodeError, KeyError, Exception) as e:
# Consider logging this exception for easier debugging.
# For example: logging.warning(f"Failed to get profile env vars: {e}")
return {}



def get_token_from_keychain() -> str | None:
"""
Expand Down Expand Up @@ -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.
Expand All @@ -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()

Expand All @@ -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()
Comment on lines +279 to +281
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There's a small inefficiency here. The _get_profile_env_vars() function already calls _get_active_profile() internally. By calling _get_active_profile() again on line 281, you're doing redundant work (e.g., reading and parsing config files twice). This could be optimized by modifying _get_profile_env_vars to also return the profile it found, or by caching the result of _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()
Expand Down Expand Up @@ -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
Comment on lines 365 to 368
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This logic for overriding with system environment variables is incomplete. It only iterates over SDK_ENV_VARS, which no longer includes model-related variables like ANTHROPIC_MODEL. This means if a user sets ANTHROPIC_MODEL as a system environment variable, it will be ignored, which breaks the expected priority of system environment variables over profile settings. The loop should iterate over all possible SDK-related variables to ensure system environment variables always take precedence.

Suggested change
for var in SDK_ENV_VARS:
value = os.environ.get(var)
if value:
env[var] = value
all_sdk_vars = set(SDK_ENV_VARS) | set(PROFILE_ENV_VARS)
for var in all_sdk_vars:
value = os.environ.get(var)
if value:
env[var] = value


return env


Expand All @@ -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()
Comment on lines +395 to +399
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to another function in this file, there's a redundant call pattern. _get_active_profile() is called on line 395, and then _get_profile_env_vars() on line 399 calls it again internally. This results in reading and parsing configuration files twice. To improve efficiency, you could pass the profile object to a modified _get_profile_env_vars or have it return the profile along with the environment variables.

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"),
}
Comment on lines +388 to +408
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

LGTM with same redundancy note.

The function provides useful profile metadata. Same observation as before: _get_profile_env_vars() (line 399) will call _get_active_profile() internally, duplicating the work done on line 395.

Consider refactoring the internal functions to accept an optional pre-fetched profile to avoid repeated file I/O, but this is a nice-to-have optimization.

🤖 Prompt for AI Agents
In apps/backend/core/auth.py around lines 388 to 408, the call to
_get_profile_env_vars() duplicates work because that helper re-calls
_get_active_profile(); modify _get_profile_env_vars to accept an optional
profile argument (defaulting to None) and use the already-fetched profile when
provided, then change this function to pass the local profile into
_get_profile_env_vars(profile) to avoid redundant file I/O; update other callers
of _get_profile_env_vars as needed to preserve behavior (use existing call with
no arg or pass a pre-fetched profile) and add minimal docstring/comments noting
the new parameter.

53 changes: 49 additions & 4 deletions apps/backend/core/worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +57 to +58
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The except Exception: pass is too broad and will silently ignore all errors during .env file processing, such as permission errors or encoding issues. This can make it very difficult to diagnose problems. It's better to log the exception to provide visibility into potential issues.

Suggested change
except Exception:
pass
except Exception as e:
# Consider logging the exception for debugging purposes, e.g.:
# import logging
# logging.warning(f"Error loading .env file {env_path}: {e}")
pass


return env_vars


class WorktreeError(Exception):
"""Error during worktree operations."""

Expand Down Expand Up @@ -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(
Expand Down