fix(git): resolve the agent repo root with git rev-parse, not a content probe (#2075) - #2076
fix(git): resolve the agent repo root with git rev-parse, not a content probe (#2075)#2076AndriiPasternak31 wants to merge 2 commits into
Conversation
…pace/ for a home-rooted repo
Adds tests/unit/test_2075_git_root_detection.py, pinning the repo-root
detection contract for agent containers.
Four cases fail against current code:
- a home-rooted repo with a populated non-git workspace/ resolves to
workspace/ instead of /home/developer;
- a valid root is not recovered from noisy probe output;
- the content heuristic is issued even when git can answer;
- the .gitignore migration's [ -d <dir>/.git ] guard is issued against
workspace/, fails, and the whole migration silently no-ops.
The remaining cases pin behaviour that must NOT change: the legacy
workspace-rooted layout, the no-repository fallback that decides where
git init lands, look-alike root rejection, and exception propagation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt probe (#2075) _detect_git_dir inferred an agent's repository root from whether /home/developer/workspace had any content, which tests content rather than legacy-ness. An agent whose repo is rooted at /home/developer but which also keeps a populated non-git data directory under workspace/ was reported as workspace-rooted, so every consumer that treats the result as a filesystem path acted on a subdirectory: the compatibility collector snapshotted the wrong root, the per-Push .gitignore migration's [ -d <dir>/.git ] guard failed and silently no-opped, and the compatibility fix endpoint wrote a .gitignore that governs only that subdirectory. Ask git instead. `git rev-parse --show-toplevel` walks up from the starting directory, so the nearest enclosing repository wins and a genuinely workspace-rooted legacy repo still resolves to workspace/. Only a root that is /home/developer or below is accepted, so `/`, `/home` and look-alikes such as `/home/developer2` are rejected rather than acted on. The content heuristic is retained verbatim as the no-repository fallback: initialize_git_in_container uses this value to choose where to run `git init`, so fresh-agent placement stays byte-compatible. Exceptions still propagate, so the push path's detect-stage error and the inspection path's unknown-never-agreement invariant are unchanged. This also makes _detect_git_dir agree with check_git_initialized, which already resolved these agents correctly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Resolve by running |
Recommend closing this as superseded by #2077 — not fixing its CII picked this up to fix the red #2075 was fixed twice, independently
Two different implementations of the same idea:
I checked the one thing this branch does that
|
|
Bump from the field — this is blocking honest compatibility panels on a live OSS instance. Three agents there (
Happy to verify on that instance once it merges and the box upgrades — I can report the before/after Anything I can add here to help it land? |
|
Concrete before-evidence from the live instance, for whoever reviews this.
Verified with The two Happy to re-run and post the after-table once this merges and the box upgrades. |
…eze-plan update (#2121) * chore(enterprise): bump submodule pin d1c5ebb → 2d64baa (ent#356 half, rooms fixes, session-policy API) The pin was left at d1c5ebb when the OSS half of the client_portal move (#2084, ent#356) merged — entitled builds mount a duplicate router on the same prefix and advertise a false client_portal entitlement (freeze-plan C2 / MUST-immediate #2). Advancing to the enterprise-main tip also ships: - bce1175 — remove client_portal from the enterprise tree (ent#356) - e0a2ef4 — rooms: message budget counts conversation, not bookkeeping (ent#218) - 0bee510 — rooms: post the reply before advancing the cursor (ent#220 partial) - 2d64baa — workspace: managed session-policy API (ent#375 enterprise half; OSS half merged as #2099) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(release): v0.9.0 freeze plan — day-1 recheck, A15/A16 scope adds, Wave B owner - RECHECK 2026-08-12: MUST-immediate 3/3 resolved; review-only items landed; #2076/#2042 still in review; trinity#2101 re-scoped (briefing grid fixed in #2113, tool-activity grouping rides B7/ent#286) - Wave A +2: A15 ent#384 (Library skills assignment, in progress), A16 #1958 (unit tests in dev required checks — freeze-week guard) - P0/P1 triage: all other open P0/P1 already status-in-dev; #2060 stays C12-conditional; #1819 held behind B11 - Wave B owner: dolho (all items, 2026-08-12); critical path B8 → B6 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: trinity-ability <trinity-ability@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Closes #
Symptom
_detect_git_dirdecided an agent's repository root by probing whether/home/developer/workspacehad any content. That tests content, notlegacy-ness: an agent whose repository is rooted at
/home/developerbut whichalso keeps a populated, non-git
workspace/data directory was classified asworkspace-rooted. Every consumer that treats the result as a filesystem path then
read and wrote a subdirectory's files as if they were the repository root's — the
compatibility collector snapshotted the wrong root (producing a false
F-001 template.yaml exists, which cascades into 24no_templateskips), theper-Push
.gitignoremigration's[ -d <dir>/.git ]guard failed and the wholemigration silently no-opped, and
POST /{agent}/compatibility/fixwrote itscorrection into a
.gitignorethat governs only that subdirectory — then verifiedits own write and reported success. A security-relevant check could therefore pass
on the strength of a rule that does not apply to the files it names.
Fix
Ask git.
git rev-parse --show-toplevel, run from/home/developer/workspacewhen it exists and from
/home/developerotherwise, returns the root of thenearest enclosing repository —
--show-toplevelwalks up, so a genuinelyworkspace-rooted legacy agent still resolves to
workspace/while a standardagent resolves to
/home/developereven with a populatedworkspace/.GIT_DISCOVERY_ACROSS_FILESYSTEM=1is set becauseworkspace/is a plausiblemount point and git otherwise stops discovery at a filesystem boundary, which
would silently reintroduce the exact misclassification this fixes. That is safe
only because
_parse_git_rootaccepts an answer only when it is/home/developeror below —/,/homeand look-alikes such as/home/developer2are rejected, each covered by a test.Caller audit (all 7)
Git discovers the enclosing repo by walking up from the cwd, so a caller that
merely
cds into the returned directory and runs a plain git command was alreadyoperating on the right repository. Only callers that treat the result as a
path were broken.
update_remote_pat:211rebind_origin_and_push:325stage="detect"path preservedinspect_container_git:453_migrate_workspace_gitignore:1453.gitguard fails → silent no-op.gitignoreinitialize_git_in_container:1533git initlandscompatibility/collector.py:176compatibility/fixes.py:172.gitignore.gitignoreWhy not just reuse
check_git_initialized?Worth preempting, because
check_git_initialized:1698-1727already resolvesaffected agents correctly (
[ -d workspace/.git ]then[ -d /home/developer/.git ]). The honest framing is that this change makes thetwo helpers agree rather than adding a third opinion — the file previously
held three different answers to one question (
_detect_git_dir's heuristic,check_git_initialized, and_append_agent_gitignore:709's hardcoded/home/developer). This matters beyond tidiness:check_git_initializedgatesthe
409 Git sync already configuredatrouters/git.py:388-397, so the twohelpers disagreeing was caller-visible.
It is not reused directly because it answers a different question — is there
a
.githere?, returningOptional[str]withNonewhen absent — whereascallers of
_detect_git_dirneed a directory even when no repository existsyet:
initialize_git_in_containeruses it to choose where to create one.Collapsing them would either break init placement or force every caller to handle
None. Consolidating the two is a reasonable follow-up, out of scope here.The no-repo fallback is retained verbatim
The old content heuristic is kept byte-identical as the no-repository
fallback, including its
"1" in outputsubstring test. This is deliberate and isthe safety argument for caller #5:
initialize_git_in_containeruses this valueto choose where
git initruns, so fresh-agent placement must not move. The"1" in outputlooseness is not where the bug lives — the bug is using a contentprobe to answer a repo-topology question — and tightening it would be an
unrequested behaviour change on the exact path whose byte-compatibility is the
safety argument. Two tests pin this (populated →
workspace/, empty → home).Cost: 1 exec in the common repo-present case, 2 in the no-repo case (previously
always 1), plus one
warninglog line when no repository resolves.Exceptions are not swallowed.
execute_command_in_containeralready convertsa dead container into a non-zero exit rather than a raise, so a genuine raise is
a real fault — swallowing it would destroy caller #2's
stage="detect"messageand caller #3's unknown-never-agreement invariant (ent#109 AC #5). A test pins
propagation.
Operator notes — behaviour changes on already-affected agents
next Push appends the fleet-wide
_GITIGNORE_PATTERNSto the real root.gitignoreandgit rm --cacheds every tracked file that now matches arule. Working-tree files are untouched and history is not rewritten —
anything already pushed stays in history, so any credential rotation is
separate ops follow-up, not part of this PR.
initialize_git_in_containeron anaffected agent runs at
/home/developerrather thanworkspace/, so the.gitignoremerge,git init,git add .and (on the empty-remote path)git push -u origin main --forcecover the whole home directory — a muchlarger add surface. The ordering is safe by construction: the ignore merge
(
:1545-1549) happens beforegit init(:1566) andgit add ., so thefleet-wide rules are in force at the real root before anything is staged.
Relatedly,
git initat/home/developerwhere a repo already exists is aharmless re-init, whereas the pre-fix behaviour could create a nested repo
inside
workspace/.Known limitation — agents this does not repair
An agent already re-initialised while misdetected now has a real
workspace/.git, i.e. a genuine nested repository, and is indistinguishablefrom a legitimate legacy agent by any probe — git's own answer for it is
workspace/. This fix keeps resolving it toworkspace/: correct by the newrule, still wrong by intent. Repairing those needs a per-agent operator decision,
not an algorithm.
Exposure is bounded:
routers/git.py:388-397refuses re-initialisation with a409whenever a git-config row exists andcheck_git_initializedfinds a.git— and that is one of the correct helpers. Only an agent with anorphaned config row (row present, no
.git) reaches the re-init path.Read-only ops detection, per agent:
Both markers present ⇒ nested repo ⇒ needs a human decision.
Follow-up filed separately (not widened into this diff)
_detect_git_dirandcheck_git_initializedcan still diverge when.gitis afile rather than a directory — the pointer form used by
git worktreeand bysubmodule checkouts.
git rev-parseresolves it;[ -d …/.git ]does not. Theresult would be
_detect_git_dirreporting a root whilecheck_git_initializedreports
None(so the re-init409fails to fire), and_migrate_workspace_gitignore's own[ -d <dir>/.git ]guard still bailing at aroot the probe just resolved. Pre-existing in kind and not triggered by any
current agent layout.
Tests
TDD, two commits: the failing test first, then the minimal fix.
RED — new test file against unfixed code:
The migration case shows the defect directly — the guard is issued against the
wrong directory and no further command is ever sent:
The 8 that passed pre-fix are the invariants that must not move: the legacy
workspace-rooted layout, both no-repository fallback branches, look-alike root
rejection, and exception propagation.
GREEN — after the fix:
Neighbours (post-fix):
Full unit suite, identical pinned flags before and after
(
-m "not slow" -p no:randomly -p no:cacheprovider;pytest-randomlyis pinnedoff so the two runs are comparable):
Delta: +12 passed, +0 failed. The +12 is exactly the new test file.
The single failure is pre-existing and unrelated — it reproduces on untouched
devbefore any change in this PR, and is not addressed here:🤖 Generated with Claude Code