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
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:
@@ -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 ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user