feat(forge): per-user forge connections — keyring, host-keyed resolution, project pin (#2778)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s

A forge token is a user's credential, not an instance's. The single
admin-settings config is replaced by per-user keyring rows (one per forge
host), and every server-side forge read runs on the PROJECT OWNER's keyring:

- forge_connections table + projects.forge_connection_id pin (migration 0078,
  which also carries the existing admin config into the first admin's row and
  deletes the old setting keys — no legacy dual-read)
- get_forge() replaced by get_forges(owner_id, project_id) -> ForgeSelector;
  resolve(repo) picks the connection whose host serves the repo. A pinned
  project uses ONLY its pinned connection; a stale pin (ownership moved) is
  ignored, never honored across users
- env FORGE_* config survives as an implicit entry for admin owners only;
  a stored row for the same host beats it
- consumers threaded: pull-time freshness (owner of the note), coverage
  (owner of the project), coverage routes' configured flag
- routes: /api/settings/forge-connections CRUD + per-connection test
  (own-rows only, tokens never returned); /api/admin/forge shrinks to
  /api/admin/forge-webhook (secret only); PUT /api/projects/<id>/forge pins,
  owner-or-admin asking, owner's connections only
- UI: Git Forges card moves to Settings -> Integrations as a connection
  list; webhook secret stays in the admin Config tab; owner-only forge
  select on the project coverage card
- backups exclude forge_connections (credentials, api_keys precedent) and
  the pin, so restores fall back to keyring resolution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 11:23:22 -04:00
co-authored by Claude Fable 5
parent 7a5e2b18d9
commit 1faf8f3ece
19 changed files with 1252 additions and 310 deletions
+18 -13
View File
@@ -941,10 +941,10 @@ async def record_verification(
# --- pull-time freshness (#2690) ---------------------------------------------
# A pull is the moment freshness matters: the reader is about to trust the
# cached body. When the instance has a forge configured, the pull fetches the
# recorded file and answers the one mechanically-answerable question — does
# the cached code still appear in the source, verbatim after whitespace
# normalization? The body is a FRAGMENT of the file, so "serve the fetched
# cached body. When the record owner's keyring serves a forge, the pull
# fetches the recorded file and answers the one mechanically-answerable
# question — does the cached code still appear in the source, verbatim after
# whitespace normalization? The body is a FRAGMENT of the file, so "serve the fetched
# file" would clobber the record; confirmation + provenance refresh is what
# fetching can honestly deliver, and divergence is reported, not overwritten.
#
@@ -990,7 +990,8 @@ async def _refresh_provenance(note, commit_sha: str) -> None:
async def attach_live_body(note, data: dict) -> None:
"""Decorate a PULL response with forge-checked freshness (#2690).
Adds, when (and only when) a forge is configured:
Adds, when (and only when) the record owner's keyring serves a forge
(#2778):
- ``body_source``: "forge" (confirmed against the source just now) or
"cache" (the stored body, for whatever reason follows)
- ``body_freshness``: "current" | "diverged" | "missing" |
@@ -1004,14 +1005,17 @@ async def attach_live_body(note, data: dict) -> None:
attention state verify_snippet uses.
"""
from scribe.services.background import spawn
from scribe.services.forge import ForgeError, ForgeNotFound, get_forge
from scribe.services.forge import ForgeError, ForgeNotFound, get_forges
try:
forge = await get_forge()
# The OWNER's keyring, honoring the project pin (#2778) — freshness
# for a record is checked with its owner's credential, never the
# reader's.
selector = await get_forges(note.user_id, getattr(note, "project_id", None))
except Exception:
logger.warning("forge lookup failed during pull", exc_info=True)
return
if forge is None:
if not selector.configured:
return
fields = data.get("snippet") if isinstance(data.get("snippet"), dict) else None
@@ -1033,18 +1037,19 @@ async def attach_live_body(note, data: dict) -> None:
# address a forge API — the project's repo BINDING is the identity that
# can (#2691). Try the location string first (it may be a real remote),
# then fall back to the bindings of the snippet's project.
repo = forge.resolve_repo(loc["repo"])
if repo is None and getattr(note, "project_id", None):
resolved = selector.resolve(loc["repo"])
if resolved is None and getattr(note, "project_id", None):
from scribe.services.repo_bindings import keys_for_project
for key in await keys_for_project(note.user_id, note.project_id):
repo = forge.resolve_repo(key)
if repo is not None:
resolved = selector.resolve(key)
if resolved is not None:
break
if repo is None:
if resolved is None:
data["body_source"] = "cache"
data["body_freshness"] = "repo-not-on-this-forge"
return
forge, repo = resolved
stored_prov_sha = (fields.get("provenance") or {}).get("commit_sha") or ""