diff --git a/docs/self-hosting-guide.md b/docs/self-hosting-guide.md
index 13a8fe4c..f9d9012c 100644
--- a/docs/self-hosting-guide.md
+++ b/docs/self-hosting-guide.md
@@ -718,6 +718,45 @@ 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. 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,
+`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 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":[
+ {"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..."}
+ ]}]}'
+```
+
+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
+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/gateway/app.py b/gateway/app.py
index 01714735..ab9a05c9 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,50 @@ 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, 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
+ 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"])
+ # 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":
+ 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 +3657,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 +3679,7 @@ async def pump():
"harness_management": True,
"session_sharing": True,
"idempotency": True,
+ "plugins": True,
}
UHP_CONFORMANCE_CLASS = "full"
@@ -3700,7 +3746,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 +3759,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 +6680,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 +6768,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.
@@ -12811,9 +12860,48 @@ 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:
+ 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("*")):
+ 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_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. 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 []:
@@ -13216,6 +13304,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 +13332,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 +13351,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 +13450,535 @@ 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"}
+_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.
+_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")
+ 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 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
+
+
+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 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)
+ 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 "")
+ 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] = []
+
+ 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 "$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.",
+ "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, org: str) -> list:
+ files = entry.get("files")
+ 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())
+ 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,
+ 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[_skill_key(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 []:
+ 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[key]} and plugin {pe['name']}.",
+ "plugins", {"component": "skill", "name": sk["name"],
+ "between": [skill_owner[key], pe["name"]]})
+ skill_owner[key] = pe["name"]
+
+
+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.
+
+ 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 = 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:
+ 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:
+ 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"]
+ 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 {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(_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:
+ 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")
+ 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": 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 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}
+
+
+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 +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, org)
hid = _rid("chrn")
now = str(int(time.time() * 1000))
props = {"org": org, "member": member, "workspace": workspace, **_harness_props(body),
@@ -13443,6 +14066,30 @@ 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)
+ 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")
+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)
@@ -13451,9 +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, 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.
@@ -13475,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}
@@ -13599,6 +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, org)
hid = _rid("chrn")
now = str(int(time.time() * 1000))
props = {"org": org, "member": member, "workspace": str(p.get("workspace") or ""),
@@ -13646,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
@@ -13687,6 +14345,48 @@ 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]:
+ """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
+ 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"):
+ # 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)
+ 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")
@@ -13717,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.
@@ -13743,6 +14460,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)}
@@ -13763,15 +14487,23 @@ 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)
body.skills = await _skills_prepare(body.skills)
+ # 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 ""),
@@ -13921,11 +14653,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):
@@ -13942,7 +14700,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,
@@ -13962,7 +14721,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)
@@ -14317,6 +15083,30 @@ 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)
+ 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")
+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)
@@ -14324,8 +15114,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, 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)
@@ -14344,4 +15140,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
new file mode 100644
index 00000000..9b937f9d
--- /dev/null
+++ b/gateway/tests/test_plugins.py
@@ -0,0 +1,334 @@
+"""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"
+
+
+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"]
+
+
+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"]
+ # 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_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)
+ 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 0cb1734e..fcc93510 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,19 @@ 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:
+ 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
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 +639,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"
@@ -756,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
@@ -778,15 +813,18 @@ 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] = []
+ wanted: set[str] = set()
for pg in plugins or []:
pg = pg or {}
name = _mcp_name(pg.get("name") or pg.get("id") or "")
@@ -794,8 +832,25 @@ 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
+ 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:
f = f or {}
@@ -809,20 +864,125 @@ 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
+_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))
+ 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():
+ 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
+ # 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
+ 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)} "$@"', ""]
+ # 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
+
+
_AGENTS_BEGIN = ""
_AGENTS_END = ""
@@ -2157,9 +2317,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 +2466,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 +3742,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:
@@ -5652,8 +5828,18 @@ 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 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], backend,
+ 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.
@@ -5779,7 +5965,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..f938cce4
--- /dev/null
+++ b/runner/tests/test_uhp_plugins.py
@@ -0,0 +1,134 @@
+"""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 "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):
+ 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"
+
+
+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/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..10baa2d3 100644
--- a/ui/src/app/revamp.css
+++ b/ui/src/app/revamp.css
@@ -2009,3 +2009,45 @@ 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, 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}
+/* 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 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}
+@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:340px){
+ .plugin-row .capability-actions{grid-template-columns:1fr;width:100%}
+ .plugin-group li{grid-template-columns:1fr}
+}
+.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}
+.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}
+@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/ApiReference.tsx b/ui/src/components/ApiReference.tsx
index 0dc7afc4..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).
@@ -321,6 +322,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
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..060585ae 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, pluginSchemas,
+ type CustomHarness, type OobHarness, type HarnessPlugin, getPluginFiles,
} 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,76 @@ 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 (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); 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];
+ 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'] & { $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 }] });
+ 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`;
+ // 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(() => {
@@ -82,6 +153,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');
@@ -301,6 +380,77 @@ export function HarnessSettings({ id, embedded = false, onNavigate }: {
+
+
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.
No plugins installed
+ {readOnly
+ ? 'A built-in Harness carries no plugins. Create a Harness of your own to install packages of tools and Skills.'
+ : '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..8dc16fd4 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,32 @@ 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 }[]> {
+ 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 }[] }>(