Merge pull request #66730 from NousResearch/feat/hsp-sync-client

feat(sync): HSP/1 personal skill sync client (M1 client)
This commit is contained in:
Teknium 2026-07-29 15:20:35 -07:00 committed by GitHub
commit 1a088989bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 4208 additions and 17 deletions

View File

@ -19,13 +19,18 @@ from typing import Optional
from agent.runtime_cwd import resolve_agent_cwd
from agent.skill_utils import (
EXCLUDED_SKILL_DIRS,
ORG_ACTIVE_MARKER,
ORG_MIRROR_DIR_NAME,
ORG_PROVENANCE_FILE,
SKILL_SUPPORT_DIRS,
extract_skill_conditions,
extract_skill_description,
get_all_skills_dirs,
get_disabled_skill_names,
iter_skill_index_files,
org_id_of_path,
parse_frontmatter,
read_active_org_id,
skill_matches_environment,
skill_matches_platform,
skill_matches_platform_list,
@ -1340,7 +1345,9 @@ def drain_truncation_warnings() -> list:
_SKILLS_PROMPT_CACHE_MAX = 8
_SKILLS_PROMPT_CACHE: OrderedDict[tuple, str] = OrderedDict()
_SKILLS_PROMPT_CACHE_LOCK = threading.Lock()
_SKILLS_SNAPSHOT_VERSION = 1
# v2: entries gained org provenance fields (org_id/org_author/rel_dir) for M2
# org-shared skills; older snapshots are discarded and rebuilt.
_SKILLS_SNAPSHOT_VERSION = 2
def _skills_prompt_snapshot_path() -> Path:
@ -1359,13 +1366,32 @@ def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None:
def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:
"""Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files."""
"""Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files.
Org mirrors (M2): only the ACTIVE org's mirror participates, and the
``.active_org`` marker itself is included so switching/leaving an org
invalidates the snapshot even when no SKILL.md changed.
"""
manifest: dict[str, list[int]] = {}
skills_dir_str = str(skills_dir)
base = os.path.join(skills_dir_str, "")
prefix_len = len(base)
active_org = read_active_org_id(skills_dir)
org_root = os.path.join(skills_dir_str, ORG_MIRROR_DIR_NAME)
marker_path = os.path.join(org_root, ORG_ACTIVE_MARKER)
try:
st = os.stat(marker_path)
manifest[ORG_MIRROR_DIR_NAME + "/" + ORG_ACTIVE_MARKER] = [
int(st.st_mtime), int(st.st_size),
]
except OSError:
pass
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
has_skill_md = "SKILL.md" in files
if root == skills_dir_str and ORG_MIRROR_DIR_NAME in dirs and active_org is None:
dirs.remove(ORG_MIRROR_DIR_NAME)
elif root == org_root:
dirs[:] = [d for d in dirs if d == active_org]
dirs[:] = [
d
for d in dirs
@ -1430,6 +1456,15 @@ def _build_snapshot_entry(
"""Build a serialisable metadata dict for one skill."""
rel_path = skill_file.relative_to(skills_dir)
parts = rel_path.parts
# M2 org mirror: strip the `_org/<org_id>/` prefix so category/name derive
# from the path WITHIN the mirror (same shape the org tree was built
# from), and record provenance for labeling + fail-loud collisions.
org_id: str | None = None
if len(parts) >= 3 and parts[0] == ORG_MIRROR_DIR_NAME:
org_id = parts[1]
parts = parts[2:]
if len(parts) >= 2:
skill_name = parts[-2]
category = "/".join(parts[:-2]) if len(parts) > 2 else parts[0]
@ -1441,7 +1476,7 @@ def _build_snapshot_entry(
if isinstance(platforms, str):
platforms = [platforms]
return {
entry = {
"skill_name": skill_name,
"category": category,
"frontmatter_name": str(frontmatter.get("name", skill_name)),
@ -1449,6 +1484,22 @@ def _build_snapshot_entry(
"platforms": [str(p).strip() for p in platforms if str(p).strip()],
"conditions": extract_skill_conditions(frontmatter),
}
if org_id:
entry["org_id"] = org_id
# Author from the pull-time provenance sidecar (token-verified at
# push by the plane's author_mismatch guard). Best-effort.
try:
import json as _json
prov_path = (
skills_dir / ORG_MIRROR_DIR_NAME / org_id / ORG_PROVENANCE_FILE
)
prov = _json.loads(prov_path.read_text(encoding="utf-8"))
device = str(prov.get("author_device") or "")
entry["org_author"] = device or str(prov.get("author_user_id") or "")
except Exception:
entry["org_author"] = ""
return entry
# =========================================================================
@ -1584,6 +1635,10 @@ def build_skills_system_prompt(
skills_by_category: dict[str, list[tuple[str, str]]] = {}
category_descriptions: dict[str, str] = {}
# Unified visible-entry list (both paths) so the org labeling +
# fail-loud collision pass below runs identically for snapshot and scan.
visible_entries: list[dict] = []
skill_entries: list[dict] = []
if snapshot is not None:
# Fast path: use pre-parsed metadata from disk
@ -1591,7 +1646,6 @@ def build_skills_system_prompt(
if not isinstance(entry, dict):
continue
skill_name = entry.get("skill_name") or ""
category = entry.get("category") or "general"
frontmatter_name = entry.get("frontmatter_name") or skill_name
platforms = entry.get("platforms") or []
if not skill_matches_platform_list(platforms):
@ -1604,16 +1658,13 @@ def build_skills_system_prompt(
available_toolsets,
):
continue
skills_by_category.setdefault(category, []).append(
(frontmatter_name, entry.get("description", ""))
)
visible_entries.append(entry)
category_descriptions = {
str(k): str(v)
for k, v in (snapshot.get("category_descriptions") or {}).items()
}
else:
# Cold path: full filesystem scan + write snapshot for next time
skill_entries: list[dict] = []
for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"):
is_compatible, frontmatter, desc = _parse_skill_file(skill_file)
entry = _build_snapshot_entry(skill_file, skills_dir, frontmatter, desc)
@ -1629,10 +1680,38 @@ def build_skills_system_prompt(
available_toolsets,
):
continue
skills_by_category.setdefault(entry["category"], []).append(
(entry["frontmatter_name"], entry["description"])
)
visible_entries.append(entry)
# ── M2 org labeling + FAIL-LOUD collisions ─────────────────────────
# An org skill lists with an explicit provenance tag. When a personal and
# an org skill share a name, NEITHER silently wins: both list qualified
# (personal keeps the bare name is the wrong default — silent divergence
# from the org set; org winning silently shadows the user's own work) —
# so both entries carry a [name collision] flag and skill_view refuses
# the ambiguous bare name (its existing multi-candidate guard).
name_owners: dict[str, set[str]] = {}
for entry in visible_entries:
fm = entry.get("frontmatter_name") or entry.get("skill_name") or ""
kind = "org" if entry.get("org_id") else "personal"
name_owners.setdefault(fm, set()).add(kind)
for entry in visible_entries:
fm = entry.get("frontmatter_name") or entry.get("skill_name") or ""
desc = entry.get("description", "")
org_id = entry.get("org_id")
collided = len(name_owners.get(fm, set())) > 1
if org_id:
author = entry.get("org_author") or ""
tag = f"[org-shared{': by ' + author if author else ''}]"
desc = f"{tag} {desc}".strip()
category = f"org:{org_id}"
else:
category = entry.get("category") or "general"
if collided:
desc = f"[name collision — also exists {'personally' if org_id else 'in your org'}; load via category path] {desc}".strip()
skills_by_category.setdefault(category, []).append((fm, desc))
if snapshot is None:
# (continuation of the cold path below: category descriptions + write)
# Read category-level DESCRIPTION.md files
for desc_file in iter_skill_index_files(skills_dir, "DESCRIPTION.md"):
try:

View File

@ -49,6 +49,55 @@ EXCLUDED_SKILL_DIRS = frozenset(
# archive workflow preserves a complete old skill package under references/.
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
# ── Org-shared skills (sync contract) ───────────────────────────
# Org mirrors live under ~/.hermes/skills/_org/<org_id>/. Resolution is
# TOKEN-GATED via a marker file the sync client writes after verifying the
# token (skills_sync_client.pull_org_skills): only the marked org's mirror is
# scanned. No marker ⇒ no org skills load. The marker is plain data (org_id
# string) so this module stays import-light; the VERIFICATION lives in the
# sync client, which is the only writer. Offline grace: the marker persists,
# so already-pulled org skills keep working without connectivity; a VERIFIED
# org change (or personal-org token) rewrites/removes it.
ORG_MIRROR_DIR_NAME = "_org"
ORG_ACTIVE_MARKER = ".active_org"
ORG_PROVENANCE_FILE = ".org-provenance.json"
# Records the fingerprint of each skill exactly as upstream sent it, so a
# later local edit is detectable and an org pull can refuse to clobber it.
ORG_BASELINE_FILE = ".org-baseline.json"
def read_active_org_id(skills_dir: Path) -> Optional[str]:
"""The org id whose mirror may resolve, or None (no org skills load)."""
try:
marker = skills_dir / ORG_MIRROR_DIR_NAME / ORG_ACTIVE_MARKER
if not marker.exists():
return None
val = marker.read_text(encoding="utf-8").strip()
return val or None
except OSError:
return None
def is_org_mirror_path(path, skills_dir: Path) -> bool:
"""True when *path* is inside the org mirror (``_org/``)."""
try:
rel = Path(path).resolve().relative_to(Path(skills_dir).resolve())
except (OSError, ValueError):
return False
return bool(rel.parts) and rel.parts[0] == ORG_MIRROR_DIR_NAME
def org_id_of_path(path, skills_dir: Path) -> Optional[str]:
"""The ``<org_id>`` segment for a path under ``_org/<org_id>/...``."""
try:
rel = Path(path).resolve().relative_to(Path(skills_dir).resolve())
except (OSError, ValueError):
return None
if len(rel.parts) >= 2 and rel.parts[0] == ORG_MIRROR_DIR_NAME:
return rel.parts[1]
return None
def is_excluded_skill_path(path, *, root: Optional[Path] = None) -> bool:
"""True if *path* should be skipped by active skill scanners.
@ -817,11 +866,24 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
scripts) can contain arbitrary markdown and even archived package
``SKILL.md`` files, but they are progressive-disclosure data loaded through
``skill_view(..., file_path=...)`` rather than active skill roots.
M2 org mirrors (``_org/``): TOKEN-GATED resolution. Only the active org's
subdir (per the sync-client-written ``.active_org`` marker) is walked;
every other ``_org/<id>/`` (stale mirror from a previous org, or no
marker at all) is pruned leave an org and its skills stop resolving,
without any manual cleanup.
"""
skills_dir_str = str(skills_dir)
active_org = read_active_org_id(skills_dir)
org_root = os.path.join(skills_dir_str, ORG_MIRROR_DIR_NAME)
matches: list[str] = []
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
has_skill_md = "SKILL.md" in files
if root == skills_dir_str and ORG_MIRROR_DIR_NAME in dirs and active_org is None:
dirs.remove(ORG_MIRROR_DIR_NAME)
elif root == org_root:
# Inside _org/: descend ONLY into the active org's mirror.
dirs[:] = [d for d in dirs if d == active_org]
dirs[:] = [
d
for d in dirs

20
cli.py
View File

@ -14650,6 +14650,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
)
except Exception:
pass
# Skill sync — best-effort periodic pull, piggy-backing on the
# curator tick. Inert unless the access gate is open and a sync base
# URL is configured; swallows all errors so it never blocks startup.
try:
from tools.skills_sync_client import maybe_pull_skills
maybe_pull_skills()
except Exception:
pass
# Org-shared skills — pull the organisation's approved set into the
# read-only mirror. Gated on real org membership: resolve_org_identity
# requires an org role on the token, which is only issued for
# multi-member organisations, so a solo account never reaches the
# network here. Fail-quiet, exactly like the personal pull above.
try:
from tools.skills_sync_client import maybe_pull_org_skills
maybe_pull_org_skills()
except Exception:
pass
if self.preloaded_skills and not self._startup_skills_line_shown:
skills_label = ", ".join(self.preloaded_skills)
self._console_print(

View File

@ -24718,6 +24718,23 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop
except Exception as e:
logger.debug("Curator tick error: %s", e)
# Skill Sync — best-effort periodic pull on the same cadence.
# Inert unless the access gate is open and a sync base URL is
# configured; never raises.
try:
from tools.skills_sync_client import maybe_pull_skills
maybe_pull_skills()
except Exception as e:
logger.debug("Sync pull tick error: %s", e)
# Org-shared skills. Gated on real org membership (the token must
# carry an org role), so a solo account never reaches the network.
try:
from tools.skills_sync_client import maybe_pull_org_skills
maybe_pull_org_skills()
except Exception as e:
logger.debug("Org sync pull tick error: %s", e)
# Stale-session auto-archive — a live timer, so gateways that stay up
# for weeks keep sweeping on schedule (the startup hook fires once).
# maybe_auto_archive() is gated by sessions.min_interval_hours in

View File

@ -433,6 +433,7 @@ import functools as _functools
from hermes_cli.sessions_cmd import cmd_sessions # noqa: F401
from hermes_cli.subcommands._shared import add_accept_hooks_flag as _add_accept_hooks_flag
from hermes_cli.subcommands.cron import build_cron_parser
from hermes_cli.subcommands.sync import build_sync_parser
from hermes_cli.subcommands.gateway import build_gateway_parser
from hermes_cli.subcommands.profile import build_profile_parser
from hermes_cli.subcommands.model import build_model_parser
@ -4536,6 +4537,201 @@ def cmd_cron(args):
cron_command(args)
def cmd_sync(args):
"""Skill Sync — personal sync across devices, plus sharing with your org."""
import json as _json
sub = getattr(args, "sync_command", None)
if sub in {None, ""}:
print(
"usage: hermes sync "
"<status|pull|push|now|enable|disable|device|propose>\n"
"\n"
"Your skills, across your devices:\n"
" status Show what is synced, and from where\n"
" pull Pull your synced skills\n"
" push Push your opted-in skills\n"
" now Reconcile now: pull then push\n"
" enable <skill> Include a skill in your sync\n"
" disable <skill> Exclude a skill from your sync\n"
" device [--name N] Show or set this device's label\n"
"\n"
"Shared with your team:\n"
" propose <skill> Share a skill with your organisation",
file=sys.stderr,
)
return 1
if sub == "device":
from tools import skills_sync_client as ssc
name = getattr(args, "device_name", None)
if name is not None:
try:
stored = ssc.set_device_name(name)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 1
print(f"device label set to '{stored}'.")
print(
"New commits from this device will use this label; existing "
"commits keep their previous one.",
file=sys.stderr,
)
return 0
# No --name: print the current (creating a default on first use).
print(ssc.stable_device_id())
return 0
if sub == "propose":
from tools import skills_sync_client as ssc
name = args.name
try:
result = ssc.propose_skill(name, message=args.message)
except ssc.SyncInertError as e:
print(f"cannot share this skill: {e}", file=sys.stderr)
return 1
except ssc.SyncError as e:
print(f"could not share '{name}': {e}", file=sys.stderr)
return 1
if result.get("proposal_pending"):
print(
f"Shared '{name}' with your organisation — an admin needs to "
f"approve it (proposal #{result.get('proposal_id')}). It is "
f"not live for the team until then."
)
else:
print(f"Added '{name}' to your organisation's shared skills.")
return 0
if sub in {"enable", "disable"}:
from tools.skill_usage import set_sync, is_curation_eligible
skill = args.skill
if not is_curation_eligible(skill):
print(
f"'{skill}' is not sync-eligible (bundled, hub-installed, "
f"external, or not found). Only agent-created / user-authored "
f"skills under ~/.hermes/skills/ can sync.",
file=sys.stderr,
)
return 1
set_sync(skill, sub == "enable")
print(f"sync {'enabled' if sub == 'enable' else 'disabled'} for '{skill}'.")
return 0
from tools import skills_sync_client as ssc
if sub == "status":
status = ssc.sync_status()
print(_json.dumps(status, indent=2, ensure_ascii=False))
if status.get("org_available"):
n = len(status.get("org_skills") or [])
modified = status.get("org_skills_modified") or []
print(
f"\nOrg skills: {n} shared skill(s) from your organisation "
f"(your role: {status.get('org_role')}). They load alongside "
f"your own, labeled by origin, and you can edit them.",
file=sys.stderr,
)
if modified:
print(
f" {len(modified)} with local edits not yet shared: "
f"{', '.join(modified)}\n"
f" Share them back with `hermes sync propose <skill>`. "
f"Org updates will not overwrite them.",
file=sys.stderr,
)
elif status.get("logged_in"):
print(
"\nOrg skills: not applicable — this account isn't a member "
"of a shared organisation.",
file=sys.stderr,
)
if not status.get("logged_in"):
print("\nNot logged into Nous Portal — sync is inert.", file=sys.stderr)
elif not status.get("nous_admin"):
print(
"\nSync is not enabled for your account yet.",
file=sys.stderr,
)
elif not status.get("feature_enabled"):
print(
"\nSync feature is off for this instance (set HERMES_SYNC_ENABLED=1 "
"or config.yaml sync.enabled: true). Sync is inert.",
file=sys.stderr,
)
elif not status.get("base_url"):
print(
"\nNo sync base URL configured (config.yaml sync.base_url or "
"HERMES_SYNC_BASE_URL). Sync is inert.",
file=sys.stderr,
)
return 0
# pull / push / now — enforce the gate up front with a clear message.
try:
identity = ssc.resolve_identity()
except ssc.SyncInertError as e:
print(f"sync inert: {e}", file=sys.stderr)
return 1
if not identity.get("nous_admin"):
print(
"sync unavailable: not enabled for your account yet.",
file=sys.stderr,
)
return 1
if not ssc.resolve_sync_base_url():
print(
"sync inert: no sync base URL configured (config.yaml sync.base_url "
"or HERMES_SYNC_BASE_URL).",
file=sys.stderr,
)
return 1
try:
if sub == "pull":
result = ssc.pull_skills(identity=identity)
# Refresh the org mirror too when this account belongs to an
# organisation (no-op otherwise), so one pull covers both.
org_result = ssc.maybe_pull_org_skills()
if org_result:
n = len(org_result.get("updated") or [])
print(
f"org: refreshed {n} shared skill(s) from your "
f"organisation.",
file=sys.stderr,
)
clashes = org_result.get("conflicted") or []
if clashes:
print(
f"org: {len(clashes)} skill(s) have BOTH local edits "
f"and org updates, so they were left as-is: "
f"{', '.join(clashes)}\n"
f" Your local version is intact. Review it, then "
f"either propose it or delete the local copy and pull "
f"again to take the org version.",
file=sys.stderr,
)
elif sub == "push":
result = ssc.push_skills(identity=identity, message="hermes sync push")
elif sub == "now":
pull_res = ssc.pull_skills(identity=identity)
push_res = ssc.push_skills(identity=identity, message="hermes sync now")
result = {"pull": pull_res, "push": push_res}
else:
print(f"Unknown sync subcommand: {sub}", file=sys.stderr)
return 1
except ssc.SyncError as e:
print(f"sync failed: {e}", file=sys.stderr)
return 1
print(_json.dumps(result, indent=2, ensure_ascii=False))
return 0
def cmd_webhook(args):
"""Webhook subscription management."""
from hermes_cli.webhook import webhook_command
@ -10232,7 +10428,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"project", "proxy",
"prompt-size",
"send", "sessions", "setup",
"skin", "skills", "slack", "status", "tools", "uninstall", "update",
"skin", "skills", "slack", "status", "sync", "tools", "uninstall", "update",
"version", "webhook", "whatsapp", "whatsapp-cloud", "chat", "secrets", "security",
# Help-ish invocations — plugin commands not being listed in
# top-level --help is an acceptable trade-off for skipping an
@ -11096,6 +11292,7 @@ def main():
# cron command (parser built in hermes_cli/subcommands/cron.py)
# =========================================================================
build_cron_parser(subparsers, cmd_cron=cmd_cron)
build_sync_parser(subparsers, cmd_sync=cmd_sync)
# =========================================================================
# webhook command (parser built in hermes_cli/subcommands/webhook.py)

View File

@ -312,4 +312,5 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None:
"config",
help="Interactive skill configuration — enable/disable individual skills",
)
skills_parser.set_defaults(func=cmd_skills)

View File

@ -0,0 +1,99 @@
"""``hermes sync`` subcommand parser — Skill Sync.
Cloned from ``hermes_cli/subcommands/cron.py`` same injected-handler shape
(``func=cmd_sync``) so this module does not import ``main`` (cycle avoidance).
Skill Sync covers two surfaces, both under this one command for launch:
Personal your own skills, across your own devices:
hermes sync status show gate/opt-in/head state
hermes sync pull pull and materialize opted-in skills
hermes sync push push opted-in skills
hermes sync now reconcile: pull then push
hermes sync enable <skill> opt a skill into sync
hermes sync disable <skill> opt a skill out of sync
hermes sync device [--name] show or set this device's label
Organisation skills shared with your team:
hermes sync propose <skill> share a skill with your organisation
Sync is INERT unless the resolved Nous token carries the access-gate claim
AND a sync base URL is configured. The commands report that state rather than
failing opaquely.
"""
from __future__ import annotations
import argparse
from typing import Callable
def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None:
"""Attach the ``sync`` subcommand (and its sub-actions) to ``subparsers``."""
sync_parser = subparsers.add_parser(
"sync",
help="Skill Sync — sync your skills across devices and with your team",
description=(
"Skill Sync keeps your skills with you. Personal sync moves your "
"own skills between your devices; if you belong to an "
"organisation, you also get its shared skills and can propose "
"your own back to the team."
),
epilog=(
"Examples:\n"
" hermes sync status what is synced, and from where\n"
" hermes sync enable my-skill include a skill in your sync\n"
" hermes sync now pull, then push\n"
" hermes sync propose my-skill share a skill with your team\n"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sync_sub = sync_parser.add_subparsers(dest="sync_command")
sync_sub.add_parser("status", help="Show what is synced, and from where")
sync_sub.add_parser(
"pull", help="Pull your synced skills (and your organisation's)"
)
sync_sub.add_parser("push", help="Push your opted-in skills")
sync_sub.add_parser("now", help="Reconcile now: pull then push")
enable = sync_sub.add_parser("enable", help="Include a skill in your sync")
enable.add_argument("skill", help="Skill name (frontmatter name / directory name)")
disable = sync_sub.add_parser("disable", help="Exclude a skill from your sync")
disable.add_argument("skill", help="Skill name (frontmatter name / directory name)")
device = sync_sub.add_parser(
"device",
help="Show or set this device's label (shown in the sync console)",
)
device.add_argument(
"--name",
dest="device_name",
default=None,
help="Set a human-friendly label for this device (e.g. \"Ben's Laptop\"). "
"Omit to print the current label.",
)
# Org-shared skills. A member's submission becomes a proposal an admin
# reviews; an admin's merges straight into the shared set. Accounts that
# aren't in a shared organisation are told so plainly.
propose = sync_sub.add_parser(
"propose",
help="Share a skill with your organisation",
description=(
"Submit one of your skills to your organisation's shared set. If "
"you are an admin it is added directly; otherwise it becomes a "
"proposal for an admin to review. Accounts that aren't part of a "
"shared organisation don't have this workflow."
),
)
propose.add_argument("name", help="Skill name to share")
propose.add_argument(
"-m",
"--message",
default=None,
help="Optional message describing the change",
)
sync_parser.set_defaults(func=cmd_sync)

View File

@ -0,0 +1,389 @@
"""M2 org-skill namespace: token-gated resolution, provenance, collisions.
Covers the design agreed 2026-07-23 (bare-name first-class org skills):
1. TOKEN-GATED discovery only the `.active_org`-marked mirror resolves;
stale mirrors and marker-less trees never load.
2. Fail-loud collisions a personal/org name clash lists BOTH sides flagged;
skill_view's existing multi-candidate guard refuses the bare name.
3. Load-time provenance header org skill content announces org + author.
4. Org mirrors are read-only (skill_manage guards) and curation-exempt.
"""
import json
import pytest
from agent import skill_utils as sku
from agent.prompt_builder import _build_snapshot_entry
def _mk_skill(root, rel, name=None, body="# body\n"):
d = root
for part in rel.split("/"):
d = d / part
d.mkdir(parents=True, exist_ok=True)
(d / "SKILL.md").write_text(
f"---\nname: {name or rel.split('/')[-1]}\ndescription: d\n---\n{body}",
encoding="utf-8",
)
return d
def _mark_active(skills, org_id):
org_root = skills / sku.ORG_MIRROR_DIR_NAME
org_root.mkdir(parents=True, exist_ok=True)
(org_root / sku.ORG_ACTIVE_MARKER).write_text(org_id, encoding="utf-8")
class TestTokenGatedDiscovery:
def test_no_marker_no_org_skills(self, tmp_path):
skills = tmp_path / "skills"
_mk_skill(skills, "personal-a")
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x")
found = [p.parent.name for p in sku.iter_skill_index_files(skills, "SKILL.md")]
assert "personal-a" in found
assert "shared-x" not in found # unmarked mirror never resolves
def test_marker_gates_to_active_org_only(self, tmp_path):
skills = tmp_path / "skills"
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x")
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-OLD/stale-y", name="stale-y")
_mark_active(skills, "org-1")
found = [p.parent.name for p in sku.iter_skill_index_files(skills, "SKILL.md")]
assert "shared-x" in found
assert "stale-y" not in found # stale mirror pruned at resolution
def test_switching_org_flips_resolution(self, tmp_path):
skills = tmp_path / "skills"
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x")
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-2/other-z", name="other-z")
_mark_active(skills, "org-2")
found = [p.parent.name for p in sku.iter_skill_index_files(skills, "SKILL.md")]
assert found and "other-z" in found and "shared-x" not in found
def test_helpers(self, tmp_path):
skills = tmp_path / "skills"
d = _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-9/cat/sk", name="sk")
assert sku.is_org_mirror_path(d, skills) is True
assert sku.org_id_of_path(d, skills) == "org-9"
p = _mk_skill(skills, "plain")
assert sku.is_org_mirror_path(p, skills) is False
assert sku.read_active_org_id(skills) is None
_mark_active(skills, "org-9")
assert sku.read_active_org_id(skills) == "org-9"
class TestSnapshotEntryProvenance:
def test_org_entry_strips_prefix_and_carries_provenance(self, tmp_path):
skills = tmp_path / "skills"
d = _mk_skill(
skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/devops/beta", name="beta"
)
(skills / sku.ORG_MIRROR_DIR_NAME / "org-1" / sku.ORG_PROVENANCE_FILE).write_text(
json.dumps(
{"author_device": "bens-macbook-a1b2c3", "author_user_id": "u1"}
),
encoding="utf-8",
)
entry = _build_snapshot_entry(d / "SKILL.md", skills, {"name": "beta"}, "d")
assert entry["org_id"] == "org-1"
assert entry["org_author"] == "bens-macbook-a1b2c3"
# Category derives from the path WITHIN the mirror, not _org/org-1/...
assert entry["category"] == "devops"
assert entry["skill_name"] == "beta"
def test_personal_entry_unchanged(self, tmp_path):
skills = tmp_path / "skills"
d = _mk_skill(skills, "devops/beta", name="beta")
entry = _build_snapshot_entry(d / "SKILL.md", skills, {"name": "beta"}, "d")
assert "org_id" not in entry
assert entry["category"] == "devops"
class TestListingCollisionsAndLabels:
def _render(self, tmp_path, monkeypatch):
from agent import prompt_builder as pb
skills = tmp_path / "skills"
skills.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(pb, "get_skills_dir", lambda: skills, raising=True)
monkeypatch.setattr(
pb, "get_all_skills_dirs", lambda: [skills], raising=True
)
monkeypatch.setattr(pb, "get_disabled_skill_names", lambda *a, **k: set())
monkeypatch.setattr(
pb, "_skills_prompt_snapshot_path", lambda: tmp_path / "snap.json"
)
pb.clear_skills_system_prompt_cache()
return skills, pb
def test_org_skill_listed_with_provenance_tag(self, tmp_path, monkeypatch):
skills, pb = self._render(tmp_path, monkeypatch)
_mk_skill(skills, "personal-a")
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x")
(skills / sku.ORG_MIRROR_DIR_NAME / "org-1" / sku.ORG_PROVENANCE_FILE).write_text(
json.dumps({"author_device": "bens-macbook"}), encoding="utf-8"
)
_mark_active(skills, "org-1")
out = pb.build_skills_system_prompt()
assert "org:org-1" in out
assert "[org-shared: by bens-macbook]" in out
assert "personal-a" in out
def test_collision_flags_both_sides(self, tmp_path, monkeypatch):
skills, pb = self._render(tmp_path, monkeypatch)
_mk_skill(skills, "k8s-debug", body="personal version\n")
_mk_skill(
skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/k8s-debug", name="k8s-debug"
)
_mark_active(skills, "org-1")
out = pb.build_skills_system_prompt()
# BOTH entries flagged — neither silently wins.
assert out.count("[name collision") == 2
def test_no_collision_flag_when_unique(self, tmp_path, monkeypatch):
skills, pb = self._render(tmp_path, monkeypatch)
_mk_skill(skills, "personal-a")
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x")
_mark_active(skills, "org-1")
out = pb.build_skills_system_prompt()
assert "[name collision" not in out
class TestOrgSkillsAreEditableInPlace:
"""The learning loop must work ON shared skills, not around them.
Refusing edits to `_org/` froze exactly the skills the most people use:
the agent is instructed to patch a skill the moment it finds a gap, and
"fork it to a personal skill first" is not something an agent does
mid-task. So edits land in place; org updates never clobber them; the
user (or auto-propose) shares them back.
"""
def _org_skill(self, tmp_path, monkeypatch):
from tools import skill_manager_tool as smt
from agent import skill_utils as _sku
skills = tmp_path / "skills"
d = _mk_skill(
skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x"
)
_mark_active(skills, "org-1")
monkeypatch.setattr(smt, "_skills_dir", lambda: skills)
monkeypatch.setattr(
_sku, "get_all_skills_dirs", lambda: [skills], raising=True
)
return smt, skills, d
def test_patch_is_allowed_and_applied(self, tmp_path, monkeypatch):
smt, _skills, d = self._org_skill(tmp_path, monkeypatch)
result = smt._patch_skill("shared-x", "body", "improved")
assert result["success"] is True, result.get("error")
assert "improved" in (d / "SKILL.md").read_text(encoding="utf-8")
def test_edit_tells_the_user_how_to_share_it_back(self, tmp_path, monkeypatch):
smt, _skills, _d = self._org_skill(tmp_path, monkeypatch)
result = smt._patch_skill("shared-x", "body", "improved")
# Without auto-propose the edit stays local, and the tool result must
# say so AND name the command — otherwise the improvement is stranded.
assert "propose" in (result.get("org_sharing") or "")
def test_delete_is_still_refused(self, tmp_path, monkeypatch):
smt, _skills, d = self._org_skill(tmp_path, monkeypatch)
guard = smt._org_mirror_write_guard("shared-x", d, "delete")
assert guard is not None and guard["success"] is False
assert "admin" in guard["error"]
def test_curation_is_allowed(self, tmp_path, monkeypatch):
from tools import skill_usage as su
skills = tmp_path / "skills"
d = _mk_skill(
skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x"
)
monkeypatch.setattr(su, "_skills_dir", lambda: skills)
# The curator must be able to improve shared skills — they are the
# highest-leverage ones in the system.
assert su.is_curation_eligible("shared-x", d) is True
class TestOrgPullIsWiredIn:
"""Guards the integration gap that unit tests structurally cannot catch.
The org pull functions were fully implemented and unit-tested while having
ZERO runtime callers, so org skills never loaded for anyone. Testing the
functions directly could never surface that. These tests assert the CALL
SITES exist, so the feature can't silently become dead code again.
"""
def test_session_startup_calls_maybe_pull_org_skills(self):
import pathlib
cli_src = (
pathlib.Path(__file__).resolve().parents[2] / "cli.py"
).read_text(encoding="utf-8")
assert "maybe_pull_org_skills" in cli_src, (
"cli.py session startup must call maybe_pull_org_skills() — "
"without a call site the org mirror is never populated and org "
"skills never load (the function being importable is not enough)."
)
# It must sit alongside the personal pull, not replace it.
assert "maybe_pull_skills" in cli_src
def test_sync_pull_command_refreshes_org_mirror(self):
import pathlib
main_src = (
pathlib.Path(__file__).resolve().parents[2]
/ "hermes_cli"
/ "main.py"
).read_text(encoding="utf-8")
assert "maybe_pull_org_skills" in main_src, (
"`hermes sync pull` must also refresh the org mirror."
)
def test_sync_status_exposes_org_state(self):
from tools import skills_sync_client as ssc
status = ssc.sync_status()
# These keys must always be present so a user can tell whether the org
# workflow applies to them, rather than it being invisible.
for key in ("org_available", "org_id", "org_role", "org_skills"):
assert key in status, f"sync status must expose {key!r}"
def test_no_internal_jargon_in_user_facing_strings(self):
"""User-visible help/errors must not leak internal design coordinates."""
import pathlib
import re
root = pathlib.Path(__file__).resolve().parents[2]
targets = [
root / "hermes_cli" / "subcommands" / "sync.py",
root / "hermes_cli" / "subcommands" / "skills.py",
]
banned = re.compile(
r"\(M[12]\)|\bHSP\b|HSP/1|§[0-9]|DEV-PHASE|hsp-1-contract"
)
for path in targets:
for i, line in enumerate(path.read_text(encoding="utf-8").split("\n"), 1):
if "help=" in line or "description=" in line:
assert not banned.search(line), (
f"{path.name}:{i} leaks internal jargon to users: {line.strip()}"
)
class TestSkillSyncIsOneCommand:
"""Every Skill Sync verb lives under `hermes sync` for launch.
The surface is deliberately encapsulated: one command to learn, one to
document, and top-level `sync` stays free of skill-management verbs that
belong elsewhere. `propose` in particular used to sit under `hermes
skills`, which split one feature across two commands.
"""
def _src(self, *parts):
import pathlib
return (
pathlib.Path(__file__).resolve().parents[2].joinpath(*parts)
).read_text(encoding="utf-8")
def test_propose_is_a_sync_subcommand(self):
sync_src = self._src("hermes_cli", "subcommands", "sync.py")
assert '"propose"' in sync_src, (
"`propose` must be a `hermes sync` subcommand."
)
def test_propose_is_not_under_skills(self):
skills_src = self._src("hermes_cli", "subcommands", "skills.py")
assert '"propose"' not in skills_src, (
"`propose` must NOT remain under `hermes skills` — Skill Sync is "
"one command for launch."
)
def test_sync_usage_lists_propose(self):
main_src = self._src("hermes_cli", "main.py")
usage_start = main_src.index("usage: hermes sync ")
usage_block = main_src[usage_start : usage_start + 1400]
assert "propose" in usage_block, (
"`hermes sync` usage must list the propose verb."
)
class TestLocalEditsSurviveOrgUpdates:
"""Ben's requirement: local edits are never silently overwritten.
An org pull materializes the shared set. Before this, it `rmtree`'d each
skill dir and re-wrote it, so any local improvement vanished on the next
session start with no warning. Now a locally-modified skill is skipped
and reported as a conflict for the user to resolve deliberately.
"""
def _mirror(self, tmp_path, monkeypatch, body="original\n"):
from tools import skills_sync_client as ssc
skills = tmp_path / "skills"
d = _mk_skill(
skills,
f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x",
name="shared-x",
body=body,
)
_mark_active(skills, "org-1")
monkeypatch.setattr(ssc, "_skills_dir", lambda: skills)
monkeypatch.setattr(
ssc, "_org_dir", lambda: skills / sku.ORG_MIRROR_DIR_NAME
)
return ssc, skills, d
def test_unmodified_skill_is_not_flagged(self, tmp_path, monkeypatch):
ssc, _skills, d = self._mirror(tmp_path, monkeypatch)
ssc._write_org_baseline(
"org-1",
{"shared-x": {"fingerprint": ssc._skill_dir_fingerprint(d), "tree": "t1"}},
)
assert ssc.org_skill_is_locally_modified("shared-x", "org-1") is False
assert ssc.list_locally_modified_org_skills("org-1") == []
def test_edited_skill_is_detected(self, tmp_path, monkeypatch):
ssc, _skills, d = self._mirror(tmp_path, monkeypatch)
ssc._write_org_baseline(
"org-1",
{"shared-x": {"fingerprint": ssc._skill_dir_fingerprint(d), "tree": "t1"}},
)
(d / "SKILL.md").write_text("---\nname: shared-x\n---\nEDITED\n", encoding="utf-8")
assert ssc.org_skill_is_locally_modified("shared-x", "org-1") is True
assert ssc.list_locally_modified_org_skills("org-1") == ["shared-x"]
def test_missing_baseline_does_not_cry_wolf(self, tmp_path, monkeypatch):
ssc, _skills, _d = self._mirror(tmp_path, monkeypatch)
# Mirror pulled before baselines existed — must not be reported as
# modified (that would block every update with a phantom conflict).
assert ssc.org_skill_is_locally_modified("shared-x", "org-1") is False
def test_fingerprint_is_content_based_not_mtime(self, tmp_path, monkeypatch):
import os
import time
ssc, _skills, d = self._mirror(tmp_path, monkeypatch)
before = ssc._skill_dir_fingerprint(d)
time.sleep(0.01)
os.utime(d / "SKILL.md", None) # touch: mtime changes, content doesn't
assert ssc._skill_dir_fingerprint(d) == before
def test_auto_propose_defaults_off(self, monkeypatch):
from tools import skills_sync_client as ssc
monkeypatch.delenv("HERMES_SYNC_ORG_AUTO_PROPOSE", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: {}, raising=False
)
# Default must be OFF: silently pushing every agent edit to the whole
# organisation is not a safe default.
assert ssc.sync_org_auto_propose() is False
def test_auto_propose_can_be_enabled_by_env(self, monkeypatch):
from tools import skills_sync_client as ssc
monkeypatch.setenv("HERMES_SYNC_ORG_AUTO_PROPOSE", "1")
assert ssc.sync_org_auto_propose() is True

View File

@ -0,0 +1,978 @@
"""Tests for tools/skills_sync_client.py — the Skill Sync client.
Covers, against the frozen contract (~/src/specs/collective-wisdom/
the sync wire contract):
* content addressing (full 64-hex) + canonical JSON (§2.1, §2.5)
* the access gate (Nous admin) making sync inert
* the M1-D opt-in default (nothing syncs without the sync flag)
* object building (blob/tree/commit, exec mode, size limit)
* push (upload + CAS), pull (materialize), and the three-way merge / 409
conflict paths all against an in-process mock sync server.
The mock server implements the contract §3/§4 endpoint shapes with an
in-memory object store + ref table. No live server, no network.
"""
import hashlib
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import pytest
import tools.skills_sync_client as ssc
# ---------------------------------------------------------------------------
# In-process mock sync server (read + write endpoints)
# ---------------------------------------------------------------------------
class _MockState:
def __init__(self):
self.objects = {} # hash -> (kind, bytes)
self.refs = {} # name -> commit hash
self.hsp_version = "1"
self.max_object_bytes = 26214400
self.force_conflict_once = False # inject a 409 on the next CAS
# M2 org behavior (contract §11): advertise the "org" feature and,
# when org_role_admin is False, convert org-HEAD CAS to 202 proposals.
self.org_feature = True
self.org_role_admin = True
self.proposals = [] # [{n, to, base}]
def _make_handler(state: _MockState):
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args): # silence
pass
def _json(self, code, obj, extra_headers=None):
body = json.dumps(obj).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
for k, v in (extra_headers or {}).items():
self.send_header(k, v)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = self.path.split("?", 1)[0]
query = ""
if "?" in self.path:
query = self.path.split("?", 1)[1]
if path == "/v1/sync/capabilities":
features = ["personal"] + (["org"] if state.org_feature else [])
return self._json(200, {
"hsp_version": state.hsp_version,
"features": features,
"max_object_bytes": state.max_object_bytes,
"hash_alg": "sha256",
"auth": "bearer",
})
if path == "/v1/sync/refs":
prefix = ""
for part in query.split("&"):
if part.startswith("prefix="):
from urllib.parse import unquote
prefix = unquote(part[len("prefix="):])
refs = [
{"name": n, "hash": h}
for n, h in state.refs.items()
if n.startswith(prefix)
]
return self._json(200, {"refs": refs})
if path.startswith("/v1/sync/objects/"):
obj_hash = path[len("/v1/sync/objects/"):]
if obj_hash not in state.objects:
return self._json(404, {"error": "not_found"})
kind, data = state.objects[obj_hash]
if kind == ssc.KIND_BLOB:
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("X-HSP-Object-Type", "blob")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("X-HSP-Object-Type", kind)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
self._json(404, {"error": "unknown"})
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b""
path = self.path.split("?", 1)[0] # e.g. /v1/sync/objects?scope=org
if path == "/v1/sync/objects":
return self._handle_put_objects(raw)
if path.startswith("/v1/sync/refs/"):
return self._handle_cas(raw)
self._json(404, {"error": "unknown"})
def _handle_put_objects(self, raw):
# multipart/form-data: parse parts (field=hash, filename=type,
# body=raw bytes). The server recomputes each hash and 422s on
# mismatch (contract §4.2).
ctype = self.headers.get("Content-Type", "")
if "multipart/form-data" not in ctype:
return self._json(400, {"error": "expected multipart"})
boundary = ctype.split("boundary=", 1)[1].encode("ascii")
accepted, already = [], []
parts = raw.split(b"--" + boundary)
for part in parts:
# Only trim the delimiter framing: a leading CRLF and a
# trailing CRLF. Do NOT strip() the whole part -- that would
# also eat legitimate trailing newlines from the object bytes.
if part.startswith(b"\r\n"):
part = part[2:]
if part.endswith(b"\r\n"):
part = part[:-2]
if not part or part == b"--":
continue
if b"\r\n\r\n" not in part:
continue
headers_blob, body = part.split(b"\r\n\r\n", 1)
hdr_text = headers_blob.decode("utf-8", "replace")
claimed_hash = None
kind = None
for line in hdr_text.split("\r\n"):
if line.lower().startswith("content-disposition"):
for token in line.split(";"):
token = token.strip()
if token.startswith('name="'):
claimed_hash = token[len('name="'):-1]
elif token.startswith('filename="'):
kind = token[len('filename="'):-1]
if claimed_hash is None:
continue
real = "sha256:" + hashlib.sha256(body).hexdigest()
if real != claimed_hash:
return self._json(422, {
"error": "hash_mismatch", "claimed": claimed_hash,
})
if claimed_hash in state.objects:
already.append(claimed_hash)
else:
state.objects[claimed_hash] = (kind, body)
accepted.append(claimed_hash)
return self._json(200, {"accepted": accepted, "already_present": already})
def _handle_cas(self, raw):
from urllib.parse import unquote
name = unquote(self.path[len("/v1/sync/refs/"):])
body = json.loads(raw.decode("utf-8")) if raw else {}
frm = body.get("from")
to = body.get("to")
# M2 (contract §11.5): a non-admin member's CAS on an org HEAD is
# accept-always converted to a proposal → 202.
if name.startswith("refs/org/") and not state.org_role_admin:
n = len(state.proposals) + 1
state.proposals.append({"n": n, "to": to, "base": frm})
org = name.split("/")[2]
prop_ref = f"refs/org/{org}/proposals/{n}"
state.refs[prop_ref] = to
return self._json(202, {"proposal_id": n, "ref": prop_ref})
if state.force_conflict_once:
state.force_conflict_once = False
return self._json(409, {"actual": state.refs.get(name, "")})
current = state.refs.get(name)
if current != frm:
return self._json(409, {"actual": current or ""})
state.refs[name] = to
return self._json(200, {"ref": name, "hash": to})
return Handler
@pytest.fixture
def mock_server():
state = _MockState()
server = HTTPServer(("127.0.0.1", 0), _make_handler(state))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
base = f"http://127.0.0.1:{server.server_address[1]}"
try:
yield base, state
finally:
server.shutdown()
server.server_close()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_skill(skills_dir: Path, name: str, body: str = "# skill\n", *, category=None):
"""Create a minimal skill dir under skills_dir; return its path."""
parent = skills_dir / category if category else skills_dir
d = parent / name
d.mkdir(parents=True, exist_ok=True)
(d / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: test\n---\n{body}", encoding="utf-8"
)
return d
def _jwt(claims: dict) -> str:
import jwt as _pyjwt
return _pyjwt.encode(claims, "x" * 32, algorithm="HS256")
# ---------------------------------------------------------------------------
# Content addressing & canonicalization (contract §2.1, §2.5, OI-5)
# ---------------------------------------------------------------------------
class TestAddressing:
def test_full_64_hex_address(self):
addr = ssc.wire_address(b"")
# sha256 of empty is the well-known e3b0... digest, full 64 hex.
assert addr == (
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
)
assert len(addr.split(":", 1)[1]) == 64
def test_address_differs_from_local_truncated_namespace(self):
# The wire full-64-hex must NOT equal the local truncated 16-hex form.
data = b"hello world"
full = ssc.wire_address(data)
truncated = "sha256:" + hashlib.sha256(data).hexdigest()[:16]
assert full != truncated
assert len(full.split(":")[1]) == 64
assert len(truncated.split(":")[1]) == 16
def test_canonical_json_sorted_no_whitespace(self):
out = ssc.canonical_json_bytes({"b": 1, "a": 2})
assert out == b'{"a":2,"b":1}'
assert b" " not in out
assert not out.endswith(b"\n")
def test_canonical_json_stable(self):
obj = {"type": "tree", "entries": [{"name": "x", "hash": "sha256:aa"}]}
assert ssc.canonical_json_bytes(obj) == ssc.canonical_json_bytes(dict(obj))
# ---------------------------------------------------------------------------
# Access gate (Nous admin) + per-skill opt-in
# ---------------------------------------------------------------------------
class TestDevGate:
def test_gate_open_with_claim(self, monkeypatch):
token = _jwt({"sub": "user1", "tool_gateway_admin": True})
monkeypatch.setattr(
ssc, "resolve_nous_runtime_credentials",
lambda **kw: {"api_key": token, "base_url": "https://x"}, raising=False,
)
# patch the lazily-imported symbol used inside resolve_identity
import hermes_cli.auth as auth_mod
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials",
lambda **kw: {"api_key": token, "base_url": "https://x"})
ident = ssc.resolve_identity()
assert ident["nous_admin"] is True
assert ident["owner"] == "user1"
def test_gate_closed_without_claim(self, monkeypatch):
token = _jwt({"sub": "user1"}) # no tool_gateway_admin
import hermes_cli.auth as auth_mod
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials",
lambda **kw: {"api_key": token, "base_url": "https://x"})
ident = ssc.resolve_identity()
assert ident["nous_admin"] is False
def test_gate_closed_when_claim_false(self, monkeypatch):
token = _jwt({"sub": "u", "tool_gateway_admin": False})
import hermes_cli.auth as auth_mod
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials",
lambda **kw: {"api_key": token, "base_url": "https://x"})
assert ssc.dev_gate_open() is False
def test_maybe_push_inert_when_gate_closed(self, monkeypatch):
token = _jwt({"sub": "u"})
import hermes_cli.auth as auth_mod
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials",
lambda **kw: {"api_key": token})
monkeypatch.setattr(ssc, "resolve_sync_base_url", lambda: "http://x")
# gate closed -> None (inert), never attempts a push
assert ssc.maybe_push_skills() is None
def test_maybe_pull_inert_when_not_logged_in(self, monkeypatch):
import hermes_cli.auth as auth_mod
def _raise(**kw):
raise RuntimeError("not logged in")
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", _raise)
assert ssc.maybe_pull_skills() is None
# ---------------------------------------------------------------------------
# Object building (contract §2.2-§2.4)
# ---------------------------------------------------------------------------
class TestObjectBuilding:
def test_build_tree_blob_and_exec(self, tmp_path):
d = tmp_path / "skill"
d.mkdir()
(d / "SKILL.md").write_text("hello", encoding="utf-8")
script = d / "run.sh"
script.write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
script.chmod(0o755)
objects = ssc.ObjectSet()
tree_hash = ssc.build_tree(d, objects, max_object_bytes=ssc.DEFAULT_MAX_OBJECT_BYTES)
assert tree_hash.startswith("sha256:")
# tree object present and canonical
kind, data = objects.objects[tree_hash]
assert kind == ssc.KIND_TREE
tree = json.loads(data)
entries = {e["name"]: e for e in tree["entries"]}
assert entries["SKILL.md"]["mode"] == ssc.MODE_FILE
assert entries["run.sh"]["mode"] == ssc.MODE_EXEC
# entries sorted by name (byte order)
names = [e["name"] for e in tree["entries"]]
assert names == sorted(names)
def test_build_tree_dedups_identical_blobs(self, tmp_path):
d = tmp_path / "skill"
(d / "a").mkdir(parents=True)
(d / "b").mkdir(parents=True)
(d / "a" / "f.txt").write_text("same", encoding="utf-8")
(d / "b" / "f.txt").write_text("same", encoding="utf-8")
objects = ssc.ObjectSet()
ssc.build_tree(d, objects, max_object_bytes=ssc.DEFAULT_MAX_OBJECT_BYTES)
blob_hashes = [h for h, (k, _) in objects.objects.items() if k == ssc.KIND_BLOB]
# only one unique blob for the identical "same" content
assert len(set(blob_hashes)) == 1
def test_build_tree_skips_symlink(self, tmp_path):
d = tmp_path / "skill"
d.mkdir()
(d / "real.txt").write_text("x", encoding="utf-8")
try:
(d / "link.txt").symlink_to(d / "real.txt")
except (OSError, NotImplementedError):
pytest.skip("symlinks unsupported here")
objects = ssc.ObjectSet()
tree_hash = ssc.build_tree(d, objects, max_object_bytes=ssc.DEFAULT_MAX_OBJECT_BYTES)
tree = json.loads(objects.objects[tree_hash][1])
names = [e["name"] for e in tree["entries"]]
assert "link.txt" not in names
assert "real.txt" in names
def test_build_tree_rejects_oversize_blob(self, tmp_path):
d = tmp_path / "skill"
d.mkdir()
(d / "big").write_bytes(b"x" * 100)
objects = ssc.ObjectSet()
with pytest.raises(ValueError):
ssc.build_tree(d, objects, max_object_bytes=10)
def test_build_commit_shape(self):
objects = ssc.ObjectSet()
c = ssc.build_commit(
"sha256:tree", ["sha256:p"], owner="o", device="dev",
message="m", objects=objects, ts="2026-07-18T00:00:00Z",
)
commit = json.loads(objects.objects[c][1])
assert commit["type"] == "commit"
assert commit["tree"] == "sha256:tree"
assert commit["parents"] == ["sha256:p"]
assert commit["author"] == {"owner": "o", "device": "dev"}
assert commit["artifact_type"] == "skill"
# ---------------------------------------------------------------------------
# Three-way merge decision (contract §4.4, M1-C; mirrors skills_sync.py:619)
# ---------------------------------------------------------------------------
class TestMergeDecision:
def test_no_change(self):
assert ssc._merge_skill("b", "b", "b") == "either"
def test_ours_only_changed(self):
assert ssc._merge_skill("b", "o", "b") == "ours"
def test_theirs_only_changed(self):
assert ssc._merge_skill("b", "b", "t") == "theirs"
def test_both_converged(self):
assert ssc._merge_skill("b", "x", "x") == "either"
def test_true_overlap(self):
assert ssc._merge_skill("b", "o", "t") == "overlap"
def test_deleted_both(self):
assert ssc._merge_skill(None, None, None) == "none"
# ---------------------------------------------------------------------------
# End-to-end push / pull / conflict against the mock server
# ---------------------------------------------------------------------------
@pytest.fixture
def synced_env(tmp_path, monkeypatch):
"""A HERMES_HOME with two opted-in skills + a token-carrying identity."""
import hermes_constants
home = tmp_path / "hermes"
skills = home / "skills"
skills.mkdir(parents=True)
monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: home)
monkeypatch.setattr(ssc, "_skills_dir", lambda: skills)
_write_skill(skills, "alpha", body="alpha v1\n")
_write_skill(skills, "beta", body="beta v1\n", category="devops")
# Opt both into sync + treat them as eligible (bypass bundled/hub checks).
monkeypatch.setattr(ssc, "list_synced_skill_names", lambda: ["alpha", "beta"])
def _rel(name):
from pathlib import PurePosixPath
return {"alpha": PurePosixPath("alpha"),
"beta": PurePosixPath("devops/beta")}.get(name)
monkeypatch.setattr(ssc, "_skill_rel_path", _rel)
def _find(name):
return {"alpha": skills / "alpha",
"beta": skills / "devops" / "beta"}.get(name)
import tools.skill_usage as su
monkeypatch.setattr(su, "_find_skill_dir", _find)
token = _jwt({"sub": "owner1", "tool_gateway_admin": True})
identity = {"api_key": token, "base_url": "http://x", "owner": "owner1",
"nous_admin": True, "claims": {}}
return home, skills, identity
class TestEndToEnd:
def test_capabilities_version_check(self, mock_server):
base, state = mock_server
client = ssc.SyncClient(base, "tok")
caps = client.capabilities()
assert caps["hsp_version"] == "1"
ssc._check_version(caps) # no raise
def test_version_mismatch_raises(self, mock_server):
base, state = mock_server
state.hsp_version = "2"
client = ssc.SyncClient(base, "tok")
with pytest.raises(ssc.SyncError):
ssc._check_version(client.capabilities())
def test_push_uploads_and_cas(self, mock_server, synced_env):
base, state = mock_server
home, skills, identity = synced_env
client = ssc.SyncClient(base, identity["api_key"])
result = ssc.push_skills(client, identity=identity)
assert result["ok"] is True
# HEAD ref advanced to our commit
head = state.refs["refs/user/owner1/HEAD"]
assert head == result["head"]
# commit object is present and well-formed
kind, data = state.objects[head]
assert kind == ssc.KIND_COMMIT
commit = json.loads(data)
assert commit["author"]["owner"] == "owner1"
assert commit["parents"] == [] # first commit
def test_push_then_pull_materializes(self, mock_server, synced_env, tmp_path, monkeypatch):
base, state = mock_server
home, skills, identity = synced_env
client = ssc.SyncClient(base, identity["api_key"])
ssc.push_skills(client, identity=identity)
# Simulate a fresh device: new skills dir, same server, same opt-in.
dev2 = tmp_path / "hermes2" / "skills"
dev2.mkdir(parents=True)
monkeypatch.setattr(ssc, "_skills_dir", lambda: dev2)
monkeypatch.setattr(ssc, "read_sync_state", lambda: {"head": None, "skills": {}})
saved = {}
monkeypatch.setattr(ssc, "write_sync_state", lambda d: saved.update(d))
result = ssc.pull_skills(client, identity=identity)
assert result["ok"] is True
assert "alpha" in result["updated"]
assert "devops/beta" in result["updated"]
# content materialized to disk
assert (dev2 / "alpha" / "SKILL.md").read_text().endswith("alpha v1\n")
assert (dev2 / "devops" / "beta" / "SKILL.md").read_text().endswith("beta v1\n")
def test_push_idempotent_reupload(self, mock_server, synced_env):
base, state = mock_server
home, skills, identity = synced_env
client = ssc.SyncClient(base, identity["api_key"])
r1 = ssc.push_skills(client, identity=identity)
n_objects = len(state.objects)
# push again with no local change -> same head, objects already_present
r2 = ssc.push_skills(client, identity=identity)
assert r2["ok"] is True
assert r2["head"] == r1["head"]
assert len(state.objects) == n_objects # nothing new stored
def test_conflict_nonoverlap_merges(self, mock_server, synced_env, monkeypatch):
base, state = mock_server
home, skills, identity = synced_env
client = ssc.SyncClient(base, identity["api_key"])
# First push establishes a base head we record locally.
first = ssc.push_skills(client, identity=identity)
# Inject a divergent server head: change beta server-side so the next
# CAS loses. We simulate by forcing one 409 whose actual == current head
# (the server keeps the same tree, so no overlap on alpha which we edit).
(skills / "alpha" / "SKILL.md").write_text(
"---\nname: alpha\ndescription: test\n---\nalpha v2\n", encoding="utf-8"
)
state.force_conflict_once = True
result = ssc.push_skills(client, identity=identity)
# actual == our own head -> both-sides identical -> merge commit succeeds
assert result.get("ok") is True
assert result.get("merged") is True
def test_conflict_true_overlap_writes_conflict_ref(self, mock_server, synced_env, monkeypatch):
base, state = mock_server
home, skills, identity = synced_env
client = ssc.SyncClient(base, identity["api_key"])
ssc.push_skills(client, identity=identity)
# Build a DIFFERENT server-side head for the SAME skill (alpha) so the
# three-way merge sees a true overlap. We construct it via a second
# snapshot after editing alpha differently, push it directly, then make
# our local head stale and edit alpha a third way.
(skills / "alpha" / "SKILL.md").write_text(
"---\nname: alpha\ndescription: test\n---\nSERVER edit\n", encoding="utf-8"
)
objs, root, _ = ssc.snapshot_profile(["alpha", "beta"])
their_commit = ssc.build_commit(
root, [], owner="owner1", device="other", message="theirs", objects=objs
)
client.put_objects(objs.objects)
state.refs["refs/user/owner1/HEAD"] = their_commit
# Our local edit to the same skill, from the OLD base -> true overlap.
(skills / "alpha" / "SKILL.md").write_text(
"---\nname: alpha\ndescription: test\n---\nLOCAL edit\n", encoding="utf-8"
)
result = ssc.push_skills(client, identity=identity)
assert result.get("conflict") is True
assert result["conflict_ref"].startswith("refs/user/owner1/conflict/")
assert "alpha" in result["overlapping_skills"]
# a conflict ref head was written server-side
assert result["conflict_ref"] in state.refs
# ---------------------------------------------------------------------------
# M1-D opt-in sidecar flag (tools/skill_usage.set_sync / is_sync_enabled)
# ---------------------------------------------------------------------------
class TestOptInFlag:
def test_set_and_read_sync_flag(self, tmp_path, monkeypatch):
import tools.skill_usage as su
monkeypatch.setattr(su, "_skills_dir", lambda: tmp_path)
# Make the skill curation-eligible so the gated mutator writes.
monkeypatch.setattr(su, "is_curation_eligible", lambda name, *a, **k: True)
assert su.is_sync_enabled("foo") is False
su.set_sync("foo", True)
assert su.is_sync_enabled("foo") is True
su.set_sync("foo", False)
assert su.is_sync_enabled("foo") is False
def test_sync_flag_ignored_for_ineligible(self, tmp_path, monkeypatch):
import tools.skill_usage as su
monkeypatch.setattr(su, "_skills_dir", lambda: tmp_path)
# Bundled/hub/external skills are not curation-eligible -> mutator no-ops.
monkeypatch.setattr(su, "is_curation_eligible", lambda name, *a, **k: False)
su.set_sync("bundled-skill", True)
assert su.is_sync_enabled("bundled-skill") is False
# ---------------------------------------------------------------------------
# §2.8 sync-manifest — opt-in as content in the sync plane (cross-device)
# ---------------------------------------------------------------------------
class TestSyncManifest:
def test_build_parse_roundtrip(self):
data = ssc.build_sync_manifest_bytes({"beta": True, "alpha": False})
parsed = ssc.parse_sync_manifest(data)
assert parsed == {"alpha": False, "beta": True}
def test_manifest_wire_shape(self):
# Must match gateway-gateway src/sync/manifest.ts: type + version:1 +
# skills:[{name,enabled}]. Skills sorted by name for a stable address.
import json
data = ssc.build_sync_manifest_bytes({"z": True, "a": True})
obj = json.loads(data.decode("utf-8"))
assert obj["type"] == "sync-manifest"
assert obj["version"] == 1
assert obj["skills"] == [
{"name": "a", "enabled": True},
{"name": "z", "enabled": True},
]
def test_parse_rejects_malformed(self):
# Strict: unknown type, bad version, non-array skills, malformed entry.
assert ssc.parse_sync_manifest(b"not json") is None
assert ssc.parse_sync_manifest(b'{"type":"nope","version":1,"skills":[]}') is None
assert ssc.parse_sync_manifest(b'{"type":"sync-manifest","version":2,"skills":[]}') is None
assert ssc.parse_sync_manifest(b'{"type":"sync-manifest","version":1,"skills":{}}') is None
assert (
ssc.parse_sync_manifest(
b'{"type":"sync-manifest","version":1,"skills":[{"name":"x"}]}'
)
is None
)
# A malformed manifest must NOT be mistaken for "no skills opted in".
assert ssc.parse_sync_manifest(b'{"type":"sync-manifest","version":1,"skills":[]}') == {}
def test_snapshot_embeds_manifest_root_blob(self, mock_server, synced_env):
# snapshot_profile must add a root-level `sync-manifest` blob recording
# the opted-in set, alongside the skill subtrees, so opt-in is durable
# plane content. Read it back via read_manifest_of_root.
base, state = mock_server
home, skills, identity = synced_env
client = ssc.SyncClient(base, identity["api_key"])
objs, root_hash, skill_map = ssc.snapshot_profile(["alpha", "beta"])
client.put_objects(objs.objects)
manifest = ssc.read_manifest_of_root(client, root_hash)
assert manifest == {"alpha": True, "beta": True}
# The manifest is a root-level BLOB, not a skill subtree, so the skill
# walk must not surface it as a skill.
trees = ssc._skill_trees_of_root(client, root_hash)
assert "sync-manifest" not in trees
assert set(trees) == {"alpha", "devops/beta"}
def test_pull_adopts_opt_in_from_manifest(self, mock_server, synced_env, monkeypatch):
# A skill opted in on device A (present + enabled in the plane manifest)
# becomes opted in locally on pull, even if this device had it disabled.
base, state = mock_server
home, skills, identity = synced_env
client = ssc.SyncClient(base, identity["api_key"])
# Device A pushes alpha+beta (manifest enables both).
ssc.push_skills(client, identity=identity)
# Simulate device B: local opt-in intent is EMPTY, but eligibility passes.
adopted = {}
import tools.skill_usage as su
monkeypatch.setattr(su, "is_curation_eligible", lambda name, *a, **k: True)
monkeypatch.setattr(su, "is_sync_enabled", lambda name: False)
monkeypatch.setattr(su, "set_sync", lambda name, val: adopted.__setitem__(name, val))
# Local head unknown so the pull actually runs.
monkeypatch.setattr(ssc, "read_sync_state", lambda: {"head": None, "skills": {}})
monkeypatch.setattr(ssc, "write_sync_state", lambda d: None)
# No local opt-in gate (so materialize isn't the thing under test).
monkeypatch.setattr(ssc, "_opted_in_rel_paths", lambda: [])
result = ssc.pull_skills(client, identity=identity)
assert result["ok"] is True
# Both skills from the plane manifest were adopted into local opt-in.
assert adopted == {"alpha": True, "beta": True}
assert set(result["opt_in_adopted"]) == {"alpha", "beta"}
# ---------------------------------------------------------------------------
# Env-var configuration (Hermes Cloud "on by default" via environment)
# ---------------------------------------------------------------------------
class TestEnvConfig:
def test_base_url_env_wins(self, monkeypatch):
monkeypatch.setenv("HERMES_SYNC_BASE_URL", "https://plane.example/")
assert ssc.resolve_sync_base_url() == "https://plane.example"
def test_base_url_defaults_to_production(self, monkeypatch):
# With nothing configured a user must still reach the real plane —
# otherwise every sync command fails with "no base URL configured".
monkeypatch.delenv("HERMES_SYNC_BASE_URL", raising=False)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}, raising=False)
assert ssc.resolve_sync_base_url() == ssc.DEFAULT_SYNC_BASE_URL
def test_default_is_a_bare_https_origin(self):
# The client appends /v1/sync/, so the default must be a scheme+host
# origin with no trailing slash and no path.
from urllib.parse import urlparse
parsed = urlparse(ssc.DEFAULT_SYNC_BASE_URL)
assert parsed.scheme == "https"
assert parsed.netloc
assert parsed.path == ""
assert not ssc.DEFAULT_SYNC_BASE_URL.endswith("/")
def test_config_overrides_default(self, monkeypatch):
monkeypatch.delenv("HERMES_SYNC_BASE_URL", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"sync": {"base_url": "https://cfg.example/"}},
raising=False,
)
assert ssc.resolve_sync_base_url() == "https://cfg.example"
def test_feature_enabled_env(self, monkeypatch):
# Default off.
monkeypatch.delenv("HERMES_SYNC_ENABLED", raising=False)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}, raising=False)
assert ssc.sync_feature_enabled() is False
for truthy in ("1", "true", "YES", "on"):
monkeypatch.setenv("HERMES_SYNC_ENABLED", truthy)
assert ssc.sync_feature_enabled() is True
for falsy in ("0", "false", "off"):
monkeypatch.setenv("HERMES_SYNC_ENABLED", falsy)
assert ssc.sync_feature_enabled() is False
def test_default_opt_in_env(self, monkeypatch):
monkeypatch.delenv("HERMES_SYNC_DEFAULT_OPT_IN", raising=False)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}, raising=False)
assert ssc.sync_default_opt_in() is False
monkeypatch.setenv("HERMES_SYNC_DEFAULT_OPT_IN", "true")
assert ssc.sync_default_opt_in() is True
def test_config_yaml_fallback_when_no_env(self, monkeypatch):
monkeypatch.delenv("HERMES_SYNC_ENABLED", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"sync": {"enabled": True}},
raising=False,
)
assert ssc.sync_feature_enabled() is True
def test_env_overrides_config_yaml(self, monkeypatch):
# Env wins over config.yaml (operator override precedence).
monkeypatch.setenv("HERMES_SYNC_ENABLED", "false")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"sync": {"enabled": True}},
raising=False,
)
assert ssc.sync_feature_enabled() is False
def test_opt_out_policy_syncs_all_eligible(self, monkeypatch):
# With opt-out on, every eligible skill syncs even with no `sync:true`
# flag; an explicit `sync:false` still excludes.
monkeypatch.setattr(ssc, "sync_default_opt_in", lambda: True)
monkeypatch.setattr(ssc, "_all_local_skill_names", lambda: ["alpha", "beta", "gamma"])
monkeypatch.setattr(ssc, "is_sync_eligible", lambda n: n in {"alpha", "beta", "gamma"})
import tools.skill_usage as su
# gamma explicitly opted out; alpha/beta have no flag.
monkeypatch.setattr(su, "load_usage", lambda: {"gamma": {"sync": False}})
assert ssc.list_synced_skill_names() == ["alpha", "beta"]
def test_opt_in_policy_requires_flag(self, monkeypatch):
# With opt-out OFF (default opt-in), only explicitly-enabled skills sync.
monkeypatch.setattr(ssc, "sync_default_opt_in", lambda: False)
monkeypatch.setattr(ssc, "is_sync_eligible", lambda n: True)
import tools.skill_usage as su
monkeypatch.setattr(
su, "load_usage",
lambda: {"alpha": {"sync": True}, "beta": {}, "gamma": {"sync": False}},
)
assert ssc.list_synced_skill_names() == ["alpha"]
class TestDeviceName:
def test_default_is_hostname_seeded(self, tmp_path, monkeypatch):
monkeypatch.setattr(ssc, "_skills_dir", lambda: tmp_path)
monkeypatch.delenv("HERMES_SYNC_DEVICE_NAME", raising=False)
monkeypatch.setattr(
"socket.gethostname", lambda: "bens-macbook.local", raising=False
)
val = ssc.stable_device_id()
# short hostname + short suffix, NOT a bare 32-char hash
assert val.startswith("bens-macbook-")
assert val != "bens-macbook-"
# persisted + stable across calls
assert (tmp_path / ".sync_device_id").read_text() == val
assert ssc.stable_device_id() == val
def test_existing_file_wins_over_default_and_env(self, tmp_path, monkeypatch):
monkeypatch.setattr(ssc, "_skills_dir", lambda: tmp_path)
(tmp_path / ".sync_device_id").write_text("Explicit Name", encoding="utf-8")
monkeypatch.setenv("HERMES_SYNC_DEVICE_NAME", "cloud-seed")
assert ssc.stable_device_id() == "Explicit Name"
def test_env_seeds_first_use(self, tmp_path, monkeypatch):
# Hermes Cloud path: HERMES_SYNC_DEVICE_NAME seeds the first-use label.
monkeypatch.setattr(ssc, "_skills_dir", lambda: tmp_path)
monkeypatch.setenv("HERMES_SYNC_DEVICE_NAME", "hermes-cloud-ben-1")
assert ssc.stable_device_id() == "hermes-cloud-ben-1"
# persisted so it stays stable even if the env later changes
assert (tmp_path / ".sync_device_id").read_text() == "hermes-cloud-ben-1"
monkeypatch.setenv("HERMES_SYNC_DEVICE_NAME", "changed")
assert ssc.stable_device_id() == "hermes-cloud-ben-1"
def test_set_device_name_overwrites(self, tmp_path, monkeypatch):
monkeypatch.setattr(ssc, "_skills_dir", lambda: tmp_path)
(tmp_path / ".sync_device_id").write_text("old", encoding="utf-8")
stored = ssc.set_device_name(" Ben's Laptop ")
assert stored == "Ben's Laptop" # trimmed
assert ssc.stable_device_id() == "Ben's Laptop"
def test_set_device_name_rejects_empty(self, tmp_path, monkeypatch):
monkeypatch.setattr(ssc, "_skills_dir", lambda: tmp_path)
import pytest
with pytest.raises(ValueError):
ssc.set_device_name(" ")
# ---------------------------------------------------------------------------
# M2 org-shared skills (contract §11): identity gate, pull, propose (202/merge)
# ---------------------------------------------------------------------------
def _org_identity(role=None, org_id="org-1", owner="owner1"):
claims = {"sub": owner, "org_id": org_id, "tool_gateway_admin": True}
if role is not None:
claims["org_role"] = role
token = _jwt(claims)
return {"api_key": token, "base_url": "http://x", "owner": owner,
"nous_admin": True, "claims": claims,
**({"org_id": org_id, "org_role": role} if role else {})}
class TestOrgIdentityGate:
def test_org_identity_requires_role_claim(self, monkeypatch):
# Personal org: NAS stamps NO org_role -> inert, not an error path.
token = _jwt({"sub": "u", "org_id": "org-1"})
import hermes_cli.auth as auth_mod
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials",
lambda **kw: {"api_key": token, "base_url": "https://x"})
with pytest.raises(ssc.SyncInertError):
ssc.resolve_org_identity()
assert ssc.org_sync_available() is False
def test_org_identity_with_role(self, monkeypatch):
token = _jwt({"sub": "u", "org_id": "org-9", "org_role": "MEMBER"})
import hermes_cli.auth as auth_mod
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials",
lambda **kw: {"api_key": token, "base_url": "https://x"})
ident = ssc.resolve_org_identity()
assert ident["org_id"] == "org-9"
assert ident["org_role"] == "MEMBER"
assert ssc.org_sync_available() is True
def test_org_mirror_excluded_from_personal_sync(self, tmp_path, monkeypatch):
# A skill under _org/<id>/ must never be personal-sync eligible.
skills = tmp_path / "skills"
org_skill = skills / "_org" / "org-1" / "shared-x"
org_skill.mkdir(parents=True)
(org_skill / "SKILL.md").write_text("---\nname: shared-x\n---\n")
monkeypatch.setattr(ssc, "_skills_dir", lambda: skills)
import tools.skill_usage as su
monkeypatch.setattr(su, "is_bundled", lambda n: False)
monkeypatch.setattr(su, "is_hub_installed", lambda n: False)
monkeypatch.setattr(su, "_find_skill_dir", lambda n: org_skill)
import agent.skill_utils as sku
monkeypatch.setattr(sku, "is_external_skill_path", lambda p: False)
assert ssc.is_sync_eligible("shared-x") is False
class TestOrgEndToEnd:
def test_admin_propose_merges_directly(self, mock_server, synced_env):
base, state = mock_server
home, skills, identity = synced_env
identity = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
client = ssc.SyncClient(base, identity["api_key"])
result = ssc.propose_skill("alpha", client, identity=identity)
assert result["ok"] is True
assert result.get("merged") is True
head = state.refs["refs/org/org-1/HEAD"]
assert head == result["head"]
commit = json.loads(state.objects[head][1])
assert commit["parents"] == [] # first org commit
def test_member_propose_becomes_202_proposal(self, mock_server, synced_env):
base, state = mock_server
home, skills, identity = synced_env
# Seed an org HEAD as admin first.
admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
client = ssc.SyncClient(base, identity["api_key"])
seeded = ssc.propose_skill("alpha", client, identity=admin_ident)
# Member edits beta and proposes: server converts to 202.
state.org_role_admin = False
(skills / "devops" / "beta" / "SKILL.md").write_text(
"---\nname: beta\n---\nbeta v2 member edit\n", encoding="utf-8"
)
member_ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"}
result = ssc.propose_skill("beta", client, identity=member_ident)
assert result["ok"] is True
assert result.get("proposal_pending") is True
assert result["proposal_id"] == 1
# HEAD untouched; proposal ref parked at the member's commit.
assert state.refs["refs/org/org-1/HEAD"] == seeded["head"]
assert state.refs["refs/org/org-1/proposals/1"] == result["commit"]
# NEVER reported as merged.
assert "merged" not in result
def test_member_proposal_splices_not_replaces(self, mock_server, synced_env):
# The proposed root must keep the OTHER skills from HEAD (per-skill
# delta, not a wholesale replace).
base, state = mock_server
home, skills, identity = synced_env
admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
client = ssc.SyncClient(base, identity["api_key"])
ssc.propose_skill("alpha", client, identity=admin_ident)
ssc.propose_skill("beta", client, identity=admin_ident)
state.org_role_admin = False
member_ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"}
result = ssc.propose_skill("alpha", client, identity=member_ident)
# Walk the proposed commit's root: both skills present.
commit = json.loads(state.objects[result["commit"]][1])
root = json.loads(state.objects[commit["tree"]][1])
names = {e["name"] for e in root["entries"]}
assert "alpha" in names and "devops" in names
def test_pull_org_skills_materializes_mirror(self, mock_server, synced_env):
base, state = mock_server
home, skills, identity = synced_env
admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
client = ssc.SyncClient(base, identity["api_key"])
ssc.propose_skill("alpha", client, identity=admin_ident)
result = ssc.pull_org_skills(client, identity=admin_ident)
assert result["ok"] is True
assert "alpha" in result["updated"]
mirrored = skills / "_org" / "org-1" / "alpha" / "SKILL.md"
assert mirrored.exists()
assert mirrored.read_text().endswith("alpha v1\n")
def test_pull_org_noop_when_no_head(self, mock_server, synced_env):
base, state = mock_server
home, skills, identity = synced_env
ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"}
client = ssc.SyncClient(base, identity["api_key"])
result = ssc.pull_org_skills(client, identity=ident)
assert result["ok"] is True
assert result["head"] is None
assert result["updated"] == []
def test_propose_requires_org_feature(self, mock_server, synced_env):
base, state = mock_server
home, skills, identity = synced_env
state.org_feature = False
ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
client = ssc.SyncClient(base, identity["api_key"])
with pytest.raises(ssc.SyncInertError):
ssc.propose_skill("alpha", client, identity=ident)
def test_maybe_pull_org_inert_without_role(self, monkeypatch):
# Personal org: no org_role claim -> None, never raises.
token = _jwt({"sub": "u", "org_id": "org-1"})
import hermes_cli.auth as auth_mod
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials",
lambda **kw: {"api_key": token})
assert ssc.maybe_pull_org_skills() is None

View File

@ -662,6 +662,81 @@ def _find_skill(name: str) -> Optional[Dict[str, Any]]:
return None
def _maybe_auto_propose_org_edit(name: str, skill_path: Path) -> Optional[str]:
"""Submit an org-skill edit upstream when `sync.org_auto_propose` is on.
Returns a short note for the tool result, or None when nothing happened.
Never raises: an offline/failed submission must not fail the edit itself
the change is already saved locally and can be proposed later.
"""
try:
from agent.skill_utils import is_org_mirror_path
from tools import skills_sync_client as ssc
if not is_org_mirror_path(skill_path, _skills_dir()):
return None
if not ssc.sync_org_auto_propose():
return (
f"This skill is shared by your organisation. Your edit is "
f"saved locally and will not be overwritten by org updates. "
f"Run `hermes sync propose {name}` to share it back."
)
result = ssc.propose_skill(name)
if result.get("proposal_pending"):
return (
f"Auto-proposed to your organisation as proposal "
f"#{result.get('proposal_id')} (pending admin review)."
)
return "Auto-proposed to your organisation (merged into the shared set)."
except Exception as e:
logger.debug("auto-propose skipped for %s: %s", name, e)
return (
f"Edit saved locally. Could not submit it to your organisation "
f"right now — run `hermes sync propose {name}` to retry."
)
def _org_mirror_write_guard(name: str, skill_path: Path, action: str) -> Optional[Dict[str, Any]]:
"""Org-shared skills are EDITABLE IN PLACE — this only blocks deletion.
Earlier versions refused every write to `_org/`, which broke the learning
loop exactly where it matters most: the agent is told to patch a skill the
moment it finds a gap, and shared skills are the ones the most people use.
Blocking that froze org skills while personal ones kept improving, and the
"fork it into a personal skill" alternative is not something an agent does
mid-task so improvements were simply lost.
Now an edit lands in the mirror and is protected from being overwritten by
the next org pull (see the baseline sidecar in skills_sync_client). It
reaches the organisation when the user runs `hermes sync propose`, or
immediately if `sync.org_auto_propose` is on.
Deletion is still refused: the mirror is a materialized view of the org
HEAD, so a local delete is meaningless (the next pull restores it) and
removing a skill for the organisation is an admin action, not a local one.
"""
if action not in {"delete", "remove_file"}:
return None
try:
from agent.skill_utils import is_org_mirror_path
if is_org_mirror_path(skill_path, _skills_dir()):
return {
"success": False,
"error": (
f"Cannot {action} '{name}' locally: it is shared by your "
"organisation, so a local delete would just come back on "
"the next sync. Ask an org admin to remove it for "
"everyone. (Editing it IS allowed — your changes are kept "
"and can be proposed back with `hermes sync propose "
f"{name}`.)"
),
}
except Exception:
logger.debug("org mirror guard lookup failed for %s", name, exc_info=True)
return None
def _find_skill_in_other_profiles(name: str) -> List[Tuple[str, Path]]:
"""Look for ``name`` under SKILL.md across OTHER Hermes profiles.
@ -912,6 +987,9 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:
existing = _find_skill(name)
if not existing:
return {"success": False, "error": _skill_not_found_error(name)}
org_guard = _org_mirror_write_guard(name, existing["path"], "edit")
if org_guard:
return org_guard
guard = _background_review_write_guard(name, existing["path"], "edit")
if guard:
return guard
@ -950,6 +1028,10 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:
"path": str(existing["path"]),
"_change": {"description": _desc},
}
org_note = _maybe_auto_propose_org_edit(name, existing["path"])
if org_note:
result["org_sharing"] = org_note
result["message"] = f"{result['message']} {org_note}"
_add_description_prompt_preview(result, content)
return result
@ -976,6 +1058,9 @@ def _patch_skill(
return {"success": False, "error": _skill_not_found_error(name)}
skill_dir = existing["path"]
org_guard = _org_mirror_write_guard(name, skill_dir, "patch")
if org_guard:
return org_guard
guard = _background_review_write_guard(name, skill_dir, "patch")
if guard:
return guard
@ -1064,6 +1149,10 @@ def _patch_skill(
"old": old_string[:200] + ("" if len(old_string) > 200 else ""),
"new": new_string[:200] + ("" if len(new_string) > 200 else ""),
}
org_note = _maybe_auto_propose_org_edit(name, skill_dir)
if org_note:
result["org_sharing"] = org_note
result["message"] = f"{result['message']} {org_note}"
return result
@ -1082,6 +1171,9 @@ def _delete_skill(name: str, absorbed_into: Optional[str] = None) -> Dict[str, A
existing = _find_skill(name)
if not existing:
return {"success": False, "error": _skill_not_found_error(name)}
org_guard = _org_mirror_write_guard(name, existing["path"], "delete")
if org_guard:
return org_guard
guard = _background_review_write_guard(name, existing["path"], "delete")
if guard:
return guard
@ -1199,6 +1291,9 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:
existing = _find_skill(name)
if not existing:
return {"success": False, "error": _skill_not_found_error(name, " Create it first with action='create'.")}
org_guard = _org_mirror_write_guard(name, existing["path"], "write_file")
if org_guard:
return org_guard
guard = _background_review_write_guard(name, existing["path"], "write_file")
if guard:
return guard
@ -1227,11 +1322,16 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:
target.unlink(missing_ok=True)
return {"success": False, "error": scan_error}
return {
result = {
"success": True,
"message": f"File '{file_path}' written to skill '{name}'.",
"path": str(target),
}
org_note = _maybe_auto_propose_org_edit(name, existing["path"])
if org_note:
result["org_sharing"] = org_note
result["message"] = f"{result['message']} {org_note}"
return result
def _remove_file(name: str, file_path: str) -> Dict[str, Any]:
@ -1360,6 +1460,56 @@ def apply_skill_pending(payload: Dict[str, Any]) -> str:
_skill_gate_bypass.reset(token)
# Debounce state for the sync push hook. A burst of skill_manage writes
# (e.g. create + several write_file calls) collapses into a single push after
# a short quiet window, on a daemon timer so the agent write never blocks.
_sync_push_timer = None
_sync_push_lock = None
_SYNC_PUSH_DEBOUNCE_S = 5.0
def _maybe_debounced_sync_push(skill_name: str) -> None:
"""Schedule a debounced best-effort sync push after a skill write.
Cheap fast-path: if the skill isn't opted into sync, do nothing (no auth,
no network). Otherwise (re)arm a daemon timer; the actual push runs through
``skills_sync_client.maybe_push_skills`` which enforces the access gate
and swallows all errors. Never blocks the caller (M1-C: agent never blocks
on sync).
"""
global _sync_push_timer, _sync_push_lock
try:
from tools.skill_usage import is_sync_enabled
if not is_sync_enabled(skill_name):
return
except Exception:
return
import threading
if _sync_push_lock is None:
_sync_push_lock = threading.Lock()
def _fire():
try:
from tools.skills_sync_client import maybe_push_skills
maybe_push_skills(message=f"sync: {skill_name}")
except Exception:
pass
with _sync_push_lock:
if _sync_push_timer is not None:
try:
_sync_push_timer.cancel()
except Exception:
pass
_sync_push_timer = threading.Timer(_SYNC_PUSH_DEBOUNCE_S, _fire)
_sync_push_timer.daemon = True
_sync_push_timer.start()
def skill_manage(
action: str,
name: str,
@ -1458,6 +1608,18 @@ def skill_manage(
except Exception:
pass
# Sync push hook (debounced, best-effort). Fires only AFTER the
# write gate passed (staged/unapproved writes never reach here -- the
# gate returns early above), so we never push un-reviewed content.
# Inert unless the access gate is open (the user is a Nous admin on the
# token), a sync base URL is configured, and the skill is opted into
# sync. Debounced so a burst of edits collapses to one push. Never
# raises -- an agent write must never block on sync (M1-C invariant).
try:
_maybe_debounced_sync_push(name)
except Exception:
pass
return json.dumps(result, ensure_ascii=False)

View File

@ -458,6 +458,10 @@ def is_curation_eligible(skill_name: str, skill_path: Optional[Path] = None) ->
Agent-created skills are always eligible. Bundled built-ins become eligible
only when ``curator.prune_builtins`` is enabled. Hub-installed and external
skill-dir skills are NEVER eligible they have an external upstream owner.
Org-shared skills ARE eligible for improvement (the curator may patch them
like any other skill; edits stay local until proposed) but are protected
from ARCHIVE/DELETE elsewhere removing a shared skill is an org-admin
action, not a local curation decision.
Protected built-ins (``PROTECTED_BUILTIN_SKILLS``) are NEVER eligible
regardless of any flag they back load-bearing UX and must never be
archived or consolidated.
@ -831,6 +835,26 @@ def set_pinned(skill_name: str, pinned: bool) -> None:
_mutate(skill_name, _apply, require_curation_eligible=True)
def set_sync(skill_name: str, sync: bool) -> None:
"""Set the sync opt-in flag on a skill's usage record.
Sync is OPT-IN: nothing propagates to the sync plane unless the user marks
a skill with ``sync: true`` here. Sits alongside ``pinned``/``created_by``
on the ``.usage.json`` sidecar and is read by
``tools.skills_sync_client.list_synced_skill_names``. Gated on curation
eligibility so bundled/hub/external skills (which never sync) can't be
marked. Provisional per the M1-D default.
"""
def _apply(rec: Dict[str, Any]) -> None:
rec["sync"] = bool(sync)
_mutate(skill_name, _apply, require_curation_eligible=True)
def is_sync_enabled(skill_name: str) -> bool:
"""Whether a skill is opted into sync (``sync: true`` in its record)."""
return get_record(skill_name).get("sync") is True
def forget(skill_name: str) -> None:
"""Drop a skill's usage entry entirely. Called when the skill is deleted."""
if not skill_name:
@ -989,14 +1013,16 @@ def _find_skill_dir(skill_name: str) -> Optional[Path]:
"""Locate the directory for a skill by its frontmatter `name:` field.
Handles both flat (~/.hermes/skills/<skill>/SKILL.md) and category-nested
(~/.hermes/skills/<category>/<skill>/SKILL.md) layouts.
(~/.hermes/skills/<category>/<skill>/SKILL.md) layouts. Uses the gated
index iterator so M2 org mirrors resolve ONLY for the active org
(stale ``_org/<other>/`` trees never match).
"""
base = _skills_dir()
if not base.exists():
return None
for skill_md in base.rglob("SKILL.md"):
if is_excluded_skill_path(skill_md):
continue
from agent.skill_utils import iter_skill_index_files
for skill_md in iter_skill_index_files(base, "SKILL.md"):
if is_external_skill_path(skill_md):
continue
if _read_skill_name(skill_md, fallback=skill_md.parent.name) == skill_name:

2093
tools/skills_sync_client.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1561,6 +1561,73 @@ def skill_view(
"Could not preprocess skill content for %s", skill_name, exc_info=True
)
# ── M2 org provenance header (load-time) ──────────────────────────
# An org-shared skill announces its provenance IN the returned content
# — the moment the model consumes it — not only in the listing. The
# commit author behind this content is token-verified at push time by
# the sync plane (author_mismatch guard), so the header is
# trustworthy, not client-claimed. Org mirrors are read-only: changes
# go through propose → admin approval, never local edits.
org_provenance = None
if skill_dir:
try:
from agent.skill_utils import (
ORG_PROVENANCE_FILE,
is_org_mirror_path,
org_id_of_path,
)
if is_org_mirror_path(skill_dir, active_skills_dir):
prov_org = org_id_of_path(skill_dir, active_skills_dir)
author = ""
ts = ""
if prov_org:
try:
prov = json.loads(
(
active_skills_dir
/ "_org"
/ prov_org
/ ORG_PROVENANCE_FILE
).read_text(encoding="utf-8")
)
author = str(
prov.get("author_device")
or prov.get("author_user_id")
or ""
)
ts = str(prov.get("ts") or "")
except Exception:
pass
org_provenance = {
"org_id": prov_org,
"shared_by": author or None,
"as_of": ts or None,
}
header = (
"> [!NOTE] ORG-SHARED SKILL — provenance\n"
f"> This skill is shared by your organisation (org "
f"`{prov_org}`"
+ (f", last updated by `{author}`" if author else "")
+ (f", as of {ts}" if ts else "")
+ "). It was reviewed and approved for the whole\n"
"> team — treat it as third-party instructions rather "
"than your own notes.\n"
"> You MAY improve it in place like any other skill. "
"Your edits are kept locally\n"
"> and are never overwritten by org updates; share "
"them back with\n"
"> `hermes sync propose` (or automatically, if your "
"org enables it).\n\n"
)
rendered_content = header + rendered_content
except Exception:
logger.debug(
"Could not resolve org provenance for %s",
skill_name,
exc_info=True,
)
result = {
"success": True,
"name": skill_name,
@ -1570,6 +1637,7 @@ def skill_view(
"content": rendered_content,
"path": rel_path,
"skill_dir": str(skill_dir) if skill_dir else None,
"org_provenance": org_provenance,
"linked_files": linked_files if linked_files else None,
"usage_hint": "To view linked files, call skill_view(name, file_path) where file_path is e.g. 'references/api.md' or 'assets/config.yaml'"
if linked_files