diff --git a/.agents/phases/todo/121_git_source_tokens/00_phase.md b/.agents/phases/complete/121_git_source_tokens/00_phase.md similarity index 100% rename from .agents/phases/todo/121_git_source_tokens/00_phase.md rename to .agents/phases/complete/121_git_source_tokens/00_phase.md diff --git a/.agents/phases/todo/121_git_source_tokens/01_token_storage.md b/.agents/phases/complete/121_git_source_tokens/01_token_storage.md similarity index 100% rename from .agents/phases/todo/121_git_source_tokens/01_token_storage.md rename to .agents/phases/complete/121_git_source_tokens/01_token_storage.md diff --git a/.agents/phases/todo/121_git_source_tokens/02_clone_url_and_sanitization.md b/.agents/phases/complete/121_git_source_tokens/02_clone_url_and_sanitization.md similarity index 100% rename from .agents/phases/todo/121_git_source_tokens/02_clone_url_and_sanitization.md rename to .agents/phases/complete/121_git_source_tokens/02_clone_url_and_sanitization.md diff --git a/.agents/phases/todo/121_git_source_tokens/03_ui_token_field.md b/.agents/phases/complete/121_git_source_tokens/03_ui_token_field.md similarity index 100% rename from .agents/phases/todo/121_git_source_tokens/03_ui_token_field.md rename to .agents/phases/complete/121_git_source_tokens/03_ui_token_field.md diff --git a/.agents/phases/todo/121_git_source_tokens/04_token_tests.md b/.agents/phases/complete/121_git_source_tokens/04_token_tests.md similarity index 100% rename from .agents/phases/todo/121_git_source_tokens/04_token_tests.md rename to .agents/phases/complete/121_git_source_tokens/04_token_tests.md diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__00_phase.a1.err b/.agents/reports/121_git_source_tokens/121_git_source_tokens__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__00_phase.a1.md b/.agents/reports/121_git_source_tokens/121_git_source_tokens__00_phase.a1.md new file mode 100644 index 0000000..f7c872a --- /dev/null +++ b/.agents/reports/121_git_source_tokens/121_git_source_tokens__00_phase.a1.md @@ -0,0 +1,15 @@ +**Phase 121 final verification pass — all green** (all 4 tasks already in `complete/`; verified, no defects found, no changes needed) + +- Verified implementation vs phase design: migration `0021` (reversible, round-tripped via `alembic downgrade base` + `upgrade head` → head `0021`), `GitSource.token` column, `normalize_credential`/`clone_url_for`/`sanitize_url`, clone callers switched (`sync.py`, `import_docs.py`), masked token fields in add form + editor, `extra="forbid"` output shapes +- Tests: `uv run pytest` → 2662 passed, 0 failed (exit 0); `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (≥90% gate) +- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings +- E2E in isolation: `uv run pytest tests/e2e/test_git_source_tokens.py -v --no-cov` → **4 passed** + +Completion criteria: +1. Private repo (UI add or pasted embedded-token URL) clones with injected token; token absent from every API response, page text, title attr, and full HTML — **PASS** (integration raw-JSON assertions + E2E `_assert_token_nowhere`) +2. Legacy embedded-token rows still clone from stored URL; output sanitized — **PASS** (`test_sync_legacy_row_clones_with_original_stored_url`, `test_get_masks_legacy_embedded_token_row`, env-fallback masking) +3. Public/local sources byte-identical — **PASS** (verbatim-URL + no-userinfo-unchanged tests) +4. pytest / coverage / ruff / pyright — **PASS** (see above) +5. Commit + phase move — harness responsibility; task files already in `complete/`, changes left in working tree (no commit made, per protocol) + +Notable: no deviations; DB left at head, functional. Next pending phase: **122_image_documents** (then 123_chat_image_questions). diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__00_phase.a1.validate b/.agents/reports/121_git_source_tokens/121_git_source_tokens__00_phase.a1.validate new file mode 100644 index 0000000..412d88f --- /dev/null +++ b/.agents/reports/121_git_source_tokens/121_git_source_tokens__00_phase.a1.validate @@ -0,0 +1,105 @@ +........................................................................ [ 2%] +........................................................................ [ 5%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 13%] +........................................................................ [ 16%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 32%] +........................................................................ [ 35%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 43%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 70%] +........................................................................ [ 73%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 81%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 95%] +........................................................................ [ 97%] +.......................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 226 1 99% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 156 1 99% +app/api/git_sources.py 241 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 231 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 66 0 100% +app/models.py 129 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 347 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 38 0 100% +app/rag/importer.py 219 3 99% +app/rag/llm.py 244 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 102 0 100% +app/rag/retriever.py 242 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 344 0 100% +-------------------------------------------------- +TOTAL 4317 16 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__01_token_storage.a1.err b/.agents/reports/121_git_source_tokens/121_git_source_tokens__01_token_storage.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__01_token_storage.a1.md b/.agents/reports/121_git_source_tokens/121_git_source_tokens__01_token_storage.a1.md new file mode 100644 index 0000000..24dac46 --- /dev/null +++ b/.agents/reports/121_git_source_tokens/121_git_source_tokens__01_token_storage.a1.md @@ -0,0 +1,19 @@ +Task 01 complete. Report: + +**Implemented** +- `GitSource.token` — `Text NULL` column in `app/models.py` with the phase-121/LOCKED-A2 field docstring (NULL = public/legacy row) +- `alembic/versions/0021_git_source_token.py` — additive, fully reversible (drop_column downgrade); verified upgrade/downgrade/upgrade on the live dev DB +- `app/schemas.py`: `GitSourceIn.token` + `GitSourcePatchIn.token` (max 500, before-mode trim validators, tri-state patch docstring); `GitSourceOut`/`GitSourceRow` gain **no** field — docstring contract + `extra="forbid"` so any `token` on construction raises +- Tests: `tests/unit/test_git_source_token.py` (21 tests) + `tests/integration/test_migration_0021.py` (4 tests, house A13 pattern incl. ORM round-trip) + +**Results** +- `uv run pytest` — 2602 passed +- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL **99%** (>90% ✓) +- `uv run ruff check . && uv run pyright` — clean (0 errors; the two deliberate `token=` rejection lines carry house-style `# type: ignore[reportCallIssue]`) +- `uv run alembic upgrade head` / `downgrade 0020` — apply/reverse cleanly; dev DB left at head (0021) + +**Decisions** +- Added the before-mode trim to `GitSourcePatchIn.token` as well (house `_trim_url` precedent; whitespace-only = clear, consistent with the tri-state) +- `extra="forbid"` on both output shapes makes "no token ever" structural (ChatMessage precedent), satisfying the task's "construction with a token kwarg raises" pin + +**Next pending task:** `02_clone_url_and_sanitization.md` (normalization on write, `clone_url_for`, `sanitize_url`) diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__01_token_storage.a1.validate b/.agents/reports/121_git_source_tokens/121_git_source_tokens__01_token_storage.a1.validate new file mode 100644 index 0000000..61e8f22 --- /dev/null +++ b/.agents/reports/121_git_source_tokens/121_git_source_tokens__01_token_storage.a1.validate @@ -0,0 +1,105 @@ +........................................................................ [ 2%] +........................................................................ [ 5%] +........................................................................ [ 8%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 16%] +........................................................................ [ 19%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 30%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 38%] +........................................................................ [ 41%] +........................................................................ [ 44%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 55%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 63%] +........................................................................ [ 66%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 74%] +........................................................................ [ 77%] +........................................................................ [ 80%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 88%] +........................................................................ [ 91%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 99%] +.......... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 226 1 99% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 156 1 99% +app/api/git_sources.py 232 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 231 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 66 0 100% +app/models.py 129 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 347 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 219 3 99% +app/rag/llm.py 244 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 102 0 100% +app/rag/retriever.py 242 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 344 0 100% +-------------------------------------------------- +TOTAL 4284 16 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__02_clone_url_and_sanitization.a1.err b/.agents/reports/121_git_source_tokens/121_git_source_tokens__02_clone_url_and_sanitization.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__02_clone_url_and_sanitization.a1.md b/.agents/reports/121_git_source_tokens/121_git_source_tokens__02_clone_url_and_sanitization.a1.md new file mode 100644 index 0000000..441f605 --- /dev/null +++ b/.agents/reports/121_git_source_tokens/121_git_source_tokens__02_clone_url_and_sanitization.a1.md @@ -0,0 +1,17 @@ +All gates green. Task 02 complete. + +**Implemented** +- `app/rag/git_sources.py`: three pure helpers — `sanitize_url` (anchored-regex userinfo strip, byte-identical/idempotent for clean URLs), `clone_url_for` (NULL token → stored URL verbatim; https? + token → `https://x-access-token:@…`; non-https + token → unchanged + warning), `normalize_credential` (write-path: embedded userinfo → bare URL + token column; explicit token wins, LOCKED A6) +- `app/api/git_sources.py`: POST normalizes (embedded `user:pass@` → bare URL + column; duplicate check on bare URL → 409); PATCH token tri-state (absent/None = no change, non-empty = replace, `""` = clear) with legacy-row re-normalization + IntegrityError→generic-409 backstop; `sanitize_url` on all 4 output sites (list DB rows, env rows, POST 201, PATCH 200) +- `app/api/sync.py` + `scripts/import_docs.py`: clone call sites now use `clone_url_for(row)`; `repo_name` stays on the bare URL (credential-free checkout paths) + +**Tests / gates** +- 32 new unit tests (`tests/unit/test_git_source_token.py`), 13 new integration tests (`tests/integration/test_git_sources_api.py`) — token absent from raw JSON text of every response, legacy rows clone with original stored URL, sync receives injected URL +- `uv run pytest --cov=app --cov-report=term-missing` → exit 0, TOTAL **99%** (>90%; touched modules 100%) +- `uv run ruff check .` clean; `uv run pyright` 0 errors; sanity: `tests/e2e/test_git_sources_admin.py` 6/6 in isolation + +**Notable decisions** +- The "embedded credential" moved to the column is the *password* part of `user:pass@` (whole run for the no-colon `https://@host` form) — only that authenticates via the injected `x-access-token:@` +- A token on a `kind=local` row is stored inert (never cloned, never echoed); env-fallback URLs are masked on output only + +**Next pending task:** `03_ui_token_field.md` diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__02_clone_url_and_sanitization.a1.validate b/.agents/reports/121_git_source_tokens/121_git_source_tokens__02_clone_url_and_sanitization.a1.validate new file mode 100644 index 0000000..f4fda49 --- /dev/null +++ b/.agents/reports/121_git_source_tokens/121_git_source_tokens__02_clone_url_and_sanitization.a1.validate @@ -0,0 +1,105 @@ +........................................................................ [ 2%] +........................................................................ [ 5%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 13%] +........................................................................ [ 16%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 32%] +........................................................................ [ 35%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 43%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 73%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 95%] +........................................................................ [ 98%] +................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 226 1 99% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 156 1 99% +app/api/git_sources.py 241 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 231 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 66 0 100% +app/models.py 129 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 347 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 38 0 100% +app/rag/importer.py 219 3 99% +app/rag/llm.py 244 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 102 0 100% +app/rag/retriever.py 242 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 344 0 100% +-------------------------------------------------- +TOTAL 4317 16 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__03_ui_token_field.a1.err b/.agents/reports/121_git_source_tokens/121_git_source_tokens__03_ui_token_field.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__03_ui_token_field.a1.md b/.agents/reports/121_git_source_tokens/121_git_source_tokens__03_ui_token_field.a1.md new file mode 100644 index 0000000..8dceb97 --- /dev/null +++ b/.agents/reports/121_git_source_tokens/121_git_source_tokens__03_ui_token_field.a1.md @@ -0,0 +1,17 @@ +All checks pass and the diff is clean. Final verification summary: + +- `uv run pytest --cov=app --cov-report=term-missing` → **2650 passed**, TOTAL coverage **99%** (>90% gate) +- `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings** +- Affected story E2Es in isolation: `test_source_ignore_paths.py` 6 passed, `test_git_sources_admin.py` 6 passed, `test_hidden_folders_toggle.py` 6 passed + +**Implemented (task 03 — UI masked token field):** +- **`frontend/index.html`**: add form gains optional masked `#git-source-token` (`type="password"`, `autocomplete="off"`, "optional — private repos" hint span); the per-row editor dialog mirrors it with `#ignore-editor-token` ("leave blank to keep the current token"); dialog title widened "Ignored files and folders" → "Source settings" to match its new scope +- **`frontend/assets/git-sources.js`**: submit body `(url, token) => ({ url, ...(token ? { token } : {}) })` (blank = key omitted); editor token always opens/resets blank, PATCH body includes `token` only when non-blank (tri-state no-change); display sites keep rendering server-sanitized `s.url` with the required one-line note; phase-121 docstring bullet +- **`frontend/assets/styles.css`**: `#git-source-token` grouped with the URL input (incl. mobile rule), minimal `.field-hint` (ink-soft, 5.1:1 AA), `.ignore-editor-token` full-width 44px box +- **Tests**: 7 new house-style source pins in `tests/unit/test_git_source_token.py` (masking, blank-omission on POST+PATCH, display sites, docstring, CSS); updated `test_source_ignore_paths.py` save-body pin + id guard for the intentional dialog extension + +**Notable decisions:** +- The task's "edit modal (L697)" is not a distinct modal — verified against the phase-authoring commit, L697 is the per-row "Ignore paths" editor dialog (the only per-row edit surface); extended that, matching the phase's "edit row (blank token) → token kept" contract and the "Sources page layout untouched" boundary +- Task-03 unit pins landed now (task file defers to 04, but they assert this task's changes); the phase E2E file stays task 04's deliverable + +**Next pending task:** `.agents/phases/todo/121_git_source_tokens/04_token_tests.md` (finalize unit/integration + isolated E2E `test_git_source_tokens.py`) diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__03_ui_token_field.a1.validate b/.agents/reports/121_git_source_tokens/121_git_source_tokens__03_ui_token_field.a1.validate new file mode 100644 index 0000000..412d88f --- /dev/null +++ b/.agents/reports/121_git_source_tokens/121_git_source_tokens__03_ui_token_field.a1.validate @@ -0,0 +1,105 @@ +........................................................................ [ 2%] +........................................................................ [ 5%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 13%] +........................................................................ [ 16%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 32%] +........................................................................ [ 35%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 43%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 70%] +........................................................................ [ 73%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 81%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 95%] +........................................................................ [ 97%] +.......................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 226 1 99% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 156 1 99% +app/api/git_sources.py 241 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 231 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 66 0 100% +app/models.py 129 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 347 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 38 0 100% +app/rag/importer.py 219 3 99% +app/rag/llm.py 244 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 102 0 100% +app/rag/retriever.py 242 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 344 0 100% +-------------------------------------------------- +TOTAL 4317 16 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__04_token_tests.a1.err b/.agents/reports/121_git_source_tokens/121_git_source_tokens__04_token_tests.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__04_token_tests.a1.md b/.agents/reports/121_git_source_tokens/121_git_source_tokens__04_token_tests.a1.md new file mode 100644 index 0000000..7f9cde3 --- /dev/null +++ b/.agents/reports/121_git_source_tokens/121_git_source_tokens__04_token_tests.a1.md @@ -0,0 +1,15 @@ +All work is verified and the tree is as it should be (only my new E2E file added; tasks 01–03 changes already in the working tree, untouched by me, no commits). + +**Phase 121 / Task 04 — Token tests: complete** + +Implemented: +- `tests/e2e/test_git_source_tokens.py` (new, 4 tests, isolated port 8146, `BOR_GIT_SOURCES=""` + tmp sources/upload dirs pinned): add via UI (bare URL + masked token) → row renders bare URL in cell + `title` attr, token absent from body text / every `title` / page HTML / raw `GET /api/git-sources` JSON, DB = bare URL + token column; old-style `user:token@` paste normalized (A6) and hidden; editor token field `type=password`, opens blank, blank save → single PATCH 200 omitting the `token` key, stored token kept; confirm-modal removal → 204, list empty. +- Verified task-01–03 deliverables already in place and green: `tests/unit/test_git_source_token.py` (50 tests — sanitize/clone/normalize/output-model/frontend pins) and the `tests/integration/test_git_sources_api.py` token block (POST/409/PATCH tri-state/legacy mask/sync-clone raw-JSON "token nowhere" on list, 201, PATCH, sync-status surfaces). + +Gates (all green): +- `uv run pytest` → 2650 passed +- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%); `app/api/git_sources.py`, `app/rag/git_sources.py`, `app/schemas.py`, `app/models.py` all 100% +- `uv run pytest tests/e2e/test_git_source_tokens.py -v --no-cov` → 4 passed +- `uv run ruff check . && uv run pyright` → clean, 0 errors + +Decisions: no defects found in prior tasks; only fix was in my new file (Playwright `APIResponse.text` is a method — `r.text()`; body text via `page.evaluate` per house style). Next pending task: none in this phase — all 4 tasks done, phase is complete. diff --git a/.agents/reports/121_git_source_tokens/121_git_source_tokens__04_token_tests.a1.validate b/.agents/reports/121_git_source_tokens/121_git_source_tokens__04_token_tests.a1.validate new file mode 100644 index 0000000..412d88f --- /dev/null +++ b/.agents/reports/121_git_source_tokens/121_git_source_tokens__04_token_tests.a1.validate @@ -0,0 +1,105 @@ +........................................................................ [ 2%] +........................................................................ [ 5%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 13%] +........................................................................ [ 16%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 24%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 32%] +........................................................................ [ 35%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 43%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 70%] +........................................................................ [ 73%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 81%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 95%] +........................................................................ [ 97%] +.......................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 226 1 99% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 156 1 99% +app/api/git_sources.py 241 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 231 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 66 0 100% +app/models.py 129 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 347 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 38 0 100% +app/rag/importer.py 219 3 99% +app/rag/llm.py 244 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 102 0 100% +app/rag/retriever.py 242 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 344 0 100% +-------------------------------------------------- +TOTAL 4317 16 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/alembic/versions/0021_git_source_token.py b/alembic/versions/0021_git_source_token.py new file mode 100644 index 0000000..944721a --- /dev/null +++ b/alembic/versions/0021_git_source_token.py @@ -0,0 +1,47 @@ +"""git_sources.token: private-repo credential column (phase 121) + +Revision ID: 0021 +Revises: 0020 +Create Date: 2026-09-24 + +Phase 121 (private git sources: a token that never reaches the UI or +the API — task 01, storage only): + +* ``git_sources.token`` — TEXT NULLABLE, no server default: the private + repo credential (LOCKED A2) the owner types into the masked + Sources-page field. NULL = public repo (or a legacy row whose + credential is still embedded in ``url`` — those rows keep their + stored value, which is what authenticates the clone, and are + sanitized on OUTPUT only, task 02). Stored plaintext BY NECESSITY: + the repo must remain cloneable, so the raw credential must be + recoverable at sync time; the Postgres DB is the trusted store and is + never served to the UI. The column is injected into the clone URL + ONLY at clone time (task 02's ``clone_url_for``) and is NEVER + returned by any API shape (the output models gain no token field — + the omission is a documented contract). + +One additive, fully reversible migration (A13); no other schema +change. Normalization of embedded-token URLs on write and output +sanitization are code (tasks 02/03) — this revision only carries the +column. +""" +from __future__ import annotations + +import sqlalchemy as sa + +from alembic import op + +revision = "0021" +down_revision = "0020" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("git_sources", sa.Column("token", sa.Text(), nullable=True)) + + +def downgrade() -> None: + # The token column is the only 0021 artefact — dropping it leaves + # 0020's schema byte-identical (A13, fully reversible). + op.drop_column("git_sources", "token") diff --git a/app/api/git_sources.py b/app/api/git_sources.py index 2b00f8a..c2a2bdc 100644 --- a/app/api/git_sources.py +++ b/app/api/git_sources.py @@ -49,11 +49,16 @@ embeddings) committed first, then the app-managed on-disk dir). The whole router sits behind :func:`app.core.auth.require_admin` — anonymous callers get 403 on every route. -No credential-echo path: git URLs may embed ``user:pass@`` (phase 32's -masking discipline), so every git 409/422 detail is a fixed generic -string that never repeats the submitted URL. Local paths are not -secrets — the local 422/409 details name the (expanded) path so the -owner sees exactly which directory failed. +No credential-echo path (phase 32's masking discipline, extended by +phase 121 — LOCKED A2): git URLs may embed ``user:pass@``, so (a) +every git 409/422 detail is a fixed generic string that never repeats +the submitted URL, and (b) every URL that LEAVES the API is masked +through :func:`app.rag.git_sources.sanitize_url` before it enters a +response (DB rows, env-fallback rows, POST 201, PATCH 200) — a legacy +row whose credential is still embedded in the stored ``url`` clones +fine (the stored value is untouched) but its API/UI output is +bare. Local paths are not secrets — the local 422/409 details name +the (expanded) path so the owner sees exactly which directory failed. Scope boundary (phase locked decisions): the CRUD routes do NOT clone or import anything — the existing Sync button performs that, and @@ -110,6 +115,7 @@ from app.rag.archive_upload import ( swap_in, unpack_archive, ) +from app.rag.git_sources import normalize_credential, sanitize_url from app.rag.importer import normalize_ignore_path from app.rag.llm import LLMClient from app.rag.overview import regenerate_overview @@ -220,6 +226,11 @@ def list_git_sources( git-only) with null ``id``/``added_at``, ``ignore_paths: []`` and ``include_hidden: False`` (no DB row to store a list or a flag on), and ``from_env: true``. + + Every URL is masked on the way out (phase 121, LOCKED A2 — + :func:`sanitize_url`): an env value or a legacy stored URL may + embed ``user:pass@`` — the env value and the DB value are + untouched, only the response is bare. """ rows = db.scalars( select(GitSource).order_by(GitSource.added_at.asc(), GitSource.id.asc()) @@ -232,7 +243,7 @@ def list_git_sources( GitSourceRow( id=row.id, kind=cast(Literal["git", "local"], row.kind), - url=row.url, + url=sanitize_url(row.url), # phase 121: never echo userinfo path=row.path, added_at=row.added_at, ignore_paths=row.ignore_paths or [], @@ -247,7 +258,7 @@ def list_git_sources( GitSourceRow( id=None, kind="git", - url=url, + url=sanitize_url(url), # phase 121: an env URL can embed a token path=None, added_at=None, ignore_paths=[], @@ -290,11 +301,20 @@ def create_git_source( ``include_hidden`` (phase 105) — optional, both kinds: absent → stored ``False`` (A4), present → stored as sent; the stored flag is what is reported. + + ``token`` (phase 121, LOCKED A2) — the masked private-repo + credential: write-only, stored in the dedicated column, never + echoed (the response has no token field by contract). Git rows + are normalized on the way in (``normalize_credential``): an + old-style embedded ``user:pass@`` URL is stored bare with the + credential in the token column, an explicit ``token`` wins over + the embedded one (LOCKED A6), and the duplicate check runs on the + bare URL. """ row = _create_git_row(payload, db) if payload.kind == "git" else _create_local_row(payload, db) return GitSourceOut( id=row.id, - url=row.url, + url=sanitize_url(row.url), # phase 121: the output mask, always added_at=row.added_at, ignore_paths=row.ignore_paths, include_hidden=row.include_hidden, @@ -346,6 +366,13 @@ def _create_git_row(payload: GitSourceIn, db: Session) -> GitSource: raise HTTPException( status_code=422, detail="not a valid git URL (expected https://, ssh:// or git@…)" ) + # Phase 121 (task 02, LOCKED A6): normalize the credential — an + # old-style embedded ``user:pass@`` URL is stored BARE and the + # embedded credential moves to the token column; an explicit + # ``token`` field wins over the embedded one. The duplicate check + # below runs on the BARE URL, so the same repo pasted with a + # different credential is the same source (409, not a second row). + url, effective_token = normalize_credential(url, payload.token) if db.scalar(select(GitSource).where(GitSource.url == url)) is not None: raise HTTPException(status_code=409, detail="a git source with this URL already exists") return _commit_new( @@ -354,6 +381,7 @@ def _create_git_row(payload: GitSourceIn, db: Session) -> GitSource: kind="git", ignore_paths=_validate_ignore_paths(payload.ignore_paths), include_hidden=bool(payload.include_hidden), + token=effective_token or None, ), "a git source with this URL already exists", db, @@ -380,7 +408,10 @@ def _create_local_row(payload: GitSourceIn, db: Session) -> GitSource: ) # ``url`` is the table's NOT-NULL location column (phase 38: local # rows carry the expanded path there too — git URL shapes and absolute - # paths cannot collide). + # paths cannot collide). A ``token`` on a local row (phase 121) is + # stored inert — local rows are walked, not cloned, so + # ``clone_url_for`` never sees it — and, like on git rows, is never + # echoed by any output shape. return _commit_new( GitSource( url=path, @@ -388,6 +419,7 @@ def _create_local_row(payload: GitSourceIn, db: Session) -> GitSource: path=path, ignore_paths=_validate_ignore_paths(payload.ignore_paths), include_hidden=bool(payload.include_hidden), + token=payload.token or None, ), f"a local source with this path already exists: {path}", db, @@ -400,14 +432,26 @@ def patch_git_source( payload: GitSourcePatchIn, db: Session = Depends(get_db), # noqa: B008 ) -> GitSourceOut: - """Edit one source's ignore list and/or hidden-folders flag. + """Edit one source's ignore list, hidden-folders flag, and/or + private-repo token. - Phase 89 A5 (ignore list) + phase 105 (the flag): 404 unknown - id; each PRESENT body field applies independently — - ``ignore_paths`` REPLACES the list (normalized + A4-validated, - fixed 422 details); ``include_hidden`` sets the flag. Both - absent → 200 no-op. Returns the updated row's public shape - (id, url, added_at, ignore_paths, include_hidden). + Phase 89 A5 (ignore list) + phase 105 (the flag) + phase 121 + (the token): 404 unknown id; each PRESENT body field applies + independently — ``ignore_paths`` REPLACES the list (normalized + + A4-validated, fixed 422 details); ``include_hidden`` sets the + flag; ``token`` is TRI-STATE (LOCKED A2): absent/None = no change + (the row's stored credential survives an edit that does not touch + the masked field), non-empty = replace, empty string = clear + (stored NULL). A PRESENT token also re-normalizes the (current + url, new token) pair with the POST write-path rules — a legacy + embedded-token URL gets its userinfo stripped (moved to the + column) the first time an explicit credential is written; a clean + URL comes back untouched. The 409 backstop: re-normalizing can + make the stored URL collide with another row's bare URL (a + legacy ``user:pass@`` row and a bare row for the same repo) — the + unique index yields the generic 409, never a 500. Returns the + updated row's public shape (id, url — masked, added_at, + ignore_paths, include_hidden); the token is never echoed. """ row = db.get(GitSource, source_id) if row is None: @@ -416,11 +460,28 @@ def patch_git_source( row.ignore_paths = _validate_ignore_paths(payload.ignore_paths) if payload.include_hidden is not None: row.include_hidden = payload.include_hidden - db.commit() + if payload.token is not None: + # Phase 121 (task 02): the tri-state applies — "" clears + # (stored NULL), non-empty replaces. Re-normalize the pair + # (see the docstring): a legacy embedded-token URL becomes + # bare + column credential. + row.url, effective = normalize_credential(row.url, payload.token) + row.token = effective or None + try: + db.commit() + except IntegrityError: + # The re-normalized URL collided with another row's stored URL + # (the legacy-embedded + bare sibling case) — the unique index + # is the backstop: a generic 409, never a 500 (the phase-35 + # convention). + db.rollback() + raise HTTPException( + status_code=409, detail="a git source with this URL already exists" + ) from None db.refresh(row) return GitSourceOut( id=row.id, - url=row.url, + url=sanitize_url(row.url), # phase 121: the output mask, always added_at=row.added_at, ignore_paths=row.ignore_paths, include_hidden=row.include_hidden, diff --git a/app/api/sync.py b/app/api/sync.py index e5959a8..8a43821 100644 --- a/app/api/sync.py +++ b/app/api/sync.py @@ -27,8 +27,14 @@ decisions): URLs) fails loudly (``no sources configured (git or local)``) instead of silently importing the legacy local directories; 3. per resolved row: ``kind=git`` → :func:`scripts.git_sync.clone_or_pull` - into ``BOR_SOURCES_DIR//`` (phase 28 — reused, not - re-implemented); ``kind=local`` → the stored directory, re-verified + with the phase-121 clone URL (:func:`app.rag.git_sources.clone_url_for` + — the row's ``token`` column injected as + ``https://x-access-token:@…`` only for https? rows; NULL + token → the bare stored URL verbatim, so public repos and legacy + embedded-token rows clone exactly as before) into + ``BOR_SOURCES_DIR//`` (phase 28 — reused, not + re-implemented; the checkout name stays on the bare URL — + credential-free); ``kind=local`` → the stored directory, re-verified ``.is_dir()`` **at sync time** (it may have moved/deleted since add-time) — a missing directory raises ``local source missing: ``; a failing clone or a missing local dir aborts before any @@ -116,7 +122,7 @@ from app.core.auth import require_admin from app.core.errors import sanitize_error as _sanitize_error from app.db import SessionLocal from app.rag.folder_summaries import generate_folder_summaries, missing_folder_summaries -from app.rag.git_sources import effective_sources +from app.rag.git_sources import clone_url_for, effective_sources from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient, check_models from app.rag.overview import regenerate_overview @@ -293,7 +299,11 @@ async def _run_sync() -> None: doc_dates_by_root: dict[str, dict[str, datetime]] = {} for row in rows: if row.kind == "git": - root = clone_or_pull(row.url, sources_root / repo_name(row.url)) + # Phase 121: the token column is injected into the clone + # URL ONLY here (clone_url_for — NULL token → the bare + # stored URL verbatim); repo_name stays on the bare URL + # so the checkout directory name is credential-free. + root = clone_or_pull(clone_url_for(row), sources_root / repo_name(row.url)) # Phase 106 (D2): the checkout's per-file last-commit # dates, keyed by the SAME root string the importer # sees (full-history checkouts → true per-file diff --git a/app/models.py b/app/models.py index aa1a4f7..d86ba35 100644 --- a/app/models.py +++ b/app/models.py @@ -286,6 +286,16 @@ class GitSource(Base): include_hidden: Mapped[bool] = mapped_column( Boolean, default=False, server_default=text("false"), nullable=False ) + #: Private-repo credential (phase 121, LOCKED A2): the PAT the owner + #: types into the masked Sources-page field. NULL = public repo (or a + #: legacy row whose credential is still embedded in ``url``). Stored + #: plaintext BY NECESSITY — the repo must remain cloneable, so the + #: raw credential must be recoverable at sync time; the DB is the + #: trusted store and is never served to the UI. Injected into the + #: clone URL ONLY at clone time + #: (:func:`app.rag.git_sources.clone_url_for`); NEVER returned by + #: any API shape (the output models gain no token field). + token: Mapped[str | None] = mapped_column(Text, default=None) added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/app/rag/git_sources.py b/app/rag/git_sources.py index a350314..6f842a3 100644 --- a/app/rag/git_sources.py +++ b/app/rag/git_sources.py @@ -21,9 +21,26 @@ CLI: the legacy ``DEFAULT_SOURCES`` fallback). (repo URLs of the effective git rows) so existing importers of the old name keep working; new code calls :func:`effective_sources` and branches on ``row.kind``. + +Phase 121 (private git sources) adds the token mechanics next to the +resolver — three pure helpers, no DB of their own: + +* :func:`sanitize_url` — the OUTPUT mask: strips the ``user:pass@`` + userinfo of ``https?://`` URLs so no API/UI surface ever shows an + embedded credential (legacy rows included; the stored value is + untouched — LOCKED A2); +* :func:`clone_url_for` — the CLONE-time credential: a row's + ``token`` column is injected into the URL handed to git, and only + there (NULL token → the bare stored URL verbatim); +* :func:`normalize_credential` — the WRITE-path normalizer: an + old-style ``https://user:pass@host/repo.git`` URL pasted into the + API is stored bare and the embedded credential is moved into the + ``token`` column (an explicit ``token`` field wins — LOCKED A6). """ from __future__ import annotations +import logging +import re from typing import Literal from sqlalchemy import select @@ -32,6 +49,109 @@ from sqlalchemy.orm import Session from app.config import get_settings from app.models import GitSource +logger = logging.getLogger(__name__) + +#: Phase 121 — the userinfo component of an ``https?://`` URL: the +#: scheme, a run of one-or-more characters that are neither ``@`` nor +#: ``/`` (the ``user`` or ``user:pass`` part), and the terminating +#: ``@``. Deliberately a small anchored regex — never a URL parser +#: re-serialization: for a credential-free URL there is no match and +#: the input is returned byte-identical (the phase-50/35 contract that +#: stored URLs surface verbatim when they carry no credential). +_USERINFO_RE = re.compile(r"^(https?://)([^/@]+)@") + + +def sanitize_url(url: str) -> str: + """Phase 121, LOCKED A2 — the token-free form of a source URL, + for API/UI output only. + + Strips the userinfo component of ``https?://`` URLs + (``https://user:pass@host/path`` → ``https://host/path``); + ``ssh://``, ``git@`` (scp-style), and local paths are left + untouched. Idempotent — and byte-identical for URLs that carry no + userinfo (no match → the input unchanged, including a ``@`` inside + the *path*, which is not userinfo). The stored row value is NOT + modified: a legacy row whose credential is still embedded in + ``url`` keeps cloning with its original stored URL; this is the + output mask that keeps that credential out of every response and + the UI (the env-fallback rows get the same treatment — the env + *value* itself is untouched, only the response is masked). + """ + return _USERINFO_RE.sub(r"\1", url, count=1) + + +def clone_url_for(row: GitSource) -> str: + """Phase 121, LOCKED A2 — the URL git actually clones, with the + row's credential injected ONLY here. + + * ``token`` NULL/falsy → ``row.url`` verbatim: public repos and + local rows behave byte-identically to pre-phase-121, and a + legacy embedded-token row (``token`` NULL, credential in the + stored URL) keeps cloning with its ORIGINAL stored URL — the + credential keeps working; + * an ``https?://`` row with a token → + ``https://x-access-token:@/`` — any existing + userinfo in the stored URL is replaced by the column credential + (``x-access-token`` as the username: GitHub-agnostic, any host + that accepts ``https://user:token@`` treats the first component + opaquely — the task-02 assumption, task 02 step 4); + * a non-https row with a token (``ssh://``/``git@``/local path) + → ``row.url`` unchanged + a WARNING log (a token cannot + authenticate ssh — the owner must use a deploy key/agent there; + the log names the repo via its sanitized URL, never the token). + + ``repo_name`` (and every other checkout-path derivation) keeps + operating on the bare ``row.url`` — the checkout directory name is + credential-free. + """ + token = row.token + if not token: + return row.url + if not row.url.startswith(("https://", "http://")): + logger.warning( + "git source %s has a stored token but a non-https? URL — " + "a token cannot authenticate ssh/git@ clones; the stored " + "URL is used as-is (configure a deploy key or SSH agent " + "for private ssh repos)", + sanitize_url(row.url), + ) + return row.url + bare = sanitize_url(row.url) + return bare.replace("://", f"://x-access-token:{token}@", 1) + + +def normalize_credential(url: str, token: str | None) -> tuple[str, str | None]: + """Phase 121, LOCKED A6 — the write-path credential normalizer. + + If the (``https?://``-only) URL carries userinfo, it is stripped + for storage and the EMBEDDED CREDENTIAL becomes the effective + token — UNLESS the caller also sent an explicit ``token`` + (non-None), which WINS (explicit beats embedded — a blank masked + field, i.e. an explicit "", is a deliberate "no credential"). + Pasting the old-style ``https://user:ghp_…@host/repo.git`` URL + still works and lands token-column-clean; the caller stores the + bare URL + ``effective_token or None`` (an empty explicit token + stores NULL) and runs its duplicate check on the BARE URL, so the + same repo with a different token is still the same source + (409, not a second row). + + The embedded credential is the *password* part of a + ``user:pass`` userinfo (after the first colon — the password may + contain further colons), or the whole userinfo run for the + username-as-token form (``https://@host/…``, the documented + GitHub shape, no colon). Clean URLs and ``ssh://``/``git@``/local + paths return ``(url, token)`` untouched — byte-identical + pre-phase behavior. + """ + match = _USERINFO_RE.match(url) + if match is None: + return url, token + userinfo = match.group(2) + user, sep, password = userinfo.partition(":") + embedded = password if sep else userinfo + effective = token if token is not None else embedded + return sanitize_url(url), effective + def effective_sources(db: Session) -> tuple[list[GitSource], Literal["db", "env"]]: """``(rows, origin)`` — the effective source rows (both kinds) and diff --git a/app/schemas.py b/app/schemas.py index 26591e6..72a8067 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -583,6 +583,14 @@ class GitSourceIn(BaseModel): ``include_hidden`` (phase 105) is optional at create time (absent → stored ``False`` — A4). + + ``token`` (phase 121, LOCKED A2) is the masked private-repo + credential from the Sources page: optional at create time (absent/ + None = no credential — public repo), trimmed *before* the length + constraints run (the ``_trim_url`` precedent), max 500. It is a + WRITE-ONLY field — stored in the dedicated ``git_sources.token`` + column and NEVER echoed back by any output shape (``GitSourceOut`` + / ``GitSourceRow`` carry no token field by contract). """ kind: Literal["git", "local"] = "git" @@ -590,6 +598,7 @@ class GitSourceIn(BaseModel): path: str | None = Field(default=None, min_length=1, max_length=2000) ignore_paths: list[str] | None = Field(default=None) include_hidden: bool | None = Field(default=None) + token: str | None = Field(default=None, max_length=500) @field_validator("url", mode="before") @classmethod @@ -601,6 +610,11 @@ class GitSourceIn(BaseModel): def _trim_path(cls, v: object) -> object: return v.strip() if isinstance(v, str) else v + @field_validator("token", mode="before") + @classmethod + def _trim_token(cls, v: object) -> object: + return v.strip() if isinstance(v, str) else v + class GitSourceOut(BaseModel): """One created git source as returned by ``POST`` (phase 35, task 02). @@ -614,8 +628,18 @@ class GitSourceOut(BaseModel): list — non-null (a row created without it reports ``[]``). ``include_hidden`` (phase 105) is the stored flag — a row created without it reports ``False`` (A4). + + There is deliberately NO ``token`` field (phase 121, LOCKED A2): + the private-repo credential is stored in the dedicated + ``git_sources.token`` column and is NEVER a response field — it + never reaches the UI or any API output. ``extra="forbid"`` makes + the omission a structural contract, not an accident: constructing + this model with a ``token`` key raises, so a regression that tries + to echo the credential back cannot even build the shape. """ + model_config = ConfigDict(extra="forbid") + id: uuid.UUID | None url: str added_at: datetime | None @@ -637,8 +661,16 @@ class GitSourceRow(BaseModel): store a list on) report ``[]``. ``include_hidden`` (phase 105) is the row's stored flag — env-fallback rows (no DB row to store a flag on) report ``False`` (the ``ignore_paths: []`` precedent). + + There is deliberately NO ``token`` field (phase 121, LOCKED A2): + same contract as :class:`GitSourceOut` — the credential never + reaches the UI or any API output, and ``extra="forbid"`` makes the + omission structural (constructing a row with a ``token`` key + raises). """ + model_config = ConfigDict(extra="forbid") + id: uuid.UUID | None kind: Literal["git", "local"] url: str @@ -658,12 +690,24 @@ class GitSourcePatchIn(BaseModel): normalized + A4-validated, becomes the row's whole list — empty list clears all; every pre-phase-105 client always sends the list, so their behavior is byte-identical). ``include_hidden`` - (phase 105) when present sets the stored flag. Both absent → - 200 no-op (the row is untouched). + (phase 105) when present sets the stored flag. ``token`` (phase + 121, LOCKED A2) is TRI-STATE — the three-way semantics the masked + edit field depends on: **absent/None = no change** (keep the row's + stored credential), **non-empty = replace**, **empty string = + clear** (the UI offers replace; clear exists for API completeness). + Trimmed *before* the length constraints run (the ``GitSourceIn`` + ``_trim_token`` precedent — whitespace-only counts as a clear), + max 500. All absent → 200 no-op (the row is untouched). """ ignore_paths: list[str] | None = Field(default=None) include_hidden: bool | None = Field(default=None) + token: str | None = Field(default=None, max_length=500) + + @field_validator("token", mode="before") + @classmethod + def _trim_token(cls, v: object) -> object: + return v.strip() if isinstance(v, str) else v class GitSourceList(BaseModel): diff --git a/frontend/assets/git-sources.js b/frontend/assets/git-sources.js index 3d207f1..05d1d71 100644 --- a/frontend/assets/git-sources.js +++ b/frontend/assets/git-sources.js @@ -203,6 +203,25 @@ * calls hideHiddenError — the phase-89 "happy path heals the error * state" precedent). * + * Phase 121 (task 03) — the masked token field (LOCKED A2): the add + * form gains the optional `#git-source-token` (type=password, + * autocomplete=off — a PAT is not a site credential) with the visible + * "optional — private repos" hint; the submit body is + * `(url, token) => ({ url, ...(token ? { token } : {}) })` — a blank + * token OMITS the key (None = no credential), and 201 clears BOTH + * inputs (the credential is stored — write-only: the API shapes + * carry no token field, so nothing round-trips). The per-row editor + * (the ignore-paths dialog) mirrors it: `#ignore-editor-token` with + * the placeholder "leave blank to keep the current token" — it + * always opens BLANK (there is no token field to prefill from) and + * the PATCH body includes `token` ONLY when non-blank (the tri-state: + * absent = no change, the row's stored credential is kept). Every + * display site keeps rendering `s.url` UNCHANGED — the server now + * returns bare URLs (sanitize_url), so the list cell, its title + * attribute, the remove modal, and the editor's source line are + * token-free with no per-site change; the UI must never re-embed a + * credential. + * * Scope boundary (phase locked decisions): adding a git repo does * NOT clone — the sync service (server-side) does that. Removing a * source, however, performs the FULL cleanup server-side (phase 69): @@ -239,6 +258,9 @@ export async function mount(root) { const contentEl = root.querySelector("#git-sources-content"); const formEl = root.querySelector("#git-source-form"); const urlInput = root.querySelector("#git-source-url"); + /* Phase 121: the add form's optional masked token field — blank = + no credential (the key is omitted from the POST body). */ + const tokenInput = root.querySelector("#git-source-token"); const addBtn = root.querySelector("#git-source-add"); const addError = root.querySelector("#git-source-error"); /* Phase 49: the archive upload form (replaces the phase-38 local @@ -275,6 +297,9 @@ export async function mount(root) { const ignoreBackdrop = root.querySelector(".ignore-editor-backdrop"); const ignoreSourceEl = root.querySelector("#ignore-editor-source"); const ignoreTextarea = root.querySelector("#ignore-editor-textarea"); + /* Phase 121: the editor's masked token field — blank = keep the + current token (the PATCH omits the key, the tri-state no-change). */ + const ignoreTokenInput = root.querySelector("#ignore-editor-token"); const ignoreErrorEl = root.querySelector("#ignore-editor-error"); const ignoreCancelBtn = root.querySelector("#ignore-editor-cancel"); const ignoreSaveBtn = root.querySelector("#ignore-editor-save"); @@ -392,6 +417,7 @@ export async function mount(root) { if (s.id) tr.dataset.id = s.id; const isLocal = s.kind === "local"; + // Phase 121: URLs arrive sanitized server-side — the UI must never re-embed a credential. const value = isLocal ? (s.path ?? s.url) : s.url; const kindLabel = isLocal ? "local" : "git"; @@ -696,6 +722,10 @@ export async function mount(root) { // The same `value` expression makeRow uses — textContent ONLY. if (ignoreSourceEl) ignoreSourceEl.textContent = isLocal ? s.path ?? s.url : s.url; ignoreTextarea.value = (s.ignore_paths || []).join("\n"); + // Phase 121: the token field always opens BLANK — the API has no + // token field to prefill from (LOCKED A2); blank = the PATCH + // omits the key (no change — the stored token is kept). + if (ignoreTokenInput) ignoreTokenInput.value = ""; if (ignoreErrorEl) { ignoreErrorEl.textContent = ""; ignoreErrorEl.hidden = true; // a new attempt starts clean @@ -719,6 +749,7 @@ export async function mount(root) { ignoreDialog.hidden = true; ignoreInFlight = false; if (ignoreTextarea) ignoreTextarea.value = ""; + if (ignoreTokenInput) ignoreTokenInput.value = ""; // phase 121: re-opens blank if (ignoreErrorEl) { ignoreErrorEl.textContent = ""; ignoreErrorEl.hidden = true; @@ -759,6 +790,10 @@ export async function mount(root) { // separator, not an entry (trim + drop empty; the server still // rejects empties defensively, A4). const lines = ignoreTextarea.value.split("\n").map((l) => l.trim()).filter(Boolean); + // Phase 121: the masked token — BLANK = the key is omitted from + // the PATCH (the tri-state: absent = no change, the row's stored + // credential is kept); non-blank replaces it. + const token = ignoreTokenInput ? ignoreTokenInput.value.trim() : ""; const t = ignoreTarget; const value = t.kind === "local" ? (t.path ?? t.url) : t.url; ignoreInFlight = true; @@ -775,7 +810,7 @@ export async function mount(root) { const r = await fetch(`/api/git-sources/${encodeURIComponent(t.id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ignore_paths: lines }), + body: JSON.stringify({ ignore_paths: lines, ...(token ? { token } : {}) }), }); if (r.ok) { // 200: the server replaced the row's list (A5). @@ -882,7 +917,7 @@ export async function mount(root) { * 409/422 details are fixed generic strings (credential safety — the * URL is never echoed). */ function wireAddForm(opts) { - const { form, input, btn, error } = opts; + const { form, input, btn, error, tokenInput } = opts; if (!form || !input || !btn) return; form.addEventListener("submit", async (e) => { e.preventDefault(); @@ -900,10 +935,13 @@ export async function mount(root) { btn.disabled = true; // §7.4: one POST per click btn.textContent = "Adding…"; try { + // Phase 121: the masked token rides the same POST — blank = + // the key is omitted (None = no credential; LOCKED A2). + const token = tokenInput ? tokenInput.value.trim() : ""; const r = await fetch("/api/git-sources", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(opts.body(value)), + body: JSON.stringify(opts.body(value, token)), }); if (r.ok) { let createdId = null; @@ -913,6 +951,7 @@ export async function mount(root) { /* the 201 body is advisory — the reload is the truth */ } input.value = ""; // 201: the source is stored + if (tokenInput) tokenInput.value = ""; // the credential is stored (write-only) announce(opts.addedMessage); await loadSources(); // the new row lands in the table focusNewRow(createdId); // a11y: land the caret on the new row @@ -940,9 +979,12 @@ export async function mount(root) { wireAddForm({ form: formEl, input: urlInput, + tokenInput, btn: addBtn, error: addError, - body: (url) => ({ url }), + // Phase 121: the masked token is included ONLY when non-blank + // (blank = key omitted = no credential — the API's tri-state). + body: (url, token) => ({ url, ...(token ? { token } : {}) }), emptyMessage: "Enter a git URL to add.", failMessage: "Could not add the git source — try again.", networkMessage: "Could not add the git source — is the app reachable?", diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 46e2e42..d11a220 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -2386,7 +2386,16 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } #archive-upload-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); } #git-source-form > label, #archive-upload-form > label { color: var(--ink); font-weight: 600; white-space: nowrap; } -#git-source-url { +/* Phase 121 (task 03): the form-label "optional" hint — the muted + ink-soft pair (5.1:1 on the label's surface, AA) at the label's + 600 weight relaxed to 400 so the hint reads as secondary (it + qualifies the name, it is not the name); small, inline. */ +.field-hint { color: var(--ink-soft); font-weight: 400; font-size: 0.8rem; } +/* #git-source-token (phase 121): the optional masked private-repo + credential — the URL input's treatment VERBATIM (mono, >=44px + target, brand focus); type=password masks the value in display. */ +#git-source-url, +#git-source-token { flex: 1; min-width: 14rem; min-height: 44px; @@ -2398,8 +2407,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } border-radius: var(--radius-sm); padding: 0.45rem 0.7rem; } -#git-source-url::placeholder { color: var(--ink-soft); } -#git-source-url:focus-visible { outline-offset: 0; border-color: var(--brand); } +#git-source-url::placeholder, +#git-source-token::placeholder { color: var(--ink-soft); } +#git-source-url:focus-visible, +#git-source-token:focus-visible { outline-offset: 0; border-color: var(--brand); } #git-source-add, #archive-upload-btn { display: inline-flex; @@ -2802,6 +2813,26 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } resize: vertical; } +/* Phase 121 (task 03): the editor's masked token field — the + textarea's box language (full panel width, --bg fill, the line + border, mono) at the 44px touch floor; the label's top margin keeps + the field pair off the textarea. type=password + autocomplete=off + live in the markup (a PAT is not a site credential — no browser + save offer); the focus ring is the global 3px outline rule. */ +.ignore-editor-token-label { margin-top: 0.9rem; } +.ignore-editor-token { + display: block; + width: 100%; + min-height: 44px; + padding: 0.55rem 0.65rem; + font-family: var(--mono); + font-size: 0.85rem; + color: var(--ink); + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius-sm); +} + /* The in-dialog failure line (role=alert): the err pair (err-ink on err-bg 9.3:1, the err-line border) — the .remove-confirm-error language. */ @@ -4943,6 +4974,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } #git-source-form > label, #archive-upload-form > label { white-space: normal; } #git-source-url, + #git-source-token, #archive-upload-file { min-width: 0; } #git-source-add, #archive-upload-btn { width: 100%; } diff --git a/frontend/index.html b/frontend/index.html index 0f9b61d..fe988dd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -625,10 +625,11 @@ to the database.

-
@@ -641,6 +642,22 @@ placeholder="https://github.com/you/your-repo.git" required > + + +
@@ -784,12 +801,16 @@ while the PATCH is out). Stored rows only (A3) — the per-row button lives in git-sources.js's makeRow; this is the page-local dialog it opens. --> +