From 114518560e93f9b18732069f0f156258dce74764 Mon Sep 17 00:00:00 2001 From: Sergi Torres Albert Date: Tue, 28 Jul 2026 23:42:23 +0200 Subject: [PATCH 1/2] feat(crypto): accept Passport keys as PEM content, not only as file paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.gitignore` excludes `keys/**` and `*.pem`, so a deployed image contains no key files at all — and every key lookup went through PASSPORT_*_KEY_PATH. On Railway that resolves to nothing: `GET /.well-known/jwks.json` answers 500 and `POST /api/generate` cannot sign. The Passport is the centrepiece of the demo and `/health` reports none of this. Adds `autoria_ai.passport.keys.resolve_pem`, used by the signer, the verifier and the JWKS route. Precedence is explicit argument, then `*_PEM`, then `*_PATH`: the PEM variable wins because it is the production signal, and a stale key baked into an image must not outrank what the operator set in the dashboard. Literal backslash-n escapes are normalised, since dashboards and .env files commonly escape them and the PEM loaders fail opaquely on that. Also commits the regenerated `keys/jwks.public.json`. The committed copy described a keypair that no longer exists on disk while carrying the *same* `kid` (`autoria-2026-07`) — so anyone verifying offline against the repo's JWKS would have rejected every Passport signed today as `invalid_signature`, with nothing to indicate why. The `passports` table holds zero rows, so no previously issued Passport is invalidated by adopting the on-disk pair as canonical. docs/DEPLOYMENT.md gains the variables it never listed, and the warning that the pair must match. Verified: pytest backend/tests -> 1 failed, 119 passed (the failure is the pre-existing live Watsonx test pinned to the withdrawn Granite model); pytest ai_pipeline/tests -> 222 passed, 3 skipped. Co-Authored-By: Claude Opus 5 --- .env.example | 12 ++ ai_pipeline/autoria_ai/passport/keys.py | 82 +++++++++++ ai_pipeline/autoria_ai/passport/signer.py | 16 +- ai_pipeline/autoria_ai/passport/verifier.py | 17 ++- backend/app/config.py | 6 + backend/app/routes/jwks.py | 36 +++-- backend/tests/test_passport_keys_from_env.py | 145 +++++++++++++++++++ docs/DEPLOYMENT.md | 27 ++++ keys/jwks.public.json | 4 +- 9 files changed, 316 insertions(+), 29 deletions(-) create mode 100644 ai_pipeline/autoria_ai/passport/keys.py create mode 100644 backend/tests/test_passport_keys_from_env.py diff --git a/.env.example b/.env.example index 1809ab8..ae921f8 100644 --- a/.env.example +++ b/.env.example @@ -34,7 +34,19 @@ NEXT_PUBLIC_API_BASE_URL="http://localhost:8000" # ─── Authorship Passport (Sprint 2) ─── # Run `make keys` (scripts/generate_keys.py) to create keys/ — git-ignored. +# +# LOCAL: point at the PEM files. +# DEPLOYED: `keys/**` is git-ignored, so Railway has no files. Paste the PEM +# *content* into PASSPORT_PRIVATE_KEY_PEM / PASSPORT_PUBLIC_KEY_PEM instead — +# they take precedence over the *_PATH pair. Whichever you use, the public key +# must match the private one: both carry the same PASSPORT_KID, so a mismatched +# pair makes every Passport fail verification with `invalid_signature` and +# nothing warns you. See docs/DEPLOYMENT.md. PASSPORT_PRIVATE_KEY_PATH="keys/passport.priv.pem" PASSPORT_PUBLIC_KEY_PATH="keys/passport.pub.pem" +# PASSPORT_PRIVATE_KEY_PEM= +# PASSPORT_PUBLIC_KEY_PEM= +# (Left as placeholders on purpose: a real PEM header here would trip the +# detect-private-key pre-commit hook, which is doing its job.) PASSPORT_KID="autoria-2026-07" PASSPORT_VERIFIER_URL="https://autoria.app/verify" diff --git a/ai_pipeline/autoria_ai/passport/keys.py b/ai_pipeline/autoria_ai/passport/keys.py new file mode 100644 index 0000000..a7b58b3 --- /dev/null +++ b/ai_pipeline/autoria_ai/passport/keys.py @@ -0,0 +1,82 @@ +"""Resolve Passport signing/verification keys from the environment or disk. + +Why this exists +--------------- +The signing keys live in `keys/` as PEM files, and `.gitignore` excludes +`keys/**` and `*.pem` — correctly, since the private key must never be +committed. But Railway and Vercel deploy from the repository: the PEM files +simply are not there, so a path-only lookup resolves to nothing in production. +The visible symptom is `/.well-known/jwks.json` answering 500 and +`POST /api/generate` failing to sign, which is the demo's centrepiece. + +Platform dashboards inject *values*, not files, so this module accepts the PEM +content directly through `PASSPORT_PRIVATE_KEY_PEM` / `PASSPORT_PUBLIC_KEY_PEM` +and keeps the existing `*_PATH` variables working unchanged for local +development. + +Precedence is explicit-argument → `*_PEM` → `*_PATH`. The PEM variable wins +over the path because it is the production signal: an image that happens to +ship a stale key file must not silently outrank the key the operator set in the +dashboard. + +Never log the return value of anything here. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# A PEM pasted into a dashboard field often arrives with literal backslash-n +# instead of real newlines (shells, .env files and some CI UIs escape them). +# `load_pem_*` rejects that with a parse error that says nothing useful, so we +# normalise before handing the bytes over. +_ESCAPED_NEWLINE = "\\n" + + +def _normalise_pem(raw: str) -> bytes: + text = raw.strip() + if _ESCAPED_NEWLINE in text and "\n" not in text: + text = text.replace(_ESCAPED_NEWLINE, "\n") + return text.encode("utf-8") + + +def resolve_pem( + *, + pem_env: str, + path_env: str, + explicit_path: str | Path | None = None, +) -> bytes: + """Return PEM bytes for a key, or raise RuntimeError naming what is missing. + + Args: + pem_env: Name of the env var holding the PEM content (production). + path_env: Name of the env var holding a filesystem path (local dev). + explicit_path: Caller-supplied path; wins over both env vars. + + Raises: + RuntimeError: Neither source is configured, or the file is absent. + The message names the variables to set and never includes key + material. + """ + if explicit_path: + path = Path(explicit_path) + if not path.is_file(): + raise RuntimeError(f"Key file not found: {path}") + return path.read_bytes() + + inline = os.getenv(pem_env) + if inline and inline.strip(): + return _normalise_pem(inline) + + env_path = os.getenv(path_env) + if env_path: + path = Path(env_path) + if not path.is_file(): + raise RuntimeError(f"Key file not found at {path_env}: {path}") + return path.read_bytes() + + raise RuntimeError( + f"No key configured: set {pem_env} (PEM content, for deploys) " + f"or {path_env} (file path, for local development)" + ) diff --git a/ai_pipeline/autoria_ai/passport/signer.py b/ai_pipeline/autoria_ai/passport/signer.py index 3cbe1be..a0fa9bb 100644 --- a/ai_pipeline/autoria_ai/passport/signer.py +++ b/ai_pipeline/autoria_ai/passport/signer.py @@ -10,21 +10,27 @@ from cryptography.hazmat.primitives.serialization import load_pem_private_key from jose import jws as jose_jws +from autoria_ai.passport.keys import resolve_pem + _TYP = "passport+jws" _ALG = "ES256" def _load_private_key(path: str | Path | None = None): - key_path = path or os.getenv("PASSPORT_PRIVATE_KEY_PATH") - if not key_path: - raise RuntimeError("PASSPORT_PRIVATE_KEY_PATH is not configured") - pem = Path(key_path).read_bytes() + # PASSPORT_PRIVATE_KEY_PEM (content) takes precedence over + # PASSPORT_PRIVATE_KEY_PATH (file) — `keys/**` is gitignored, so on Railway + # there is no file to point at. See autoria_ai.passport.keys. + pem = resolve_pem( + pem_env="PASSPORT_PRIVATE_KEY_PEM", + path_env="PASSPORT_PRIVATE_KEY_PATH", + explicit_path=path, + ) # Fix #3: wrap cryptography errors with `from None` so the raw PEM bytes # never appear in __cause__/__context__ or in log traces. try: return load_pem_private_key(pem, password=None) except Exception: - raise RuntimeError("Failed to load private key (check key file format)") from None + raise RuntimeError("Failed to load private key (check key format)") from None def sign_passport( diff --git a/ai_pipeline/autoria_ai/passport/verifier.py b/ai_pipeline/autoria_ai/passport/verifier.py index d681af1..24092ea 100644 --- a/ai_pipeline/autoria_ai/passport/verifier.py +++ b/ai_pipeline/autoria_ai/passport/verifier.py @@ -19,6 +19,8 @@ from jose import jws as jose_jws from jose.exceptions import JOSEError +from autoria_ai.passport.keys import resolve_pem + _ALLOWED_ALGS = frozenset({"ES256"}) _SUPPORTED_SCHEMA_VERSIONS = frozenset({"1.0"}) # Hard cap against oversized-token DoS (compact JWS string length). @@ -58,12 +60,15 @@ def _load_public_key( ) -> EllipticCurvePublicKey: if public_key is not None: return public_key - path = public_key_path or os.getenv("PASSPORT_PUBLIC_KEY_PATH") - if not path: - raise RuntimeError("PASSPORT_PUBLIC_KEY_PATH is not configured") - if not Path(path).is_file(): - raise RuntimeError("Public key file not found") - key = load_pem_public_key(Path(path).read_bytes()) + # PASSPORT_PUBLIC_KEY_PEM (content) takes precedence over + # PASSPORT_PUBLIC_KEY_PATH (file): `keys/**` is gitignored, so a deployed + # image has no key file. See autoria_ai.passport.keys. + pem = resolve_pem( + pem_env="PASSPORT_PUBLIC_KEY_PEM", + path_env="PASSPORT_PUBLIC_KEY_PATH", + explicit_path=public_key_path, + ) + key = load_pem_public_key(pem) if not isinstance(key, EllipticCurvePublicKey): raise RuntimeError("Configured public key is not an EC key") return key diff --git a/backend/app/config.py b/backend/app/config.py index e1bfc2a..8169042 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -31,6 +31,12 @@ # Passport key env var names — exposed only as presence booleans in env_report(). PASSPORT_ENV_VARS: tuple[str, ...] = ( + # Deploys inject PEM *content* (dashboards hold values, not files) and + # `keys/**` is gitignored, so the image has no key file to point at. + # Local development keeps using the *_PATH form. Either pair works; the + # PEM one wins when both are set (autoria_ai.passport.keys.resolve_pem). + "PASSPORT_PRIVATE_KEY_PEM", + "PASSPORT_PUBLIC_KEY_PEM", "PASSPORT_PRIVATE_KEY_PATH", "PASSPORT_PUBLIC_KEY_PATH", "PASSPORT_KID", diff --git a/backend/app/routes/jwks.py b/backend/app/routes/jwks.py index 2914a7d..7316edd 100644 --- a/backend/app/routes/jwks.py +++ b/backend/app/routes/jwks.py @@ -5,10 +5,11 @@ back to our servers. Security invariants enforced here: -- Only the public key is ever read (PASSPORT_PUBLIC_KEY_PATH). +- Only the public key is ever read (PASSPORT_PUBLIC_KEY_PEM, else + PASSPORT_PUBLIC_KEY_PATH). - The private key is never touched by this module. -- If the key file is absent or PASSPORT_PUBLIC_KEY_PATH is unset → 500; we - never silently serve an empty key set (that would make all signatures +- If neither variable is configured, or the file is absent → 500; we never + silently serve an empty key set (that would make all signatures unverifiable without error). - The `d` (private scalar) field is never present in the response; ensured by using `cryptography`'s `public_key().public_bytes()` path rather than @@ -19,8 +20,6 @@ import base64 import logging -import os -from pathlib import Path from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey from cryptography.hazmat.primitives.serialization import load_pem_public_key @@ -29,6 +28,7 @@ import app.config as _cfg from app.schemas import JsonWebKey, JwksDocument +from autoria_ai.passport.keys import resolve_pem logger = logging.getLogger(__name__) @@ -49,22 +49,26 @@ def _load_public_jwk() -> JsonWebKey: Raises RuntimeError (→ 500) if the env var is unset or the file is missing. Never logs key material. """ - path = _cfg.settings.passport_public_key_path kid = _cfg.settings.passport_kid or "autoria" - if not path: - raise RuntimeError("PASSPORT_PUBLIC_KEY_PATH is not configured") - - if not os.path.isfile(path): - # Log the path (not the content) to assist ops debugging. - logger.error("Public key file not found: %s", path) - raise RuntimeError("Public key file not found") + # PASSPORT_PUBLIC_KEY_PEM (content) wins over PASSPORT_PUBLIC_KEY_PATH + # (file). `keys/**` is gitignored, so a Railway image ships no key file and + # a path-only lookup made this endpoint answer 500 in every deploy. + try: + pem = resolve_pem( + pem_env="PASSPORT_PUBLIC_KEY_PEM", + path_env="PASSPORT_PUBLIC_KEY_PATH", + ) + except RuntimeError as exc: + # Message names the missing variable, never key material. + logger.error("Public key unavailable: %s", exc) + raise try: - key = load_pem_public_key(Path(path).read_bytes()) + key = load_pem_public_key(pem) except Exception as exc: - # exc message may include path details but never key material. - logger.error("Failed to load public key from %s: %s", path, type(exc).__name__) + # Log the error *type* only — the message could echo PEM bytes. + logger.error("Failed to parse the configured public key: %s", type(exc).__name__) raise RuntimeError("Failed to parse public key") from exc if not isinstance(key, EllipticCurvePublicKey): diff --git a/backend/tests/test_passport_keys_from_env.py b/backend/tests/test_passport_keys_from_env.py new file mode 100644 index 0000000..cfb96fa --- /dev/null +++ b/backend/tests/test_passport_keys_from_env.py @@ -0,0 +1,145 @@ +"""Keys supplied as PEM *content* must work everywhere a key path works. + +`.gitignore` excludes `keys/**` and `*.pem`, so a deployed image contains no +key files at all: Railway and Vercel inject values, not files. Until now every +key lookup went through `PASSPORT_*_KEY_PATH`, which meant that in production +`/.well-known/jwks.json` answered 500 and `POST /api/generate` could not sign +the passport — the demo's centrepiece. + +These tests pin the deployed configuration: PEM content in the environment, +no file on disk anywhere. +""" + +from __future__ import annotations + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from fastapi.testclient import TestClient + +from autoria_ai.passport.keys import resolve_pem +from autoria_ai.passport.signer import sign_passport +from autoria_ai.passport.verifier import verify_passport + + +@pytest.fixture() +def pem_pair() -> tuple[str, str, str]: + """An EC P-256 keypair as PEM strings — never written to disk.""" + priv = ec.generate_private_key(ec.SECP256R1()) + priv_pem = priv.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode() + pub_pem = ( + priv.public_key() + .public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode() + ) + return priv_pem, pub_pem, "test-kid-env" + + +def _clear_key_env(monkeypatch) -> None: + for name in ( + "PASSPORT_PRIVATE_KEY_PEM", + "PASSPORT_PUBLIC_KEY_PEM", + "PASSPORT_PRIVATE_KEY_PATH", + "PASSPORT_PUBLIC_KEY_PATH", + ): + monkeypatch.delenv(name, raising=False) + + +# --------------------------------------------------------------------------- +# resolve_pem +# --------------------------------------------------------------------------- + + +def test_resolve_pem_reads_inline_content(monkeypatch, pem_pair): + _, pub_pem, _ = pem_pair + monkeypatch.setenv("PASSPORT_PUBLIC_KEY_PEM", pub_pem) + assert ( + resolve_pem(pem_env="PASSPORT_PUBLIC_KEY_PEM", path_env="PASSPORT_PUBLIC_KEY_PATH") + == pub_pem.strip().encode() + ) + + +def test_resolve_pem_accepts_escaped_newlines(monkeypatch, pem_pair): + """Dashboards and .env files often escape newlines; a PEM with literal + backslash-n must still parse, or the operator gets an opaque error.""" + _, pub_pem, _ = pem_pair + monkeypatch.setenv("PASSPORT_PUBLIC_KEY_PEM", pub_pem.strip().replace("\n", "\\n")) + pem = resolve_pem(pem_env="PASSPORT_PUBLIC_KEY_PEM", path_env="PASSPORT_PUBLIC_KEY_PATH") + serialization.load_pem_public_key(pem) # raises if the normalisation failed + + +def test_resolve_pem_prefers_content_over_stale_file(monkeypatch, tmp_path, pem_pair): + """A key baked into an image must never outrank the operator's variable.""" + _, pub_pem, _ = pem_pair + other = ec.generate_private_key(ec.SECP256R1()).public_key() + stale = tmp_path / "stale.pub.pem" + stale.write_bytes( + other.public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ) + ) + monkeypatch.setenv("PASSPORT_PUBLIC_KEY_PEM", pub_pem) + monkeypatch.setenv("PASSPORT_PUBLIC_KEY_PATH", str(stale)) + assert ( + resolve_pem(pem_env="PASSPORT_PUBLIC_KEY_PEM", path_env="PASSPORT_PUBLIC_KEY_PATH") + == pub_pem.strip().encode() + ) + + +def test_resolve_pem_names_both_variables_when_unset(monkeypatch): + _clear_key_env(monkeypatch) + with pytest.raises(RuntimeError) as exc: + resolve_pem(pem_env="PASSPORT_PUBLIC_KEY_PEM", path_env="PASSPORT_PUBLIC_KEY_PATH") + assert "PASSPORT_PUBLIC_KEY_PEM" in str(exc.value) + assert "PASSPORT_PUBLIC_KEY_PATH" in str(exc.value) + + +# --------------------------------------------------------------------------- +# Full round trip with no key file on disk — the deployed shape +# --------------------------------------------------------------------------- + + +def test_sign_and_verify_with_env_pem_only(monkeypatch, pem_pair): + priv_pem, pub_pem, kid = pem_pair + _clear_key_env(monkeypatch) + monkeypatch.setenv("PASSPORT_PRIVATE_KEY_PEM", priv_pem) + monkeypatch.setenv("PASSPORT_PUBLIC_KEY_PEM", pub_pem) + monkeypatch.setenv("PASSPORT_KID", kid) + + payload = { + "schema_version": "1.0", + "passport_id": "0e2f1b4a-52a8-4a2e-9c5b-1f0d3b7a9e11", + "issued_at": "2026-07-28T22:00:00Z", + } + token = sign_passport(payload, kid=kid) + result = verify_passport(token, expected_kid=kid, schema={"type": "object"}) + assert result.valid, result.errors + assert result.payload == payload + + +def test_jwks_endpoint_serves_key_from_env_pem(monkeypatch, pem_pair): + """The deployed shape: no key file exists, only the environment.""" + _priv_pem, pub_pem, kid = pem_pair + _clear_key_env(monkeypatch) + monkeypatch.setenv("PASSPORT_PUBLIC_KEY_PEM", pub_pem) + monkeypatch.setenv("PASSPORT_KID", kid) + + import app.config as cfg + + monkeypatch.setattr(cfg, "settings", cfg.load_settings()) + from app.main import app + + resp = TestClient(app).get("/.well-known/jwks.json") + assert resp.status_code == 200, resp.text + jwk = resp.json()["keys"][0] + assert jwk["kid"] == kid + assert jwk["alg"] == "ES256" + assert jwk["crv"] == "P-256" + assert "d" not in jwk, "private scalar must never be served" diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f388795..226bb7f 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -47,6 +47,33 @@ Commonly needed alongside them (see `.env.example`): `WATSONX_URL`, 3. **Variables** → add: `WATSONX_API_KEY`, `SUPABASE_URL`, `SUPABASE_KEY`, plus `WATSONX_URL`, `WATSONX_PROJECT_ID`, `DATABASE_URL`, and `AUTORIA_CORS_ORIGINS` (include your Vercel URL once you have it). + + **Passport signing keys — required, and easy to miss.** `.gitignore` + excludes `keys/**` and `*.pem`, so the deployed image contains **no key + files**: pointing `PASSPORT_*_KEY_PATH` at `keys/…` resolves to nothing in + Railway. Add instead: + + | Variable | Value | + | --- | --- | + | `PASSPORT_PRIVATE_KEY_PEM` | full contents of `keys/passport.priv.pem` | + | `PASSPORT_PUBLIC_KEY_PEM` | full contents of `keys/passport.pub.pem` | + | `PASSPORT_KID` | must match the `kid` in `keys/jwks.public.json` | + | `PASSPORT_VERIFIER_URL` | the public `/verify` URL | + + Multi-line values paste fine; literal `\n` escapes are also accepted. The + `_PEM` pair takes precedence over `_PATH`, so a stale key baked into an + image can never outrank the one you set here. + + Without these, `GET /.well-known/jwks.json` answers **500** and + `POST /api/generate` cannot sign — the Passport is the demo's centrepiece, + and `/health` will not tell you it is broken. + + **The pair must match.** Public and private key travel under the same + `kid`, so a mismatched pair produces Passports that fail verification with + `invalid_signature` and no other symptom. After deploying, compare the `x` + and `y` served by `/.well-known/jwks.json` against `keys/jwks.public.json` + in the repo; if they differ, the committed JWKS is stale and offline + verification against it will reject every valid Passport. 4. Deploy. Confirm the Nixpacks plan shows `start │ cd backend && uvicorn app.main:app --host 0.0.0.0 --port $PORT`. 5. Note the public URL (e.g. `https://autoria-api.up.railway.app`). diff --git a/keys/jwks.public.json b/keys/jwks.public.json index ebfae60..3f88a06 100644 --- a/keys/jwks.public.json +++ b/keys/jwks.public.json @@ -3,8 +3,8 @@ { "kty": "EC", "crv": "P-256", - "x": "mdwVHOhqza-78IPdo18B0cc6MmUBhXb2KW0rYv5pxas", - "y": "BFQ4yg8ITmDvartx-EBRsEvHyFFqnj7NvsBvitaDjPY", + "x": "Xu201CTeVLiSiSN5eOeV2kVMCXJYcH9GE2sujOnsj9I", + "y": "ad8_7Cczipi70UJcruLbcf9OkwApCRXfZHAY2n0MjH0", "use": "sig", "alg": "ES256", "kid": "autoria-2026-07" From eade8677c67b802e55f983eba4bb94ce053aff3b Mon Sep 17 00:00:00 2001 From: Sergi Torres Albert Date: Tue, 28 Jul 2026 23:44:56 +0200 Subject: [PATCH 2/2] test(back): put ai_pipeline on sys.path for the backend suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_generation_params.py` imports `autoria_ai.generator` to check our parameters against the SDK schema. That passes locally, where an editable install of `autoria_ai` sits in the venv, and fails in CI, which installs only `backend/`: ModuleNotFoundError: No module named 'autoria_ai' Same resolution the production code already uses in `app.routes.generate._ensure_ai_pipeline_on_path` — the repo-root `ai_pipeline` directory goes on `sys.path`. This is exactly the local/CI divergence the completeness audit warned about: a green local run proving nothing about the deployed shape. Co-Authored-By: Claude Opus 5 --- backend/conftest.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/conftest.py b/backend/conftest.py index 99d83fe..6514ed3 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -2,4 +2,17 @@ pytest inserts the directory containing the rootdir conftest.py onto sys.path, so tests can `from app.main import app` without an editable install. + +The monorepo's ``ai_pipeline`` is added the same way, and for the same reason +the production code does it (``app.routes.generate._ensure_ai_pipeline_on_path``): +CI installs only ``backend/``, so ``import autoria_ai`` fails there while +passing locally, where an editable install papers over the difference. Tests +that assert on pipeline constants must not be green locally and red in CI. """ + +import sys +from pathlib import Path + +_AI_PIPELINE = Path(__file__).resolve().parent.parent / "ai_pipeline" +if _AI_PIPELINE.is_dir() and str(_AI_PIPELINE) not in sys.path: + sys.path.insert(0, str(_AI_PIPELINE))