fix(dashboard): learning-node and skill editor writes honour ?profile=

DELETE/PUT /api/learning/node, POST /api/skills and PUT /api/skills/content
read the target profile from the JSON body only. A shared-backend Desktop
(global remote, registry connections) scopes these calls with ?profile= and
sends a body without one, so the star map's delete archived, and its edit
rewrote, the same-named skill of the backend's launch profile while the
selected profile's skill stayed as it was.

Take the body profile first and fall back to the query, as
PUT /api/skills/toggle and the tools routes already do.
This commit is contained in:
John Paul Soliva
2026-10-01 20:17:23 +09:00
committed by brooklyn!
parent c82abc309a
commit 0ef09aed76
4 changed files with 47 additions and 12 deletions

View File

@@ -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

View File

@@ -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).

View File

@@ -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(

View File

@@ -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=<name>` 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