Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/memory/feature-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
61 changes: 60 additions & 1 deletion docs/memory/feature-flows/github-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions docs/memory/feature-flows/platform-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
5 changes: 3 additions & 2 deletions docs/memory/project_index.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions src/backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
16 changes: 14 additions & 2 deletions src/backend/routers/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
166 changes: 166 additions & 0 deletions src/backend/services/github_pat_propagation_service.py
Original file line number Diff line number Diff line change
@@ -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,
)
28 changes: 28 additions & 0 deletions src/frontend/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,30 @@
</span>
</template>
</div>
<!-- Propagation result (#211) -->
<div v-if="githubPatPropagation" class="mt-2 text-sm">
<template v-if="githubPatPropagation.error">
<div class="text-red-600 dark:text-red-400">
PAT saved, but propagation failed: {{ githubPatPropagation.error }}
</div>
</template>
<template v-else-if="githubPatPropagation.total_running === 0">
<div class="text-gray-600 dark:text-gray-400">
PAT updated. No running agents to propagate to.
</div>
</template>
<template v-else>
<div :class="githubPatPropagation.failed.length ? 'text-yellow-700 dark:text-yellow-400' : 'text-green-600 dark:text-green-400'">
PAT updated and applied to {{ githubPatPropagation.updated.length }} of {{ githubPatPropagation.total_running }} running agent{{ githubPatPropagation.total_running === 1 ? '' : 's' }}.
</div>
<div v-if="githubPatPropagation.failed.length" class="mt-1 text-red-600 dark:text-red-400">
Failed: {{ githubPatPropagation.failed.map(a => a.agent_name).join(', ') }}
</div>
<div v-if="githubPatPropagation.skipped.length" class="mt-1 text-gray-500 dark:text-gray-400">
Skipped: {{ githubPatPropagation.skipped.map(a => `${a.agent_name} (${a.status === 'skipped_per_agent_pat' ? 'per-agent PAT' : 'no GITHUB_PAT'})`).join(', ') }}
</div>
</template>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Required for creating and pushing agents to GitHub repositories. Get your token at
<a href="https://github.com/settings/tokens/new" target="_blank" class="text-indigo-600 dark:text-indigo-400 hover:underline">
Expand Down Expand Up @@ -1612,6 +1636,7 @@ const githubPatStatus = ref({
masked: null,
source: null
})
const githubPatPropagation = ref(null)

// Slack Integration state (SLACK-001)
const slackClientId = ref('')
Expand Down Expand Up @@ -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
Expand Down
Loading