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:
Claude
2026-08-12 19:26:15 +00:00
parent 6555f1ad79
commit 68c24f3a4b
4 changed files with 39 additions and 20 deletions
+17 -8
View File
@@ -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)}
+12 -6
View File
@@ -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 "
+3 -2
View File
@@ -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:
+7 -4
View File
@@ -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: