Protocol B append: failed-turn retry (L3–4), git source tokens (L5), image documents (L6 ingest), chat image questions (L6 chat side). TODO.md items now live in .agents/phases/todo/ and the file is cleared. LLM-Generated: true
8.9 KiB
Phase 121 — Private git sources: a token that never reaches the UI or the API
Source: TODO.md L5 — "Need a way to add private repos without exposing the token in the UI (like when adding an https repo https://myuser:ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/myuser/my-private-repo.git)"
Story: n/a (feature request; extends the phase-28/35/38 git/local sources and phase-89 per-source settings assets).
Context: app/models.py:245 — GitSource: url: Text UNIQUE NOT NULL, kind ("git"/"local"), path, ignore_paths (JSONB), include_hidden, added_at — no token column. app/schemas.py — GitSourceIn (L561: kind, url min 1/max 500, path, ignore_paths, include_hidden; _trim_url before-validator L594), GitSourceOut (L605: url: str), GitSourceRow (L626: url), GitSourcePatchIn (L651). app/api/git_sources.py — URL_RE = ^(?:https?://|ssh://|git@) (L195; a prefix match, so user:token@ URLs pass); POST validates the prefix (L344–345) and duplicates via GitSource.url == url (L349); GET list / GET single return row.url RAW (L235, L250, L297, L423) — an embedded token is echoed to any browser (the leak); the local-source upload endpoint (L432). app/api/sync.py:296 — clone_or_pull(row.url, sources_root / repo_name(row.url)); scripts/git_sync.py — clone_or_pull, repo_name; scripts/import_docs.py::_resolve_sources — the CLI's second clone caller (both consume app.rag.git_sources.effective_sources, L27). frontend/assets/git-sources.js — add form #git-source-url (L241; submit body: (url) => ({ url }) L945); every display site renders s.url raw (list cell L395–406 incl. the title attr, delete row L533, edit modal L697, ignore-list context L763/L830). alembic/ — migrations.
Objective
Private repos are added with a bare URL plus an optional MASKED token field. The token lives in a dedicated DB column, is injected only into the clone URL at sync/clone time, and is absent from every API response and UI surface — including legacy rows that already embed the token in url (those are sanitized on output but keep working).
Dependencies
120_failed_turn_retry(todo) — pipeline predecessor (execution order) only; no code dependency.- Code dependencies (all complete): phase 35/38
GitSourcekinds +effective_sources, phase 89 per-source settings (the PATCH-field precedent), phase 28clone_or_pull/repo_name.
Design (shared by all tasks — the executor reads this, not the chat)
- Storage (task 01, LOCKED A2):
GitSource.token—Text NULL(NULL = public/no credential; plaintext by necessity — the repo must remain cloneable, so the raw credential must be recoverable; the Postgres DB is the trusted store and is never served to the UI; there is deliberately no external secrets backend).GitSourceIn.token: str | None = None(max 500, trimmed);GitSourcePatchIn.token: str | None = None— PATCH semantics: absent/None = no change, non-empty = replace, empty string = clear (the UI offers replace; clear exists for API completeness).GitSourceOut/GitSourceRowgain NO token field — no response shape ever carries it (LOCKED A2). - Normalization (task 02): on POST (and PATCH when a new url/token arrives), the server normalizes: if the incoming URL contains userinfo (
user:pass@host, only forhttps?://URLs — ssh/git@carry no userinfo), the userinfo is stripped for storage and the embedded credential is moved intotoken— UNLESS the caller also sent an explicittokenfield, which WINS (explicit beats embedded). Pasting the old-stylehttps://user:ghp_…@github.com/x/y.gitURL still works and ends up token-column-clean. The duplicate check (L349) runs on the NORMALIZED bare URL, so the same repo with a different token is still the same source (409, not a second row). - Clone-time credential (task 02):
clone_url_for(row) -> strinapp/rag/git_sources.py(next toeffective_sources):row.urlunchanged whentokenis NULL; otherwise injecthttps://x-access-token:<token>@<host>/<path>(https rows only — a token on a non-https row is a no-op with a warning log).repo_namekeeps operating on the barerow.url. Callers switch fromrow.urltoclone_url_for(row):app/api/sync.py:296andscripts/import_docs.py::_resolve_sources(both already import fromapp.rag.git_sources). - Output sanitization (task 02, LOCKED A2): every API surface that returns a git URL runs it through
sanitize_url(url)(new, inapp/rag/git_sources.py): strips the userinfo component (https://…@host/…→https://host/…), leaves ssh/git@/local paths untouched. Applied toGitSourceOut.url/GitSourceRow.urlconstruction (GET list L235/L297, GET single, theBOR_GIT_SOURCESenv fallback rows L250 — env rows can embed tokens too) and to any sync-status field echoing a repo URL (grep forurl=in the sync responses). Belt-and-braces for legacy embedded-token rows whose credential is NOT in thetokencolumn: their DB value is untouched (the clone still authenticates from the stored URL) but no API/UI output ever shows the credential. - UI (task 03): the add form gains a second field — a masked
<input type="password" id="git-source-token">, optional, labelled "Token (private repos)" with a visible "optional" hint; submit sends{ url, token }(token omitted when blank). The edit modal mirrors it with placeholder "leave blank to keep the current token" (blank → omit from PATCH = no change). Every display site keeps renderings.url— now bare by server sanitization, so list cells,titleattributes, the delete row, and the ignore-list context become token-free with no per-site change. No new CSS beyond reusing the existing form-field styles (the theme's input treatment). - NOT touched: local-kind sources (no URL credential), the
BOR_GIT_SOURCESenv parsing (its rows are sanitized on OUTPUT only), the upload endpoint, sync scheduling, and the Sources page layout.
Tasks
01_token_storage.md— migration +GitSource.token+ input schemas (GitSourceIn/GitSourcePatchIn); no token in any output shape.02_clone_url_and_sanitization.md— URL/token normalization on write,clone_url_forat clone time,sanitize_urlon every output.03_ui_token_field.md— masked token field in the add form + edit modal; display stayss.url(now bare).04_token_tests.md— unit + integration + isolated E2Etest_git_source_tokens.py.
Testing & Quality
- Unit:
tests/unit/test_git_source_token.py(new, task 04) —sanitize_url(https userinfo stripped, ssh/git@/local untouched, no-userinfo unchanged),clone_url_for(NULL token → bare URL; token → injected; non-https token → bare + no crash), normalization (embedded token moved to the column when no explicit token; explicit token wins; duplicate on bare URL). - Integration:
tests/integration/test_git_sources_api.py(extend) — POST withtoken→ GET list/single responses contain the token NOWHERE (assert on the raw JSON text) and show the bare URL; POST with an old-style embedded-token URL → stored bare + token column populated, responses clean; PATCH token replace/clear semantics; the sync flow builds the clone URL with the injected token (mockclone_or_pull). - E2E:
tests/e2e/test_git_source_tokens.py(new, task 04) — isolated run per AGENTS.md §4: add a private repo through the Sources UI (bare URL + token) → the list row shows the bare URL, the token is absent from the page text, thetitleattribute, andGET /api/git-sourcesJSON; edit the row (blank token) → no 4xx, token kept. - Coverage: >90% on
app/(validate.sh gate).
Completion Criteria
- A private repo added via the UI (or a pasted embedded-token URL) syncs/clones fine, and its token appears in NO API response, NO page text, and NO attribute.
- Legacy embedded-token rows (pre-phase) still clone, and their API/UI output is token-free.
- Public repos and local sources behave byte-identically to before.
uv run pytestgreen;uv run pytest --cov=app --cov-report=term-missingTOTAL >90%;uv run ruff check . && uv run pyrightclean.- One
--no-gpg-signcommit; phase dir moved to.agents/phases/complete/by the pipeline gate.
Locked decisions
- A2 — the token is stored PLAINTEXT in a dedicated
GitSource.tokencolumn (cloneability requires the raw credential; no external secrets backend), is NEVER returned by any API shape, and legacy embedded-token URLs are sanitized on output while keeping their stored value for clones (owner-confirmed 2026-09-24, roadmap confirmation). - A6 — pasting an old-style embedded-token URL is accepted and normalized (userinfo →
tokencolumn); an explicittokenfield wins over an embedded one (owner-confirmed: same confirmation — the proposed design).
Commit
git add app/ alembic/ frontend/ scripts/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(sources): add private git repos with a masked token that never reaches the UI or API"