diff --git a/.github/actions/cloudwright/action.yml b/.github/actions/cloudwright/action.yml index ff451b6..6f08be4 100644 --- a/.github/actions/cloudwright/action.yml +++ b/.github/actions/cloudwright/action.yml @@ -60,6 +60,8 @@ runs: HAS_ERRORS=0 HAS_WARNINGS=0 + _append() { RESULTS="$RESULTS"$'\n'"## $1"$'\n'"$2"$'\n'; } + # Validate if echo "$CHECKS" | grep -q "validate"; then echo "::group::Validation" @@ -71,10 +73,7 @@ runs: fi VALIDATE_OUT=$(cloudwright validate "$SPEC_FILE" $COMPLIANCE_ARGS --json 2>&1) || true echo "$VALIDATE_OUT" - RESULTS="${RESULTS} -## Validation -${VALIDATE_OUT} -" + _append "Validation" "$VALIDATE_OUT" if echo "$VALIDATE_OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); exit(0 if d.get('passed', True) else 1)" 2>/dev/null; then : else @@ -88,10 +87,7 @@ ${VALIDATE_OUT} echo "::group::Cost Estimation" COST_OUT=$(cloudwright cost "$SPEC_FILE" --json 2>&1) || true echo "$COST_OUT" - RESULTS="${RESULTS} -## Cost Estimate -${COST_OUT} -" + _append "Cost Estimate" "$COST_OUT" echo "::endgroup::" fi @@ -100,10 +96,7 @@ ${COST_OUT} echo "::group::Score" SCORE_OUT=$(cloudwright score "$SPEC_FILE" --json 2>&1) || true echo "$SCORE_OUT" - RESULTS="${RESULTS} -## Score -${SCORE_OUT} -" + _append "Score" "$SCORE_OUT" echo "::endgroup::" fi @@ -112,10 +105,7 @@ ${SCORE_OUT} echo "::group::Lint" LINT_OUT=$(cloudwright lint "$SPEC_FILE" --json 2>&1) || true echo "$LINT_OUT" - RESULTS="${RESULTS} -## Lint -${LINT_OUT} -" + _append "Lint" "$LINT_OUT" if echo "$LINT_OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); issues=d if isinstance(d,list) else d.get('issues',[]); exit(1 if any(i.get('severity')=='error' for i in issues if isinstance(i,dict)) else 0)" 2>/dev/null; then : else @@ -129,10 +119,7 @@ ${LINT_OUT} echo "::group::Blast Radius Analysis" ANALYZE_OUT=$(cloudwright analyze "$SPEC_FILE" --json 2>&1) || true echo "$ANALYZE_OUT" - RESULTS="${RESULTS} -## Analyze -${ANALYZE_OUT} -" + _append "Analyze" "$ANALYZE_OUT" echo "::endgroup::" fi @@ -141,10 +128,7 @@ ${ANALYZE_OUT} echo "::group::Diff" DIFF_OUT=$(cloudwright diff "$OLD_SPEC_FILE" "$SPEC_FILE" --json 2>&1) || true echo "$DIFF_OUT" - RESULTS="${RESULTS} -## Diff -${DIFF_OUT} -" + _append "Diff" "$DIFF_OUT" echo "::endgroup::" fi @@ -154,10 +138,7 @@ ${DIFF_OUT} echo "::group::Policy Check" POLICY_OUT=$(cloudwright policy "$SPEC_FILE" "$POLICY_FILE" --json 2>&1) || true echo "$POLICY_OUT" - RESULTS="${RESULTS} -## Policy -${POLICY_OUT} -" + _append "Policy" "$POLICY_OUT" if echo "$POLICY_OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); exit(0 if d.get('passed', True) else 1)" 2>/dev/null; then : else diff --git a/.github/workflows/architecture-review.yml b/.github/workflows/architecture-review.yml index 83d9f6b..954bd89 100644 --- a/.github/workflows/architecture-review.yml +++ b/.github/workflows/architecture-review.yml @@ -13,14 +13,14 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 with: fetch-depth: 2 - name: Find changed specs id: specs run: | - SPECS=$(git diff --name-only HEAD~1 HEAD -- '*.yaml' '*.yml' | grep -E '(spec|arch)' | head -1 || true) + SPECS=$(git diff --name-only HEAD~1 HEAD -- '*.yaml' '*.yml' | grep -v '^\.github/' | grep -E '(spec|arch)' | head -1 || true) echo "file=$SPECS" >> "$GITHUB_OUTPUT" echo "found=$([ -n "$SPECS" ] && echo true || echo false)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cdb333..ba2e702 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: pip install -e ./packages/mcp pip install pytest pytest-timeout pytest-cov - name: Run core tests - run: pytest packages/core/tests/ -x -q --timeout=30 -m "not slow" --cov=cloudwright --cov-report=term-missing + run: pytest packages/core/tests/ -x -q --timeout=30 -m "not slow" --cov=cloudwright --cov-report=term-missing --cov-fail-under=70 - name: Run web tests run: pytest packages/web/tests/ -x -q --timeout=60 - name: Run CLI tests diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d19517c..fa81236 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,7 +5,11 @@ on: tags: ["v*"] jobs: + test: + uses: ./.github/workflows/ci.yml + publish: + needs: [test] runs-on: ubuntu-latest environment: release permissions: diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7819d50 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY packages/core/pyproject.toml packages/core/pyproject.toml +COPY packages/core/cloudwright/ packages/core/cloudwright/ +COPY packages/web/pyproject.toml packages/web/pyproject.toml +COPY packages/web/cloudwright_web/ packages/web/cloudwright_web/ + +RUN pip install --no-cache-dir ./packages/core ./packages/web + +EXPOSE 8000 + +CMD ["uvicorn", "cloudwright_web.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..78490d3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + web: + build: . + ports: + - "8000:8000" + environment: + - CLOUDWRIGHT_API_KEY=${CLOUDWRIGHT_API_KEY} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - CLOUDWRIGHT_CORS_ORIGINS=${CLOUDWRIGHT_CORS_ORIGINS:-http://localhost:5173} + - CLOUDWRIGHT_LOG_FORMAT=json + restart: unless-stopped diff --git a/packages/cli/cloudwright_cli/__init__.py b/packages/cli/cloudwright_cli/__init__.py index 5becc17..6849410 100644 --- a/packages/cli/cloudwright_cli/__init__.py +++ b/packages/cli/cloudwright_cli/__init__.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/packages/cli/cloudwright_cli/commands/design.py b/packages/cli/cloudwright_cli/commands/design.py index 12160d5..2ca9ed4 100644 --- a/packages/cli/cloudwright_cli/commands/design.py +++ b/packages/cli/cloudwright_cli/commands/design.py @@ -13,6 +13,8 @@ from rich.syntax import Syntax from rich.table import Table +from cloudwright_cli.completions import complete_compliance as _complete_compliance +from cloudwright_cli.completions import complete_provider as _complete_provider from cloudwright_cli.output import emit_dry_run, emit_error, emit_success, is_json_mode console = Console() @@ -21,11 +23,14 @@ def design( ctx: typer.Context, description: Annotated[str, typer.Argument(help="Natural language architecture description")], - provider: Annotated[str, typer.Option(help="Cloud provider")] = "aws", + provider: Annotated[str, typer.Option(help="Cloud provider", autocompletion=_complete_provider)] = "aws", region: Annotated[str, typer.Option(help="Primary region")] = "us-east-1", budget: Annotated[float | None, typer.Option(help="Monthly budget in USD")] = None, compliance: Annotated[ - list[str] | None, typer.Option(help="Compliance frameworks (hipaa, pci-dss, soc2, fedramp, gdpr)") + list[str] | None, + typer.Option( + help="Compliance frameworks (hipaa, pci-dss, soc2, fedramp, gdpr)", autocompletion=_complete_compliance + ), ] = None, output: Annotated[Path | None, typer.Option("--output", "-o", help="Write YAML to file")] = None, yaml_output: Annotated[bool, typer.Option("--yaml")] = False, diff --git a/packages/cli/cloudwright_cli/main.py b/packages/cli/cloudwright_cli/main.py index 2ef3ef9..095ec95 100644 --- a/packages/cli/cloudwright_cli/main.py +++ b/packages/cli/cloudwright_cli/main.py @@ -49,6 +49,9 @@ def main( dry_run: bool = typer.Option(False, "--dry-run", help="Preview LLM operations without calling the API"), stream: bool = typer.Option(False, "--stream", help="NDJSON streaming output (one JSON line per item)"), ) -> None: + from cloudwright.logging import configure_logging + + configure_logging() ctx.ensure_object(dict) ctx.obj["verbose"] = verbose ctx.obj["json"] = json_output diff --git a/packages/core/cloudwright/__init__.py b/packages/core/cloudwright/__init__.py index 3ffa8ec..4c327b9 100644 --- a/packages/core/cloudwright/__init__.py +++ b/packages/core/cloudwright/__init__.py @@ -17,7 +17,7 @@ ValidationResult, ) -__version__ = "1.0.0" +__version__ = "1.1.0" __all__ = [ "Alternative", diff --git a/packages/core/cloudwright/exporter/__init__.py b/packages/core/cloudwright/exporter/__init__.py index d6f7e07..648db61 100644 --- a/packages/core/cloudwright/exporter/__init__.py +++ b/packages/core/cloudwright/exporter/__init__.py @@ -88,10 +88,9 @@ def export_spec(spec: ArchSpec, fmt: str, output: str | None = None, output_dir: """Export an ArchSpec to the given format. Returns the rendered string.""" fmt = fmt.lower().strip() - # Validate all component configs before exporting to IaC formats - if fmt in ("terraform", "cloudformation", "cfn"): - for comp in spec.components: - validate_export_config(comp.config, path=f"component[{comp.id}].config") + # Validate all component configs before exporting (prevents injection in any format) + for comp in spec.components: + validate_export_config(comp.config, path=f"component[{comp.id}].config") if fmt == "terraform": from cloudwright.exporter.terraform import render diff --git a/packages/core/cloudwright/exporter/cloudformation.py b/packages/core/cloudwright/exporter/cloudformation.py index f1c71a1..bd00057 100644 --- a/packages/core/cloudwright/exporter/cloudformation.py +++ b/packages/core/cloudwright/exporter/cloudformation.py @@ -54,7 +54,7 @@ def _build_properties(c: "Component") -> dict[str, Any]: "DBInstanceClass": cfg.get("instance_class", "db.t3.medium"), "Engine": cfg.get("engine", "mysql"), "AllocatedStorage": str(cfg.get("allocated_storage", 20)), - "MasterUsername": "admin", + "MasterUsername": {"Ref": "DBUsername"}, "MasterUserPassword": {"Ref": "DBPassword"}, "Tags": tags, } @@ -201,6 +201,11 @@ def render(spec: "ArchSpec") -> str: "Type": "String", "Default": "production", }, + "DBUsername": { + "Type": "String", + "Description": "Database master username", + "Default": "dbadmin", + }, "DBPassword": { "Type": "String", "NoEcho": True, diff --git a/packages/core/cloudwright/exporter/terraform/aws.py b/packages/core/cloudwright/exporter/terraform/aws.py index 358cad1..9e63063 100644 --- a/packages/core/cloudwright/exporter/terraform/aws.py +++ b/packages/core/cloudwright/exporter/terraform/aws.py @@ -55,9 +55,9 @@ def render_resource(c: "Component", spec: "ArchSpec") -> str: f' engine = "{engine}"', f' instance_class = "{instance_class}"', f" allocated_storage = {cfg.get('allocated_storage', 20)}", - ' username = "admin"', + " username = var.db_username", " password = var.db_password", - " skip_final_snapshot = true", + " skip_final_snapshot = false", " tags = {", f' Name = "{c.label}"', " }", @@ -317,7 +317,7 @@ def render_resource(c: "Component", spec: "ArchSpec") -> str: lines += [ f'resource "aws_ecr_repository" "{c.id}" {{', f' name = "{c.id.replace("_", "-")}"', - ' image_tag_mutability = "MUTABLE"', + ' image_tag_mutability = "IMMUTABLE"', " tags = {", f' Name = "{c.label}"', " }", diff --git a/packages/core/cloudwright/exporter/terraform/azure.py b/packages/core/cloudwright/exporter/terraform/azure.py index 65cbbe2..feb1232 100644 --- a/packages/core/cloudwright/exporter/terraform/azure.py +++ b/packages/core/cloudwright/exporter/terraform/azure.py @@ -46,7 +46,7 @@ def render_resource(c: "Component", spec: "ArchSpec") -> str: f" resource_group_name = {_RG}", f" location = {_LOCATION}", f' size = "{cfg.get("size", "Standard_B2s")}"', - ' admin_username = "adminuser"', + " admin_username = var.db_username", f" network_interface_ids = [azurerm_network_interface.{c.id}_nic.id]", " os_disk {", ' caching = "ReadWrite"', @@ -71,7 +71,7 @@ def render_resource(c: "Component", spec: "ArchSpec") -> str: f" resource_group_name = {_RG}", f" location = {_LOCATION}", ' version = "12.0"', - ' administrator_login = "sqladmin"', + " administrator_login = var.db_username", " administrator_login_password = var.db_password", " tags = {", f' Name = "{c.label}"', diff --git a/packages/core/cloudwright/llm/anthropic.py b/packages/core/cloudwright/llm/anthropic.py index 3d8cdee..fa07575 100644 --- a/packages/core/cloudwright/llm/anthropic.py +++ b/packages/core/cloudwright/llm/anthropic.py @@ -14,7 +14,7 @@ log = get_logger(__name__) -GENERATE_MODEL = "claude-sonnet-4-6" +GENERATE_MODEL = os.environ.get("CLOUDWRIGHT_MODEL") or "claude-sonnet-4-6" FAST_MODEL = "claude-haiku-4-5-20251001" _MAX_RETRIES = int(os.environ.get("CLOUDWRIGHT_LLM_MAX_RETRIES", 3)) @@ -55,9 +55,18 @@ def generate_stream( kwargs = dict(model=GENERATE_MODEL, max_tokens=max_tokens, system=system_block, messages=messages) if timeout is not None: kwargs["timeout"] = timeout - with self.client.messages.stream(**kwargs) as stream: - for text in stream.text_stream: - yield text + delay = 1.0 + for attempt in range(_MAX_RETRIES): + try: + with self.client.messages.stream(**kwargs) as stream: + for text in stream.text_stream: + yield text + return + except _RETRYABLE: + if attempt == _MAX_RETRIES - 1: + raise + time.sleep(delay * (1 + random.uniform(0, 0.5))) + delay *= 2 def _call( self, model: str, messages: list[dict], system: str, max_tokens: int, timeout: float | None = None diff --git a/packages/core/cloudwright/llm/openai.py b/packages/core/cloudwright/llm/openai.py index feb1bc6..e5b41df 100644 --- a/packages/core/cloudwright/llm/openai.py +++ b/packages/core/cloudwright/llm/openai.py @@ -14,7 +14,7 @@ log = get_logger(__name__) -GENERATE_MODEL = "gpt-5.2" +GENERATE_MODEL = os.environ.get("CLOUDWRIGHT_MODEL") or "gpt-5.2" FAST_MODEL = "gpt-5-mini" _MAX_RETRIES = int(os.environ.get("CLOUDWRIGHT_LLM_MAX_RETRIES", 3)) @@ -54,19 +54,28 @@ def generate_stream( self, messages: list[dict], system: str, max_tokens: int = 2000, timeout: float | None = None ) -> Iterator[str]: full_messages = [{"role": "system", "content": system}] + messages - kwargs = dict(model=GENERATE_MODEL, max_tokens=max_tokens, messages=full_messages, stream=True) + kwargs = dict(model=GENERATE_MODEL, max_completion_tokens=max_tokens, messages=full_messages, stream=True) if timeout is not None: kwargs["timeout"] = timeout - stream = self.client.chat.completions.create(**kwargs) - for chunk in stream: - content = chunk.choices[0].delta.content - if content: - yield content + delay = 1.0 + for attempt in range(_MAX_RETRIES): + try: + stream = self.client.chat.completions.create(**kwargs) + for chunk in stream: + content = chunk.choices[0].delta.content + if content: + yield content + return + except _RETRYABLE: + if attempt == _MAX_RETRIES - 1: + raise + time.sleep(delay * (1 + random.uniform(0, 0.5))) + delay *= 2 def _call( self, model: str, messages: list[dict], max_tokens: int, timeout: float | None = None ) -> tuple[str, dict]: - kwargs = dict(model=model, max_tokens=max_tokens, messages=messages) + kwargs = dict(model=model, max_completion_tokens=max_tokens, messages=messages) if timeout is not None: kwargs["timeout"] = timeout delay = 1.0 diff --git a/packages/core/cloudwright/prompts.py b/packages/core/cloudwright/prompts.py index 0f1a7dd..5652a2c 100644 --- a/packages/core/cloudwright/prompts.py +++ b/packages/core/cloudwright/prompts.py @@ -272,6 +272,18 @@ # -- Service normalization for LLM output drift ------------------------------------ +# Provider-aware normalization: ambiguous service names resolve differently per provider. +# Keys that are unambiguous map to a single target. Keys that depend on provider +# are handled by normalize_service(raw, provider) below. +_PROVIDER_SPECIFIC_NORMALIZATION: dict[str, dict[str, str]] = { + "redis": {"aws": "elasticache", "gcp": "memorystore", "azure": "azure_cache"}, + "postgres": {"aws": "rds", "gcp": "cloud_sql", "azure": "azure_sql"}, + "mysql": {"aws": "rds", "gcp": "cloud_sql", "azure": "azure_sql"}, + "mongodb": {"aws": "dynamodb", "gcp": "firestore", "azure": "cosmos_db"}, + "kubernetes": {"aws": "eks", "gcp": "gke", "azure": "aks"}, + "docker": {"aws": "ecs", "gcp": "cloud_run", "azure": "container_apps"}, +} + SERVICE_NORMALIZATION: dict[str, str] = { "aws_rds": "rds", "aws_lambda": "lambda", @@ -592,3 +604,12 @@ - Design a single provider-agnostic architecture using the primary provider's service keys - Include realistic instance types and configurations for accurate pricing - Respond with ONLY the JSON object""" + + +def normalize_service(raw: str, provider: str | None = None) -> str: + """Normalize a raw service name, using provider context for ambiguous names.""" + key = raw.lower().strip() + if provider and key in _PROVIDER_SPECIFIC_NORMALIZATION: + provider_map = _PROVIDER_SPECIFIC_NORMALIZATION[key] + return provider_map.get(provider.lower(), SERVICE_NORMALIZATION.get(key, key)) + return SERVICE_NORMALIZATION.get(key, key) diff --git a/packages/core/cloudwright/session.py b/packages/core/cloudwright/session.py index 09c553a..dbfd6a6 100644 --- a/packages/core/cloudwright/session.py +++ b/packages/core/cloudwright/session.py @@ -70,7 +70,11 @@ def send(self, message: str) -> tuple[str, ArchSpec | None]: self._trim_history() self.history.append({"role": "user", "content": message}) system = self._build_system_with_hints(CHAT_SYSTEM) - text, usage = self.llm.generate(self.history, system, max_tokens=10000) + try: + text, usage = self.llm.generate(self.history, system, max_tokens=10000) + except Exception: + self.history.pop() # remove orphaned user message + raise self._track_usage(usage) self.history.append({"role": "assistant", "content": text}) @@ -90,13 +94,22 @@ def send_stream(self, message: str) -> Iterator[str]: system = self._build_system_with_hints(CHAT_SYSTEM) accumulated = [] - for chunk in self.llm.generate_stream(self.history, system, max_tokens=10000): - accumulated.append(chunk) - yield chunk + try: + for chunk in self.llm.generate_stream(self.history, system, max_tokens=10000): + accumulated.append(chunk) + yield chunk + except Exception: + self.history.pop() # remove orphaned user message + raise full_text = "".join(accumulated) self.history.append({"role": "assistant", "content": full_text}) + # Track usage from stream (estimate from accumulated text) + estimated_output = len(full_text) // 4 + estimated_input = self.estimate_context_tokens() + self._track_usage({"input_tokens": estimated_input, "output_tokens": estimated_output}) + spec = self._try_parse_spec(full_text) if spec is not None: if self.constraints: @@ -110,9 +123,11 @@ def modify(self, instruction: str) -> ArchSpec: if self.current_spec is None: raise ValueError("No current architecture to modify. Use send() to create one first.") + from cloudwright.evolution import create_version from cloudwright.parsing import _extract_json, _parse_arch_spec old_spec = self.current_spec + create_version(old_spec, description=f"Before modify: {instruction[:100]}") current_json = _slim_for_modify(self.current_spec) prompt = f"Current architecture:\n{current_json}\n\nModification: {instruction}" @@ -159,7 +174,13 @@ def modify(self, instruction: str) -> ArchSpec: self._add_error_hint("Do not remove existing components unless explicitly asked") if self.current_spec.cost_estimate and not updated.cost_estimate: - updated = updated.model_copy(update={"cost_estimate": self.current_spec.cost_estimate}) + updated = updated.model_copy(update={"cost_estimate": None}) + if not hasattr(updated, "metadata") or updated.metadata is None: + updated = updated.model_copy(update={"metadata": {"cost_stale": True}}) + else: + meta = dict(updated.metadata) if isinstance(updated.metadata, dict) else {} + meta["cost_stale"] = True + updated = updated.model_copy(update={"metadata": meta}) self.current_spec = updated @@ -217,8 +238,8 @@ def _trim_history(self) -> None: role = msg["role"] content = msg.get("content", "")[:200] summary_parts.append(f"{role}: {content}") - summary_text = "Earlier conversation summary:\n" + "\n".join(summary_parts) - self.history = [{"role": "user", "content": summary_text}] + self.history[messages_to_trim:] + self._trim_summary = "Earlier conversation summary:\n" + "\n".join(summary_parts) + self.history = self.history[messages_to_trim:] def to_dict(self) -> dict: return { @@ -275,10 +296,13 @@ def _try_parse_spec(self, text: str) -> ArchSpec | None: return None def _build_system_with_hints(self, base_system: str) -> str: - if not self._error_hints: - return base_system - hints = "\n".join(f"- {h}" for h in self._error_hints[-_MAX_ERROR_HINTS:]) - return f"{base_system}\n\nLEARNINGS FROM THIS SESSION (avoid repeating):\n{hints}" + parts = [base_system] + if getattr(self, "_trim_summary", None): + parts.append(f"\nCONVERSATION CONTEXT:\n{self._trim_summary}") + if self._error_hints: + hints = "\n".join(f"- {h}" for h in self._error_hints[-_MAX_ERROR_HINTS:]) + parts.append(f"\nLEARNINGS FROM THIS SESSION (avoid repeating):\n{hints}") + return "\n".join(parts) def _slim_for_modify(spec: ArchSpec) -> str: diff --git a/packages/core/cloudwright/validator.py b/packages/core/cloudwright/validator.py index 2fba7e5..5a0387b 100644 --- a/packages/core/cloudwright/validator.py +++ b/packages/core/cloudwright/validator.py @@ -755,9 +755,21 @@ def _check_gdpr(spec: ArchSpec) -> ValidationResult: svcs = _services(spec) checks = [] - # Data residency — regions must be EU + # Data residency — regions must be EU (across all providers) regions = _get_regions(spec) - non_eu = [r for r in regions if not r.startswith("eu-")] + _eu_prefixes = ( + "eu-", + "europe-", + "northeurope", + "westeurope", + "germanywestcentral", + "swedencentral", + "francesouth", + "francecentral", + "switzerlandnorth", + "norwayeast", + ) + non_eu = [r for r in regions if not any(r.startswith(p) for p in _eu_prefixes)] checks.append( ValidationCheck( name="data_residency", diff --git a/packages/core/tests/test_architect_chat.py b/packages/core/tests/test_architect_chat.py index 1b221c0..a2824b6 100644 --- a/packages/core/tests/test_architect_chat.py +++ b/packages/core/tests/test_architect_chat.py @@ -200,7 +200,7 @@ def test_modify_passes_current_spec_in_prompt(self): assert "Current architecture:" in prompt assert "Add monitoring" in prompt - def test_modify_preserves_cost_estimate(self): + def test_modify_marks_cost_stale_when_llm_omits_cost(self): from cloudwright.spec import CostEstimate updated_json = _make_spec_json("Base App") @@ -209,9 +209,9 @@ def test_modify_preserves_cost_estimate(self): session.current_spec.cost_estimate = CostEstimate(monthly_total=500.0, components=[]) result = session.modify("Minor tweak") - # Cost should be carried over since LLM response had none - assert result.cost_estimate is not None - assert result.cost_estimate.monthly_total == 500.0 + # Cost should be cleared with stale flag since LLM response had none + assert result.cost_estimate is None + assert result.metadata["cost_stale"] is True # Constraints propagation diff --git a/packages/core/tests/test_conversation_context.py b/packages/core/tests/test_conversation_context.py index 665dec0..5ade157 100644 --- a/packages/core/tests/test_conversation_context.py +++ b/packages/core/tests/test_conversation_context.py @@ -53,8 +53,9 @@ def test_trimmed_history_has_summary(): session.send("add redis on aws") session.send("add s3 on aws") - first_content = session.history[0]["content"] - assert "Earlier conversation summary" in first_content + # Summary is now stored as a system prompt addition, not in history + assert hasattr(session, "_trim_summary") + assert "Earlier conversation summary" in session._trim_summary def test_estimate_context_tokens(): diff --git a/packages/core/tests/test_llm_provider.py b/packages/core/tests/test_llm_provider.py new file mode 100644 index 0000000..d393149 --- /dev/null +++ b/packages/core/tests/test_llm_provider.py @@ -0,0 +1,130 @@ +"""Tests for LLM provider routing and model override.""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +import pytest +from cloudwright.llm import get_llm +from cloudwright.llm.anthropic import AnthropicLLM +from cloudwright.llm.base import BaseLLM +from cloudwright.llm.openai import OpenAILLM + + +class TestGetLLM: + """Tests for get_llm() factory routing.""" + + @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test"}, clear=False) + def test_auto_detect_anthropic(self): + env = dict(os.environ) + env.pop("CLOUDWRIGHT_LLM_PROVIDER", None) + env.pop("OPENAI_API_KEY", None) + with patch.dict(os.environ, env, clear=True): + llm = get_llm() + assert isinstance(llm, AnthropicLLM) + + @patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test"}, clear=False) + def test_auto_detect_openai(self): + env = dict(os.environ) + env.pop("CLOUDWRIGHT_LLM_PROVIDER", None) + env.pop("ANTHROPIC_API_KEY", None) + with patch.dict(os.environ, env, clear=True): + llm = get_llm() + assert isinstance(llm, OpenAILLM) + + @patch.dict(os.environ, {"CLOUDWRIGHT_LLM_PROVIDER": "openai", "OPENAI_API_KEY": "sk-test"}) + def test_explicit_openai_provider(self): + llm = get_llm() + assert isinstance(llm, OpenAILLM) + + @patch.dict(os.environ, {"CLOUDWRIGHT_LLM_PROVIDER": "anthropic", "ANTHROPIC_API_KEY": "sk-ant-test"}) + def test_explicit_anthropic_provider(self): + llm = get_llm() + assert isinstance(llm, AnthropicLLM) + + def test_no_keys_raises(self): + env = dict(os.environ) + env.pop("ANTHROPIC_API_KEY", None) + env.pop("OPENAI_API_KEY", None) + env.pop("CLOUDWRIGHT_LLM_PROVIDER", None) + with patch.dict(os.environ, env, clear=True): + with pytest.raises(RuntimeError, match="No LLM provider"): + get_llm() + + +class TestModelOverride: + """Tests for CLOUDWRIGHT_MODEL env var override.""" + + @patch.dict(os.environ, {"CLOUDWRIGHT_MODEL": "claude-opus-4-6"}) + def test_anthropic_model_override(self): + # Re-import to pick up env var at module level + import importlib + + import cloudwright.llm.anthropic as mod + + importlib.reload(mod) + assert mod.GENERATE_MODEL == "claude-opus-4-6" + # Restore + os.environ.pop("CLOUDWRIGHT_MODEL", None) + importlib.reload(mod) + + @patch.dict(os.environ, {"CLOUDWRIGHT_MODEL": "gpt-5"}) + def test_openai_model_override(self): + import importlib + + import cloudwright.llm.openai as mod + + importlib.reload(mod) + assert mod.GENERATE_MODEL == "gpt-5" + # Restore + os.environ.pop("CLOUDWRIGHT_MODEL", None) + importlib.reload(mod) + + +class TestOpenAILLM: + """Tests for OpenAI LLM provider.""" + + def test_implements_base_interface(self): + llm = OpenAILLM(api_key="test") + assert isinstance(llm, BaseLLM) + assert llm.model_name is not None + assert "input" in llm.pricing + assert "output" in llm.pricing + + def test_generate_prepends_system_message(self): + llm = OpenAILLM(api_key="test") + llm.client = MagicMock() + response = MagicMock() + response.choices = [MagicMock(message=MagicMock(content="hello"))] + response.usage.prompt_tokens = 10 + response.usage.completion_tokens = 5 + llm.client.chat.completions.create.return_value = response + + text, usage = llm.generate([{"role": "user", "content": "hi"}], "You are helpful") + assert text == "hello" + + call_args = llm.client.chat.completions.create.call_args + messages = call_args.kwargs["messages"] + assert messages[0]["role"] == "system" + assert messages[0]["content"] == "You are helpful" + + def test_generate_stream_yields_chunks(self): + llm = OpenAILLM(api_key="test") + llm.client = MagicMock() + + chunk1 = MagicMock() + chunk1.choices = [MagicMock(delta=MagicMock(content="hel"))] + chunk2 = MagicMock() + chunk2.choices = [MagicMock(delta=MagicMock(content="lo"))] + chunk3 = MagicMock() + chunk3.choices = [MagicMock(delta=MagicMock(content=None))] + + llm.client.chat.completions.create.return_value = iter([chunk1, chunk2, chunk3]) + + chunks = list(llm.generate_stream([{"role": "user", "content": "hi"}], "system")) + assert chunks == ["hel", "lo"] + + def test_estimate_tokens(self): + llm = OpenAILLM(api_key="test") + assert llm.estimate_tokens("hello world") > 0 diff --git a/packages/mcp/cloudwright_mcp/__init__.py b/packages/mcp/cloudwright_mcp/__init__.py index 5becc17..6849410 100644 --- a/packages/mcp/cloudwright_mcp/__init__.py +++ b/packages/mcp/cloudwright_mcp/__init__.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/packages/mcp/cloudwright_mcp/tools/session.py b/packages/mcp/cloudwright_mcp/tools/session.py index 2df2a15..aecd628 100644 --- a/packages/mcp/cloudwright_mcp/tools/session.py +++ b/packages/mcp/cloudwright_mcp/tools/session.py @@ -41,7 +41,9 @@ def chat_send(session_id: str, message: str) -> dict: except FileNotFoundError: return {"error": f"Session {session_id!r} not found. Create one with chat_create_session."} - text, spec = session.send(message) + text, spec = session.send(message) + + with _lock: store.save(session_id, session) return { diff --git a/packages/web/cloudwright_web/__init__.py b/packages/web/cloudwright_web/__init__.py index 9d40559..b21c38f 100644 --- a/packages/web/cloudwright_web/__init__.py +++ b/packages/web/cloudwright_web/__init__.py @@ -1,6 +1,6 @@ """Cloudwright Web — FastAPI backend for architecture intelligence.""" -__version__ = "1.0.0" +__version__ = "1.1.0" def __getattr__(name: str): diff --git a/packages/web/cloudwright_web/app.py b/packages/web/cloudwright_web/app.py index 7370180..f9998ad 100644 --- a/packages/web/cloudwright_web/app.py +++ b/packages/web/cloudwright_web/app.py @@ -3,11 +3,13 @@ from __future__ import annotations import multiprocessing +import os from pathlib import Path from fastapi import FastAPI from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware from cloudwright_web import __version__ from cloudwright_web.middleware import ( # noqa: F401 @@ -29,6 +31,18 @@ from cloudwright_web.singletons import get_architect, get_catalog, get_cost_engine # noqa: F401 +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + response = await call_next(request) + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" + ) + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + return response + + def create_app() -> FastAPI: application = FastAPI( title="Cloudwright", @@ -36,6 +50,7 @@ def create_app() -> FastAPI: description="Architecture intelligence for cloud engineers", ) + application.add_middleware(SecurityHeadersMiddleware) application.add_middleware(PathTraversalMiddleware) add_cors(application) @@ -73,6 +88,15 @@ def serve_frontend(path: str): def serve(host: str = "127.0.0.1", port: int = 8000): """Start the Cloudwright web server.""" import uvicorn + from cloudwright.logging import configure_logging + + configure_logging() + + if not os.environ.get("CLOUDWRIGHT_API_KEY"): + raise SystemExit( + "CLOUDWRIGHT_API_KEY environment variable is required when running the web server. " + "Set it to a secret value that clients must pass in the X-API-Key header." + ) workers = min(multiprocessing.cpu_count(), 4) uvicorn.run("cloudwright_web.app:app", host=host, port=port, workers=workers) diff --git a/packages/web/cloudwright_web/middleware.py b/packages/web/cloudwright_web/middleware.py index 62934d9..0d68d50 100644 --- a/packages/web/cloudwright_web/middleware.py +++ b/packages/web/cloudwright_web/middleware.py @@ -85,14 +85,26 @@ def error_response(code: str, message: str, suggestion: str, status_code: int = ) +def _get_client_ip(request: Request) -> str: + """Extract client IP, respecting X-Forwarded-For when behind a trusted proxy.""" + if os.environ.get("CLOUDWRIGHT_TRUST_PROXY"): + forwarded = request.headers.get("x-forwarded-for", "") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + def check_rate_limit(request: Request): - ip = request.client.host if request.client else "unknown" + ip = _get_client_ip(request) allowed, retry_after = _rate_limiter.is_allowed(ip) if not allowed: - return error_response( - "rate_limited", - "Too many requests", - f"Wait {retry_after} seconds before retrying", + return JSONResponse( status_code=429, + content={ + "code": "rate_limited", + "message": "Too many requests", + "suggestion": f"Wait {retry_after} seconds before retrying", + }, + headers={"Retry-After": str(retry_after)}, ) return None diff --git a/packages/web/cloudwright_web/routers/chat.py b/packages/web/cloudwright_web/routers/chat.py index 67ca055..f23e671 100644 --- a/packages/web/cloudwright_web/routers/chat.py +++ b/packages/web/cloudwright_web/routers/chat.py @@ -41,6 +41,8 @@ async def chat(req: ChatRequest, request: Request): session = ConversationSession(llm=architect.llm) for msg in req.history: + if msg.role != "user": + continue # only accept user-role messages from client history session.history.append({"role": msg.role, "content": msg.content}) try: @@ -85,10 +87,12 @@ async def event_generator(): session = ConversationSession(llm=architect.llm) for msg in req.history: + if msg.role != "user": + continue # only accept user-role messages from client history session.history.append({"role": msg.role, "content": msg.content}) try: - queue: asyncio.Queue = asyncio.Queue() + queue: asyncio.Queue = asyncio.Queue(maxsize=256) loop = asyncio.get_running_loop() def _run_stream(): diff --git a/packages/web/cloudwright_web/routers/health.py b/packages/web/cloudwright_web/routers/health.py index 60df0b5..aa1feea 100644 --- a/packages/web/cloudwright_web/routers/health.py +++ b/packages/web/cloudwright_web/routers/health.py @@ -2,10 +2,11 @@ from __future__ import annotations +import os from pathlib import Path from fastapi import APIRouter, HTTPException -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse import cloudwright_web.singletons as _singletons @@ -14,6 +15,13 @@ @router.get("/health") def health(): + # Check LLM key presence + has_llm_key = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY")) + if not has_llm_key: + return JSONResponse( + status_code=503, + content={"status": "degraded", "reason": "No LLM API key configured (ANTHROPIC_API_KEY or OPENAI_API_KEY)"}, + ) try: catalog = _singletons.get_catalog() results = catalog.search(query="m5", limit=1) diff --git a/packages/web/tests/conftest.py b/packages/web/tests/conftest.py index 9630143..5009218 100644 --- a/packages/web/tests/conftest.py +++ b/packages/web/tests/conftest.py @@ -2,9 +2,18 @@ from __future__ import annotations +import os + import pytest +@pytest.fixture(autouse=True) +def _mock_llm_key(monkeypatch): + """Ensure an LLM API key is present for health checks in CI.""" + if not os.environ.get("ANTHROPIC_API_KEY") and not os.environ.get("OPENAI_API_KEY"): + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key-for-ci") + + @pytest.fixture(autouse=True) def reset_rate_limiter(): """Clear rate limiter state between tests so requests don't bleed across.""" diff --git a/packages/web/tests/test_api.py b/packages/web/tests/test_api.py index a598dd7..9431b49 100644 --- a/packages/web/tests/test_api.py +++ b/packages/web/tests/test_api.py @@ -93,13 +93,24 @@ def serverless_spec(): class TestHealth: - def test_health_returns_ok(self, client): + @pytest.mark.parametrize("env_key", ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]) + def test_health_returns_ok_with_llm_key(self, client, env_key, monkeypatch): + monkeypatch.setenv(env_key, "test-key") resp = client.get("/api/health") assert resp.status_code == 200 data = resp.json() assert data["status"] == "ok" - def test_health_has_catalog(self, client): + def test_health_returns_503_without_llm_key(self, client, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + resp = client.get("/api/health") + assert resp.status_code == 503 + data = resp.json() + assert data["status"] == "degraded" + + def test_health_has_catalog(self, client, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") resp = client.get("/api/health") data = resp.json() assert "catalog_loaded" in data diff --git a/packages/web/tests/test_streaming_api.py b/packages/web/tests/test_streaming_api.py index de334f4..c33e65c 100644 --- a/packages/web/tests/test_streaming_api.py +++ b/packages/web/tests/test_streaming_api.py @@ -105,4 +105,6 @@ def test_chat_stream_with_history(self, client): ) assert resp.status_code == 200 - assert len(mock_session.history) == 2 + # Only user-role messages are accepted from client history (security fix) + assert len(mock_session.history) == 1 + assert mock_session.history[0]["role"] == "user"