feat(phase-2): forge integration — credentials, role skills, approval gate, CI poller

Phase 2 configures forge for the agents (operator only sets a credential_ref +
optional version pin) and lets them drive a junior→senior→deploy workflow:

- Credential resolution/injection (control/credentials.py): credential_ref pointers
  (env:/file:/cmd:) resolved only at spawn, injected as FORGE_TOKEN + host var, with a
  forge-host-scoped git credential helper reading the token from env (never on disk / in
  the DB). Resolution is a fail-fast spawn gate.
- Role-based forge skills committed into the managed repo (control/skills_gen.py,
  `handler forge-init`): forge-junior/senior/deploy + a workflow overview.
- Hard approval gate (hooks/gate.py, approvals table, migration 0002): merge/deploy —
  and direct pushes to protected branches — are denied unless a DIFFERENT agent has an
  `approved` record for the branch, pinned to the reviewed commit (approved_sha). Senior
  records verdicts via `handler approve`/`reject`.
- forge/git seams (control/forge.py, control/gitops.py) matching the Phase 1 seam pattern.
- CI status poller (control/poller.py, `handler poll-ci [--watch]`) backfilling
  ci_status/ci_checked_at via `forge ci list`.
- Fix: migrations/env.py commits explicitly after run_migrations — pysqlite on Py 3.12+
  was rolling back the final migration's DDL + alembic_version stamp (latent in Phase 1).

Reviewed via a separate code-reviewer pass; gate-bypass and credential-scoping findings
addressed. 106 tests, ruff clean, verified end-to-end against real git + migrations.
This commit is contained in:
2026-07-08 22:05:49 -04:00
parent 30e0e51e5b
commit 6fb26115ce
25 changed files with 1792 additions and 40 deletions
+92
View File
@@ -0,0 +1,92 @@
"""Phase 2 spawn wiring: credential injection, git helper, forge version note, role."""
from __future__ import annotations
import pytest
from handler.control import spawn
from handler.db import repository as repo
from handler.db.engine import get_engine
def _write_mise(root):
root.mkdir(parents=True, exist_ok=True)
(root / ".mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
def _register(root, **kw):
with get_engine().begin() as conn:
repo.create_project(conn, "proj", str(root), **kw)
def test_spawn_injects_credentials_and_installs_helper(env, fake_tmux, fake_gitops, monkeypatch):
monkeypatch.setenv("PROJ_TOKEN", "s3cret")
root = env["tmp"] / "proj"
_write_mise(root)
_register(root, git_remote="https://github.com/me/proj.git", credential_ref="env:PROJ_TOKEN")
spawn.spawn("proj", "junior", role="junior")
call = fake_tmux["calls"]["new_session"][0]
# Token injected under the generic + host-specific names, never the raw ref stored.
assert call["env"]["FORGE_TOKEN"] == "s3cret"
assert call["env"]["GITHUB_TOKEN"] == "s3cret"
assert call["env"]["HANDLER_AGENT_ROLE"] == "junior"
# Git credential helper installed, scoped to the forge host (not global).
helper = [c for c in fake_gitops["config"] if c["key"].endswith(".helper")]
assert helper and helper[0]["key"] == "credential.https://github.com.helper"
assert "$FORGE_TOKEN" in helper[0]["value"]
def test_spawn_ssh_remote_installs_no_https_helper(env, fake_tmux, fake_gitops, monkeypatch):
monkeypatch.setenv("PROJ_TOKEN", "s3cret")
root = env["tmp"] / "proj"
_write_mise(root)
_register(root, git_remote="git@github.com:me/proj.git", credential_ref="env:PROJ_TOKEN")
spawn.spawn("proj", "junior", role="junior")
# ssh remote -> token still injected, but no HTTPS credential helper installed.
assert fake_tmux["calls"]["new_session"][0]["env"]["GITHUB_TOKEN"] == "s3cret"
assert fake_gitops["config"] == []
def test_spawn_fails_fast_on_broken_credential_ref(env, fake_tmux, fake_gitops, monkeypatch):
monkeypatch.delenv("ABSENT_TOKEN", raising=False)
root = env["tmp"] / "proj"
_write_mise(root)
_register(root, credential_ref="env:ABSENT_TOKEN")
with pytest.raises(spawn.SpawnError, match="not set"):
spawn.spawn("proj", "junior", role="junior")
# No agent row and no session left behind by the failed spawn.
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "proj", "junior") is None
assert fake_tmux["calls"]["new_session"] == []
def test_spawn_without_credential_ref_injects_no_token(env, fake_tmux, fake_gitops):
root = env["tmp"] / "proj"
_write_mise(root)
_register(root)
spawn.spawn("proj", "api")
call = fake_tmux["calls"]["new_session"][0]
assert "FORGE_TOKEN" not in call["env"]
# No token -> no credential helper installed.
assert fake_gitops["config"] == []
def test_spawn_reports_forge_version_mismatch(env, fake_tmux, fake_gitops, fake_forge, monkeypatch):
monkeypatch.setenv("FORGE_VERSION", "9.9.9")
from handler import config
from handler.db import engine
config.get_settings.cache_clear()
engine.get_engine.cache_clear()
root = env["tmp"] / "proj"
_write_mise(root)
_register(root)
fake_forge["version_ok"] = False
fake_forge["version_out"] = "forge 1.2.3"
agent = spawn.spawn("proj", "api")
assert "9.9.9" in agent["forge_note"]