Files
rune/scripts/gitea_auth.py
Nick 4eefc15837 fix(lifecycle+auth): canonical gitea auth path, terminalization helper, fault-injection gaps (T15 Parts A-E)
- scripts/gitea_auth.py: single credential ladder (env -> 0600 token file ->
  git credential fill); 6 consumers migrated off direct os.environ reads
- rune_operator: consolidate 5 duplicated failure blocks into
  _terminalize_execution_failure; fix 3 fault-injection gaps:
  (1) dual-module PreparedTool identity split crashed approval-path saves,
  (2) fallback-model failure escaped send_task (created/pending orphan),
  (3) running-transition save uncaught (running-with-no-owner)
- envelope task.status now agrees with canonical ledger (awaiting_approval)
- orphan task_ac35f4e287c8 terminalized failed with durable evidence
- tests: gitea auth ladder (6), lifecycle fault matrix (9 boundaries +
  restart/reload + helper contract); 1280 green in ci-venv; ratchet 352/80
2026-09-11 20:15:57 +01:00

113 lines
3.6 KiB
Python

"""gitea_auth.py — canonical Gitea API authentication (handoff 2026-09-11 §H).
One deterministic helper for authenticated Gitea API access. Never prints
credentials. Fails clearly when unavailable. Credential ladder:
1. GITEA_TOKEN environment variable (preferred for CI/deployment)
2. private token file (GITEA_TOKEN_FILE, default
~/.config/opencode/secrets/gitea-token, must be mode 0600)
3. git credential fill (uses the credential helper / store)
Never parses credentials from git remote URLs.
"""
from __future__ import annotations
import json
import os
import stat
import subprocess
import urllib.request
from pathlib import Path
from typing import Any
DEFAULT_SERVER = "https://gitea.thingscouldgetdicey.online"
DEFAULT_REPO = "conductor/rune"
DEFAULT_TOKEN_FILE = Path("~/.config/opencode/secrets/gitea-token").expanduser()
GITEA_HOST = "gitea.thingscouldgetdicey.online"
class GiteaCredentialError(RuntimeError):
"""Raised when no Gitea credential is available. Message never contains one."""
def _token_from_file() -> str:
path = Path(os.environ.get("GITEA_TOKEN_FILE", DEFAULT_TOKEN_FILE)).expanduser()
if not path.is_file():
return ""
mode = stat.S_IMODE(path.stat().st_mode)
if mode & 0o077:
raise PermissionError(f"Gitea token file must be mode 0600: {path}")
return path.read_text(encoding="utf-8").strip()
def _token_from_credential_helper() -> str:
try:
result = subprocess.run(
["git", "credential", "fill"],
input=f"protocol=https\nhost={GITEA_HOST}\nusername=conductor\n\n",
capture_output=True,
text=True,
timeout=10,
)
for line in result.stdout.splitlines():
if line.startswith("password="):
return line[len("password=") :]
except (subprocess.TimeoutExpired, OSError):
pass
return ""
def has_token() -> bool:
"""True if any rung of the credential ladder answers. Never raises."""
try:
get_token()
return True
except Exception: # noqa: BLE001 — probe-only: any failure means "no credential available"
return False
def get_token() -> str:
"""Obtain the Gitea API token. Never prints the token."""
token = os.environ.get("GITEA_TOKEN", "").strip()
if token:
return token
token = _token_from_file()
if token:
return token
token = _token_from_credential_helper()
if token:
return token
raise GiteaCredentialError(
"Gitea credentials unavailable: set GITEA_TOKEN, create a mode-0600 "
f"token file at {DEFAULT_TOKEN_FILE}, or configure a git credential "
f"helper for {GITEA_HOST}"
)
def api(
path: str,
method: str = "GET",
data: dict | None = None,
server: str = DEFAULT_SERVER,
repo: str = DEFAULT_REPO,
) -> tuple[int, Any]:
"""Make an authenticated Gitea API request. Returns (status_code, parsed_body)."""
token = get_token()
url = f"{server}/api/v1/repos/{repo}{path}"
req = urllib.request.Request(
url,
data=json.dumps(data).encode() if data is not None else None,
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
body, status = r.read(), r.status
except urllib.error.HTTPError as e:
body, status = e.read(), e.code
try:
return status, (json.loads(body) if body else {})
except json.JSONDecodeError:
return status, {"_raw": body.decode("utf-8", "replace")[:200]}