mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-04 12:46:27 +00:00
Apply per-user skills/connectors at launch; stamp installed skills' owner
claude_gen.apply now takes the launching project's owner and materializes only shared rows plus that user's own; spawn and resume pass it through. skill_install stamps imported rows with the requesting user from the command payload (reinstalls keep the existing owner). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR
This commit is contained in:
@@ -137,8 +137,8 @@ def sync_user_skills(skills: list[dict], home: str | None = None) -> list[str]:
|
|||||||
return written
|
return written
|
||||||
|
|
||||||
|
|
||||||
def _load_skills(conn: Connection) -> list[dict]:
|
def _load_skills(conn: Connection, visible_to) -> list[dict]:
|
||||||
skills = repo.list_claude_skills(conn, enabled_only=True)
|
skills = repo.list_claude_skills(conn, enabled_only=True, visible_to=visible_to)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
**s,
|
**s,
|
||||||
@@ -150,15 +150,24 @@ def _load_skills(conn: Connection) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def apply(working_dir: str, conn: Connection | None = None) -> dict:
|
def apply(working_dir: str, conn: Connection | None = None, visible_to=None) -> dict:
|
||||||
"""Apply the whole web-managed config for one launch; returns a small summary."""
|
"""Apply the whole web-managed config for one launch; returns a small summary.
|
||||||
|
|
||||||
|
``visible_to`` is the launching project's ``owner_user_id`` — the launch gets the
|
||||||
|
shared rows plus that user's own, so one user's skills and connectors never reach
|
||||||
|
another user's agents. ``None`` (a shared/legacy project) applies shared rows only,
|
||||||
|
which is exactly the pre-accounts behavior when nothing has an owner. Note the
|
||||||
|
skills sync target is the worker's user-level skills dir: each launch rewrites it
|
||||||
|
to its own visible set, so on a busy multi-user worker the set follows the most
|
||||||
|
recent launch (a bounded staleness, not a leak — a launch never *reads* another
|
||||||
|
user's skills into its own sync)."""
|
||||||
if conn is None:
|
if conn is None:
|
||||||
with connection() as c:
|
with connection() as c:
|
||||||
connectors = repo.list_claude_connectors(c, enabled_only=True)
|
connectors = repo.list_claude_connectors(c, enabled_only=True, visible_to=visible_to)
|
||||||
skills = _load_skills(c)
|
skills = _load_skills(c, visible_to)
|
||||||
else:
|
else:
|
||||||
connectors = repo.list_claude_connectors(conn, enabled_only=True)
|
connectors = repo.list_claude_connectors(conn, enabled_only=True, visible_to=visible_to)
|
||||||
skills = _load_skills(conn)
|
skills = _load_skills(conn, visible_to)
|
||||||
mcp_path = write_mcp_config(working_dir, [memory_server_connector()] + connectors)
|
mcp_path = write_mcp_config(working_dir, [memory_server_connector()] + connectors)
|
||||||
written = sync_user_skills(skills)
|
written = sync_user_skills(skills)
|
||||||
return {"mcp_config": mcp_path, "skills_written": len(written)}
|
return {"mcp_config": mcp_path, "skills_written": len(written)}
|
||||||
|
|||||||
@@ -170,10 +170,13 @@ def _collect_skill(skill_dir: str) -> tuple[dict[str, str], dict[str, str], list
|
|||||||
return files_and_meta
|
return files_and_meta
|
||||||
|
|
||||||
|
|
||||||
def import_staged(staging_dir: str, conn: Connection) -> list[dict]:
|
def import_staged(
|
||||||
|
staging_dir: str, conn: Connection, owner_user_id: int | None = None
|
||||||
|
) -> list[dict]:
|
||||||
"""Upsert every ``<staging>/<name>/SKILL.md`` as a managed skill row (matched by
|
"""Upsert every ``<staging>/<name>/SKILL.md`` as a managed skill row (matched by
|
||||||
name — reinstalling a skill updates it in place) with its auxiliary files. Returns
|
name — reinstalling a skill updates it in place) with its auxiliary files. Returns
|
||||||
one summary dict per skill."""
|
one summary dict per skill. New rows belong to ``owner_user_id`` (None = shared);
|
||||||
|
a reinstall keeps the existing row's owner."""
|
||||||
results: list[dict] = []
|
results: list[dict] = []
|
||||||
for entry in sorted(os.listdir(staging_dir)):
|
for entry in sorted(os.listdir(staging_dir)):
|
||||||
skill_dir = os.path.join(staging_dir, entry)
|
skill_dir = os.path.join(staging_dir, entry)
|
||||||
@@ -190,7 +193,9 @@ def import_staged(staging_dir: str, conn: Connection) -> list[dict]:
|
|||||||
body = meta["__body__"].strip() + "\n"
|
body = meta["__body__"].strip() + "\n"
|
||||||
existing = repo.get_claude_skill_by_name(conn, name)
|
existing = repo.get_claude_skill_by_name(conn, name)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
row = repo.create_claude_skill(conn, name, body, description=description)
|
row = repo.create_claude_skill(
|
||||||
|
conn, name, body, description=description, owner_user_id=owner_user_id
|
||||||
|
)
|
||||||
action = "created"
|
action = "created"
|
||||||
else:
|
else:
|
||||||
row = repo.update_claude_skill(
|
row = repo.update_claude_skill(
|
||||||
@@ -205,11 +210,12 @@ def import_staged(staging_dir: str, conn: Connection) -> list[dict]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def run(prompt: str) -> dict:
|
def run(prompt: str, owner_user_id: int | None = None) -> dict:
|
||||||
"""The whole flow: stage, run the wrapped prompt through headless claude, import.
|
"""The whole flow: stage, run the wrapped prompt through headless claude, import.
|
||||||
|
|
||||||
Returns ``{"skills": [...], "summary": <claude's closing report>}``; raises
|
Returns ``{"skills": [...], "summary": <claude's closing report>}``; raises
|
||||||
InstallError when the run fails or fetched nothing importable.
|
InstallError when the run fails or fetched nothing importable. Imported skills
|
||||||
|
belong to ``owner_user_id`` (None = shared).
|
||||||
"""
|
"""
|
||||||
prompt = (prompt or "").strip()
|
prompt = (prompt or "").strip()
|
||||||
if not prompt:
|
if not prompt:
|
||||||
@@ -221,7 +227,7 @@ def run(prompt: str) -> dict:
|
|||||||
output = _run_claude(_WRAPPER.format(prompt=prompt), staging, settings_path)
|
output = _run_claude(_WRAPPER.format(prompt=prompt), staging, settings_path)
|
||||||
os.remove(settings_path) # never importable, but keep the scan surface clean
|
os.remove(settings_path) # never importable, but keep the scan surface clean
|
||||||
with connection() as conn:
|
with connection() as conn:
|
||||||
skills = import_staged(staging, conn)
|
skills = import_staged(staging, conn, owner_user_id=owner_user_id)
|
||||||
if not skills:
|
if not skills:
|
||||||
raise InstallError(
|
raise InstallError(
|
||||||
"the install run finished but no <skill>/SKILL.md landed in the staging "
|
"the install run finished but no <skill>/SKILL.md landed in the staging "
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ def spawn(
|
|||||||
# Materialize the web-managed Claude config (MCP connectors + user-level skills)
|
# Materialize the web-managed Claude config (MCP connectors + user-level skills)
|
||||||
# so this launch picks up what the operator configured in the dashboard. The skills
|
# so this launch picks up what the operator configured in the dashboard. The skills
|
||||||
# half also feeds pi-harness agents (their settings.json points at the same dir).
|
# half also feeds pi-harness agents (their settings.json points at the same dir).
|
||||||
claude_gen.apply(working_dir)
|
# Scoped to the project's owner: shared rows plus theirs, nobody else's.
|
||||||
|
claude_gen.apply(working_dir, visible_to=project.get("owner_user_id"))
|
||||||
env, harness = _agent_env(project, agent, token, role=role, mise_init=mise_init)
|
env, harness = _agent_env(project, agent, token, role=role, mise_init=mise_init)
|
||||||
|
|
||||||
# Verify the pinned forge version, if one is configured. Non-fatal: a version drift
|
# Verify the pinned forge version, if one is configured. Non-fatal: a version drift
|
||||||
@@ -284,7 +285,7 @@ def resume(agent: dict, answer: str, worker_id: str | None = None) -> tuple[bool
|
|||||||
|
|
||||||
working_dir = agent["working_dir"]
|
working_dir = agent["working_dir"]
|
||||||
settings_path = settings_gen.write_settings(working_dir)
|
settings_path = settings_gen.write_settings(working_dir)
|
||||||
claude_gen.apply(working_dir)
|
claude_gen.apply(working_dir, visible_to=project.get("owner_user_id"))
|
||||||
try:
|
try:
|
||||||
token = None
|
token = None
|
||||||
with connection() as conn:
|
with connection() as conn:
|
||||||
|
|||||||
@@ -251,12 +251,14 @@ def _cmd_login_submit(command: dict) -> dict:
|
|||||||
|
|
||||||
def _cmd_skill_install(command: dict) -> dict:
|
def _cmd_skill_install(command: dict) -> dict:
|
||||||
"""Run a pasted marketplace install prompt through a one-off headless claude and
|
"""Run a pasted marketplace install prompt through a one-off headless claude and
|
||||||
import the fetched skills as managed rows (Claude page, Skills tab)."""
|
import the fetched skills as managed rows (Claude page, Skills tab). Imported rows
|
||||||
prompt = _payload(command).get("prompt")
|
belong to the requesting user (``owner_user_id`` in the payload; None = shared)."""
|
||||||
|
payload = _payload(command)
|
||||||
|
prompt = payload.get("prompt")
|
||||||
if not prompt or not str(prompt).strip():
|
if not prompt or not str(prompt).strip():
|
||||||
raise CommandError("skill_install requires a 'prompt' in the payload")
|
raise CommandError("skill_install requires a 'prompt' in the payload")
|
||||||
try:
|
try:
|
||||||
return skill_install.run(str(prompt))
|
return skill_install.run(str(prompt), owner_user_id=payload.get("owner_user_id"))
|
||||||
except skill_install.InstallError as exc:
|
except skill_install.InstallError as exc:
|
||||||
raise CommandError(str(exc)) from exc
|
raise CommandError(str(exc)) from exc
|
||||||
|
|
||||||
@@ -488,7 +490,8 @@ def run(
|
|||||||
pass
|
pass
|
||||||
did_work = drain(worker_id) > 0
|
did_work = drain(worker_id) > 0
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if credsync_interval > 0 and (last_credsync == 0.0 or now - last_credsync >= credsync_interval):
|
credsync_due = last_credsync == 0.0 or now - last_credsync >= credsync_interval
|
||||||
|
if credsync_interval > 0 and credsync_due:
|
||||||
# First pass runs immediately: a fresh worker container must materialize the
|
# First pass runs immediately: a fresh worker container must materialize the
|
||||||
# claude credentials before it claims its first spawn.
|
# claude credentials before it claims its first spawn.
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user