Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Routes: `POST /api/skills`, `POST /api/skills/sync`, `POST /api/skills/install`,

- `F25.auth`: every skills route answers 401 without a valid session key, and nothing is installed.
- `F25.public-repo`: against the real public extensions repository (network), sync clones it into the public cache and `POST /api/skills` with `load_public` lists skills loaded from that clone.
- `F25.marketplace-public`: against the real public extensions repository, `GET /api/skills/marketplace` lists its installable entries; today it returns an empty catalog because the repository has `.plugin/marketplace.json` but no `marketplaces/default.json`.
- `F25.marketplace-public`: against the real public extensions repository, `GET /api/skills/marketplace` lists its installable entries, discovered from `.plugin/marketplace.json` (the repository has no `marketplaces/default.json`).
- `F25.catalog-project`: `POST /api/skills` offline with a `project_dir` lists the project's AgentSkills (`agentskills`), legacy trigger skill (`knowledge`) and `AGENTS.md` (`repo`), counts them in `sources.project`, and silently skips a SKILL.md with an invalid name.
- `F25.catalog-user-precedence`: user skills from `~/.agents/skills` appear only with `load_user`, and a project skill overrides a user skill of the same name.
- `F25.catalog-server-sources`: `sandbox_config` URLs named `WORKER_*` become one always-on `work_hosts` skill, and `org_configs` repositories are cloned from a git URL (an unreachable one contributes nothing).
Expand Down Expand Up @@ -114,10 +114,10 @@ Preconditions:
preset themselves; the TypeScript bullet needs `node`.
No launch flags are needed. Outbound HTTPS
to github.com is needed by `F25.public-repo` and `F25.install-github`, which
arrange the network state for the two known-bug bullets after them
arrange the network state for the bullets after them
(`F25.marketplace-public`, `F25.install-documented-sources`); every other
bullet runs offline (apart from the DeepSeek calls). Each of these recipes
declares what it needs on its own `Needs:` line (the two known-bug bullets
declares what it needs on its own `Needs:` line (those two bullets
inherit `network` through `Requires:`), so a machine without a model key,
network or `node` reports just those recipes blocked and drives the rest. `F25.ts-client` builds
`clients/typescript/dist` with `npm ci && npm run build` only when it is
Expand Down Expand Up @@ -195,21 +195,21 @@ Preconditions:
the public tier lists skills whose `source` is a `SKILL.md` inside the
clone. This bullet is the network arrange step for
`F25.marketplace-public`; without network it fails here.
- **Public marketplace catalog (`F25.marketplace-public`), known bug.** Read
- **Public marketplace catalog (`F25.marketplace-public`).** Read
the catalog against the clone of the real `OpenHands/extensions`
repository that `F25.public-repo` made.

Requires: `F25.public-repo`
```sh
control-agent-server api GET /api/skills/marketplace --timeout 180 --expect 200 --check skills len-ge 1 --save F25.marketplace-public/catalog # bug: status eq 200 && response.body.skills len-eq 0
control-agent-server api GET /api/skills/marketplace --timeout 180 --expect 200 --check skills len-ge 1 --save F25.marketplace-public/catalog
```
The clone holds `.plugin/marketplace.json` with about 70 plugin entries
whose sources are `./skills/<name>`, so the catalog should list them. Today
it returns `{"skills": []}`: `_fetch_catalog_entries` gives up when
`marketplaces/default.json` is missing (the repository ships
`marketplaces/openhands-extensions.json` and `large-codebase.json`
instead) before it tries `Marketplace.load`, and the empty list is then
cached for 5 minutes.
whose sources are `./skills/<name>`, and the catalog lists them (73 on
2026-10-13), each `source` an absolute path inside the clone.
`_fetch_catalog_entries` loads the manifest with `Marketplace.load` first
and reads `marketplaces/default.json` only as a fallback; the repository
ships `marketplaces/openhands-extensions.json` and `large-codebase.json`
instead of `default.json`.
- **Project skills (`F25.catalog-project`).** Load only the fixture project.
```sh
control-agent-server api POST /api/skills --json '{"load_public": false, "load_user": false, "load_org": false, "project_dir": "'"$P"'"}' \
Expand Down Expand Up @@ -819,8 +819,8 @@ Preconditions:
The sync brings the new entry into the clone, so a refreshed catalog should
list it. Today the catalog serves its module-level 5-minute cache: sync only
clears the public-skills cache, and unlike that cache the catalog also
caches an empty result (so an offline first call or `F25.marketplace-public`
leaves the tab empty for 5 minutes). A restart clears it.
caches an empty result (so an offline first call leaves the tab empty for
5 minutes). A restart clears it.
- **Uninstall (`F25.uninstall`).** Remove the marketplace skill and the local
skill.
```sh
Expand Down Expand Up @@ -875,8 +875,8 @@ Preconditions:
the local stand-in work: replacing `public-skills` with a clone of a local
repository makes `load_public`, sync and the marketplace catalog offline and
deterministic. The real repository has no `marketplaces/default.json`, so
`load_public` loads every `skills/` entry there while the catalog stays
empty (`F25.marketplace-public`).
`load_public` loads every `skills/` entry there, while the catalog reads
`.plugin/marketplace.json` (`F25.marketplace-public`).
- Install sources: plain absolute paths are copied; `file://` and other URLs
are cloned into `$OH_PERSISTENCE_DIR/cache/extensions/`. The examples in the
OpenAPI description of `InstallSkillRequest.source` do not work
Expand Down Expand Up @@ -907,10 +907,11 @@ Preconditions:
skill body in their reply, so select the `InvokeSkillObservation` by its
`skill_name` before looking for the body text.
- Network: only `F25.public-repo` and `F25.install-github` reach github.com.
They are normal bullets so that the two known-bug bullets after them start
from a proven clone or install, and those bullets `Require` them; without
network all four are reported `blocked` rather than failing or reporting a
misleading `xfail`.
They are normal bullets so that the two bullets after them
(`F25.marketplace-public`, `F25.install-documented-sources`) start from a
proven clone or install, and those bullets `Require` them; without network
all four are reported `blocked` rather than failing or reporting a
misleading result.
- `conversation events --contains` fails when no selected event contains the
text, so it asserts content on its own. `ws listen` has no content filter;
`--until-kind K --until FIELD=VALUE` fails unless a frame of kind `K` with
Expand Down
16 changes: 10 additions & 6 deletions openhands-agent-server/openhands/agent_server/skills_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -721,15 +721,19 @@ def _fetch_catalog_entries(marketplace_path: str) -> list[_CatalogEntry]:
logger.warning("Failed to access public skills repository")
return []

marketplace_file = repo_path / marketplace_path
if not marketplace_file.exists():
logger.warning(f"Marketplace file not found: {marketplace_file}")
return []

# Manifest discovery (``.plugin/marketplace.json``, the current
# OpenHands/extensions layout) comes first; the explicit
# ``marketplace_path`` file is only a fallback, as in the plugin catalog.
try:
marketplace = Marketplace.load(repo_path)
except (FileNotFoundError, ValueError) as e:
# Fallback to loading from specific path
marketplace_file = repo_path / marketplace_path
if not marketplace_file.exists():
logger.warning(
f"Failed to load marketplace via manifest discovery ({e}); "
f"fallback file not found: {marketplace_file}"
)
return []
try:
with open(marketplace_file, encoding="utf-8") as f:
data = json.load(f)
Expand Down
57 changes: 57 additions & 0 deletions tests/agent_server/test_skills_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1002,3 +1002,60 @@ def test_cache_expires_after_ttl(self):
service_get_marketplace_catalog()

assert mock_fetch.call_count == 2 # fetched again after expiry


class TestFetchCatalogEntries:
"""Tests for _fetch_catalog_entries manifest discovery and fallback."""

def test_loads_plugin_manifest_without_default_json(self, tmp_path):
"""The real extensions layout has .plugin/marketplace.json and no
marketplaces/default.json; the catalog must still list its entries."""
from openhands.agent_server.skills_service import _fetch_catalog_entries

repo = _create_test_marketplace(tmp_path / "extensions")
assert not (repo / "marketplaces" / "default.json").exists()

with patch(
"openhands.agent_server.skills_service.update_skills_repository",
return_value=repo,
):
entries = _fetch_catalog_entries("marketplaces/default.json")

assert sorted(name for name, _, _ in entries) == ["auto", "manual"]

def test_falls_back_to_explicit_marketplace_path(self, tmp_path):
"""Without a .plugin manifest, the explicit marketplace file is used."""
from openhands.agent_server.skills_service import _fetch_catalog_entries

repo = tmp_path / "extensions"
_create_standalone_skill(repo / "skills" / "greet", "greet")
(repo / "marketplaces").mkdir(parents=True)
(repo / "marketplaces" / "default.json").write_text(
json.dumps(
{
"name": "fallback",
"owner": {"name": "Test Team"},
"plugins": [],
"skills": [{"name": "greet", "source": "./skills/greet"}],
}
)
)

with patch(
"openhands.agent_server.skills_service.update_skills_repository",
return_value=repo,
):
entries = _fetch_catalog_entries("marketplaces/default.json")

assert [name for name, _, _ in entries] == ["greet"]

def test_returns_empty_without_manifest_or_fallback(self, tmp_path):
from openhands.agent_server.skills_service import _fetch_catalog_entries

repo = tmp_path / "extensions"
repo.mkdir()
with patch(
"openhands.agent_server.skills_service.update_skills_repository",
return_value=repo,
):
assert _fetch_catalog_entries("marketplaces/default.json") == []
Loading