Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d38ac3a
gateway, runner: the Plugins sub-protocol (UHP 2026-09-12), served be…
richard-epsilla Sep 13, 2026
8ba824f
console: a Plugins section on the Harness page
richard-epsilla Sep 13, 2026
9bbdf56
gateway: a kit that ships a plugin package launches as an installed p…
richard-epsilla Sep 13, 2026
53fbeee
docs: installing a plugin on a harness, in the guide and the console …
richard-epsilla Sep 13, 2026
03cabab
console: the plugin version chip keeps its own width when it wraps un…
richard-epsilla Sep 13, 2026
aa894a3
plugins: the review's findings, fixed
richard-epsilla Sep 14, 2026
0e3618c
kits: launch installs the kit's package on a Harness launched before …
richard-epsilla Sep 14, 2026
ca57223
kits: launch installs a kit package whose files changed under the sam…
richard-epsilla Sep 14, 2026
90488e4
console: the Plugins section reads like the Tools and Skills sections…
richard-epsilla Sep 14, 2026
fdad224
kits: launch can run a kit on a Harness you already have
richard-epsilla Sep 14, 2026
e0ab765
console: a plugin row's four actions sit two per row, one per row whe…
richard-epsilla Sep 14, 2026
481c5af
console: a plugin row's layout follows the width of its list, not the…
richard-epsilla Sep 14, 2026
fc415bd
console: one action per row only at phone width (list under 340px)
richard-epsilla Sep 14, 2026
0e7f441
console: the plugin export footer's button never wraps; the footer st…
richard-epsilla Sep 14, 2026
43b0872
console: the Plugins section offers what it says, installed plugins; …
richard-epsilla Sep 14, 2026
705bb5c
console: a gap between a plugin's name and its version pill
richard-epsilla Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/self-hosting-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
829 changes: 813 additions & 16 deletions gateway/app.py

Large diffs are not rendered by default.

334 changes: 334 additions & 0 deletions gateway/tests/test_plugins.py

Large diffs are not rendered by default.

227 changes: 208 additions & 19 deletions runner/server.py

Large diffs are not rendered by default.

134 changes: 134 additions & 0 deletions runner/tests/test_uhp_plugins.py
Original file line number Diff line number Diff line change
@@ -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"
7 changes: 7 additions & 0 deletions ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions ui/src/app/revamp.css
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
16 changes: 16 additions & 0 deletions ui/src/components/ApiReference.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ curl "${mgmtBase}/sessions/{session_id}/files?changed=true" \\
<Param name="system_prompt" type="string">The agent&apos;s instructions (written into its workspace as AGENTS.md / CLAUDE.md).</Param>
<Param name="mcp_servers" type="array">Tool connections: <code>{`{name, url, auth?, enabled}`}</code>. For <code>auth</code>, pass a secrets ref (below) or a <code>$headers.&#123;Name&#125;</code> per-request reference.</Param>
<Param name="skills" type="array"><code>{`{name, files:[{path, content|content_b64}]}`}</code>, a bundle MUST include a <code>SKILL.md</code>; invalid bundles are rejected with 400. Skills can be whole folders: <code>path</code> is the file&apos;s relative path inside the skill (nested layouts like <code>guides/SKILL.md</code> install as-is); the root <code>SKILL.md</code> needs YAML frontmatter (<code>name</code>, <code>description</code>). Bundles over ~48&nbsp;KB are offloaded server-side and the saved agent returns that skill as <code>{`{name, enabled, blob}`}</code> with no <code>files</code>, normal; the folder still mounts into every run. On later updates pass such entries back unchanged; only include <code>files</code> when changing content.</Param>
<Param name="plugins" type="array">Packages in the Agent Plugins format: <code>{`{files:[{path, content|content_b64}], enabled?}`}</code>, each with a <code>plugin.json</code> at its root, tools in <code>mcp.json</code>, Skills under <code>skills/</code>. The saved agent returns each as <code>{`{name, enabled, blob, manifest, mcpServers, skills, skipped}`}</code>; pass such entries back unchanged, and include <code>files</code> only for a new or changed package.</Param>
<Param name="disabled_tools" type="array">Inherited/built-in tool names to disable.</Param>
<Param name="additional_headers" type="array">Header NAMES your product passes per request for app-level auth (see Additional headers above).</Param>
<Param name="max_step" type="integer">Default agent step budget (default 400).</Param>
Expand All @@ -321,6 +322,21 @@ curl "${mgmtBase}/sessions/{session_id}/files?changed=true" \\
<code> id</code> or <code>name</code>; returns <code>{`{ id, name, files: [{path, content|content_b64}] }`}</code>.</p>
</Endpoint>

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

<Endpoint method="GET" path="/v1/harnesses/{id}/plugin">
<p className="hr-meta">This agent&apos;s own tools and Skills as an Agent Plugins package, ready to
send to another agent&apos;s <code>plugins</code> or to write to disk for any client that reads the
format. Credentials are never included; each omission is listed in <code>skipped</code>.</p>
</Endpoint>

<Endpoint method="GET" path="/v1/models">
<p className="hr-meta">The current model catalog per engine, each with its default. Build model
pickers from this, don&apos;t hardcode model names. The map is keyed by <b>backend</b>, not
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/HarnessEditors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SkillFile> {
export async function readSkillUpload(file: File, path: string): Promise<SkillFile> {
try {
const bytes = new Uint8Array(await file.arrayBuffer());
try {
Expand Down
Loading
Loading