Skip to content
Draft
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
20 changes: 20 additions & 0 deletions .env.quickstart.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,25 @@
# REQUIRED — fill all of these before running specflow-init.sh
# =============================================================================

# Git host — set ONE of GITHUB_TOKEN or BITBUCKET_TOKEN below; leave the other
# blank. A deployment is either all-GitHub or all-BitBucket, never mixed.
#
# GITHUB_TOKEN
# What: GitHub Personal Access Token used to create and manage disposable
# workspace repositories where agents commit generated code.
# How: Generate a classic PAT with `repo` + `workflow` scopes:
# https://github.com/settings/tokens/new?scopes=repo,workflow
GITHUB_TOKEN=
#
# BITBUCKET_TOKEN / BITBUCKET_WORKSPACE (alternative to GitHub)
# What: A BitBucket Cloud Repository/Workspace access token, plus the
# workspace slug that owns the disposable workspace repos. No
# username needed — access tokens always authenticate as the fixed
# actor `x-token-auth`.
# How: Create a Workspace access token with repository read/write/admin
# scopes in BitBucket: Workspace settings > Access tokens.
# BITBUCKET_TOKEN=
# BITBUCKET_WORKSPACE=

# P10Y_API_KEY
# What: API key for Compass by P10Y — a code-complexity scoring service.
Expand Down Expand Up @@ -111,6 +124,13 @@ GIT_USER_EMAIL=
# or organization name.
GITHUB_ORG=

# GIT_PROVIDER
# What: Explicit active git host override ("github" or "bitbucket_cloud").
# How: Leave blank — auto-inferred from whichever of GITHUB_TOKEN /
# BITBUCKET_TOKEN is set. Only set this if you ever have both
# configured at once (which otherwise fails fast as ambiguous).
# GIT_PROVIDER=

# GIT_COMMITTER_USER_EMAIL
# What: Email for system-level git commits (archive, workspace reset).
# This is NOT your personal commit email — it identifies automated
Expand Down
10 changes: 10 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,15 @@ def _empty_str_to_none_int(cls, v: object) -> object:
validation_alias=AliasChoices("GITHUB_TOKEN_DEFAULT", "GITHUB_TOKEN"),
)

# BitBucket Cloud — mutually exclusive with GitHub (see git_provider.resolve_active_git_provider).
BITBUCKET_TOKEN_DEFAULT: Optional[str] = Field(
default=None,
validation_alias=AliasChoices("BITBUCKET_TOKEN_DEFAULT", "BITBUCKET_TOKEN"),
)
BITBUCKET_WORKSPACE: Optional[str] = None
# Explicit active-provider override; else inferred from exactly one of the tokens above.
GIT_PROVIDER: Optional[str] = None

# For cloning and https auth
GIT_USER_NAME_DEFAULT: Optional[str] = Field(
default=None,
Expand All @@ -221,6 +230,7 @@ def _empty_str_to_none_int(cls, v: object) -> object:
K8S_SECRET_KEY_ENCRYPTION: str = "token-encryption-key"
K8S_SECRET_KEY_GITHUB_DEFAULT: str = "github-token-default"
K8S_SECRET_KEY_GIT_USER_DEFAULT: str = "git-user-default"
K8S_SECRET_KEY_BITBUCKET_DEFAULT: str = "bitbucket-token-default"

# Database Configuration
DATABASE_TYPE: DatabaseType = DatabaseType.MEMORY
Expand Down
42 changes: 41 additions & 1 deletion backend/app/core/github_platform_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
from cryptography.fernet import Fernet, InvalidToken

from app.core.config import Settings
from app.services.git_provider import (
GitProvider,
resolve_active_git_provider,
resolve_active_git_provider_from_flags,
strategy_for,
)

logger = logging.getLogger(__name__)

Expand All @@ -29,6 +35,8 @@ class GithubPlatformSecrets:
_fernet: Fernet
github_token_default: Optional[str]
git_user_name_default: Optional[str]
bitbucket_token_default: Optional[str] = None
active_provider: GitProvider = GitProvider.GITHUB

def encrypt_token(self, token: str) -> str:
return self._fernet.encrypt(token.encode()).decode()
Expand All @@ -39,6 +47,16 @@ def decrypt_token(self, ciphertext: str) -> str:
except InvalidToken as e:
raise ValueError("GitHub token decryption failed") from e

@property
def active_default_token(self) -> Optional[str]:
if self.active_provider == GitProvider.BITBUCKET_CLOUD:
return self.bitbucket_token_default
return self.github_token_default

@property
def active_git_user_default(self) -> str:
return self.git_user_name_default or strategy_for(self.active_provider).default_git_user


def get_github_platform_secrets() -> GithubPlatformSecrets:
if _secrets is None:
Expand All @@ -60,13 +78,17 @@ def init_github_platform_secrets_for_tests(
fernet_key: bytes,
github_token_default: str | None = "test-default-token",
git_user_name_default: str | None = "test-user",
bitbucket_token_default: str | None = None,
active_provider: GitProvider = GitProvider.GITHUB,
) -> None:
"""Initialize secrets for unit tests (no K8s / .env)."""
global _secrets
_secrets = GithubPlatformSecrets(
_fernet=Fernet(fernet_key),
github_token_default=github_token_default,
git_user_name_default=git_user_name_default,
bitbucket_token_default=bitbucket_token_default,
active_provider=active_provider,
)


Expand Down Expand Up @@ -97,6 +119,8 @@ def _build_secrets_from_map(
encryption_key_field: str,
github_default_field: str,
git_user_field: str,
bitbucket_default_field: str,
git_provider_override: str | None,
) -> GithubPlatformSecrets:
enc_raw = data.get(encryption_key_field)
if not enc_raw:
Expand All @@ -106,10 +130,20 @@ def _build_secrets_from_map(
fernet = Fernet(_decode_secret_value(enc_raw).strip().encode())
gh = data.get(github_default_field)
gu = data.get(git_user_field)
bb = data.get(bitbucket_default_field)
gh_value = gh.strip() if gh else None
bb_value = bb.strip() if bb else None
active_provider = resolve_active_git_provider_from_flags(
override=git_provider_override,
has_github_token=bool(gh_value),
has_bitbucket_token=bool(bb_value),
)
return GithubPlatformSecrets(
_fernet=fernet,
github_token_default=gh.strip() if gh else None,
github_token_default=gh_value,
git_user_name_default=gu.strip() if gu else None,
bitbucket_token_default=bb_value,
active_provider=active_provider,
)


Expand All @@ -122,10 +156,14 @@ def _load_from_env(settings: Settings) -> GithubPlatformSecrets:
fernet = Fernet(str(enc).strip().encode())
gh_default = settings.GITHUB_TOKEN_DEFAULT
git_user = settings.GIT_USER_NAME_DEFAULT
bb_default = settings.BITBUCKET_TOKEN_DEFAULT
active_provider = resolve_active_git_provider(settings)
return GithubPlatformSecrets(
_fernet=fernet,
github_token_default=gh_default.strip() if gh_default else None,
git_user_name_default=git_user.strip() if git_user else None,
bitbucket_token_default=bb_default.strip() if bb_default else None,
active_provider=active_provider,
)


Expand All @@ -149,6 +187,8 @@ def init_github_platform_secrets(settings: Settings) -> None:
encryption_key_field=settings.K8S_SECRET_KEY_ENCRYPTION,
github_default_field=settings.K8S_SECRET_KEY_GITHUB_DEFAULT,
git_user_field=settings.K8S_SECRET_KEY_GIT_USER_DEFAULT,
bitbucket_default_field=settings.K8S_SECRET_KEY_BITBUCKET_DEFAULT,
git_provider_override=settings.GIT_PROVIDER,
)
logger.info(
"Loaded GitHub platform secrets from Kubernetes API (namespace=%s, name=%s)",
Expand Down
107 changes: 107 additions & 0 deletions backend/app/services/git_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""
Minimal git-host provider abstraction.

A deployment is either all-GitHub or all-BitBucket, never mixed — the active
provider is resolved once, globally, from configured tokens (or an explicit
override). See docs/plans/workspace-repos/bitbucket-pr1-commit-and-read.md.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Optional

if TYPE_CHECKING:
from app.core.config import Settings


class GitProvider(str, Enum):
GITHUB = "github"
BITBUCKET_CLOUD = "bitbucket_cloud"


class GitProviderResolutionError(Exception):
"""Raised when the active git provider cannot be determined unambiguously."""


@dataclass(frozen=True)
class GitHostStrategy:
provider: GitProvider
default_git_user: str
sanitization_patterns: tuple[tuple[re.Pattern[str], str], ...]


_GITHUB = GitHostStrategy(
provider=GitProvider.GITHUB,
default_git_user="x-access-token",
sanitization_patterns=(
(re.compile(r"(https?://[^:]+:)[^@]+(@github\.com)"), r"\1[REDACTED]\2"),
(re.compile(r"gh[ps]_[A-Za-z0-9_]+"), "[REDACTED]"),
(re.compile(r"github_pat_[A-Za-z0-9_]+"), "[REDACTED]"),
),
)

_BITBUCKET = GitHostStrategy(
provider=GitProvider.BITBUCKET_CLOUD,
default_git_user="x-token-auth",
sanitization_patterns=(
(re.compile(r"(https?://[^:]+:)[^@]+(@bitbucket\.org)"), r"\1[REDACTED]\2"),
(re.compile(r"ATCTT[A-Za-z0-9_=\-]+"), "[REDACTED]"),
),
)

_STRATEGIES: dict[GitProvider, GitHostStrategy] = {
GitProvider.GITHUB: _GITHUB,
GitProvider.BITBUCKET_CLOUD: _BITBUCKET,
}


def strategy_for(provider: GitProvider) -> GitHostStrategy:
return _STRATEGIES[provider]


def all_strategies() -> list[GitHostStrategy]:
return list(_STRATEGIES.values())


def resolve_active_git_provider_from_flags(
*,
override: Optional[str],
has_github_token: bool,
has_bitbucket_token: bool,
) -> GitProvider:
"""Pure resolution logic shared by settings-based and Kubernetes-secret-map loading."""
explicit = (override or "").strip().lower()
if explicit:
try:
return GitProvider(explicit)
except ValueError as e:
valid = [p.value for p in GitProvider]
raise GitProviderResolutionError(
f"GIT_PROVIDER={explicit!r} is not valid; expected one of {valid}"
) from e

if has_github_token and has_bitbucket_token:
raise GitProviderResolutionError(
"Both a GitHub and a BitBucket token are configured; set GIT_PROVIDER "
"explicitly to choose the active git host (mixing providers is not supported)"
)
if has_github_token:
return GitProvider.GITHUB
if has_bitbucket_token:
return GitProvider.BITBUCKET_CLOUD

raise GitProviderResolutionError(
"No git provider configured; set GITHUB_TOKEN or BITBUCKET_TOKEN "
"(and GIT_PROVIDER if both might be set)"
)


def resolve_active_git_provider(settings: "Settings") -> GitProvider:
return resolve_active_git_provider_from_flags(
override=settings.GIT_PROVIDER,
has_github_token=bool(settings.GITHUB_TOKEN_DEFAULT),
has_bitbucket_token=bool(settings.BITBUCKET_TOKEN_DEFAULT),
)
13 changes: 7 additions & 6 deletions backend/app/services/github_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,23 @@ def resolve_github_auth_for_api_key_document(
token = secrets.decrypt_token(str(ciphertext))
except ValueError as e:
raise GithubAuthResolutionError("Stored GitHub token could not be decrypted") from e
user = (per_key_user or secrets.git_user_name_default or "x-access-token").strip()
user = (per_key_user or secrets.active_git_user_default).strip()
return GithubAuthContext(git_user_name=user, token=token)

if pool == DEFAULT_WORKSPACE_POOL:
if not secrets.github_token_default or not secrets.git_user_name_default:
if not secrets.active_default_token or not secrets.git_user_name_default:
raise GithubAuthResolutionError(
"Default workspace pool requires GITHUB_TOKEN_DEFAULT and GIT_USER_NAME_DEFAULT "
"(platform secrets) when no per-key token is stored"
f"Default workspace pool requires a default token and GIT_USER_NAME_DEFAULT "
f"(platform secrets) for the active provider ({secrets.active_provider.value}) "
f"when no per-key token is stored"
)
return GithubAuthContext(
git_user_name=secrets.git_user_name_default,
token=secrets.github_token_default,
token=secrets.active_default_token,
)

raise GithubAuthResolutionError(
f"Workspace pool {pool!r} requires a GitHub PAT: call PUT /api/v1/auth/github-token "
f"Workspace pool {pool!r} requires a git PAT: call PUT /api/v1/auth/github-token "
f"with this API key before running git operations"
)

Expand Down
32 changes: 9 additions & 23 deletions backend/app/services/workspace_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import logging
import os
from pathlib import Path
import re
import shutil
from typing import Any, Dict, List, Optional

Expand All @@ -32,6 +31,7 @@
GithubAuthResolutionError,
resolve_github_auth_for_generation_id,
)
from app.services.git_provider import all_strategies
from app.database.interface import IDatabase
from app.state import WorkspaceStateMachine
from app.state.db_adapter import StateMachineDBAdapter
Expand Down Expand Up @@ -173,31 +173,17 @@ def _sanitize_token_in_message(message: str, token: Optional[str] = None) -> str
return message

sanitized = message

# If specific token provided, replace it
if token:
sanitized = sanitized.replace(token, "[REDACTED]")

# Also sanitize common patterns for GitHub tokens in URLs
# Pattern: https://username:token@github.com/...
sanitized = re.sub(
r'(https?://[^:]+:)[^@]+(@github\.com)',
r'\1[REDACTED]\2',
sanitized
)

# Pattern: ghp_XXXX or github_pat_XXXX tokens (GitHub personal access tokens)
sanitized = re.sub(
r'gh[ps]_[A-Za-z0-9_]+',
'[REDACTED]',
sanitized
)
sanitized = re.sub(
r'github_pat_[A-Za-z0-9_]+',
'[REDACTED]',
sanitized
)


# Sanitize known token/URL patterns for every provider, not just the active one —
# cheap, and defends even if a token from another provider leaks into these logs.
for strategy in all_strategies():
for pattern, replacement in strategy.sanitization_patterns:
sanitized = pattern.sub(replacement, sanitized)

return sanitized

def __init__(self, db: IDatabase, workspace_base_path: Optional[str | Path] = None):
Expand Down
Loading