diff --git a/apps/api/pytest.ini b/apps/api/pytest.ini index 8609b3c..392210a 100644 --- a/apps/api/pytest.ini +++ b/apps/api/pytest.ini @@ -34,5 +34,6 @@ python_files = test_suite_v060.py test_suite_v062.py test_suite_v0610.py test_se test_agent_versions.py test_agent_redeploy.py test_package_revocation.py + test_deps_allowlist.py markers = no_api_key: test does not require WAYFORTH_TEST_API_KEY (e.g. probes unauthenticated paths) diff --git a/apps/api/routers/cloud.py b/apps/api/routers/cloud.py index c5e87da..b7b032a 100644 --- a/apps/api/routers/cloud.py +++ b/apps/api/routers/cloud.py @@ -820,6 +820,24 @@ async def rollback_version(request: Request, agent_id: str, db=Depends(get_db)) return {"id": agent_id, "rolled_back_to": out["version_no"], "version_id": out["version_id"]} +def _allowlist_payload() -> dict: + """The requirements-picker payload: name → sorted versions, + the dep cap. Hashes are + a build-time integrity detail and are dropped here — they NEVER reach the client.""" + from services.agent_deps import MAX_DIRECT_DEPS, load_lockfile + lock = load_lockfile() # {name: {version: [hashes]}} + return {"packages": {name: sorted(versions.keys()) for name, versions in lock.items()}, + "max_direct_deps": MAX_DIRECT_DEPS} + + +@router.get("/deps/allowlist") +@limiter.limit("60/minute") +async def deps_allowlist(request: Request, db=Depends(get_db)) -> dict: + """Installable packages for the requirements picker: name → versions (no hashes).""" + _user_id, _, tier = await _resolve_caller(request, db) + require_tier(tier, "cloud_agents") + return _allowlist_payload() + + @router.get("/agents") @limiter.limit("60/minute") async def list_agents(request: Request, db=Depends(get_db)) -> dict: diff --git a/apps/api/tests/test_deps_allowlist.py b/apps/api/tests/test_deps_allowlist.py new file mode 100644 index 0000000..9aaecd7 --- /dev/null +++ b/apps/api/tests/test_deps_allowlist.py @@ -0,0 +1,33 @@ +"""test_deps_allowlist.py — GET /cloud/deps/allowlist payload (requirements picker source). + +The load-bearing assertion: HASHES NEVER REACH THE CLIENT — the picker gets only package +names + installable versions. Tests the exact transform the endpoint serves. +""" +import json + +from routers.cloud import _allowlist_payload +from services.agent_deps import MAX_DIRECT_DEPS, load_lockfile + + +def test_payload_shape(): + p = _allowlist_payload() + assert set(p) == {"packages", "max_direct_deps"} + assert p["max_direct_deps"] == MAX_DIRECT_DEPS + assert isinstance(p["packages"], dict) and p["packages"] # non-empty + + +def test_no_hashes_reach_the_client(): + blob = json.dumps(_allowlist_payload()) + assert "sha256" not in blob # not a single hash + # every value is a list of version strings — never the {version: [hashes]} sub-dict + for name, versions in _allowlist_payload()["packages"].items(): + assert isinstance(versions, list) + assert all(isinstance(v, str) and "sha256" not in v for v in versions) + + +def test_names_and_versions_match_the_lockfile(): + lock = load_lockfile() + pkgs = _allowlist_payload()["packages"] + assert set(pkgs) == set(lock) # same package names + for name, versions in pkgs.items(): + assert versions == sorted(lock[name].keys()) # same versions, sorted diff --git a/docs/agent-editor-frontend-contract.md b/docs/agent-editor-frontend-contract.md index 5b3cee8..1dbd827 100644 --- a/docs/agent-editor-frontend-contract.md +++ b/docs/agent-editor-frontend-contract.md @@ -20,7 +20,7 @@ mirror is up). When OFF, those endpoints return **`409 versioning_disabled`**. | Endpoint | Flag-gated? | Testable now? | |---|---|---| | `GET /cloud/agents/{id}/versions` | no | **yes** (returns the backfilled v1 today) | -| `GET /cloud/deps/allowlist` *(to add — §2)* | no | yes, once implemented | +| `GET /cloud/deps/allowlist` | no | **yes** (live) | | `GET /templates`, `GET /templates/{id}` | no | **yes** (already live) | | `POST /cloud/agents/{id}/deploy` | **yes** | after the flip (409 until then) | | `POST /cloud/agents/{id}/rollback` | **yes** | after the flip (409 until then) | @@ -116,13 +116,12 @@ rebuild). Roll **forward or backward** to any prior version that was actually li ## 2. The requirements picker — the allowlist source -**Answer to "is there a GET, or is it static?":** the allowlist is currently a **static -server-side lockfile** (`services/agent_deps_lock.json`, shape `{name: {version: [hashes]}}`) -with **no public endpoint** yet. The frontend cannot and should not read that file or bundle -a copy (it drifts + leaks hashes). So this contract specifies a small read-only endpoint to -add: +**Answer to "is there a GET, or is it static?":** the allowlist is a static server-side +lockfile (`services/agent_deps_lock.json`, shape `{name: {version: [hashes]}}`). The +frontend must not read that file or bundle a copy (it drifts + leaks hashes), so it's +served — hashes stripped — by a dedicated endpoint: -### `GET /cloud/deps/allowlist` — **NOT YET IMPLEMENTED (trivial to add)** +### `GET /cloud/deps/allowlist` — **live** **Response `200`** ```json { @@ -142,9 +141,9 @@ add: `name==version` line into `requirements`. Anything not in this map will be rejected at deploy with `not_allowed` (§4) — so gate the "add" button on this list. -> **Action needed (one-liner, your call):** implement `GET /cloud/deps/allowlist` returning -> the lockfile minus hashes. Until then, the picker has no data source. Flagging it here so -> it's not discovered mid-build. +> **Auth + security:** authenticated `cloud_agents` tier (same as the rest of `/cloud/*`). +> Hashes are a build-time integrity detail and are **never** sent — the payload is only +> names + versions. ---