Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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: 19 additions & 1 deletion backend/app/api/endpoints/cloud_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

"""Shared cloud project endpoints."""

from fastapi import APIRouter, Depends, File, Form, Query, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile, status
from sqlalchemy.orm import Session

from app.api.dependencies import get_db
Expand All @@ -25,6 +25,7 @@
CloudProjectMemberCreate,
CloudProjectMemberResponse,
CloudProjectMemberUpdate,
CloudProjectProviderCredentialResponse,
CloudProjectResponse,
CloudProjectUpdate,
LocalBindingCreate,
Expand Down Expand Up @@ -69,6 +70,23 @@ def get_cloud_project(
return CloudProjectResponse.model_validate(project)


@router.get(
"/{project_id}/provider-credential",
response_model=CloudProjectProviderCredentialResponse,
)
def get_cloud_project_provider_credential(
project_id: int,
response: Response,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> CloudProjectProviderCredentialResponse:
response.headers["Cache-Control"] = "no-store"
token = cloud_project_service.get_provider_credential(
db, project_id, current_user.id
)
return CloudProjectProviderCredentialResponse(token=token)


@router.patch("/{project_id}", response_model=CloudProjectResponse)
def update_cloud_project(
project_id: int,
Expand Down
165 changes: 165 additions & 0 deletions backend/app/core/provider_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# SPDX-FileCopyrightText: 2026 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

"""Encrypted provider credentials stored with cloud project metadata."""

import base64
import hashlib
import os
from typing import Any

from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

from app.core.config import settings
from shared.utils.crypto import (
CryptoConfigurationError,
decrypt_sensitive_data,
)

TOKEN_KEY = "token"
CREDENTIAL_KEY = "credential"
CREDENTIAL_VERSION = 2
CREDENTIAL_ALGORITHM = "aes-256-gcm"
LEGACY_CREDENTIAL_VERSION = 1
LEGACY_CREDENTIAL_ALGORITHM = "aes-256-cbc"
NONCE_BYTES = 12


def store_provider_config(
task_provider: str,
replacement: dict[str, object],
current: dict[str, object] | None = None,
) -> dict[str, object]:
"""Normalize provider config and encrypt a supplied token."""
config = dict(replacement)
if CREDENTIAL_KEY in config:
raise ValueError("encrypted provider credentials cannot be supplied")
config.pop("credential_configured", None)
token_supplied = TOKEN_KEY in config
token = config.pop(TOKEN_KEY, None)
if token is not None and not isinstance(token, str):
raise ValueError("provider token must be a string")

if not token_supplied and current:
_preserve_credential(task_provider, current, config)
return config

normalized_token = token.strip() if isinstance(token, str) else ""
if normalized_token and normalized_token != "***":
config[CREDENTIAL_KEY] = _encrypt_provider_token(
normalized_token,
_credential_context(task_provider, config),
)
return config
Comment on lines +45 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Masked/empty token wipes the stored credential.

token_supplied short-circuits the preserve path, so an update that echoes the masked placeholder ("token": "***") or an empty string drops the existing credential from the config entirely — the project silently loses its provider credential. Since mask_provider_config is what clients read back, echoing *** is a realistic client behavior (the != "***" check acknowledges it).

🐛 Proposed fix
-    if not token_supplied and current:
-        _preserve_credential(task_provider, current, config)
-        return config
-
     normalized_token = token.strip() if isinstance(token, str) else ""
+    if (not token_supplied or not normalized_token or normalized_token == "***") and current:
+        _preserve_credential(task_provider, current, config)
+        return config
+
     if normalized_token and normalized_token != "***":
         config[CREDENTIAL_KEY] = _encrypt_provider_token(
             normalized_token,
             _credential_context(task_provider, config),
         )
     return config
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not token_supplied and current:
_preserve_credential(task_provider, current, config)
return config
normalized_token = token.strip() if isinstance(token, str) else ""
if normalized_token and normalized_token != "***":
config[CREDENTIAL_KEY] = _encrypt_provider_token(
normalized_token,
_credential_context(task_provider, config),
)
return config
normalized_token = token.strip() if isinstance(token, str) else ""
if (not token_supplied or not normalized_token or normalized_token == "***") and current:
_preserve_credential(task_provider, current, config)
return config
if normalized_token and normalized_token != "***":
config[CREDENTIAL_KEY] = _encrypt_provider_token(
normalized_token,
_credential_context(task_provider, config),
)
return config
🧰 Tools
🪛 Ruff (0.15.21)

[error] 50-50: Possible hardcoded password assigned to: "normalized_token"

(S105)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/core/provider_credentials.py` around lines 45 - 55, Update the
credential handling flow around token_supplied and normalized_token so masked
("***") or empty tokens preserve the existing credential instead of removing it.
Reuse the existing _preserve_credential path when current is available, while
continuing to encrypt and store genuinely new non-masked tokens via
_encrypt_provider_token.



def mask_provider_config(provider_config: object) -> dict[str, object]:
"""Return non-sensitive provider settings for normal project responses."""
if not isinstance(provider_config, dict):
return {}
config = dict(provider_config)
configured = isinstance(config.get(CREDENTIAL_KEY), dict)
config.pop(TOKEN_KEY, None)
config.pop(CREDENTIAL_KEY, None)
config["credential_configured"] = configured
return config


def decrypt_provider_token(task_provider: str, provider_config: object) -> str | None:
"""Decrypt a stored cloud project provider token."""
if not isinstance(provider_config, dict):
return None
credential = provider_config.get(CREDENTIAL_KEY)
if not isinstance(credential, dict):
return None
version = credential.get("version")
algorithm = credential.get("algorithm")
if (
version == LEGACY_CREDENTIAL_VERSION
and algorithm == LEGACY_CREDENTIAL_ALGORITHM
):
return _decrypt_legacy_provider_token(credential)
if version != CREDENTIAL_VERSION or algorithm != CREDENTIAL_ALGORITHM:
raise ValueError("unsupported provider credential format")
nonce = credential.get("nonce")
ciphertext = credential.get("ciphertext")
context = credential.get("context")
expected_context = _credential_context(task_provider, provider_config)
if not isinstance(nonce, str) or not nonce:
raise ValueError("provider credential nonce is required")
if not isinstance(ciphertext, str) or not ciphertext:
raise ValueError("provider credential ciphertext is required")
if not isinstance(context, str) or context != expected_context:
raise ValueError("provider credential context does not match project")
try:
token = AESGCM(_provider_credential_key()).decrypt(
base64.b64decode(nonce, validate=True),
base64.b64decode(ciphertext, validate=True),
context.encode("utf-8"),
)
except (InvalidTag, ValueError) as exc:
raise ValueError("provider credential decryption failed") from exc
if not token:
raise ValueError("provider credential decryption failed")
return token.decode("utf-8")


def _preserve_credential(
task_provider: str,
current: dict[str, object],
replacement: dict[str, object],
) -> None:
credential = current.get(CREDENTIAL_KEY)
if not isinstance(credential, dict):
return
if _credential_context(task_provider, current) != _credential_context(
task_provider, replacement
):
raise ValueError("provider token is required when repository or domain changes")
replacement[CREDENTIAL_KEY] = credential


def _credential_context(task_provider: str, config: dict[str, Any]) -> str:
repository = str(config.get("repository") or "").strip()
default_domain = "github.com" if task_provider == "github" else "gitlab.com"
domain = str(config.get("domain") or default_domain).strip()
return f"{task_provider}:{domain}:{repository}"


def _provider_credential_key() -> bytes:
material = f"wegent-cloud-project-provider:{settings.SECRET_KEY}".encode("utf-8")
return hashlib.sha256(material).digest()


def _encrypt_provider_token(token: str, context: str) -> dict[str, object]:
nonce = os.urandom(NONCE_BYTES)
ciphertext = AESGCM(_provider_credential_key()).encrypt(
nonce,
token.encode("utf-8"),
context.encode("utf-8"),
)
return {
"version": CREDENTIAL_VERSION,
"algorithm": CREDENTIAL_ALGORITHM,
"context": context,
"nonce": base64.b64encode(nonce).decode("ascii"),
"ciphertext": base64.b64encode(ciphertext).decode("ascii"),
}


def _decrypt_legacy_provider_token(credential: dict[str, object]) -> str:
ciphertext = credential.get("ciphertext")
if not isinstance(ciphertext, str) or not ciphertext:
raise ValueError("provider credential ciphertext is required")
try:
token = decrypt_sensitive_data(ciphertext)
except CryptoConfigurationError as exc:
raise ValueError(
"legacy provider credentials require GIT_TOKEN_AES_KEY and "
"GIT_TOKEN_AES_IV"
) from exc
if not token or token == ciphertext:
raise ValueError("provider credential decryption failed")
return token
61 changes: 40 additions & 21 deletions backend/app/mcp_server/tools/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ def _serialize_collaborator(row: dict[str, Any]) -> dict[str, Any]:
}


def _serialize_project(project: Any) -> dict[str, Any]:
return {
"id": project.id,
"key": project.project_key,
"name": project.name,
"description": project.description,
"projectStore": project.project_store,
"taskProvider": project.task_provider,
"providerConfig": project.provider_config,
}


@mcp_tool(
name="list_loop_item_deliveries",
description="List immutable deliveries available for a TODO or Loop Item.",
Expand Down Expand Up @@ -150,24 +162,18 @@ def read_delivery_asset(asset_id: str, token_info: MCPAuthInfo) -> dict[str, Any

@mcp_tool(
name="list_cloud_projects",
description="List shared cloud projects the current user can access.",
description=(
"List shared cloud project spaces and their independent taskProvider. "
"For github or gitlab projects, use the local wegent_tasks MCP tools for "
"TODO/Issue operations; never copy the project or create a Backend TODO."
),
server="delivery",
exclude_params=["token_info"],
)
def list_cloud_projects(token_info: MCPAuthInfo) -> dict[str, Any]:
with SessionLocal() as db:
projects = cloud_project_service.list_accessible(db, token_info.user_id)
return {
"projects": [
{
"id": project.id,
"key": project.project_key,
"name": project.name,
"description": project.description,
}
for project in projects
]
}
return {"projects": [_serialize_project(project) for project in projects]}


@mcp_tool(
Expand All @@ -193,12 +199,7 @@ def create_cloud_project(
name=name, project_key=project_key, description=description
)
project = cloud_project_service.create(db, token_info.user_id, values)
return {
"id": project.id,
"key": project.project_key,
"name": project.name,
"description": project.description,
}
return _serialize_project(project)


@mcp_tool(
Expand Down Expand Up @@ -267,7 +268,10 @@ def read_cloud_file(file_id: int, token_info: MCPAuthInfo) -> dict[str, Any]:

@mcp_tool(
name="list_cloud_todos",
description="List TODOs and their current state in an authorized cloud project.",
description=(
"List Backend-native TODOs only when the cloud project's taskProvider is "
"local. GitHub and GitLab Issues are handled by wegent_tasks."
),
server="delivery",
exclude_params=["token_info"],
)
Expand Down Expand Up @@ -304,7 +308,9 @@ def get_cloud_todo(item_id: str, token_info: MCPAuthInfo) -> dict[str, Any]:
@mcp_tool(
name="create_cloud_todo",
description=(
"Create a TODO in an authorized cloud project. Status must be one of "
"Create a Backend-native TODO only when the cloud project's taskProvider "
"is local. GitHub and GitLab projects must use wegent_tasks.create_todo. "
"Status must be one of "
"inbox, pending, in_progress, in_review, completed; priority one of "
"none, low, medium, high, urgent; due_at is an ISO 8601 datetime."
),
Expand Down Expand Up @@ -528,10 +534,23 @@ def resolve_cloud_reference(reference: str, token_info: MCPAuthInfo) -> dict[str
return {"error": "Invalid cloud project id"}

if len(parts) == 1:
with SessionLocal() as db:
project = cloud_project_service.get(db, project_id, token_info.user_id)
project_data = _serialize_project(project)
todos = (
list_cloud_todos(project_id, token_info)
if project.task_provider == "local"
else {
"items": [],
"taskProvider": project.task_provider,
"todoTool": "wegent_tasks.create_todo",
}
)
return {
"projectId": project_id,
"project": project_data,
"workspace": list_cloud_workspace(project_id, token_info),
"todos": list_cloud_todos(project_id, token_info),
"todos": todos,
}
if len(parts) != 3:
return {"error": "Unsupported cloud reference path"}
Expand Down
21 changes: 21 additions & 0 deletions backend/app/models/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from sqlalchemy.engine import Connection
from sqlalchemy.sql import func

from app.core.provider_credentials import mask_provider_config
from app.db.base import Base
from shared.models.db.types import big_integer_id_type

Expand Down Expand Up @@ -147,6 +148,26 @@ def tags(self) -> list[str]:
return []
return [str(tag) for tag in tags]

@property
def project_store(self) -> str:
return "backend"

@property
def task_provider(self) -> str:
metadata = self.metadata_json
if not isinstance(metadata, dict):
return "local"
provider = metadata.get("task_provider")
return provider if provider in {"local", "github", "gitlab"} else "local"

@property
def provider_config(self) -> dict[str, object]:
metadata = self.metadata_json
if not isinstance(metadata, dict):
return {}
config = metadata.get("provider_config")
return mask_provider_config(config)

def __init__(self, **kwargs: object) -> None:
kwargs.setdefault("status", "active")
kwargs.setdefault("next_item_number", 1)
Expand Down
Loading
Loading