Skip to content

feat(git): bind an agent to a GitHub repo you own — post-creation ownership retrofit (ent#109) - #1947

Merged
vybe merged 21 commits into
devfrom
AndriiPasternak31/ent109-bind-own-repo
Aug 3, 2026
Merged

feat(git): bind an agent to a GitHub repo you own — post-creation ownership retrofit (ent#109)#1947
vybe merged 21 commits into
devfrom
AndriiPasternak31/ent109-bind-own-repo

Conversation

@AndriiPasternak31

Copy link
Copy Markdown
Contributor

Fixes Abilityai/trinity-enterprise#109
Supersedes Abilityai/trinity-enterprise#230 (its three ACs are folded in and verified here).

Stacked on #1913 (AndriiPasternak31/git-env-seam-fix). This branch is cut from that PR's head, not from dev, so until #1913 merges the diff below also shows its 9 commits. Once #1913 lands they become dev ancestors and this diff cleans itself up.

The stack is functional, not textual. PR 2's step 7 calls recreate_container_with_updated_config() to re-bake GITHUB_REPO — and that re-bake is what #1913 introduces. Before it, that path replayed git env from the old container and never read the DB. A file-overlap check says these two PRs are independent and is wrong: branched off plain dev, this PR's "rebind survives a restart" acceptance criterion verifies green against a mechanism that isn't in the tree.


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-repo creates 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, repoints origin in 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_principal on top of OwnedAgentByName, 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

  • Classification partitions on source_mode, the column idx_git_config_repo_branch_unique actually keys on — not on write-credential state, which is an orthogonal column. A credential-less source_mode = 0 row 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).
  • The lock is destination-scoped, 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), unlike agent:data_op: — a lost lock here means two repo creates and two concurrent recreates of one container.
  • The commit point is a single CAS (rebind_git_config, predicate stated in its docstring). The post-write loser is restored from captured previous values — never delete_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).
  • The PAT is persisted LAST, but strictly before the recreate. Earlier makes _agent_has_write_credentials report 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 with pat_gate="per_agent_only" and startup.sh's configure_push_remote would then blackhole its push remote.
  • The recreate is mandatory, and is not a re-provision. startup.sh's restart branch rewrites origin unconditionally from baked GITHUB_REPO, so a DB-only rebind is silently reverted by the next plain restart. Same volumes are reused via the volume_base_name pin (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.
  • Resumption is an explicit branch, not assumed idempotence. See "What review caught" below.
  • Decision Client/Viewer User Role (AUTH-002) #17's drift predicate was cut. The only drift-proof way to write check_github_repo_env_matches is to call _apply_git_env_from_db itself — 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 loop lifecycle.py documents against. Idempotent retry supplies the convergence instead.

What review caught (and how)

/review C1 — 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's origin still 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 used destination_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 origin never selects what is pushed (the push is by explicit URL; origin is written afterwards) and the push carries no --force / + refspec, so git rejects unrelated history non-fast-forward — the branches gate 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-set dest_state = "empty" between the two calls stepped around the other gate.

/cso S1 — HIGH, verified end-to-end, fixed. A GitHub PAT is sent as Authorization: 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 returns exc.errors() verbatim, so a model-level rejection merely relocates the secret into the 422. Both halves landed together, proven against a real TestClient before either shipped: models._validate_pat_secret (strip, then printable-ASCII only, on both BindAgentRepoRequest and ForkToOwnRequest) plus error_handlers.validation_error_without_input, which strips input from every 422 entry.

Also fixed in the same pass: the bind is recreate_container_with_updated_config's second production call site and was skipping clear_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() returns created | empty | branches and 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_pat is a sibling rather than folded in, preserving the create path's validate-before-resolve-template ordering.

Both no_write_credentials workaround strings retired

The 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 by tests/unit/test_ent109_no_write_credentials_message.py. test_ent123_tokenless_clone.py was 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.

  • 134 ent#109 tests pass across 6 modules (5 new + the fix(lifecycle): re-derive git env from the DB on config-drift recreate (ent#109) #1913 seam module); all 5 new modules registered in tests/registry.json.
  • Full backend unit suite: 6373 passed, 1 failed, 16 skipped (6m09s). The single failure is test_1069_voip_call_path_param::test_flat_path_params_are_agent_name_not_nameImportError: cannot import name 'get_flat_dependant' from 'fastapi.dependencies.utils', a fastapi-pin drift in the local venv with no relation to routers/git.py. Reproduced byte-identically by a second independent run.
  • /verify-local FULL 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 container verify-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_remote proved separately — and this matters. verify-local's agent stage boots a local:test-echo agent with no GITHUB_REPO/GITHUB_PAT, so GIT_SYNC_ENABLED is never true and every startup.sh branch 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.sh inside the built image and both branches exercised against a throwaway repo: tokenless → push blackholed; GITHUB_PAT baked → 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).
  • No agent-side change at all: git diff origin/dev...HEAD -- docker/ returns 0 files, and the agent base-image build was a cache hit.
  • Design-system contract (Design system v2: shared UI primitives, raw-color ratchet, and written style guide #1430): scan-raw-colors.mjs exits 0 — no ratchet regression. BindRepoPanel.vue at raw_nongray 0; GitPanel.vue unchanged at its baseline 24/146/0. raw-color-baseline.json is not touched by this PR, so the ratchet is honest rather than rebaselined.
  • Merges clean against dev @ c4c83f4a.

Notes for the reviewer

  • The linked issue is on the private tracker, so GitHub's auto-close and the status-in-dev promotion (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.js uses raw axios rather than the shared api.js instance, matching the surrounding idiom in that file (no store in the codebase imports api.js for git calls). The timeout override is mandatory either wayapi.js hard-codes 30s, well below this call's budget, and would abort after the commit point straight into the 504 recovery path.
  • No VERSION bump and no CHANGELOG entry, per the release process.

🤖 Generated with Claude Code

AndriiPasternak31 and others added 20 commits July 31, 2026 15:40
…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>
Comment thread src/backend/routers/git.py Dismissed
Comment thread src/backend/services/agent_service/repo_binding.py Dismissed
@AndriiPasternak31

Copy link
Copy Markdown
Contributor Author

⚠️ Reviewer: I dismissed two CodeQL alerts on this PR — please sanity-check that call

Flagging this explicitly rather than letting a green check speak for itself, because the alerts land on the highest-risk lines in a PR whose entire subject is not leaking a PAT. If you disagree, re-open in one call (command at the bottom).

Dismissed as false positive:

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 worksscrub_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

@AndriiPasternak31
AndriiPasternak31 marked this pull request as ready for review August 2, 2026 21:43
@AndriiPasternak31 AndriiPasternak31 self-assigned this Aug 3, 2026
@AndriiPasternak31
AndriiPasternak31 requested a review from vybe August 3, 2026 00:34
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

#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 vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/validate-pr: PASS.

  • Base dev ✅ · 32 files (post-rebase, under the 50 threshold) ✅ · Fixes Abilityai/trinity-enterprise#109 resolves ✅
  • Security scan clean. ghp_THE_USERS_REAL_TOKEN is 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:

  1. The 422 leak is real and this closes it, not relocates it. Pydantic v2 puts the rejected value in errors()[i]["input"] and FastAPI returns exc.errors() verbatim, so the PAT charset guard alone would have moved the leak from a 500 into a 422. Dropping input/ctx for 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.
  2. The bind endpoint is owner-only AND human-only. OwnedAgentByName (uniform 404, Invariant #8) plus reject_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).
  3. 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.

⚠️ Cross-tracker: ent#109 needs status-in-dev by hand.

@vybe
vybe enabled auto-merge (squash) August 3, 2026 10:13
@vybe
vybe merged commit f0ebb43 into dev Aug 3, 2026
23 checks passed
vybe pushed a commit that referenced this pull request Aug 3, 2026
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants