fix(skills): match the legacy library URL by repo, not by raw string (#2763) - #2764
Conversation
…2763) `_adopt_legacy_clone` decided whether a legacy `skills_library_url` named an already-configured source with raw string equality, while the rest of the platform stores that URL normalized. The same repository written two ways therefore never matched, the install took the ent#346 "already has sources" refusal branch on every sync, and filed a fresh un-deduped high-priority alert each time (#2744 is that flood). `validate_skills_library_url` is a validator AND a normalizer (`github.com/o/r` -> `https://github.com/o/r`), and `routers/skills.py:649` uses it as one when it stores a source. This path called it and discarded the return. Both representations occur on real installs by construction: the bundled default source is seeded from `config.TRINITY_DEFAULT_SKILL_SOURCE`, a bare literal that never passes through the validator, while a source created via `POST /api/skills/sources` is stored normalized. So either direction of the mismatch is reachable, and both are covered. Two changes: assign the validator's return, and compare through the new `_same_skills_repo`, which normalizes the STORED side too — normalizing only the setting would still miss every install whose source came from the seed. `reject_embedded_credentials` keeps seeing the ORIGINAL string; it must judge what was actually written, not a form we produced. This cannot weaken ent#346. A match returns an existing source id and creates no row, so it is the no-op branch; the grant branch (`count_skill_sources() == 0` -> `create_skill_source`) is untouched, and a key naming a genuinely different repo still reaches the refusal. Normalizing removes false positives from the detector without widening what may be granted — asserted by two of the eight tests, not just claimed here. Scope is deliberately the scheme/no-scheme split the platform itself creates. `…/repo.git`, a trailing `/` and the bare `owner/repo` shorthand stay distinct and are pinned by a test, so collapsing them later is an argued ent#346 decision rather than a quiet widening. Verified: reverting ONLY the call site (keeping the helper defined) fails exactly the two regression cases and leaves the other six green — the fix flips what it targets and nothing else. 496 passed across every skills-related suite. Fixes #2763 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
Validated against a live instance, not just unit testsRan the whole thing end-to-end on a local dev instance (PostgreSQL, Precondition — the real-world shape, not a contrived oneSame repository. Two representations. Set exactly as it occurs in the wild: the source row is the bundled default seeded from Before the fix — reproduced exactly as reportedOne sync produced one permanent And it grows without bound — one alert per sync, each with a distinct un-deduped id and In the UI this read as "5 pending responses" with the Operations badge at 21, four of the five being this alert — and the one item that genuinely needed a person ("A Workspace client rated a response as not useful") pushed to the bottom of the list. That is #2744's "it degrades the gate it lives in", visible. After the fix — same precondition, unchangedThe setting and the source row were left exactly as they were; only the code changed (backend restarted on the patched Nine syncs, zero alerts. Badge 21 -> 17, "5 pending responses" -> "1 pending response" — and the Workspace rating item is now at the top, unburied. Negative control — the detector still detectsThe part that matters for ent#346. With the fix live, the setting was repointed at a genuinely different repository: The alert fires, names the offending repo, and reaches RestoredLegacy key deleted (absent, as originally found), test alerts cleared, the patched file reverted byte-for-byte from backup, backend restarted on the original code, Screenshots of all three states (before / after / negative control) were captured and are with the reviewer. |
Review:
|
| Category | Status | Notes |
|---|---|---|
| Lane | B | services/skill_service.py + a test; no auth/credential/parser/image/CI path |
| Base branch | ✅ | dev |
| Closing keyword | ✅ | Fixes #2763 — same-repo, so the promotion to status-in-dev fires automatically |
| Size | ✅ | 2 files, +190/−2 |
| Security greps | ✅ | clean |
| Docs | ➖ | bug fix → descriptive commit only, per tiered docs |
| Test adequacy | ✅ | 8 tests; reverting only the call site fails exactly the 2 regressions and leaves 6 green |
| Merge gate | 🟡 | MERGEABLE / BLOCKED — needs a review, no failing checks |
/review — structural
Critical: none.
Verified rather than assumed:
- The fix cannot widen what matches. Two different repos cannot normalize to the same string —
validate_skills_library_urlonly adds a scheme; it never rewrites owner or repo. - The grant branch is untouched.
count_skill_sources() == 0 → create_skill_sourceis byte-identical, and a test asserts it registers the rawurlexactly as before. reject_embedded_credentialsstill sees the ORIGINAL string, not the normalized form — it must judge what was actually written. Pinned bytest_a_credential_bearing_key_is_still_refused_without_echoing_it.- Fail-safe direction. An unparseable stored url returns
False→ falls through to the refusal, rather than being treated as equal and silently adopted.
[I1] The comparison now makes a DNS call, and the code does not say so (Confidence: 9/10)
_same_skills_repo calls validate_skills_library_url on the stored side. That function is not pure — it performs SSRF defence-in-depth:
# utils/url_validation.py
# Defense-in-depth: resolve hostname and reject private/internal IPs
resolved_ips = socket.getaddrinfo(hostname, None)So a comparison that used to be == can now issue a blocking DNS resolution, on a path (_adopt_legacy_clone) that runs on every sync_library(). I did not know this when I wrote the helper, and nothing in the diff warns the next reader.
Three things keep the impact small, and they are worth stating because they are why this is informational rather than a defect:
- The exact-match short-circuit runs first (
if stored_url == normalized_url: return True), so a correctly-stored install never reaches the validator. Only the broken case — the one this PR fixes — takes the slow path, and only until the key is cleared. - A DNS failure is tolerated upstream —
except socket.gaierror: pass— so an outage does not turn a legitimate match into a refusal. - The source list is 1–2 rows on a real install, and the caller is already about to do git I/O.
But there is one narrow regression path worth knowing about: if a stored URL's hostname resolves to a private/internal address, the validator raises ValueError, _same_skills_repo returns False, and the install starts emitting the very alert this PR exists to stop. That needs github.com to resolve privately — a split-horizon DNS or an internal mirror — so it is unlikely, not impossible.
Cheapest fix is a comment naming the DNS call and the short-circuit that avoids it. A pure string normalizer would remove the network call entirely, but it would be a second implementation of a policy that deliberately lives in one place, which is the trade #2763 was fixing in the first place — so I would not.
Note on scope
.git suffixes, trailing slashes and the bare owner/repo shorthand stay distinct after normalization, pinned by test_the_documented_tail_is_still_distinct. That is deliberate — collapsing them changes which strings count as "already configured", which is an ent#346 decision rather than a tidy-up.
🤖 Generated with Claude Code · https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…2764) — mechanical, per the merge-train note on the PR #2777 passes steady_state=True to the same call; without **kw the stub raises TypeError on the merged tree and the ent#346 negative control fails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtVjZEsxjyz2E4x5XdQg99
|
merge-train note — pushed one mechanical commit ( |
vybe
left a comment
There was a problem hiding this comment.
merge-train: batch validated on train/20260914-1332



Fixes #2763
The defect
_adopt_legacy_clonedecided whether a legacyskills_library_urlnamed an already-configured source with raw string equality, while the rest of the platform stores that URL normalized. The same repository written two ways never matched, so the install took the ent#346 "already has sources" refusal branch on every sync — forever — and filed a fresh un-deduped high-priority alert each time. That flood is #2744; this is its cause.validate_skills_library_urlis a validator and a normalizer, and the write route uses it as one:Both representations occur on real installs by construction:
config.TRINITY_DEFAULT_SKILL_SOURCE(config.py:522), a bare literal that never passes the validatorgithub.life-white.uk/abilityai/trinity-skillsPOST /api/skills/sourceshttps://github.com/abilityai/trinity-skillsSo either direction of the mismatch is reachable, and both are covered.
The change
Two lines of behaviour, plus a helper:
normalized_url);_same_skills_repo, which normalizes the stored side too — normalizing only the setting would still miss every install whose source came from the seed;reject_embedded_credentialskeeps seeing the original string. It must judge what was actually written, not a form we produced.Why this cannot weaken ent#346
A match returns an existing source id and creates no row — it is the no-op branch. The grant branch (
count_skill_sources() == 0→create_skill_source) is untouched, and a key naming a genuinely different repo still reaches the refusal exactly as before.Normalizing removes false positives from the detector; it does not widen what may be granted. That is asserted by two of the eight tests, not just claimed here (
test_a_genuinely_different_repo_still_reaches_the_ent346_refusal,test_the_grant_branch_is_untouched_on_a_pre_migration_install).Verification
Every test drives the real
_adopt_legacy_clone; the only stubs aredbandlibrary_root.Reverting only the call site — keeping the helper defined, so the module still imports and the behavioural tests actually run — fails exactly the two regression cases and leaves the other six green:
With the fix:
8 passed. The fix flips what it targets and nothing else.496 passed, 2 skippedacross every skills-related suite (-k "skill or 346 or 237").Scope
Deliberately the scheme/no-scheme split the platform itself creates. These stay distinct and are pinned by a test, so collapsing them later is an argued ent#346 decision rather than a quiet widening:
The grant branch still registers the raw
url, unchanged — normalizing what gets created is a behaviour change to the branch this PR claims not to touch.Relationship to #2744
Complementary, not overlapping — #2744 is assigned and in progress, and this does not take any of its three suggestions:
🤖 Generated with Claude Code
https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ