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.

+
+
{(draft?.plugins || []).length} installed {(draft?.plugins || []).length === 1 ? 'plugin' : 'plugins'} + {!readOnly && } + void installPluginFolder(e.target.files)} + {...({ webkitdirectory: '', directory: '' } as Record)} /> +
+ {pluginNote &&
{pluginNote.text}
} +
+ {(draft?.plugins || []).map((p, idx) => { + const servers = p.mcpServers || [], skls = p.skills || [], skipped = p.skipped || []; + const pending = Boolean(p.files && p.files.length && !p.blob); + const m = p.manifest || {}; + const open = pluginOpen === p.name; + const facts = pending + ? ['Installs when you save the Harness'] + : [`${skls.length} ${skls.length === 1 ? 'Skill' : 'Skills'}`, `${servers.length} ${servers.length === 1 ? 'tool' : 'tools'}`, + ...(m.author?.name ? [`by ${m.author.name}`] : []), ...(m.license ? [m.license] : [])]; + return ( +
+ +
+ {p.name}{m.version ? v{m.version} : null} + {m.description || 'Agent Plugins package'} + {facts.join(' \u00b7 ')} + {!pending && skipped.length > 0 && {' \u00b7 '}{skipped.length} not loaded} +
+
+ {!pending && } + {!pending && draft?.id && } + {!readOnly && } + +
+ {open && !pending && ( +
+ {skls.length > 0 &&
Skills +
    {skls.map((s) =>
  • {s.name}{s.description ? {s.description} : null}
  • )}
} + {servers.length > 0 &&
Tools +
    {servers.map((s) =>
  • {s.name}{s.transport === 'stdio' ? `runs ${s.command}${(s.args || []).length ? ' ' + (s.args || []).join(' ') : ''}` : s.url}
  • )}
} + {skipped.length > 0 &&
Not loaded +
    {skipped.map((s) =>
  • {s.path}{s.reason}
  • )}
} + {(m.homepage || m.repository || (m.keywords || []).length > 0) && ( +
About +
    {m.homepage &&
  • Homepage
  • } + {m.repository &&
  • Source
  • } + {(m.keywords || []).length > 0 &&
  • {(m.keywords || []).join(', ')}
  • }
+ )} +
+ )} +
+ ); + })} + {(draft?.plugins || []).length === 0 && ( +
+ +
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 }[] }>(