diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 1f1701cdf8..fbc27d93ee 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -26,36 +26,20 @@ The LangGraph backend provides sandbox execution, persistent memory, subagent de - Gateway MCP/skill updates write `extensions_config.json` at runtime. Production Compose mounts it read-write (`config.yaml` stays `:ro`); Helm seeds a writable home-volume copy. Hold both `extensions_config_write_lock` and the sidecar advisory `extensions_config_file_lock` during read-modify-write to prevent lost updates across workers. `atomic_write_extensions_config` uses temp-file replacement; only mount-point `EBUSY` permits a non-atomic in-place overwrite (crashes can truncate). Warn about this fallback once per target; propagate other errors. `get_extensions_config()` revalidates reads by file signature; see [config caching](packages/harness/deerflow/config/AGENTS.md). Tests: `tests/test_compose_extensions_config_writable.py`, `tests/test_extensions_config_atomic_write.py`, `tests/test_helm_extensions_config_writable.py`. - MCP cache reset scope and discovery fencing: [MCP guide](packages/harness/deerflow/mcp/AGENTS.md). -**Project Structure** (backend/ subtree; the root guide maps the full repo): -``` -backend/ -├── Makefile # Backend-only commands (dev, gateway, lint) -├── langgraph.json # LangGraph Studio graph configuration -├── packages/ -│ ├── extension-api/ # public, host-independent extension contracts (import: deerflow_extension_api.*) -│ └── harness/ # deerflow-harness package (import: deerflow.*) -│ └── deerflow/ -│ ├── agents/ # LangGraph agent system (lead_agent/, middlewares/ — see Middleware Chain, memory/, thread_state.py) -│ ├── sandbox/ # Sandbox execution system -│ ├── subagents/ # Subagent delegation (builtins/, executor.py, registry.py) -│ ├── tools/builtins/ # Built-in tools (present_files, ask_clarification, view_image, review_skill_package) -│ ├── mcp/ # MCP integration (tools, cache, client) -│ ├── integrations/ # Managed first-party integration installers (e.g. Lark CLI skill pack) -│ ├── extensions/ # Python plugin loader, registry, placement, and isolation -│ ├── models/ # Model factory with thinking/vision support -│ ├── skills/ # Skills discovery, loading, parsing -│ ├── config/ # Configuration system -│ ├── community/ # Community tools (search/fetch/scrape, image search, AIO sandbox) -│ ├── reflection/ # Dynamic module loading (resolve_variable, resolve_class) -│ ├── utils/ # Utilities (network, readability) -│ └── client.py # Embedded Python client (DeerFlowClient) -├── app/ # Application layer (import: app.*) -│ ├── gateway/ # FastAPI Gateway API (app.py, routers/) -│ └── channels/ # IM platform integrations -├── scripts/benchmark/ # Standalone reproducible backend benchmarks -├── tests/ # Test suite -└── docs/ # Documentation -``` +**Project Structure** (relative to `backend/`; see the root guide for the full repo): + +- `Makefile`: backend commands; `langgraph.json`: LangGraph Studio configuration. +- `packages/extension-api/`: public extension contracts (`deerflow_extension_api.*`). +- `packages/harness/deerflow/`: publishable framework (`deerflow.*`): + `agents/` (lead agent, middleware, memory, thread state), `sandbox/`, `subagents/` + (builtins, executor, registry), `tools/builtins/` (file presentation, clarification, + images, skill review), `mcp/`, `integrations/` (Lark CLI skill pack), `extensions/`, + `models/`, `skills/`, `config/`, `community/` (search, fetch, scrape, images, + AIO sandbox), `reflection/`, `utils/`, and the embedded `client.py`. +- `app/`: application layer (`app.*`), including `gateway/` (FastAPI app and routers) + and `channels/` (IM integrations). +- `scripts/benchmark/`: standalone backend benchmarks; `tests/`: test suite; + `docs/`: backend documentation. ## Important Development Guidelines diff --git a/backend/app/gateway/auth/jwt.py b/backend/app/gateway/auth/jwt.py index 3853692b78..3d5be999da 100644 --- a/backend/app/gateway/auth/jwt.py +++ b/backend/app/gateway/auth/jwt.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime, timedelta import jwt -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from app.gateway.auth.config import get_auth_config from app.gateway.auth.errors import TokenError @@ -51,5 +51,8 @@ def decode_token(token: str) -> TokenPayload | TokenError: return TokenError.EXPIRED except jwt.InvalidSignatureError: return TokenError.INVALID_SIGNATURE - except jwt.PyJWTError: + except (jwt.PyJWTError, ValidationError): + # A correctly signed token with the wrong claims (e.g. the OIDC state + # cookie, which shares jwt_secret but has no sub/exp) is malformed, not + # a server error. return TokenError.MALFORMED diff --git a/backend/app/gateway/auth/oidc_state.py b/backend/app/gateway/auth/oidc_state.py index 7205a7fa5a..44ba4ddd06 100644 --- a/backend/app/gateway/auth/oidc_state.py +++ b/backend/app/gateway/auth/oidc_state.py @@ -12,7 +12,7 @@ import time import jwt from fastapi import Request, Response -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError from app.gateway.auth.config import get_auth_config from app.gateway.csrf_middleware import is_secure_request @@ -51,7 +51,9 @@ def _verify_state_signed(signed: str, max_age: int = OIDC_STATE_MAX_AGE) -> OIDC if time.time() - payload.issued_at > max_age: return None return payload - except jwt.PyJWTError: + except (jwt.PyJWTError, ValidationError): + # A state cookie signed with our secret but missing required claims is not a server error: + # it is an unusable state, and the callback must answer 403 rather than 500. return None diff --git a/backend/tests/test_auth_errors.py b/backend/tests/test_auth_errors.py index b3b46c75f1..0d17dc177e 100644 --- a/backend/tests/test_auth_errors.py +++ b/backend/tests/test_auth_errors.py @@ -7,6 +7,7 @@ import jwt as pyjwt from app.gateway.auth.config import AuthConfig, set_auth_config from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError from app.gateway.auth.jwt import create_access_token, decode_token +from app.gateway.auth.oidc_state import _verify_state_signed def test_auth_error_code_values(): @@ -73,3 +74,38 @@ def test_decode_token_returns_payload_on_valid(): result = decode_token(token) assert not isinstance(result, TokenError) assert result.sub == "user-123" + + +def test_decode_token_returns_token_error_on_signed_token_with_wrong_claims(): + _setup_config() + # The OIDC state cookie is signed with the same secret but has no sub/exp. + state_payload = {"provider": "google", "state": "abc", "issued_at": 1} + token = pyjwt.encode(state_payload, _TEST_SECRET, algorithm="HS256") + + assert decode_token(token) == TokenError.MALFORMED + + +def test_decode_token_prefers_expired_over_malformed(): + _setup_config() + # jwt.decode raises ExpiredSignatureError before TokenPayload(**payload) runs, so a token that is + # both expired and missing `sub` must keep reporting EXPIRED. + expired_without_sub = {"provider": "x", "exp": datetime.now(UTC) - timedelta(hours=1)} + token = pyjwt.encode(expired_without_sub, _TEST_SECRET, algorithm="HS256") + assert decode_token(token) == TokenError.EXPIRED + + +def test_verify_state_signed_rejects_signed_payload_with_bad_claims(): + _setup_config() + # A cookie signed with our secret but missing required claims used to raise ValidationError out of + # the public callback endpoint; it has to be treated like any other unusable state. + signed = pyjwt.encode({"not_provider": "x"}, _TEST_SECRET, algorithm="HS256") + assert _verify_state_signed(signed) is None + + +def test_verify_state_signed_accepts_a_valid_state(): + _setup_config() + now = datetime.now(UTC).timestamp() + signed = pyjwt.encode({"provider": "google", "state": "abc", "issued_at": now}, _TEST_SECRET, algorithm="HS256") + payload = _verify_state_signed(signed) + assert payload is not None + assert payload.provider == "google"