- _fetch_file_bytes: a DNS-poisoned / NXDOMAIN raw.githubusercontent.com raises
SSRFConnectionBlocked (a ValueError), which escaped the httpx.HTTPError suppress and
failed the install before the Contents API fallback. Any transport failure on the raw
attempt now falls back, and latches raw unusable on the source so a dropping firewall
costs one 15 s timeout, not one per file.
- ensure_bundled_skill: a leftover dest is refused if any entry in it is a symlink or
junction (copytree with dirs_exist_ok followed them out of skills/), and only missing
files are copied (a user-edited file was overwritten). The canonical-path "present"
check also requires the SKILL.md frontmatter name to match; a dir holding another
skill's SKILL.md is refused, as on main.
- Docstring: a restore over leftover user files reads as user-modified, so update keeps it.
`hermes-index rate_limited` (26 events / 9 installs in 14 days): GitHubSource.fetch
paid one Contents API call per file in the skill folder on top of repo + tree,
so an anonymous user (60/h) could never install a skill with ~58+ files and
four average skills drained the hour. Live on origin/main, anonymous:
skills-sh/remotion-dev/skills/remotion-best-practices cost 143 API calls and
failed rate_limited.
A file fetch pinned to the tree's commit sha now reads
raw.githubusercontent.com/<repo>/<sha>/<path> first: the same immutable bytes,
outside the REST limit. A raw miss (private repo, raw outage) falls back to the
Contents API as before. The same install now costs 2 API calls and succeeds
anonymously; anthropics/skills/pdf drops from 16 to 2.
Review fixes for the generic-dir fallback:
- SkillsShSource.fetch now names a generic-dir bundle after the slug too, so a skills.sh install
that bypasses the index no longer lands in a shared skills/ dir.
- HermesIndexSource.fetch applies the slug name whether the row resolved via resolved_github_id
or fell back to the tree, so a rebuilt index does not create a second lock entry ('skills').
- The tree fallback only runs for skills.sh rows: a deleted concrete GitHub path stays unavailable
instead of installing whichever skill remains in that repo under the requested name.
- _find_skill_in_repo_tree's lone-SKILL.md fallback only accepts a generic dir (skills,
.agents/skills, .claude/skills); a lone named skill (skills/bar) no longer answers for slug foo.
- The PR's two tests move to tests/tools/test_skills_hub_generic_dir.py (test_skills_hub.py is at its
code-health line cap).
skills.sh rows in the hermes-index carry the skills.sh slug as `path` and,
since mid-September, never a `resolved_github_id`: the scheduled index build
resolves paths AFTER the ClawHub walk, ~80 min into the job, when the 1 h
GitHub App token has expired. Every tree fetch 401s and the log says
"Resolved 0/20000 paths" (last good run 2026-09-15: 17917/20000). Installs
then work only where the repo dir happens to equal the slug, and everything
else is reported as a "stale index entry".
- build_skills_index: resolve skills.sh paths straight after the skills.sh
crawl, while the token is fresh.
- HermesIndexSource.fetch: when the indexed candidates miss, locate the skill
in the repo tree (slug-named dir, a lone SKILL.md in a generic dir such as
`skills/`, or the repo root) and install it under the slug, not as
`skills/`.
- _find_skill_in_repo_tree: a tree with exactly one non-root SKILL.md
resolves to it when no dir matches the slug (fails closed on 2+).
- GitHubAuth: once the credential chain came up empty, stay anonymous for the
instance instead of re-running `gh auth token` before every API call; with
gh installed but not logged in that cost up to 5 s per request and pushed an
anonymous install past four minutes.
Fixes#130129
Based on #130135 by @dskwe.
GitHubAuth resolved the .env PAT first and latched it for the process; GitHub's
401 Bad credentials was returned to callers as a plain miss, so every
GitHub-backed install failed even when `gh auth login` worked or the repo was
public, and the CLI blamed a "stale index entry".
- GitHubSource._github_get: on a 401 for the credential it sent, mark it
rejected and replay the same request (caller headers kept) with the next
method, ending anonymous.
- GitHubAuth: skips rejected tokens/methods; records a label per rejection.
`gh auth token` now goes through git_credentials._gh_cli_token, which strips
GH_TOKEN/GITHUB_TOKEN so gh returns its keyring login instead of echoing
the dead env token back.
- _print_fetch_failure: names the rejected credential and never calls such a
failure a stale index entry.
Credit: @easonlao's #46402 proposed the same fallback against the pre-split
tools/skills_hub.py.
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).
Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.
uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
skills.sh repos that keep SKILL.md at the repo root (no skill directory, e.g.
orzcls/win-disk-cleaner) are listed by `hermes skills search` but `inspect` and
`install` failed with "Could not find '<identifier>' in any source": discovery only
matched `<name>/SKILL.md` paths and the repo-root scan skipped non-directory entries by
construction, so no identifier form could express "the skill directory IS the repo root".
Install goes through the same resolver, so those hub-listed skills were uninstallable.
- GitHubSource._find_repo_root_skill() returns the `owner/repo/` identifier (empty path
segment = repo root) when the tree holds exactly one SKILL.md and it sits at the root,
so a categorized multi-skill repo never resolves this way.
- SKILL.md, support-file, bundle-name and source_url paths go through _skill_file_path(),
which collapses an empty skill path to the repo root; the bundle is named after the repo
(a root skill has no directory to be named after).
- SkillsShSource._discover_identifier() consults the root fallback after the tree walk.
Conflicts resolved toward the PM model: main's lazy_deps/update_cmd_deps/npm
stamp machinery stays deleted (PM + scripts/build/node-deps.mjs own it), the
systemd ExecStop stop-mark rides the installation launcher, legacy
linux_only/macos_only/windows_only markers are rewritten to platforms(), and
finalize_update_receipt carries pending manual-serve obligations forward
again (lost when the ContextVar receipt rewrite crossed c0aa3ce354).
Test harness: the real-home I/O guard exempts /proc/<pid>/fd metadata reads
(deleted-WAL holder scans) and run_tests.sh drops ~/.hermes PATH entries so
shutil.which() cannot trip the tripwire.
Gate review: a transient blob failure installs with a gap, and with the tree sha recorded the
update check would report that gap up_to_date until upstream moved (main re-fetched and healed
it). _collect_tree_files now reports completeness and fetch() records the sha only for a
complete bundle. _get_repo_tree caches its misses so the new probe cannot double the GETs on a
truncated tree, and a probe exception degrades one row to the full-fetch path instead of
aborting the whole check.
`check_for_skill_updates` fetched the full bundle (SKILL.md + every support
blob) for every installed hub skill just to re-hash it, even though each lock
entry already records the tree sha it was installed from. GitHubSource now
exposes `current_revision()` (one cached tree lookup per repo, no blob GETs)
and the update loop reports `up_to_date` without fetching when the recorded
`source_revision` still matches. Entries without a recorded revision, or
whose registry has no revision probe, keep the full-fetch path.
Closes#101454
Co-authored-by: gobeumsu <gobeumsu@gmail.com>
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
Follow-up polish on the provider-filter-before-limit fix:
- GitHubSource.search now skips taps whose repo maps to a different provider
instead of enumerating every tap and filtering afterwards. A tap's repo fixes
the provider of every result it yields (github_provider_for is the only source
of extra.provider in this adapter), so the skip is lossless and avoids up to 23
useless tap enumerations per provider-filtered search — real GitHub API calls
against the 60/hr unauthenticated budget and the 30s overall timeout when the
index is unavailable. The now-redundant post-loop filter is dropped.
- _provider_filter_of() is the single owner of "does --source name a provider";
it replaces the four inline copies of the strip/lower/membership idiom in
_select_active_sources, parallel_search_sources, unified_search and do_browse.
- _tap_cache_key() is shared by _list_skills_in_repo and the regression test so
the seeded tap cache can never drift from the production key format.
- _entry_provider() dedupes the raw-index provider extraction used by both the
pre-ranking filter and the scoring loop in HermesIndexSource.search.
- browse_skills (the TUI-gateway browse path) now applies the same merged
provider cut as do_browse; it accepted a provider value but returned
unfiltered results.
Validation: 121 targeted tests green; the regression tests go red on both
adapters when either the tap skip or the index pre-filter is neutralized, and
4/4 red on unpatched main; live CLI repro returns 0 results on main and 3/3
provider matches on this stack.
Seven sites hand-rolled `float(headers.get("Retry-After"))` (anon_auth,
shared_metrics_sender, gemini_native_adapter, extract_api_error_context,
nous_rate_guard, skills_hub_github, skills_hub_clawhub x2) and silently
dropped RFC 7231 HTTP-date values that the conversation loop already honours
via agent/retry_utils.py::parse_retry_after_seconds. They now call it; per-site
caps/floors stay at the call site.
The free-text "resets in / quotaResetDelay / retry after N s" regexes lived in
two tables (agent_runtime_helpers vs credential_pool) whose "resets in"
grammars diverged: the pool accepted only integer `Nhr Nmin` while the error
context accepted h/hr/hours + m/min/minutes + s/seconds with decimals. One table
(agent/retry_utils.py::RETRY_DELAY_PATTERNS / reset_delay_from_message) using
the wider grammar, so a pooled credential's cooldown and the UI's reset time
now agree.
Preserve upstream fixes without restoring retired dependency installers.
Run configured-feature checks in the selected build interpreter. Reuse a
supported base Python during bootstrap, and preserve durable backup media.
Refresh the dependency lock through PM. Keep the frozen historical import
surface unchanged. Adapt incoming native tests to the platform markers.
Verification: the incoming 86-file pass found two fixture mismatches;
both passed after correction. Targeted PM/update/compatibility checks,
Electron and renderer typechecks, and desktop tests passed.
Native Windows/macOS update journeys and the full suite remain unrun.
~480 scientific research skills become searchable/installable through the Skills Hub
with nothing vendored: K-Dense-AI/scientific-agent-skills (165, MIT) and
synthetic-sciences/openscience (314 across 17 category paths, Apache-2.0).
A new optional tap-level `bucket` key stamps extra["category"] on every skill from a
tap whose repo ships no skills.sh.json grouping, so several repos surface as one hub
category; a sidecar grouping still wins when present. Both repos stay at community
trust (not in TRUSTED_REPOS) so the guard scans every install.
Re-grafted from #60559 onto the post-split tools/skills_hub_github.py.
The footgun class-fix left 23 files with unquoted encoding=utf-8-sig
(and main.py an f-string with nested double quotes) — SyntaxError /
NameError 'utf' at every import. Quoted all encoding values, re-verified
py_compile across every staged .py.
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.