From d38ac3a7f1fc8271366ba8aa25e97a24988d6014 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Sun, 13 Sep 2026 15:05:32 -0700 Subject: [PATCH 01/16] gateway, runner: the Plugins sub-protocol (UHP 2026-09-12), served beside 2026-08-11 The gateway now lists both versions, echoes the one asked for, and reports the plugins capability with the Agent Plugins manifest schema it installs. A harness accepts plugins on create and update: each package is read the way the Agent Plugins client conformance section says (closed manifest, components from their fixed locations, an invalid component recorded in skipped rather than fatal, a missing or invalid manifest refused), stored out of line, and recorded as manifest, mcpServers, skills and skipped. The harness's own mcpServers and skills are never rewritten by a plugin, which is what keeps every 2026-08-11 client working. Component names are unique across the harness and its enabled plugins; a collision is a 409 plugin_conflict at write time. A stdio server on a base whose runner cannot launch one (pi, dsh) is refused with unsupported_transport. Two endpoints on both the org and the public surface: the package files, and the harness exported as a package with credentials omitted and recorded. Every turn folds enabled plugins in: their skills take the ordinary skill path, their remote servers the ordinary MCP path, and their stdio servers travel with the whole package so the runner has a root to run them from. The runner materialises each package at .harness/plugins// with a writable .harness/plugin-data//, and turns each stdio server into a launcher script that exports PLUGIN_ROOT and PLUGIN_DATA and the entry's env, expands the two placeholders once, resolves a ./ command against the root, confines cwd to the root or the data directory, and execs the command with its args as argv. Every backend writer therefore sees one shape, command plus args; codex, hermes, cline and omp gain that entry beside the URL entry they had. A package that also carries .claude-plugin/plugin.json still reaches Claude Code through --plugin-dir. Co-Authored-By: Claude Fable 5.1 --- gateway/app.py | 518 ++++++++++++++++++++++++++++++- gateway/tests/test_plugins.py | 228 ++++++++++++++ runner/server.py | 142 ++++++++- runner/tests/test_uhp_plugins.py | 100 ++++++ 4 files changed, 965 insertions(+), 23 deletions(-) create mode 100644 gateway/tests/test_plugins.py create mode 100644 runner/tests/test_uhp_plugins.py diff --git a/gateway/app.py b/gateway/app.py index 01714735..4cd2e996 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -18,6 +18,7 @@ import json import os import re +import urllib.parse import collections import time import uuid @@ -2888,7 +2889,7 @@ async def _harness_plugins(harness_id: str, org: str, hdr_vals: dict[str, str] | together if the read failed (hv is None → empty plugins), never inconsistently (review #5). `sid`: the session this turn belongs to, used to scope the database tool's credential to it.""" if not harness_id: - return [], [], [], [] + return [], [], [], [], [] v = hv if not v: # An out-of-box harness has no stored record — there is nothing to read, and that is @@ -2896,7 +2897,7 @@ async def _harness_plugins(harness_id: str, org: str, hdr_vals: dict[str, str] | # of the deployment rather than of a saved configuration. Returning nothing here is why # the default harnesses, which is what most people actually use, had no image generation # and no document skills while custom ones did. - return [], _builtin_default_skills(), [], [] + return [], _builtin_default_skills(), [], [], [] v = await _mcp_migrate(org, harness_id, v) def _arr(prop): @@ -3010,12 +3011,47 @@ def _arr(prop): continue skills_out.append({"name": name, "files": files, "content": sk.get("content")}) + # Installed plugins (Plugins §4, §6): each enabled package contributes its skills and its MCP + # servers, and travels whole to the runner so a stdio server has a root to run from. The + # harness's own lists above are never rewritten by any of this. Placeholders stay literal + # here; the runner expands them where the paths exist. + plugins_out: list[dict] = [] + for pe in _plugins_of(v): + if not pe["enabled"]: + continue + pfiles = await _plugin_files_of(pe) + if not pfiles: + print(f"[plugins] '{pe['name']}' on {harness_id}: package missing from the blob store — not offered this turn", flush=True) + continue + plugins_out.append({"name": pe["name"], "files": pfiles}) + for sk in pe["skills"]: + prefix = f"skills/{sk['name']}/" + sfiles = [{**f, "path": f["path"][len(prefix):]} for f in pfiles + if isinstance(f, dict) and str(f.get("path") or "").startswith(prefix)] + if sfiles: + seen.add(sk["name"]) + skills_out.append({"name": sk["name"], "files": sfiles, "plugin": pe["name"]}) + for s in pe["mcpServers"]: + if s.get("transport") == "stdio": + mcp_out.append({"name": s["name"], "transport": "stdio", "command": s["command"], + "args": list(s.get("args") or []), "env": dict(s.get("env") or {}), + "cwd": s.get("cwd") or "", "plugin": pe["name"]}) + continue + _blocked = await _ssrf_check(str(s.get("url") or "")) + if _blocked: + print(f"[plugins] refusing '{s['name']}' of {pe['name']} for harness {harness_id}: {_blocked}", flush=True) + continue + entry = {"name": s["name"], "url": s["url"], "transport": s.get("transport") or "http"} + if isinstance(s.get("headers"), dict): + entry["headers"] = {str(k): str(vv) for k, vv in s["headers"].items()} + mcp_out.append(entry) + # Built-ins the harness never mentions: on when the image says so. Implicit, so the set follows # the image rather than whatever was true when the Harness was created. skills_out += _builtin_default_skills(seen) disabled_tools = [t for t in _arr("disabled_tools") if isinstance(t, str)] - return mcp_out, skills_out, suppressed, disabled_tools + return mcp_out, skills_out, suppressed, disabled_tools, plugins_out # ── API ────────────────────────────────────────────────────────────────────────── @@ -3618,8 +3654,14 @@ async def pump(): # retry. Added the structured error envelope. The old `detail` string is still emitted beside # it, because clients in the wild read it — it is documented as deprecated, not removed under # them. -UHP_VERSION = "2026-08-11" -UHP_VERSIONS = [UHP_VERSION] +UHP_VERSION = "2026-09-12" +# Both versions, one code path: 2026-09-12 is additive to 2026-08-11 (plugins, and nothing +# the earlier version defined changes shape), so the same objects answer either request and +# the header is the only thing that differs. VERSIONING.md, server rule 3. +UHP_VERSIONS = [UHP_VERSION, "2026-08-11"] +# The Agent Plugins manifest schemas this server installs (Plugins §7). A package targeting +# another version is refused with unsupported_plugin_schema naming this list. +UHP_PLUGIN_SCHEMAS = ["https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"] # Capabilities are declared, not inferred, and the conformance suite checks each one against the # behaviour it promises. Reporting false is a supported answer; omitting a key is not, because a @@ -3634,6 +3676,7 @@ async def pump(): "harness_management": True, "session_sharing": True, "idempotency": True, + "plugins": True, } UHP_CONFORMANCE_CLASS = "full" @@ -3700,7 +3743,9 @@ async def _uhp_version(request: Request, call_next): "detail": f"This server does not implement UHP version '{want}'."}, status_code=400, headers={"UHP-Version": UHP_VERSION}) resp = await call_next(request) - resp.headers["UHP-Version"] = UHP_VERSION + # The version SERVED, which is the one asked for when it is one this server speaks: both + # versions answer with the same objects, so honouring the request is just saying so. + resp.headers["UHP-Version"] = want if want in UHP_VERSIONS else UHP_VERSION return resp @@ -3711,6 +3756,7 @@ def uhp_discovery() -> dict: Nothing here is principal-specific.""" return {"object": "uhp.discovery", "protocol": "uhp", "versions": UHP_VERSIONS, "default_version": UHP_VERSION, + "plugin_schemas": UHP_PLUGIN_SCHEMAS, "conformance_class": UHP_CONFORMANCE_CLASS, "capabilities": dict(UHP_CAPABILITIES), # The version this build IS (baked by the release workflow). "dev" for a build that @@ -6631,7 +6677,7 @@ async def _resp_execute(translator: _RespTranslator, *, org: str, member: str, s # HR-INF-010: the caller (create_response) already read this harness's vertex — thread it in # instead of re-reading it twice more here. Beyond cutting reads, a same-request snapshot means # _harness_plugins and agent_doc can't observe a mid-turn config edit inconsistently. - mcp_servers, skills, skills_suppressed, tools_disabled = await _harness_plugins(harness_id, org, hdr_vals, hv=hv, sid=sid) + mcp_servers, skills, skills_suppressed, tools_disabled, plugin_pkgs = await _harness_plugins(harness_id, org, hdr_vals, hv=hv, sid=sid) if mcp_servers or skills or skills_suppressed or tools_disabled: rec["plugins"] = {"mcp": [m.get("name") for m in mcp_servers], "skills": [s.get("name") for s in skills], "skills_off": skills_suppressed, "tools_off": tools_disabled} @@ -6719,7 +6765,7 @@ async def _resp_execute(translator: _RespTranslator, *, org: str, member: str, s "prompt": runner_prompt, "max_turns": max_step, "timeout_seconds": timeout_s, "auth": sandbox_auth, "resume_session_id": resume, "files": files_in, - "mcp_servers": mcp_servers, "skills": skills, "agent_doc": agent_doc, + "mcp_servers": mcp_servers, "skills": skills, "plugins": plugin_pkgs, "agent_doc": agent_doc, "skills_suppressed": skills_suppressed, "tools_disabled": turn_tools_off, # Image generation, when an integration can serve it. A per-turn credential for # the broker, never a provider key — see _image_auth. @@ -13216,6 +13262,7 @@ class HarnessBody(BaseModel): system_prompt: str | None = None mcp_servers: list | None = None skills: list | None = None + plugins: list | None = None # installed Agent Plugins packages (Plugins chapter) disabled_tools: list | None = None # inherited/built-in tool names the harness disabled max_step: int | None = None # default agent step budget for this harness's turns timeout_seconds: int | None = None # default per-turn wall-clock cap @@ -13243,6 +13290,9 @@ def _parse(s): # Verbatim: what a client reads is what is stored, for every entry, which is the # ordinary contract and the only one there now is. "mcpServers": _mcp_list(v), "skills": _parse(v.get("skills")), + # Derived by the server from each package on every write, never copied into the two + # lists above: a client that PUTs back what it read cannot install a plugin twice. + "plugins": _plugins_of(v), "disabledTools": [t for t in _parse(v.get("disabled_tools")) if isinstance(t, str)], "additionalHeaders": [h for h in _parse(v.get("additional_headers")) if isinstance(h, str) and h.strip()], "maxStep": int(v.get("max_step")) if str(v.get("max_step") or "").isdigit() else None, @@ -13259,6 +13309,7 @@ def _harness_props(body: HarnessBody) -> dict: return {"name": body.name, "base": base, "base_label": body.base_label or base, "default_model": body.default_model or "", "system_prompt": body.system_prompt or "", "mcp_servers": json.dumps(body.mcp_servers or []), "skills": json.dumps(body.skills or []), + "plugins": json.dumps(body.plugins or []), "disabled_tools": json.dumps(body.disabled_tools or []), "additional_headers": json.dumps([str(h).strip() for h in (body.additional_headers or []) if isinstance(h, str) and str(h).strip()]), @@ -13357,6 +13408,410 @@ async def _skill_bundle_files(sk: dict) -> list: files = [{"path": "SKILL.md", "content": sk["content"]}] return files or [] +# ── Plugins (UHP 2026-09-12, Plugins chapter) ───────────────────────────────────────────── +# A plugin IS an Agent Plugins 1.0.0 package: plugin.json at the root, MCP servers in mcp.json, +# skills under skills/. This gateway reads one the way that specification's client conformance +# section says (manifest validated and closed, components discovered from their fixed locations, +# a failure isolated to one component recorded rather than fatal), stores the package out of line, +# and records what it derived on the harness. The harness's own mcpServers and skills are never +# touched by a plugin: they report what a client wrote there, which is what keeps every 2026-08-11 +# client working (Plugins §4.1). Names are unique across the harness and its enabled plugins, and a +# collision is refused here, at configuration time, rather than resolved silently in a turn. +_PLUGIN_MANIFEST_SCHEMA = UHP_PLUGIN_SCHEMAS[0] +_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json" +_PLUGIN_NAME_RE = re.compile(r"^(?!.*(--|\.\.))[a-z0-9]([a-z0-9.-]*[a-z0-9])?$") +_SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +_PLUGIN_PACKAGE_MAX = 16 * 1024 * 1024 # the JSON-encoded package; a plugin is code and prose, not data +_PLUGIN_MANIFEST_FIELDS = {"$schema", "name", "version", "description", "author", "homepage", + "repository", "license", "keywords", "extensions"} +# Runner backends that can launch a stdio MCP server (each writer emits command/args). pi's MCP +# adapter and dsh take URLs only; a package that needs a process is refused for those bases rather +# than accepted and skipped in a turn, which Harnesses §4.1 forbids. +_STDIO_MCP_BACKENDS = {"claude", "codex", "hermes", "gemini", "qwen", "opencode", "goose", "omp", "cline"} + + +def _plugin_invalid(name: str, path: str, reason: str) -> HTTPException: + return uhp_error(422, "plugin_invalid", f"Plugin {name or 'package'}: {reason}", "plugins", + {"path": path, "reason": reason}) + + +def _plugin_path_ok(path, name: str) -> str: + if not isinstance(path, str) or not path or path.startswith("/"): + raise _plugin_invalid(name, str(path), "file paths are relative to the plugin root") + if any(seg in ("", ".", "..") for seg in path.split("/")): + raise _plugin_invalid(name, path, "the path escapes the plugin root") + return path + + +def _plugin_frontmatter(text: str) -> dict: + m = re.match(r"^---\r?\n(.*?)\r?\n---", text or "", flags=re.S) + out: dict = {} + for line in (m.group(1) if m else "").splitlines(): + if ":" in line and not line.startswith((" ", "\t")): + k, v = line.split(":", 1) + out[k.strip()] = v.strip().strip("'\"") + return out + + +def _plugin_command_ok(cmd) -> str | None: + """Agent Plugins §7.2.1: one executable token, a bare name or a plugin-relative ./ path.""" + if not isinstance(cmd, str) or not cmd or re.search(r"\s", cmd): + return "command must be one executable token" + if cmd.startswith("./"): + if any(seg in ("", ".", "..") for seg in cmd[2:].split("/")): + return "a plugin-relative command must stay within the plugin root" + elif "/" in cmd: + return "command must be a bare executable name or a path beginning with ./" + return None + + +def _plugin_cwd_ok(cwd) -> str | None: + if not isinstance(cwd, str) or not cwd: + return "cwd must be a string" + for root in ("${PLUGIN_ROOT}", "${PLUGIN_DATA}", "."): + if cwd == root or cwd.startswith(root + "/"): + rest = cwd[len(root):].lstrip("/") + if any(seg in (".", "..") for seg in rest.split("/") if rest): + return "cwd escapes its root" + return None + return "cwd must be plugin-relative (./…) or rooted in ${PLUGIN_ROOT} or ${PLUGIN_DATA}" + + +def _plugin_url_ok(url) -> str | None: + if not isinstance(url, str) or not url: + return "url must be a string" + try: + u = urllib.parse.urlsplit(url) + except Exception: # noqa: BLE001 + return "url is not a URL" + if u.scheme not in ("http", "https") or not u.hostname: + return "url must be an absolute http or https URL" + if u.username or u.password or u.fragment: + return "url must not carry user information or a fragment" + host = u.hostname.lower() + loopback = host == "localhost" or host.startswith("127.") or host == "::1" + if u.scheme == "http" and not loopback: + return "a non-loopback url must use https" + return None + + +def _plugin_server(sname: str, cfg) -> tuple[dict | None, str | None]: + """One mcp.json entry -> (a plugin MCP server object, None) or (None, why it is invalid).""" + if not isinstance(cfg, dict): + return None, "server entry is not an object" + t = cfg.get("type") + allowed = {"stdio": {"type", "command", "args", "env", "cwd"}, + "streamable-http": {"type", "url", "headers"}, + "sse": {"type", "url", "headers"}}.get(t) + if allowed is None: + return None, f"unknown transport type {t!r}" + extra = set(cfg) - allowed + if extra: + return None, f"fields {sorted(extra)} do not belong to a {t} server" + if t == "stdio": + why = _plugin_command_ok(cfg.get("command")) + if why: + return None, why + s: dict = {"name": sname, "transport": "stdio", "command": cfg["command"], "enabled": True} + if "args" in cfg: + if not isinstance(cfg["args"], list) or not all(isinstance(a, str) for a in cfg["args"]): + return None, "args must be a list of strings" + s["args"] = list(cfg["args"]) + if "env" in cfg: + env = cfg["env"] + if not isinstance(env, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()): + return None, "env must be an object of strings" + if any(k.upper() in ("PLUGIN_ROOT", "PLUGIN_DATA") for k in env): + return None, "env must not name PLUGIN_ROOT or PLUGIN_DATA; the server sets those" + s["env"] = dict(env) + if "cwd" in cfg: + why = _plugin_cwd_ok(cfg["cwd"]) + if why: + return None, why + s["cwd"] = cfg["cwd"] + return s, None + why = _plugin_url_ok(cfg.get("url")) + if why: + return None, why + s = {"name": sname, "transport": "http" if t == "streamable-http" else "sse", + "url": cfg["url"], "enabled": True} + if "headers" in cfg: + hdrs = cfg["headers"] + if not isinstance(hdrs, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in hdrs.items()): + return None, "headers must be an object of strings" + if len({k.lower() for k in hdrs}) != len(hdrs): + return None, "a header name appears more than once under different casing" + s["headers"] = dict(hdrs) + return s, None + + +def _plugin_read_package(files: list, sent_name: str | None = None) -> dict: + """Read a package as an Agent Plugins client: {name, manifest, mcpServers, skills, skipped}. + + Fatal (refused with plugin_invalid or unsupported_plugin_schema): no manifest, a manifest that + is not valid JSON or violates its closed schema, an escaping path, a `name` on the write that + contradicts the manifest. Not fatal, recorded in `skipped`: an unknown manifest field, a + non-object `extensions`, an invalid mcp.json or server entry, a skill folder whose SKILL.md + does not conform. That split is Agent Plugins §5.2 and §11.3, and the recording is UHP's own + rule that ignoring must be observable (Plugins §2.2).""" + if not isinstance(files, list) or not files: + raise _plugin_invalid(sent_name or "", "", "a plugin needs files") + by_path: dict[str, dict] = {} + for f in files: + if not isinstance(f, dict): + raise _plugin_invalid(sent_name or "", "", "files must be objects with a path") + path = _plugin_path_ok(f.get("path"), sent_name or "") + if f.get("content") is None and f.get("content_b64") is None: + raise _plugin_invalid(sent_name or "", path, "a file needs content or content_b64") + by_path[path] = f + skipped: list[dict] = [] + + raw = by_path.get("plugin.json") + if raw is None: + raise _plugin_invalid(sent_name or "", "plugin.json", "no plugin.json at the plugin root") + try: + manifest = json.loads(raw.get("content") if raw.get("content") is not None + else base64.b64decode(raw.get("content_b64") or "").decode()) + except Exception: # noqa: BLE001 + raise _plugin_invalid(sent_name or "", "plugin.json", "plugin.json is not valid JSON") + if not isinstance(manifest, dict): + raise _plugin_invalid(sent_name or "", "plugin.json", "plugin.json is not an object") + if manifest.get("$schema") not in UHP_PLUGIN_SCHEMAS: + raise uhp_error(422, "unsupported_plugin_schema", + "The manifest targets an Agent Plugins version this server does not install.", + "plugins", {"supported": list(UHP_PLUGIN_SCHEMAS), + "declared": manifest.get("$schema")}) + name = manifest.get("name") + if not isinstance(name, str) or not _PLUGIN_NAME_RE.match(name) or len(name) > 64: + raise _plugin_invalid(str(name or sent_name or ""), "plugin.json#/name", + "name must be 1 to 64 characters of a-z, 0-9, hyphen and period, " + "starting and ending alphanumeric, with no -- or ..") + if sent_name and sent_name != name: + raise _plugin_invalid(name, "plugin.json#/name", + f"the write names the plugin {sent_name!r} but the manifest says {name!r}") + for k, v in manifest.items(): + if k not in _PLUGIN_MANIFEST_FIELDS: + skipped.append({"path": f"plugin.json#/{k}", "reason": "unknown top-level field, ignored"}) + elif k in ("version", "description", "homepage", "repository", "license") and not isinstance(v, str): + raise _plugin_invalid(name, f"plugin.json#/{k}", f"{k} must be a string") + elif k == "keywords" and not (isinstance(v, list) and all(isinstance(x, str) for x in v)): + raise _plugin_invalid(name, "plugin.json#/keywords", "keywords must be a list of strings") + elif k == "author" and not (isinstance(v, dict) and set(v) <= {"name", "email", "url"} + and all(isinstance(x, str) for x in v.values())): + raise _plugin_invalid(name, "plugin.json#/author", + "author may carry only name, email and url, each a string") + elif k == "extensions" and not isinstance(v, dict): + skipped.append({"path": "plugin.json#/extensions", "reason": "extensions is not an object, ignored"}) + + servers: list[dict] = [] + raw = by_path.get("mcp.json") + if raw is not None: + try: + mcp = json.loads(raw.get("content") if raw.get("content") is not None + else base64.b64decode(raw.get("content_b64") or "").decode()) + except Exception: # noqa: BLE001 + mcp = None + entries = mcp.get("mcpServers") if isinstance(mcp, dict) else None + if not isinstance(mcp, dict) or set(mcp) != {"$schema", "mcpServers"} or not isinstance(entries, dict): + skipped.append({"path": "mcp.json", "reason": "not an object of exactly $schema and mcpServers"}) + entries = {} + elif mcp.get("$schema") != _PLUGIN_MCP_SCHEMA: + skipped.append({"path": "mcp.json#/$schema", + "reason": "mcp.json targets a different Agent Plugins version than plugin.json"}) + entries = {} + for sname, cfg in entries.items(): + s, why = _plugin_server(str(sname), cfg) + if s is None: + skipped.append({"path": f"mcp.json#/mcpServers/{sname}", "reason": why}) + else: + servers.append(s) + + skills: list[dict] = [] + dirs = sorted({p.split("/")[1] for p in by_path if p.startswith("skills/") and p.count("/") >= 2}) + for d in dirs: + md = by_path.get(f"skills/{d}/SKILL.md") + if md is None: + continue # a folder without SKILL.md is not a skill (Agent Plugins §7.1) + try: + text = md.get("content") if md.get("content") is not None else base64.b64decode(md.get("content_b64") or "").decode() + except Exception: # noqa: BLE001 + text = "" + fm = _plugin_frontmatter(text) + if fm.get("name") != d or not _SKILL_NAME_RE.match(d) or len(d) > 64: + skipped.append({"path": f"skills/{d}", "reason": "SKILL.md name must equal the directory name and follow Agent Skills naming"}) + continue + if not fm.get("description") or len(fm["description"]) > 1024: + skipped.append({"path": f"skills/{d}", "reason": "SKILL.md needs a description of at most 1024 characters"}) + continue + skills.append({"name": d, "description": fm["description"]}) + return {"name": name, "manifest": manifest, "mcpServers": servers, "skills": skills, "skipped": skipped} + + +def _plugins_of(v: dict | None) -> list[dict]: + """The stored plugin entries of a harness vertex, each with every derived field present.""" + try: + a = json.loads((v or {}).get("plugins") or "[]") + except Exception: # noqa: BLE001 + a = [] + out = [] + for e in a if isinstance(a, list) else []: + if isinstance(e, dict) and e.get("name"): + out.append({"name": e["name"], "enabled": e.get("enabled", True) is not False, + **({"blob": e["blob"]} if e.get("blob") else {}), + "manifest": e.get("manifest") or {}, "mcpServers": e.get("mcpServers") or [], + "skills": e.get("skills") or [], "skipped": e.get("skipped") or []}) + return out + + +async def _plugin_files_of(entry: dict) -> list: + files = entry.get("files") + if not files and entry.get("blob"): + raw = await _blob_get(f"plugins/{entry['blob']}.json", kb=BLOB_KB) + if raw: + try: + files = json.loads(raw.decode()) + except Exception: # noqa: BLE001 + files = None + return files if isinstance(files, list) else [] + + +def _plugin_collisions(plugins: list[dict], mcp_servers: list | None, skills: list | None) -> None: + """Plugins §4.2: one namespace per component type across the harness and its enabled plugins.""" + servers: dict[str, str] = {} + for s in mcp_servers or []: + if isinstance(s, dict) and str(s.get("enabled")) not in ("False", "false", "0"): + servers[_mcp_name(str(s.get("name") or "mcp"))] = "harness" + skill_owner: dict[str, str] = {} + for sk in skills or []: + if isinstance(sk, dict) and str(sk.get("enabled")) not in ("False", "false", "0"): + skill_owner[str(sk.get("name") or sk.get("id") or "")] = "harness" + for pe in plugins: + if not pe.get("enabled", True): + continue + for s in pe.get("mcpServers") or []: + key = _mcp_name(str(s.get("name"))) + if key in servers: + raise uhp_error(409, "plugin_conflict", + f"MCP server '{s.get('name')}' exists on both {servers[key]} and plugin {pe['name']}.", + "plugins", {"component": "mcp_server", "name": s.get("name"), + "between": [servers[key], pe["name"]]}) + servers[key] = pe["name"] + for sk in pe.get("skills") or []: + if sk["name"] in skill_owner: + raise uhp_error(409, "plugin_conflict", + f"skill '{sk['name']}' exists on both {skill_owner[sk['name']]} and plugin {pe['name']}.", + "plugins", {"component": "skill", "name": sk["name"], + "between": [skill_owner[sk["name"]], pe["name"]]}) + skill_owner[sk["name"]] = pe["name"] + + +async def _plugins_prepare(body: HarnessBody) -> list[dict]: + """Validate and store every package on a create or update, and derive what the harness records. + + A refused package leaves the harness unchanged (the caller writes nothing until this returns). + Files always go to the blob store: a package is bigger than the vertex prop cap by construction, + and one storage path is one fewer to get wrong. What the vertex carries is the derived object + and the blob handle, which is exactly what a client reads back and PUTs again.""" + backend = str(_BASE_CATALOG.get(body.base or "", {}).get("backend") or "") + out: list[dict] = [] + seen: dict[str, bool] = {} + for i, item in enumerate(body.plugins or []): + if not isinstance(item, dict): + raise uhp_error(400, "invalid_input", f"plugins[{i}]: must be an object.", "plugins") + sent = item.get("name") if isinstance(item.get("name"), str) else None + files, blob = item.get("files"), item.get("blob") + if files: + blob = None # a fresh package supersedes whatever the handle held + elif blob: + files = await _plugin_files_of({"blob": blob}) + if not files: + raise _plugin_invalid(sent or "", "", "the package this handle refers to is gone; send its files") + else: + raise _plugin_invalid(sent or "", "", "a plugin needs files or a blob handle") + encoded = json.dumps(files) + if len(encoded) > _PLUGIN_PACKAGE_MAX: + raise uhp_error(413, "file_too_large", "The plugin package is larger than this server accepts.", + "plugins", {"max_bytes": _PLUGIN_PACKAGE_MAX}) + derived = _plugin_read_package(files, sent) + name = derived["name"] + if name in seen: + raise uhp_error(409, "plugin_conflict", f"Two plugins are named '{name}'.", "plugins", + {"component": "plugin", "name": name, "between": [name, name]}) + seen[name] = True + enabled = item.get("enabled", True) is not False + if enabled and backend and backend not in _STDIO_MCP_BACKENDS \ + and any(s.get("transport") == "stdio" for s in derived["mcpServers"]): + raise uhp_error(422, "unsupported_transport", + f"Plugin {name} declares a stdio MCP server, which the {body.base} base cannot run.", + "plugins", {"transport": "stdio", "base": body.base, "plugin": name}) + if not blob: + blob = _rid("plg") + if not await _blob_put(f"plugins/{blob}.json", encoded.encode(), kb=BLOB_KB): + raise uhp_error(502, "internal_error", f"Plugin {name}: the package could not be stored.", "plugins") + out.append({"name": name, "enabled": enabled, "blob": blob, "manifest": derived["manifest"], + "mcpServers": derived["mcpServers"], "skills": derived["skills"], + "skipped": derived["skipped"]}) + _plugin_collisions(out, body.mcp_servers, body.skills) + return out + + +def _plugin_export_name(v: dict) -> str: + """Plugins §5: the harness name as a plugin name, or the id when nothing usable remains.""" + def derive(s: str) -> str: + s = re.sub(r"[^a-z0-9.]+", "-", (s or "").lower()).strip("-") + s = re.sub(r"\.{2,}", ".", s).strip(".").strip("-") + return s[:64].strip("-.") + n = derive(str(v.get("name") or "")) + if not n or not _PLUGIN_NAME_RE.match(n): + n = derive(str(v.get("id") or "")) or "harness" + return n if _PLUGIN_NAME_RE.match(n) else "harness" + + +async def _harness_export_plugin(v: dict) -> dict: + """The harness's own tools and skills as an Agent Plugins package (Plugins §5). Installed + plugins, the prompt, the model, the base and disabled tools are not part of it; credentials + never are, and each omission is written down so the importer knows what to re-enter.""" + files: list[dict] = [{"path": "plugin.json", "content": json.dumps( + {"$schema": _PLUGIN_MANIFEST_SCHEMA, "name": _plugin_export_name(v)}, indent=2)}] + skipped: list[dict] = [] + entries: dict = {} + for s in _mcp_list(v): + if str(s.get("enabled")) in ("False", "false", "0") or not s.get("url"): + continue + sname = str(s.get("name") or s.get("id") or "mcp") + entries[sname] = {"type": "sse" if (s.get("transport") or "").lower() == "sse" else "streamable-http", + "url": s["url"]} + for k in ("auth", "headers"): + if s.get(k): + skipped.append({"path": f"mcp.json#/mcpServers/{sname}/{k}", + "reason": "credentials are not exported"}) + if entries: + files.append({"path": "mcp.json", "content": json.dumps( + {"$schema": _PLUGIN_MCP_SCHEMA, "mcpServers": entries}, indent=2)}) + try: + skills = json.loads(v.get("skills") or "[]") + except Exception: # noqa: BLE001 + skills = [] + for sk in skills if isinstance(skills, list) else []: + if not isinstance(sk, dict) or str(sk.get("enabled")) in ("False", "false", "0"): + continue + sname = str(sk.get("name") or sk.get("id") or "") + if not sname: + continue + for f in await _skill_bundle_files(sk): + if isinstance(f, dict) and f.get("path"): + files.append({**f, "path": f"skills/{sname}/{f['path']}"}) + derived = _plugin_read_package(files) + return {**derived, "enabled": True, "skipped": derived["skipped"] + skipped, "files": files} + + +def _plugin_entry(v: dict, name: str) -> dict: + pe = next((e for e in _plugins_of(v) if e["name"] == name), None) + if not pe: + raise uhp_error(404, "plugin_not_found", f"No plugin '{name}' on this harness.", "plugin_name") + return pe + + def _workspace_keep(item_ws: str, workspace: str, ws_default: bool) -> bool: """Workspace filter with Default-Workspace leniency: unstamped legacy records belong @@ -13373,6 +13828,7 @@ async def create_harness(org: str, body: HarnessBody, request: Request) -> dict: workspace = request.headers.get("x-harness-workspace", "") body.mcp_servers = _mcp_servers_prepare(body.mcp_servers) body.skills = await _skills_prepare(body.skills) + body.plugins = await _plugins_prepare(body) hid = _rid("chrn") now = str(int(time.time() * 1000)) props = {"org": org, "member": member, "workspace": workspace, **_harness_props(body), @@ -13443,6 +13899,27 @@ async def get_harness_skill_files(org: str, hid: str, skill_id: str, request: Re "files": await _skill_bundle_files(sk)} +@app.get("/v1/orgs/{org}/harnesses/{hid}/plugins/{name}/files") +async def get_harness_plugin_files(org: str, hid: str, name: str, request: Request) -> dict: + """The complete package of one installed plugin (Plugins §3.1), byte-for-byte.""" + await _owned_org(request, org) + v = await _vertex_get(hid) + if not v or v.get("org") != org or str(v.get("deleted")) in ("1", "true", "True"): + raise uhp_error(404, "harness_not_found", "No harness with that id.", "harness_id") + pe = _plugin_entry(v, name) + return {"name": pe["name"], "files": await _plugin_files_of(pe)} + + +@app.get("/v1/orgs/{org}/harnesses/{hid}/plugin") +async def export_harness_plugin(org: str, hid: str, request: Request) -> dict: + """The harness's own tools and skills as an Agent Plugins package (Plugins §5).""" + await _owned_org(request, org) + v = await _vertex_get(hid) + if not v or v.get("org") != org or str(v.get("deleted")) in ("1", "true", "True"): + raise uhp_error(404, "harness_not_found", "No harness with that id.", "harness_id") + return await _harness_export_plugin(await _mcp_migrate(org, hid, v) or v) + + @app.put("/v1/orgs/{org}/harnesses/{hid}") async def update_harness(org: str, hid: str, body: HarnessBody, request: Request) -> dict: await _owned_org(request, org) @@ -13454,6 +13931,7 @@ async def update_harness(org: str, hid: str, body: HarnessBody, request: Request # coalesce-upsert: created_at/org are untouched (only the provided props are set) body.mcp_servers = _mcp_servers_prepare(body.mcp_servers) body.skills = await _skills_prepare(body.skills) + body.plugins = await _plugins_prepare(body) await _vg_upsert("Harness", hid, {**_harness_props(body), "updated_at": str(int(time.time() * 1000))}) # AFTER the write: _harness_props can still refuse this save (an unsupported base), and a # refused save that had already scrubbed a record would disconnect a database nobody removed. @@ -13599,6 +14077,7 @@ async def create_harness_public(body: HarnessBody, request: Request) -> dict: raise uhp_error(401, "invalid_credential", "Missing or invalid API key.") body.mcp_servers = _mcp_servers_prepare(body.mcp_servers) body.skills = await _skills_prepare(body.skills) + body.plugins = await _plugins_prepare(body) hid = _rid("chrn") now = str(int(time.time() * 1000)) props = {"org": org, "member": member, "workspace": str(p.get("workspace") or ""), @@ -13772,6 +14251,7 @@ async def launch_kit(kit_id: str, request: Request, body_in: KitLaunchBody | Non disabled_tools=spec.get("disabled_tools") or []) body.mcp_servers = _mcp_servers_prepare(body.mcp_servers) body.skills = await _skills_prepare(body.skills) + body.plugins = await _plugins_prepare(body) hid = _rid("chrn") now = str(int(time.time() * 1000)) props = {"org": org, "member": member, "workspace": str(p.get("workspace") or ""), @@ -14317,6 +14797,27 @@ async def get_harness_skill_files_public(hid: str, skill_id: str, request: Reque "files": await _skill_bundle_files(sk)} +@app.get("/v1/harnesses/{hid}/plugins/{name}/files") +async def get_harness_plugin_files_public(hid: str, name: str, request: Request) -> dict: + """UHP Plugins §3.1: the complete package of one installed plugin.""" + org, _ = await _pub_org_member(request) + v = await _vertex_get(hid) + if not v or v.get("org") != org or str(v.get("deleted")) in ("1", "true", "True"): + raise uhp_error(404, "harness_not_found", "No harness with that id.", "harness_id") + pe = _plugin_entry(v, name) + return {"name": pe["name"], "files": await _plugin_files_of(pe)} + + +@app.get("/v1/harnesses/{hid}/plugin") +async def export_harness_plugin_public(hid: str, request: Request) -> dict: + """UHP Plugins §5: the harness's own tools and skills as an Agent Plugins package.""" + org, _ = await _pub_org_member(request) + v = await _vertex_get(hid) + if not v or v.get("org") != org or str(v.get("deleted")) in ("1", "true", "True"): + raise uhp_error(404, "harness_not_found", "No harness with that id.", "harness_id") + return await _harness_export_plugin(await _mcp_migrate(org, hid, v) or v) + + @app.put("/v1/harnesses/{hid}") async def update_harness_public(hid: str, body: HarnessBody, request: Request) -> dict: org, _ = await _pub_org_member(request) @@ -14326,6 +14827,7 @@ async def update_harness_public(hid: str, body: HarnessBody, request: Request) - v = await _mcp_migrate(org, hid, v) body.mcp_servers = _mcp_servers_prepare(body.mcp_servers) body.skills = await _skills_prepare(body.skills) + body.plugins = await _plugins_prepare(body) await _vg_upsert("Harness", hid, {**_harness_props(body), "updated_at": str(int(time.time() * 1000))}) # AFTER the write: see update_harness. await _hosted_scrub_removed(org, hid, _mcp_list(v), body.mcp_servers) diff --git a/gateway/tests/test_plugins.py b/gateway/tests/test_plugins.py new file mode 100644 index 00000000..26f39db7 --- /dev/null +++ b/gateway/tests/test_plugins.py @@ -0,0 +1,228 @@ +"""Plugins (UHP 2026-09-12, Plugins chapter): a package is read as an Agent Plugins client reads +it, what the server derives is what it records, refusals happen at configuration time, and a +harness exports as a package that installs again. + +These drive the public UHP surface through the app so they measure the same thing the P-series +of the conformance suite measures, without a live turn: create, read, files, export, refusals. +""" +from __future__ import annotations + +import json +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import app # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +ORG = "plugorg" +HEADERS = {"x-harness-internal": "test-internal-key", "x-harness-org": ORG, + "x-harness-member": "tester@example.com"} +MANIFEST_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" +MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json" + + +def _package(name="demo-plugin", *, manifest=None, mcp=None, skill=True, extra=None): + files = [] + if manifest is not None: + files.append({"path": "plugin.json", "content": json.dumps(manifest)}) + if mcp is not None: + files.append({"path": "mcp.json", "content": json.dumps(mcp)}) + if skill: + files += [ + {"path": "skills/demo-skill/SKILL.md", + "content": "---\nname: demo-skill\ndescription: A fixture skill.\n---\n\nSee references/data.md.\n"}, + {"path": "skills/demo-skill/references/data.md", "content": "nested\n"}, + {"path": "bin/tool.bin", "content_b64": "AAECAwQF"}, + ] + return files + (extra or []) + + +MANIFEST = {"$schema": MANIFEST_SCHEMA, "name": "demo-plugin", "version": "1.0.0", + "description": "A fixture.", "author": {"name": "Tester"}} +MCP = {"$schema": MCP_SCHEMA, "mcpServers": { + "remote": {"type": "streamable-http", "url": "https://mcp.example.invalid/mcp", + "headers": {"X-Team": "fixture"}}, + "local": {"type": "stdio", "command": "./bin/tool.bin", "args": ["--data", "${PLUGIN_DATA}"], + "env": {"MODE": "test"}, "cwd": "${PLUGIN_ROOT}"}, +}} + + +@pytest.fixture(scope="module") +def api(): + with TestClient(app.app) as c: + class Api: + def __getattr__(self, m): + def call(path, **kw): + kw["headers"] = {**HEADERS, **(kw.get("headers") or {})} + return getattr(c, m)(path, **kw) + return call + yield Api() + + +def _create(api, **cfg): + r = api.post("/v1/harnesses", json={"name": "Plugged", "base": "claude-code", **cfg}) + assert r.status_code == 200, r.text + return r.json() + + +def test_discovery_advertises_both_versions_and_the_plugin_schemas(api): + d = api.get("/v1/uhp").json() + assert d["versions"] == ["2026-09-12", "2026-08-11"] and d["default_version"] == "2026-09-12" + assert d["capabilities"]["plugins"] is True + assert d["plugin_schemas"] == [MANIFEST_SCHEMA] + # Both versions from one code path: a 2026-08-11 request is honoured and echoed as such. + r = api.get("/v1/uhp", headers={"UHP-Version": "2026-08-11"}) + assert r.status_code == 200 and r.headers["uhp-version"] == "2026-08-11" + r = api.get("/v1/uhp", headers={"UHP-Version": "2025-01-01"}) + assert r.status_code == 400 and r.json()["error"]["code"] == "unsupported_protocol_version" + + +def test_a_package_is_derived_not_copied(api): + h = _create(api, plugins=[{"files": _package(manifest=MANIFEST, mcp=MCP)}]) + assert h["mcpServers"] == [] and h["skills"] == [], "the harness's own lists absorbed the plugin's" + (pl,) = h["plugins"] + assert pl["name"] == "demo-plugin" and pl["enabled"] is True and pl["blob"] + assert pl["manifest"]["name"] == "demo-plugin" + servers = {s["name"]: s for s in pl["mcpServers"]} + assert servers["remote"]["transport"] == "http" and servers["remote"]["headers"] == {"X-Team": "fixture"} + assert servers["local"]["transport"] == "stdio" and servers["local"]["command"] == "./bin/tool.bin" + assert servers["local"]["args"] == ["--data", "${PLUGIN_DATA}"], "placeholders must come back unexpanded" + assert [s["name"] for s in pl["skills"]] == ["demo-skill"] + assert pl["skipped"] == [] + # the files endpoint returns the whole package, byte for byte + r = api.get(f"/v1/harnesses/{h['id']}/plugins/demo-plugin/files") + assert r.status_code == 200 + paths = sorted(f["path"] for f in r.json()["files"]) + assert paths == ["bin/tool.bin", "mcp.json", "plugin.json", "skills/demo-skill/SKILL.md", + "skills/demo-skill/references/data.md"] + assert any(f.get("content_b64") == "AAECAwQF" for f in r.json()["files"]) + # a read then write keeps the package + g = api.get(f"/v1/harnesses/{h['id']}").json() + r = api.put(f"/v1/harnesses/{h['id']}", json={"name": "Renamed", "base": g["base"], + "plugins": g["plugins"], "skills": g["skills"], + "mcp_servers": g["mcpServers"]}) + assert r.status_code == 200, r.text + r = api.get(f"/v1/harnesses/{h['id']}/plugins/demo-plugin/files") + assert sorted(f["path"] for f in r.json()["files"]) == paths + + +def test_no_manifest_is_refused(api): + r = api.post("/v1/harnesses", json={"name": "x", "base": "claude-code", + "plugins": [{"files": _package(manifest=None, mcp=MCP)}]}) + assert r.status_code == 422 and r.json()["error"]["code"] == "plugin_invalid" + + +def test_unknown_schema_is_refused_naming_the_supported_ones(api): + m = {**MANIFEST, "$schema": "https://agent-plugins.org/schemas/0.0.1/plugin.schema.json"} + r = api.post("/v1/harnesses", json={"name": "x", "base": "claude-code", + "plugins": [{"files": _package(manifest=m, mcp=None)}]}) + assert r.status_code == 422 + e = r.json()["error"] + assert e["code"] == "unsupported_plugin_schema" and e["detail"]["supported"] == [MANIFEST_SCHEMA] + + +def test_a_name_that_contradicts_the_manifest_is_refused(api): + r = api.post("/v1/harnesses", json={"name": "x", "base": "claude-code", + "plugins": [{"name": "other", "files": _package(manifest=MANIFEST, mcp=None)}]}) + assert r.status_code == 422 and r.json()["error"]["code"] == "plugin_invalid" + + +def test_an_escaping_path_is_refused(api): + r = api.post("/v1/harnesses", json={"name": "x", "base": "claude-code", + "plugins": [{"files": _package(manifest=MANIFEST, mcp=None, + extra=[{"path": "../x", "content": ""}])}]}) + assert r.status_code == 422 and r.json()["error"]["code"] == "plugin_invalid" + + +def test_an_invalid_component_is_skipped_and_recorded(api): + mcp = {"$schema": MCP_SCHEMA, "mcpServers": { + "good": {"type": "streamable-http", "url": "https://mcp.example.invalid/mcp"}, + "bad": {"type": "carrier-pigeon", "url": "https://x"}, + "leaky": {"type": "stdio", "command": "./bin/tool.bin", "env": {"PLUGIN_ROOT": "/tmp"}}, + }} + broken_skill = [{"path": "skills/wrong-name/SKILL.md", + "content": "---\nname: other\ndescription: x\n---\n"}] + h = _create(api, plugins=[{"files": _package(manifest={**MANIFEST, "vendorField": 1}, + mcp=mcp, extra=broken_skill)}]) + (pl,) = h["plugins"] + assert [s["name"] for s in pl["mcpServers"]] == ["good"] + assert [s["name"] for s in pl["skills"]] == ["demo-skill"] + paths = sorted(s["path"] for s in pl["skipped"]) + assert paths == ["mcp.json#/mcpServers/bad", "mcp.json#/mcpServers/leaky", + "plugin.json#/vendorField", "skills/wrong-name"] + assert all(s["reason"] for s in pl["skipped"]) + + +def test_a_component_collision_with_the_harness_is_refused(api): + direct = [{"name": "remote", "url": "https://direct.example.invalid/mcp", "transport": "http"}] + r = api.post("/v1/harnesses", json={"name": "x", "base": "claude-code", "mcp_servers": direct, + "plugins": [{"files": _package(manifest=MANIFEST, mcp=MCP)}]}) + assert r.status_code == 409 + e = r.json()["error"] + assert e["code"] == "plugin_conflict" + assert e["detail"] == {"component": "mcp_server", "name": "remote", "between": ["harness", "demo-plugin"]} + + +def test_two_plugins_with_one_name_are_refused(api): + r = api.post("/v1/harnesses", json={"name": "x", "base": "claude-code", + "plugins": [{"files": _package(manifest=MANIFEST, mcp=None)}, + {"files": _package(manifest=MANIFEST, mcp=None, skill=False)}]}) + assert r.status_code == 409 and r.json()["error"]["code"] == "plugin_conflict" + + +def test_a_disabled_plugin_stays_installed_and_clears_collisions(api): + direct = [{"name": "remote", "url": "https://direct.example.invalid/mcp", "transport": "http"}] + h = _create(api, mcp_servers=direct, plugins=[{"files": _package(manifest=MANIFEST, mcp=MCP), "enabled": False}]) + (pl,) = h["plugins"] + assert pl["name"] == "demo-plugin" and pl["enabled"] is False + + +def test_stdio_on_a_base_that_cannot_run_it_is_refused(api): + r = api.post("/v1/harnesses", json={"name": "x", "base": "dsh", + "plugins": [{"files": _package(manifest=MANIFEST, mcp=MCP)}]}) + assert r.status_code in (422, 400), r.text + e = r.json()["error"] + if r.status_code == 422 and e["code"] == "unsupported_transport": + assert e["detail"]["transport"] == "stdio" and e["detail"]["base"] == "dsh" + else: + pytest.skip(f"dsh is not a creatable base here: {e['code']}") + + +def test_export_is_a_package_that_installs_again_without_credentials(api): + direct = [{"name": "vault", "url": "https://mcp.example.invalid/mcp", "transport": "http", + "headers": {"X-Team": "t"}, "auth": "secret-token"}] + skill = {"name": "own-skill", "enabled": True, "files": [ + {"path": "SKILL.md", "content": "---\nname: own-skill\ndescription: mine\n---\n"}, + {"path": "references/r.md", "content": "ref"}]} + h = _create(api, mcp_servers=direct, skills=[skill]) + r = api.get(f"/v1/harnesses/{h['id']}/plugin") + assert r.status_code == 200, r.text + pl = r.json() + files = {f["path"]: f for f in pl["files"]} + manifest = json.loads(files["plugin.json"]["content"]) + assert manifest["$schema"] == MANIFEST_SCHEMA and manifest["name"] == "plugged" == pl["name"] + entry = json.loads(files["mcp.json"]["content"])["mcpServers"]["vault"] + assert entry == {"type": "streamable-http", "url": "https://mcp.example.invalid/mcp"} + assert "secret-token" not in json.dumps(pl) + assert {s["path"] for s in pl["skipped"]} == {"mcp.json#/mcpServers/vault/auth", "mcp.json#/mcpServers/vault/headers"} + assert {"skills/own-skill/SKILL.md", "skills/own-skill/references/r.md"} <= set(files) + h2 = _create(api, plugins=[{"files": pl["files"]}]) + (got,) = h2["plugins"] + assert got["name"] == "plugged" + assert [s["name"] for s in got["mcpServers"]] == ["vault"] + assert [s["name"] for s in got["skills"]] == ["own-skill"] + + +def test_a_missing_plugin_is_plugin_not_found(api): + h = _create(api) + r = api.get(f"/v1/harnesses/{h['id']}/plugins/nope/files") + assert r.status_code == 404 and r.json()["error"]["code"] == "plugin_not_found" + + +def test_export_name_derivation(): + assert app._plugin_export_name({"name": "Contract Review Agent", "id": "chrn_x"}) == "contract-review-agent" + assert app._plugin_export_name({"name": "acme..Tools v2", "id": "chrn_x"}) == "acme.tools-v2" + assert app._plugin_export_name({"name": "!!!", "id": "chrn_08da"}) == "chrn-08da" diff --git a/runner/server.py b/runner/server.py index 0cb1734e..ce306696 100644 --- a/runner/server.py +++ b/runner/server.py @@ -50,6 +50,7 @@ import os import pathlib import re +import shlex import shutil import signal import socket @@ -604,9 +605,16 @@ def _codex_mcp_toml(servers: list[dict]) -> str: blocks: list[str] = [] for s in servers or []: url = (s or {}).get("url") + name = _mcp_name((s or {}).get("name") or (s or {}).get("id") or "mcp") + if not url and (s or {}).get("command"): + # A stdio server (a plugin's, through its launcher): Codex's native shape. + def _tq(x: str) -> str: + return '"' + str(x).replace("\\", "\\\\").replace('"', '\\"') + '"' + blocks.append("\n".join([f"[mcp_servers.{name}]", f"command = {_tq(s['command'])}", + "args = [" + ", ".join(_tq(a) for a in (s.get("args") or [])) + "]"])) + continue if not url: continue - name = _mcp_name((s or {}).get("name") or (s or {}).get("id") or "mcp") auth = (s or {}).get("auth") lines = [f"[mcp_servers.{name}]", f'url = "{url}"'] # One http_headers inline table: Authorization from `auth` + any extra headers the @@ -628,6 +636,7 @@ def _tesc(x: str) -> str: if not blocks: return "" # rmcp HTTP client is opt-in in Codex; enable it when any HTTP MCP server is configured. + # (stdio servers need no switch; the flag is harmless beside them.) return "\nexperimental_use_rmcp_client = true\n" + "\n".join(blocks) + "\n" @@ -778,14 +787,16 @@ def _plugin_file_is_executable(rel: str, declared: bool | None) -> bool: def _write_plugins(cwd: str, plugins: list[dict]) -> list[str]: - """Materialize enabled Claude Code plugins into the workspace, one directory per plugin - under `.harness/plugins//`. Each plugin: {name, files:[{path, content|content_b64, - executable?}]}. Returns the absolute paths written, in order — the caller turns each into - one `--plugin-dir ` argument to `_build_claude`. - - A single root, unlike skills: a plugin is loaded exclusively through --plugin-dir, so - there is no discovery path to also mirror it under, and a stray second copy under - .claude/ would risk Claude Code loading the same plugin twice.""" + """Materialize each installed plugin package whole, at its own root under + `.harness/plugins//`, with a writable data directory beside it under + `.harness/plugin-data//` (UHP Plugins §6: PLUGIN_ROOT and PLUGIN_DATA). Each plugin: + {name, files:[{path, content|content_b64, executable?}]}. Returns the roots written, in order. + + The package's skills reach the agent through the ordinary skill path (the gateway lists them + in `skills`); its stdio servers run from this root through the launchers _plugin_launchers + writes. A package that also carries `.claude-plugin/plugin.json` is a Claude Code plugin + besides, and the claude branch hands that root to `--plugin-dir`: one root, never a second + copy under .claude/, which would risk Claude Code loading the same plugin twice.""" installed: list[str] = [] for pg in plugins or []: pg = pg or {} @@ -794,8 +805,10 @@ def _write_plugins(cwd: str, plugins: list[dict]) -> list[str]: if not name or not files: continue root = _safe_join(cwd, f".harness/plugins/{name}") - if root is None: + data = _safe_join(cwd, f".harness/plugin-data/{name}") + if root is None or data is None: continue + data.mkdir(parents=True, exist_ok=True) wrote = False for f in files: f = f or {} @@ -823,6 +836,82 @@ def _write_plugins(cwd: str, plugins: list[dict]) -> list[str]: return installed +_PLUGIN_PLACEHOLDER = re.compile(r"\$\{PLUGIN_(ROOT|DATA)\}") +_ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _plugin_launchers(cwd: str, servers: list[dict] | None) -> list[dict]: + """The stdio servers a plugin declares, made runnable (UHP Plugins §6, Agent Plugins §7.2.1 + and §9): the two placeholders expanded once and non-recursively in args, env and cwd; a + `./` command resolved against the plugin root and refused if it escapes; cwd defaulting to the + root and confined to the root or the data directory; PLUGIN_ROOT and PLUGIN_DATA set last. + + All of that lands in one launcher script per server in the plugin's data directory, so every + backend writer sees the same shape it already understands, {name, command, args}, and none of + the nine has to learn env or cwd. The user's command is exec'd with its args as argv, never + parsed by the shell. Servers that are not a plugin's pass through untouched.""" + out: list[dict] = [] + for s in servers or []: + s = s or {} + pname = s.get("plugin") + if not pname or not s.get("command") or s.get("url"): + out.append(s) + continue + dname = _mcp_name(str(pname)) + root = _safe_join(cwd, f".harness/plugins/{dname}") + data = _safe_join(cwd, f".harness/plugin-data/{dname}") + if root is None or data is None or not root.is_dir(): + print(f"[plugins] '{s.get('name')}': plugin {pname} has no root in this workspace — skipped", flush=True) + continue + data.mkdir(parents=True, exist_ok=True) + subst = {"ROOT": str(root), "DATA": str(data)} + + def expand(x) -> str: + return _PLUGIN_PLACEHOLDER.sub(lambda m: subst[m.group(1)], str(x)) + + cmd = str(s["command"]) + if cmd.startswith("./"): + target = _safe_join(str(root), cmd[2:]) + if target is None: + print(f"[plugins] '{s.get('name')}': command escapes the plugin root — skipped", flush=True) + continue + command = str(target) + else: + command = cmd # a bare name, resolved on PATH at exec + raw_cwd = str(s.get("cwd") or "") + if not raw_cwd or raw_cwd == ".": + wd = root + elif raw_cwd.startswith("./"): + wd = _safe_join(str(root), raw_cwd[2:]) + else: + candidate = pathlib.Path(expand(raw_cwd)) + wd = None + for base in (root, data): + try: + candidate.resolve().relative_to(base.resolve()) + wd = candidate + break + except ValueError: + continue + if wd is None: + print(f"[plugins] '{s.get('name')}': cwd escapes the plugin root and data directory — skipped", flush=True) + continue + args = [expand(a) for a in (s.get("args") or [])] + env = {str(k): expand(v) for k, v in (s.get("env") or {}).items() + if _ENV_NAME.match(str(k)) and str(k).upper() not in ("PLUGIN_ROOT", "PLUGIN_DATA")} + launcher = data / f".launch-{_mcp_name(str(s.get('name') or 'mcp'))}.sh" + lines = ["#!/bin/sh", "# written by the harness runner for one plugin MCP server; not user content"] + lines += [f"export {k}={shlex.quote(v)}" for k, v in env.items()] + lines += [f"export PLUGIN_ROOT={shlex.quote(str(root))}", + f"export PLUGIN_DATA={shlex.quote(str(data))}", + f"mkdir -p {shlex.quote(str(wd))} && cd {shlex.quote(str(wd))} || exit 1", + f'exec {shlex.quote(command)} "$@"', ""] + launcher.write_text("\n".join(lines)) + launcher.chmod(0o755) + out.append({"name": s.get("name"), "command": str(launcher), "args": args, "plugin": pname}) + return out + + _AGENTS_BEGIN = "" _AGENTS_END = "" @@ -2157,9 +2246,15 @@ def _omp_write_mcp(agent_dir: pathlib.Path, servers: list[dict] | None) -> bool: entries: dict = {} for s in servers or []: url = (s or {}).get("url") + name = _mcp_name((s or {}).get("name") or (s or {}).get("id") or "mcp") + if not url and (s or {}).get("command"): + # A stdio server (a plugin's, through its launcher): the standard mcpServers shape, + # which the loader infers as stdio from `command` and which `type` states outright. + entries[name] = {"type": "stdio", "command": str(s["command"]), + "args": [str(a) for a in (s.get("args") or [])]} + continue if not url: continue - name = _mcp_name((s or {}).get("name") or (s or {}).get("id") or "mcp") entry: dict = {"type": "http", "url": url} auth = (s or {}).get("auth") if auth: @@ -2300,9 +2395,13 @@ def _hermes_mcp_section(servers: list[dict] | None) -> dict: out: dict = {} for s in servers or []: url = (s or {}).get("url") + name = _mcp_name((s or {}).get("name") or (s or {}).get("id") or "mcp") + if not url and (s or {}).get("command"): + # A stdio server (a plugin's, through its launcher): hermes's own command/args shape. + out[name] = {"command": str(s["command"]), "args": [str(a) for a in (s.get("args") or [])]} + continue if not url: continue - name = _mcp_name((s or {}).get("name") or (s or {}).get("id") or "mcp") entry: dict = {"url": url} if ((s or {}).get("transport") or "").lower() == "sse": entry["transport"] = "sse" @@ -3572,9 +3671,15 @@ def _cline_settings(home: pathlib.Path, base_url: str, api_key: str, model: str, if not isinstance(sv, dict) or sv.get("enabled") is False: continue url = (sv.get("url") or "").strip() - if not url: - continue # cline's file also takes stdio; the harness config is URL-only name = _skill_dir_name(sv.get("name") or sv.get("id") or f"server{i}") + if not url and sv.get("command"): + # A stdio server (a plugin's, through its launcher), in the same transport-object + # shape 3.0.60 writes for `mcp install` of a local server. + servers[name] = {"transport": {"type": "stdio", "command": str(sv["command"]), + "args": [str(a) for a in (sv.get("args") or [])]}} + continue + if not url: + continue entry: dict = {"transport": {"type": "streamableHttp", "url": url}} hdrs = sv.get("headers") if isinstance(hdrs, dict) and hdrs: @@ -5655,6 +5760,10 @@ def turn(req: TurnReq, identifier: str = "") -> dict: installed_skills = _write_skills( cwd, [s for s in (req.skills or []) if (s.get("name") or s.get("id")) not in _skip], backend, ) # materialize enabled skills for this backend (minus suppressed built-ins) + # Installed plugin packages, whole, at their roots; then every stdio server a plugin declares + # becomes a launcher every backend writer below already knows how to hand to its CLI. + plugin_roots = _write_plugins(cwd, req.plugins) + req.mcp_servers = _plugin_launchers(cwd, req.mcp_servers) # Seed the agent's instruction file (AGENTS.md/CLAUDE.md) from the harness doc + installed skills. # The model's system prompt stays the CLI default; persistent instructions live in this file. # @@ -5779,7 +5888,10 @@ def turn(req: TurnReq, identifier: str = "") -> dict: skills_dir=skills_dir, tools_disabled=req.tools_disabled) else: mcp_config = _write_mcp_config_claude(cwd, req.mcp_servers) - plugin_dirs = _write_plugins(cwd, req.plugins) + # Only a package that is ALSO a Claude Code plugin gets --plugin-dir; an Agent Plugins + # package without .claude-plugin/plugin.json would make the CLI refuse the flag. + plugin_dirs = [d for d in plugin_roots + if (pathlib.Path(d) / ".claude-plugin" / "plugin.json").is_file()] cmd = _build_claude(req.provider, auth, model, req.prompt, req.max_turns, cwd, env, resume_session_id=req.resume_session_id, mcp_config=mcp_config, disallowed_tools=req.tools_disabled, partial=bool(req.partial_messages), diff --git a/runner/tests/test_uhp_plugins.py b/runner/tests/test_uhp_plugins.py new file mode 100644 index 00000000..7e6cafd1 --- /dev/null +++ b/runner/tests/test_uhp_plugins.py @@ -0,0 +1,100 @@ +"""UHP Plugins §6 in the runner: a package lands whole at its root with a data directory, and a +stdio server a plugin declares runs through a launcher that sets PLUGIN_ROOT and PLUGIN_DATA, +expands the two placeholders once, resolves a ./ command against the root, and honours cwd. The +launcher is real: one test runs it and reads what the child process saw.""" +import json +import os +import pathlib +import subprocess +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) +import server as rn # noqa: E402 + +PKG = {"name": "demo-plugin", "files": [ + {"path": "plugin.json", "content": "{}"}, + {"path": "bin/probe.sh", "content": "#!/bin/sh\nprintf '%s|%s|%s|%s|' \"$PLUGIN_ROOT\" \"$PLUGIN_DATA\" \"$MODE\" \"$(pwd)\"; printf '%s|' \"$@\"\n"}, +]} + + +def _server(**over): + return {"name": "probe", "plugin": "demo-plugin", "command": "./bin/probe.sh", + "args": ["--data", "${PLUGIN_DATA}/x", "${PLUGIN_ROOT}"], "env": {"MODE": "t-${PLUGIN_DATA}"}, **over} + + +def test_a_package_lands_at_its_root_with_a_data_directory(tmp_path): + roots = rn._write_plugins(str(tmp_path), [PKG]) + assert roots == [str(tmp_path / ".harness/plugins/demo_plugin")] + assert (tmp_path / ".harness/plugins/demo_plugin/bin/probe.sh").is_file() + assert (tmp_path / ".harness/plugin-data/demo_plugin").is_dir() + + +def test_the_launcher_runs_the_command_with_root_data_env_cwd_and_args(tmp_path): + rn._write_plugins(str(tmp_path), [PKG]) + (out,) = rn._plugin_launchers(str(tmp_path), [_server()]) + assert out["name"] == "probe" and out["command"].endswith("/.launch-probe.sh") and "env" not in out + root = str(tmp_path / ".harness/plugins/demo_plugin") + data = str(tmp_path / ".harness/plugin-data/demo_plugin") + assert out["args"] == ["--data", f"{data}/x", root], "placeholders expand in args" + seen = subprocess.run([out["command"], *out["args"], "extra"], capture_output=True, text=True, timeout=10) + assert seen.returncode == 0, seen.stderr + got = seen.stdout.split("|") + assert got[0] == root and got[1] == data, "PLUGIN_ROOT and PLUGIN_DATA reach the process" + assert got[2] == f"t-{data}", "the env overlay is expanded and exported" + assert os.path.realpath(got[3]) == os.path.realpath(root), "cwd defaults to the plugin root" + assert got[4:8] == ["--data", f"{data}/x", root, "extra"], "args are passed as argv, unparsed" + + +def test_cwd_may_sit_in_the_root_or_the_data_directory_and_nowhere_else(tmp_path): + rn._write_plugins(str(tmp_path), [PKG]) + (a,) = rn._plugin_launchers(str(tmp_path), [_server(cwd="${PLUGIN_DATA}/work")]) + seen = subprocess.run([a["command"]], capture_output=True, text=True, timeout=10).stdout.split("|") + assert seen[3].endswith("/.harness/plugin-data/demo_plugin/work"), "a data-rooted cwd is created and used" + (b,) = rn._plugin_launchers(str(tmp_path), [_server(cwd="./sub")]) + seen = subprocess.run([b["command"]], capture_output=True, text=True, timeout=10).stdout.split("|") + assert seen[3].endswith("/.harness/plugins/demo_plugin/sub") + assert rn._plugin_launchers(str(tmp_path), [_server(cwd="/etc")]) == [] + assert rn._plugin_launchers(str(tmp_path), [_server(cwd="${PLUGIN_ROOT}/../../escape")]) == [] + + +def test_an_escaping_command_is_dropped_and_a_bare_one_kept(tmp_path): + rn._write_plugins(str(tmp_path), [PKG]) + assert rn._plugin_launchers(str(tmp_path), [_server(command="./../../bin/sh")]) == [] + (bare,) = rn._plugin_launchers(str(tmp_path), [_server(command="sh", args=["-c", "echo hi"])]) + assert subprocess.run([bare["command"], *bare["args"]], capture_output=True, text=True, timeout=10).stdout.strip() == "hi" + + +def test_the_reserved_names_cannot_be_overridden_from_env(tmp_path): + rn._write_plugins(str(tmp_path), [PKG]) + (out,) = rn._plugin_launchers(str(tmp_path), [_server(env={"PLUGIN_ROOT": "/tmp/evil", "plugin_data": "/x", "MODE": "ok"})]) + text = pathlib.Path(out["command"]).read_text() + assert "/tmp/evil" not in text and "'/x'" not in text and "export MODE=ok" in text + + +def test_servers_that_are_not_a_plugins_pass_through(tmp_path): + direct = {"name": "vault", "url": "https://mcp.example.invalid/mcp"} + assert rn._plugin_launchers(str(tmp_path), [direct]) == [direct] + + +def test_a_plugin_without_a_root_in_this_workspace_is_skipped(tmp_path): + assert rn._plugin_launchers(str(tmp_path), [_server()]) == [] + + +def test_every_url_only_writer_now_carries_a_stdio_entry(tmp_path): + srv = [{"name": "probe", "command": "/ws/.harness/plugin-data/demo/.launch-probe.sh", "args": ["--x", "1"]}, + {"name": "vault", "url": "https://mcp.example.invalid/mcp"}] + toml = rn._codex_mcp_toml(srv) + assert '[mcp_servers.probe]\ncommand = "/ws/.harness/plugin-data/demo/.launch-probe.sh"\nargs = ["--x", "1"]' in toml + assert "[mcp_servers.vault]" in toml + hermes = rn._hermes_mcp_section(srv) + assert hermes["probe"] == {"command": "/ws/.harness/plugin-data/demo/.launch-probe.sh", "args": ["--x", "1"]} + assert hermes["vault"]["url"].startswith("https://") + home = tmp_path / "home" + rn._cline_settings(home, "https://relay", "k", "m", srv) + cline = json.loads((home / ".cline/data/settings/cline_mcp_settings.json").read_text())["mcpServers"] + assert cline["probe"]["transport"] == {"type": "stdio", "command": "/ws/.harness/plugin-data/demo/.launch-probe.sh", "args": ["--x", "1"]} + agent = tmp_path / "omp" + assert rn._omp_write_mcp(agent, srv) + omp = json.loads((agent / "mcp.json").read_text())["mcpServers"] + assert omp["probe"] == {"type": "stdio", "command": "/ws/.harness/plugin-data/demo/.launch-probe.sh", "args": ["--x", "1"]} + assert omp["vault"]["type"] == "http" From 8ba824fa8022f00e6d6d6f5a272c618f3c14beae Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Sun, 13 Sep 2026 15:09:06 -0700 Subject: [PATCH 02/16] console: a Plugins section on the Harness page Install a package from a folder (the picker reads every file under it, paths relative to the folder, and names the row from plugin.json before the save; the server validates on save and answers with what it derived and what it skipped), see each installed plugin's tools, Skills and what did not load, enable or disable it, remove it, and download the Harness's own tools and Skills as a package zipped in the browser. The harness type and save body carry `plugins`; fflate zips the export. Co-Authored-By: Claude Fable 5.1 --- ui/package-lock.json | 7 ++ ui/package.json | 1 + ui/src/app/revamp.css | 17 ++++ ui/src/components/HarnessEditors.tsx | 2 +- ui/src/components/HarnessSettings.tsx | 125 +++++++++++++++++++++++++- ui/src/lib/harness.ts | 30 +++++++ 6 files changed, 177 insertions(+), 5 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 74596d53..63d99edb 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -34,6 +34,7 @@ "echarts": "^5.6.0", "echarts-for-react": "^3.0.6", "emoji-mart": "^5.6.0", + "fflate": "^0.8.3", "framer-motion": "^12.23.16", "highlight.js": "^11.11.1", "iconify-icon": "^3.0.2", @@ -7248,6 +7249,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", diff --git a/ui/package.json b/ui/package.json index e2d4f8b5..0da203a9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -43,6 +43,7 @@ "echarts": "^5.6.0", "echarts-for-react": "^3.0.6", "emoji-mart": "^5.6.0", + "fflate": "^0.8.3", "framer-motion": "^12.23.16", "highlight.js": "^11.11.1", "iconify-icon": "^3.0.2", diff --git a/ui/src/app/revamp.css b/ui/src/app/revamp.css index 6c43b928..514cdfd3 100644 --- a/ui/src/app/revamp.css +++ b/ui/src/app/revamp.css @@ -2009,3 +2009,20 @@ button.creq-srcstat.on b { color: var(--accent); } when the record actually knows the cause. The badge above it deliberately no longer names a cause, because it used to name the wrong one for interrupted turns. */ .wbx-incomplete-why { font-size: 12.5px; color: var(--faint, #6e6760); margin: 4px 0 0 2px; } + +/* ── Plugins on the Harness page: one row per installed package, expandable to what the server + derived from it (tools, Skills, what it did not load) ─────────────────────────────────── */ +.section-actions-group{display:flex;gap:8px;flex-wrap:wrap;align-items:center} +.plugin-row .capability-copy{min-width:0} +.plugin-version{margin-left:8px;font:500 11px/1 var(--mono,ui-monospace,monospace);color:var(--muted,#6f6f78); + border:1px solid var(--line,#e7e7eb);border-radius:6px;padding:2px 6px;vertical-align:middle} +.plugin-facts{display:block;font-size:12px;color:var(--faint,#9696a0)} +.plugin-warn{font-style:normal;color:#b45309} +.plugin-details{margin-top:10px;display:grid;gap:10px;font-size:12.5px;color:var(--muted,#6f6f78)} +.plugin-details b{display:block;margin-bottom:4px;font-size:11px;font-weight:660;letter-spacing:.04em;text-transform:uppercase;color:var(--faint,#9696a0)} +.plugin-details code{font:500 12px/1.4 var(--mono,ui-monospace,monospace);color:var(--ink,#111317);word-break:break-all} +.plugin-details div{overflow-wrap:anywhere} +@media (max-width:768px){ + .plugin-row{flex-wrap:wrap} + .plugin-row .capability-actions{width:100%;justify-content:flex-start;flex-wrap:wrap} +} diff --git a/ui/src/components/HarnessEditors.tsx b/ui/src/components/HarnessEditors.tsx index a240a9a0..5b0de655 100644 --- a/ui/src/components/HarnessEditors.tsx +++ b/ui/src/components/HarnessEditors.tsx @@ -31,7 +31,7 @@ export type SkillFile = { path: string; content?: string; content_b64?: string } // anything else (binaries like .exe/.png, or text with embedded NULs) is base64-encoded into // `content_b64`. This keeps binary bytes intact AND keeps NUL out of any JSON string, a literal // in skill content otherwise aborts the harness PUT at the Postgres layer. -async function readSkillUpload(file: File, path: string): Promise { +export async function readSkillUpload(file: File, path: string): Promise { try { const bytes = new Uint8Array(await file.arrayBuffer()); try { diff --git a/ui/src/components/HarnessSettings.tsx b/ui/src/components/HarnessSettings.tsx index e319e3be..5c53b64c 100644 --- a/ui/src/components/HarnessSettings.tsx +++ b/ui/src/components/HarnessSettings.tsx @@ -3,16 +3,17 @@ // IA review): General → Default model → Agent instructions → Tools → Skills → Runtime limits → // Request headers, one Save. Built-in harnesses render read-only with a Clone action. // Reuses the battle-tested SkillEditor + McpModal from the workbench. -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { zipSync, strToU8 } from 'fflate'; import { SkelPage } from '@/components/Skel'; import { useRouter } from 'next/navigation'; import { - OOB, oobById, oobDefaultModel, oobModels, useModelCatalog, modelAvailable, modelAvailability, availabilityNote, useBases, getCustom, saveCustom, deleteCustom, createCustom, getSkillFiles, storeMcpSecret, - type CustomHarness, type OobHarness, + OOB, oobById, oobDefaultModel, oobModels, useModelCatalog, modelAvailable, modelAvailability, availabilityNote, useBases, getCustom, saveCustom, deleteCustom, createCustom, getSkillFiles, storeMcpSecret, exportPlugin, + type CustomHarness, type OobHarness, type HarnessPlugin, } from '@/lib/harness'; import { HarnessLogo } from '@/components/HarnessLogo'; import { CopyId } from '@/components/CopyId'; -import { SkillEditor, McpModal, type McpServer } from '@/components/HarnessEditors'; +import { SkillEditor, McpModal, readSkillUpload, type McpServer } from '@/components/HarnessEditors'; import { fetchTraceWindow, statsFor, p95Of, avgCreditsOf, timeAgo, type TraceCard } from '@/lib/revamp-data'; // Self-hosted only: publish a custom harness (instructions, model, skills, MCP wiring) to a // hosted workspace. Hidden and inert on hosted builds. @@ -50,6 +51,57 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: { }, [id]); // Draft of a NEW skill being created in the SkillEditor popup (name edited in the same popup). const [newSkill, setNewSkill] = useState<{ name: string } | null>(null); + // Plugins: which row is expanded, the folder picker, and the export in flight. + const [pluginOpen, setPluginOpen] = useState(null); + const [pluginBusy, setPluginBusy] = useState(false); + const pluginDirRef = useRef(null); + // A folder picked as a plugin package: every file under it, paths relative to the folder itself, + // and the manifest read up front so the row can name what was picked before it is saved. The + // server does the real validation on save and answers with what it derived and what it skipped. + async function installPluginFolder(list: FileList | null) { + if (!list || !list.length || !draft) return; + setPluginBusy(true); setErr(null); + try { + const files: NonNullable = []; + for (let i = 0; i < list.length; i++) { + const f = list[i]; + const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name; + const path = rel.includes('/') ? rel.slice(rel.indexOf('/') + 1) : rel; // drop the picked folder's own name + if (!path || path.split('/').some((seg) => seg === 'node_modules' || seg === '.git')) continue; + files.push(await readSkillUpload(f, path)); + } + const manifestFile = files.find((f) => f.path === 'plugin.json'); + if (!manifestFile || manifestFile.content === undefined) throw new Error('That folder has no plugin.json at its root, so it is not a plugin package.'); + let manifest: HarnessPlugin['manifest'] = {}; + try { manifest = JSON.parse(manifestFile.content) as HarnessPlugin['manifest']; } catch { throw new Error('plugin.json is not valid JSON.'); } + const name = String(manifest?.name || ''); + if (!name) throw new Error('plugin.json names no plugin.'); + if ((draft.plugins || []).some((p) => p.name === name)) throw new Error(`A plugin named ${name} is already installed on this Harness.`); + upd({ plugins: [...(draft.plugins || []), { name, enabled: true, files, manifest }] }); + } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } + finally { setPluginBusy(false); if (pluginDirRef.current) pluginDirRef.current.value = ''; } + } + // The Harness's own tools and Skills as a package, zipped in the browser and handed to the + // person as a file they can install anywhere that reads the Agent Plugins format. + async function downloadPluginPackage() { + if (!draft || pluginBusy) return; + setPluginBusy(true); setErr(null); + try { + const pkg = await exportPlugin(draft.id); + const entries: Record = {}; + for (const f of pkg.files || []) { + entries[f.path] = f.content_b64 !== undefined + ? Uint8Array.from(atob(f.content_b64), (c) => c.charCodeAt(0)) + : strToU8(f.content || ''); + } + const blob = new Blob([zipSync(entries) as BlobPart], { type: 'application/zip' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = `${pkg.name || 'harness'}.zip`; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } + finally { setPluginBusy(false); } + } const [cards, setCards] = useState([]); useEffect(() => { @@ -301,6 +353,71 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: { +
+

Plugins

Install a package of tools and Skills in the Agent Plugins format. What it brings joins this Harness's own tools and Skills on every Task.

+
+
{(draft?.plugins || []).length} installed plugins + + {!readOnly && draft?.id && !oob && } + {!readOnly && } + + void installPluginFolder(e.target.files)} + {...({ webkitdirectory: '', directory: '' } as Record)} /> +
+
+ {(draft?.plugins || []).map((p, idx) => { + const servers = p.mcpServers || [], skls = p.skills || [], skipped = p.skipped || []; + const pending = Boolean(p.files && p.files.length && !p.blob); + const m = p.manifest || {}; + const open = pluginOpen === idx; + return ( +
+ +
+ {p.name}{m.version ? {m.version} : null} + {m.description || 'Agent Plugins package'} + + {pending ? 'Saves with the Harness' : `${servers.length} ${servers.length === 1 ? 'tool' : 'tools'} · ${skls.length} ${skls.length === 1 ? 'Skill' : 'Skills'}`} + {!pending && skipped.length > 0 && · {skipped.length} not loaded} + + {open && !pending && ( +
+ {servers.length > 0 &&
Tools{servers.map((s) =>
{s.name} {s.transport === 'stdio' ? `runs ${s.command}` : s.url}
)}
} + {skls.length > 0 &&
Skills{skls.map((s) =>
{s.name} {s.description}
)}
} + {skipped.length > 0 &&
Not loaded{skipped.map((s) =>
{s.path} {s.reason}
)}
} + {(m.author?.name || m.homepage || m.repository || m.license) && ( +
About
+ {m.author?.name ? `By ${m.author.name}. ` : ''}{m.license ? `${m.license}. ` : ''} + {m.homepage && Homepage}{m.homepage && m.repository ? ' · ' : ''} + {m.repository && Source} +
+ )} +
+ )} +
+
+ {!pending && } + {!readOnly && } + +
+
+ ); + })} + {(draft?.plugins || []).length === 0 && ( +
+ +
No plugins installed + A plugin is a folder with a plugin.json at its root, tools in mcp.json and Skills under skills. Install one to add all of it at once.
+
+ )} +
+
+
+

Runtime limits

Stop Tasks that run longer or take more agent steps than expected.

diff --git a/ui/src/lib/harness.ts b/ui/src/lib/harness.ts index 8de15518..85497c59 100644 --- a/ui/src/lib/harness.ts +++ b/ui/src/lib/harness.ts @@ -49,9 +49,24 @@ export interface CustomHarness { maxStep?: number | null; // default agent step budget per turn (blank = 40) timeoutSeconds?: number | null; // default per-turn wall-clock cap (blank = server default) additionalHeaders?: string[]; // declared header NAMES callers pass per request (app-level auth) + // Installed plugins (UHP Plugins chapter). The server derives manifest, mcpServers, skills and + // skipped from the package on every save; a saved plugin round-trips as {name, enabled, blob} + // plus those derived fields, and a freshly picked one carries its `files` until it is saved. + plugins?: HarnessPlugin[]; createdAt: number; } +export type HarnessPlugin = { + name: string; enabled?: boolean; blob?: string; + files?: { path: string; content?: string; content_b64?: string }[]; + manifest?: { name?: string; version?: string; description?: string; license?: string; + homepage?: string; repository?: string; keywords?: string[]; + author?: { name?: string; email?: string; url?: string } }; + mcpServers?: { name: string; transport?: string; url?: string; command?: string; args?: string[] }[]; + skills?: { name: string; description?: string }[]; + skipped?: { path: string; reason: string }[]; +}; + export const OOB: OobHarness[] = [ { id: 'codex', name: 'Codex', version: 'v1.0.1', backend: 'codex', status: 'ready', models: ['gpt-6-astra', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.2'], defaultModel: 'gpt-5.5', moreModels: 0, @@ -330,6 +345,7 @@ function harnessBody(input: Partial & { name: string; base: strin mcp_servers: input.mcpServers || [], skills: input.skills || [], disabled_tools: input.disabledTools || [], + plugins: input.plugins || [], max_step: input.maxStep || null, timeout_seconds: input.timeoutSeconds || null, additional_headers: input.additionalHeaders || [], @@ -373,6 +389,20 @@ export async function deleteCustom(id: string): Promise { /** Full files of one skill on a harness, resolving the server-side blob offload, used to * hydrate a folder skill for editing when the record only carries {name, enabled, blob}. */ +/** The complete package of one installed plugin, byte for byte (UHP Plugins §3.1). */ +export async function getPluginFiles(harnessId: string, name: string): + Promise<{ path: string; content?: string; content_b64?: string }[]> { + const r = await gw<{ files: { path: string; content?: string; content_b64?: string }[] }>( + 'GET', `/v1/harnesses/${encodeURIComponent(harnessId)}/plugins/${encodeURIComponent(name)}/files`); + return r.files || []; +} + +/** This Harness's own tools and Skills as an Agent Plugins package (UHP Plugins §5). Credentials + * are never in it; each omission is listed in `skipped`. */ +export async function exportPlugin(harnessId: string): Promise { + return gw('GET', `/v1/harnesses/${encodeURIComponent(harnessId)}/plugin`); +} + export async function getSkillFiles(harnessId: string, skillId: string): Promise<{ path: string; content?: string; content_b64?: string }[]> { const r = await gw<{ files: { path: string; content?: string; content_b64?: string }[] }>( From 9bbdf56f587d0e061615298e9fa0af5887426cfd Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Sun, 13 Sep 2026 15:10:01 -0700 Subject: [PATCH 03/16] gateway: a kit that ships a plugin package launches as an installed plugin The starter kits now carry their Skills as an Agent Plugins package at /plugin/. Launch installs it, so the Harness records the kit as a named, versioned plugin whose skills derive from the package, and can export it. A kit baked before that layout still carries its skills directly through the path that read them before. Co-Authored-By: Claude Fable 5.1 --- gateway/app.py | 34 +++++++++++++++++++++++++++++++--- gateway/tests/test_plugins.py | 27 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/gateway/app.py b/gateway/app.py index 4cd2e996..07aa3d99 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -12857,9 +12857,33 @@ def _kits() -> dict: return out +def _kit_plugin(kit: dict) -> dict | None: + """The kit's plugin package, read whole from `//` (an Agent Plugins + package: plugin.json at its root, the kit's skills under skills/). A launched Harness installs + it as a named, versioned plugin it can export, and the skills derive from the package; there + is no second list of them to keep in step. None when the kit ships no package.""" + sub = str((kit.get("harness") or {}).get("plugin") or "") + root = pathlib.Path(_KITS_DIR) / str(kit.get("id") or "") / sub + if not sub or not (root / "plugin.json").is_file(): + return None + files: list[dict] = [] + for f in sorted(root.rglob("*")): + if not f.is_file() or f.stat().st_size > _SKILL_FILE_MAX: + continue + raw = f.read_bytes() + rel = f.relative_to(root).as_posix() + try: + files.append({"path": rel, "content": raw.decode()}) + except UnicodeDecodeError: + files.append({"path": rel, "content_b64": base64.b64encode(raw).decode()}) + return {"files": files, "enabled": True} + + def _kit_skills(kit: dict) -> list[dict]: - """A kit's own skills, read from its folder. Self-contained by design: a kit carries the - skills its Harness needs rather than depending on what the image happens to bundle.""" + """A kit's own skills, read from its folder, for a kit built before kits shipped a plugin + package (harness.skills names them). Self-contained by design: a kit carries the skills its + Harness needs rather than depending on what the image happens to bundle. Retire this with + the last image whose kits bake predates the package layout.""" root = pathlib.Path(_KITS_DIR) / str(kit.get("id") or "") out: list[dict] = [] for name in (kit.get("harness") or {}).get("skills") or []: @@ -14242,11 +14266,15 @@ async def launch_kit(kit_id: str, request: Request, body_in: KitLaunchBody | Non base, model = want_base, want_model else: base, model = await _kit_base(org, kit) + kit_plugin = _kit_plugin(kit) body = HarnessBody(name=spec.get("name") or kit.get("title") or kit_id, base=base, default_model=model, system_prompt=spec.get("system_prompt") or "", - skills=_kit_skills(kit), + # A kit that ships a package installs it; its skills derive from the + # package. A kit from before that layout still carries them directly. + skills=[] if kit_plugin else _kit_skills(kit), + plugins=[kit_plugin] if kit_plugin else [], mcp_servers=spec.get("mcp_servers") or [], disabled_tools=spec.get("disabled_tools") or []) body.mcp_servers = _mcp_servers_prepare(body.mcp_servers) diff --git a/gateway/tests/test_plugins.py b/gateway/tests/test_plugins.py index 26f39db7..303e0cbc 100644 --- a/gateway/tests/test_plugins.py +++ b/gateway/tests/test_plugins.py @@ -226,3 +226,30 @@ def test_export_name_derivation(): assert app._plugin_export_name({"name": "Contract Review Agent", "id": "chrn_x"}) == "contract-review-agent" assert app._plugin_export_name({"name": "acme..Tools v2", "id": "chrn_x"}) == "acme.tools-v2" assert app._plugin_export_name({"name": "!!!", "id": "chrn_08da"}) == "chrn-08da" + + +def test_a_kit_that_ships_a_package_launches_as_an_installed_plugin(api, tmp_path, monkeypatch): + """The starter kits carry their skills as an Agent Plugins package; launch installs it, and + the Harness records the kit as a named, versioned plugin rather than as loose skills.""" + kid = "demo-kit" + plug = tmp_path / kid / "plugin" + (plug / "skills" / "demo-skill").mkdir(parents=True) + (plug / "plugin.json").write_text(json.dumps({"$schema": MANIFEST_SCHEMA, "name": "harnessrouter-demo", + "version": "1.2.0", "description": "A kit."})) + (plug / "skills" / "demo-skill" / "SKILL.md").write_text("---\nname: demo-skill\ndescription: fixture\n---\n") + (plug / "skills" / "demo-skill" / "helper.py").write_text("print('hi')\n") + monkeypatch.setattr(app, "_KITS_DIR", str(tmp_path)) + monkeypatch.setattr(app, "_kits", lambda: {kid: { + "id": kid, "title": "Demo", "app": {"route": "/kits/demo"}, + "harness": {"name": "Demo", "plugin": "plugin", "recommended": [{"base": "claude-code", "model": "claude-opus-5"}]}}}) + r = api.post(f"/v1/kits/{kid}/launch", json={}) + assert r.status_code == 200, r.text + h = r.json()["harness"] + assert h["skills"] == [], "the kit's skills come from the package, not the direct list" + (pl,) = h["plugins"] + assert pl["name"] == "harnessrouter-demo" and pl["manifest"]["version"] == "1.2.0" + assert [s["name"] for s in pl["skills"]] == ["demo-skill"] and pl["skipped"] == [] + files = api.get(f"/v1/harnesses/{h['id']}/plugins/harnessrouter-demo/files").json()["files"] + assert sorted(f["path"] for f in files) == ["plugin.json", "skills/demo-skill/SKILL.md", "skills/demo-skill/helper.py"] + again = api.post(f"/v1/kits/{kid}/launch", json={}) + assert again.status_code == 200 and again.json()["created"] is False and again.json()["harnessId"] == h["id"] From 53fbeeeb11e5887bac039b8169b238dbe85b92a9 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Sun, 13 Sep 2026 15:12:15 -0700 Subject: [PATCH 04/16] docs: installing a plugin on a harness, in the guide and the console API reference Co-Authored-By: Claude Fable 5.1 --- docs/self-hosting-guide.md | 29 +++++++++++++++++++++++++++++ ui/src/components/ApiReference.tsx | 15 +++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/docs/self-hosting-guide.md b/docs/self-hosting-guide.md index 13a8fe4c..729f92d1 100644 --- a/docs/self-hosting-guide.md +++ b/docs/self-hosting-guide.md @@ -718,6 +718,35 @@ It is created in this self-hosted instance and authenticates requests to that CE neither a Cloud key, your Console password, nor the model-provider key configured in **Integrations**. Never put it in browser-side code or commit it to Git. +### Install a plugin on a harness + +A plugin is a folder in the [Agent Plugins](https://agent-plugins.org) format: `plugin.json` at its +root, tools in `mcp.json`, Skills under `skills/`. Installing one gives a harness all of it at once, +and the harness records it as a named, versioned package it can export again. The starter kits ship +their Skills this way, so a launched kit's harness shows the kit as an installed plugin. + +In the Console, open the harness, find **Plugins**, and choose **Install from folder**. Over the API, +send the folder's files on create or update: + +```bash +curl -s -X PUT "$HARNESSROUTER_BASE_URL/v1/harnesses/$HARNESS_ID" \ + -H "Authorization: Bearer $HARNESSROUTER_API_KEY" -H "Content-Type: application/json" \ + -d '{"name":"Contract Review Agent","base":"claude-code", + "plugins":[{"files":[ + {"path":"plugin.json","content":"{\"$schema\":\"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json\",\"name\":\"contract-review\"}"}, + {"path":"skills/risk-checklist/SKILL.md","content":"---\nname: risk-checklist\ndescription: Turn an agreement into a risk checklist.\n---\n..."} + ]}]}' +``` + +The record you read back carries what the server derived from the package (`manifest`, +`mcpServers`, `skills`, and `skipped` for anything it could not load) and a `blob` handle that +round-trips on the next PUT. The harness's own `mcpServers` and `skills` stay exactly what you wrote +there; a plugin's components are listed on the plugin. Names must not collide across the harness and +its enabled plugins, and a package that declares a process (a `stdio` server) is refused on a base +that cannot run one. `GET /v1/harnesses/{id}/plugin` exports the harness's own tools and Skills as a +package, credentials omitted. The full contract is the +[Plugins chapter](https://unifiedharnessprotocol.org/spec/2026-09-12/plugins) of the protocol. + ### Run a task This example assumes `HARNESSROUTER_API_KEY` is already set. Choose an installed harness and a diff --git a/ui/src/components/ApiReference.tsx b/ui/src/components/ApiReference.tsx index 0dc7afc4..d3773ecc 100644 --- a/ui/src/components/ApiReference.tsx +++ b/ui/src/components/ApiReference.tsx @@ -321,6 +321,21 @@ curl "${mgmtBase}/sessions/{session_id}/files?changed=true" \\ id or name; returns {`{ id, name, files: [{path, content|content_b64}] }`}.

+ +

The complete package of one installed plugin, byte for byte. A plugin is a + folder in the Agent Plugins format (plugin.json at its root, tools in + mcp.json, Skills under skills/), sent on create or update as + {`plugins: [{ files: [{path, content|content_b64}] }]`}. The record you read back carries + what the server derived from it: manifest, mcpServers, skills + and skipped, plus a blob handle that round-trips on PUT.

+
+ + +

This agent's own tools and Skills as an Agent Plugins package, ready to + send to another agent's plugins or to write to disk for any client that reads the + format. Credentials are never included; each omission is listed in skipped.

+
+

The current model catalog per engine, each with its default. Build model pickers from this, don't hardcode model names. The map is keyed by backend, not From 03cabab74ce731732877268158d6558b49cc6845 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Sun, 13 Sep 2026 15:44:04 -0700 Subject: [PATCH 05/16] console: the plugin version chip keeps its own width when it wraps under the name Co-Authored-By: Claude Fable 5.1 --- ui/src/app/revamp.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/app/revamp.css b/ui/src/app/revamp.css index 514cdfd3..a3d8c7e3 100644 --- a/ui/src/app/revamp.css +++ b/ui/src/app/revamp.css @@ -2014,7 +2014,7 @@ button.creq-srcstat.on b { color: var(--accent); } derived from it (tools, Skills, what it did not load) ─────────────────────────────────── */ .section-actions-group{display:flex;gap:8px;flex-wrap:wrap;align-items:center} .plugin-row .capability-copy{min-width:0} -.plugin-version{margin-left:8px;font:500 11px/1 var(--mono,ui-monospace,monospace);color:var(--muted,#6f6f78); +.plugin-version{display:inline-block;width:auto;margin-left:8px;font:500 11px/1 var(--mono,ui-monospace,monospace);color:var(--muted,#6f6f78); border:1px solid var(--line,#e7e7eb);border-radius:6px;padding:2px 6px;vertical-align:middle} .plugin-facts{display:block;font-size:12px;color:var(--faint,#9696a0)} .plugin-warn{font-style:normal;color:#b45309} From aa894a399c94ac84ddd266a68e1c3a24604f78f3 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Sun, 13 Sep 2026 17:51:37 -0700 Subject: [PATCH 06/16] plugins: the review's findings, fixed Gateway. Packages are stored under the org that installed them and a handle is checked for shape and ownership, so a leaked handle cannot read another org's package through a PUT. Everything is validated before any package is stored, and a write that drops a plugin deletes its package; deleting a harness deletes them all. The stdio gate normalises the base the way the write does, so no spelling of pi or dsh slips a process past it. Two plugin names, or a plugin skill and a harness skill, that the runner would fold into one directory are refused as one name. A remote server this deployment's network policy refuses is refused at write time and written into skipped, not dropped with a log line at turn time. File entries are checked in full: content or content_b64 but not both, base64 that decodes, no duplicate paths, no control characters; a manifest with no $schema is invalid rather than an unsupported version; env names must be environment variable names; the frontmatter reader handles block scalars and trailing comments; the derived record is bounded so the harness write cannot exceed the property cap. The export never fails on what an earlier write accepted and strips credentials from URLs. A disabled own skill no longer suppresses a plugin's skill of that name. The kit catalog names what a package installs; launch reserves the names of the hosted entries it attaches; a declared package that is missing is logged. Cloud upload carries plugins whole when the hosted side reports the capability and as their skills and remote servers when it does not. A harness's base is immutable on update, as the spec says. Runner. The plugin root and data directory are checked as the paths the session could have replaced with links, before anything is written through them; roots are rebuilt every turn and stale roots removed; files are written beside and renamed over so a running server's binary is replaced rather than truncated; a package's own ./ command is made runnable; the launcher is written without following links; codex's TOML escapes control characters; skills this runner wrote on an earlier turn and does not write now are removed; a package that is also a Claude Code plugin is not folded into the skills directory a second time. Console. Errors and notices from installing or downloading a package appear in the Plugins section; a folder over the package limit or for a manifest version the server does not install is refused before it is read into the draft; a save that leaves a plugin with parts not loaded stays on the page and opens that row; the download is disabled while the draft is unsaved and reports what the package left out; the open row is keyed by name; copy and wrapping at phone width. Docs. The guide's install example is a create, and the update path says what PUT replaces; the API reference lists the plugins field. Co-Authored-By: Claude Fable 5.1 --- docs/self-hosting-guide.md | 18 +- gateway/app.py | 309 +++++++++++++++++++++----- gateway/tests/test_plugins.py | 10 + runner/server.py | 93 +++++++- runner/tests/test_uhp_plugins.py | 36 ++- ui/src/app/revamp.css | 4 + ui/src/components/ApiReference.tsx | 1 + ui/src/components/HarnessSettings.tsx | 63 ++++-- ui/src/lib/harness.ts | 12 + 9 files changed, 465 insertions(+), 81 deletions(-) diff --git a/docs/self-hosting-guide.md b/docs/self-hosting-guide.md index 729f92d1..f9d9012c 100644 --- a/docs/self-hosting-guide.md +++ b/docs/self-hosting-guide.md @@ -722,14 +722,16 @@ in **Integrations**. Never put it in browser-side code or commit it to Git. A plugin is a folder in the [Agent Plugins](https://agent-plugins.org) format: `plugin.json` at its root, tools in `mcp.json`, Skills under `skills/`. Installing one gives a harness all of it at once, -and the harness records it as a named, versioned package it can export again. The starter kits ship -their Skills this way, so a launched kit's harness shows the kit as an installed plugin. +and the harness records it as a named, versioned package it can export again. Starter kits built from +the kit release that carries plugin packages ship their Skills this way, so a kit launched from such +an image shows the kit as an installed plugin; a kit launched earlier keeps its Skills as its own. In the Console, open the harness, find **Plugins**, and choose **Install from folder**. Over the API, -send the folder's files on create or update: +`plugins` is a field of the harness record. The simplest case is a new harness created with the +package in it: ```bash -curl -s -X PUT "$HARNESSROUTER_BASE_URL/v1/harnesses/$HARNESS_ID" \ +curl -s -X POST "$HARNESSROUTER_BASE_URL/v1/harnesses" \ -H "Authorization: Bearer $HARNESSROUTER_API_KEY" -H "Content-Type: application/json" \ -d '{"name":"Contract Review Agent","base":"claude-code", "plugins":[{"files":[ @@ -738,6 +740,14 @@ curl -s -X PUT "$HARNESSROUTER_BASE_URL/v1/harnesses/$HARNESS_ID" \ ]}]}' ``` +To add a plugin to a harness that already exists, remember that `PUT /v1/harnesses/{id}` replaces +the whole mutable configuration: any field you leave out is cleared, and an omitted `mcp_servers` +also removes the hosted tools behind it. Read the harness first, then send back its current +`system_prompt`, `default_model`, `mcp_servers`, `skills`, `disabled_tools`, `additional_headers`, +`max_step` and `timeout_seconds` (the write body uses these snake_case names; the record you read +uses camelCase) with the new package appended to `plugins`. Installed plugins round-trip as +`{name, enabled, blob}`, so send those back unchanged and add the new one beside them. + The record you read back carries what the server derived from the package (`manifest`, `mcpServers`, `skills`, and `skipped` for anything it could not load) and a `blob` handle that round-trips on the next PUT. The harness's own `mcpServers` and `skills` stay exactly what you wrote diff --git a/gateway/app.py b/gateway/app.py index 07aa3d99..41153ec7 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -3019,7 +3019,7 @@ def _arr(prop): for pe in _plugins_of(v): if not pe["enabled"]: continue - pfiles = await _plugin_files_of(pe) + pfiles = await _plugin_files_of(pe, org) if not pfiles: print(f"[plugins] '{pe['name']}' on {harness_id}: package missing from the blob store — not offered this turn", flush=True) continue @@ -3030,6 +3030,9 @@ def _arr(prop): if isinstance(f, dict) and str(f.get("path") or "").startswith(prefix)] if sfiles: seen.add(sk["name"]) + # A same-named own entry with enabled:false marks a built-in as off; the plugin's + # skill is the one that is on, so it must not be filtered out under that name. + suppressed = [n for n in suppressed if n != sk["name"]] skills_out.append({"name": sk["name"], "files": sfiles, "plugin": pe["name"]}) for s in pe["mcpServers"]: if s.get("transport") == "stdio": @@ -12864,7 +12867,10 @@ def _kit_plugin(kit: dict) -> dict | None: is no second list of them to keep in step. None when the kit ships no package.""" sub = str((kit.get("harness") or {}).get("plugin") or "") root = pathlib.Path(_KITS_DIR) / str(kit.get("id") or "") / sub - if not sub or not (root / "plugin.json").is_file(): + if not sub: + return None + if not (root / "plugin.json").is_file(): + print(f"[kits] {kit.get('id')}: harness.plugin names {sub}/ but there is no plugin.json in it", flush=True) return None files: list[dict] = [] for f in sorted(root.rglob("*")): @@ -12879,6 +12885,18 @@ def _kit_plugin(kit: dict) -> dict | None: return {"files": files, "enabled": True} +def _kit_skill_names(kit: dict) -> list[str]: + """What launching this kit installs, for the catalog: the package's skills when it ships + one, else the names kit.json lists.""" + pkg = _kit_plugin(kit) + if pkg: + try: + return [s["name"] for s in _plugin_read_package(pkg["files"])["skills"]] + except HTTPException: + return [] + return [str(n) for n in ((kit.get("harness") or {}).get("skills") or [])] + + def _kit_skills(kit: dict) -> list[dict]: """A kit's own skills, read from its folder, for a kit built before kits shipped a plugin package (harness.skills names them). Self-contained by design: a kit carries the skills its @@ -13448,6 +13466,24 @@ async def _skill_bundle_files(sk: dict) -> list: _PLUGIN_PACKAGE_MAX = 16 * 1024 * 1024 # the JSON-encoded package; a plugin is code and prose, not data _PLUGIN_MANIFEST_FIELDS = {"$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "extensions"} +_PLUGIN_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_PLUGIN_BLOB_RE = re.compile(r"^plg_[0-9a-f]{32}$") +# The derived object rides on the harness record beside the blob handle; the record has a hard +# per-property cap on the graph backing, so the manifest and component metadata are bounded here +# rather than truncated there. +_PLUGIN_DERIVED_MAX = 24_000 + + +def _plugin_blob_key(org: str, blob: str) -> str: + """Packages are stored under the org that installed them: a handle is unguessable, but a + handle that leaked would otherwise read another org's package back through a PUT.""" + return f"plugins/{org}/{blob}.json" + + +def _skill_key(name: str) -> str: + """The runner's skill directory name (its _skill_dir_name), so two skills the gateway sees + as different cannot land in one folder.""" + return re.sub(r"[^A-Za-z0-9_-]+", "-", str(name or "")) # Runner backends that can launch a stdio MCP server (each writer emits command/args). pi's MCP # adapter and dsh take URLs only; a package that needs a process is refused for those bases rather # than accepted and skipped in a turn, which Harnesses §4.1 forbids. @@ -13464,16 +13500,38 @@ def _plugin_path_ok(path, name: str) -> str: raise _plugin_invalid(name, str(path), "file paths are relative to the plugin root") if any(seg in ("", ".", "..") for seg in path.split("/")): raise _plugin_invalid(name, path, "the path escapes the plugin root") + if any(ord(c) < 32 or c == "\x7f" for c in path) or "\\" in path: + raise _plugin_invalid(name, path, "the path contains a control character or a backslash") return path def _plugin_frontmatter(text: str) -> dict: + """The top-level scalars of a SKILL.md frontmatter: plain values, quoted values, trailing + comments dropped, and `>` / `|` block scalars joined, which is what the Agent Skills fields + (name, description) are written with in practice.""" m = re.match(r"^---\r?\n(.*?)\r?\n---", text or "", flags=re.S) out: dict = {} + key, block = None, None for line in (m.group(1) if m else "").splitlines(): - if ":" in line and not line.startswith((" ", "\t")): - k, v = line.split(":", 1) - out[k.strip()] = v.strip().strip("'\"") + if line.startswith((" ", "\t")): + if key and block is not None: + block.append(line.strip()) + continue + if key and block is not None: + out[key] = " ".join(block).strip() + block = None + key = None + if ":" not in line: + continue + k, v = line.split(":", 1) + key, v = k.strip(), v.strip() + v = re.sub(r"\s+#.*$", "", v) + if v in (">", "|", ">-", "|-", ">+", "|+"): + block = [] + continue + out[key] = v.strip("'\"") + if key and block is not None: + out[key] = " ".join(block).strip() return out @@ -13545,6 +13603,8 @@ def _plugin_server(sname: str, cfg) -> tuple[dict | None, str | None]: env = cfg["env"] if not isinstance(env, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()): return None, "env must be an object of strings" + if not all(_PLUGIN_ENV_NAME_RE.match(k) for k in env): + return None, "env names must be environment variable names (letters, digits, underscore)" if any(k.upper() in ("PLUGIN_ROOT", "PLUGIN_DATA") for k in env): return None, "env must not name PLUGIN_ROOT or PLUGIN_DATA; the server sets those" s["env"] = dict(env) @@ -13585,8 +13645,20 @@ def _plugin_read_package(files: list, sent_name: str | None = None) -> dict: if not isinstance(f, dict): raise _plugin_invalid(sent_name or "", "", "files must be objects with a path") path = _plugin_path_ok(f.get("path"), sent_name or "") - if f.get("content") is None and f.get("content_b64") is None: + content, b64 = f.get("content"), f.get("content_b64") + if content is None and b64 is None: raise _plugin_invalid(sent_name or "", path, "a file needs content or content_b64") + if content is not None and b64 is not None: + raise _plugin_invalid(sent_name or "", path, "a file carries content or content_b64, not both") + if content is not None and not isinstance(content, str): + raise _plugin_invalid(sent_name or "", path, "content must be a string") + if b64 is not None: + try: + base64.b64decode(b64, validate=True) + except Exception: # noqa: BLE001 + raise _plugin_invalid(sent_name or "", path, "content_b64 is not base64") + if path in by_path: + raise _plugin_invalid(sent_name or "", path, "the same path appears twice") by_path[path] = f skipped: list[dict] = [] @@ -13600,6 +13672,9 @@ def _plugin_read_package(files: list, sent_name: str | None = None) -> dict: raise _plugin_invalid(sent_name or "", "plugin.json", "plugin.json is not valid JSON") if not isinstance(manifest, dict): raise _plugin_invalid(sent_name or "", "plugin.json", "plugin.json is not an object") + if "$schema" not in manifest: + raise _plugin_invalid(str(manifest.get("name") or sent_name or ""), "plugin.json#/$schema", + "the manifest names no $schema") if manifest.get("$schema") not in UHP_PLUGIN_SCHEMAS: raise uhp_error(422, "unsupported_plugin_schema", "The manifest targets an Agent Plugins version this server does not install.", @@ -13687,10 +13762,10 @@ def _plugins_of(v: dict | None) -> list[dict]: return out -async def _plugin_files_of(entry: dict) -> list: +async def _plugin_files_of(entry: dict, org: str) -> list: files = entry.get("files") - if not files and entry.get("blob"): - raw = await _blob_get(f"plugins/{entry['blob']}.json", kb=BLOB_KB) + if not files and entry.get("blob") and _PLUGIN_BLOB_RE.match(str(entry["blob"])): + raw = await _blob_get(_plugin_blob_key(org, str(entry["blob"])), kb=BLOB_KB) if raw: try: files = json.loads(raw.decode()) @@ -13699,16 +13774,22 @@ async def _plugin_files_of(entry: dict) -> list: return files if isinstance(files, list) else [] -def _plugin_collisions(plugins: list[dict], mcp_servers: list | None, skills: list | None) -> None: - """Plugins §4.2: one namespace per component type across the harness and its enabled plugins.""" +def _plugin_collisions(plugins: list[dict], mcp_servers: list | None, skills: list | None, + reserved_mcp: tuple[str, ...] = ()) -> None: + """Plugins §4.2: one namespace per component type across the harness and its enabled plugins. + Names are compared the way the runner keys them (its MCP and skill directory sanitisers), so + two names the protocol sees as different cannot land on one file. `reserved_mcp` names the + servers this deployment attaches to a harness itself, such as a kit's hosted database.""" servers: dict[str, str] = {} for s in mcp_servers or []: if isinstance(s, dict) and str(s.get("enabled")) not in ("False", "false", "0"): servers[_mcp_name(str(s.get("name") or "mcp"))] = "harness" + for n in reserved_mcp: + servers[_mcp_name(str(n))] = "harness" skill_owner: dict[str, str] = {} for sk in skills or []: if isinstance(sk, dict) and str(sk.get("enabled")) not in ("False", "false", "0"): - skill_owner[str(sk.get("name") or sk.get("id") or "")] = "harness" + skill_owner[_skill_key(str(sk.get("name") or sk.get("id") or ""))] = "harness" for pe in plugins: if not pe.get("enabled", True): continue @@ -13721,33 +13802,46 @@ def _plugin_collisions(plugins: list[dict], mcp_servers: list | None, skills: li "between": [servers[key], pe["name"]]}) servers[key] = pe["name"] for sk in pe.get("skills") or []: - if sk["name"] in skill_owner: + key = _skill_key(sk["name"]) + if key in skill_owner: raise uhp_error(409, "plugin_conflict", - f"skill '{sk['name']}' exists on both {skill_owner[sk['name']]} and plugin {pe['name']}.", + f"skill '{sk['name']}' exists on both {skill_owner[key]} and plugin {pe['name']}.", "plugins", {"component": "skill", "name": sk["name"], - "between": [skill_owner[sk["name"]], pe["name"]]}) - skill_owner[sk["name"]] = pe["name"] + "between": [skill_owner[key], pe["name"]]}) + skill_owner[key] = pe["name"] -async def _plugins_prepare(body: HarnessBody) -> list[dict]: +async def _plugins_prepare(body: HarnessBody, org: str, previous: list[dict] | None = None, + reserved_mcp: tuple[str, ...] = ()) -> list[dict]: """Validate and store every package on a create or update, and derive what the harness records. - A refused package leaves the harness unchanged (the caller writes nothing until this returns). - Files always go to the blob store: a package is bigger than the vertex prop cap by construction, - and one storage path is one fewer to get wrong. What the vertex carries is the derived object - and the blob handle, which is exactly what a client reads back and PUTs again.""" - backend = str(_BASE_CATALOG.get(body.base or "", {}).get("backend") or "") - out: list[dict] = [] - seen: dict[str, bool] = {} + Everything is checked before anything is stored, so a refused write leaves no orphan package + behind and the harness unchanged (the caller writes nothing until this returns). Files always + go to the blob store under this org: a package is bigger than the vertex prop cap by + construction, and one storage path is one fewer to get wrong. What the vertex carries is the + derived object and the blob handle, which is exactly what a client reads back and PUTs again. + `previous` is what the harness held before an update; packages the new list no longer refers to + are deleted once the new list is validated.""" + base = _require_supported_base(body.base) if body.base else "" + body.base = base or body.base + backend = str(_BASE_CATALOG.get(base, {}).get("backend") or "") + staged: list[tuple[dict, str | None, str | None]] = [] # (entry, encoded files to store, kept blob) + seen: dict[str, str] = {} for i, item in enumerate(body.plugins or []): if not isinstance(item, dict): raise uhp_error(400, "invalid_input", f"plugins[{i}]: must be an object.", "plugins") - sent = item.get("name") if isinstance(item.get("name"), str) else None + sent = None + if "name" in item: + if not isinstance(item["name"], str) or not item["name"]: + raise _plugin_invalid("", "plugin.json#/name", "name must be a non-empty string when sent") + sent = item["name"] files, blob = item.get("files"), item.get("blob") if files: blob = None # a fresh package supersedes whatever the handle held elif blob: - files = await _plugin_files_of({"blob": blob}) + if not isinstance(blob, str) or not _PLUGIN_BLOB_RE.match(blob): + raise _plugin_invalid(sent or "", "", "the blob handle is not one this server issued") + files = await _plugin_files_of({"blob": blob}, org) if not files: raise _plugin_invalid(sent or "", "", "the package this handle refers to is gone; send its files") else: @@ -13758,27 +13852,58 @@ async def _plugins_prepare(body: HarnessBody) -> list[dict]: "plugins", {"max_bytes": _PLUGIN_PACKAGE_MAX}) derived = _plugin_read_package(files, sent) name = derived["name"] - if name in seen: - raise uhp_error(409, "plugin_conflict", f"Two plugins are named '{name}'.", "plugins", - {"component": "plugin", "name": name, "between": [name, name]}) - seen[name] = True + key = _mcp_name(name) + if name in seen or key in seen.values(): + other = name if name in seen else next(n for n, k in seen.items() if k == key) + raise uhp_error(409, "plugin_conflict", f"Plugins '{other}' and '{name}' would share one name.", "plugins", + {"component": "plugin", "name": name, "between": [other, name]}) + seen[name] = key enabled = item.get("enabled", True) is not False if enabled and backend and backend not in _STDIO_MCP_BACKENDS \ and any(s.get("transport") == "stdio" for s in derived["mcpServers"]): raise uhp_error(422, "unsupported_transport", - f"Plugin {name} declares a stdio MCP server, which the {body.base} base cannot run.", - "plugins", {"transport": "stdio", "base": body.base, "plugin": name}) - if not blob: + f"Plugin {name} declares a stdio MCP server, which the {base} base cannot run.", + "plugins", {"transport": "stdio", "base": base, "plugin": name}) + # A remote server this deployment's network policy would refuse at turn time is refused + # here instead, and written down: the turn would otherwise drop it with only a log line. + kept: list[dict] = [] + for s in derived["mcpServers"]: + blocked = await _ssrf_check(str(s.get("url") or "")) if s.get("transport") != "stdio" else "" + if blocked: + derived["skipped"].append({"path": f"mcp.json#/mcpServers/{s['name']}", + "reason": f"refused by this deployment's network policy: {blocked}"}) + else: + kept.append(s) + derived["mcpServers"] = kept + entry = {"name": name, "enabled": enabled, "manifest": derived["manifest"], + "mcpServers": derived["mcpServers"], "skills": derived["skills"], + "skipped": derived["skipped"]} + if len(json.dumps(entry)) > _PLUGIN_DERIVED_MAX: + raise _plugin_invalid(name, "plugin.json", "the manifest and component metadata exceed 24 KB") + staged.append((entry, None if blob else encoded, blob)) + out = [e for e, _, _ in staged] + _plugin_collisions(out, body.mcp_servers, body.skills, reserved_mcp) + # Every check passed: store the fresh packages, then let go of the ones this write dropped. + for entry, encoded, blob in staged: + if encoded is not None: blob = _rid("plg") - if not await _blob_put(f"plugins/{blob}.json", encoded.encode(), kb=BLOB_KB): - raise uhp_error(502, "internal_error", f"Plugin {name}: the package could not be stored.", "plugins") - out.append({"name": name, "enabled": enabled, "blob": blob, "manifest": derived["manifest"], - "mcpServers": derived["mcpServers"], "skills": derived["skills"], - "skipped": derived["skipped"]}) - _plugin_collisions(out, body.mcp_servers, body.skills) + if not await _blob_put(_plugin_blob_key(org, blob), encoded.encode(), kb=BLOB_KB): + raise uhp_error(502, "internal_error", f"Plugin {entry['name']}: the package could not be stored.", "plugins") + entry["blob"] = blob + kept_blobs = {e["blob"] for e in out} + for pe in previous or []: + if pe.get("blob") and pe["blob"] not in kept_blobs: + await _blob_delete(_plugin_blob_key(org, str(pe["blob"])), kb=BLOB_KB) return out +async def _plugins_discard(org: str, v: dict | None) -> None: + """Delete the packages of a harness being deleted.""" + for pe in _plugins_of(v): + if pe.get("blob"): + await _blob_delete(_plugin_blob_key(org, str(pe["blob"])), kb=BLOB_KB) + + def _plugin_export_name(v: dict) -> str: """Plugins §5: the harness name as a plugin name, or the id when nothing usable remains.""" def derive(s: str) -> str: @@ -13803,8 +13928,18 @@ async def _harness_export_plugin(v: dict) -> dict: if str(s.get("enabled")) in ("False", "false", "0") or not s.get("url"): continue sname = str(s.get("name") or s.get("id") or "mcp") + url = str(s["url"]) + try: + u = urllib.parse.urlsplit(url) + if u.username or u.password: + url = urllib.parse.urlunsplit((u.scheme, u.hostname + (f":{u.port}" if u.port else ""), + u.path, u.query, "")) + skipped.append({"path": f"mcp.json#/mcpServers/{sname}/url", + "reason": "credentials in the url are not exported"}) + except Exception: # noqa: BLE001 + pass entries[sname] = {"type": "sse" if (s.get("transport") or "").lower() == "sse" else "streamable-http", - "url": s["url"]} + "url": url} for k in ("auth", "headers"): if s.get(k): skipped.append({"path": f"mcp.json#/mcpServers/{sname}/{k}", @@ -13823,8 +13958,16 @@ async def _harness_export_plugin(v: dict) -> dict: if not sname: continue for f in await _skill_bundle_files(sk): - if isinstance(f, dict) and f.get("path"): - files.append({**f, "path": f"skills/{sname}/{f['path']}"}) + if not isinstance(f, dict) or not isinstance(f.get("path"), str): + continue + rel = f["path"] + ok = rel and not rel.startswith("/") and not any(seg in ("", ".", "..") for seg in rel.split("/")) \ + and ((isinstance(f.get("content"), str)) != (f.get("content_b64") is not None)) + if not ok: + skipped.append({"path": f"skills/{sname}/{rel}", "reason": "not a file a package can carry"}) + continue + files.append({"path": f"skills/{sname}/{rel}", + **({"content": f["content"]} if isinstance(f.get("content"), str) else {"content_b64": f["content_b64"]})}) derived = _plugin_read_package(files) return {**derived, "enabled": True, "skipped": derived["skipped"] + skipped, "files": files} @@ -13852,7 +13995,7 @@ async def create_harness(org: str, body: HarnessBody, request: Request) -> dict: workspace = request.headers.get("x-harness-workspace", "") body.mcp_servers = _mcp_servers_prepare(body.mcp_servers) body.skills = await _skills_prepare(body.skills) - body.plugins = await _plugins_prepare(body) + body.plugins = await _plugins_prepare(body, org) hid = _rid("chrn") now = str(int(time.time() * 1000)) props = {"org": org, "member": member, "workspace": workspace, **_harness_props(body), @@ -13931,7 +14074,10 @@ async def get_harness_plugin_files(org: str, hid: str, name: str, request: Reque if not v or v.get("org") != org or str(v.get("deleted")) in ("1", "true", "True"): raise uhp_error(404, "harness_not_found", "No harness with that id.", "harness_id") pe = _plugin_entry(v, name) - return {"name": pe["name"], "files": await _plugin_files_of(pe)} + files = await _plugin_files_of(pe, org) + if not files: + raise uhp_error(500, "internal_error", f"The stored package of plugin '{name}' is missing.", "plugin_name") + return {"name": pe["name"], "files": files} @app.get("/v1/orgs/{org}/harnesses/{hid}/plugin") @@ -13952,10 +14098,18 @@ async def update_harness(org: str, hid: str, body: HarnessBody, request: Request if not cur or str(cur.get("org") or "") != org or str(cur.get("deleted")) in ("1", "true", "True"): raise uhp_error(404, "harness_not_found", "No harness with that id.", "harness_id") cur = await _mcp_migrate(org, hid, cur) + # Harnesses §5.2: id, base and createdAt are immutable. A body naming a different base is + # refused rather than applied, because applying it would change the behaviour of every + # session already attached to this harness. + if body.base and body.base != str(cur.get("base") or ""): + raise uhp_error(409, "harness_mismatch", + "A harness's base cannot change; create a different harness for a different base.", + "base", {"base": cur.get("base")}) + body.base = str(cur.get("base") or body.base) # coalesce-upsert: created_at/org are untouched (only the provided props are set) body.mcp_servers = _mcp_servers_prepare(body.mcp_servers) body.skills = await _skills_prepare(body.skills) - body.plugins = await _plugins_prepare(body) + body.plugins = await _plugins_prepare(body, org, previous=_plugins_of(cur)) await _vg_upsert("Harness", hid, {**_harness_props(body), "updated_at": str(int(time.time() * 1000))}) # AFTER the write: _harness_props can still refuse this save (an unsupported base), and a # refused save that had already scrubbed a record would disconnect a database nobody removed. @@ -13977,6 +14131,7 @@ async def delete_harness(org: str, hid: str, request: Request) -> dict: # spending at a provider await _hosted_scrub_removed(org, hid, _mcp_list(cur), []) await _media_harness_purge(hid) + await _plugins_discard(org, cur) return {"id": hid, "deleted": True} @@ -14101,7 +14256,7 @@ async def create_harness_public(body: HarnessBody, request: Request) -> dict: raise uhp_error(401, "invalid_credential", "Missing or invalid API key.") body.mcp_servers = _mcp_servers_prepare(body.mcp_servers) body.skills = await _skills_prepare(body.skills) - body.plugins = await _plugins_prepare(body) + body.plugins = await _plugins_prepare(body, org) hid = _rid("chrn") now = str(int(time.time() * 1000)) props = {"org": org, "member": member, "workspace": str(p.get("workspace") or ""), @@ -14149,7 +14304,7 @@ async def list_kits(request: Request) -> dict: "harnessId": (h or {}).get("id") or None, # What the kit actually installs, from its own config — so the card can say # what you get instead of asking you to take it on trust. - "skills": [str(n) for n in ((kit.get("harness") or {}).get("skills") or [])], + "skills": _kit_skill_names(kit), # What a LAUNCHED kit is really running on. Not the same thing as the # recommendation: the person may have chosen differently at launch, or changed # it since. Showing the recommendation for a running kit would state something @@ -14279,7 +14434,10 @@ async def launch_kit(kit_id: str, request: Request, body_in: KitLaunchBody | Non disabled_tools=spec.get("disabled_tools") or []) body.mcp_servers = _mcp_servers_prepare(body.mcp_servers) body.skills = await _skills_prepare(body.skills) - body.plugins = await _plugins_prepare(body) + # The hosted entries launch attaches below (a kit's database, the media server) are names a + # plugin's own servers must not take, so they count as the harness's here. + body.plugins = await _plugins_prepare(body, org, reserved_mcp=tuple( + str(d["name"]) for d in (decl, media_decl) if d and d.get("name"))) hid = _rid("chrn") now = str(int(time.time() * 1000)) props = {"org": org, "member": member, "workspace": str(p.get("workspace") or ""), @@ -14429,11 +14587,37 @@ async def _cloud_me(base_url: str, api_key: str) -> dict: return me -async def _cloud_harness_body(org: str, hid: str, v: dict) -> dict: +async def _cloud_harness_body(org: str, hid: str, v: dict, plugins_ok: bool = False) -> dict: """What travels: the harness config, with every skill's files inlined (a bundle the local instance offloaded to a blob is a local detail; the hosted side gets the files). Never: - provider keys (not part of a harness), sessions, produced files, the local workspace.""" + provider keys (not part of a harness), sessions, produced files, the local workspace. + + Installed plugins travel whole, with their files, when the hosted side reports the plugins + capability. When it does not (a hosted side still on 2026-08-11), each enabled plugin travels + as what it contains, its skills and its remote servers on the harness's own lists, so the + hosted copy runs the same way; a stdio server has no place there and is left out.""" out = _harness_out(await _mcp_migrate(org, hid, v)) + plugins: list[dict] = [] + mcp_extra: list[dict] = [] + skills_extra: list[dict] = [] + for pe in _plugins_of(v): + pfiles = await _plugin_files_of(pe, org) + if not pfiles: + continue + if plugins_ok: + plugins.append({"name": pe["name"], "enabled": pe["enabled"], "files": pfiles}) + continue + if not pe["enabled"]: + continue + for sk in pe["skills"]: + prefix = f"skills/{sk['name']}/" + sfiles = [{**f, "path": f["path"][len(prefix):]} for f in pfiles + if isinstance(f, dict) and str(f.get("path") or "").startswith(prefix)] + if sfiles: + skills_extra.append({"name": sk["name"], "enabled": True, "files": sfiles}) + for s in pe["mcpServers"]: + if s.get("transport") != "stdio" and s.get("url"): + mcp_extra.append({k: s[k] for k in ("name", "url", "transport", "headers") if k in s}) skills = [] for sk in out.get("skills") or []: if not isinstance(sk, dict): @@ -14450,7 +14634,8 @@ async def _cloud_harness_body(org: str, hid: str, v: dict) -> dict: skills.append(sk) return {"name": out["name"], "base": out["base"], "base_label": out.get("baseLabel") or out["base"], "default_model": out.get("defaultModel") or None, "system_prompt": out.get("systemPrompt") or None, - "mcp_servers": out.get("mcpServers") or [], "skills": skills, + "mcp_servers": (out.get("mcpServers") or []) + mcp_extra, "skills": skills + skills_extra, + **({"plugins": plugins} if plugins_ok else {}), "disabled_tools": out.get("disabledTools") or [], "max_step": out.get("maxStep"), "timeout_seconds": out.get("timeoutSeconds"), "additional_headers": out.get("additionalHeaders") or [], "kit": out.get("kit") or None, @@ -14470,7 +14655,14 @@ async def _cloud_upload_one(org: str, hid: str, target: dict, records: dict) -> v = await _vertex_get(hid) if not v or str(v.get("org") or "") != org or str(v.get("deleted") or "") in ("1", "true"): return {"id": hid, "ok": False, "action": "skip", "error": "not found"} - body = await _cloud_harness_body(org, hid, v) + plugins_ok = False + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as c: + d = (await c.get(f"{target['base_url']}/v1/uhp")).json() + plugins_ok = bool((d.get("capabilities") or {}).get("plugins")) + except Exception: # noqa: BLE001 + plugins_ok = False + body = await _cloud_harness_body(org, hid, v, plugins_ok) fp = _cloud_fingerprint(body) per = records.setdefault("harnesses", {}).setdefault(hid, {}) tkey = _cloud_tkey(target) @@ -14833,7 +15025,10 @@ async def get_harness_plugin_files_public(hid: str, name: str, request: Request) if not v or v.get("org") != org or str(v.get("deleted")) in ("1", "true", "True"): raise uhp_error(404, "harness_not_found", "No harness with that id.", "harness_id") pe = _plugin_entry(v, name) - return {"name": pe["name"], "files": await _plugin_files_of(pe)} + files = await _plugin_files_of(pe, org) + if not files: + raise uhp_error(500, "internal_error", f"The stored package of plugin '{name}' is missing.", "plugin_name") + return {"name": pe["name"], "files": files} @app.get("/v1/harnesses/{hid}/plugin") @@ -14853,9 +15048,14 @@ async def update_harness_public(hid: str, body: HarnessBody, request: Request) - if not v or v.get("org") != org or str(v.get("deleted")) in ("1", "true", "True"): raise uhp_error(404, "harness_not_found", "No harness with that id.", "harness_id") v = await _mcp_migrate(org, hid, v) + if body.base and body.base != str(v.get("base") or ""): # Harnesses §5.2, as in update_harness + raise uhp_error(409, "harness_mismatch", + "A harness's base cannot change; create a different harness for a different base.", + "base", {"base": v.get("base")}) + body.base = str(v.get("base") or body.base) body.mcp_servers = _mcp_servers_prepare(body.mcp_servers) body.skills = await _skills_prepare(body.skills) - body.plugins = await _plugins_prepare(body) + body.plugins = await _plugins_prepare(body, org, previous=_plugins_of(v)) await _vg_upsert("Harness", hid, {**_harness_props(body), "updated_at": str(int(time.time() * 1000))}) # AFTER the write: see update_harness. await _hosted_scrub_removed(org, hid, _mcp_list(v), body.mcp_servers) @@ -14874,4 +15074,5 @@ async def delete_harness_public(hid: str, request: Request) -> dict: # spending at a provider await _hosted_scrub_removed(org, hid, _mcp_list(v), []) await _media_harness_purge(hid) + await _plugins_discard(org, v) return {"id": hid, "deleted": True} diff --git a/gateway/tests/test_plugins.py b/gateway/tests/test_plugins.py index 303e0cbc..01f4be1d 100644 --- a/gateway/tests/test_plugins.py +++ b/gateway/tests/test_plugins.py @@ -253,3 +253,13 @@ def test_a_kit_that_ships_a_package_launches_as_an_installed_plugin(api, tmp_pat assert sorted(f["path"] for f in files) == ["plugin.json", "skills/demo-skill/SKILL.md", "skills/demo-skill/helper.py"] again = api.post(f"/v1/kits/{kid}/launch", json={}) assert again.status_code == 200 and again.json()["created"] is False and again.json()["harnessId"] == h["id"] + + +def test_an_update_cannot_change_the_base(api): + """Harnesses §5.2: id, base and createdAt are immutable on update.""" + h = _create(api) + r = api.put(f"/v1/harnesses/{h['id']}", json={"name": "Moved", "base": "codex"}) + assert r.status_code == 409 and r.json()["error"]["code"] == "harness_mismatch" + assert api.get(f"/v1/harnesses/{h['id']}").json()["base"] == "claude-code" + r = api.put(f"/v1/harnesses/{h['id']}", json={"name": "Renamed", "base": "claude-code"}) + assert r.status_code == 200 and r.json()["name"] == "Renamed" diff --git a/runner/server.py b/runner/server.py index ce306696..fcc93510 100644 --- a/runner/server.py +++ b/runner/server.py @@ -609,7 +609,10 @@ def _codex_mcp_toml(servers: list[dict]) -> str: if not url and (s or {}).get("command"): # A stdio server (a plugin's, through its launcher): Codex's native shape. def _tq(x: str) -> str: - return '"' + str(x).replace("\\", "\\\\").replace('"', '\\"') + '"' + s2 = str(x).replace("\\", "\\\\").replace('"', '\\"') + s2 = s2.replace("\n", "\\n").replace("\t", "\\t").replace("\r", "\\r") + s2 = "".join(c if ord(c) >= 32 and c != "\x7f" else f"\\u{ord(c):04X}" for c in s2) + return '"' + s2 + '"' blocks.append("\n".join([f"[mcp_servers.{name}]", f"command = {_tq(s['command'])}", "args = [" + ", ".join(_tq(a) for a in (s.get("args") or [])) + "]"])) continue @@ -765,6 +768,29 @@ def _write_skills(cwd: str, skills: list[dict], backend: str = "claude") -> list if wrote: installed.append({"name": name, "desc": _skill_desc(files), "entry": f"{entryroot}/{name}/SKILL.md"}) + # Skills this runner wrote on an earlier turn of the session that this turn does not name + # (disabled, removed, or a plugin's that is now off) are removed, so the CLI cannot keep + # offering them. Only what this runner wrote is touched: the record of that is its own. + now_names = {s["name"] for s in installed} + ledger = _safe_join(cwd, ".harness/skills-written.json") + if ledger is not None: + before: list[str] = [] + try: + before = [str(n) for n in json.loads(ledger.read_text())] if ledger.is_file() else [] + except Exception: # noqa: BLE001 + before = [] + for old in before: + if old in now_names or not old: + continue + for rootrel in rootrels: + d = _safe_join(str(pathlib.Path(cwd) / rootrel), old) + if d is not None and d.is_dir() and not d.is_symlink(): + shutil.rmtree(d, ignore_errors=True) + try: + ledger.parent.mkdir(parents=True, exist_ok=True) + ledger.write_text(json.dumps(sorted(now_names))) + except Exception: # noqa: BLE001 + pass return installed @@ -798,6 +824,7 @@ def _write_plugins(cwd: str, plugins: list[dict]) -> list[str]: besides, and the claude branch hands that root to `--plugin-dir`: one root, never a second copy under .claude/, which would risk Claude Code loading the same plugin twice.""" installed: list[str] = [] + wanted: set[str] = set() for pg in plugins or []: pg = pg or {} name = _mcp_name(pg.get("name") or pg.get("id") or "") @@ -808,6 +835,21 @@ def _write_plugins(cwd: str, plugins: list[dict]) -> list[str]: data = _safe_join(cwd, f".harness/plugin-data/{name}") if root is None or data is None: continue + wanted.add(name) + # The runner writes as itself and the agent ran as the session's uid last turn: a root + # or data directory it replaced with a symlink would redirect these writes, so the link + # itself (the unresolved path) is removed before anything is written through it, and the + # root is rebuilt from the package every turn so nothing from an older version lingers. + for raw in (pathlib.Path(cwd) / ".harness" / "plugins" / name, + pathlib.Path(cwd) / ".harness" / "plugin-data" / name): + if raw.is_symlink(): + raw.unlink() + root = _safe_join(cwd, f".harness/plugins/{name}") + data = _safe_join(cwd, f".harness/plugin-data/{name}") + if root is None or data is None: + continue + if root.exists(): + shutil.rmtree(root, ignore_errors=True) data.mkdir(parents=True, exist_ok=True) wrote = False for f in files: @@ -822,17 +864,30 @@ def _write_plugins(cwd: str, plugins: list[dict]) -> list[str]: continue try: dest.parent.mkdir(parents=True, exist_ok=True) + # Written beside and renamed over: a binary a still-running server has open + # cannot be truncated in place (ETXTBSY), and a rename replaces it regardless. + tmp = dest.with_name(dest.name + ".hr-tmp") if content_b64 is not None: - dest.write_bytes(base64.b64decode(content_b64)) + tmp.write_bytes(base64.b64decode(content_b64)) else: - dest.write_text(content) + tmp.write_text(content) if _plugin_file_is_executable(rel, f.get("executable")): - dest.chmod(dest.stat().st_mode | 0o111) + tmp.chmod(tmp.stat().st_mode | 0o111) + os.replace(tmp, dest) wrote = True except Exception: # noqa: BLE001 continue if wrote: installed.append(str(root)) + # A plugin removed or disabled since the last turn leaves nothing runnable behind. + plugroot = _safe_join(cwd, ".harness/plugins") + if plugroot is not None and plugroot.is_dir(): + for d in plugroot.iterdir(): + if d.name not in wanted: + if d.is_symlink(): + d.unlink() + else: + shutil.rmtree(d, ignore_errors=True) return installed @@ -858,6 +913,9 @@ def _plugin_launchers(cwd: str, servers: list[dict] | None) -> list[dict]: out.append(s) continue dname = _mcp_name(str(pname)) + raw_data = pathlib.Path(cwd) / ".harness" / "plugin-data" / dname + if raw_data.is_symlink(): # never write the launcher through a planted link + raw_data.unlink() root = _safe_join(cwd, f".harness/plugins/{dname}") data = _safe_join(cwd, f".harness/plugin-data/{dname}") if root is None or data is None or not root.is_dir(): @@ -875,6 +933,13 @@ def expand(x) -> str: if target is None: print(f"[plugins] '{s.get('name')}': command escapes the plugin root — skipped", flush=True) continue + # A package's own script is what the entry says to run; JSON has no file mode, so + # the bit is set here rather than left to a naming convention. + try: + if target.is_file() and not target.is_symlink(): + target.chmod(target.stat().st_mode | 0o111) + except Exception: # noqa: BLE001 + pass command = str(target) else: command = cmd # a bare name, resolved on PATH at exec @@ -906,7 +971,13 @@ def expand(x) -> str: f"export PLUGIN_DATA={shlex.quote(str(data))}", f"mkdir -p {shlex.quote(str(wd))} && cd {shlex.quote(str(wd))} || exit 1", f'exec {shlex.quote(command)} "$@"', ""] - launcher.write_text("\n".join(lines)) + # Never through a link the session may have planted where the launcher goes: the data + # directory is the agent's to write, and the runner writes here as itself. + if launcher.is_symlink() or (launcher.exists() and not launcher.is_file()): + launcher.unlink() + fd = os.open(str(launcher), os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0), 0o755) + with os.fdopen(fd, "w") as fh: + fh.write("\n".join(lines)) launcher.chmod(0o755) out.append({"name": s.get("name"), "command": str(launcher), "args": args, "plugin": pname}) return out @@ -5757,13 +5828,19 @@ def turn(req: TurnReq, identifier: str = "") -> dict: # image-mounted (there is no _mount_builtin_skills), and the gateway already drops suppress markers # from req.skills, so this filter is a parity guard: never write a skill whose name is suppressed. _skip = set(req.skills_suppressed or []) - installed_skills = _write_skills( - cwd, [s for s in (req.skills or []) if (s.get("name") or s.get("id")) not in _skip], backend, - ) # materialize enabled skills for this backend (minus suppressed built-ins) # Installed plugin packages, whole, at their roots; then every stdio server a plugin declares # becomes a launcher every backend writer below already knows how to hand to its CLI. plugin_roots = _write_plugins(cwd, req.plugins) req.mcp_servers = _plugin_launchers(cwd, req.mcp_servers) + # A package that is also a Claude Code plugin reaches the claude CLI whole through + # --plugin-dir, which loads its skills/ itself; folding those skills into the skills + # directory too would offer each one twice. + claude_native = {pathlib.Path(d).name for d in plugin_roots + if (pathlib.Path(d) / ".claude-plugin" / "plugin.json").is_file()} if backend == "claude" else set() + installed_skills = _write_skills( + cwd, [s for s in (req.skills or []) if (s.get("name") or s.get("id")) not in _skip + and _mcp_name(str(s.get("plugin") or "")) not in claude_native], backend, + ) # materialize enabled skills for this backend (minus suppressed built-ins) # Seed the agent's instruction file (AGENTS.md/CLAUDE.md) from the harness doc + installed skills. # The model's system prompt stays the CLI default; persistent instructions live in this file. # diff --git a/runner/tests/test_uhp_plugins.py b/runner/tests/test_uhp_plugins.py index 7e6cafd1..f938cce4 100644 --- a/runner/tests/test_uhp_plugins.py +++ b/runner/tests/test_uhp_plugins.py @@ -68,7 +68,8 @@ def test_the_reserved_names_cannot_be_overridden_from_env(tmp_path): rn._write_plugins(str(tmp_path), [PKG]) (out,) = rn._plugin_launchers(str(tmp_path), [_server(env={"PLUGIN_ROOT": "/tmp/evil", "plugin_data": "/x", "MODE": "ok"})]) text = pathlib.Path(out["command"]).read_text() - assert "/tmp/evil" not in text and "'/x'" not in text and "export MODE=ok" in text + assert "/tmp/evil" not in text and "plugin_data" not in text and "export MODE=ok" in text + assert text.count("export PLUGIN_ROOT=") == 1 and text.count("export PLUGIN_DATA=") == 1 def test_servers_that_are_not_a_plugins_pass_through(tmp_path): @@ -98,3 +99,36 @@ def test_every_url_only_writer_now_carries_a_stdio_entry(tmp_path): omp = json.loads((agent / "mcp.json").read_text())["mcpServers"] assert omp["probe"] == {"type": "stdio", "command": "/ws/.harness/plugin-data/demo/.launch-probe.sh", "args": ["--x", "1"]} assert omp["vault"]["type"] == "http" + + +def test_a_dot_command_is_made_runnable_and_control_chars_are_escaped_for_codex(tmp_path): + pkg = {"name": "demo-plugin", "files": [{"path": "plugin.json", "content": "{}"}, + {"path": "server.sh", "content": "#!/bin/sh\necho started\n"}]} + rn._write_plugins(str(tmp_path), [pkg]) + (out,) = rn._plugin_launchers(str(tmp_path), [_server(command="./server.sh", args=["a\nb"])]) + assert subprocess.run([out["command"]], capture_output=True, text=True, timeout=10).stdout.strip() == "started" + toml = rn._codex_mcp_toml([out]) + assert "\\n" in toml and "\n\n" not in toml.split("args = ")[1].split("]")[0] + + +def test_stale_plugin_roots_and_skills_are_removed_on_the_next_turn(tmp_path): + rn._write_plugins(str(tmp_path), [PKG, {"name": "other", "files": [{"path": "plugin.json", "content": "{}"}]}]) + assert (tmp_path / ".harness/plugins/other").is_dir() + rn._write_plugins(str(tmp_path), [PKG]) + assert not (tmp_path / ".harness/plugins/other").exists() and (tmp_path / ".harness/plugins/demo_plugin").is_dir() + skill = {"name": "risk", "files": [{"path": "SKILL.md", "content": "---\nname: risk\ndescription: x\n---\n"}]} + rn._write_skills(str(tmp_path), [skill], "claude") + assert (tmp_path / ".harness/home/.claude/skills/risk/SKILL.md").is_file() + rn._write_skills(str(tmp_path), [], "claude") + assert not (tmp_path / ".harness/home/.claude/skills/risk").exists() + + +def test_a_symlinked_data_directory_is_not_followed(tmp_path): + rn._write_plugins(str(tmp_path), [PKG]) + outside = tmp_path / "outside"; outside.mkdir() + data = tmp_path / ".harness/plugin-data/demo_plugin" + import shutil as _sh; _sh.rmtree(data); data.symlink_to(outside) + (out,) = rn._plugin_launchers(str(tmp_path), [_server()]) + assert not data.is_symlink() and data.is_dir(), "the planted link is replaced by a real directory" + assert pathlib.Path(out["command"]).parent.resolve() == data.resolve() + assert not list(outside.iterdir()), "nothing was written through the link" diff --git a/ui/src/app/revamp.css b/ui/src/app/revamp.css index a3d8c7e3..3a0df44c 100644 --- a/ui/src/app/revamp.css +++ b/ui/src/app/revamp.css @@ -2014,6 +2014,10 @@ button.creq-srcstat.on b { color: var(--accent); } derived from it (tools, Skills, what it did not load) ─────────────────────────────────── */ .section-actions-group{display:flex;gap:8px;flex-wrap:wrap;align-items:center} .plugin-row .capability-copy{min-width:0} +.plugin-row .capability-copy strong,.plugin-row .capability-copy span{overflow-wrap:anywhere} +.plugin-note{margin:8px 0 0;padding:9px 12px;border-radius:8px;font-size:13px;line-height:1.45;overflow-wrap:anywhere} +.plugin-note.is-info{background:var(--subtle,#f7f7f9);color:var(--muted,#6f6f78)} +.plugin-note.is-error{background:#fef2f2;color:#991b1b} .plugin-version{display:inline-block;width:auto;margin-left:8px;font:500 11px/1 var(--mono,ui-monospace,monospace);color:var(--muted,#6f6f78); border:1px solid var(--line,#e7e7eb);border-radius:6px;padding:2px 6px;vertical-align:middle} .plugin-facts{display:block;font-size:12px;color:var(--faint,#9696a0)} diff --git a/ui/src/components/ApiReference.tsx b/ui/src/components/ApiReference.tsx index d3773ecc..1944895c 100644 --- a/ui/src/components/ApiReference.tsx +++ b/ui/src/components/ApiReference.tsx @@ -295,6 +295,7 @@ curl "${mgmtBase}/sessions/{session_id}/files?changed=true" \\ The agent's instructions (written into its workspace as AGENTS.md / CLAUDE.md). Tool connections: {`{name, url, auth?, enabled}`}. For auth, pass a secrets ref (below) or a $headers.{Name} per-request reference. {`{name, files:[{path, content|content_b64}]}`}, a bundle MUST include a SKILL.md; invalid bundles are rejected with 400. Skills can be whole folders: path is the file's relative path inside the skill (nested layouts like guides/SKILL.md install as-is); the root SKILL.md needs YAML frontmatter (name, description). Bundles over ~48 KB are offloaded server-side and the saved agent returns that skill as {`{name, enabled, blob}`} with no files, normal; the folder still mounts into every run. On later updates pass such entries back unchanged; only include files when changing content. + Packages in the Agent Plugins format: {`{files:[{path, content|content_b64}], enabled?}`}, each with a plugin.json at its root, tools in mcp.json, Skills under skills/. The saved agent returns each as {`{name, enabled, blob, manifest, mcpServers, skills, skipped}`}; pass such entries back unchanged, and include files only for a new or changed package. Inherited/built-in tool names to disable. Header NAMES your product passes per request for app-level auth (see Additional headers above). Default agent step budget (default 400). diff --git a/ui/src/components/HarnessSettings.tsx b/ui/src/components/HarnessSettings.tsx index 5c53b64c..f2acf2cb 100644 --- a/ui/src/components/HarnessSettings.tsx +++ b/ui/src/components/HarnessSettings.tsx @@ -8,7 +8,7 @@ import { zipSync, strToU8 } from 'fflate'; import { SkelPage } from '@/components/Skel'; import { useRouter } from 'next/navigation'; import { - OOB, oobById, oobDefaultModel, oobModels, useModelCatalog, modelAvailable, modelAvailability, availabilityNote, useBases, getCustom, saveCustom, deleteCustom, createCustom, getSkillFiles, storeMcpSecret, exportPlugin, + OOB, oobById, oobDefaultModel, oobModels, useModelCatalog, modelAvailable, modelAvailability, availabilityNote, useBases, getCustom, saveCustom, deleteCustom, createCustom, getSkillFiles, storeMcpSecret, exportPlugin, pluginSchemas, type CustomHarness, type OobHarness, type HarnessPlugin, } from '@/lib/harness'; import { HarnessLogo } from '@/components/HarnessLogo'; @@ -51,17 +51,25 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: { }, [id]); // Draft of a NEW skill being created in the SkillEditor popup (name edited in the same popup). const [newSkill, setNewSkill] = useState<{ name: string } | null>(null); - // Plugins: which row is expanded, the folder picker, and the export in flight. - const [pluginOpen, setPluginOpen] = useState(null); + // Plugins: which row is expanded (by name, so removing another row cannot move it), the folder + // picker, the export in flight, and a notice that belongs to this section rather than to Save. + const [pluginOpen, setPluginOpen] = useState(null); const [pluginBusy, setPluginBusy] = useState(false); + const [pluginNote, setPluginNote] = useState<{ kind: 'error' | 'info'; text: string } | null>(null); const pluginDirRef = useRef(null); + // The gateway refuses a package over this size (its _PLUGIN_PACKAGE_MAX); refusing here first + // spares reading a 30 MB folder into the draft only to be told no on Save. + const PLUGIN_PACKAGE_MAX = 16 * 1024 * 1024; // A folder picked as a plugin package: every file under it, paths relative to the folder itself, // and the manifest read up front so the row can name what was picked before it is saved. The // server does the real validation on save and answers with what it derived and what it skipped. async function installPluginFolder(list: FileList | null) { if (!list || !list.length || !draft) return; - setPluginBusy(true); setErr(null); + setPluginBusy(true); setPluginNote(null); try { + let total = 0; + for (let i = 0; i < list.length; i++) total += list[i].size; + if (total > PLUGIN_PACKAGE_MAX) throw new Error(`That folder is ${fmtMb(total)}; a plugin package can be at most ${fmtMb(PLUGIN_PACKAGE_MAX)}.`); const files: NonNullable = []; for (let i = 0; i < list.length; i++) { const f = list[i]; @@ -72,20 +80,30 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: { } const manifestFile = files.find((f) => f.path === 'plugin.json'); if (!manifestFile || manifestFile.content === undefined) throw new Error('That folder has no plugin.json at its root, so it is not a plugin package.'); - let manifest: HarnessPlugin['manifest'] = {}; - try { manifest = JSON.parse(manifestFile.content) as HarnessPlugin['manifest']; } catch { throw new Error('plugin.json is not valid JSON.'); } + let manifest: (HarnessPlugin['manifest'] & { $schema?: string }) = {}; + try { manifest = JSON.parse(manifestFile.content) as typeof manifest; } catch { throw new Error('plugin.json is not valid JSON.'); } const name = String(manifest?.name || ''); if (!name) throw new Error('plugin.json names no plugin.'); if ((draft.plugins || []).some((p) => p.name === name)) throw new Error(`A plugin named ${name} is already installed on this Harness.`); + // The server says which package versions it installs; a package for another one is + // refused on Save, so say so now, while the folder is still in front of the person. + const schemas = await pluginSchemas(); + if (schemas.length && !schemas.includes(String(manifest.$schema || ''))) { + throw new Error(manifest.$schema + ? `This package targets ${manifest.$schema}, which this server does not install (it installs ${schemas.join(', ')}).` + : 'plugin.json names no $schema, which every package must.'); + } upd({ plugins: [...(draft.plugins || []), { name, enabled: true, files, manifest }] }); - } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } + setPluginNote({ kind: 'info', text: `${name} is added; it installs when you save the Harness.` }); + } catch (e) { setPluginNote({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } finally { setPluginBusy(false); if (pluginDirRef.current) pluginDirRef.current.value = ''; } } + const fmtMb = (n: number) => `${(n / 1048576).toFixed(n < 10485760 ? 1 : 0)} MB`; // The Harness's own tools and Skills as a package, zipped in the browser and handed to the // person as a file they can install anywhere that reads the Agent Plugins format. async function downloadPluginPackage() { if (!draft || pluginBusy) return; - setPluginBusy(true); setErr(null); + setPluginBusy(true); setPluginNote(null); try { const pkg = await exportPlugin(draft.id); const entries: Record = {}; @@ -99,7 +117,11 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: { a.href = URL.createObjectURL(blob); a.download = `${pkg.name || 'harness'}.zip`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); - } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } + const left = pkg.skipped || []; + setPluginNote({ kind: 'info', text: left.length + ? `Downloaded ${pkg.name}.zip. Not included, to re-enter where you install it: ${left.map((s) => s.path.replace(/^mcp\.json#\/mcpServers\//, '')).join(', ')}.` + : `Downloaded ${pkg.name}.zip.` }); + } catch (e) { setPluginNote({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } finally { setPluginBusy(false); } } const [cards, setCards] = useState([]); @@ -134,6 +156,14 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: { try { const s = await saveCustom(draft); setSaved(s); setDraft(s); + // A plugin the server installed with parts it could not load is only visible on this + // page; leaving now would hide it. Stay, open that row, and say so. + const partial = (s.plugins || []).filter((p) => (p.skipped || []).length); + if (partial.length) { + setPluginOpen(partial[0].name); + setPluginNote({ kind: 'info', text: `Saved. ${partial.map((p) => `${p.name}: ${(p.skipped || []).length} part${(p.skipped || []).length === 1 ? '' : 's'} not loaded`).join('; ')}. Open Details to see which.` }); + return; + } // After a successful save, jump to Tasks with THIS harness selected so the user can // immediately run a task on the config they just changed. go('tasks'); @@ -356,9 +386,11 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: {

Plugins

Install a package of tools and Skills in the Agent Plugins format. What it brings joins this Harness's own tools and Skills on every Task.

-
{(draft?.plugins || []).length} installed plugins +
{(draft?.plugins || []).length} installed {(draft?.plugins || []).length === 1 ? 'plugin' : 'plugins'} - {!readOnly && draft?.id && !oob && } {!readOnly && } @@ -366,12 +398,13 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: { void installPluginFolder(e.target.files)} {...({ webkitdirectory: '', directory: '' } as Record)} />
+ {pluginNote &&
{pluginNote.text}
}
{(draft?.plugins || []).map((p, idx) => { const servers = p.mcpServers || [], skls = p.skills || [], skipped = p.skipped || []; const pending = Boolean(p.files && p.files.length && !p.blob); const m = p.manifest || {}; - const open = pluginOpen === idx; + const open = pluginOpen === p.name; return (
@@ -398,7 +431,7 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: { )}
- {!pending && } + {!pending && } {!readOnly && }
)}
diff --git a/ui/src/lib/harness.ts b/ui/src/lib/harness.ts index 85497c59..8dc16fd4 100644 --- a/ui/src/lib/harness.ts +++ b/ui/src/lib/harness.ts @@ -389,6 +389,18 @@ export async function deleteCustom(id: string): Promise { /** Full files of one skill on a harness, resolving the server-side blob offload, used to * hydrate a folder skill for editing when the record only carries {name, enabled, blob}. */ +let _pluginSchemas: Promise | null = null; +/** The Agent Plugins manifest schemas this server installs, from its discovery document; empty + * when the server reports no plugin support (the Console then leaves the check to Save). */ +export function pluginSchemas(): Promise { + if (!_pluginSchemas) { + _pluginSchemas = gw<{ capabilities?: Record; plugin_schemas?: string[] }>('GET', '/v1/uhp') + .then((d) => (d.capabilities?.plugins ? (d.plugin_schemas || []) : [])) + .catch(() => []); + } + return _pluginSchemas; +} + /** The complete package of one installed plugin, byte for byte (UHP Plugins §3.1). */ export async function getPluginFiles(harnessId: string, name: string): Promise<{ path: string; content?: string; content_b64?: string }[]> { From 0e3618c0015325b53cae91173e01fecdfa482bca Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Sun, 13 Sep 2026 19:33:11 -0700 Subject: [PATCH 07/16] kits: launch installs the kit's package on a Harness launched before the kit shipped one Launch on an existing kit Harness handled the database tool and the media entry but never the package, so the three kit Harnesses on hr-oss-test launched before their kits carried packages stayed without one through a relaunch while a fresh launch installed it (2026-09-14). The existing path now installs the kit's current package, replaces an older version and leaves a matching one alone, the kit's loose skills giving way to the package's, as on a fresh launch. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Q96VFkSEJZdTydczxxJHMD --- gateway/app.py | 29 +++++++++++++++++++++++++++++ gateway/tests/test_plugins.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/gateway/app.py b/gateway/app.py index 41153ec7..a68dfe15 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -14347,6 +14347,28 @@ class KitLaunchBody(BaseModel): database: KitDatabaseBody | None = None +async def _kit_plugin_ensure(org: str, hid: str, v: dict, kit_plugin: dict, + reserved_mcp: tuple[str, ...] = ()) -> dict | None: + """The kit's current package on an existing kit Harness. Installed when the Harness holds no + package of that name, replaced when it holds another version, left alone when the versions + match; the kit's loose skills from before the package layout give way to the package's. Three + kit Harnesses launched before their kits shipped packages stayed without one through a + relaunch (hr-oss-test, 2026-09-14), while a fresh launch installed it.""" + want = _plugin_read_package(kit_plugin["files"]) + have = _plugins_of(v) + same = next((e for e in have if e["name"] == want["name"]), None) + if same and (same.get("manifest") or {}).get("version") == (want.get("manifest") or {}).get("version"): + return None + body = HarnessBody(name=str(v.get("name") or ""), base=str(v.get("base") or ""), + plugins=[e for e in have if e["name"] != want["name"]] + [kit_plugin]) + body.plugins = await _plugins_prepare(body, org, previous=have, reserved_mcp=reserved_mcp) + await _vg_upsert("Harness", hid, {"plugins": json.dumps(body.plugins), "skills": json.dumps([]), + "updated_at": str(int(time.time() * 1000))}) + print(f"[kits] {v.get('kit')}: package {want['name']} {(want.get('manifest') or {}).get('version')} " + f"{'replaced' if same else 'installed'} on {hid}", flush=True) + return await _vertex_get(hid) + + @app.post("/v1/kits/{kit_id}/launch") async def launch_kit(kit_id: str, request: Request, body_in: KitLaunchBody | None = None) -> dict: """Provision this kit's Harness, or hand back the one that already exists. @@ -14401,6 +14423,13 @@ async def launch_kit(kit_id: str, request: Request, body_in: KitLaunchBody | Non # one record, not two of each. await _hosted_media_attach(org, hid0, existing, media_decl) existing = await _vertex_get(hid0) or existing + kit_plugin = _kit_plugin(kit) + if kit_plugin: + # The package comes from the kit definition, like the database tool above: a Harness + # launched before the kit shipped one, or one holding an older version, gets the + # current package on the next press of Launch. Same version: nothing is rewritten. + existing = await _kit_plugin_ensure(org, hid0, existing, kit_plugin, tuple( + str(d["name"]) for d in (decl, media_decl) if d and d.get("name"))) or existing return {"kit": kit_id, "harnessId": hid0, "route": (kit.get("app") or {}).get("route") or "", "created": False, "harness": _harness_out(existing)} diff --git a/gateway/tests/test_plugins.py b/gateway/tests/test_plugins.py index 01f4be1d..ccb7cea1 100644 --- a/gateway/tests/test_plugins.py +++ b/gateway/tests/test_plugins.py @@ -255,6 +255,38 @@ def test_a_kit_that_ships_a_package_launches_as_an_installed_plugin(api, tmp_pat assert again.status_code == 200 and again.json()["created"] is False and again.json()["harnessId"] == h["id"] +def test_a_kit_harness_from_before_the_package_gets_it_on_the_next_launch(api, tmp_path, monkeypatch): + """Three kit Harnesses launched before their kits shipped packages stayed without one through a + relaunch (hr-oss-test, 2026-09-14). Launch on an existing Harness installs the kit's current + package, replaces an older version, and leaves a matching one alone.""" + kid = "demo-kit-before-packages" # its own kit id: the org keeps the previous test's kit Harness + plug = tmp_path / kid / "plugin" + (plug / "skills" / "demo-skill").mkdir(parents=True) + (plug / "skills" / "demo-skill" / "SKILL.md").write_text("---\nname: demo-skill\ndescription: fixture\n---\n") + monkeypatch.setattr(app, "_KITS_DIR", str(tmp_path)) + kit = {"id": kid, "title": "Demo", "app": {"route": "/kits/demo"}, + "harness": {"name": "Demo", "recommended": [{"base": "claude-code", "model": "claude-opus-5"}]}} + monkeypatch.setattr(app, "_kits", lambda: {kid: kit}) + first = api.post(f"/v1/kits/{kid}/launch", json={}).json() # the kit ships no package yet + assert first["created"] is True and first["harness"]["plugins"] == [] + hid = first["harnessId"] + (plug / "plugin.json").write_text(json.dumps({"$schema": MANIFEST_SCHEMA, "name": "harnessrouter-demo", + "version": "1.0.0", "description": "A kit."})) + kit["harness"]["plugin"] = "plugin" # the kit now ships one + again = api.post(f"/v1/kits/{kid}/launch", json={}).json() + assert again["created"] is False and again["harnessId"] == hid + (pl,) = again["harness"]["plugins"] + assert pl["name"] == "harnessrouter-demo" and pl["manifest"]["version"] == "1.0.0" + assert [s["name"] for s in pl["skills"]] == ["demo-skill"] and again["harness"]["skills"] == [] + blob = pl.get("blob") + same = api.post(f"/v1/kits/{kid}/launch", json={}).json()["harness"]["plugins"][0] + assert same.get("blob") == blob, "a matching version is left alone, not re-stored" + (plug / "plugin.json").write_text(json.dumps({"$schema": MANIFEST_SCHEMA, "name": "harnessrouter-demo", + "version": "1.1.0", "description": "A kit."})) + newer = api.post(f"/v1/kits/{kid}/launch", json={}).json()["harness"]["plugins"] + assert [p["manifest"]["version"] for p in newer] == ["1.1.0"] + + def test_an_update_cannot_change_the_base(api): """Harnesses §5.2: id, base and createdAt are immutable on update.""" h = _create(api) From ca572238025e48e618e0aa4c65865fa6f999fe1a Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Sun, 13 Sep 2026 21:43:29 -0700 Subject: [PATCH 08/16] kits: launch installs a kit package whose files changed under the same version The version check alone let a kit whose Skills changed without a bump (the 2026-09-14 Skill fix shipped as 1.0.0 twice) leave launched Harnesses on the old bytes. Launch now compares the stored package with the one on disk, path by path, and installs when they differ; a matching version and matching files stay untouched. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Q96VFkSEJZdTydczxxJHMD --- gateway/app.py | 18 +++++++++++++++++- gateway/tests/test_plugins.py | 6 ++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/gateway/app.py b/gateway/app.py index a68dfe15..a3d85785 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -14347,6 +14347,17 @@ class KitLaunchBody(BaseModel): database: KitDatabaseBody | None = None +def _plugin_files_key(files: list) -> list[tuple]: + """A package's files as a comparable value: path, bytes and the executable bit, in path order.""" + out = [] + for f in files or []: + if not isinstance(f, dict): + continue + body = f.get("content_b64") if f.get("content_b64") is not None else f.get("content") + out.append((str(f.get("path") or ""), str(body or ""), bool(f.get("executable")))) + return sorted(out) + + async def _kit_plugin_ensure(org: str, hid: str, v: dict, kit_plugin: dict, reserved_mcp: tuple[str, ...] = ()) -> dict | None: """The kit's current package on an existing kit Harness. Installed when the Harness holds no @@ -14358,7 +14369,12 @@ async def _kit_plugin_ensure(org: str, hid: str, v: dict, kit_plugin: dict, have = _plugins_of(v) same = next((e for e in have if e["name"] == want["name"]), None) if same and (same.get("manifest") or {}).get("version") == (want.get("manifest") or {}).get("version"): - return None + # The version matching is not the whole story: a kit whose files changed under the same + # version (the Skill fix of 2026-09-14 shipped as 1.0.0 twice) would otherwise never reach + # a launched Harness. Launch reflects the package on disk, byte for byte. + stored = await _plugin_files_of({"blob": same["blob"]}, org) if same.get("blob") else [] + if _plugin_files_key(stored) == _plugin_files_key(kit_plugin["files"]): + return None body = HarnessBody(name=str(v.get("name") or ""), base=str(v.get("base") or ""), plugins=[e for e in have if e["name"] != want["name"]] + [kit_plugin]) body.plugins = await _plugins_prepare(body, org, previous=have, reserved_mcp=reserved_mcp) diff --git a/gateway/tests/test_plugins.py b/gateway/tests/test_plugins.py index ccb7cea1..bb1a8a0b 100644 --- a/gateway/tests/test_plugins.py +++ b/gateway/tests/test_plugins.py @@ -285,6 +285,12 @@ def test_a_kit_harness_from_before_the_package_gets_it_on_the_next_launch(api, t "version": "1.1.0", "description": "A kit."})) newer = api.post(f"/v1/kits/{kid}/launch", json={}).json()["harness"]["plugins"] assert [p["manifest"]["version"] for p in newer] == ["1.1.0"] + # the same version with different bytes is a changed package too (the 2026-09-14 Skill fix) + (plug / "skills" / "demo-skill" / "SKILL.md").write_text("---\nname: demo-skill\ndescription: fixture, revised\n---\n") + revised = api.post(f"/v1/kits/{kid}/launch", json={}).json()["harness"]["plugins"][0] + assert revised.get("blob") != newer[0].get("blob"), "changed files under the same version are installed" + files = api.get(f"/v1/harnesses/{hid}/plugins/harnessrouter-demo/files").json()["files"] + assert any("revised" in (f.get("content") or "") for f in files) def test_an_update_cannot_change_the_base(api): From 90488e41f50d9c5d34744071f686d0ec432d2ac0 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Mon, 14 Sep 2026 13:34:42 -0700 Subject: [PATCH 09/16] console: the Plugins section reads like the Tools and Skills sections, and downloads the plugin The version rendered as a full-width bordered box (the row's block-span rule outranked the pill), the details were a wall of text beside floating actions, the header wrapped at 390, and "Download as plugin" exported the Harness's own tools and Skills, which for a kit Harness is an empty package. Each installed plugin now has its own Download (the package as installed, zipped in the browser), an inline version pill, a two-line description, a facts line and a details panel with Skills, tools, what was not loaded and links. The Harness export moved to a footer that says what it packages and appears only when the Harness has tools or Skills of its own. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Q96VFkSEJZdTydczxxJHMD --- ui/src/app/revamp.css | 40 +++++++---- ui/src/components/HarnessSettings.tsx | 97 ++++++++++++++++++--------- 2 files changed, 91 insertions(+), 46 deletions(-) diff --git a/ui/src/app/revamp.css b/ui/src/app/revamp.css index 3a0df44c..c99f0f7a 100644 --- a/ui/src/app/revamp.css +++ b/ui/src/app/revamp.css @@ -2010,23 +2010,37 @@ button.creq-srcstat.on b { color: var(--accent); } cause, because it used to name the wrong one for interrupted turns. */ .wbx-incomplete-why { font-size: 12.5px; color: var(--faint, #6e6760); margin: 4px 0 0 2px; } -/* ── Plugins on the Harness page: one row per installed package, expandable to what the server - derived from it (tools, Skills, what it did not load) ─────────────────────────────────── */ -.section-actions-group{display:flex;gap:8px;flex-wrap:wrap;align-items:center} +/* ── Plugins on the Harness page: one row per installed package, the same row grammar as the + Tools and Skills sections, expandable to what the server derived from it ─────────────── */ +.plugin-head{flex-wrap:wrap} .plugin-row .capability-copy{min-width:0} .plugin-row .capability-copy strong,.plugin-row .capability-copy span{overflow-wrap:anywhere} +.plugin-row.is-off .capability-copy{opacity:.62} +.plugin-title{display:flex;align-items:center;gap:8px;min-width:0} +.plugin-row .capability-copy .plugin-version{display:inline-flex;align-items:center;width:auto;margin:0;padding:1px 7px;border-radius:999px; + border:1px solid var(--line,#e7e7eb);background:rgba(255,255,255,.72);font:500 10.5px/1.6 var(--mono,ui-monospace,monospace);color:var(--muted,#6f6f78);white-space:nowrap} +.plugin-row .capability-copy .plugin-desc{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;color:var(--muted,#6f6f78);font-size:12px;line-height:1.45} +.plugin-row .capability-copy .plugin-facts{display:block;margin-top:4px;font-size:11.5px;color:var(--faint,#9696a0)} +.plugin-warn{font-style:normal;color:#b45309} +.plugin-details{grid-column:2 / -1;margin:6px 0 2px;padding:10px 12px;display:grid;gap:12px;border-radius:9px;background:rgba(255,255,255,.72);border:1px solid var(--line,#e7e7eb);font-size:12.5px;color:var(--muted,#6f6f78)} +.plugin-group b{display:block;margin-bottom:6px;font-size:10.5px;font-weight:660;letter-spacing:.06em;text-transform:uppercase;color:var(--faint,#9696a0)} +.plugin-group ul{margin:0;padding:0;list-style:none;display:grid;gap:6px} +.plugin-group li{display:grid;grid-template-columns:minmax(120px,max-content) minmax(0,1fr);gap:10px;align-items:baseline} +.plugin-group li code{font:500 12px/1.45 var(--mono,ui-monospace,monospace);color:var(--ink,#111317);word-break:break-all} +.plugin-group li span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:1.45} +.plugin-group.is-warn code{color:#b45309} .plugin-note{margin:8px 0 0;padding:9px 12px;border-radius:8px;font-size:13px;line-height:1.45;overflow-wrap:anywhere} .plugin-note.is-info{background:var(--subtle,#f7f7f9);color:var(--muted,#6f6f78)} .plugin-note.is-error{background:#fef2f2;color:#991b1b} -.plugin-version{display:inline-block;width:auto;margin-left:8px;font:500 11px/1 var(--mono,ui-monospace,monospace);color:var(--muted,#6f6f78); - border:1px solid var(--line,#e7e7eb);border-radius:6px;padding:2px 6px;vertical-align:middle} -.plugin-facts{display:block;font-size:12px;color:var(--faint,#9696a0)} -.plugin-warn{font-style:normal;color:#b45309} -.plugin-details{margin-top:10px;display:grid;gap:10px;font-size:12.5px;color:var(--muted,#6f6f78)} -.plugin-details b{display:block;margin-bottom:4px;font-size:11px;font-weight:660;letter-spacing:.04em;text-transform:uppercase;color:var(--faint,#9696a0)} -.plugin-details code{font:500 12px/1.4 var(--mono,ui-monospace,monospace);color:var(--ink,#111317);word-break:break-all} -.plugin-details div{overflow-wrap:anywhere} +.plugin-export{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;padding:10px 12px;border-radius:9px;border:1px dashed var(--line,#e7e7eb);font-size:12px;line-height:1.45;color:var(--muted,#6f6f78)} +.plugin-export span{min-width:0} @media (max-width:768px){ - .plugin-row{flex-wrap:wrap} - .plugin-row .capability-actions{width:100%;justify-content:flex-start;flex-wrap:wrap} + .plugin-row .capability-actions{grid-column:2;justify-content:flex-start;flex-wrap:wrap} + .plugin-details{grid-column:1 / -1} + .plugin-group li{grid-template-columns:1fr} + .plugin-export{flex-direction:column;align-items:flex-start} +} +@media (max-width:480px){ + .plugin-head{display:grid;grid-template-columns:1fr;gap:8px} + .plugin-head .button{width:100%;justify-content:center} } diff --git a/ui/src/components/HarnessSettings.tsx b/ui/src/components/HarnessSettings.tsx index f2acf2cb..d8c43d42 100644 --- a/ui/src/components/HarnessSettings.tsx +++ b/ui/src/components/HarnessSettings.tsx @@ -9,7 +9,7 @@ import { SkelPage } from '@/components/Skel'; import { useRouter } from 'next/navigation'; import { OOB, oobById, oobDefaultModel, oobModels, useModelCatalog, modelAvailable, modelAvailability, availabilityNote, useBases, getCustom, saveCustom, deleteCustom, createCustom, getSkillFiles, storeMcpSecret, exportPlugin, pluginSchemas, - type CustomHarness, type OobHarness, type HarnessPlugin, + type CustomHarness, type OobHarness, type HarnessPlugin, getPluginFiles, } from '@/lib/harness'; import { HarnessLogo } from '@/components/HarnessLogo'; import { CopyId } from '@/components/CopyId'; @@ -124,6 +124,28 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: { } catch (e) { setPluginNote({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } finally { setPluginBusy(false); } } + // One installed plugin, byte for byte as the server holds it, zipped in the browser. + async function downloadInstalledPlugin(p: HarnessPlugin) { + if (!draft?.id || pluginBusy) return; + setPluginBusy(true); setPluginNote(null); + try { + const files = await getPluginFiles(draft.id, p.name); + const entries: Record = {}; + for (const f of files) { + entries[f.path] = f.content_b64 !== undefined + ? Uint8Array.from(atob(f.content_b64), (c) => c.charCodeAt(0)) + : strToU8(f.content || ''); + } + const fname = `${p.name}${p.manifest?.version ? '-' + p.manifest.version : ''}.zip`; + const blob = new Blob([zipSync(entries) as BlobPart], { type: 'application/zip' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = fname; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + setPluginNote({ kind: 'info', text: `Downloaded ${fname}: ${files.length} ${files.length === 1 ? 'file' : 'files'}, the package as installed.` }); + } catch (e) { setPluginNote({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } + finally { setPluginBusy(false); } + } const [cards, setCards] = useState([]); useEffect(() => { @@ -384,17 +406,11 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: {
-

Plugins

Install a package of tools and Skills in the Agent Plugins format. What it brings joins this Harness's own tools and Skills on every Task.

+

Plugins

A plugin is a package of tools and Skills in the Agent Plugins format. What it brings joins this Harness's own tools and Skills on every Task.

-
{(draft?.plugins || []).length} installed {(draft?.plugins || []).length === 1 ? 'plugin' : 'plugins'} - - {!readOnly && draft?.id && !oob && } - {!readOnly && } - +
{(draft?.plugins || []).length} installed {(draft?.plugins || []).length === 1 ? 'plugin' : 'plugins'} + {!readOnly && } void installPluginFolder(e.target.files)} {...({ webkitdirectory: '', directory: '' } as Record)} />
@@ -405,38 +421,45 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: { const pending = Boolean(p.files && p.files.length && !p.blob); const m = p.manifest || {}; const open = pluginOpen === p.name; + const facts = pending + ? ['Installs when you save the Harness'] + : [`${skls.length} ${skls.length === 1 ? 'Skill' : 'Skills'}`, `${servers.length} ${servers.length === 1 ? 'tool' : 'tools'}`, + ...(m.author?.name ? [`by ${m.author.name}`] : []), ...(m.license ? [m.license] : [])]; return ( -
+
- {p.name}{m.version ? {m.version} : null} - {m.description || 'Agent Plugins package'} - - {pending ? 'Saves with the Harness' : `${servers.length} ${servers.length === 1 ? 'tool' : 'tools'} · ${skls.length} ${skls.length === 1 ? 'Skill' : 'Skills'}`} - {!pending && skipped.length > 0 && · {skipped.length} not loaded} - - {open && !pending && ( -
- {servers.length > 0 &&
Tools{servers.map((s) =>
{s.name} {s.transport === 'stdio' ? `runs ${s.command}` : s.url}
)}
} - {skls.length > 0 &&
Skills{skls.map((s) =>
{s.name} {s.description}
)}
} - {skipped.length > 0 &&
Not loaded{skipped.map((s) =>
{s.path} {s.reason}
)}
} - {(m.author?.name || m.homepage || m.repository || m.license) && ( -
About
- {m.author?.name ? `By ${m.author.name}. ` : ''}{m.license ? `${m.license}. ` : ''} - {m.homepage && Homepage}{m.homepage && m.repository ? ' · ' : ''} - {m.repository && Source} -
- )} -
- )} + {p.name}{m.version ? v{m.version} : null} + {m.description || 'Agent Plugins package'} + {facts.join(' \u00b7 ')} + {!pending && skipped.length > 0 && {' \u00b7 '}{skipped.length} not loaded}
- {!pending && } + {!pending && } + {!pending && draft?.id && } {!readOnly && }
+ {open && !pending && ( +
+ {skls.length > 0 &&
Skills +
    {skls.map((s) =>
  • {s.name}{s.description ? {s.description} : null}
  • )}
} + {servers.length > 0 &&
Tools +
    {servers.map((s) =>
  • {s.name}{s.transport === 'stdio' ? `runs ${s.command}${(s.args || []).length ? ' ' + (s.args || []).join(' ') : ''}` : s.url}
  • )}
} + {skipped.length > 0 &&
Not loaded +
    {skipped.map((s) =>
  • {s.path}{s.reason}
  • )}
} + {(m.homepage || m.repository || (m.keywords || []).length > 0) && ( +
About +
    {m.homepage &&
  • Homepage
  • } + {m.repository &&
  • Source
  • } + {(m.keywords || []).length > 0 &&
  • {(m.keywords || []).join(', ')}
  • }
+ )} +
+ )}
); })} @@ -450,6 +473,14 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: {
)}
+ {!readOnly && draft?.id && !oob && (ownSkills.length + (draft?.mcpServers || []).length) > 0 && ( +
+ This Harness's own {ownSkills.length} {ownSkills.length === 1 ? 'Skill' : 'Skills'} and {(draft?.mcpServers || []).length} {(draft?.mcpServers || []).length === 1 ? 'tool' : 'tools'} can travel as a package too. Installed plugins are downloaded from their own rows. + +
+ )}
From fdad224e4cf1093ce8d770b3388a6a473483a006 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Mon, 14 Sep 2026 13:40:50 -0700 Subject: [PATCH 10/16] kits: launch can run a kit on a Harness you already have `POST /v1/kits/{kit}/launch {"harness": }` binds the kit to that Harness: the kit's app then talks to it, it receives the kit's package and launch entries, and the previous kit Harness keeps its sessions and package but is no longer the one the app finds. A Harness running another kit is not taken over (409 kit_conflict). This is how a package migrated to another Harness takes the kit with it, which the plugin round trip needs to be a closed loop. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Q96VFkSEJZdTydczxxJHMD --- gateway/app.py | 21 +++++++++++++++++++++ gateway/tests/test_plugins.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/gateway/app.py b/gateway/app.py index a3d85785..ab9a05c9 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -14345,6 +14345,10 @@ class KitLaunchBody(BaseModel): base: str = "" model: str = "" database: KitDatabaseBody | None = None + # An existing Harness of yours to run the kit on, instead of the one launch made or would + # make: the kit's app then talks to that Harness, which receives the kit's package and its + # launch entries. How a package migrated to another Harness takes the kit with it. + harness: str = "" def _plugin_files_key(files: list) -> list[tuple]: @@ -14413,6 +14417,23 @@ async def launch_kit(kit_id: str, request: Request, body_in: KitLaunchBody | Non existing = next((r for r in await _vg_list_by_org("Harness", org) if str(r.get("kit") or "") == kit_id and str(r.get("deleted") or "0") != "1"), None) + want_h = (body_in.harness if body_in else "").strip() + if want_h and want_h != str((existing or {}).get("id") or ""): + # Run the kit on a Harness the person already has. One kit, one Harness: the previous kit + # Harness keeps its sessions and its package but is no longer the one the app talks to, + # and a Harness already running another kit is not taken over. + target = await _vertex_get(want_h) + if not target or str(target.get("org") or "") != org or str(target.get("deleted") or "0") == "1": + raise uhp_error(404, "harness_not_found", "No harness with that id.", "harness") + other = str(target.get("kit") or "") + if other and other != kit_id: + raise uhp_error(409, "kit_conflict", f"That Harness runs the '{other}' kit.", "harness", {"kit": other}) + now0 = str(int(time.time() * 1000)) + if existing: + await _vg_upsert("Harness", str(existing["id"]), {"kit": "", "updated_at": now0}) + await _vg_upsert("Harness", want_h, {"kit": kit_id, "updated_at": now0}) + existing = await _vertex_get(want_h) or {**target, "kit": kit_id} + print(f"[kits] {kit_id} now runs on {want_h}", flush=True) if decl and not db_in and not existing: # Declaring launch.database is what makes it required: every panel this kit builds would # have nothing to read, so a launch without a connection is not a partial success. diff --git a/gateway/tests/test_plugins.py b/gateway/tests/test_plugins.py index bb1a8a0b..9b937f9d 100644 --- a/gateway/tests/test_plugins.py +++ b/gateway/tests/test_plugins.py @@ -293,6 +293,37 @@ def test_a_kit_harness_from_before_the_package_gets_it_on_the_next_launch(api, t assert any("revised" in (f.get("content") or "") for f in files) +def test_a_kit_can_move_to_a_harness_you_already_have(api, tmp_path, monkeypatch): + """A package migrated to another Harness takes the kit with it: launch with `harness` binds the + kit to that Harness, installs the kit's package there, and the previous kit Harness keeps its + sessions and package but is no longer the one the app finds.""" + kid = "demo-kit-moves" + plug = tmp_path / kid / "plugin" + (plug / "skills" / "demo-skill").mkdir(parents=True) + (plug / "plugin.json").write_text(json.dumps({"$schema": MANIFEST_SCHEMA, "name": "harnessrouter-moves", "version": "1.0.0", "description": "A kit."})) + (plug / "skills" / "demo-skill" / "SKILL.md").write_text("---\nname: demo-skill\ndescription: fixture\n---\n") + monkeypatch.setattr(app, "_KITS_DIR", str(tmp_path)) + kits = {kid: {"id": kid, "title": "Moves", "app": {"route": "/kits/moves"}, + "harness": {"name": "Moves", "plugin": "plugin", "recommended": [{"base": "claude-code", "model": "claude-opus-5"}]}}, + "other-kit": {"id": "other-kit", "title": "Other", "app": {"route": "/kits/other"}, + "harness": {"name": "Other", "recommended": [{"base": "claude-code", "model": "claude-opus-5"}]}}} + monkeypatch.setattr(app, "_kits", lambda: kits) + first = api.post(f"/v1/kits/{kid}/launch", json={}).json(); a = first["harnessId"] + mine = _create(api, name="Mine") + moved = api.post(f"/v1/kits/{kid}/launch", json={"harness": mine["id"]}) + assert moved.status_code == 200, moved.text + assert moved.json()["harnessId"] == mine["id"] and moved.json()["created"] is False + h = moved.json()["harness"] + assert h["kit"] == kid and [p["name"] for p in h["plugins"]] == ["harnessrouter-moves"] + assert api.get(f"/v1/harnesses/{a}").json().get("kit") in (None, "") + again = api.post(f"/v1/kits/{kid}/launch", json={}).json() + assert again["harnessId"] == mine["id"], "the kit now finds the Harness it moved to" + other = api.post("/v1/kits/other-kit/launch", json={"harness": mine["id"]}) + assert other.status_code == 409 and other.json()["error"]["code"] == "kit_conflict" + r = api.post(f"/v1/kits/{kid}/launch", json={"harness": "chrn_nothere"}) + assert r.status_code == 404 + + def test_an_update_cannot_change_the_base(api): """Harnesses §5.2: id, base and createdAt are immutable on update.""" h = _create(api) From e0ab7657d6c4d961083376b1bfda55793a79af30 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Mon, 14 Sep 2026 16:09:59 -0700 Subject: [PATCH 11/16] console: a plugin row's four actions sit two per row, one per row when narrow Four buttons in one line took the width the description needed. Wide: a two by two block on the right, the copy keeps the rest. Narrow: one per row under the copy. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Q96VFkSEJZdTydczxxJHMD --- ui/src/app/revamp.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/src/app/revamp.css b/ui/src/app/revamp.css index c99f0f7a..c13b5674 100644 --- a/ui/src/app/revamp.css +++ b/ui/src/app/revamp.css @@ -2016,6 +2016,9 @@ button.creq-srcstat.on b { color: var(--accent); } .plugin-row .capability-copy{min-width:0} .plugin-row .capability-copy strong,.plugin-row .capability-copy span{overflow-wrap:anywhere} .plugin-row.is-off .capability-copy{opacity:.62} +/* The four actions sit two per row so the description keeps the width; one per row when narrow. */ +.plugin-row .capability-actions{display:grid;grid-template-columns:repeat(2,minmax(104px,max-content));gap:6px 8px;justify-content:end;align-self:start;margin-top:2px} +.plugin-row .capability-actions .button,.plugin-row .capability-actions .toggle-button{width:100%;min-width:0;justify-content:center} .plugin-title{display:flex;align-items:center;gap:8px;min-width:0} .plugin-row .capability-copy .plugin-version{display:inline-flex;align-items:center;width:auto;margin:0;padding:1px 7px;border-radius:999px; border:1px solid var(--line,#e7e7eb);background:rgba(255,255,255,.72);font:500 10.5px/1.6 var(--mono,ui-monospace,monospace);color:var(--muted,#6f6f78);white-space:nowrap} @@ -2035,12 +2038,13 @@ button.creq-srcstat.on b { color: var(--accent); } .plugin-export{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;padding:10px 12px;border-radius:9px;border:1px dashed var(--line,#e7e7eb);font-size:12px;line-height:1.45;color:var(--muted,#6f6f78)} .plugin-export span{min-width:0} @media (max-width:768px){ - .plugin-row .capability-actions{grid-column:2;justify-content:flex-start;flex-wrap:wrap} + .plugin-row .capability-actions{grid-column:2;grid-template-columns:1fr;justify-content:stretch;width:100%;max-width:240px;margin-top:6px} .plugin-details{grid-column:1 / -1} .plugin-group li{grid-template-columns:1fr} .plugin-export{flex-direction:column;align-items:flex-start} } @media (max-width:480px){ + .plugin-row .capability-actions{max-width:none} .plugin-head{display:grid;grid-template-columns:1fr;gap:8px} .plugin-head .button{width:100%;justify-content:center} } From 481c5af71804d843895467e0bbd5ce5e50e472f7 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Mon, 14 Sep 2026 16:15:25 -0700 Subject: [PATCH 12/16] console: a plugin row's layout follows the width of its list, not the viewport The settings panel is about 430px wide on a 1024 screen, so a viewport breakpoint left the copy squeezed to 140px beside four buttons. The list is now a size container: beside the copy, two per row; under 680px the actions drop below the copy; under 420px one per row. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Q96VFkSEJZdTydczxxJHMD --- ui/src/app/revamp.css | 22 ++++++++++++++-------- ui/src/components/HarnessSettings.tsx | 2 +- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/ui/src/app/revamp.css b/ui/src/app/revamp.css index c13b5674..e889c322 100644 --- a/ui/src/app/revamp.css +++ b/ui/src/app/revamp.css @@ -2016,9 +2016,22 @@ button.creq-srcstat.on b { color: var(--accent); } .plugin-row .capability-copy{min-width:0} .plugin-row .capability-copy strong,.plugin-row .capability-copy span{overflow-wrap:anywhere} .plugin-row.is-off .capability-copy{opacity:.62} -/* The four actions sit two per row so the description keeps the width; one per row when narrow. */ +/* The four actions sit two per row beside the copy so the description keeps its width. The row + follows the width of the list it is in, not the viewport: the settings panel is narrow on a + 1024 screen too. Under 680px the actions drop below the copy, two per row; under 420px one per row. */ +.plugin-list{container-type:inline-size} .plugin-row .capability-actions{display:grid;grid-template-columns:repeat(2,minmax(104px,max-content));gap:6px 8px;justify-content:end;align-self:start;margin-top:2px} .plugin-row .capability-actions .button,.plugin-row .capability-actions .toggle-button{width:100%;min-width:0;justify-content:center} +@container (max-width:680px){ + .plugin-row{grid-template-columns:30px minmax(0,1fr)} + .plugin-row .capability-actions{grid-column:2;justify-content:start;margin-top:8px} + .plugin-details{grid-column:1 / -1} +} +@container (max-width:420px){ + .plugin-row .capability-actions{grid-template-columns:1fr;width:100%} + .plugin-group li{grid-template-columns:1fr} + .plugin-export{flex-direction:column;align-items:flex-start} +} .plugin-title{display:flex;align-items:center;gap:8px;min-width:0} .plugin-row .capability-copy .plugin-version{display:inline-flex;align-items:center;width:auto;margin:0;padding:1px 7px;border-radius:999px; border:1px solid var(--line,#e7e7eb);background:rgba(255,255,255,.72);font:500 10.5px/1.6 var(--mono,ui-monospace,monospace);color:var(--muted,#6f6f78);white-space:nowrap} @@ -2037,14 +2050,7 @@ button.creq-srcstat.on b { color: var(--accent); } .plugin-note.is-error{background:#fef2f2;color:#991b1b} .plugin-export{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;padding:10px 12px;border-radius:9px;border:1px dashed var(--line,#e7e7eb);font-size:12px;line-height:1.45;color:var(--muted,#6f6f78)} .plugin-export span{min-width:0} -@media (max-width:768px){ - .plugin-row .capability-actions{grid-column:2;grid-template-columns:1fr;justify-content:stretch;width:100%;max-width:240px;margin-top:6px} - .plugin-details{grid-column:1 / -1} - .plugin-group li{grid-template-columns:1fr} - .plugin-export{flex-direction:column;align-items:flex-start} -} @media (max-width:480px){ - .plugin-row .capability-actions{max-width:none} .plugin-head{display:grid;grid-template-columns:1fr;gap:8px} .plugin-head .button{width:100%;justify-content:center} } diff --git a/ui/src/components/HarnessSettings.tsx b/ui/src/components/HarnessSettings.tsx index d8c43d42..5017b83c 100644 --- a/ui/src/components/HarnessSettings.tsx +++ b/ui/src/components/HarnessSettings.tsx @@ -415,7 +415,7 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: { {...({ webkitdirectory: '', directory: '' } as Record)} />
{pluginNote &&
{pluginNote.text}
} -
+
{(draft?.plugins || []).map((p, idx) => { const servers = p.mcpServers || [], skls = p.skills || [], skipped = p.skipped || []; const pending = Boolean(p.files && p.files.length && !p.blob); From fc415bd4ddaff83a1abd4caf956086c8ef194fd4 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Mon, 14 Sep 2026 16:19:34 -0700 Subject: [PATCH 13/16] console: one action per row only at phone width (list under 340px) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Q96VFkSEJZdTydczxxJHMD --- ui/src/app/revamp.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/app/revamp.css b/ui/src/app/revamp.css index e889c322..6c3a993f 100644 --- a/ui/src/app/revamp.css +++ b/ui/src/app/revamp.css @@ -2018,7 +2018,7 @@ button.creq-srcstat.on b { color: var(--accent); } .plugin-row.is-off .capability-copy{opacity:.62} /* The four actions sit two per row beside the copy so the description keeps its width. The row follows the width of the list it is in, not the viewport: the settings panel is narrow on a - 1024 screen too. Under 680px the actions drop below the copy, two per row; under 420px one per row. */ + 1024 screen too. Under 680px the actions drop below the copy, two per row; under 340px (a phone) one per row. */ .plugin-list{container-type:inline-size} .plugin-row .capability-actions{display:grid;grid-template-columns:repeat(2,minmax(104px,max-content));gap:6px 8px;justify-content:end;align-self:start;margin-top:2px} .plugin-row .capability-actions .button,.plugin-row .capability-actions .toggle-button{width:100%;min-width:0;justify-content:center} @@ -2027,7 +2027,7 @@ button.creq-srcstat.on b { color: var(--accent); } .plugin-row .capability-actions{grid-column:2;justify-content:start;margin-top:8px} .plugin-details{grid-column:1 / -1} } -@container (max-width:420px){ +@container (max-width:340px){ .plugin-row .capability-actions{grid-template-columns:1fr;width:100%} .plugin-group li{grid-template-columns:1fr} .plugin-export{flex-direction:column;align-items:flex-start} From 0e7f44170b2b540c9a52aea0abf5514be3332ff1 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Mon, 14 Sep 2026 16:23:44 -0700 Subject: [PATCH 14/16] console: the plugin export footer's button never wraps; the footer stacks on a phone Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Q96VFkSEJZdTydczxxJHMD --- ui/src/app/revamp.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/src/app/revamp.css b/ui/src/app/revamp.css index 6c3a993f..ef8438a9 100644 --- a/ui/src/app/revamp.css +++ b/ui/src/app/revamp.css @@ -2030,7 +2030,6 @@ button.creq-srcstat.on b { color: var(--accent); } @container (max-width:340px){ .plugin-row .capability-actions{grid-template-columns:1fr;width:100%} .plugin-group li{grid-template-columns:1fr} - .plugin-export{flex-direction:column;align-items:flex-start} } .plugin-title{display:flex;align-items:center;gap:8px;min-width:0} .plugin-row .capability-copy .plugin-version{display:inline-flex;align-items:center;width:auto;margin:0;padding:1px 7px;border-radius:999px; @@ -2050,6 +2049,10 @@ button.creq-srcstat.on b { color: var(--accent); } .plugin-note.is-error{background:#fef2f2;color:#991b1b} .plugin-export{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;padding:10px 12px;border-radius:9px;border:1px dashed var(--line,#e7e7eb);font-size:12px;line-height:1.45;color:var(--muted,#6f6f78)} .plugin-export span{min-width:0} +.plugin-export .button{flex:none;white-space:nowrap} +@media (max-width:480px){ + .plugin-export{flex-direction:column;align-items:flex-start} +} @media (max-width:480px){ .plugin-head{display:grid;grid-template-columns:1fr;gap:8px} .plugin-head .button{width:100%;justify-content:center} From 43b0872fc3bc97d75504d4617b8c4d927b3ada02 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Mon, 14 Sep 2026 16:29:22 -0700 Subject: [PATCH 15/16] console: the Plugins section offers what it says, installed plugins; the Harness export footer is gone Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Q96VFkSEJZdTydczxxJHMD --- ui/src/app/revamp.css | 6 ----- ui/src/components/HarnessSettings.tsx | 35 +-------------------------- 2 files changed, 1 insertion(+), 40 deletions(-) diff --git a/ui/src/app/revamp.css b/ui/src/app/revamp.css index ef8438a9..67bb2206 100644 --- a/ui/src/app/revamp.css +++ b/ui/src/app/revamp.css @@ -2047,12 +2047,6 @@ button.creq-srcstat.on b { color: var(--accent); } .plugin-note{margin:8px 0 0;padding:9px 12px;border-radius:8px;font-size:13px;line-height:1.45;overflow-wrap:anywhere} .plugin-note.is-info{background:var(--subtle,#f7f7f9);color:var(--muted,#6f6f78)} .plugin-note.is-error{background:#fef2f2;color:#991b1b} -.plugin-export{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;padding:10px 12px;border-radius:9px;border:1px dashed var(--line,#e7e7eb);font-size:12px;line-height:1.45;color:var(--muted,#6f6f78)} -.plugin-export span{min-width:0} -.plugin-export .button{flex:none;white-space:nowrap} -@media (max-width:480px){ - .plugin-export{flex-direction:column;align-items:flex-start} -} @media (max-width:480px){ .plugin-head{display:grid;grid-template-columns:1fr;gap:8px} .plugin-head .button{width:100%;justify-content:center} diff --git a/ui/src/components/HarnessSettings.tsx b/ui/src/components/HarnessSettings.tsx index 5017b83c..060585ae 100644 --- a/ui/src/components/HarnessSettings.tsx +++ b/ui/src/components/HarnessSettings.tsx @@ -8,7 +8,7 @@ import { zipSync, strToU8 } from 'fflate'; import { SkelPage } from '@/components/Skel'; import { useRouter } from 'next/navigation'; import { - OOB, oobById, oobDefaultModel, oobModels, useModelCatalog, modelAvailable, modelAvailability, availabilityNote, useBases, getCustom, saveCustom, deleteCustom, createCustom, getSkillFiles, storeMcpSecret, exportPlugin, pluginSchemas, + OOB, oobById, oobDefaultModel, oobModels, useModelCatalog, modelAvailable, modelAvailability, availabilityNote, useBases, getCustom, saveCustom, deleteCustom, createCustom, getSkillFiles, storeMcpSecret, pluginSchemas, type CustomHarness, type OobHarness, type HarnessPlugin, getPluginFiles, } from '@/lib/harness'; import { HarnessLogo } from '@/components/HarnessLogo'; @@ -99,31 +99,6 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: { finally { setPluginBusy(false); if (pluginDirRef.current) pluginDirRef.current.value = ''; } } const fmtMb = (n: number) => `${(n / 1048576).toFixed(n < 10485760 ? 1 : 0)} MB`; - // The Harness's own tools and Skills as a package, zipped in the browser and handed to the - // person as a file they can install anywhere that reads the Agent Plugins format. - async function downloadPluginPackage() { - if (!draft || pluginBusy) return; - setPluginBusy(true); setPluginNote(null); - try { - const pkg = await exportPlugin(draft.id); - const entries: Record = {}; - for (const f of pkg.files || []) { - entries[f.path] = f.content_b64 !== undefined - ? Uint8Array.from(atob(f.content_b64), (c) => c.charCodeAt(0)) - : strToU8(f.content || ''); - } - const blob = new Blob([zipSync(entries) as BlobPart], { type: 'application/zip' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); a.download = `${pkg.name || 'harness'}.zip`; - document.body.appendChild(a); a.click(); a.remove(); - setTimeout(() => URL.revokeObjectURL(a.href), 1000); - const left = pkg.skipped || []; - setPluginNote({ kind: 'info', text: left.length - ? `Downloaded ${pkg.name}.zip. Not included, to re-enter where you install it: ${left.map((s) => s.path.replace(/^mcp\.json#\/mcpServers\//, '')).join(', ')}.` - : `Downloaded ${pkg.name}.zip.` }); - } catch (e) { setPluginNote({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } - finally { setPluginBusy(false); } - } // One installed plugin, byte for byte as the server holds it, zipped in the browser. async function downloadInstalledPlugin(p: HarnessPlugin) { if (!draft?.id || pluginBusy) return; @@ -473,14 +448,6 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: {
)}
- {!readOnly && draft?.id && !oob && (ownSkills.length + (draft?.mcpServers || []).length) > 0 && ( -
- This Harness's own {ownSkills.length} {ownSkills.length === 1 ? 'Skill' : 'Skills'} and {(draft?.mcpServers || []).length} {(draft?.mcpServers || []).length === 1 ? 'tool' : 'tools'} can travel as a package too. Installed plugins are downloaded from their own rows. - -
- )}
From 705bb5c39f0afe1db0064957d45f21c8d836f504 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Mon, 14 Sep 2026 16:52:03 -0700 Subject: [PATCH 16/16] console: a gap between a plugin's name and its version pill The title's flex rule lost to the row's block rule for strong, so the pill touched the name. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Q96VFkSEJZdTydczxxJHMD --- ui/src/app/revamp.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/app/revamp.css b/ui/src/app/revamp.css index 67bb2206..10baa2d3 100644 --- a/ui/src/app/revamp.css +++ b/ui/src/app/revamp.css @@ -2031,7 +2031,7 @@ button.creq-srcstat.on b { color: var(--accent); } .plugin-row .capability-actions{grid-template-columns:1fr;width:100%} .plugin-group li{grid-template-columns:1fr} } -.plugin-title{display:flex;align-items:center;gap:8px;min-width:0} +.plugin-row .capability-copy .plugin-title{display:flex;align-items:center;gap:10px;min-width:0;flex-wrap:wrap} /* outranks the row's block strong, so the gap applies */ .plugin-row .capability-copy .plugin-version{display:inline-flex;align-items:center;width:auto;margin:0;padding:1px 7px;border-radius:999px; border:1px solid var(--line,#e7e7eb);background:rgba(255,255,255,.72);font:500 10.5px/1.6 var(--mono,ui-monospace,monospace);color:var(--muted,#6f6f78);white-space:nowrap} .plugin-row .capability-copy .plugin-desc{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;color:var(--muted,#6f6f78);font-size:12px;line-height:1.45}