feat(git): bind an agent to a GitHub repo you own — post-creation ownership retrofit (ent#109) - #1947
Conversation
…109) `recreate_container_with_updated_config` seeds env from the OLD container and re-derived only `GITHUB_PAT`, replaying whatever `GITHUB_REPO` / `GIT_SYNC_*` each container happened to be carrying. The git-env derivation lived only in `_apply_persisted_auth_env` (`recreate_missing_container`). That split is a pre-existing fleet-wide bug, not a cosmetic one: the recreate has exactly one production caller, `start_agent_internal`, which fires on nine config-drift predicates AND on base-image drift at cold start — so a base-image rebuild arms the replay for every agent at once. `_apply_git_env_from_db` is now the single writer. Three load-bearing details: * **The PAT gate is a parameter, never inherited.** The two paths gate differently on purpose. `per_agent_only` (config-drift recreate) preserves #211 verbatim — resolve the effective PAT only when the container already carries one or a per-agent PAT row exists — so a global-only platform PAT is never injected into a previously-tokenless container. A verbatim lift would have swapped that for the 2-tier per-agent -> GLOBAL resolver used by `effective` (the rebuild-from-nothing path, which has no old container to inherit a token from): `configure_push_remote` then clears the push blackhole and a tokenless agent can push a private KB to the shared public upstream. learnings.md ent#162 names this class exactly. * **Set-or-clear**, since the recreate writes into a carried-forward dict. A deleted `agent_git_config` row pops the whole owned set; a `source_mode` flip clears the mode/branch pair. `GITHUB_PAT` alone stays set-only while a repo is bound — clearing it would revoke a live agent`s push on an unrelated recreate. * **`GIT_SYNC_AUTO` = DB flag OR baked env**, plus a convergence backfill. crud.py`s two writers genuinely disagree (`and not config.ephemeral` sits inside a swallowing try/except on the DB side only; the column defaults to 0), so deriving from `auto_sync_enabled` alone would silently stop auto-push for that slice of the fleet. The backfill writes the column the moment the disagreement is observed, so the OR retires itself. Making the #389 toggle authoritative is a separate follow-up. ent#123 is preserved: the gate is the REPO, not the PAT, so a tokenless agent rebuilt after container loss still clones (#843/#1439 silent-empty class). One deliberate divergence from a verbatim lift, asserted by test: a container with a baked `GITHUB_PAT` and NO git binding previously had that token refreshed from the global platform PAT on every recreate; it is now popped. The per-agent PAT is a column ON `agent_git_config`, so "no row" means no per-agent credential and no repo to push to by construction. Tests: tests/unit/test_ent109_git_env_seam.py — each of the four behaviours proved to have teeth by mutation (un-gate the PAT, flip the call site to `effective`, derive GIT_SYNC_AUTO DB-only, drop the clear sweep, drop the source-mode clear, diverge the GIT_SYNC_AUTO literal, unguard the backfill: all seven go red). Plus a static call-site guard, so flipping either gate fails CI even though no behavioural test of the helper alone would catch it. Refs Abilityai/trinity-enterprise#109 (PR 1 of 3) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ld paths (ent#109) Adds the missing agent_service/lifecycle.py catalog entry and records the per-call-site PAT gate, the set-or-clear contract, and the GIT_SYNC_AUTO OR-derivation. Amends the ent#123 clause to point at the new shared seam instead of _apply_persisted_auth_env. Refs Abilityai/trinity-enterprise#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs Abilityai/trinity-enterprise#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
github-sync.md: retitle the rebuild-recovery section to "Container-rebuild env — lifecycle.py::_apply_git_env_from_db" and document the per-call-site PAT gate, the set-or-clear contract, the GIT_SYNC_AUTO OR-derivation, and the two vars deliberately NOT owned. git-sync-health.md: GIT_SYNC_AUTO is re-derived on every rebuild as auto_sync_enabled OR the baked env (the two creation writers disagree), with a self-retiring backfill; kill-switch row and file table corrected. agent-lifecycle.md: Revision History row. feature-flows.md: hand-added Recent Updates row (the skill drops it past ~400 lines). Note: that table is at 56 rows against its stated ~20 cap (#1360) — pre-existing drift, deliberately not trimmed here. Refs Abilityai/trinity-enterprise#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…it_env_from_db ent#109 moved GITHUB_PAT derivation out of the inline block in recreate_container_with_updated_config (which the guard anchored on via the comment "Update GITHUB_PAT") into the shared _apply_git_env_from_db. The guard intent is unchanged and still enforced: that block resolves the effective per-agent PAT, never the platform-only get_github_pat(). Also fixes a silent-degradation flaw in the guard itself. str.find returns -1 on a miss, and src[-1:-1+300] slices to an EMPTY string — so a moved anchor made the guard assert "get_github_pat_for_agent in \x27\x27", failing with no hint about why. The anchor is now asserted first with a message naming the fix (re-point it, do not delete it), and the block is sliced to the next top-level def rather than a fixed byte window. Both failure modes proved red by mutation: swapping the helper to get_github_pat() and renaming the anchored function. Refs Abilityai/trinity-enterprise#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ackfill (ent#109) Two defects found reviewing the ent#109 PR 1 env seam. 1. The config-drift recreate blackholed push for agents bound post-creation. The repo half of the block is repo-gated (ent#123) while the PAT half keeps #211's narrower per-agent gate, and those two disagree for one real row shape: an agent bound via POST /{agent}/git/initialize on the GLOBAL platform PAT. That path writes an agent_git_config row and pushes, but never recreates the container, never bakes git env, never persists a per-agent PAT row, and never writes the token into the workspace .env — so its only credential is the one embedded in .git/config's origin URL, and startup.sh's #1264 fallback does not cover it. Handing startup.sh GIT_SYNC_ENABLED=true with no GITHUB_PAT is exactly what it reads as "deliberately tokenless": the restart branch rewrites origin to the credential-less CLONE_URL, destroying that token, and configure_push_remote blackholes the push remote — silently, and fleet-wide on the same base-image drift this helper exists to fix. `per_agent_only` now writes the block only when the old container already carried GITHUB_REPO or a PAT resolves. It still corrects a stale repo, a flipped source_mode and a deleted row — every case the fix is about; a tokenless ent#123 agent carries GITHUB_REPO from creation, so the flagship is unaffected. `effective` is exempt: with no old container, NOT introducing the block is the #843/#1439 silently-empty-agent bug. 2. The GIT_SYNC_AUTO backfill erased an owner's explicit disable. PUT /{agent}/git/auto-sync writes the row and nothing else while the agent gates on container env, and creation sets both true for the ordinary non-source-mode PAT agent — so "baked true / DB 0" is also exactly what an owner's disable looks like. The backfill re-enabled it on the next recreate and erased the only record of the intent, so the toggle could never stick. It was a privilege boundary too: PUT .../auto-sync is OwnedAgentByName while POST .../start, which triggers the recreate, is AuthorizedAgentByName — so a shared non-owner, or an agent-scoped key resolving to its owner with the owner's role (trinity-ops-agent#232), flipped an owner-only flag arming a 15-minute background commit-and-push loop. The OR-derivation stays (crud.py's two creation writers genuinely disagree, and DB-only derivation would silently stop auto-push for that slice). The write-back is gone; the disagreement is logged. Making the #389 toggle authoritative remains the tracked follow-up that retires the OR honestly. Tests 17 -> 22: a TestIntroduceGuard class (unbaked container untouched, carried repo still corrected, resolvable PAT still introduces, effective exempt, clear sweep unaffected) and the derive-only assertion. Both fixes proved to have teeth by mutation — removing the guard and restoring the backfill each go red on exactly one test. Two learnings.md entries. Refs trinity-enterprise#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…urce grep
The previous call-site guard sliced `lifecycle.py` by function header and
counted a single-line literal in each half. Two blind spots:
1. It pinned only the two KNOWN sites. ent#109's bug WAS that git env had
two writers and one of them was wrong; a THIRD writer added later on any
container-seeded path re-opens exactly that hole, and the grep version
stayed green through a planted `pat_gate="effective"` writer (verified by
mutation).
2. `lifecycle.py` names the helper in two comments, so a substring count
read prose as call sites — the same first blind spot the #1871 guard hit.
The AST walk maps `{enclosing function: pat_gate literal}` and asserts the set
equals exactly `{recreate_container_with_updated_config: per_agent_only,
_apply_persisted_auth_env: effective}`. It also fails loud on a non-literal or
omitted `pat_gate` and on a duplicate call in one function — each of which
would make the guard silently vacuous, which is worse than the leak it guards.
Also drops the stale "convergence backfill" wording from the module docstring
and the registry entry (d8da9d08 removed the backfill; the description still
described it) and re-states the idempotence test as "the DB row is never
mutated".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e check Follow-on to the AST guard: `architecture.md` and the `agent-lifecycle.md` change log both said the static guard "fails CI if either call site flips", which understates what it now enforces. It pins the whole writer SET, so a third writer on any container-seeded path fails CI too — the property that matters, since ent#109's bug was two writers with one of them wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…env-seam-fix # Conflicts: # docs/memory/learnings.md
…#109) Trinity Rule #1 — requirements before implementation. §11.12 specifies the "bind to your own repo" retrofit: FR-1 the explicit supported-row table keyed on source_mode (the column the partial unique index actually keys on) with named structural refusals for everything else, FR-2 source_mode preserved at 1 so no branch reservation is needed, FR-3 the destination-scoped fail-closed lock + CAS + compensating restore (never delete_git_config on a pre-existing row — that is destruction, not rollback), FR-4 the PAT persisted last, FR-5 the mandatory recreate because startup.sh rewrites origin unconditionally from baked env, FR-6 owner-only AND human-only with explicit PAT disclosure, FR-7 the no_write_credentials surfaces. Also amends §11.11 FR-5: the tokenless push refusal no longer teaches the create-a-new-agent-and-import workaround. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…109 §4.5) AC #4 asks the post-creation rebind to reuse ent#93's machinery rather than build a parallel path. The seam is NOT the destination triage lifted whole — that is not expressible, because the create path's reuse branch IS the template-tip SHA comparison, interleaved with the triage in one if/elif/else. So the seam is one level lower: inspect_or_create_destination_repo() reports created | empty | branches and never decides. Reuse/refuse POLICY stays in each caller, because the two callers genuinely disagree — the create path compares against a template tip; the rebind has no template, its content source is the agent's workspace volume, so any existing branch is a refusal. validate_destination_pat() is a SIBLING, not folded in: the create path validates the PAT before resolving the template tip, so 'bad PAT + unreachable template' reports FORK_PAT_INVALID. Folding it into the inspect primitive (which runs after the tip resolves) would silently reorder that into a template error. Behaviour preservation is asserted, not claimed: the 40 pre-existing test_fork_to_own.py tests pass unchanged, and both new guards were shown to have teeth — making the primitive refuse instead of report turns the create path's SHA-match reuse red, and swapping the validate/resolve order turns the ordering guard red. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POST /api/agents/{name}/git/bind-to-own-repo — create a user-owned repo from a
LIVE agent's current workspace, rebind origin in place, persist the per-agent
PAT, and re-bake the container env so the rebind survives a restart. Plus a
GET .../status companion so a client that eats a proxy timeout can resolve the
outcome from state rather than from a remembered request.
Shape, per requirements §11.12:
- Orchestration in services/agent_service/repo_binding.py, NOT the router
(Invariant #1). It raises BindError and never HTTPException; the router is a
thin mapper owning only the two locks, the idempotency claim, and the audit.
- Classification partitions on source_mode — the column the partial unique
index actually keys on — and refuses every other shape BY NAME rather than
mis-routing it. Credential state is an orthogonal column and is not used.
- Concurrency: a DESTINATION-scoped lock is the one that serializes the real
collision (two different agents, one destination repo); the agent-scoped
lock only guards double-submit. Both FAIL CLOSED with 503 + Retry-After —
agent_data's fail-open is calibrated for a tar round-trip, not for two repo
creates and two concurrent recreates of one container.
- The CAS in db.rebind_git_config is the whole commit point, its predicate
named in the docstring. The loser path restores the captured previous values;
it never calls delete_git_config, which on a pre-existing row is destruction
(the next recreate would drop GITHUB_REPO — #843/#1439).
- The PAT is persisted LAST and strictly before the recreate: earlier makes the
agent look already-writable on a retry, later bakes a repo-bound container
with no token that startup.sh then blackholes.
- Post-rewire, origin is read back and confirmed — a set-url that exits 0
without taking effect is exactly the silent mismatch AC #5 forbids.
- Owner-only AND human-only (reject_agent_principal): an agent-scoped key
resolves to its owner carrying the owner's role, so a role gate alone is
satisfied by any agent's injected key on a default admin-owned install.
Decision #17 (check_github_repo_env_matches) is deliberately CUT: the only
drift-proof way to build it is to call _apply_git_env_from_db, which turns PR
1's AST writer-set guard red, and idempotent retry already supplies the
convergence it was meant to buy. BIND_RECREATE_FAILED states the retry path
instead of a convergence promise, and warns against a plain restart.
Refs ent#109
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ygiene (ent#109) 31 tests over the properties that would otherwise only be true by inspection: - Classification: the supported shape succeeds; every other shape is refused BY NAME. Two cases asserted rather than argued — an already-writable agent is an ORDINARY rebind (the refusal that used to sit there is what made the documented retry unreachable), and trinity-system is refused through the no-git-config path so it never reaches the recreate that bypasses #1816's running-system gate. - Commit point: a moved row yields 409 with nothing partial, and the post-commit loser is RESTORED to its captured previous values. Asserts delete_git_config is never called — on a pre-existing row that is destruction, and the row is asserted to still exist afterwards. - Ordering: rebind -> pat -> recreate, proven by recorded call order. A push failure persists no PAT; a PAT-persist failure blocks the recreate; and fail-at-push -> retry -> success is an explicit regression test for the contradiction that a 409-on-retry used to produce. - The CAS statement runs against a REAL SQLite engine, not a double — the predicate is the whole safety argument, so a stub cannot verify it. Includes two racers reading the same expected value: exactly one wins. - Secret hygiene: the PAT is absent from the outcome, the audit dict and every error path, and a stale baked token in git output is redacted too. That last group found a real defect, now fixed: repo_binding composed its failure messages from foreign text (git output, a docker exception) and relied on the producer having scrubbed. git_service scrubs what it reads from a container, but the docker and GitHub exception paths arrive through libraries that never saw the token. Added _scrub() as a belt at the boundary where the PAT is actually in scope. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd (ent#109) ent#230's sharpest AC, which ent#109 omitted: the no_write_credentials surfaces must point at the retrofit once it exists. Both change together (Invariant #13): - git_service.NO_WRITE_CREDENTIALS_MESSAGE (consumed by sync_to_github and reset_to_main_preserve_state, mapped 409 in routers/git.py) - the MCP 409 hint in src/mcp-server/src/tools/git.ts Neither now teaches 'create a new agent with fork-to-own and import your data' — an instruction that discards the agent's identity, its 180-day name reservation and its history. ent#123's carve-out is preserved: this branch still suppresses the chat_with_agent remedy, because a chat turn cannot conjure credentials. The third surface — startup.sh's push-remote blackhole sentinel — is deliberately unchanged and now asserted as such: it is a git remote URL (one shell-safe token) that already names a remedy, and editing it would force a base-image rebuild for cosmetics. The parity guard was teeth-checked in both directions: reverting the MCP hint turns it red, AND breaking the source anchor turns it red with a named error rather than silently asserting against an empty slice — the way a source-grep guard usually dies. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new BindRepoPanel.vue mounted in GitPanel.vue rather than more markup inside it, for two reasons: GitPanel is already 639 lines, and #1430's raw-color ratchet is PER FILE — appending a form there would raise counts that may only shrink. GitPanel's numbers are unchanged at 24 nongray / 146 gray; the new panel is at ZERO raw non-gray with 51 semantic tokens (its 46 grays are the contract's own surface/ink vocabulary — there are no Base* primitives in the repo yet to absorb them). Design-system contract (read first, per CLAUDE.md rule #10): semantic tokens only (action-primary / status-success / status-warning / status-danger), both themes first-class, gray-750 for dark chrome, and no dark:text-gray-500 — the dark ink floor. Behaviour worth noting: - The store method uses raw axios with an explicit 300s timeout, following the surrounding idiom. It deliberately does NOT use api.js, whose instance-wide 30s timeout is far below this call's worst case; aborting the client mid-bind strands the user past the commit point with no response, which is the exact situation the status endpoint exists to rescue rather than manufacture. - The PAT is read out of the reactive ref BEFORE the await and cleared immediately, so it never lingers regardless of how the request ends. - A client timeout is reported as PARTIAL, never as a clean failure — the request may well have landed. - Post-commit failures render as 'Partly applied — action needed' in warning colour rather than as an error, because the binding genuinely IS saved and telling the user it failed would send them looking in the wrong place. - The restart warning states what happens, what is preserved, and how long. Both SFCs verified against the real @vue/compiler-sfc (parse + script + template); npm run check:tokens passes. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#109) - architecture.md: two endpoint rows, a Post-Creation Repo Binding subsystem block, the two Redis lock keyspaces, repo_binding.py + git_service's new primitives in the service catalog, the shared destination seam on the fork_to_own entry, and the ent#123 paragraph tail now that its no_write_credentials refusal points at the retrofit. PR 1's _apply_git_env_from_db prose is already present on this branch and was NOT re-added. - New feature-flows/agent-repo-binding.md: the end-to-end trace, the five decisions that carry the design (source_mode partition, destination lock, CAS + restore-not-delete, PAT-last, mandatory recreate), the error registry with which codes are partial, the ent#93 sharing seam, security, and known limits — including why Decision #17's drift predicate was cut. - feature-flows.md: Recent Updates row added BY HAND (/sync-feature-flows drops it past ~400 lines) plus the category-table entry. - Cross-linked the three affected flows: github-sync.md and mcp-git-tools.md had the retired workaround quoted verbatim in their prose, and github-repo-initialization.md now names its post-creation sibling and the boundary between them. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/update-tests review found the router layer uncovered — its own rule says a new or changed endpoint needs a caller that exercises the path params and auth dependency (#1069's 422-every-call class). 26 tests over the five things no service-level test can see: - reject_agent_principal really called, and wired in the handler rather than merely imported (an agent-scoped key resolves to its owner CARRYING the owner's role, so an owner/role gate alone is satisfied by any agent's injected key on a default admin-owned install) - route path-param matches the handler parameter, for both routes - locks FAIL CLOSED on a Redis outage, on a raising SETNX, and on contention; the destination key is case-folded; locks release on success AND failure - idempotency key is verb-folded; absent header derives nothing; in-flight 409; completed replay returns the snapshot with X-Idempotent-Replay - audit on EVERY exit path incl. lock contention and the unexpected 500 Also fixes a regression this work introduced: test_ent123_tokenless_clone.py asserted the literal retired wording of NO_WRITE_CREDENTIALS_MESSAGE, and I had not re-run that suite after changing the shared constant. Re-anchored on the CONSTANT plus the invariant ent#123 actually cares about (named message, still actionable) — stronger than before, and it cannot drift again; the exact copy is owned by test_ent109_no_write_credentials_message.py. Both new guards mutation-verified: deleting reject_agent_principal and making the lock fail open each turn two tests red. Full unit suite: 6350 passed, 14 skipped, 0 failed. Identical under random and fixed order (no sys.modules pollution across the new modules). Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verification of the hand-written docs against the code found two gaps: - template-processing.md described the fork-to-own copy pipeline's steps 1-2 as living inline in fork_to_own.py. They are now the SHARED half (validate_destination_pat + inspect_or_create_destination_repo), so a reader tracing the code would have found the triage in a different function than documented. Updated to name the seam and why it sits one level below the triage, with the reuse/refuse policy explicitly still owned by that caller. Behaviour there is unchanged. - The new flow's error registry was missing three codes that ARE reachable on the bind path: FORK_DESTINATION_UNREACHABLE (shared primitive), BIND_DESTINATION_UNREACHABLE (fail-closed guard-read failure) and BIND_UNEXPECTED_ERROR (router catch-all). Verified by diffing the codes in the source against the codes in the doc; the seven still absent are create-path-only and correctly omitted. Checked and deliberately NOT changed: git-sync-health.md and dark-mode-theme.md reference the touched files but document nothing this PR alters. The Recent Updates table is 66 rows against its own stated ~20 cap — pre-existing drift (65 before this PR); trimming 46 of other people's entries is unrelated churn on a feature PR. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ejection (ent#109)
Fixes from /review (C1, I1, I3, I6, I2) and /cso --diff (S1) on PR 2.
── /review C1: every post-commit failure promises an idempotent retry, and all
four are refused ──────────────────────────────────────────────────────────
The CAS is the commit point, so after it the row names the destination while
the container's origin still names the old repo — and both pre-flight gates
read that skew as a refusal:
push/rewire fail -> row moved, origin did not -> 409 BIND_STATE_UNCLASSIFIED
PAT/recreate fail -> destination holds our own pushed history
-> 409 BIND_DESTINATION_EXISTS
The §0.4 class the plan wrote a section to eliminate for the PAT ordering,
re-entering through the classification guard. The vestige was in the signature:
`_classify(agent_name, destination_repo)` never used `destination_repo` — the
carve-out had been designed and not written.
A row already naming the requested destination is now a resumption:
* origin may lag — it never selects what is pushed (step 4 pushes
refs/heads/<branch> from the workspace by explicit URL, writes origin after),
and it cannot be tightened anyway: a committed CAS has overwritten the old
repo name, so "still the old repo" and "something else" are
indistinguishable, and treating the ambiguity as fatal strands the agent.
* existing branches are accepted — bounded by git, not trust: the push carries
no --force and no `+` refspec, so unrelated history is rejected
non-fast-forward and an unrelated branch is untouched.
* previous_repo=None on a resume leaves `upstream` alone instead of repointing
it at the destination itself, erasing the provenance the rebind preserves.
A mismatch against any OTHER repo stays BIND_STATE_UNCLASSIFIED.
The regression test written for exactly this was green because its double
returned `origin_repo=fake_db.config.github_repo` — the container's observed
state WAS the row, so they could never disagree — and a hand-set
`dest_state = "empty"` stepped around the other gate. The fixture now tracks
the container independently and mirrors the real side effects.
── /cso S1: a GitHub PAT reaches the response body and the platform log ──────
A PAT is sent as `Authorization: Bearer <pat>`, and h11 rejects an illegal
header value by ECHOING it (verified: `LocalProtocolError: Illegal header value
b'Bearer ghp_...\r'`). The validator only checked non-emptiness and returned the
value UNSTRIPPED, so a token carrying a trailing \r or \n — what a paste from a
terminal or clipboard routinely produces — surfaced raw in a 500 body and, via
logger.exception, in the Vector-captured platform log. Trigger is far more often
an ordinary paste than an attacker.
* `models._validate_pat_secret` strips whitespace and rejects anything outside
printable ASCII, on BOTH BindAgentRepoRequest and ForkToOwnRequest (ent#93's
create path feeds the same GitHubService constructor).
* That alone would only RELOCATE the leak: Pydantic v2 records the rejected
value in errors()["input"] and FastAPI returns exc.errors() verbatim — proven
against a real TestClient. `error_handlers.validation_error_without_input`
strips `input` from every 422 entry. Dropped for all fields, not for names
that look sensitive: a name allowlist is the new-producer-missing-from-the-
consumer's-list class, and the caller already has the value they sent.
* The router catch-all and the PAT-persist log line now scrub, and the
dual-scrub itself collapses from two copies into one home in
`utils/credential_sanitizer` (fork_to_own re-exports for its callers).
── Also ─────────────────────────────────────────────────────────────────────
* The bind is `recreate_container_with_updated_config`'s SECOND production call
site and skipped the `clear_agent_breakers` that `start_agent_internal` runs
immediately before its own call — both breakers are agent-name-keyed with no
TTL, so the replacement container inherited its predecessor's verdict
(#1560). Cleared before the recreate, not after. Two stale "one production
caller" claims corrected.
* Audit rows on the two idempotency-replay exits, so "exactly once per exit
path" (#905) is literally true.
* Client timeout resolves against the status endpoint instead of telling the
user to reload the tab.
* Five test modules registered in tests/registry.json.
Each of the six behaviour fixes was mutation-checked (revert -> red -> restore),
including the breaker clear in both directions (absent, and after the recreate).
Verified: 6332 backtest unit tests pass; the original C1 probe — written before
the fix and unchanged — now reports both post-commit shapes converging; frontend
`vite build` and the design-token check pass; GitPanel's raw-color counts are
unchanged from baseline and BindRepoPanel is at raw_nongray 0.
Refs Abilityai/trinity-enterprise#109
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eps (ent#109) Plan §7 lists "uniform 404 for unknown *and* inaccessible agent (Invariant #8)" as a PR 2 case, and it was the one bullet with no test behind it. The 404 BEHAVIOUR is not re-tested here — `test_186_enumeration_uniformity.py` already proves parametrically that both helpers evaluate existence and access before branching, so nonexistent and inaccessible come back byte-identical. Re-asserting that would only re-test the shared dependency. What no dependency-level test can see is whether *this* endpoint routes through it. So the assertion is the identity of the callable actually bound to `agent_name` on each route — `get_owned_agent_by_name` on the mutating verb, `get_authorized_agent_by_name` on the read-only status verb — mirroring the existing `reject_agent_principal(current_user)` getsource guard: an annotation that merely looks right in a diff, or a hand-rolled lookup with a 404-then-403 split, is how the enumeration oracle gets reintroduced. The two scopes are not interchangeable, so both are pinned: swapping them would either lock a shared reader out of a surface the Git tab already shows them, or let one rebind an agent they do not own. Not vacuous: the two dependencies are distinct objects, so binding the wrong one fails the assertion. Route introspection goes through `route.dependant`, not `get_flat_dependant` — that symbol drifts in the verify venv. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| Alert | Location | Rule |
|---|---|---|
| #274 | src/backend/routers/git.py:989 |
py/clear-text-logging-sensitive-data |
| #275 | src/backend/services/agent_service/repo_binding.py:435 |
py/clear-text-logging-sensitive-data |
Why they fire. Both flagged expressions are the output of the scrubber, not the secret:
# routers/git.py:986-990
safe = scrub_secret_and_urls(str(e), body.github_pat.get_secret_value())
logger.error("repo-bind: unexpected failure for %s (%s): %s", agent_name, type(e).__name__, safe)The PAT reaches these lines only as the argument naming what to strip. CodeQL tracks taint into the call and has no sanitizer model for a custom scrubber, so adding the guard is what created the alert.
Evidence the scrub actually works — scrub_secret_and_urls = redact_url_userinfo(scrub_secret(text, secret)), run against four real leak shapes:
| Input shape | Output |
|---|---|
h11 header echo (the /cso S1 finding) |
Illegal header value b'Bearer ***\r' |
| git remote URL userinfo | https://***@github.com/o/r.git/ |
| stale baked token (not the request PAT) | https://***@github.com |
| bare token in text | denied for token *** |
No leak in any. The stale-token case is why both passes exist — scrub_secret alone would miss a credential that isn't the caller's. Covered by test_ent109_bind_endpoint.py::test_stale_baked_token_is_redacted_not_just_the_request_pat.
Why not a code barrier instead. py/path-injection has a modeled SafeAccessCheck a real guard can satisfy (how #1455 was fixed). This rule has no equivalent. The only ways to silence it are to stop passing the secret to the scrubber (breaking the scrubbing) or rename the variable out of the sensitive-name heuristic (gaming the linter in security code). Both are worse than the alert.
To re-open if you disagree:
gh api -X PATCH repos/Abilityai/trinity/code-scanning/alerts/274 -f state=open
gh api -X PATCH repos/Abilityai/trinity/code-scanning/alerts/275 -f state=open|
Resolve by running |
#1913 (this branch's parent) was squash-merged to dev, so its 9 commits are no longer ancestors and the stack surfaced as a conflict. Resolved additively: - lifecycle.py docstring only — this branch's text is the superset (it names repo_binding's bind path as the second caller of recreate_container_with_updated_config, which this PR adds) - architecture.md kept both: this branch's repo_binding.py row plus dev's three existing agent_service rows - feature-flows.md kept both changelog rows, newest-first: ent#109 (08-02) above #1932 (08-01) - learnings.md positional conflict against an adjacent dev addition; dev added nothing in the hunk, so both sets of entries stand - registry.json this branch's 5 new entries; dev's 104 all retained (109 total) Verified: zero conflict markers tree-wide, registry.json parses, lifecycle.py compiles, and `git diff origin/dev` shows no deletions in any of the four docs.
vybe
left a comment
There was a problem hiding this comment.
/validate-pr: PASS.
- Base
dev✅ · 32 files (post-rebase, under the 50 threshold) ✅ ·Fixes Abilityai/trinity-enterprise#109resolves ✅ - Security scan clean.
ghp_THE_USERS_REAL_TOKENis a leak-assertion fixture, not a credential — the tests assert it does NOT appear in responses/logs ✅ - MCP third surface updated (
src/mcp-server/src/tools/git.ts) — Invariant #13 ✅ - Docs: requirements §11.12 + architecture + 8 feature flows ✅ · 9 test files ✅
error_handlers.py is packaged. docker/backend/Dockerfile:83 is COPY ../../src/backend/*.py /app/ — a glob, changed to exactly this after #1033 — so a new top-level module is picked up, and chmod -R 644 /app/*.py covers it. No packaging gap.
Three things I verified rather than took on trust:
- The 422 leak is real and this closes it, not relocates it. Pydantic v2 puts the rejected value in
errors()[i]["input"]and FastAPI returnsexc.errors()verbatim, so the PAT charset guard alone would have moved the leak from a 500 into a 422. Droppinginput/ctxfor every field rather than name-matching is the right shape — an allowlist is the new-producer-missing-from-the-list class, and the caller already has the value they sent. - The bind endpoint is owner-only AND human-only.
OwnedAgentByName(uniform 404, Invariant #8) plusreject_agent_principal— necessary because an agent-scoped key resolves to its owner carrying the owner's role, which on a default admin-owned install is the whole fleet (trinity-ops-agent#232). - The bind locks fail CLOSED, and the destination lock is the one that matters. A per-agent lock never serializes two different agents binding one destination repo; keying on
sha256(lower(dest))also handles GitHub's case-insensitive slugs. Diverging from_agent_data_op_lock's fail-open is correct — a lost lock here means two repo creations, two CAS writes and two concurrent container recreates.
Conflict resolution (mine). #1913 was squash-merged, so this branch's 9 shared commits stopped being ancestors and the stack surfaced as 5 conflicts. Resolved additively and pushed as e6e82a34: lifecycle.py docstring → this branch's superset (it names repo_binding's bind path as the second caller); architecture.md → both; feature-flows.md → both rows, newest-first (ent#109 08-02 above #1932 08-01); learnings.md → both (dev added nothing in the hunk); registry.json → this branch's 5 entries, dev's 104 retained (109 total). Verified zero markers tree-wide, registry parses, lifecycle.py compiles, and git diff origin/dev shows no deletions in any of the four docs. Diff went 36 files/+6001 → 32/+5034, which is #1913's content correctly dropping out.
status-in-dev by hand.
dev has since taken #1913/#1937/#1947/#1949/#1899. Seven conflicts, resolved so that no side's change is lost: SOURCE - static_checks.py the one real semantic conflict. ent#128 (#1899) flipped the per-check swallow from _skip to _fail so a crashed check is counted by _counts; ent#89 kept _skip and added logging. Taking this branch's side verbatim would have silently reverted the HARD-count fix. Merged: _fail from #1899 + logger.error(exc_info=True) from ent#89, which is strictly more diagnostic than the logger.warning it replaces. The docstring directly above already asserts "a check that could not evaluate is not a check that passed". - template_service three hunks, all adjacent additions: both import blocks kept (template_schedules got its own statement — the two sides shared a closing paren), both new functions kept, and both pre-literal computations kept at each of the two call sites. - crud.py import list, both symbols kept. DOCS - architecture.md two hunks where BOTH sides had edited the same three bullets (template_service / fork_to_own / crud). Not a pick — each line was 3-way merged at word granularity against the merge base; no edit pairs overlapped, so both sides' text survives verbatim. dev's bullet order preserved, ent#89's new template_schedules.py bullet appended. - feature-flows.md all rows kept, table stays reverse-chronological. - learnings.md both sets of entries kept. - registry.json both entry lists kept. The conflict opened after a bare '{' and closed before a bare '}', so each side was an object BODY -- a naive concatenation produced invalid JSON. Re-added the '},{' separator. 109 -> 112 entries, none dropped. Verified: zero markers tree-wide, registry.json parses (112 entries), all three touched modules compile, and every deletion vs origin/dev is one of ent#89's own intended replacements (crud docstring three->four, the cron helper replaced by the shared validator, T-018 wired into the dispatch map).
Fixes Abilityai/trinity-enterprise#109
Supersedes Abilityai/trinity-enterprise#230 (its three ACs are folded in and verified here).
What this does
A tokenless public-template agent — the default Cornelius — accumulates a knowledge base it cannot push anywhere. Until now the only documented escape was create a new agent with fork-to-own and import your data, which throws away the agent's identity, its container, its 180-day name reservation and its history in order to keep a directory.
POST /api/agents/{name}/git/bind-to-own-repocreates a repo the user owns from the agent's current workspace volume (not from its template — that is exactly why it cannot reuse ent#93's copy step), pushes the committed history, repointsoriginin place, persists the per-agent PAT and re-bakes the container env.It is framed as a rebind, not a fork verb. An already-writable agent is an ordinary rebind rather than a refusal — which is what makes AC #3 ("works for any agent") literally true, and is less code than gating that state out.
Owner-only and human-only:
reject_agent_principalon top ofOwnedAgentByName, because an agent-scoped key resolves to its owner carrying the owner's role, so a role gate alone is satisfied by any agent's injected key on a default admin-owned install.Design decisions worth reviewing
source_mode, the columnidx_git_config_repo_branch_uniqueactually keys on — not on write-credential state, which is an orthogonal column. A credential-lesssource_mode = 0row would pass a credentials gate and be rebound inside the unique index. Everything unsupported is refused by name (BIND_NO_GIT_CONFIG,BIND_WORKING_BRANCH_MODE_UNSUPPORTED,BIND_STATE_UNCLASSIFIED).agent:bind_dest:{sha256(lower(dest))}. The real race is two different agents targeting one repo, which no per-agent lock serializes. It fails CLOSED (503 +Retry-After), unlikeagent:data_op:— a lost lock here means two repo creates and two concurrent recreates of one container.rebind_git_config, predicate stated in its docstring). The post-write loser is restored from captured previous values — neverdelete_git_config. ent#93 may delete because its row was INSERTed microseconds earlier; on a pre-existing row that strips a live agent's binding, and the next recreate brings it back with no repo (the bug: GET /api/templates omits local templates from config/agent-templates/ #843/bug: GitHub-template agent deploy — race intermittently leaves a silent empty agent (clone into non-empty /home/developer, exit 128, not surfaced) #1439 silently-empty-agent class)._agent_has_write_credentialsreport the agent already-writable on a retry, and lets a mid-window manual Push hit the OLD repo with the NEW token. Later bakes a repo-bound container with no token, because the config-drift recreate resolves the PAT withpat_gate="per_agent_only"andstartup.sh'sconfigure_push_remotewould then blackhole its push remote.startup.sh's restart branch rewritesoriginunconditionally from bakedGITHUB_REPO, so a DB-only rebind is silently reverted by the next plain restart. Same volumes are reused via thevolume_base_namepin (bug: #1581 orphan-volume sweep can destroy a live renamed agent's home volume (rename keeps old volume name+label) #1664), so ent#230's AC Feature/gemini runtime support #2 (the S4 persistent-state allowlist survives) holds by construction, not by copying.check_github_repo_env_matchesis to call_apply_git_env_from_dbitself — which turns fix(lifecycle): re-derive git env from the DB on config-drift recreate (ent#109) #1913's AST writer-set guard red. An independent re-implementation is precisely the writer/checker feedback looplifecycle.pydocuments against. Idempotent retry supplies the convergence instead.What review caught (and how)
/reviewC1 — critical, empirically proven, fixed. Every post-commit failure message promised an idempotent retry, and all four were refused. The CAS is the commit point, so after it the DB row names the destination while the container'soriginstill names the old repo — and both pre-flight gates read that skew as a refusal:_classify(origin ≠ row →BIND_STATE_UNCLASSIFIED), and the destination policy (branches present →BIND_DESTINATION_EXISTS, where those branches are the agent's own pushed history). The tell was that_classify(agent_name, destination_repo)never useddestination_repo— the carve-out had been designed and not written.Fixed by treating "row already names this destination" as a resumption that relaxes both gates. That is safe because
originnever selects what is pushed (the push is by explicit URL;originis written afterwards) and the push carries no--force/+refspec, so git rejects unrelated history non-fast-forward — thebranchesgate was UX, not integrity.Why it shipped green the first time: the regression test written for exactly this derived the container's origin from the DB row (
origin_repo=fake_db.config.github_repo), so the two stores could never disagree — and disagreement was the entire subject. A hand-setdest_state = "empty"between the two calls stepped around the other gate./csoS1 — HIGH, verified end-to-end, fixed. A GitHub PAT is sent asAuthorization: Bearer <pat>, and h11 rejects an illegal header value by echoing it — confirmed against real httpx:LocalProtocolError: Illegal header value b'Bearer ghp_REAL\r'. The validator only checked non-emptiness and returned the value unstripped, so a token with a trailing\r/\n— a routine paste artifact, not an attack — reached the 500 body and the Vector-captured platform log.The obvious fix is a lateral move on its own: Pydantic v2 puts the rejected value in
errors()["input"]and FastAPI returnsexc.errors()verbatim, so a model-level rejection merely relocates the secret into the 422. Both halves landed together, proven against a realTestClientbefore either shipped:models._validate_pat_secret(strip, then printable-ASCII only, on bothBindAgentRepoRequestandForkToOwnRequest) pluserror_handlers.validation_error_without_input, which stripsinputfrom every 422 entry.Also fixed in the same pass: the bind is
recreate_container_with_updated_config's second production call site and was skippingclear_agent_breakers(#1560 — both breakers are name-keyed with no TTL, so the replacement container inherits its predecessor's verdict); audit rows on both idempotency-replay exits; the status endpoint wired into the client-timeout branch.Shared with ent#93 (AC #4)
fork_to_own.inspect_or_create_destination_repo()returnscreated | empty | branchesand never decides. Reuse/refuse policy stays in each caller, because the create path's reuse branch is its template-tip SHA comparison and the rebind has no template to compare against.validate_destination_patis a sibling rather than folded in, preserving the create path's validate-before-resolve-template ordering.Both
no_write_credentialsworkaround strings retiredThe backend refusal (
git_service.NO_WRITE_CREDENTIALS_MESSAGE) and the MCP hint (tools/git.ts) no longer teach create a new agent and import your data — they point at Bind to your own repo on the agent's own Git tab. Kept in sync per Invariant #13 bytests/unit/test_ent109_no_write_credentials_message.py.test_ent123_tokenless_clone.pywas re-anchored onto the constant rather than its literal copy, so the copy has exactly one owner.Verification
All figures below were observed from real runs, not inferred.
tests/registry.json.test_1069_voip_call_path_param::test_flat_path_params_are_agent_name_not_name→ImportError: cannot import name 'get_flat_dependant' from 'fastapi.dependencies.utils', a fastapi-pin drift in the local venv with no relation torouters/git.py. Reproduced byte-identically by a second independent run./verify-localFULL mode — PASS (exit 0), agent stage included. preflight ✔ · agent-precheck ✔ · backend build +import main✔ · agent base build +import agent_server✔ · boot+health ✔ · agent-exercise ✔ (real containerverify-smoke-…reached/health→{"status":"healthy","runtime":"claude-code","clone_status":"ok"}, correct network attachment) · integration ✔ (70 passed, 13 skipped, 2 deselected). Unit stage skipped deliberately, since it had already been run directly (above) and its known false-fail fail-fasts the pipeline before the build/boot/agent stages.configure_push_remoteproved separately — and this matters. verify-local's agent stage boots alocal:test-echoagent with noGITHUB_REPO/GITHUB_PAT, soGIT_SYNC_ENABLEDis never true and everystartup.shbranch behind that gate is structurally unreachable. A passing agent stage therefore does not prove this PR's git-env premise. The function was extracted from/app/startup.shinside the built image and both branches exercised against a throwaway repo: tokenless → push blackholed;GITHUB_PATbaked → blackhole cleared; idempotent on a second start. The extraction anchor was asserted non-empty (an empty body would make every assertion pass vacuously), and the image was tied to the branch by sha (a4788b71…host == image).git diff origin/dev...HEAD -- docker/returns 0 files, and the agent base-image build was a cache hit.scan-raw-colors.mjsexits 0 — no ratchet regression.BindRepoPanel.vueatraw_nongray 0;GitPanel.vueunchanged at its baseline 24/146/0.raw-color-baseline.jsonis not touched by this PR, so the ratchet is honest rather than rebaselined.dev@c4c83f4a.Notes for the reviewer
status-in-devpromotion (issue-status-on-merge.yml) are same-repo only — ent#109 and ent#230 both need a manual status bump / close at release.stores/agents.jsuses rawaxiosrather than the sharedapi.jsinstance, matching the surrounding idiom in that file (no store in the codebase importsapi.jsfor git calls). The timeout override is mandatory either way —api.jshard-codes 30s, well below this call's budget, and would abort after the commit point straight into the 504 recovery path.🤖 Generated with Claude Code