From 0f77e9a8764cb44d165ba7424a6c253d3e4f11e1 Mon Sep 17 00:00:00 2001
From: ducoterra
Date: Thu, 24 Sep 2026 20:51:39 -0400
Subject: [PATCH] phase: 121_git_source_tokens
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**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).
---
.../121_git_source_tokens/00_phase.md | 0
.../121_git_source_tokens/01_token_storage.md | 0
.../02_clone_url_and_sanitization.md | 0
.../03_ui_token_field.md | 0
.../121_git_source_tokens/04_token_tests.md | 0
.../121_git_source_tokens__00_phase.a1.err | 0
.../121_git_source_tokens__00_phase.a1.md | 15 +
...21_git_source_tokens__00_phase.a1.validate | 105 +++
...git_source_tokens__01_token_storage.a1.err | 0
..._git_source_tokens__01_token_storage.a1.md | 19 +
...ource_tokens__01_token_storage.a1.validate | 105 +++
...kens__02_clone_url_and_sanitization.a1.err | 0
...okens__02_clone_url_and_sanitization.a1.md | 17 +
..._02_clone_url_and_sanitization.a1.validate | 105 +++
...it_source_tokens__03_ui_token_field.a1.err | 0
...git_source_tokens__03_ui_token_field.a1.md | 17 +
...urce_tokens__03_ui_token_field.a1.validate | 105 +++
...1_git_source_tokens__04_token_tests.a1.err | 0
...21_git_source_tokens__04_token_tests.a1.md | 15 +
..._source_tokens__04_token_tests.a1.validate | 105 +++
alembic/versions/0021_git_source_token.py | 47 ++
app/api/git_sources.py | 97 ++-
app/api/sync.py | 18 +-
app/models.py | 10 +
app/rag/git_sources.py | 120 ++++
app/schemas.py | 48 +-
frontend/assets/git-sources.js | 50 +-
frontend/assets/styles.css | 38 +-
frontend/index.html | 42 +-
scripts/import_docs.py | 21 +-
tests/e2e/test_git_source_tokens.py | 429 ++++++++++++
tests/integration/test_git_sources_api.py | 472 +++++++++++++
tests/integration/test_migration_0021.py | 280 ++++++++
tests/unit/test_git_source_token.py | 641 ++++++++++++++++++
tests/unit/test_source_ignore_paths.py | 21 +-
35 files changed, 2894 insertions(+), 48 deletions(-)
rename .agents/phases/{todo => complete}/121_git_source_tokens/00_phase.md (100%)
rename .agents/phases/{todo => complete}/121_git_source_tokens/01_token_storage.md (100%)
rename .agents/phases/{todo => complete}/121_git_source_tokens/02_clone_url_and_sanitization.md (100%)
rename .agents/phases/{todo => complete}/121_git_source_tokens/03_ui_token_field.md (100%)
rename .agents/phases/{todo => complete}/121_git_source_tokens/04_token_tests.md (100%)
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__00_phase.a1.err
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__00_phase.a1.md
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__00_phase.a1.validate
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__01_token_storage.a1.err
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__01_token_storage.a1.md
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__01_token_storage.a1.validate
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__02_clone_url_and_sanitization.a1.err
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__02_clone_url_and_sanitization.a1.md
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__02_clone_url_and_sanitization.a1.validate
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__03_ui_token_field.a1.err
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__03_ui_token_field.a1.md
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__03_ui_token_field.a1.validate
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__04_token_tests.a1.err
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__04_token_tests.a1.md
create mode 100644 .agents/reports/121_git_source_tokens/121_git_source_tokens__04_token_tests.a1.validate
create mode 100644 alembic/versions/0021_git_source_token.py
create mode 100644 tests/e2e/test_git_source_tokens.py
create mode 100644 tests/integration/test_migration_0021.py
create mode 100644 tests/unit/test_git_source_token.py
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.
-
@@ -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. -->
+
-
Ignored files and folders
+
Source settings
One path per line. A file is ignored when its path in the
@@ -801,6 +822,17 @@
+
+
+