From 1872f80982226760a7935e26f8736a5fdde934aa Mon Sep 17 00:00:00 2001 From: Andrii Pasternak Date: Sun, 19 Apr 2026 00:49:59 +0100 Subject: [PATCH 1/2] feat(settings): auto-propagate GitHub PAT to running agents (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the global GitHub PAT is updated in Settings, push the new value into each running agent's .env so agents pick it up without a restart. Reuses the existing agent-side /api/credentials/read + /api/credentials/inject endpoints — no new agent-server routes. Per-agent PATs (#347) and agents that never had GITHUB_PAT in .env are skipped. Delete PAT does NOT propagate. Partial failures are reported per agent and never block the PAT save. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../memory/feature-flows/platform-settings.md | 11 + src/backend/models.py | 20 ++ src/backend/routers/settings.py | 16 +- .../github_pat_propagation_service.py | 166 ++++++++++ src/frontend/src/views/Settings.vue | 28 ++ tests/test_github_pat_propagation_unit.py | 297 ++++++++++++++++++ 6 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 src/backend/services/github_pat_propagation_service.py create mode 100644 tests/test_github_pat_propagation_unit.py diff --git a/docs/memory/feature-flows/platform-settings.md b/docs/memory/feature-flows/platform-settings.md index 8fced1662..c6158e751 100644 --- a/docs/memory/feature-flows/platform-settings.md +++ b/docs/memory/feature-flows/platform-settings.md @@ -111,9 +111,20 @@ async function saveGithubPat() { masked: response.data.masked, source: 'settings' } + // #211: backend auto-propagates the new PAT to running agents. + // response.data.propagation = {total_running, updated, skipped, failed} + githubPatPropagation.value = response.data.propagation || null } ``` +**Auto-Propagation on PAT Update (#211)** — When the global PAT is saved, the +backend reuses the agent-side `/api/credentials/read` + `/api/credentials/inject` +endpoints (via `services/github_pat_propagation_service.py`) to merge the new +`GITHUB_PAT` into each running agent's `.env` without a restart. Agents with a +per-agent PAT (#347) are skipped; agents whose `.env` never had `GITHUB_PAT` +are skipped. Delete PAT does NOT propagate. Partial failures are reported per +agent and never block the PAT save. + **Ops Settings Load** (Settings.vue lines 820-829) ```javascript async function loadOpsSettings() { diff --git a/src/backend/models.py b/src/backend/models.py index 1a2847e22..d8078d678 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -386,3 +386,23 @@ class CredentialImportResponse(BaseModel): class InternalDecryptInjectRequest(BaseModel): """Request for internal decrypt-and-inject (startup.sh).""" agent_name: str + + +# ============================================================================ +# GitHub PAT Propagation Models (#211) +# ============================================================================ + +class AgentPropagationStatus(BaseModel): + """Per-agent result when propagating the global GitHub PAT.""" + agent_name: str + # "updated", "skipped_per_agent_pat", "skipped_no_pat", "failed" + status: str + error: Optional[str] = None + + +class GithubPatPropagationResult(BaseModel): + """Aggregate result of a GitHub PAT propagation run.""" + total_running: int + updated: List[str] + skipped: List[AgentPropagationStatus] + failed: List[AgentPropagationStatus] diff --git a/src/backend/routers/settings.py b/src/backend/routers/settings.py index b201c5043..0d5d4636f 100644 --- a/src/backend/routers/settings.py +++ b/src/backend/routers/settings.py @@ -275,7 +275,8 @@ async def update_github_pat( """ Set or update the GitHub Personal Access Token. - Admin-only. Token is stored in system settings. + Admin-only. Token is stored in system settings and auto-propagated to all + running agents that currently use the global PAT (#211). """ require_admin(current_user) @@ -291,9 +292,20 @@ async def update_github_pat( # Store in settings db.set_setting('github_pat', key) + # Auto-propagate to running agents (#211). Never block the PAT save on + # propagation failures — the token is already persisted. + from services.github_pat_propagation_service import propagate_github_pat + try: + propagation = await propagate_github_pat(key) + propagation_payload: Dict[str, Any] = propagation.model_dump() + except Exception as e: + logger.exception("GitHub PAT propagation failed") + propagation_payload = {"error": f"Propagation failed: {str(e)}"} + return { "success": True, - "masked": mask_api_key(key) + "masked": mask_api_key(key), + "propagation": propagation_payload, } except HTTPException: raise diff --git a/src/backend/services/github_pat_propagation_service.py b/src/backend/services/github_pat_propagation_service.py new file mode 100644 index 000000000..6cdb08ea8 --- /dev/null +++ b/src/backend/services/github_pat_propagation_service.py @@ -0,0 +1,166 @@ +""" +GitHub PAT propagation service (#211). + +Pushes the global GitHub PAT to running agents' .env files when it is updated +in Settings, so agents pick up the new token without a restart. + +Eligibility rules: +- Agent container must be running. +- Agent must NOT have a per-agent PAT (#347) configured — those override the global + and are managed separately. +- Agent's current .env must already contain a GITHUB_PAT key. Agents that never + set up GitHub are skipped to avoid injecting unused credentials. +""" +import asyncio +import logging +import re +from typing import List + +import httpx + +from database import db +from models import AgentPropagationStatus, GithubPatPropagationResult +from services.docker_service import list_all_agents_fast + +logger = logging.getLogger(__name__) + +AGENT_HTTP_TIMEOUT_SECONDS = 30.0 + +# Matches a GITHUB_PAT line in an agent's .env, ignoring leading whitespace. +# Captures everything up to (and including) the newline so we can replace cleanly. +_GITHUB_PAT_LINE_RE = re.compile(r'(?m)^[ \t]*GITHUB_PAT=.*$') + + +def _format_pat_line(pat: str) -> str: + """Format a GITHUB_PAT line matching the agent's own .env writer. + + The agent writes credentials as `KEY="value"` with embedded double quotes + escaped (see docker/base-image/agent_server/routers/credentials.py). + """ + escaped = pat.replace('"', '\\"') + return f'GITHUB_PAT="{escaped}"' + + +def _patch_env_github_pat(env_content: str, new_pat: str) -> str: + """Return env_content with the GITHUB_PAT line replaced.""" + new_line = _format_pat_line(new_pat) + if _GITHUB_PAT_LINE_RE.search(env_content): + return _GITHUB_PAT_LINE_RE.sub(new_line, env_content, count=1) + # Caller should have filtered this case, but keep the behavior explicit. + suffix = "" if env_content.endswith("\n") else "\n" + return f"{env_content}{suffix}{new_line}\n" + + +def _env_has_github_pat(env_content: str) -> bool: + return bool(_GITHUB_PAT_LINE_RE.search(env_content)) + + +async def _propagate_to_agent( + agent_name: str, + new_pat: str, + client: httpx.AsyncClient, +) -> AgentPropagationStatus: + """Read .env from one agent, patch GITHUB_PAT, write it back.""" + base_url = f"http://agent-{agent_name}:8000" + try: + read_resp = await client.get( + f"{base_url}/api/credentials/read", + params={"paths": ".env"}, + timeout=AGENT_HTTP_TIMEOUT_SECONDS, + ) + read_resp.raise_for_status() + env_content = read_resp.json().get("files", {}).get(".env") + + if env_content is None: + return AgentPropagationStatus( + agent_name=agent_name, + status="skipped_no_pat", + ) + + if not _env_has_github_pat(env_content): + return AgentPropagationStatus( + agent_name=agent_name, + status="skipped_no_pat", + ) + + patched = _patch_env_github_pat(env_content, new_pat) + + inject_resp = await client.post( + f"{base_url}/api/credentials/inject", + json={"files": {".env": patched}}, + timeout=AGENT_HTTP_TIMEOUT_SECONDS, + ) + inject_resp.raise_for_status() + + return AgentPropagationStatus(agent_name=agent_name, status="updated") + + except httpx.HTTPStatusError as e: + error = f"agent returned {e.response.status_code}: {e.response.text[:200]}" + logger.warning("GITHUB_PAT propagation failed for %s: %s", agent_name, error) + return AgentPropagationStatus( + agent_name=agent_name, status="failed", error=error + ) + except httpx.RequestError as e: + error = f"connection error: {e}" + logger.warning("GITHUB_PAT propagation failed for %s: %s", agent_name, error) + return AgentPropagationStatus( + agent_name=agent_name, status="failed", error=error + ) + + +async def propagate_github_pat(new_pat: str) -> GithubPatPropagationResult: + """Propagate a new global GitHub PAT to all eligible running agents. + + Per-agent failures are captured in the result; they do not raise. + """ + running_agents = [a for a in list_all_agents_fast() if a.status == "running"] + + targets: List[str] = [] + pre_skipped: List[AgentPropagationStatus] = [] + + for agent in running_agents: + if db.has_agent_github_pat(agent.name): + pre_skipped.append( + AgentPropagationStatus( + agent_name=agent.name, status="skipped_per_agent_pat" + ) + ) + continue + targets.append(agent.name) + + updated: List[str] = [] + skipped: List[AgentPropagationStatus] = list(pre_skipped) + failed: List[AgentPropagationStatus] = [] + + if targets: + async with httpx.AsyncClient() as client: + results = await asyncio.gather( + *(_propagate_to_agent(name, new_pat, client) for name in targets), + return_exceptions=True, + ) + + for name, result in zip(targets, results): + if isinstance(result, BaseException): + logger.exception( + "Unexpected error propagating GITHUB_PAT to %s", name + ) + failed.append( + AgentPropagationStatus( + agent_name=name, status="failed", error=str(result) + ) + ) + continue + + if result.status == "updated": + updated.append(result.agent_name) + elif result.status == "failed": + failed.append(result) + else: + skipped.append(result) + + return GithubPatPropagationResult( + total_running=len(running_agents), + updated=updated, + skipped=skipped, + failed=failed, + ) diff --git a/src/frontend/src/views/Settings.vue b/src/frontend/src/views/Settings.vue index 50e1ede80..509e1c9b5 100644 --- a/src/frontend/src/views/Settings.vue +++ b/src/frontend/src/views/Settings.vue @@ -279,6 +279,30 @@ + +
+ + + +

Required for creating and pushing agents to GitHub repositories. Get your token at @@ -1612,6 +1636,7 @@ const githubPatStatus = ref({ masked: null, source: null }) +const githubPatPropagation = ref(null) // Slack Integration state (SLACK-001) const slackClientId = ref('') @@ -1865,6 +1890,9 @@ async function saveGithubPat() { source: 'settings' } + // Propagation result (#211): backend auto-pushes the new PAT to running agents + githubPatPropagation.value = response.data.propagation || null + // Clear input and show success githubPat.value = '' githubPatTestResult.value = null diff --git a/tests/test_github_pat_propagation_unit.py b/tests/test_github_pat_propagation_unit.py new file mode 100644 index 000000000..7673e42e7 --- /dev/null +++ b/tests/test_github_pat_propagation_unit.py @@ -0,0 +1,297 @@ +""" +GitHub PAT propagation service unit tests (#211). + +Tests the pure-logic helpers (env patching) and the orchestration function +`propagate_github_pat` with docker_service, database, and httpx all mocked. +No running backend required. +""" +import importlib.util +import os +import sys +import types +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# Add backend to path for direct imports of `models`. +_backend_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "backend") +) +if _backend_path not in sys.path: + sys.path.insert(0, _backend_path) + +# Stub utils.helpers (tests/utils shadows src/backend/utils in this env). +if "utils.helpers" not in sys.modules: + _helpers = types.ModuleType("utils.helpers") + _helpers.utc_now = lambda: datetime.utcnow() + _helpers.utc_now_iso = lambda: datetime.utcnow().isoformat() + "Z" + _helpers.to_utc_iso = lambda v: str(v) + _helpers.parse_iso_timestamp = lambda s: datetime.fromisoformat(s.rstrip("Z")) + sys.modules["utils.helpers"] = _helpers + + +# Stub heavy dependencies before importing the service under test so we don't +# need docker / redis / a live DB to import it. We bypass `services/__init__.py` +# entirely by loading the service module directly from its file path. +_fake_database = types.ModuleType("database") +_fake_database.db = MagicMock() +sys.modules.setdefault("database", _fake_database) + +_fake_services_pkg = types.ModuleType("services") +_fake_services_pkg.__path__ = [os.path.join(_backend_path, "services")] +sys.modules["services"] = _fake_services_pkg + +_fake_docker_service = types.ModuleType("services.docker_service") +_fake_docker_service.list_all_agents_fast = MagicMock(return_value=[]) +sys.modules["services.docker_service"] = _fake_docker_service + + +def _load_service(): + """Load the service module directly, bypassing services/__init__.py.""" + path = os.path.join( + _backend_path, "services", "github_pat_propagation_service.py" + ) + spec = importlib.util.spec_from_file_location( + "services.github_pat_propagation_service", path + ) + module = importlib.util.module_from_spec(spec) + sys.modules["services.github_pat_propagation_service"] = module + spec.loader.exec_module(module) + return module + + +# Override package-level fixtures that try to talk to a real backend. +@pytest.fixture(scope="session") +def api_client(): + yield None + + +@pytest.fixture(autouse=True) +def cleanup_after_test(): + yield + + +@pytest.fixture +def service(): + """Fresh service module with stubs in place.""" + if "services.github_pat_propagation_service" in sys.modules: + del sys.modules["services.github_pat_propagation_service"] + return _load_service() + + +# --------------------------------------------------------------------------- +# Pure helpers +# --------------------------------------------------------------------------- + + +def test_env_has_github_pat_detects_present(service): + env = 'FOO="bar"\nGITHUB_PAT="ghp_old"\nBAZ="qux"\n' + assert service._env_has_github_pat(env) is True + + +def test_env_has_github_pat_detects_absent(service): + env = 'FOO="bar"\nBAZ="qux"\n' + assert service._env_has_github_pat(env) is False + + +def test_patch_env_preserves_other_keys(service): + env = 'FOO="bar"\nGITHUB_PAT="ghp_old"\nBAZ="qux"\n' + result = service._patch_env_github_pat(env, "ghp_new") + assert 'FOO="bar"' in result + assert 'BAZ="qux"' in result + assert 'GITHUB_PAT="ghp_new"' in result + assert 'GITHUB_PAT="ghp_old"' not in result + + +def test_patch_env_escapes_embedded_quotes(service): + env = 'GITHUB_PAT="ghp_old"\n' + tricky = 'ghp_weird"quote' + result = service._patch_env_github_pat(env, tricky) + # Matches the escaping done by the agent's own .env writer. + assert r'GITHUB_PAT="ghp_weird\"quote"' in result + + +def test_patch_env_only_replaces_github_pat_line(service): + env = ( + 'SOME_GITHUB_PAT_LIKE="not-this"\n' + 'GITHUB_PAT="ghp_old"\n' + 'ANOTHER="keep"\n' + ) + result = service._patch_env_github_pat(env, "ghp_new") + assert 'SOME_GITHUB_PAT_LIKE="not-this"' in result + assert 'ANOTHER="keep"' in result + assert 'GITHUB_PAT="ghp_new"' in result + + +# --------------------------------------------------------------------------- +# propagate_github_pat orchestration +# --------------------------------------------------------------------------- + + +def _agent(name: str, status: str = "running"): + a = MagicMock() + a.name = name + a.status = status + return a + + +def _make_async_client(read_responses: dict, inject_responses: dict): + """Build an AsyncMock httpx.AsyncClient that routes per-URL.""" + + async def _get(url, params=None, timeout=None): + agent = url.split("http://agent-")[1].split(":")[0] + resp = read_responses[agent] + r = MagicMock() + r.raise_for_status = MagicMock() + r.json = MagicMock(return_value=resp) + return r + + async def _post(url, json=None, timeout=None): + agent = url.split("http://agent-")[1].split(":")[0] + behavior = inject_responses[agent] + if isinstance(behavior, Exception): + raise behavior + r = MagicMock() + r.raise_for_status = MagicMock() + r.json = MagicMock(return_value=behavior) + # Capture the payload for assertions. + r._sent_json = json + return r + + client_instance = AsyncMock() + client_instance.get = AsyncMock(side_effect=_get) + client_instance.post = AsyncMock(side_effect=_post) + + client_cm = MagicMock() + client_cm.__aenter__ = AsyncMock(return_value=client_instance) + client_cm.__aexit__ = AsyncMock(return_value=False) + return client_cm, client_instance + + +@pytest.mark.asyncio +async def test_skips_agent_with_per_agent_pat(service): + """Agents with a per-agent PAT configured must not be touched.""" + with patch.object(service, "list_all_agents_fast", return_value=[_agent("a1")]), \ + patch.object(service, "db") as mock_db: + mock_db.has_agent_github_pat.return_value = True + + result = await service.propagate_github_pat("ghp_new") + + assert result.total_running == 1 + assert result.updated == [] + assert len(result.skipped) == 1 + assert result.skipped[0].status == "skipped_per_agent_pat" + assert result.failed == [] + + +@pytest.mark.asyncio +async def test_skips_agent_without_github_pat_in_env(service): + """Agents whose .env does not contain GITHUB_PAT are skipped (AC).""" + with patch.object(service, "list_all_agents_fast", return_value=[_agent("a1")]), \ + patch.object(service, "db") as mock_db: + mock_db.has_agent_github_pat.return_value = False + + client_cm, _ = _make_async_client( + read_responses={"a1": {"files": {".env": 'OTHER="x"\n'}}}, + inject_responses={}, + ) + with patch("services.github_pat_propagation_service.httpx.AsyncClient", + return_value=client_cm): + result = await service.propagate_github_pat("ghp_new") + + assert result.updated == [] + assert any(s.status == "skipped_no_pat" and s.agent_name == "a1" + for s in result.skipped) + + +@pytest.mark.asyncio +async def test_updates_agent_and_preserves_other_keys(service): + """Happy path: env is merged, GITHUB_PAT replaced, other keys kept.""" + original_env = 'FOO="keep"\nGITHUB_PAT="ghp_old"\nBAR="also-keep"\n' + + with patch.object(service, "list_all_agents_fast", return_value=[_agent("a1")]), \ + patch.object(service, "db") as mock_db: + mock_db.has_agent_github_pat.return_value = False + + client_cm, client = _make_async_client( + read_responses={"a1": {"files": {".env": original_env}}}, + inject_responses={"a1": {"status": "success", "files_written": [".env"]}}, + ) + with patch("services.github_pat_propagation_service.httpx.AsyncClient", + return_value=client_cm): + result = await service.propagate_github_pat("ghp_new") + + assert result.updated == ["a1"] + assert result.failed == [] + + # Verify the payload sent to inject preserved other keys. + post_call = client.post.await_args + sent_env = post_call.kwargs["json"]["files"][".env"] + assert 'FOO="keep"' in sent_env + assert 'BAR="also-keep"' in sent_env + assert 'GITHUB_PAT="ghp_new"' in sent_env + assert 'GITHUB_PAT="ghp_old"' not in sent_env + + +@pytest.mark.asyncio +async def test_non_running_agents_ignored(service): + """Only running agents are considered targets.""" + with patch.object(service, "list_all_agents_fast", + return_value=[_agent("a1", status="stopped"), + _agent("a2", status="running")]), \ + patch.object(service, "db") as mock_db: + mock_db.has_agent_github_pat.return_value = False + + client_cm, client = _make_async_client( + read_responses={"a2": {"files": {".env": 'GITHUB_PAT="ghp_old"\n'}}}, + inject_responses={"a2": {"status": "success", "files_written": [".env"]}}, + ) + with patch("services.github_pat_propagation_service.httpx.AsyncClient", + return_value=client_cm): + result = await service.propagate_github_pat("ghp_new") + + assert result.total_running == 1 + assert result.updated == ["a2"] + # Stopped agent never hit the wire. + assert all("a1" not in str(c) for c in client.get.call_args_list) + + +@pytest.mark.asyncio +async def test_partial_failure_does_not_block_others(service): + """One failing agent should not stop the rest from being updated.""" + import httpx + + with patch.object(service, "list_all_agents_fast", + return_value=[_agent("good"), _agent("bad")]), \ + patch.object(service, "db") as mock_db: + mock_db.has_agent_github_pat.return_value = False + + client_cm, _ = _make_async_client( + read_responses={ + "good": {"files": {".env": 'GITHUB_PAT="ghp_old"\n'}}, + "bad": {"files": {".env": 'GITHUB_PAT="ghp_old"\n'}}, + }, + inject_responses={ + "good": {"status": "success", "files_written": [".env"]}, + "bad": httpx.ConnectError("boom"), + }, + ) + with patch("services.github_pat_propagation_service.httpx.AsyncClient", + return_value=client_cm): + result = await service.propagate_github_pat("ghp_new") + + assert "good" in result.updated + assert any(f.agent_name == "bad" and f.status == "failed" for f in result.failed) + + +@pytest.mark.asyncio +async def test_no_running_agents_returns_empty_result(service): + with patch.object(service, "list_all_agents_fast", return_value=[]), \ + patch.object(service, "db"): + result = await service.propagate_github_pat("ghp_new") + + assert result.total_running == 0 + assert result.updated == [] + assert result.skipped == [] + assert result.failed == [] From 354f052753ec8c7fc36d06830af0eb4063920cc9 Mon Sep 17 00:00:00 2001 From: Andrii Pasternak Date: Sun, 19 Apr 2026 01:18:12 +0100 Subject: [PATCH 2/2] docs(github-sync): document global PAT auto-propagation (#211) Adds a Global PAT Auto-Propagation section to github-sync.md covering eligibility rules, the read-modify-inject mechanism, response shape, and delete non-propagation. Updates feature-flows index and project_index. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/memory/feature-flows.md | 1 + docs/memory/feature-flows/github-sync.md | 61 +++++++++++++++++++++++- docs/memory/project_index.json | 5 +- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/docs/memory/feature-flows.md b/docs/memory/feature-flows.md index 30d0e8277..474862996 100644 --- a/docs/memory/feature-flows.md +++ b/docs/memory/feature-flows.md @@ -11,6 +11,7 @@ | Date | ID | Feature | Flow | |------|-----|---------|------| +| 2026-04-19 | #211 | Auto-propagate global GitHub PAT to running agents on update — per-agent PAT holders and agents without `GITHUB_PAT` in `.env` are skipped; delete does NOT propagate | [github-sync.md](feature-flows/github-sync.md), [platform-settings.md](feature-flows/platform-settings.md) | | 2026-04-18 | DOCS-QA-001 | Trinity Docs Q&A — public Vertex AI Search endpoint + in-app floating help widget (#391) | [trinity-docs-qa.md](feature-flows/trinity-docs-qa.md) | | 2026-04-17 | #376 | Proactive messaging UI toggle — SharingPanel shows allow_proactive switch per shared user | [proactive-messaging.md](feature-flows/proactive-messaging.md), [agent-sharing.md](feature-flows/agent-sharing.md) | | 2026-04-16 | #321 | Proactive agent messaging — agents send messages to users by verified email via Telegram/Slack/web | [proactive-messaging.md](feature-flows/proactive-messaging.md) | diff --git a/docs/memory/feature-flows/github-sync.md b/docs/memory/feature-flows/github-sync.md index 6c2c7452d..41456dc2e 100644 --- a/docs/memory/feature-flows/github-sync.md +++ b/docs/memory/feature-flows/github-sync.md @@ -414,7 +414,66 @@ def get_github_pat_for_agent(agent_name: str) -> str: - PAT values never returned in API responses (only `configured: true/false`) - PAT validated via `GitHubService.validate_token()` before storage - Encrypted at rest using platform-wide `CREDENTIAL_ENCRYPTION_KEY` -- Agent restart required for git operations to use new PAT +- Per-agent PAT requires agent restart to take effect in git operations; the + global PAT is auto-propagated to running agents on update (see next section). + +### Global PAT Auto-Propagation (#211) + +When an admin updates the global GitHub PAT via +`PUT /api/settings/api-keys/github`, the new value is pushed into each eligible +running agent's `.env` so agents pick up the new token without a restart. + +**Service:** `src/backend/services/github_pat_propagation_service.py` + +**Eligibility (per target agent):** +1. Container status is `running`. +2. Agent does NOT have a per-agent PAT configured + (`db.has_agent_github_pat(agent_name) == False`). Per-agent PATs override + the global and are managed separately. +3. Agent's current `.env` already contains a `GITHUB_PAT` key. Agents that + never set up GitHub are skipped — avoids injecting unused credentials. + +**Mechanism:** Reuses existing agent-side endpoints — no new agent surface. +1. `GET http://agent-{name}:8000/api/credentials/read?paths=.env` — fetch + current `.env`. +2. Regex-patch the `GITHUB_PAT` line (preserves all other keys and matches the + agent's `KEY="value"` quoting/escaping format). +3. `POST http://agent-{name}:8000/api/credentials/inject` with the merged + `.env`. The agent-side handler refreshes its credential sanitizer and + exports the new value to the in-process environment. + +Per-agent calls run concurrently via `asyncio.gather(..., return_exceptions=True)` +with a 30s per-call timeout. Per-agent failures are captured and do NOT roll +back the PAT save. + +**Response shape** (from `PUT /api/settings/api-keys/github`): +```json +{ + "success": true, + "masked": "ghp_****abcd", + "propagation": { + "total_running": 3, + "updated": ["agent-a"], + "skipped": [ + {"agent_name": "agent-b", "status": "skipped_per_agent_pat", "error": null}, + {"agent_name": "agent-c", "status": "skipped_no_pat", "error": null} + ], + "failed": [] + } +} +``` + +**Delete does NOT propagate** — `DELETE /api/settings/api-keys/github` is +intentionally unchanged; agents keep working with their existing injected PAT +until the next restart. + +**Frontend** (`src/frontend/src/views/Settings.vue`): The +`githubPatPropagation` ref renders below the PAT status row — success count, +failed agent list, and skipped reasons. + +**Tests:** `tests/test_github_pat_propagation_unit.py` (11 cases: env +patching, per-agent PAT skip, no-GITHUB_PAT skip, stopped-agent skip, merge +preserves other keys, partial-failure isolation). ### GitHub Service Integration diff --git a/docs/memory/project_index.json b/docs/memory/project_index.json index 6fd812c5c..33c66751d 100644 --- a/docs/memory/project_index.json +++ b/docs/memory/project_index.json @@ -4,7 +4,7 @@ "version": "1.0.0", "description": "Universal infrastructure for deploying Claude Code agent configurations", "repository": "project_trinity", - "last_updated": "2026-03-09T16:42:00Z" + "last_updated": "2026-04-19T00:00:00Z" }, "tech_stack": { "frontend": ["Vue.js 3.5", "Tailwind CSS 3.4", "Pinia 2.3", "Vite 6", "Auth0 Vue"], @@ -62,7 +62,8 @@ "dompurify_xss_protection", "cli_tool", "pypi_publishing", - "per_agent_github_pat" + "per_agent_github_pat", + "github_pat_auto_propagation" ], "in_progress": [ "github_native_agents"