diff --git a/hermes_cli/web_routers/skills.py b/hermes_cli/web_routers/skills.py index e66fd9b6bb..f77ef46382 100644 --- a/hermes_cli/web_routers/skills.py +++ b/hermes_cli/web_routers/skills.py @@ -416,13 +416,14 @@ async def get_skill_content(name: str, profile: Optional[str] = None): @router.post("/api/skills") -async def create_skill(body: SkillCreate): +async def create_skill(body: SkillCreate, profile: Optional[str] = None): """Create a skill via the agent's ``skill_manage`` write path, minus the - write-approval gate — an authenticated dashboard write IS the user.""" + write-approval gate — an authenticated dashboard write IS the user. + Profile from the body or ``?profile=``, like the rest of ``/api/skills``.""" from tools.skill_manager_tool import _create_skill result = await scoped_to_thread( - body.profile, lambda: _create_skill(body.name, body.content, body.category or None)) + body.profile or profile, lambda: _create_skill(body.name, body.content, body.category or None)) if not result.get("success"): raise HTTPException(status_code=400, detail=result.get("error", "Failed to create skill.")) _clear_skills_prompt_cache() @@ -430,11 +431,11 @@ async def create_skill(body: SkillCreate): @router.put("/api/skills/content") -async def update_skill_content(body: SkillContentUpdate): +async def update_skill_content(body: SkillContentUpdate, profile: Optional[str] = None): """Replace the SKILL.md of an existing skill (full rewrite) from the editor.""" from tools.skill_manager_tool import _edit_skill - result = await scoped_to_thread(body.profile, lambda: _edit_skill(body.name, body.content)) + result = await scoped_to_thread(body.profile or profile, lambda: _edit_skill(body.name, body.content)) if not result.get("success"): err = result.get("error", "Failed to update skill.") status = 404 if "not found" in str(err).lower() else 400 diff --git a/hermes_cli/web_routers/status.py b/hermes_cli/web_routers/status.py index a6d037ea5c..3f3039461f 100644 --- a/hermes_cli/web_routers/status.py +++ b/hermes_cli/web_routers/status.py @@ -703,19 +703,22 @@ async def get_learning_node(id: str, profile: Optional[str] = None): @router.delete("/api/learning/node") -async def delete_learning_node(body: LearningNodeRef): - """Delete a journey node — skills are archived (restorable), memories removed.""" +async def delete_learning_node(body: LearningNodeRef, profile: Optional[str] = None): + """Delete a journey node — skills are archived (restorable), memories removed. + + ``?profile=`` is honoured too: a shared-backend Desktop scopes this call by query only, and + ignoring it archived the same-named skill of the launch profile instead.""" from agent.learning_mutations import delete_node return await _learning_mutation( - body.profile, lambda: delete_node(body.id), 400, "delete failed") + body.profile or profile, lambda: delete_node(body.id), 400, "delete failed") @router.put("/api/learning/node") -async def update_learning_node(body: LearningNodeEdit): - """Rewrite a journey node's content (SKILL.md or memory chunk).""" +async def update_learning_node(body: LearningNodeEdit, profile: Optional[str] = None): + """Rewrite a journey node's content (SKILL.md or memory chunk); profile as for DELETE.""" from agent.learning_mutations import edit_node return await _learning_mutation( - body.profile, lambda: edit_node(body.id, body.content), 400, "edit failed") + body.profile or profile, lambda: edit_node(body.id, body.content), 400, "edit failed") # Portal — Nous Portal auth + Tool Gateway routing status (read-only). diff --git a/tests/hermes_cli/test_web_server_skills_profiles.py b/tests/hermes_cli/test_web_server_skills_profiles.py index db394e8433..7084b4216c 100644 --- a/tests/hermes_cli/test_web_server_skills_profiles.py +++ b/tests/hermes_cli/test_web_server_skills_profiles.py @@ -92,6 +92,37 @@ class TestProfileScopedSkills: client.get("/api/skills", params={"profile": "worker_alpha"}) assert skills_tool.SKILLS_DIR == before + @pytest.mark.parametrize("method,path,body", [ + ("PUT", "/api/learning/node", {"id": "shared-skill", "content": "EDITED"}), + ("DELETE", "/api/learning/node", {"id": "shared-skill"}), + ("PUT", "/api/skills/content", {"name": "shared-skill", "content": "EDITED"}), + ("POST", "/api/skills", {"name": "shared-skill-2", "content": "EDITED"}), + ]) + def test_query_profile_scopes_skill_writes(self, client, isolated_profiles, method, path, body): + """A shared-backend Desktop names the profile in the query only; the write must land in + that profile and leave the same-named skill of the dashboard's own profile alone.""" + skill_md = ( + "---\nname: {name}\ndescription: edited\n---\n\n# EDITED\n").format( + name=body.get("name", body.get("id"))) + if "content" in body: + body = {**body, "content": skill_md} + for home in isolated_profiles.values(): + _write_skill(home / "skills", "shared-skill") + default_md = isolated_profiles["default"] / "skills" / "shared-skill" / "SKILL.md" + before = default_md.read_text() + + resp = client.request(method, path, params={"profile": "worker_alpha"}, json=body) + + assert resp.status_code == 200, resp.text + worker_skills = isolated_profiles["worker_alpha"] / "skills" + if method == "DELETE": + assert not (worker_skills / "shared-skill").exists() + else: + target = body.get("name", body.get("id")) + assert "# EDITED" in (worker_skills / target / "SKILL.md").read_text() + assert default_md.read_text() == before + assert not (isolated_profiles["default"] / "skills" / "shared-skill-2").exists() + class TestProfileScopedHubActions: def test_hub_install_spawns_with_profile_flag( diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index fe693cf400..a7cd53a100 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -451,7 +451,7 @@ The management endpoint families — `/api/config`, `/api/env`, `/api/skills`, `/api/tools/toolsets`, `/api/mcp`, `/api/model/{info,options,auxiliary,set,recommended-default}`, `/api/cron/{delivery-targets,blueprints}`, `/api/audio/voice-config`, -`/api/ops/debug-share`, `/api/learning/graph`, and `/api/dashboard/plugins/hub` — +`/api/ops/debug-share`, `/api/learning/{graph,node}`, and `/api/dashboard/plugins/hub` — accept an optional `?profile=` query parameter (or `"profile"` in the JSON body for writes) that scopes the read/write to that profile's `HERMES_HOME`. Omitted = the dashboard's own profile. Unknown profile names