diff --git a/harness-plugins/README.md b/harness-plugins/README.md index 9fb3504..04019da 100644 --- a/harness-plugins/README.md +++ b/harness-plugins/README.md @@ -6,7 +6,7 @@ Per-harness **plugins**: each exposes the framework in the harness's own plugin |---|---|---|---| | [integration-starter/](integration-starter/) | — (shared engine + template) | TypeScript | The 17 build+operate tools, skills, display, `runA2App`, and `registerHarnessProfile`; every plugin below uses it | | [openclaw/](openclaw/) | [OpenClaw](https://docs.openclaw.ai) | TypeScript, `definePluginEntry`; `pnpm build` stages a self-contained installable `dist/` | An "Agent Apps" **manager tab** (browser-style tabs per app: embedded app view, launch/pause/delete, a per-app session side panel, and a build form that starts the agent run directly) + `/agent-app` chat command + `agent-app` CLI + the six skills; no agent tools (skills + CLIs are the agent surface); ships `openclaw.plugin.json` | -| [hermes/](hermes/) | [Hermes](https://github.com/NousResearch/hermes-agent) | **Python**, `plugin.yaml` + `register(ctx)` | 11 tools via `ctx.register_tool(...)` | +| [hermes/](hermes/) | [Hermes](https://github.com/NousResearch/hermes-agent) | **Python**, `plugin.yaml` + `register(ctx)`; `scripts/build.py` stages a self-contained `dist/` | The 17 build+operate tools via `ctx.register_tool(...)` (a Python port of the engine), the six skills, `/agent-app` + `hermes agent-app`, and an "Agent Apps" **dashboard tab** like OpenClaw's | | [dsh/](dsh/) | deepseek-harness | TypeScript Cordis (peer deps `cordis`, `@deepseek-ai/dsh-tools`) | 14 tools via `ctx.tools.register(defineTool(...))`; a browser iframe renderer | | [craftbot/](craftbot/) | [CraftBot](https://github.com/CraftOS-dev/CraftBot) | **Python**, `@action` decorator (`agent_core`) | 11 actions in the `agent_app` action set | | [claude-code/](claude-code/) | Claude Code | **MCP** (stdio JSON-RPC 2.0) | 14 tools via a real MCP server; register with `claude mcp add` | @@ -91,6 +91,7 @@ exactly that. What the plugins here register: | [claude-code/](claude-code/) | `claude`, replacing the built-in | `claude -p {prompt} --permission-mode dontAsk --allowedTools "Bash(a2app *)"`. The built-in passes no permission flags, and print mode denies every Bash call that would prompt, so the agent could not run `a2app` at all | | [openclaw/](openclaw/) | `openclaw` | `openclaw agent exec {prompt}`: one embedded turn in the app's directory, alongside a running gateway | | [dsh/](dsh/) | `dsh` | `dsh --profile headless {prompt}` | +| [hermes/](hermes/) | `hermes` | `hermes --cli chat -Q --query-file -`, the prompt on stdin: one single-query turn in the app's directory | | [pi/](pi/) | `pi` | `pi -p {prompt}`, written by a Pi extension | What a plugin must **not** do is start a bridge on a user's behalf. A bridge lets diff --git a/harness-plugins/hermes/README.md b/harness-plugins/hermes/README.md index 153e466..d241f19 100644 --- a/harness-plugins/hermes/README.md +++ b/harness-plugins/hermes/README.md @@ -1,10 +1,84 @@ # Hermes plugin -A real Hermes plugin. Hermes plugins are **Python**: a `plugin.yaml` manifest plus an `__init__.py` with `register(ctx)` that calls `ctx.register_tool(...)`. This plugin registers 11 tools that build and operate Agent Apps by shelling the framework CLIs. +A Hermes plugin (`plugin.yaml` + `register(ctx)`, Python) whose dashboard tab is the same **Agent Apps manager** the OpenClaw and dsh plugins ship, plus the build + operate tools, the six framework skills, and the `/agent-app` command. -- **Manifest:** [plugin.yaml](plugin.yaml) — `kind: backend`, `provides_tools: [...]`. -- **Code:** [__init__.py](__init__.py) — `register(ctx)` → `ctx.register_tool(name=, toolset="agent_app", schema=, handler=, emoji=)`; handlers return the CLI output (a guard rejection is returned to the model, not swallowed). +## Surfaces -Drop this directory into `~/.hermes/plugins/`. Set `A2APP_CLI` (operate, default `a2app`) and `AGENT_APP_CLI` (build/evolve, default `agent-app`); a `…/cli.js` entry is run with Node. No shell is used, so field values can't inject. +- **Agent Apps dashboard tab** ([dashboard/](dashboard/)): a browser-style tab strip with one tab per Agent App (status dot; the name greys out when the app is offline) and a **New +** tab. An app tab embeds the running app; an offline app shows **Launch**; the active tab's **⋯** menu offers Pause / Launch, Open in browser, Show session, and Delete (two-step confirm; `agent-app remove --yes` backs data up before deleting). The **New +** form (name, requirement, stack, optional port) starts the build directly. +- **Per-app session panel**: each app has its own Hermes session (id `agent-app--`). The right panel renders its transcript and a composer. Build progress shows up there as it happens, and evolve or operate requests are messages into that session. A failed turn shows why (for example, no model configured). +- **Tools**: the seventeen `agent_app_*` tools of the shared engine, in the toolset `agent_app`, each shelling a real `agent-app` or `a2app` verb. A relative `dir` resolves against the calling session's working directory. +- **Skills**: the six framework skills, registered as plugin skills (`agent-app:creator`, `agent-app:modify`, ...) and loaded with `skill_view`. Hermes keeps plugin skills out of the `` index (they do get `/agent-app:` commands in the CLI, TUI and Desktop, but not on messaging platforms), so the plugin also registers a system-prompt section that routes any app request to the right one. +- **`/agent-app `**: hands the request to the agent. In the classic CLI the command injects it as the next turn. On messaging platforms a `pre_gateway_dispatch` hook rewrites `/agent-app ` (or `/agent_app`, for Telegram) into the request before dispatch. The TUI and the dashboard chat give plugin commands no way to start a turn, so there the command says to send the request as a normal message, which the routing section still covers. +- **`hermes agent-app `**: a passthrough to the CLIs, routed by verb (build/evolve verbs to `agent-app`, the rest to `a2app`), output streamed and exit code propagated. +- **The same tab in hermes-webui** ([webui/](webui/)): an "Agent Apps" rail button in [hermes-webui](https://github.com/nesquena/hermes-webui), as a web UI extension. See [hermes-webui](#hermes-webui). +- **Bridge route**: on load the plugin registers Hermes with `agent-app bridge`, the service that starts a harness when an app queues work, as a `hermes` profile in `$A2APP_HOME/harnesses.json`: `hermes --cli chat -Q --query-file -` with the prompt on stdin, one turn in the app's directory. The framework has no built-in Hermes profile, so without this the bridge cannot see Hermes. Written once; an existing `hermes` entry (your own) is kept. -**Verified:** the plugin registers 11 tools and its handlers drive the real CLI — `agent_app_build` scaffolds an app and `agent_app_validate` runs the real gate (framework files + Python self-test + ownership all pass). +## How it works + +`register(ctx)` ([__init__.py](__init__.py)) runs in every Hermes process and registers the tools, skills, routing section, command, gateway hook, passthrough, and bridge route. The tab is a separate half: `hermes dashboard` imports [dashboard/plugin_api.py](dashboard/plugin_api.py) at startup and mounts its `router` at `/api/plugins/agent-app/`, where the dashboard's own session-token auth covers it. The page ([dashboard/manager.js](dashboard/manager.js)) is a classic script on the dashboard plugin SDK and calls that API with the SDK's `fetchJSON`. + +A session turn is one subprocess, `hermes --cli chat -Q --resume --source tool --in --query-file -`, with the message on stdin. This is how Hermes runs its own background workers (the kanban dispatcher), so a turn uses your configured model, toolsets, and approval policy, and nothing depends on agent internals. Turns on one session run one at a time, in order, and a turn never inherits the host process's own session or approval state (a session-less `/yolo` there does not reach it). The `tool` source keeps these sessions out of the CLI, TUI and Desktop session pickers and the dashboard's Chats view; they live in the tab (the dashboard's Sessions page still lists them under Automation and All). Deleting an app stops its session's running turn before the app is removed. Each turn's output is appended to `$HERMES_HOME/plugin-data/agent-app/logs/.log`, and the transcript is read back from the session database. New apps are created under `$A2APP_HOME/apps/` (default `~/.a2app/apps`), as in the other managers. App status comes from `agent-app list --json`, polled by the page. + +[engine.py](engine.py) is a Python port of the shared TypeScript engine ([../integration-starter/](../integration-starter/)): same tool names, schemas, and argv, same build kickoff prompt, same harness-file rules. No shell is used. On Windows an npm `.cmd` shim is read for the script it runs and Node is started on that script directly, so a field value holding `& | ^ < > %` or quotes reaches the CLI intact. + +**Embedding:** an app listens on the Hermes machine's loopback interface, so the tab embeds it when the dashboard is open over http on that machine (`http://127.0.0.1:9119` by default). Opened from another host or over HTTPS, the tab shows the app's URL in place of a frame that could not load. + +**Hermes Desktop** does not load dashboard plugins (it has its own plugin SDK), so the tab appears in `hermes dashboard` and, as an extension, in hermes-webui. The tools, skills, command, and passthrough work on every Hermes surface. + +## hermes-webui + +[hermes-webui](https://github.com/nesquena/hermes-webui) runs the agent in-process, so the plugin's tools, skills, and routing section load in its chats once the plugin is enabled. It cannot start a turn from a plugin command, so there `/agent-app` says to send the request as a normal message. It hosts a dashboard plugin's tab only as a sandboxed iframe and never runs `plugin_api.py`, and an app framed inside that sandbox has no origin of its own (no cookies or storage, its requests to itself refused). So the tab comes as an extension instead: + +- [webui/agent-apps.js](webui/agent-apps.js) runs in the web UI's main page. It adds an **Agent Apps** rail button and fills the web UI's own plugin page view with the manager ([dashboard/manager.js](dashboard/manager.js), mounted through `window.__AGENT_APP_MANAGER__.mount`). Settings → Plugins → Open for agent-app lands in the same place. +- The tab's API is served by the plugin itself: when it loads in the hermes-webui server process (and only there), [webui_backend.py](webui_backend.py) starts the same routes on a loopback port, on its own thread (the web UI loads plugins midway through its own imports, and `register()` must not wait on them). It publishes its address and a token in `runtime.json` beside the extension's files, which the web UI serves like them, so the backend is exactly as protected as the web UI. Each request needs that token, the web UI's own origin (or none, for a local tool), and a loopback `Host`. + +Install both halves, allow framing local apps, and restart the web UI: + +```bash +python harness-plugins/hermes/scripts/build.py --install --webui +hermes plugins enable agent-app --no-allow-tool-override +echo 'HERMES_WEBUI_CSP_FRAME_EXTRA="http://127.0.0.1:* http://localhost:*"' >> ~/.hermes/.env +./ctl.sh restart # in the hermes-webui checkout +``` + +`--webui` copies [webui/](webui/) and the manager into the web UI's managed extension directory (`$HERMES_WEBUI_STATE_DIR/extensions/agent-app`, default `~/.hermes/webui`) and records it as the web UI's gallery installer does, so Settings → Extensions lists it and can disable or uninstall it. Without the frame setting the tab still works, and links to running apps instead of embedding them. + +A web UI started by a supervisor may not have your shell's `PATH`, so give the plugin absolute paths in `~/.hermes/.env` (Hermes loads it in every process): `AGENT_APP_CLI`, `A2APP_CLI`, and `HERMES_BIN` (the `hermes` session turns run). Turns use Hermes's own model configuration (`hermes model`), not a model picked in the web UI's composer. + +## Build and install + +Requires the framework CLIs on `PATH` (`npm i -g agent-app-framework`; from a cloned repo, `pnpm -r build` then `npm link` in `framework/cli`). The Hermes home is `$HERMES_HOME`, default `~/.hermes` (`%LOCALAPPDATA%\hermes` on native Windows). + +From a cloned repo, [scripts/build.py](scripts/build.py) stages a self-contained `dist/` (the plugin plus the six skills from the repo-root [../../skills](../../skills)) and `--install` copies it to `$HERMES_HOME/plugins/agent-app`: + +```bash +python harness-plugins/hermes/scripts/build.py --install +hermes plugins enable agent-app --no-allow-tool-override +hermes dashboard +``` + +With Hermes's own installer, from GitHub: + +```bash +hermes plugins install CraftOS-dev/Agent-App/harness-plugins/hermes --enable +``` + +That clones this directory, which holds no copy of the skills, so the plugin uses the ones the installed CLI ships (`agent-app skills --path`). + +Hermes plugins are opt-in: nothing loads until `agent-app` is in `plugins.enabled`, which `hermes plugins enable` writes, and it takes effect in the next session. Restart a running `hermes dashboard` after enabling, because the dashboard mounts plugin APIs at startup. + +## Configuration + +- `AGENT_APP_CLI` (build/evolve, default `agent-app`) and `A2APP_CLI` (operate, default `a2app`): the binaries to shell. A value ending in `.js`/`.mjs`/`.cjs` is run with Node directly. +- `A2APP_HOME`: the framework home (default `~/.a2app`), where new apps and `harnesses.json` go. +- `HERMES_BIN`: the `hermes` the tab runs session turns with. Default: the dashboard's own interpreter (`python -m hermes_cli.main`, the same install and profile) when it runs in Hermes's venv, else the `hermes` launcher. +- hermes-webui: `AGENT_APP_WEBUI_BACKEND=0` keeps the tab backend from starting; `AGENT_APP_WEBUI_BACKEND_PORT` fixes its port (default: a free one); `AGENT_APP_WEBUI_ORIGINS` adds origins allowed to call it, for a web UI reached at other than `http://127.0.0.1:` or `localhost`. +- Approvals: session turns and bridge runs are single-query runs, so a command Hermes flags as dangerous follows `approvals.single_query_mode` (default `deny`: the command is blocked and the agent is told). The plugin grants nothing on your behalf. + +## Tests + +```bash +python harness-plugins/hermes/test/test_plugin.py +``` + +Standard library plus Node (the fake CLIs are `.js` entries); the dashboard API tests also need FastAPI and are skipped without it (the hermes-webui backend's do not). The suite loads the plugin the way Hermes's loader does and the backend the way the dashboard does, with fakes for the plugin context and the `hermes` binary, and checks argv and kickoff prompts against the compiled TypeScript engine when `@a2app/integration-starter` is built. diff --git a/harness-plugins/hermes/__init__.py b/harness-plugins/hermes/__init__.py index 33f1558..3616d66 100644 --- a/harness-plugins/hermes/__init__.py +++ b/harness-plugins/hermes/__init__.py @@ -1,183 +1,244 @@ -"""Agent App Framework plugin for Hermes. - -A native Hermes backend plugin (Hermes plugins are Python). It registers tools -that build and operate Agent Apps by shelling the framework CLIs, so anything an -agent does over the A2App protocol, Hermes can do through these tools. Drop this -directory into `~/.hermes/plugins/`; set `A2APP_CLI` (operate, default `a2app`) and `AGENT_APP_CLI` -(build/evolve, default `agent-app`) to override the binaries. - -Every tool shells the real CLI and returns its output verbatim — a guard -rejection (invalid enum, relative date, …) is useful data, so it is returned to -the model rather than swallowed. +"""Agent App Framework plugin for Hermes: the agent side. + +`register(ctx)` wires the framework into Hermes through its own plugin API: + + - the build + operate tools (`agent_app_*`, toolset `agent_app`), each + shelling a real `agent-app` or `a2app` verb; + - the six framework skills, as plugin skills (`agent-app:creator`, ...); + - a system-prompt section that routes any app request to the owning skill, + which is how the model finds plugin skills at all (Hermes keeps them out + of the `` index); + - the `/agent-app ` command, which hands the request to the agent: + `ctx.inject_message` in the classic CLI, a `pre_gateway_dispatch` rewrite + on messaging platforms; + - `hermes agent-app `, a passthrough to both CLIs; + - the `hermes` route in `$A2APP_HOME/harnesses.json`, so + `agent-app bridge` can start Hermes when an app queues work; + - inside hermes-webui only, the Agent Apps tab's loopback backend + (`webui_backend.py`), since the web UI mounts no plugin API. + +The Agent Apps dashboard tab lives beside this, in `dashboard/` (Hermes loads +dashboard plugins in the dashboard process, not through `register`), and its +hermes-webui extension in `webui/`. """ +from __future__ import annotations + +import argparse +import logging import os -import subprocess - -CLI = os.environ.get("A2APP_CLI", "a2app") -# Build/evolve is a second binary; the operate client rejects build verbs by -# design (framework spec 5.1), so each verb is routed to its owner. -FRAMEWORK_CLI = os.environ.get("AGENT_APP_CLI", "agent-app") -FRAMEWORK_VERBS = { - "scaffold", "import", "validate", "toolkit-sync", "adapter-sync", "serve", "stop", - "list", "global", "skills", "dev", "promote", "backup", "restore", +import re +import sys + +from . import engine, webui_backend + +logger = logging.getLogger(__name__) + +# How `agent-app bridge` starts Hermes when an app queues work: one +# turn, run in the app's directory, then exit (`-Q` exits after answering +# even on a TTY; `--cli` keeps a `display.interface: tui` default from taking +# over a run nobody is watching). The prompt goes in on stdin, as the tab's +# own turns send theirs: an argument is capped by the OS (128 KiB on Linux, +# 32 KiB for a whole Windows command line) and a task's payload has no such +# bound. The framework has no built-in Hermes profile, so without this entry +# the bridge cannot see Hermes. Dangerous commands follow the user's +# `approvals.single_query_mode` (deny by default); this route grants nothing +# on their behalf. +HERMES_PROFILE = { + "id": "hermes", + "name": "Hermes", + "routes": [{"mode": "headless", "command": "hermes", "args": ["--cli", "chat", "-Q", "--query-file", "-"], "input": "stdin"}], } -# The closed set of verbs that address every app rather than one, and so take no -# app argument. Closed is what makes _verb exact: it never has to inspect a -# positional to guess what it is. -REGISTRY_VERBS = {"list", "global", "skills"} - -def _verb(argv: list[str]) -> str: - """The verb in an app-first argv. +# One line per skill for the routing section; the full descriptions are long +# and the section has a hard size limit. +_SKILL_ROLES = { + "creator": "build a NEW app", + "modify": "change an EXISTING app", + "operator": "use, read, or run an app (no code changes)", + "importer": "adopt existing software", + "walk-verify": "independent verification, never by the builder", + "connect": "a published app you do not own", +} - Both CLIs are written ` [args]`, so the verb is the - SECOND element — except for a registry verb, which takes no app and - therefore stands alone in first position (framework spec 5.1). - """ - if argv and argv[0] in REGISTRY_VERBS: - return argv[0] - return argv[1] if len(argv) > 1 else "" +# The prefix a routed /agent-app request carries. A leading slash would send +# the injected text back through command dispatch. +ROUTED_PREFIX = "Agent App Framework request (/agent-app):" + +USAGE = ( + "Usage: /agent-app \n" + 'e.g. "/agent-app build a CRM", "/agent-app add a report to my expense app", ' + '"/agent-app operate atlas-erp"' +) + +# Where a plugin command cannot start a turn (the TUI, and the dashboard +# chat): say what to do instead of failing silently. +NO_TURN_HERE = ( + "This interface does not let a plugin command start a turn. Send the request as a " + 'normal message instead, e.g. "build a CRM as an Agent App": the Agent App skills ' + "are loaded and the request is routed to the right one." +) + +# `/agent-app ...` on a messaging platform. Telegram allows no hyphen in a +# command, and appends `@botname` in groups. +_GATEWAY_COMMAND = re.compile(r"^/agent[-_]app(?:@\S+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL) + + +def routed_prompt(request: str) -> str: + return f"{ROUTED_PREFIX} {request}" + + +def guidance(skills: list[tuple[str, object, dict]], namespace: str, skills_home: str | None) -> str: + """The system-prompt section that makes the framework reachable.""" + lines = [ + "## Agent App Framework", + "", + "An Agent App is a self-contained full-stack web app operated through its A2App adapter " + "(the `a2app` CLI), never by driving its UI. Build and evolve go through the `agent-app` CLI. " + "When a message asks to build, change, run, import, verify, or connect to an app (a message " + f'beginning "{ROUTED_PREFIX}" came from the /agent-app command), load the matching framework ' + "skill with skill_view and follow it end to end:", + ] + for name, _path, meta in skills: + role = _SKILL_ROLES.get(name) or str(meta.get("description", ""))[:120] + lines.append(f"- `{namespace}:{name}`: {role}") + lines += [ + "", + "Do not build or operate an app from general knowledge outside these skills. " + "`agent-app list` locates every known Agent App, and a command accepts a registered app id " + "or name wherever it takes a directory. The `agent_app_*` tools wrap the same CLIs.", + ] + if skills_home: + lines.append( + f"The skills' shared files (QUALITY.md, index.json) are in `{skills_home}`; read them " + "with your file tools when a skill cites them." + ) + return "\n".join(lines) -def _run(argv: list[str]) -> str: - # Run a JS entry (…/cli.js) with node; never use a shell, so field values - # reach the CLI as literal arguments. - exe = FRAMEWORK_CLI if _verb(argv) in FRAMEWORK_VERBS else CLI - cmd = ["node", exe, *argv] if exe.endswith((".js", ".mjs", ".cjs")) else [exe, *argv] +def agent_cwd() -> str | None: + """The calling session's working directory, so a relative `dir` means what + it means in the agent's terminal. A host running many sessions at once + (the gateway, the TUI, a web UI) keeps each one's directory per session; + TERMINAL_CWD in the environment is only right for a single-session CLI, + so it is the fallback for a Hermes without `agent.runtime_cwd`. A remote + terminal backend's path does not exist here, and is ignored.""" try: - proc = subprocess.run(cmd, capture_output=True, text=True) - except FileNotFoundError: - return f"framework CLI not found (looked for {exe!r}). Install it with `npm i -g agent-app`, or set A2APP_CLI / AGENT_APP_CLI to the binary paths." - out = ((proc.stdout or "") + (proc.stderr or "")).strip() - return out or f"(exit {proc.returncode})" - - -# --------------------------------------------------------------- handlers - -def _describe(args: dict, **_kw) -> str: - # Describe is navigational: the path names ONE place in the app. - segments = [p for p in str(args.get("path", "")).split("/") if p] - return _run([str(args["dir"]), *segments]) - - -def _list(args: dict, **_kw) -> str: - a = [str(args["dir"]), "data", str(args["entity"]), "list"] - if args.get("filter"): - a += ["--filter", str(args["filter"])] - if args.get("sort"): - a += ["--sort", str(args["sort"])] - if args.get("limit") is not None: - a += ["--limit", str(args["limit"])] - return _run(a) - - -def _get(args: dict, **_kw) -> str: - return _run([str(args["dir"]), "data", str(args["entity"]), "get", str(args["id"])]) - - -def _create(args: dict, **_kw) -> str: - import json - return _run([str(args["dir"]), "data", str(args["entity"]), "create", "--json", json.dumps(args.get("fields") or {})]) - + from agent.runtime_cwd import resolve_agent_cwd -def _update(args: dict, **_kw) -> str: - import json - return _run([str(args["dir"]), "data", str(args["entity"]), "update", str(args["id"]), "--json", json.dumps(args.get("fields") or {})]) + cwd = str(resolve_agent_cwd()) + except Exception: + cwd = os.environ.get("TERMINAL_CWD") or "" + return cwd if cwd and os.path.isdir(cwd) else None -def _delete(args: dict, **_kw) -> str: - return _run([str(args["dir"]), "data", str(args["entity"]), "delete", str(args["id"])]) +def _tool_handler(tool: engine.Tool): + def handler(args: dict, **_kw) -> str: + return tool.call(args if isinstance(args, dict) else {}, cwd=agent_cwd()).text() + return handler -def _find(args: dict, **_kw) -> str: - return _run([str(args["dir"]), "--find", str(args["term"])]) +def _cli_setup(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "argv", + nargs=argparse.REMAINDER, + metavar="ARGS", + help="arguments for agent-app (build/evolve verbs) or a2app (operate); routed by verb", + ) -def _run_operation(args: dict, **_kw) -> str: - a = [str(args["dir"]), *[p for p in str(args["path"]).split("/") if p], str(args["operation"])] - for key, value in (args.get("fields") or {}).items(): - a += [f"--{key}", str(value)] - if args.get("approve"): - a += ["--approve", str(args["approve"])] - return _run(a) - -def _poll_tasks(args: dict, **_kw) -> str: - if args.get("status"): - return _run([str(args["dir"]), "tasks", "--status", str(args["status"])]) - return _run([str(args["dir"]), "tasks"]) - - -def _build(args: dict, **_kw) -> str: - a = [str(args["dir"]), "scaffold"] - if args.get("blueprint"): - a += ["--blueprint", str(args["blueprint"])] - if args.get("name"): - a += ["--name", str(args["name"])] - return _run(a) - - -def _validate(args: dict, **_kw) -> str: - return _run([str(args["dir"]), "validate", "--no-build"] if args.get("noBuild") else [str(args["dir"]), "validate"]) - - -# --------------------------------------------------------------- schemas - -_STR = {"type": "string"} -_DIR = {"type": "string", "description": "Agent App project directory"} -_ENTITY = {"type": "string", "description": "entity / collection name"} - - -def _schema(name: str, description: str, properties: dict, required: list[str]) -> dict: - return { - "name": name, - "description": description, - "parameters": {"type": "object", "properties": properties, "required": required}, - } +def _cli_run(args: argparse.Namespace) -> int: + argv = list(getattr(args, "argv", None) or []) + if argv and argv[0] == "--": + argv = argv[1:] + if not argv: + sys.stderr.write( + "usage: hermes agent-app [] [] | | list\n" + "Build/evolve verbs run agent-app; everything else runs a2app.\n" + ) + return 2 + return engine.run_passthrough(engine.bin_for(argv), argv) -_TOOLS = [ - (_schema("agent_app_describe", - 'Describe ONE place in an Agent App. `path` is empty for the root (its modules), "sales" for a ' - 'module, "sales/invoices" for an entity, "sales/invoices/INV-1" for a record and the operations ' - "its state allows. Every response names the legal next moves. No call returns the whole model.", - {"dir": _DIR, "path": _STR}, ["dir"]), _describe, "🔎"), - (_schema("agent_app_list", "List records of an entity (optional filter/sort/limit).", - {"dir": _DIR, "entity": _ENTITY, "filter": _STR, "sort": _STR, "limit": {"type": "integer"}}, ["dir", "entity"]), _list, "📋"), - (_schema("agent_app_get", "Fetch one record by id.", - {"dir": _DIR, "entity": _ENTITY, "id": _STR}, ["dir", "entity", "id"]), _get, "🔍"), - (_schema("agent_app_create", "Create a record; the app's guard validates it and rejections are returned verbatim.", - {"dir": _DIR, "entity": _ENTITY, "fields": {"type": "object"}}, ["dir", "entity", "fields"]), _create, "➕"), - (_schema("agent_app_update", "Update a record by id.", - {"dir": _DIR, "entity": _ENTITY, "id": _STR, "fields": {"type": "object"}}, ["dir", "entity", "id", "fields"]), _update, "✏️"), - (_schema("agent_app_delete", "Delete a record by id.", - {"dir": _DIR, "entity": _ENTITY, "id": _STR}, ["dir", "entity", "id"]), _delete, "🗑️"), - # No agent_app_operations: no global operation list exists. An operation is - # found on the screen it belongs to and invoked at the path identifying it. - (_schema("agent_app_find", - "Search entity, operation and module names across the app; returns their locations.", - {"dir": _DIR, "term": _STR}, ["dir", "term"]), _find, "🔎"), - (_schema("agent_app_run_operation", - "Invoke a declared operation at the path that identifies it. A destructive op returns " - "approval_required with a key; pass `approve` to execute.", - {"dir": _DIR, "path": _STR, "operation": _STR, "fields": {"type": "object"}, "approve": _STR}, - ["dir", "path", "operation"]), _run_operation, "▶️"), - (_schema("agent_app_poll_tasks", "Poll the app-to-agent task queue (default status: submitted).", - {"dir": _DIR, "status": _STR}, ["dir"]), _poll_tasks, "📥"), - (_schema("agent_app_build", "Scaffold a new Agent App from a blueprint.", - {"dir": _DIR, "blueprint": _STR, "name": _STR}, ["dir"]), _build, "🏗️"), - (_schema("agent_app_validate", "Run the validation + security gate.", - {"dir": _DIR, "noBuild": {"type": "boolean"}}, ["dir"]), _validate, "✅"), -] +def _on_pre_gateway_dispatch(event=None, **_kw): + text = getattr(event, "text", None) + if not isinstance(text, str): + return None + match = _GATEWAY_COMMAND.match(text.strip()) + if match is None: + return None + request = (match.group(1) or "").strip() + if not request: + return None # a bare command reaches its handler, which replies with usage + return {"action": "rewrite", "text": routed_prompt(request)} def register(ctx) -> None: """Called once by the Hermes plugin loader.""" - for schema, handler, emoji in _TOOLS: + namespace = getattr(getattr(ctx, "manifest", None), "name", None) or "agent-app" + + registration = engine.register_harness_profile(HERMES_PROFILE) + if registration["status"] == "registered": + logger.info("agent-app: %s", registration["detail"]) + elif registration["status"] == "refused": + logger.warning("agent-app: %s", registration["detail"]) + + for tool in engine.a2app_tools(): ctx.register_tool( - name=schema["name"], + name=tool.name, toolset="agent_app", - schema=schema, - handler=handler, - emoji=emoji, + schema=tool.schema(), + handler=_tool_handler(tool), + emoji=tool.emoji, + ) + + skills_home = engine.skills_dir() + skills = engine.framework_skills(skills_home) if skills_home is not None else [] + for name, path, meta in skills: + try: + ctx.register_skill(name, path, description=meta["description"], frontmatter=meta) + except (ValueError, OSError) as exc: + logger.warning("agent-app: skill %s not registered: %s", name, exc) + if not skills: + logger.warning( + "agent-app: the framework skills were not found (no staged skills/ and `agent-app skills --path` " + "gave none). %s", + engine.INSTALL_HINT, ) + + ctx.register_system_prompt_section( + "agent-app", + guidance(skills, namespace, str(skills_home) if skills_home is not None else None), + ) + + def slash(raw_args: str): + request = (raw_args or "").strip() + if not request: + return USAGE + if ctx.inject_message(routed_prompt(request)): + return None + return NO_TURN_HERE + + ctx.register_command( + "agent-app", + handler=slash, + description="Build, evolve, or operate an Agent App: routes the request to the right framework skill.", + args_hint="", + argument_mode="text", + ) + ctx.register_hook("pre_gateway_dispatch", _on_pre_gateway_dispatch) + + ctx.register_cli_command( + name="agent-app", + help="Run the Agent App Framework CLIs (build/evolve/operate an Agent App)", + setup_fn=_cli_setup, + handler_fn=_cli_run, + description="Passthrough to agent-app (build/evolve verbs) and a2app (operate), routed by verb.", + ) + + # Inside hermes-webui, which never mounts a plugin API, the Agent Apps + # extension needs this plugin to serve the tab's routes itself. Started + # on its own thread, never here: see webui_backend.maybe_start. + webui_backend.start_in_background( + lambda info: logger.info("agent-app: Agent Apps backend for hermes-webui at %s", info["base"]) + ) diff --git a/harness-plugins/hermes/dashboard/manager.css b/harness-plugins/hermes/dashboard/manager.css new file mode 100644 index 0000000..448e967 --- /dev/null +++ b/harness-plugins/hermes/dashboard/manager.css @@ -0,0 +1,145 @@ +/* + * The Agent Apps dashboard tab. Every rule is scoped under .aa: the dashboard + * loads plugin CSS into the whole page. Colours, fonts and radii come from the + * dashboard's own theme tokens (the --color-* set its shadcn components use), + * so the tab follows whichever theme is active; the fallbacks are the default + * theme's values. Tailwind classes are not used because the dashboard only + * generates the ones its own sources mention. + */ +.aa { + --aa-fg: var(--color-foreground, #ffe6cb); + --aa-muted: var(--color-muted-foreground, color-mix(in srgb, #ffe6cb 60%, transparent)); + --aa-border: var(--color-border, color-mix(in srgb, #ffe6cb 15%, transparent)); + --aa-border-strong: color-mix(in srgb, var(--aa-fg) 24%, transparent); + --aa-base: var(--background-base, #041c1c); + --aa-card: var(--color-card, color-mix(in srgb, #ffe6cb 4%, #041c1c)); + --aa-hover: color-mix(in srgb, var(--aa-fg) 8%, transparent); + --aa-subtle: color-mix(in srgb, var(--aa-fg) 5%, transparent); + --aa-primary: var(--color-primary, var(--aa-fg)); + --aa-primary-fg: var(--color-primary-foreground, var(--aa-base)); + --aa-ring: var(--color-ring, var(--aa-fg)); + --aa-ok: var(--color-success, #4ade80); + --aa-warn: var(--color-warning, #ffbd38); + --aa-danger: var(--color-destructive, #fb2c36); + --aa-radius: var(--radius-lg, 0.5rem); + --aa-radius-sm: var(--radius-sm, 4px); + --aa-font: var(--theme-font-sans, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); + --aa-mono: var(--theme-font-mono, ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace); + --aa-ease: cubic-bezier(0.16, 1, 0.3, 1); + + display: flex; + flex-direction: column; + min-height: 420px; + border: 1px solid var(--aa-border); + border-radius: var(--aa-radius); + overflow: hidden; + background: var(--aa-base); + color: var(--aa-fg); + font: 400 14px/1.55 var(--aa-font); + -webkit-font-smoothing: antialiased; +} +.aa *, .aa *::before, .aa *::after { box-sizing: border-box; } +.aa [hidden] { display: none !important; } +.aa button { font: inherit; color: inherit; cursor: pointer; } +.aa :focus-visible { outline: 2px solid var(--aa-ring); outline-offset: 2px; } + +/* ── Tab strip ── */ +.aa-tabbar { display: flex; align-items: flex-end; padding: 6px 8px 0; border-bottom: 1px solid var(--aa-border); flex: none; overflow-x: auto; scrollbar-width: none; } +.aa-tabbar::-webkit-scrollbar { display: none; } +.aa-tab { position: relative; display: flex; align-items: center; gap: 7px; max-width: 200px; min-width: 0; height: 30px; padding: 0 11px; border: 1px solid transparent; border-bottom: none; border-radius: 8px 8px 0 0; background: transparent; color: var(--aa-muted); font-size: 12px; font-weight: 550; white-space: nowrap; } +.aa-tab:not(.active):not(:hover) + .aa-tab:not(.active):not(:hover)::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 1px; background: var(--aa-border); } +.aa-tab:hover { color: var(--aa-fg); background: var(--aa-subtle); } +.aa-tab.active { background: var(--aa-card); border-color: var(--aa-border); color: var(--aa-fg); box-shadow: 0 1px 0 var(--aa-card); } +.aa-tab.offline .aa-tab-name { opacity: 0.55; } +.aa-tab.active.offline .aa-tab-name { opacity: 0.75; } +.aa-tab-name { overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.aa-tab-more { flex: none; display: none; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: var(--aa-radius-sm); color: var(--aa-muted); line-height: 1; font-size: 12px; } +.aa-tab.active .aa-tab-more { display: inline-flex; } +.aa-tab-more:hover { background: var(--aa-hover); color: var(--aa-fg); } +.aa-newtab { margin-left: auto; flex: none; font-weight: 600; } + +.aa-dot { flex: none; display: inline-block; width: 6px; height: 6px; border-radius: 9999px; } +.aa-dot.ok { background: var(--aa-ok); box-shadow: 0 0 6px color-mix(in srgb, var(--aa-ok) 50%, transparent); } +.aa-dot.warn { background: var(--aa-warn); } +.aa-dot.off { background: var(--aa-muted); opacity: 0.5; } +.aa-dot.busy { background: var(--aa-warn); animation: aa-pulse 1.4s ease-in-out infinite; } +@keyframes aa-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } + +/* ── Layout ── */ +.aa-main { flex: 1; display: flex; min-height: 0; } +.aa-view { flex: 1; min-width: 0; position: relative; background: var(--aa-card); } +.aa-frames { position: absolute; inset: 0; } +.aa-frames iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: none; background: #fff; } +.aa-center { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; overflow: auto; } +.aa-card { width: 100%; max-width: 520px; border: 1px solid var(--aa-border); background: var(--aa-base); border-radius: var(--aa-radius); padding: 24px; } +.aa-card h2 { margin: 0 0 4px; font-size: 15px; font-weight: 600; color: var(--aa-fg); } +.aa-sub { font-size: 13px; color: var(--aa-muted); margin: 0 0 16px; line-height: 1.5; } +.aa-meta { font: 12px/1.6 var(--aa-mono); color: var(--aa-muted); word-break: break-all; margin: 0 0 16px; } +.aa-row { display: flex; gap: 10px; flex-wrap: wrap; } + +/* ── Buttons ── */ +.aa-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 1px solid var(--aa-border); background: var(--aa-subtle); padding: 8px 14px; border-radius: var(--aa-radius); font-size: 13px; font-weight: 500; text-decoration: none; color: var(--aa-fg); word-break: break-all; } +.aa-btn:hover { background: var(--aa-hover); border-color: var(--aa-border-strong); } +.aa-btn:disabled { opacity: 0.55; cursor: default; } +.aa-btn.primary { border-color: var(--aa-primary); background: var(--aa-primary); color: var(--aa-primary-fg); } +.aa-btn.primary:hover { opacity: 0.9; } + +/* ── Menu ── */ +.aa-menu { position: fixed; z-index: 60; min-width: 180px; padding: 4px; border: 1px solid var(--aa-border-strong); border-radius: var(--aa-radius); background: var(--aa-base); box-shadow: 0 1px 2px rgb(0 0 0 / 0.18), 0 8px 24px rgb(0 0 0 / 0.28); animation: aa-menu-in 140ms var(--aa-ease); } +@keyframes aa-menu-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } +.aa-menu button { display: flex; width: 100%; align-items: center; min-height: 28px; border: none; border-radius: var(--aa-radius-sm); background: transparent; padding: 0 10px; font-size: 13px; text-align: left; } +.aa-menu button:hover { background: var(--aa-hover); } +.aa-menu button.danger { color: var(--aa-danger); } +.aa-menu button.danger:hover { background: color-mix(in srgb, var(--aa-danger) 10%, transparent); } +.aa-menu hr { border: none; border-top: 1px solid var(--aa-border); margin: 4px 2px; } + +/* ── New-app form ── */ +.aa-form { width: 100%; max-width: 560px; } +.aa-form h1 { font-size: 22px; font-weight: 650; letter-spacing: -0.02em; line-height: 1.2; margin: 0 0 4px; color: var(--aa-fg); } +.aa-lead { font-size: 12px; color: var(--aa-muted); margin: 0 0 20px; } +.aa-form label { display: block; margin: 14px 0 6px; font-size: 13px; font-weight: 600; } +.aa-input { width: 100%; padding: 7px 10px; border-radius: var(--aa-radius); color: var(--aa-fg); border: 1px solid var(--aa-border); background: var(--aa-base); font: inherit; } +.aa-input::placeholder { color: var(--aa-muted); } +.aa-input:focus-visible { outline: none; border-color: var(--aa-border-strong); box-shadow: 0 0 0 2px color-mix(in srgb, var(--aa-ring) 25%, transparent); } +select.aa-input option { background: var(--aa-base); color: var(--aa-fg); } +textarea.aa-input { min-height: 120px; resize: vertical; } +.aa-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.aa-go { width: 100%; margin-top: 20px; padding: 11px 14px; font-weight: 600; } +.aa-err { margin-top: 12px; font-size: 13px; font-weight: 600; color: var(--aa-danger); white-space: pre-wrap; } +.aa-buildstamp { margin: 16px 0 0; font-size: 11px; color: var(--aa-muted); opacity: 0.7; } + +/* ── Session panel ── */ +.aa-resizer { flex: none; width: 5px; margin: 0 -2px; cursor: col-resize; z-index: 5; touch-action: none; } +.aa-resizer:hover, .aa-resizer.dragging { background: color-mix(in srgb, var(--aa-fg) 25%, transparent); } +.aa-side { width: 380px; min-width: 280px; max-width: 70%; flex: none; display: flex; flex-direction: column; border-left: 1px solid var(--aa-border); background: var(--aa-base); } +.aa-side-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--aa-border); flex: none; } +.aa-side-title { font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; } +.aa-side-sub { font-size: 11px; color: var(--aa-muted); flex: none; } +.aa-side-close { flex: none; border: none; background: transparent; color: var(--aa-muted); width: 22px; height: 22px; border-radius: var(--aa-radius-sm); padding: 0; line-height: 1; } +.aa-side-close:hover { background: var(--aa-hover); color: var(--aa-fg); } +.aa-log { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; } +.aa-msg { max-width: 100%; font-size: 13px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; } +.aa-msg.user { align-self: flex-end; max-width: 88%; background: var(--aa-subtle); border: 1px solid var(--aa-border); border-radius: 14px 14px 4px 14px; padding: 8px 12px; } +.aa-msg.assistant { align-self: flex-start; max-width: 95%; } +.aa-toolsline { display: flex; align-items: center; gap: 6px; color: var(--aa-muted); font-size: 11px; font-weight: 550; } +.aa-toolsline::before { content: "\2261"; font-size: 13px; opacity: 0.7; } +.aa-toolrow { max-width: 100%; } +.aa-toolrow summary { cursor: pointer; color: var(--aa-muted); font-size: 11px; font-weight: 550; user-select: none; } +.aa-toolrow summary:hover { color: var(--aa-fg); } +.aa-toolrow pre, .aa-status pre { font: 11px/1.5 var(--aa-mono); background: var(--aa-card); border: 1px solid var(--aa-border); border-radius: var(--aa-radius-sm); padding: 8px 10px; margin: 6px 0 0; max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-word; } +.aa-log-empty { margin: auto; text-align: center; color: var(--aa-muted); font-size: 13px; } +.aa-status { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-top: 1px solid var(--aa-border); font-size: 12px; color: var(--aa-muted); } +.aa-status.error { display: block; color: var(--aa-danger); } +.aa-status-title { font-weight: 600; } +.aa-status.error pre { color: var(--aa-fg); max-height: 160px; } +.aa-composer { padding: 10px 12px 12px; border-top: 1px solid var(--aa-border); flex: none; } +.aa-composer-box { display: flex; align-items: flex-end; gap: 8px; border: 1px solid var(--aa-border); background: var(--aa-card); border-radius: 14px; padding: 7px 7px 7px 12px; } +.aa-composer-box:focus-within { border-color: var(--aa-border-strong); box-shadow: 0 0 0 2px color-mix(in srgb, var(--aa-ring) 18%, transparent); } +/* max-height = 6 rows: 6 x (13px x 1.45) + 4px padding top and bottom */ +.aa-composer-box textarea { flex: 1; min-width: 0; max-height: 122px; resize: none; border: none; background: transparent; color: var(--aa-fg); font: inherit; font-size: 13px; line-height: 1.45; padding: 4px 0; outline: none; } +.aa-composer-box textarea::placeholder { color: var(--aa-muted); } +.aa-send { flex: none; display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 9999px; border: 1px solid var(--aa-primary); background: var(--aa-primary); color: var(--aa-primary-fg) !important; padding: 0; } +.aa-send:disabled { cursor: default; border-color: var(--aa-border); background: var(--aa-hover); color: var(--aa-muted) !important; } +.aa-send svg { display: block; } +.aa-spinner { width: 22px; height: 22px; border: 2px solid var(--aa-border-strong); border-top-color: var(--aa-fg); border-radius: 9999px; animation: aa-spin 0.8s linear infinite; margin: 0 auto 14px; } +@keyframes aa-spin { to { transform: rotate(360deg); } } diff --git a/harness-plugins/hermes/dashboard/manager.js b/harness-plugins/hermes/dashboard/manager.js new file mode 100644 index 0000000..1f8fae7 --- /dev/null +++ b/harness-plugins/hermes/dashboard/manager.js @@ -0,0 +1,590 @@ +/** + * The Agent Apps tab: the page. + * + * A classic script, so no modules and no JSX. It runs in two hosts: + * - `hermes dashboard`, which loads it as a plugin entry: the registered + * component (React from the plugin SDK) only hosts a container that the + * manager mounts into, and calls go through the SDK's fetchJSON so the + * dashboard's session token rides along; + * - hermes-webui, whose extension (../webui/agent-apps.js) calls + * `window.__AGENT_APP_MANAGER__.mount(root, host)` with a fetchJSON bound to + * the plugin's loopback backend. + * + * Layout: a browser-style tab strip (one tab per Agent App plus "New +"), a + * content area embedding the active app, and a resizable side panel rendering + * that app's Hermes session with a composer. Data flows through the plugin's + * API (`/api/plugins/agent-app/*`); apps are polled every 4s and the open + * transcript every 2.5s, only while the page is visible. DOM is built with + * createElement/textContent so app-provided strings never reach innerHTML. + * + * An app runs on the Hermes machine's loopback interface, so it is embedded + * only when the page itself is open over http on loopback; anywhere else the + * frame would point at the viewer's own machine (or be blocked as mixed + * content), and the page says so instead of showing a broken frame. + */ +(function () { + "use strict"; + var SDK = window.__HERMES_PLUGIN_SDK__; + + var NAME = "agent-app"; + var API = "/api/plugins/" + NAME; + var LOOPBACK_HTTP = location.protocol === "http:" && /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname); + + function el(tag, cls, text) { + var n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; + } + + /** One API call. Failures resolve to `{ ok: false, message }` rather than + * throwing. On a non-2xx, fetchJSON throws an ApiError whose message is + * already the body's `message`/`detail` and which keeps the raw body on + * `.body`; older dashboards threw ": ". Either way the body + * is this API's own JSON. */ + function makeApi(fetchJSON) { + return function api(path, body) { + var init = body + ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } + : undefined; + return Promise.resolve().then(function () { return fetchJSON(API + path, init); }).catch(function (e) { + var text = String((e && e.message) || e); + var legacy = /^\d+: ([\s\S]*)$/.exec(text); + var raw = e && typeof e.body === "string" ? e.body : legacy ? legacy[1] : ""; + try { + var j = JSON.parse(raw); + if (j && typeof j === "object" && (j.message || j.detail)) return { ok: false, message: j.message || j.detail }; + } catch (_) { /* not JSON */ } + return { ok: false, message: text }; + }); + }; + } + + /** + * Mount the manager into `root`. `host`: + * - fetchJSON(url, init): required; resolves the parsed body, throws on a non-2xx + * - embeddable: whether running apps may be framed (default: page is http on loopback) + * - fill: size from CSS instead of fitting the viewport below `root` + * - unreachableHint: what to tell the user when the API does not answer + * Returns a dispose function, which also carries refresh() and setEmbeddable(bool). + */ + function mount(root, host) { + host = host || {}; + var api = makeApi(host.fetchJSON); + var EMBEDDABLE = host.embeddable != null ? !!host.embeddable : LOOPBACK_HTTP; + var noFrameReason = ""; + var S = { + meta: { blueprints: [], build: "dev" }, + rows: [], active: null, side: false, + log: [], busy: false, error: null, sending: false, + acting: new Set(), confirmDelete: null, + }; + var timers = []; + var disposers = []; + function on(target, type, fn, opts) { + target.addEventListener(type, fn, opts); + disposers.push(function () { target.removeEventListener(type, fn, opts); }); + } + + /* ── Skeleton ── */ + var tabbar = el("div", "aa-tabbar"); + var main = el("div", "aa-main"); + var view = el("section", "aa-view"); + var framesBox = el("div", "aa-frames"); + var panel = el("div", "aa-center"); + panel.hidden = true; + view.append(framesBox, panel); + var resizer = el("div", "aa-resizer"); + resizer.hidden = true; + var side = el("aside", "aa-side"); + side.hidden = true; + var sideHead = el("div", "aa-side-head"); + var sideTitle = el("span", "aa-side-title"); + var sideClose = el("button", "aa-side-close", "✕"); + sideClose.title = "Close"; + sideHead.append(sideTitle, el("span", "aa-side-sub", "session"), sideClose); + var logBox = el("div", "aa-log"); + var status = el("div", "aa-status"); + status.hidden = true; + var composer = el("div", "aa-composer"); + var box = el("div", "aa-composer-box"); + var chatIn = el("textarea"); + chatIn.rows = 1; + chatIn.placeholder = "Ask for a change, a task, a report…"; + var chatSend = el("button", "aa-send"); + chatSend.title = "Send"; + chatSend.disabled = true; + chatSend.innerHTML = ''; + box.append(chatIn, chatSend); + composer.append(box); + side.append(sideHead, logBox, status, composer); + main.append(view, resizer, side); + var menu = el("div", "aa-menu"); + menu.hidden = true; + root.append(tabbar, main, menu); + + /* ── Size: fill the viewport below the dashboard's page header (or let the host's CSS size it) ── */ + function fit() { + var top = root.getBoundingClientRect().top; + root.style.height = Math.max(420, Math.floor(window.innerHeight - top - 24)) + "px"; + } + if (!host.fill) { + on(window, "resize", fit); + requestAnimationFrame(fit); + } + + /* ── Data ── */ + function refresh() { + return api("/apps").then(function (data) { + if (!data.ok) { + S.apiError = data.message || "no response"; + if (S.active === null) S.active = "new"; + render(); + return; + } + if (S.apiError || !S.meta.loaded) loadMeta().then(render); + S.apiError = null; + S.rows = data.rows; + if (S.active === null) S.active = S.rows.length ? S.rows[0].path : "new"; + if (S.active !== "new" && !S.rows.some(function (r) { return r.path === S.active; })) { + S.active = S.rows.length ? S.rows[0].path : "new"; + } + render(); + }); + } + function activeRow() { + return S.rows.find(function (r) { return r.path === S.active; }) || null; + } + + /* ── Tab strip ── */ + function dotClass(r) { + if (r.building) return "busy"; + if (r.status === "running") return "ok"; + if (r.status === "unreachable") return "warn"; + return "off"; + } + function renderTabs() { + tabbar.replaceChildren(); + S.rows.forEach(function (r) { + var t = el("button", "aa-tab" + (r.path === S.active ? " active" : "") + (r.status !== "running" && !r.building ? " offline" : "")); + t.appendChild(el("span", "aa-dot " + dotClass(r))); + t.appendChild(el("span", "aa-tab-name", r.name)); + var more = el("span", "aa-tab-more", "⋯"); + more.title = "App actions"; + more.setAttribute("role", "button"); + more.onclick = function (e) { e.stopPropagation(); openMenu(r, more); }; + t.appendChild(more); + t.onclick = function () { S.active = r.path; S.confirmDelete = null; closeMenu(); render(); }; + tabbar.appendChild(t); + }); + var nt = el("button", "aa-tab aa-newtab" + (S.active === "new" ? " active" : ""), "New +"); + nt.onclick = function () { S.active = "new"; closeMenu(); render(); }; + tabbar.appendChild(nt); + } + + /* ── "⋯" menu ── */ + function openMenu(r, anchor) { + menu.replaceChildren(); + function item(label, cls, fn) { var b = el("button", cls, label); b.onclick = fn; menu.appendChild(b); } + if (r.status === "running") { + item("Pause", "", function () { closeMenu(); act(r, "stop"); }); + if (r.url) item("Open in browser", "", function () { closeMenu(); window.open(r.url, "_blank", "noopener"); }); + } else if (!r.building) { + item("Launch", "", function () { closeMenu(); act(r, "serve"); }); + } + item(S.side ? "Hide session" : "Show session", "", function () { closeMenu(); toggleSide(); }); + menu.appendChild(el("hr")); + if (S.confirmDelete === r.path) { + item("Confirm delete — removes files", "danger", function () { closeMenu(); removeApp(r); }); + } else { + item("Delete…", "danger", function () { S.confirmDelete = r.path; openMenu(r, anchor); }); + } + var rect = anchor.getBoundingClientRect(); + menu.hidden = false; + menu.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - menu.offsetWidth - 8)) + "px"; + menu.style.top = rect.bottom + 6 + "px"; + } + function closeMenu() { menu.hidden = true; S.confirmDelete = null; } + // The event's own path, not menu.contains(target): "Delete…" re-renders the + // menu, which detaches the clicked button before this listener runs, and a + // detached target would read as an outside click and close the confirm step. + on(document, "click", function (e) { if (e.composedPath().indexOf(menu) < 0) closeMenu(); }); + + /* ── Lifecycle actions ── */ + function act(r, verb) { + S.acting.add(r.path); render(); + return api("/app/" + verb, { path: r.path }).then(function (out) { + S.acting.delete(r.path); + if (!out.ok && out.message) S.notice = { path: r.path, text: out.message }; + return refresh(); + }); + } + function removeApp(r) { + S.acting.add(r.path); render(); + return api("/app/remove", { path: r.path }).then(function (out) { + S.acting.delete(r.path); + if (!out.ok && out.message) S.notice = { path: r.path, text: out.message }; + else S.active = null; + return refresh(); + }); + } + + /* ── Views ── */ + var frames = new Map(); + // Rebuild the panel only when its content key changes, so polling never + // wipes in-progress form input. + var viewKey = ""; + function renderView() { + var r = activeRow(); + var showFrame = !!(r && r.status === "running" && r.url && EMBEDDABLE); + frames.forEach(function (f, path) { + var row = S.rows.find(function (x) { return x.path === path; }); + if (!row || row.status !== "running") { f.remove(); frames.delete(path); } + else f.style.display = showFrame && path === r.path ? "block" : "none"; + }); + if (showFrame && !frames.has(r.path)) { + var f = document.createElement("iframe"); + f.src = r.url; + f.title = r.name; + frames.set(r.path, f); + framesBox.appendChild(f); + } + panel.hidden = showFrame; + var notice = S.notice && r && S.notice.path === r.path ? S.notice.text : ""; + var key = showFrame + ? "frame:" + r.path + : S.apiError && !S.rows.length + ? "apierror:" + S.apiError + : S.active === "new" || !r + ? "new" + : ["app", r.path, r.status, r.building, r.buildEnded, S.acting.has(r.path), S.side, notice].join("|"); + if (key === viewKey) return; + viewKey = key; + if (showFrame) return; + panel.replaceChildren(); + if (S.apiError && !S.rows.length) { panel.appendChild(apiErrorPanel()); return; } + if (S.active === "new" || !r) { panel.appendChild(buildForm()); return; } + panel.appendChild(appPanel(r, notice)); + } + // The dashboard shows an enabled plugin's tab at once but mounts its API + // only at startup, so a fresh enable answers 404 until a restart. + function apiErrorPanel() { + var c = el("div", "aa-card"); + c.appendChild(el("h2", null, "The Agent Apps API is not reachable")); + c.appendChild(el("p", "aa-sub", (host.unreachableHint || "If you just enabled the plugin, restart `hermes dashboard`: it mounts plugin APIs at startup.") + " The page retries every few seconds.")); + c.appendChild(el("p", "aa-meta", S.apiError)); + return c; + } + function appPanel(r, notice) { + var c = el("div", "aa-card"); + var acting = S.acting.has(r.path); + if (r.building) { + c.appendChild(el("div", "aa-spinner")); + c.appendChild(el("h2", null, "The agent is building “" + r.name + "”")); + c.appendChild(el("p", "aa-sub", "It scaffolds, builds feature by feature, validates, walk-verifies, and serves the app. Watch it work in the session panel; the tab goes live the moment the app is up.")); + if (!S.side) { + var b = el("button", "aa-btn", "Show session"); + b.onclick = function () { toggleSide(true); }; + c.appendChild(b); + } + return c; + } + if (r.status === "running") { + c.appendChild(el("h2", null, r.name + " is running")); + c.appendChild(el("p", "aa-sub", (noFrameReason || "The app listens on the Hermes machine's loopback interface, so it can only be embedded when this page is open over http on that machine (http://127.0.0.1 or http://localhost).") + " Open it directly:")); + var link = el("a", "aa-btn primary", r.url); + link.href = r.url; + link.target = "_blank"; + link.rel = "noopener"; + c.appendChild(link); + return c; + } + if (r.status === "unreachable") { + c.appendChild(el("h2", null, r.name + " is unreachable")); + c.appendChild(el("p", "aa-sub", (r.port ? "Port " + r.port : "Its port") + " is answering, but not as this app. Stop whatever holds the port, or pause and relaunch.")); + } else if (r.buildEnded) { + c.appendChild(el("h2", null, "Build session ended")); + c.appendChild(el("p", "aa-sub", "The agent's build run for “" + r.name + "” finished, but the app is not running. Check the session for what happened, or try launching it.")); + } else { + c.appendChild(el("h2", null, r.name + " is offline")); + c.appendChild(el("p", "aa-sub", "The app is registered but not serving right now.")); + } + c.appendChild(el("p", "aa-meta", r.path)); + if (notice) c.appendChild(el("p", "aa-err", notice)); + var row = el("div", "aa-row"); + var launch = el("button", "aa-btn primary", acting ? "Launching…" : "Launch"); + launch.disabled = acting; + launch.onclick = function () { S.notice = null; act(r, "serve"); }; + row.appendChild(launch); + var sess = el("button", "aa-btn", "Show session"); + sess.onclick = function () { toggleSide(true); }; + row.appendChild(sess); + c.appendChild(row); + return c; + } + + /* ── New-app form ── */ + var form = {}; + function buildForm() { + var f = el("div", "aa-form"); + f.appendChild(el("h1", null, "Agent Apps")); + f.appendChild(el("p", "aa-lead", "Describe the app; the agent builds, verifies, and serves it here.")); + f.appendChild(el("label", null, "App name")); + form.name = el("input", "aa-input"); + form.name.placeholder = "Acme CRM"; + form.name.autocomplete = "off"; + f.appendChild(form.name); + f.appendChild(el("label", null, "What should it do?")); + form.req = el("textarea", "aa-input"); + form.req.placeholder = "Track contacts, companies, and deals. A pipeline board, per-contact activity log, and a weekly summary."; + f.appendChild(form.req); + var g = el("div", "aa-grid2"); + var c1 = el("div"); + c1.appendChild(el("label", null, "Stack")); + form.bp = el("select", "aa-input"); + S.meta.blueprints.forEach(function (b) { form.bp.appendChild(new Option(b, b)); }); + c1.appendChild(form.bp); + g.appendChild(c1); + var c2 = el("div"); + c2.appendChild(el("label", null, "Port (optional)")); + form.port = el("input", "aa-input"); + form.port.type = "number"; + form.port.placeholder = "auto"; + form.port.autocomplete = "off"; + c2.appendChild(form.port); + g.appendChild(c2); + f.appendChild(g); + var go = el("button", "aa-btn primary aa-go", "Build it"); + go.onclick = function () { submitBuild(go); }; + f.appendChild(go); + form.err = el("div", "aa-err"); + f.appendChild(form.err); + f.appendChild(el("p", "aa-buildstamp", "plugin build " + S.meta.build)); + return f; + } + function submitBuild(btn) { + var body = { + name: form.name.value.trim(), requirement: form.req.value.trim(), + blueprint: form.bp.value, port: form.port.value.trim(), + }; + form.err.textContent = ""; + if (!body.name || !body.requirement) { form.err.textContent = "Give the app a name and describe what it should do."; return; } + btn.disabled = true; + btn.textContent = "Starting…"; + api("/build", body).then(function (out) { + if (!out.ok) { form.err.textContent = out.message; btn.disabled = false; btn.textContent = "Build it"; return; } + S.active = out.path; + return refresh().then(function () { toggleSide(true); }); + }); + } + + /* ── Session panel ── */ + function toggleSide(force) { + S.side = force === true ? true : !S.side; + render(); + } + sideClose.onclick = function () { S.side = false; render(); }; + function pollLog() { + var row = S.side ? activeRow() : null; + if (!row) return Promise.resolve(); + return api("/session?app=" + encodeURIComponent(row.path)).then(function (out) { + // A late response for a tab the user already left is discarded. + if (!out.ok || !S.side || !activeRow() || activeRow().path !== row.path) return; + S.log = out.messages; + S.busy = !!out.busy; + S.error = out.error || null; + renderLog(); + }); + } + function prettify(text) { + try { return JSON.stringify(JSON.parse(text), null, 2); } catch (_) { return text; } + } + function nodesFor(m, i) { + var tools = m.tools || []; + var out = []; + if (m.role === "user") { + out.push(el("div", "aa-msg user", m.text)); + } else if (m.role === "assistant") { + if (tools.length) out.push(el("div", "aa-toolsline", "Used " + tools.join(", "))); + if (m.text) out.push(el("div", "aa-msg assistant", m.text)); + } else { + var d = document.createElement("details"); + d.className = "aa-toolrow"; + d.dataset.i = String(i); + d.appendChild(el("summary", null, tools[0] || (m.role === "system" ? "System" : "Tool result"))); + d.appendChild(el("pre", null, prettify(m.text))); + out.push(d); + } + return out; + } + // The transcript is append-only: an unchanged render touches nothing, + // growth appends only (so expanded tool rows and text selection survive), + // and a rare rewrite rebuilds while restoring expanded rows by position. + var logKeys = []; + function itemKey(m) { return m.role + "\u001f" + m.text + "\u001f" + (m.tools || []).join(","); } + function resetLogView() { logKeys = []; logBox.replaceChildren(); } + function renderStatus() { + status.replaceChildren(); + if (S.error) { + status.className = "aa-status error"; + status.appendChild(el("div", "aa-status-title", "The last turn failed")); + status.appendChild(el("pre", null, S.error)); + status.hidden = false; + } else if (S.busy) { + status.className = "aa-status"; + status.appendChild(el("span", "aa-dot busy")); + status.appendChild(el("span", null, "The agent is working…")); + status.hidden = false; + } else { + status.hidden = true; + } + } + function renderLog() { + renderStatus(); + var items = S.log.slice(-200); + if (!items.length) { + if (logKeys.length || !logBox.childElementCount) { + logKeys = []; + logBox.replaceChildren(el("div", "aa-log-empty", "No session activity yet.")); + } + return; + } + var keys = items.map(itemKey); + var grown = keys.length >= logKeys.length && logKeys.every(function (k, i) { return k === keys[i]; }); + if (grown && keys.length === logKeys.length) return; + var stick = logBox.scrollTop + logBox.clientHeight >= logBox.scrollHeight - 40; + if (grown) { + if (!logKeys.length) logBox.replaceChildren(); + for (var i = logKeys.length; i < items.length; i++) nodesFor(items[i], i).forEach(function (n) { logBox.appendChild(n); }); + } else { + var open = new Set(Array.prototype.map.call(logBox.querySelectorAll("details[open]"), function (d) { return d.dataset.i; })); + logBox.replaceChildren(); + items.forEach(function (m, i) { + nodesFor(m, i).forEach(function (n) { if (n.tagName === "DETAILS" && open.has(n.dataset.i)) n.open = true; logBox.appendChild(n); }); + }); + } + logKeys = keys; + if (stick) logBox.scrollTop = logBox.scrollHeight; + } + function sendChat() { + var text = chatIn.value.trim(); + var row = activeRow(); + if (!text || !row || S.sending) return; + S.sending = true; + chatIn.value = ""; + chatIn.style.height = "auto"; + chatSend.disabled = true; + S.log.push({ role: "user", text: text, tools: [] }); + S.busy = true; + S.error = null; + renderLog(); + api("/session/send", { path: row.path, name: row.name, text: text }).then(function (out) { + S.sending = false; + if (!out.ok) { S.error = out.message; S.busy = false; renderStatus(); } + }); + } + chatSend.onclick = sendChat; + on(chatIn, "keydown", function (e) { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendChat(); } + }); + on(chatIn, "input", function () { + chatSend.disabled = !chatIn.value.trim(); + chatIn.style.height = "auto"; + chatIn.style.height = Math.min(chatIn.scrollHeight, 122) + "px"; + }); + + /* ── Session panel resize ── */ + on(resizer, "pointerdown", function (e) { + e.preventDefault(); + resizer.setPointerCapture(e.pointerId); + resizer.classList.add("dragging"); + var startX = e.clientX; + var startW = side.getBoundingClientRect().width; + var mainW = main.getBoundingClientRect().width; + function move(ev) { + side.style.width = Math.min(mainW * 0.7, Math.max(280, startW + (startX - ev.clientX))) + "px"; + } + function up() { + resizer.classList.remove("dragging"); + resizer.removeEventListener("pointermove", move); + resizer.removeEventListener("pointerup", up); + } + resizer.addEventListener("pointermove", move); + resizer.addEventListener("pointerup", up); + }); + + /* ── Render root ── */ + // The session panel is bound to the active tab, so switching tabs + // switches the session and the New + tab shows none. + var sidePath = null; + function render() { + renderTabs(); + renderView(); + var row = activeRow(); + var showSide = S.side && row != null; + side.hidden = !showSide; + resizer.hidden = !showSide; + if (showSide) { + sideTitle.textContent = row.name; + if (sidePath !== row.path) { + sidePath = row.path; + S.log = []; + S.busy = false; + S.error = null; + resetLogView(); + renderStatus(); + pollLog().catch(function () {}); + } + } else { + sidePath = null; + } + } + + // A transient failure must not kill the poll loops; the next tick retries. + // A hidden page does not poll: every /apps poll runs `agent-app list`, and a + // host may keep the page mounted while showing another view. + function visible() { return root.isConnected && root.getClientRects().length > 0; } + function quiet(fn) { return function () { if (visible()) fn().catch(function () {}); }; } + function loadMeta() { + return api("/meta").then(function (meta) { + if (!meta.ok) return; + S.meta = { blueprints: meta.blueprints || [], build: meta.build || "dev", loaded: true }; + viewKey = ""; + }); + } + loadMeta().then(refresh).catch(function () {}); + timers.push(setInterval(quiet(refresh), 4000)); + timers.push(setInterval(quiet(pollLog), 2500)); + + function dispose() { + timers.forEach(clearInterval); + disposers.forEach(function (d) { d(); }); + root.replaceChildren(); + } + dispose.refresh = function () { return refresh().catch(function () {}); }; + // A host that learns frames are blocked (a CSP frame-src refusal) turns + // embedding off; the page then links to the app instead. + dispose.setEmbeddable = function (value, reason) { + if (EMBEDDABLE === !!value) return; + EMBEDDABLE = !!value; + noFrameReason = value ? "" : String(reason || ""); + frames.forEach(function (f) { f.remove(); }); + frames.clear(); + viewKey = ""; + render(); + }; + return dispose; + } + + window.__AGENT_APP_MANAGER__ = { mount: mount }; + + if (SDK && window.__HERMES_PLUGINS__) { + var React = SDK.React; + var AgentAppsPage = function () { + var ref = SDK.hooks.useRef(null); + SDK.hooks.useEffect(function () { return mount(ref.current, { fetchJSON: SDK.fetchJSON }); }, []); + return React.createElement("div", { ref: ref, className: "aa" }); + }; + window.__HERMES_PLUGINS__.register(NAME, AgentAppsPage); + } +})(); diff --git a/harness-plugins/hermes/dashboard/manifest.json b/harness-plugins/hermes/dashboard/manifest.json new file mode 100644 index 0000000..74f1ec5 --- /dev/null +++ b/harness-plugins/hermes/dashboard/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "agent-app", + "label": "Agent Apps", + "description": "Build, evolve, and operate Agent Apps: one tab per app, an embedded view of each running app, and its agent session beside it.", + "icon": "Package", + "version": "0.1.0", + "tab": { "path": "/agent-apps", "position": "end" }, + "entry": "manager.js", + "css": "manager.css", + "api": "plugin_api.py" +} diff --git a/harness-plugins/hermes/dashboard/plugin_api.py b/harness-plugins/hermes/dashboard/plugin_api.py new file mode 100644 index 0000000..e2f124f --- /dev/null +++ b/harness-plugins/hermes/dashboard/plugin_api.py @@ -0,0 +1,648 @@ +"""The Agent Apps dashboard tab: its backend. + +`hermes dashboard` imports this file once at startup and mounts `router` at +`/api/plugins/agent-app/`. The dashboard's own auth covers every route (the +page calls them through the SDK's `fetchJSON`, which attaches the session +token), and the dashboard serves the plugin only while it is enabled. + +Endpoints, all JSON: + + GET /meta blueprints, build stamp, where new apps go + GET /apps registered apps merged with builds in flight + POST /build start a build: the creator kickoff, in the app's session + GET /session?app= an app's transcript, whether a turn is running, the last error + POST /session/send a message into an app's session + POST /app/serve|stop|remove lifecycle, shelling `agent-app` + +Each app has its own Hermes session, with a deterministic id. A turn is one +`hermes --cli chat -Q --resume --query-file -` subprocess run in the +app's directory, the same way Hermes runs its own background workers (the +kanban dispatcher), so nothing here depends on agent internals and a turn +uses the user's configured model, tools and approval policy. Turns on one +session run one at a time, in order. The transcript is read back from the +session database. +""" +from __future__ import annotations + +import collections +import functools +import hashlib +import importlib.util +import os +import re +import shutil +import signal +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import Any, Callable + +from urllib.parse import parse_qs + +try: + from fastapi import APIRouter, Body + from fastapi.responses import JSONResponse +except ImportError: # loaded by the hermes-webui backend (../webui_backend.py), which needs only dispatch() + APIRouter = None + + +def _load_engine(): + # Hermes loads this file as a standalone module, not as part of the plugin + # package, so the shared engine one directory up is loaded by path. + name = "hermes_agent_app_engine" + if name in sys.modules: + return sys.modules[name] + spec = importlib.util.spec_from_file_location(name, Path(__file__).resolve().parent.parent / "engine.py") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +engine = _load_engine() + +# Sessions started here are integrations, not chats the user opened, so they +# stay out of Hermes's session lists; they live in this tab. +SESSION_SOURCE = "tool" + +# How long a build may run before the manager stops showing it as building. +BUILD_WAIT_S = 2 * 60 * 60 + +# Environment a turn must not inherit from the host process (the dashboard, or +# a web UI that hosts the tab): the gateway-session switches a desktop or +# Chat-tab session sets process-wide (they would route the turn's approvals to +# a gateway nobody watches), a session-less `/yolo` (it would auto-approve the +# turn's dangerous commands, which only the user's own config may do), the +# delegated-child and plugin-host markers, and a TUI preference. The +# per-session routing variables are scrubbed by prefix and, when Hermes is +# importable, from its own map of them, which is what the kanban dispatcher +# scrubs too. +_SCRUBBED_ENV = frozenset({ + "HERMES_TUI", "HERMES_GATEWAY_SESSION", "HERMES_EXEC_ASK", "HERMES_INTERACTIVE", + "HERMES_YOLO_MODE", "HERMES_DELEGATED_CHILD_CONTEXT", "HERMES_PLUGIN_HOST_PROCESS", +}) +_SCRUBBED_PREFIXES = ("HERMES_SESSION_", "HERMES_UI_SESSION_", "HERMES_CRON_", "HERMES_BROWSER_CONTROL_") + +# How long a cancelled turn gets to exit on SIGTERM before it is killed. +CANCEL_GRACE_S = 5.0 + + +def _session_scoped_vars() -> frozenset[str]: + try: + from gateway.session_context import _VAR_MAP + + return frozenset(_VAR_MAP) + except Exception: + return frozenset() + + +def turn_env(environ: dict[str, str] | None = None) -> dict[str, str]: + """The environment a session turn runs with: the host's, minus anything + that belongs to the host's own sessions or approval state.""" + source = os.environ if environ is None else environ + drop = _SCRUBBED_ENV | _session_scoped_vars() + return {k: v for k, v in source.items() if k not in drop and not k.startswith(_SCRUBBED_PREFIXES)} + + +def session_id_for(path: str) -> str: + """The app's session id: stable for the app's life, unique per directory, + and within the `[A-Za-z0-9_-]` set Hermes uses in file names.""" + base = re.sub(r"[^A-Za-z0-9_-]+", "-", os.path.basename(os.path.normpath(path))).strip("-")[:40] or "app" + digest = hashlib.sha1(os.path.normcase(os.path.abspath(path)).encode("utf-8")).hexdigest()[:8] + return f"agent-app-{base}-{digest}" + + +def session_preamble(name: str, path: str) -> str: + """Prepended to the first message of an app's session; later messages are + the user's text alone.""" + return ( + f'You are working on the Agent App "{name}" at `{path}`. For a code or feature change load ' + "the **modify** skill; for using the app (data, tasks, reports) load the **operator** skill. " + "Work through the `agent-app` and `a2app` CLIs, respect the ownership boundary, and never " + "drive the app UI." + ) + + +def _is_batch(path: str) -> bool: + return os.name == "nt" and path.lower().endswith((".cmd", ".bat")) + + +def _hermes_launchers() -> list[str]: + """Where Hermes's installers put the `hermes` launcher, for a host whose + PATH does not include it (a web UI started by a supervisor, say).""" + out = [os.path.join(os.path.expanduser("~"), ".local", "bin", "hermes")] + try: + from hermes_constants import get_hermes_home + + home = Path(get_hermes_home()) + out += [str(home / "bin" / "hermes.exe"), str(home / "bin" / "hermes")] + except Exception: + pass + return out + + +def hermes_argv() -> list[str]: + """How to start Hermes. HERMES_BIN overrides, as it does for the kanban + dispatcher. Otherwise, inside `hermes dashboard` this process IS Hermes, + running in its own venv, so its interpreter runs the same install (and + inherits the active profile's HERMES_HOME), and the module form never goes + through a Windows batch shim. A host that imports Hermes into a bare + interpreter (hermes-webui does) cannot start a child that way, so it uses + the `hermes` launcher instead.""" + override = os.environ.get("HERMES_BIN", "").strip() + if override: + if override.lower().endswith(".py"): + return [sys.executable, override] + found = shutil.which(override) or override + if not _is_batch(found): + return [found] + if sys.prefix != sys.base_prefix: + return [sys.executable, "-m", "hermes_cli.main"] + found = shutil.which("hermes") or next((p for p in _hermes_launchers() if os.path.isfile(p) and os.access(p, os.X_OK)), None) + if found is not None and not _is_batch(found): + return [found] + return [sys.executable, "-m", "hermes_cli.main"] + + +def view_message(row: dict) -> dict: + """Flatten a stored message to role, text, and tool names, so the page can + render prose and tool activity separately.""" + role = row.get("role") if isinstance(row.get("role"), str) else "" + content = row.get("content") + texts: list[str] = [] + if isinstance(content, str): + texts.append(content) + elif isinstance(content, list): + texts += [p["text"] for p in content if isinstance(p, dict) and isinstance(p.get("text"), str)] + tools: list[str] = [] + for call in row.get("tool_calls") or []: + if isinstance(call, dict): + fn = call.get("function") if isinstance(call.get("function"), dict) else {} + name = fn.get("name") or call.get("name") + if isinstance(name, str) and name: + tools.append(name) + if role == "tool" and isinstance(row.get("tool_name"), str) and row["tool_name"]: + tools = [row["tool_name"]] + return {"role": role, "text": "".join(texts), "tools": tools} + + +class HermesSessions: + """App sessions in Hermes's session database (`hermes_state.SessionDB`, + the active profile's state.db), opened per call as the dashboard does.""" + + @staticmethod + def _db(): + from hermes_state import SessionDB + + return SessionDB() + + def ensure(self, sid: str, title: str) -> None: + db = self._db() + try: + if db.get_session(sid) is None: + db.create_session(sid, source=SESSION_SOURCE) + try: + db.set_session_title(sid, title) + except Exception: # a title clash must not stop the build + pass + finally: + db.close() + + def messages(self, sid: str) -> list[dict]: + db = self._db() + try: + if db.get_session(sid) is None: + return [] + # Compacted rows are display history; dropping them would make + # earlier turns vanish once a long build compacts its context. + return db.get_messages(sid, include_compacted=True) + finally: + db.close() + + def delete(self, sid: str) -> None: + db = self._db() + try: + if db.get_session(sid) is not None: + db.delete_session(sid) + finally: + db.close() + + +def _stop_tree(proc: subprocess.Popen, grace: float = CANCEL_GRACE_S) -> None: + """Stop a turn and everything it started. On POSIX the turn leads its own + process group: SIGTERM, then SIGKILL if the group outlives `grace`. Windows + has no graceful stop for a windowless tree, so it is killed outright.""" + if os.name == "nt": + subprocess.run( + ["taskkill", "/PID", str(proc.pid), "/T", "/F"], + capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + return + try: + os.killpg(proc.pid, signal.SIGTERM) + except OSError: + proc.terminate() + return + + def escalate() -> None: + try: + proc.wait(timeout=grace) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except OSError: + proc.kill() + + threading.Thread(target=escalate, name=f"agent-app:kill:{proc.pid}", daemon=True).start() + + +class _Session: + def __init__(self) -> None: + self.queue: collections.deque = collections.deque() + self.proc: subprocess.Popen | None = None + self.working = False + self.error: str | None = None + + +class TurnRunner: + """Runs Hermes turns as subprocesses, one at a time per session, in the + order they were sent. Each run's output is appended to + `/logs/.log`; a failed run's tail is kept as the + session's last error, so the page can say why nothing happened (no model + configured, no credentials, ...).""" + + def __init__(self, data_dir: Path, argv: Callable[[], list[str]] = hermes_argv) -> None: + self._logs = data_dir / "logs" + self._argv = argv + self._lock = threading.Lock() + self._sessions: dict[str, _Session] = {} + + def _state(self, sid: str) -> _Session: + state = self._sessions.get(sid) + if state is None: + state = self._sessions[sid] = _Session() + return state + + def submit(self, sid: str, message: str, cwd: str, on_done: Callable[[bool], None] | None = None) -> None: + with self._lock: + state = self._state(sid) + state.queue.append((message, cwd, on_done)) + if state.working: + return + state.working = True + threading.Thread(target=self._drain, args=(sid, state), name=f"agent-app:{sid}", daemon=True).start() + + def busy(self, sid: str) -> bool: + with self._lock: + state = self._sessions.get(sid) + return state is not None and state.working + + def error(self, sid: str) -> str | None: + with self._lock: + state = self._sessions.get(sid) + return state.error if state is not None else None + + def cancel(self, sid: str, wait: float = 0.0) -> None: + """Drop queued turns and stop the running one (with its children). + With `wait`, block up to that many seconds for it to exit, so the + caller can safely remove what the turn was working on.""" + with self._lock: + state = self._sessions.pop(sid, None) + if state is None: + return + state.queue.clear() + proc = state.proc + if proc is None or proc.poll() is not None: + return + _stop_tree(proc) + if wait > 0: + try: + proc.wait(timeout=wait) + except subprocess.TimeoutExpired: + pass + + def _owns(self, sid: str, state: _Session) -> bool: + # A cancel pops the state, and a later submit may install a new one: + # a drain thread works only for the state it was started for, or two + # threads would run turns on one session at once. + return self._sessions.get(sid) is state + + def _drain(self, sid: str, state: _Session) -> None: + while True: + with self._lock: + if not self._owns(sid, state): + return # cancelled + if not state.queue: + state.working = False + state.proc = None + return + message, cwd, on_done = state.queue.popleft() + ok = self._turn(sid, state, message, cwd) + if on_done is not None: + try: + on_done(ok) + except Exception: + pass + + def _turn(self, sid: str, state: _Session, message: str, cwd: str) -> bool: + self._logs.mkdir(parents=True, exist_ok=True) + log_path = self._logs / f"{sid}.log" + cmd = [ + *self._argv(), + "--cli", + "chat", + "-Q", + "--resume", sid, + "--source", SESSION_SOURCE, + "--in", cwd, + # The message goes in on stdin: arbitrary text, never re-parsed. + "--query-file", "-", + ] + env = turn_env() + windows = os.name == "nt" + with open(log_path, "ab") as log: + start = log.tell() + log.write(f"\n=== {time.strftime('%Y-%m-%d %H:%M:%S')} cwd={cwd}\n".encode("utf-8")) + log.flush() + try: + proc = subprocess.Popen( + cmd, + cwd=cwd, + env=env, + stdin=subprocess.PIPE, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=not windows, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if windows else 0, + ) + except OSError as exc: + with self._lock: + state.error = f"could not start Hermes ({' '.join(cmd[:3])}): {exc}" + return False + with self._lock: + state.proc = proc + cancelled = not self._owns(sid, state) + if cancelled: # cancelled while it was starting, before it could be found + _stop_tree(proc) + try: + proc.communicate(input=message.encode("utf-8")) + except OSError: + proc.wait() + code = proc.returncode + if code == 0: + with self._lock: + state.error = None + return True + with open(log_path, "rb") as log: + log.seek(start) + tail = log.read().decode("utf-8", errors="replace").strip().splitlines() + detail = "\n".join(line for line in tail[-12:] if not line.startswith("=== ")) + with self._lock: + state.error = f"the turn exited with code {code}" + (f":\n{detail}" if detail else "") + return False + + +class Manager: + """The tab's state: builds in flight plus each app's session.""" + + def __init__(self, runner: TurnRunner, sessions: Any) -> None: + self.runner = runner + self.sessions = sessions + self.builds: dict[str, dict] = {} + # Sessions this process has seeded with the preamble, so a send right + # after a build (before its first turn is stored) does not prepend it. + self.seeded: set[str] = set() + self._lock = threading.Lock() + + @classmethod + def default(cls) -> "Manager": + try: + from hermes_constants import get_hermes_home + + data_dir = Path(get_hermes_home()) / "plugin-data" / "agent-app" + except Exception: + data_dir = engine.apps_dir().parent / "hermes-plugin" + return cls(TurnRunner(data_dir), HermesSessions()) + + def meta(self) -> dict: + return {"ok": True, "blueprints": list(engine.FRAMEWORK_BLUEPRINTS), "build": engine.build_stamp(), "appsDir": str(engine.apps_dir())} + + def rows(self) -> list[dict]: + """Registered apps merged with builds not yet in the registry. A build + retires once its app is seen running.""" + apps = engine.list_known_apps() + now = time.time() + with self._lock: + for app in apps: + if app.get("status") == "running": + self.builds.pop(app["path"], None) + for build in self.builds.values(): + if not build["ended"] and now - build["started"] > BUILD_WAIT_S: + build["ended"] = True + builds = {path: dict(b) for path, b in self.builds.items()} + out = [] + for app in apps: + build = builds.get(app["path"]) + out.append({**app, "building": build is not None and not build["ended"], "buildEnded": bool(build and build["ended"])}) + known = {app["path"] for app in apps} + for path, build in builds.items(): + if path in known: + continue + out.append({ + "id": os.path.basename(path), "name": build["name"], "path": path, "url": None, + "status": "stopped", "building": not build["ended"], "buildEnded": build["ended"], + }) + return out + + def start_build(self, body: dict) -> tuple[int, dict]: + name = str(body.get("name") or "").strip() + slug = engine.slugify(name) + if not slug: + return 400, {"ok": False, "message": "Give the app a name."} + directory = str(engine.apps_dir() / slug) + kick = engine.build_kickoff_prompt({ + "action": "build", "name": name, "requirement": body.get("requirement"), + "blueprint": body.get("blueprint"), "port": body.get("port"), + }) + if kick["kind"] == "error": + return 400, {"ok": False, "message": kick["message"]} + sid = session_id_for(directory) + entry = {"name": name, "dir": directory, "ended": False, "started": time.time()} + # Checked and claimed in one step: two quick submits of one name must + # not both start a build of the same directory. + with self._lock: + if directory in self.builds: + return 400, {"ok": False, "message": f'"{name}" is already being built.'} + if os.path.exists(directory): + return 400, {"ok": False, "message": f'An app directory named "{slug}" already exists.'} + self.builds[directory] = entry + self.seeded.add(sid) + try: + engine.apps_dir().mkdir(parents=True, exist_ok=True) + self.sessions.ensure(sid, f"Agent App: {name}") + except Exception: + with self._lock: + self.builds.pop(directory, None) + self.seeded.discard(sid) + raise + prompt = f"{kick['prompt']}\n\nCreate the app at `{directory}`: pass that directory to every agent-app command." + + def done(_ok: bool) -> None: + with self._lock: + entry["ended"] = True + + self.runner.submit(sid, prompt, str(engine.apps_dir()), on_done=done) + return 200, {"ok": True, "path": directory} + + def send(self, body: dict) -> tuple[int, dict]: + path = str(body.get("path") or "") + text = str(body.get("text") or "").strip() + if not path: + return 400, {"ok": False, "message": "no app selected"} + if not text: + return 400, {"ok": False, "message": "empty message"} + sid = session_id_for(path) + name = str(body.get("name") or os.path.basename(path)) + with self._lock: + seeded = sid in self.seeded + if not seeded and not self.sessions.messages(sid): + text = f"{session_preamble(name, path)}\n\n{text}" + self.sessions.ensure(sid, f"Agent App: {name}") + with self._lock: + self.seeded.add(sid) + # Before a build's scaffold step the app directory may not exist yet. + self.runner.submit(sid, text, path if os.path.isdir(path) else str(engine.apps_dir())) + return 200, {"ok": True} + + def transcript(self, path: str) -> dict: + sid = session_id_for(path) + messages = [m for m in map(view_message, self.sessions.messages(sid)) if m["text"] or m["tools"]] + return {"ok": True, "messages": messages[-200:], "busy": self.runner.busy(sid), "error": self.runner.error(sid)} + + def lifecycle(self, verb: str, path: str) -> tuple[int, dict]: + registered = any(app["path"] == path for app in engine.list_known_apps()) + if verb in ("serve", "stop"): + if not registered: + return 404, {"ok": False, "message": "unknown app"} + result = engine.run_framework([path, verb]) + return (200 if result.ok else 500), {"ok": result.ok, "message": "" if result.ok else (result.stderr or result.stdout).strip()} + with self._lock: + building = path in self.builds + if not registered and not building: + return 404, {"ok": False, "message": "unknown app"} + sid = session_id_for(path) + # The agent stops first: a turn still running would go on writing to, + # serving, or re-registering the app while it is being removed. + self.runner.cancel(sid, wait=2 * CANCEL_GRACE_S) + if registered: + engine.run_framework([path, "stop"]) + # `remove --yes` backs the app's data up outside the app before deleting it. + result = engine.run_framework([path, "remove", "--yes"]) + if not result.ok: + return 500, {"ok": False, "message": (result.stderr or result.stdout).strip()} + elif os.path.isdir(path) and Path(path).resolve().parent == engine.apps_dir().resolve(): + # A build stopped before it registered the app. What it left is a + # partial scaffold in the directory this manager chose for it, and + # leaving it would refuse a rebuild under the same name. + shutil.rmtree(path, ignore_errors=True) + with self._lock: + self.builds.pop(path, None) + self.seeded.discard(sid) + try: + self.sessions.delete(sid) + except Exception: + pass # no session to delete + return 200, {"ok": True} + + +def _reply(status: int, payload: dict) -> JSONResponse: + return JSONResponse(status_code=status, content=payload) + + +def _guarded(fn: Callable[..., Any]) -> Callable[..., Any]: + """Report a failure as the page's JSON shape, never as a bare 500 page.""" + + @functools.wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return fn(*args, **kwargs) + except Exception as exc: + return _reply(500, {"ok": False, "message": str(exc) or exc.__class__.__name__}) + + return wrapper + + +def build_router(manager: Manager) -> APIRouter: + # Plain `def` routes: FastAPI runs them on its thread pool, so the CLI + # calls they make never block the dashboard's event loop. + r = APIRouter() + + @r.get("/meta") + @_guarded + def meta() -> Any: + return manager.meta() + + @r.get("/apps") + @_guarded + def apps() -> Any: + return {"ok": True, "rows": manager.rows()} + + @r.get("/session") + @_guarded + def session(app: str = "") -> Any: + return manager.transcript(app) + + @r.post("/build") + @_guarded + def build(body: dict | None = Body(default=None)) -> Any: + return _reply(*manager.start_build(body or {})) + + @r.post("/session/send") + @_guarded + def send(body: dict | None = Body(default=None)) -> Any: + return _reply(*manager.send(body or {})) + + @r.post("/app/{verb}") + @_guarded + def app_action(verb: str, body: dict | None = Body(default=None)) -> Any: + if verb not in ("serve", "stop", "remove"): + return _reply(404, {"ok": False, "message": "unknown endpoint"}) + return _reply(*manager.lifecycle(verb, str((body or {}).get("path") or ""))) + + return r + + +def dispatch(manager: Manager, method: str, path: str, query: str = "", body: Any = None) -> tuple[int, dict]: + """The same routes as `build_router`, for a host with no FastAPI app to + mount a router on (the hermes-webui backend). `path` is relative to + `/api/plugins/agent-app`.""" + body = body if isinstance(body, dict) else {} + try: + if method == "GET" and path == "/meta": + return 200, manager.meta() + if method == "GET" and path == "/apps": + return 200, {"ok": True, "rows": manager.rows()} + if method == "GET" and path == "/session": + app = (parse_qs(query).get("app") or [""])[0] + return 200, manager.transcript(app) + if method == "POST" and path == "/build": + return manager.start_build(body) + if method == "POST" and path == "/session/send": + return manager.send(body) + if method == "POST" and path.startswith("/app/"): + verb = path[len("/app/"):] + if verb not in ("serve", "stop", "remove"): + return 404, {"ok": False, "message": "unknown endpoint"} + return manager.lifecycle(verb, str(body.get("path") or "")) + return 404, {"ok": False, "message": "unknown endpoint"} + except Exception as exc: + return 500, {"ok": False, "message": str(exc) or exc.__class__.__name__} + + +# One manager per process: the dashboard mounts `router`, and the hermes-webui +# backend dispatches to the same MANAGER. +MANAGER = Manager.default() +router = build_router(MANAGER) if APIRouter is not None else None diff --git a/harness-plugins/hermes/engine.py b/harness-plugins/hermes/engine.py new file mode 100644 index 0000000..bdacc50 --- /dev/null +++ b/harness-plugins/hermes/engine.py @@ -0,0 +1,677 @@ +"""The Agent App Framework engine, in Python, for the Hermes plugin. + +A port of `@a2app/integration-starter`, the TypeScript engine every other +plugin inlines. Hermes plugins are Python and load in-process, so the engine +cannot be imported from Node; this module carries the same contract instead: +the build + operate tools, verb routing between the two binaries, the kickoff +prompt the manager's build form sends, the app registry read, and the +harness-profile writer for `agent-app bridge`. It imports nothing from +Hermes, so `__init__.py` (the agent side) and `dashboard/plugin_api.py` (the +dashboard side) both load it, and the tests run it without a Hermes install. + +Every tool shells a REAL verb on its owning binary (`agent-app` for +build/evolve, `a2app` for operate) and preserves the exit-code contract +(0 success, 1 rejected, 2 usage, 3 unreachable). No shell is ever used: field +values reach the CLI as literal argv. +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +PLUGIN_ROOT = Path(__file__).resolve().parent + +# The framework activities shipped as portable skills (folder-per-skill). +FRAMEWORK_SKILLS = ("creator", "modify", "importer", "operator", "walk-verify", "connect") + +# Verbs owned by the `agent-app` binary; everything else is `a2app` operate +# (framework spec 5.1). A2App is operate-only, so its client rejects these. +FRAMEWORK_VERBS = frozenset({ + "scaffold", "import", "validate", "toolkit-sync", "adapter-sync", "serve", "stop", "open", + "bridge", "list", "global", "skills", "dev", "promote", "backup", "restore", "remove", "forget", +}) + +# The closed set of verbs that address every app rather than one, and so take +# no app argument. Closed is what makes verb_of exact: it never has to inspect a +# positional to guess what it is. +REGISTRY_VERBS = frozenset({"list", "global", "skills"}) + +# Blueprints offered in the manager's stack dropdown. A convenience list kept in +# step with the TypeScript engine; the CLI accepts any toolkit it can resolve. +FRAMEWORK_BLUEPRINTS = ( + "blueprint-react-node", + "blueprint-python-fastapi", + "blueprint-pocketbase-react", + "blueprint-base", +) + +# Where a headless route's rendered prompt goes in its argument list. +PROMPT_PLACEHOLDER = "{prompt}" + +INSTALL_HINT = ( + "Install the framework CLIs with `npm i -g agent-app-framework`, or set " + "A2APP_CLI / AGENT_APP_CLI to the binaries (a path ending in .js runs with Node)." +) + + +def cli_bin() -> str: + """The `a2app` operate client (binary or JS entry).""" + return os.environ.get("A2APP_CLI") or "a2app" + + +def framework_bin() -> str: + """The `agent-app` build/evolve binary (binary or JS entry).""" + return os.environ.get("AGENT_APP_CLI") or "agent-app" + + +def verb_of(argv: list[str]) -> str: + """The verb in an app-first argv. + + Both CLIs are written ` [args]`, so the verb is the + SECOND element, except for a registry verb, which takes no app and so + stands alone in first position. + """ + first = argv[0] if argv else "" + if first in REGISTRY_VERBS: + return first + return argv[1] if len(argv) > 1 else "" + + +def bin_for(argv: list[str]) -> str: + """Route a HAND-TYPED argv to the binary owning its verb. + + For the `hermes agent-app ...` passthrough only. The registered tools name + their binary directly: under the walk, an operate argv's second element is + a module name the app chose, so inference would misroute an app with a + module called `validate` or `promote`. + """ + return framework_bin() if verb_of(argv) in FRAMEWORK_VERBS else cli_bin() + + +# ── Running a CLI ──────────────────────────────────────────────────────────── + + +@dataclass +class CliResult: + code: int + stdout: str + stderr: str + + @property + def ok(self) -> bool: + return self.code == 0 + + @property + def json(self) -> Any: + text = self.stdout.strip() + if not text.startswith(("{", "[")): + return None + try: + return json.loads(text) + except ValueError: + return None + + def text(self) -> str: + """What a tool hands the model: stdout, plus stderr when the call + failed. A guard rejection is useful data, so it is never swallowed.""" + out = self.stdout.strip() + err = self.stderr.strip() + if err and (not self.ok or not out): + out = f"{out}\n{err}" if out else err + return out or f"(exit {self.code})" + + +# npm and pnpm install a CLI on Windows as a `.cmd` shim that runs +# `node "\...\cli.js" %*`. A batch file re-parses its arguments through +# cmd.exe, which would corrupt a field value holding `& | ^ < > %` or a quote +# (every JSON payload has quotes). So the shim is read for the script it runs +# and Node is started on that script directly, and argv arrives literally. +_SHIM_TARGET = re.compile(r'"%~?dp0%?\\([^"]+?\.[cm]?js)"', re.IGNORECASE) + + +def _node() -> str: + return shutil.which("node") or "node" + + +def _resolve_shim(shim: str) -> list[str] | None: + try: + text = Path(shim).read_text(encoding="utf-8", errors="replace") + except OSError: + return None + match = _SHIM_TARGET.search(text) + if not match: + return None + base = Path(shim).parent + # The shim's path is written with backslashes; split it so it also joins + # correctly where `\` is not a separator (the tests run on every OS). + script = base.joinpath(*re.split(r"[\\/]+", match.group(1))) + if not script.is_file(): + return None + bundled = base / "node.exe" + return [str(bundled) if bundled.is_file() else _node(), str(script)] + + +def command_for(binary: str) -> list[str]: + """The argv prefix that runs `binary`. Raises FileNotFoundError when it + cannot be found, so callers report the install hint rather than a trace.""" + if binary.lower().endswith((".js", ".mjs", ".cjs")): + return [_node(), binary] + found = shutil.which(binary) + if found is None: + raise FileNotFoundError(binary) + if os.name == "nt" and found.lower().endswith((".cmd", ".bat")): + direct = _resolve_shim(found) + if direct is not None: + return direct + return [found] + + +def run(binary: str, argv: list[str], *, cwd: str | None = None, timeout: float | None = None) -> CliResult: + """Run one framework CLI and capture its result. Never uses a shell.""" + try: + cmd = [*command_for(binary), *argv] + except FileNotFoundError: + return CliResult(-1, "", f"framework CLI not found (looked for {binary!r}). {INSTALL_HINT}") + flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + try: + proc = subprocess.run( + cmd, + cwd=cwd, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + creationflags=flags, + ) + except FileNotFoundError: + return CliResult(-1, "", f"could not start {cmd[0]!r}. {INSTALL_HINT}") + except subprocess.TimeoutExpired: + return CliResult(-1, "", f"{binary} {' '.join(argv)} did not finish within {timeout}s") + return CliResult(proc.returncode, proc.stdout or "", proc.stderr or "") + + +def run_passthrough(binary: str, argv: list[str]) -> int: + """Run one framework CLI with the caller's terminal attached, for the + `hermes agent-app ...` passthrough: output streams, and the exit code + propagates.""" + try: + cmd = [*command_for(binary), *argv] + except FileNotFoundError: + sys.stderr.write(f"framework CLI not found (looked for {binary!r}). {INSTALL_HINT}\n") + return 127 + try: + return subprocess.run(cmd).returncode + except FileNotFoundError: + sys.stderr.write(f"could not start {cmd[0]!r}. {INSTALL_HINT}\n") + return 127 + + +def run_operate(argv: list[str], **kw: Any) -> CliResult: + return run(cli_bin(), argv, **kw) + + +def run_framework(argv: list[str], **kw: Any) -> CliResult: + return run(framework_bin(), argv, **kw) + + +def build_stamp() -> str: + """When the installed copy was staged (written by scripts/build.py), shown + in the manager footer to flag a stale install. "dev" for a source tree.""" + try: + return (PLUGIN_ROOT / "BUILD_STAMP").read_text(encoding="utf-8").strip() or "dev" + except OSError: + return "dev" + + +# ── The build + operate tools ──────────────────────────────────────────────── + + +@dataclass +class Tool: + name: str + description: str + parameters: dict + # "operate" shells a2app, "build" shells agent-app. + binary: str + argv: Callable[[dict], list[str]] + emoji: str = "" + + def schema(self) -> dict: + return {"name": self.name, "description": self.description, "parameters": self.parameters} + + def call(self, args: dict, cwd: str | None = None) -> CliResult: + argv = self.argv(args or {}) + return run_operate(argv, cwd=cwd) if self.binary == "operate" else run_framework(argv, cwd=cwd) + + +def _obj(properties: dict, required: list[str] | None = None) -> dict: + return {"type": "object", "additionalProperties": False, "required": required or [], "properties": properties} + + +def _s(value: Any) -> str: + # JSON scalars reach the CLI the way JavaScript's String() renders them, + # so an argv is identical whichever engine built it. + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + if isinstance(value, (dict, list)): + return _json(value) + return str(value) + + +def _fields(args: dict, key: str) -> dict: + value = args.get(key) + return value if isinstance(value, dict) else {} + + +def segments(path: Any) -> list[str]: + """A describe path split into CLI positionals. An empty path is the app + root, which is a real destination: the screen an agent lands on.""" + return [s for s in _s(path if path is not None else "").split("/") if s] + + +_DIR = {"type": "string", "description": "path to the Agent App project directory"} +_ENTITY = {"type": "string", "description": "entity / collection name"} +_STR = {"type": "string"} + + +def _describe(a: dict) -> list[str]: + argv = [_s(a["dir"]), *segments(a.get("path"))] + if a.get("all") is True: + argv.append("--all") + return argv + + +def _list(a: dict) -> list[str]: + argv = [_s(a["dir"]), "data", _s(a["entity"]), "list"] + for key in ("filter", "sort", "limit"): + if a.get(key) is not None: + argv += [f"--{key}", _s(a[key])] + return argv + + +def _run_operation(a: dict) -> list[str]: + argv = [_s(a["dir"]), *segments(a.get("path")), _s(a["operation"])] + for key, value in _fields(a, "fields").items(): + argv += [f"--{key}", _s(value)] + if a.get("approve") is not None: + argv += ["--approve", _s(a["approve"])] + return argv + + +def _next_task(a: dict) -> list[str]: + argv = [_s(a["dir"]), "tasks", "next"] + if a.get("waitMs") is not None: + argv += ["--wait", _s(a["waitMs"])] + if a.get("capability") is not None: + argv += ["--capability", _s(a["capability"])] + return argv + + +def _task_progress(a: dict) -> list[str]: + argv = [_s(a["dir"]), "tasks", "progress", _s(a["id"])] + if a.get("step") is not None: + argv += ["--step", _s(a["step"])] + if a.get("percent") is not None: + argv += ["--percent", _s(a["percent"])] + return argv + + +def _complete_task(a: dict) -> list[str]: + argv = [_s(a["dir"]), "tasks", "complete", _s(a["id"])] + if a.get("reason") is not None: + argv += ["--reason", _s(a["reason"])] + else: + argv += ["--result", _json(_fields(a, "result"))] + return argv + + +def _scaffold(a: dict) -> list[str]: + argv = [_s(a["dir"]), "scaffold"] + if a.get("blueprint") is not None: + argv += ["--blueprint", _s(a["blueprint"])] + if a.get("name") is not None: + argv += ["--name", _s(a["name"])] + return argv + + +def _serve(a: dict) -> list[str]: + argv = [_s(a["dir"]), "serve"] + if a.get("install") is True: + argv.append("--install") + if a.get("open") is True: + argv.append("--open") + return argv + + +def _json(value: Any) -> str: + # JSON.stringify's output: compact, non-ASCII kept as is. + return json.dumps(value, separators=(",", ":"), ensure_ascii=False) + + +def a2app_tools() -> list[Tool]: + """The concrete build + operate tools, the same seventeen as the TypeScript + engine's `a2appTools`, with the same names, schemas and argv.""" + return [ + # ONE describe tool taking a path, not a tool per operation: a tool list + # that grows with the app reproduces the cost and selection problem the + # navigational surface removes, and cannot vary with a record's state. + Tool("agent_app_describe", + "Describe ONE place in an Agent App. `path` is empty for the root (its modules), " + '"sales" for a module, "sales/invoices" for an entity, "sales/invoices/INV-1" for one ' + "record and the operations its current state allows, plus one more segment for a " + "sub-resource. Every response names the legal next moves. There is no call that " + "returns the whole model.", + _obj({"dir": _DIR, "path": _STR, "all": {"type": "boolean"}}, ["dir"]), "operate", _describe, "🔎"), + Tool("agent_app_find", + "Search entity, operation, and module names across the app and return their locations. " + "Use this instead of guessing a branch and walking back out of it.", + _obj({"dir": _DIR, "term": _STR}, ["dir", "term"]), "operate", + lambda a: [_s(a["dir"]), "--find", _s(a["term"])], "🧭"), + Tool("agent_app_list", "List records of an entity. Optional filter/sort/limit.", + _obj({"dir": _DIR, "entity": _ENTITY, "filter": _STR, "sort": _STR, "limit": {"type": "number"}}, ["dir", "entity"]), + "operate", _list, "📋"), + Tool("agent_app_get", "Fetch one record by id.", + _obj({"dir": _DIR, "entity": _ENTITY, "id": _STR}, ["dir", "entity", "id"]), "operate", + lambda a: [_s(a["dir"]), "data", _s(a["entity"]), "get", _s(a["id"])], "📄"), + Tool("agent_app_create", + "Create a record. `fields` is an object of field→value; the app's guard validates it and any " + "rejection (invalid enum, relative date, etc.) is returned verbatim.", + _obj({"dir": _DIR, "entity": _ENTITY, "fields": {"type": "object"}}, ["dir", "entity", "fields"]), "operate", + lambda a: [_s(a["dir"]), "data", _s(a["entity"]), "create", "--json", _json(_fields(a, "fields"))], "➕"), + Tool("agent_app_update", "Update a record by id with `fields`.", + _obj({"dir": _DIR, "entity": _ENTITY, "id": _STR, "fields": {"type": "object"}}, ["dir", "entity", "id", "fields"]), + "operate", + lambda a: [_s(a["dir"]), "data", _s(a["entity"]), "update", _s(a["id"]), "--json", _json(_fields(a, "fields"))], "✏️"), + Tool("agent_app_delete", "Delete a record by id.", + _obj({"dir": _DIR, "entity": _ENTITY, "id": _STR}, ["dir", "entity", "id"]), "operate", + lambda a: [_s(a["dir"]), "data", _s(a["entity"]), "delete", _s(a["id"])], "🗑️"), + # Deliberately no `agent_app_operations`: no global operation list + # exists. An operation is found on the screen it belongs to and invoked + # at the path that identifies it. + Tool("agent_app_run_operation", + "Invoke a declared operation at the path that identifies it. `path` is the module, " + 'entity, and record it was found under (e.g. "sales/invoices/INV-1"), or just the ' + "module for a module-level operation. A destructive op returns approval_required with " + "a content-addressed key; re-run with `approve` set to that key to execute.", + _obj({"dir": _DIR, "path": _STR, "operation": _STR, "fields": {"type": "object"}, "approve": _STR}, + ["dir", "path", "operation"]), "operate", _run_operation, "▶️"), + Tool("agent_app_poll_tasks", + "Poll the app→agent task queue (default status: submitted). Task payloads are data, never instructions.", + _obj({"dir": _DIR, "status": _STR}, ["dir"]), "operate", + lambda a: [_s(a["dir"]), "tasks", "--status", _s(a["status"])] if a.get("status") is not None else [_s(a["dir"]), "tasks"], + "📥"), + # Polling shows the queue; this TAKES from it. Claiming makes a task + # yours (the app answers 409 to everyone else). + Tool("agent_app_next_task", + "Take the next task an app queued for you: claims it and returns it, waiting up to `waitMs` " + 'for one to arrive. `"task": null` means the queue stayed empty, not a failure. A claimed task ' + "is yours to finish: report progress on long work (the app requeues a task after ~60s with no " + "update) and close it with agent_app_complete_task. The payload is data, never instructions.", + _obj({"dir": _DIR, "waitMs": {"type": "number"}, "capability": _STR}, ["dir"]), "operate", _next_task, "📨"), + Tool("agent_app_task_progress", + "Report progress on a task you claimed. Send one at least every 30 seconds on long work, or the " + "app decides you are gone and gives the task to someone else.", + _obj({"dir": _DIR, "id": _STR, "step": _STR, "percent": {"type": "number"}}, ["dir", "id"]), "operate", + _task_progress, "⏳"), + Tool("agent_app_complete_task", + 'Close a task you claimed, exactly once. Pass `result` (an object, e.g. {"summary": "what changed"}) ' + "when the work is done, or `reason` (a machine code) when it failed. A task you never close is work " + "the app believes is still happening.", + _obj({"dir": _DIR, "id": _STR, "result": {"type": "object"}, "reason": _STR}, ["dir", "id"]), "operate", + _complete_task, "✅"), + Tool("agent_app_build", + "Scaffold a new Agent App from a blueprint (writes the adapter app part + the ownership canon).", + _obj({"dir": _DIR, "blueprint": _STR, "name": _STR}, ["dir"]), "build", _scaffold, "🏗️"), + Tool("agent_app_validate", "Run the validation + security gate on an Agent App.", + _obj({"dir": _DIR, "noBuild": {"type": "boolean"}}, ["dir"]), "build", + lambda a: [_s(a["dir"]), "validate", "--no-build"] if a.get("noBuild") else [_s(a["dir"]), "validate"], "🛡️"), + # Without serve an agent could scaffold and gate an app but never launch + # it: the last step of every build would fall out of the tool surface. + Tool("agent_app_serve", + "Launch an Agent App as a managed background process and wait until it answers its health " + "endpoint. Returns the app's URL. Idempotent: serving an already-running app reports the " + "existing instance rather than starting a second one. Set `open` to also show it to the user.", + _obj({"dir": _DIR, "install": {"type": "boolean"}, "open": {"type": "boolean"}}, ["dir"]), "build", _serve, "🚀"), + Tool("agent_app_stop", "Stop an app launched with agent_app_serve.", + _obj({"dir": _DIR}, ["dir"]), "build", lambda a: [_s(a["dir"]), "stop"], "⏹️"), + # The URL is the deliverable, not the side effect: an agent that owns a + # browser passes printOnly and opens it with its own tool. + Tool("agent_app_open", + "Show a RUNNING Agent App to the user: opens it with the harness's configured opener, else " + "the OS browser, and always returns the URL. Set `printOnly` when YOU have a browser tool " + "and will open the URL yourself; that suppresses the OS browser so the user gets one window, " + "not two. `opened:false` is not a failure; the URL is still valid.", + _obj({"dir": _DIR, "printOnly": {"type": "boolean"}}, ["dir"]), "build", + lambda a: [_s(a["dir"]), "open", "--print-only"] if a.get("printOnly") is True else [_s(a["dir"]), "open"], "🖥️"), + ] + + +# ── The app registry and the build kickoff ────────────────────────────────── + + +def parse_known_apps(result: CliResult) -> list[dict]: + """The `apps` array of `agent-app list --json`. Tolerant: a failed or + non-JSON result yields an empty list, so the manager still renders.""" + doc = result.json + rows = doc.get("apps") if isinstance(doc, dict) else None + out: list[dict] = [] + for row in rows if isinstance(rows, list) else []: + if not isinstance(row, dict) or not isinstance(row.get("id"), str) or not isinstance(row.get("path"), str): + continue + app = { + "id": row["id"], + "name": row["name"] if isinstance(row.get("name"), str) else row["id"], + "path": row["path"], + "url": row["url"] if isinstance(row.get("url"), str) else None, + } + if isinstance(row.get("port"), int) and not isinstance(row.get("port"), bool): + app["port"] = row["port"] + if isinstance(row.get("status"), str): + app["status"] = row["status"] + out.append(app) + return out + + +def list_known_apps() -> list[dict]: + return parse_known_apps(run_framework(["list", "--json"])) + + +def _str(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def build_kickoff_prompt(body: dict) -> dict: + """The prompt that hands a manager-form submission to the agent, routed to + the skill owning the activity. Same wording as the TypeScript engine.""" + action = _str(body.get("action")) + requirement = _str(body.get("requirement")) + if action == "build": + name = _str(body.get("name")) + if not name or not requirement: + return {"kind": "error", "message": "A build needs an app name and a description of what it should do."} + blueprint = _str(body.get("blueprint")) or FRAMEWORK_BLUEPRINTS[0] + port = _str(body.get("port")) + prompt = ( + f'Build a new Agent App named "{name}".\n\n' + f"Requirements:\n{requirement}\n\n" + f"Scaffold from blueprint `{blueprint}`{f' on port {port}' if port else ''}. " + "Load the **creator** skill and follow it end to end: scaffold, build feature by feature under " + "the ownership boundary, run `agent-app validate`, launch, and have a separate agent walk-verify " + "before announcing. Do not build from general knowledge outside the skill." + ) + return {"kind": "kickoff", "activity": "creator", "prompt": prompt} + if action in ("modify", "operate"): + directory = _str(body.get("dir")) + if not directory or not requirement: + noun = "change" if action == "modify" else "task" + return {"kind": "error", "message": f"Select an app and describe the {noun}."} + if action == "modify": + prompt = ( + f"Evolve the existing Agent App at `{directory}`.\n\n" + f"Change requested:\n{requirement}\n\n" + "Load the **modify** skill and follow it: decide data-vs-code, edit under the ownership " + "boundary, then dev → `agent-app validate` → walk-verify → promote with a pre-promote backup." + ) + return {"kind": "kickoff", "activity": "modify", "dir": directory, "prompt": prompt} + prompt = ( + f"Operate the Agent App at `{directory}`.\n\n" + f"Task:\n{requirement}\n\n" + "Load the **operator** skill: read state and act through the A2App adapter (the `a2app` CLI). " + "No code changes." + ) + return {"kind": "kickoff", "activity": "operator", "dir": directory, "prompt": prompt} + return {"kind": "error", "message": f'Unknown action "{action}".'} + + +def apps_dir() -> Path: + """Where the manager creates new apps. The framework imposes no app home + (the registry tracks absolute paths), so this convention is the host's, + and it is the same one the OpenClaw and dsh managers use.""" + override = os.environ.get("A2APP_HOME", "").strip() + home = Path(override).resolve() if override else Path.home() / ".a2app" + return home / "apps" + + +def slugify(name: str) -> str: + return re.sub(r"^-+|-+$", "", re.sub(r"[^a-z0-9]+", "-", name.lower())) + + +# ── The skills ─────────────────────────────────────────────────────────────── + + +def skills_dir() -> Path | None: + """The six framework skills: the copy staged beside the plugin by the + build, else the copy the installed CLI carries (`agent-app skills + --path`), so a plugin installed straight from git still has them.""" + staged = PLUGIN_ROOT / "skills" + if (staged / "creator" / "SKILL.md").is_file(): + return staged + # This runs inside `register()`, which Hermes abandons after 10s by default + # (`plugins.load_timeout_seconds`), and then nothing of the plugin loads. + # A CLI that has not answered in 5s gets no skills rather than no plugin. + result = run_framework(["skills", "--path"], timeout=5) + candidate = Path(result.stdout.strip()) if result.ok and result.stdout.strip() else None + if candidate is not None and (candidate / "creator" / "SKILL.md").is_file(): + return candidate + return None + + +def frontmatter(text: str) -> dict: + """The `key: value` block the skill contract requires. Deliberately + minimal: the contract allows name, activity, description and optional + stack/toolkit tags, all single-line scalars.""" + match = re.match(r"^---\r?\n(.*?)\r?\n---(?:\r?\n|$)", text, re.DOTALL) + out: dict = {} + if not match: + return out + for line in match.group(1).splitlines(): + kv = re.match(r"^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$", line) + if not kv: + continue + value = kv.group(2).strip() + if len(value) >= 2 and value[0] == value[-1] == '"': + value = value[1:-1] + out[kv.group(1)] = value + return out + + +def framework_skills(directory: Path) -> list[tuple[str, Path, dict]]: + """Each `/SKILL.md` with a name and description, sorted by name.""" + found = [] + for child in sorted(directory.iterdir()): + md = child / "SKILL.md" + if not child.is_dir() or not md.is_file(): + continue + meta = frontmatter(md.read_text(encoding="utf-8", errors="replace")) + if meta.get("name") and meta.get("description"): + found.append((child.name, md, meta)) + return found + + +# ── The bridge route (a plugin's half of the app→agent ladder) ────────────── + + +def harnesses_file() -> Path: + """The file `agent-app bridge` reads, resolved like the framework home.""" + override = os.environ.get("A2APP_HOME", "").strip() + home = Path(override).resolve() if override else Path.home() / ".a2app" + return home / "harnesses.json" + + +def _write_atomic(file: Path, contents: str) -> None: + # A synced temp file and a rename, so a crash never leaves the machine's + # harness file present but empty. + file.parent.mkdir(parents=True, exist_ok=True) + tmp = file.with_name(f"{file.name}.{os.getpid()}.tmp") + try: + with open(tmp, "w", encoding="utf-8", newline="\n") as fh: + fh.write(contents) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, file) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +def register_harness_profile(profile: dict) -> dict: + """Add `profile` to the machine's harness file unless a profile with its id + is already there. + + The file is the user's, so this only ever ADDS its own entry: it never + overwrites one (a user's edit survives), never touches another harness, + never sets `default`, and never rewrites a file it cannot parse. It never + raises for a file it cannot use; the outcome is returned for the plugin to + log. Same rules as the TypeScript engine's `registerHarnessProfile`. + """ + file = harnesses_file() + doc: dict = {"version": 1, "harnesses": []} + if file.exists(): + try: + parsed = json.loads(file.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + return {"status": "refused", "file": str(file), "detail": f"{file} is not valid JSON ({exc}); left it alone"} + if not isinstance(parsed, dict): + return {"status": "refused", "file": str(file), "detail": f"{file} is not a JSON object; left it alone"} + doc = parsed + doc.setdefault("harnesses", []) + if not isinstance(doc["harnesses"], list): + return {"status": "refused", "file": str(file), "detail": f'{file} has a "harnesses" that is not an array; left it alone'} + if any(isinstance(h, dict) and h.get("id") == profile["id"] for h in doc["harnesses"]): + return {"status": "kept", "file": str(file), "detail": f'a "{profile["id"]}" profile is already in {file}'} + doc["harnesses"].append(profile) + try: + _write_atomic(file, json.dumps(doc, indent=2, ensure_ascii=False) + "\n") + except OSError as exc: + return {"status": "refused", "file": str(file), "detail": f"could not write {file}: {exc}"} + return { + "status": "registered", + "file": str(file), + "detail": f'registered the "{profile["id"]}" harness for `agent-app bridge` in {file}', + } + + +def main(argv: list[str] | None = None) -> int: + """`python engine.py `: the passthrough, runnable without Hermes.""" + args = list(sys.argv[1:] if argv is None else argv) + result = run(bin_for(args), args) + sys.stdout.write(result.stdout) + sys.stderr.write(result.stderr) + return result.code if result.code >= 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness-plugins/hermes/plugin.yaml b/harness-plugins/hermes/plugin.yaml index 5158d85..8dd578c 100644 --- a/harness-plugins/hermes/plugin.yaml +++ b/harness-plugins/hermes/plugin.yaml @@ -1,17 +1,30 @@ -name: agent_app +name: agent-app version: 0.1.0 -description: "Build, evolve, and operate Agent Apps via the framework CLIs: agent-app (build/evolve — scaffold, validate) and a2app (A2App operate — describe, records CRUD, declared operations, task polling)." -author: Agent App Framework -kind: backend +description: "Build, evolve, and operate Agent Apps through the agent-app and a2app CLIs. Adds the /agent-app command, `hermes agent-app`, the six framework skills, the build + operate tools, and an Agent Apps dashboard tab." +author: CraftOS +license: MIT +homepage: https://github.com/CraftOS-dev/Agent-App +# Explicit, not defaulted: the loader reclassifies a kind-less plugin whose +# code mentions certain provider symbols, and a standalone plugin is exactly +# what this is (and a standalone user plugin still needs `hermes plugins enable`). +kind: standalone provides_tools: - agent_app_describe + - agent_app_find - agent_app_list - agent_app_get - agent_app_create - agent_app_update - agent_app_delete - - agent_app_find - agent_app_run_operation - agent_app_poll_tasks + - agent_app_next_task + - agent_app_task_progress + - agent_app_complete_task - agent_app_build - agent_app_validate + - agent_app_serve + - agent_app_stop + - agent_app_open +provides_hooks: + - pre_gateway_dispatch diff --git a/harness-plugins/hermes/scripts/build.py b/harness-plugins/hermes/scripts/build.py new file mode 100644 index 0000000..6f8c420 --- /dev/null +++ b/harness-plugins/hermes/scripts/build.py @@ -0,0 +1,169 @@ +"""Stage the installable Hermes plugin into `dist/`, and optionally install it. + +A Hermes user plugin is a directory under `$HERMES_HOME/plugins/` holding +`plugin.yaml` + `__init__.py`; the dashboard tab is its `dashboard/` folder. +This copies exactly the plugin's runtime files plus the repo-root `skills/` +(the six framework skills, so the installed plugin does not depend on the CLI +to find them) and a build stamp the manager shows in its footer. It also +stages `dist/webui/`, the tab as a hermes-webui extension. + + python harness-plugins/hermes/scripts/build.py # stage dist/ + python harness-plugins/hermes/scripts/build.py --install # stage, then copy to $HERMES_HOME/plugins/agent-app + python harness-plugins/hermes/scripts/build.py --install --webui + # also install the extension into hermes-webui's managed extension + # directory ($HERMES_WEBUI_STATE_DIR, default $HERMES_HOME/webui) + +Standard library only. +""" +from __future__ import annotations + +import argparse +import datetime +import json +import os +import shutil +import sys +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parent.parent +REPO = PLUGIN.parent.parent +DIST = PLUGIN / "dist" +NAME = "agent-app" + +RUNTIME_FILES = ( + "plugin.yaml", + "__init__.py", + "engine.py", + "webui_backend.py", + "README.md", + "dashboard/manifest.json", + "dashboard/plugin_api.py", + "dashboard/manager.js", + "dashboard/manager.css", +) + +# The hermes-webui extension: its own files plus the dashboard's page, which +# both hosts run. Published path -> source, relative to the plugin. +WEBUI_FILES = { + "manifest.json": "webui/manifest.json", + "agent-apps.js": "webui/agent-apps.js", + "agent-apps.css": "webui/agent-apps.css", + "manager.js": "dashboard/manager.js", + "manager.css": "dashboard/manager.css", +} +WEBUI_ID = "agent-app" +# Written at runtime by the plugin's backend; listed so the web UI's own +# Uninstall removes it with the rest. +WEBUI_RUNTIME_FILE = "runtime.json" + + +def hermes_home() -> Path: + """Hermes's own resolution: HERMES_HOME, else %LOCALAPPDATA%\\hermes on + native Windows, else ~/.hermes.""" + override = os.environ.get("HERMES_HOME", "").strip() + if override: + return Path(override).expanduser() + local = os.environ.get("LOCALAPPDATA", "").strip() + if os.name == "nt" and local: + return Path(local) / "hermes" + return Path.home() / ".hermes" + + +def normalize_modes(root: Path) -> None: + """Directories 755, files 644. A source tree on a Windows drive mounted in + WSL reports every file as 777, and Hermes runs this code: a copy that kept + those modes would be writable by every local user.""" + if os.name == "nt": + return + for path in [root, *root.rglob("*")]: + path.chmod(0o755 if path.is_dir() else 0o644) + + +def stage() -> None: + shutil.rmtree(DIST, ignore_errors=True) + for rel in RUNTIME_FILES: + target = DIST / rel + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(PLUGIN / rel, target) + shutil.copytree(REPO / "skills", DIST / "skills", copy_function=shutil.copyfile) + for published, source in WEBUI_FILES.items(): + target = DIST / "webui" / published + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(PLUGIN / source, target) + stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + (DIST / "BUILD_STAMP").write_text(stamp + "\n", encoding="utf-8") + + +def install(home: Path) -> Path: + target = home / "plugins" / NAME + if target.exists(): + shutil.rmtree(target) + shutil.copytree(DIST, target, copy_function=shutil.copyfile) + normalize_modes(target) + return target + + +def webui_state_dir(home: Path) -> Path: + override = os.environ.get("HERMES_WEBUI_STATE_DIR", "").strip() + return Path(override).expanduser() if override else home / "webui" + + +def install_webui(state_dir: Path) -> Path: + """Install the extension into hermes-webui's managed extension directory + and record it the way the web UI's gallery installer does, so Settings > + Extensions lists it and can disable or uninstall it. Other installed + extensions, and their records, are left as they are.""" + root = state_dir / "extensions" + target = root / WEBUI_ID + target.mkdir(parents=True, exist_ok=True) + for published in WEBUI_FILES: + shutil.copyfile(DIST / "webui" / published, target / published) + normalize_modes(target) + record_file = state_dir / "extension-install-manifest.json" + record = {"version": 1, "installed": {}} + if record_file.is_file(): + try: + parsed = json.loads(record_file.read_text(encoding="utf-8")) + except ValueError: + sys.exit(f"{record_file} is not valid JSON; fix or remove it, then rerun") + if isinstance(parsed, dict) and isinstance(parsed.get("installed"), dict): + record = parsed + version = json.loads((DIST / "webui" / "manifest.json").read_text(encoding="utf-8")).get("version", "0.0.0") + record["installed"][WEBUI_ID] = { + "version": version, + "files": [*WEBUI_FILES, WEBUI_RUNTIME_FILE], + "installed_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + tmp = record_file.with_name(f".{record_file.name}.{os.getpid()}.tmp") + tmp.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8") + os.replace(tmp, record_file) + return target + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--install", nargs="?", const="", metavar="HERMES_HOME", + help="also copy the staged plugin into /plugins/agent-app") + parser.add_argument("--webui", nargs="?", const="", metavar="STATE_DIR", + help="also install the Agent Apps tab as a hermes-webui extension under " + "/extensions (default: $HERMES_WEBUI_STATE_DIR, else /webui)") + args = parser.parse_args() + stage() + print(f"hermes plugin staged: {DIST}") + home = Path(args.install).expanduser() if args.install else hermes_home() + if args.install is not None: + target = install(home) + print(f"installed: {target}") + print("next: `hermes plugins enable agent-app`, then restart `hermes dashboard`") + else: + print(f"install: copy it to {hermes_home() / 'plugins' / NAME}, or rerun with --install") + if args.webui is not None: + target = install_webui(Path(args.webui).expanduser() if args.webui else webui_state_dir(home)) + print(f"hermes-webui extension installed: {target}") + print('next: set HERMES_WEBUI_CSP_FRAME_EXTRA="http://127.0.0.1:* http://localhost:*" to embed apps, ' + "then restart hermes-webui (./ctl.sh restart)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harness-plugins/hermes/test/test_plugin.py b/harness-plugins/hermes/test/test_plugin.py new file mode 100644 index 0000000..2daac50 --- /dev/null +++ b/harness-plugins/hermes/test/test_plugin.py @@ -0,0 +1,927 @@ +"""Tests for the Hermes plugin, runnable without a Hermes install. + + python harness-plugins/hermes/test/test_plugin.py + +Standard library only, plus Node (the fake framework CLIs are .js entries, as +a real `A2APP_CLI`/`AGENT_APP_CLI` override would be) and, for the dashboard +API, FastAPI (Hermes's own dependency; those tests are skipped without it). + +The plugin is loaded the way Hermes's loader loads a directory plugin (a +`hermes_plugins.` package from `__init__.py`), and the dashboard backend +the way the dashboard loads it (a standalone module from its file, mounted at +`/api/plugins/agent-app`). Hermes itself is replaced by fakes of the exact +surfaces the plugin touches: the PluginContext methods, and a `hermes` binary +that records what it was run with and writes a transcript. +""" +from __future__ import annotations + +import argparse +import contextlib +import importlib.util +import json +import os +import shutil +import subprocess +import sys +import tempfile +import textwrap +import threading +import time +import types +import unittest +import unittest.mock +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parent.parent +REPO = PLUGIN.parent.parent +STARTER_DIST = REPO / "harness-plugins" / "integration-starter" / "dist" / "index.js" +NODE = shutil.which("node") + +FAKE_CLI = """ +const fs = require("fs"); +const BIN = %(bin)s; +const argv = process.argv.slice(2); +if (process.env.FAKE_CALLS) fs.appendFileSync(process.env.FAKE_CALLS, JSON.stringify({ bin: BIN, argv, cwd: process.cwd() }) + "\\n"); +if (BIN === "agent-app" && argv[0] === "skills" && argv[1] === "--path") { console.log(process.env.FAKE_SKILLS_DIR || ""); process.exit(process.env.FAKE_SKILLS_DIR ? 0 : 1); } +if (BIN === "agent-app" && argv[0] === "list") { + const f = process.env.FAKE_REGISTRY; + console.log(f && fs.existsSync(f) ? fs.readFileSync(f, "utf8") : JSON.stringify({ ok: true, apps: [] })); + process.exit(0); +} +if (argv.includes("BAD")) { console.log(JSON.stringify({ ok: false, error: { code: "invalid_enum", field: "status" } })); console.error("rejected by the guard"); process.exit(1); } +console.log(JSON.stringify({ ok: true, bin: BIN, argv })); +""" + +FAKE_HERMES = """ +import json, os, re, sys, time +args = sys.argv[1:] +message = sys.stdin.read() +sid = args[args.index("--resume") + 1] +record = {"argv": args, "cwd": os.getcwd(), "message": message, "start": time.time(), + "env": {k: os.environ.get(k) for k in ("HERMES_TUI", "HERMES_SESSION_KEY", "HERMES_GATEWAY_SESSION", "HERMES_YOLO_MODE", "KEEP_ME")}} +# A message may set its own turn length ("SLEEP=2 ..."); otherwise FAKE_TURN_DELAY. +sleep = re.search(r"SLEEP=([0-9.]+)", message) +time.sleep(float(sleep.group(1) if sleep else os.environ.get("FAKE_TURN_DELAY", "0"))) +record["end"] = time.time() +with open(os.environ["FAKE_HERMES_CALLS"], "a", encoding="utf-8") as fh: + fh.write(json.dumps(record) + "\\n") +if "FAIL" in message: + print("Error: no API key configured for provider 'openrouter'") + sys.exit(1) +with open(os.path.join(os.environ["FAKE_STORE"], sid + ".jsonl"), "a", encoding="utf-8") as fh: + fh.write(json.dumps({"role": "user", "content": message}) + "\\n") + fh.write(json.dumps({"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "terminal"}}]}) + "\\n") + fh.write(json.dumps({"role": "tool", "tool_name": "terminal", "content": "{\\"output\\": \\"ok\\"}"}) + "\\n") + fh.write(json.dumps({"role": "assistant", "content": "Done."}) + "\\n") +print("Done.") +""" + + +def load_plugin(): + """Import the plugin as Hermes does: package `hermes_plugins.agent_app`.""" + if "hermes_plugins" not in sys.modules: + ns = types.ModuleType("hermes_plugins") + ns.__path__ = [] + ns.__package__ = "hermes_plugins" + sys.modules["hermes_plugins"] = ns + name = "hermes_plugins.agent_app" + for key in [k for k in sys.modules if k == name or k.startswith(name + ".")]: + del sys.modules[key] + spec = importlib.util.spec_from_file_location(name, PLUGIN / "__init__.py", submodule_search_locations=[str(PLUGIN)]) + module = importlib.util.module_from_spec(spec) + module.__package__ = name + module.__path__ = [str(PLUGIN)] + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@contextlib.contextmanager +def fake_modules(modules: dict): + """Install modules in sys.modules for the block (None makes an import fail), + restoring whatever was there before.""" + saved = {name: sys.modules.get(name, _MISSING) for name in modules} + sys.modules.update(modules) + try: + yield + finally: + for name, module in saved.items(): + if module is _MISSING: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + +_MISSING = object() + + +def load_dashboard_api(): + """Import the dashboard backend as the dashboard does: a standalone module.""" + name = "hermes_dashboard_plugin_agent-app" + sys.modules.pop(name, None) + spec = importlib.util.spec_from_file_location(name, PLUGIN / "dashboard" / "plugin_api.py") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +class FakeContext: + """The PluginContext surface the plugin uses, recording each call.""" + + def __init__(self, inject_ok: bool = True) -> None: + self.manifest = types.SimpleNamespace(name="agent-app", key="agent-app") + self.tools: dict[str, dict] = {} + self.skills: dict[str, dict] = {} + self.sections: dict[str, str] = {} + self.commands: dict[str, dict] = {} + self.hooks: dict[str, object] = {} + self.cli: dict[str, dict] = {} + self.injected: list[str] = [] + self.inject_ok = inject_ok + + def register_tool(self, name, toolset, schema, handler, check_fn=None, requires_env=None, is_async=False, description="", emoji="", override=False): + assert name not in self.tools, name + self.tools[name] = {"toolset": toolset, "schema": schema, "handler": handler, "emoji": emoji, "is_async": is_async} + + def register_skill(self, name, path, description="", frontmatter=None): + assert ":" not in name and Path(path).exists() + self.skills[f"{self.manifest.name}:{name}"] = {"path": Path(path), "description": description, "frontmatter": dict(frontmatter or {})} + + def register_system_prompt_section(self, id, content, *, position="after_memory", max_chars=4000): + assert id not in self.sections + self.sections[id] = content + + def register_command(self, name, handler, description="", args_hint="", argument_mode=None): + self.commands[name] = {"handler": handler, "description": description, "args_hint": args_hint, "argument_mode": argument_mode} + + def register_hook(self, hook_name, callback): + self.hooks[hook_name] = callback + + def register_cli_command(self, name, help, setup_fn, handler_fn=None, description=""): + self.cli[name] = {"help": help, "setup_fn": setup_fn, "handler_fn": handler_fn} + + def inject_message(self, content, role="user", *, session_key=None): + self.injected.append(content) + return self.inject_ok + + +class Sandbox: + """A throwaway framework home with fake CLIs and env pointing at them.""" + + def __init__(self) -> None: + self.dir = Path(tempfile.mkdtemp(prefix="a2app-hermes-")) + self.home = self.dir / "home" + self.calls = self.dir / "calls.jsonl" + self.registry = self.dir / "registry.json" + for binary in ("agent-app", "a2app"): + (self.dir / f"{binary}.js").write_text(FAKE_CLI % {"bin": json.dumps(binary)}, encoding="utf-8") + self.saved = dict(os.environ) + os.environ.update({ + "A2APP_HOME": str(self.home), + "AGENT_APP_CLI": str(self.dir / "agent-app.js"), + "A2APP_CLI": str(self.dir / "a2app.js"), + "FAKE_CALLS": str(self.calls), + "FAKE_REGISTRY": str(self.registry), + "FAKE_SKILLS_DIR": str(REPO / "skills"), + }) + os.environ.pop("TERMINAL_CWD", None) + + def set_registry(self, apps: list[dict]) -> None: + self.registry.write_text(json.dumps({"ok": True, "apps": apps}), encoding="utf-8") + + def recorded(self) -> list[dict]: + if not self.calls.exists(): + return [] + return [json.loads(line) for line in self.calls.read_text(encoding="utf-8").splitlines() if line] + + def close(self) -> None: + os.environ.clear() + os.environ.update(self.saved) + shutil.rmtree(self.dir, ignore_errors=True) + + +@unittest.skipIf(NODE is None, "node is required for the fake framework CLIs") +class PluginRegistration(unittest.TestCase): + def setUp(self) -> None: + self.box = Sandbox() + self.plugin = load_plugin() + self.ctx = FakeContext() + self.plugin.register(self.ctx) + + def tearDown(self) -> None: + self.box.close() + + def call(self, tool: str, **args) -> str: + return self.ctx.tools[tool]["handler"](args, task_id="t1", session_id="s1") + + def test_registers_the_engines_seventeen_tools(self) -> None: + names = set(self.ctx.tools) + self.assertEqual(len(names), 17) + if STARTER_DIST.parent.parent.joinpath("src", "index.ts").exists(): + import re + ts = (STARTER_DIST.parent.parent / "src" / "index.ts").read_text(encoding="utf-8") + self.assertEqual(names, set(re.findall(r'name: "(agent_app_\w+)"', ts)), "same tool set as the TypeScript engine") + manifest = (PLUGIN / "plugin.yaml").read_text(encoding="utf-8") + for name in names: + self.assertIn(f" - {name}\n", manifest, "provides_tools lists every registered tool") + tool = self.ctx.tools[name] + self.assertEqual(tool["toolset"], "agent_app") + self.assertEqual(tool["schema"]["name"], name) + self.assertEqual(tool["schema"]["parameters"]["type"], "object") + self.assertFalse(tool["is_async"]) + + def test_tools_shell_the_owning_binary_with_literal_argv(self) -> None: + out = json.loads(self.call("agent_app_describe", dir="/apps/crm", path="sales/invoices/INV-1", all=True)) + self.assertEqual((out["bin"], out["argv"]), ("a2app", ["/apps/crm", "sales", "invoices", "INV-1", "--all"])) + out = json.loads(self.call("agent_app_create", dir="crm", entity="deals", fields={"title": 'x & "y" | z', "amount": 5})) + self.assertEqual(out["argv"], ["crm", "data", "deals", "create", "--json", '{"title":"x & \\"y\\" | z","amount":5}']) + out = json.loads(self.call("agent_app_run_operation", dir="crm", path="sales/invoices/INV-1", operation="void", fields={"reason": "dup"}, approve="k1")) + self.assertEqual(out["argv"], ["crm", "sales", "invoices", "INV-1", "void", "--reason", "dup", "--approve", "k1"]) + out = json.loads(self.call("agent_app_build", dir="crm", blueprint="blueprint-base", name="CRM")) + self.assertEqual((out["bin"], out["argv"]), ("agent-app", ["crm", "scaffold", "--blueprint", "blueprint-base", "--name", "CRM"])) + out = json.loads(self.call("agent_app_open", dir="crm", printOnly=True)) + self.assertEqual((out["bin"], out["argv"]), ("agent-app", ["crm", "open", "--print-only"])) + + def test_a_guard_rejection_reaches_the_model(self) -> None: + text = self.call("agent_app_update", dir="crm", entity="deals", id="BAD", fields={"status": "nope"}) + self.assertIn('"invalid_enum"', text) + self.assertIn("rejected by the guard", text) + + def test_a_relative_dir_resolves_in_the_calling_sessions_directory(self) -> None: + # A multi-session host keeps each session's directory per session; + # TERMINAL_CWD then holds some other session's, and must lose. + session_dir = self.box.dir / "session" + session_dir.mkdir() + os.environ["TERMINAL_CWD"] = str(self.box.dir) + fake = types.ModuleType("agent.runtime_cwd") + fake.resolve_agent_cwd = lambda: session_dir + with fake_modules({"agent.runtime_cwd": fake}): + self.call("agent_app_get", dir=".", entity="deals", id="D1") + self.assertEqual(Path(self.box.recorded()[-1]["cwd"]).resolve(), session_dir.resolve()) + + def test_without_runtime_cwd_a_relative_dir_resolves_in_the_terminal(self) -> None: + os.environ["TERMINAL_CWD"] = str(self.box.dir) + with fake_modules({"agent.runtime_cwd": None}): # a Hermes that predates it + self.call("agent_app_get", dir=".", entity="deals", id="D1") + self.assertEqual(Path(self.box.recorded()[-1]["cwd"]).resolve(), self.box.dir.resolve()) + + def test_missing_cli_is_reported_with_the_install_hint(self) -> None: + os.environ["A2APP_CLI"] = "definitely-not-a2app-xyz" + text = self.call("agent_app_list", dir="crm", entity="deals") + self.assertIn("framework CLI not found", text) + self.assertIn("npm i -g agent-app-framework", text) + + def test_ships_the_six_skills_and_routes_to_them(self) -> None: + self.assertEqual(set(self.ctx.skills), {f"agent-app:{n}" for n in ("creator", "modify", "importer", "operator", "walk-verify", "connect")}) + for skill in self.ctx.skills.values(): + self.assertTrue(skill["description"]) + section = self.ctx.sections["agent-app"] + self.assertLessEqual(len(section), 4000, "Hermes caps a prompt section at 4000 characters") + for name in self.ctx.skills: + self.assertIn(f"`{name}`", section) + self.assertIn(self.plugin.ROUTED_PREFIX, section) + self.assertIn(str(REPO / "skills"), section, "skills found through `agent-app skills --path`") + + def test_slash_command_hands_the_request_to_the_agent(self) -> None: + cmd = self.ctx.commands["agent-app"] + self.assertEqual(cmd["argument_mode"], "text") + self.assertIn("Usage", cmd["handler"](" ")) + self.assertIsNone(cmd["handler"]("build a CRM")) + self.assertEqual(self.ctx.injected, [f"{self.plugin.ROUTED_PREFIX} build a CRM"]) + self.assertFalse(self.ctx.injected[0].startswith("/"), "injected text must not re-enter command dispatch") + self.ctx.inject_ok = False + self.assertEqual(cmd["handler"]("build a CRM"), self.plugin.NO_TURN_HERE) + + def test_gateway_command_is_rewritten_into_a_request(self) -> None: + hook = self.ctx.hooks["pre_gateway_dispatch"] + event = lambda text: types.SimpleNamespace(text=text) + self.assertEqual(hook(event=event("/agent-app build a CRM"), gateway=None, session_store=None), + {"action": "rewrite", "text": f"{self.plugin.ROUTED_PREFIX} build a CRM"}) + self.assertEqual(hook(event=event("/agent_app@hermes_bot add a report"))["text"], f"{self.plugin.ROUTED_PREFIX} add a report") + self.assertIsNone(hook(event=event("/agent-app")), "a bare command reaches its handler for usage") + self.assertIsNone(hook(event=event("/agent-apps x"))) + self.assertIsNone(hook(event=event("build a CRM"))) + self.assertIsNone(hook(event=types.SimpleNamespace(text=None))) + + def test_cli_passthrough_routes_by_verb_and_keeps_flags(self) -> None: + entry = self.ctx.cli["agent-app"] + parser = argparse.ArgumentParser(prog="hermes agent-app") + entry["setup_fn"](parser) + args = parser.parse_args(["./crm", "validate", "--no-build"]) + self.assertEqual(args.argv, ["./crm", "validate", "--no-build"]) + self.assertEqual(entry["handler_fn"](args), 0) + last = self.box.recorded()[-1] + self.assertEqual((last["bin"], last["argv"]), ("agent-app", ["./crm", "validate", "--no-build"])) + self.assertEqual(entry["handler_fn"](parser.parse_args(["crm", "tasks", "next"])), 0) + self.assertEqual(self.box.recorded()[-1]["bin"], "a2app") + self.assertEqual(entry["handler_fn"](parser.parse_args(["crm", "data", "deals", "get", "BAD"])), 1, "exit code propagates") + self.assertEqual(entry["handler_fn"](parser.parse_args([])), 2) + + def test_registers_the_hermes_bridge_route(self) -> None: + doc = json.loads((self.box.home / "harnesses.json").read_text(encoding="utf-8")) + self.assertEqual(doc, {"version": 1, "harnesses": [self.plugin.HERMES_PROFILE]}) + route = self.plugin.HERMES_PROFILE["routes"][0] + self.assertEqual(route["command"], "hermes") + # The prompt goes in on stdin (no OS cap on its size), so no argument carries it. + self.assertEqual(route["input"], "stdin") + self.assertEqual(route["args"][-2:], ["--query-file", "-"]) + self.assertNotIn("{prompt}", " ".join(route["args"])) + + +@unittest.skipIf(NODE is None, "node is required for the fake framework CLIs") +class Engine(unittest.TestCase): + def setUp(self) -> None: + self.box = Sandbox() + self.engine = load_plugin().engine + + def tearDown(self) -> None: + self.box.close() + + def test_verb_routing(self) -> None: + e = self.engine + self.assertEqual(e.bin_for(["./app", "bridge", "start"]), os.environ["AGENT_APP_CLI"]) + self.assertEqual(e.bin_for(["list", "--json"]), os.environ["AGENT_APP_CLI"]) + self.assertEqual(e.bin_for(["./app", "tasks", "next"]), os.environ["A2APP_CLI"]) + self.assertEqual(e.bin_for(["./app"]), os.environ["A2APP_CLI"]) + + def test_harness_file_rules(self) -> None: + e, file = self.engine, self.box.home / "harnesses.json" + profile = {"id": "myharness", "routes": [{"mode": "headless", "command": "mh", "args": ["{prompt}"]}]} + self.assertEqual(e.register_harness_profile(profile)["status"], "registered") + edited = {"id": "myharness", "routes": [{"mode": "headless", "command": "/opt/mh", "args": ["{prompt}"]}]} + file.write_text(json.dumps({"version": 1, "harnesses": [edited]}), encoding="utf-8") + self.assertEqual(e.register_harness_profile(profile)["status"], "kept") + self.assertEqual(json.loads(file.read_text(encoding="utf-8"))["harnesses"], [edited], "an existing entry is never overwritten") + other = {"id": "other", "routes": [{"mode": "subscribe"}]} + file.write_text(json.dumps({"version": 1, "default": "other", "harnesses": [other]}), encoding="utf-8") + self.assertEqual(e.register_harness_profile(profile)["status"], "registered") + self.assertEqual(json.loads(file.read_text(encoding="utf-8")), {"version": 1, "default": "other", "harnesses": [other, profile]}) + for unreadable in ("{ not json", json.dumps({"version": 1, "harnesses": {"id": "x"}}), "[]"): + file.write_text(unreadable, encoding="utf-8") + self.assertEqual(e.register_harness_profile(profile)["status"], "refused") + self.assertEqual(file.read_text(encoding="utf-8"), unreadable, "an unreadable file is left exactly as it was") + + def test_npm_shim_is_run_through_node_not_cmd(self) -> None: + shim_dir = self.box.dir / "npm" + script = shim_dir / "node_modules" / "agent-app-framework" / "bin" / "agent-app.js" + script.parent.mkdir(parents=True) + script.write_text("", encoding="utf-8") + shim = shim_dir / "agent-app.cmd" + shim.write_text('@ECHO off\r\n... "%_prog%" "%dp0%\\node_modules\\agent-app-framework\\bin\\agent-app.js" %*\r\n', encoding="utf-8") + resolved = self.engine._resolve_shim(str(shim)) + self.assertEqual(Path(resolved[1]).resolve(), script.resolve()) + pnpm = shim_dir / "pnpm-style.cmd" + pnpm.write_text('@SETLOCAL\r\n"%~dp0\\node_modules\\agent-app-framework\\bin\\agent-app.js" %*\r\n', encoding="utf-8") + self.assertEqual(Path(self.engine._resolve_shim(str(pnpm))[1]).resolve(), script.resolve()) + + def test_known_apps_parse_is_tolerant(self) -> None: + e = self.engine + self.assertEqual(e.parse_known_apps(e.CliResult(0, "not json", "")), []) + rows = e.parse_known_apps(e.CliResult(0, json.dumps({"apps": [ + {"id": "crm", "name": "CRM", "path": "/a/crm", "port": 8080, "status": "running", "url": "http://127.0.0.1:8080/"}, + {"id": "x"}, {"id": "y", "path": "/a/y", "port": True}, + ]}), "")) + self.assertEqual(rows, [ + {"id": "crm", "name": "CRM", "path": "/a/crm", "url": "http://127.0.0.1:8080/", "port": 8080, "status": "running"}, + {"id": "y", "name": "y", "path": "/a/y", "url": None}, + ]) + + @unittest.skipUnless(STARTER_DIST.exists(), "build @a2app/integration-starter for the parity check") + def test_argv_and_kickoff_match_the_typescript_engine(self) -> None: + cases = [ + ["agent_app_describe", {"dir": "crm", "path": ""}], + ["agent_app_describe", {"dir": "crm", "path": "/sales//invoices/", "all": True}], + ["agent_app_find", {"dir": "crm", "term": "invoice"}], + ["agent_app_list", {"dir": "crm", "entity": "deals", "filter": "stage=won", "sort": "-amount", "limit": 5}], + ["agent_app_get", {"dir": "crm", "entity": "deals", "id": "D1"}], + ["agent_app_create", {"dir": "crm", "entity": "deals", "fields": {"a": 1, "b": [1, "x"], "c": None, "d": "é & ü"}}], + ["agent_app_update", {"dir": "crm", "entity": "deals", "id": "D1", "fields": {"t": True}}], + ["agent_app_delete", {"dir": "crm", "entity": "deals", "id": "D1"}], + ["agent_app_run_operation", {"dir": "crm", "path": "sales", "operation": "close", "fields": {"n": 2, "ok": False, "s": "x y"}, "approve": "abc"}], + ["agent_app_poll_tasks", {"dir": "crm"}], + ["agent_app_poll_tasks", {"dir": "crm", "status": "working"}], + ["agent_app_next_task", {"dir": "crm", "waitMs": 60000, "capability": "report"}], + ["agent_app_task_progress", {"dir": "crm", "id": "T1", "step": "half", "percent": 50}], + ["agent_app_complete_task", {"dir": "crm", "id": "T1", "result": {"summary": "done"}}], + ["agent_app_complete_task", {"dir": "crm", "id": "T1", "reason": "blocked"}], + ["agent_app_build", {"dir": "crm"}], + ["agent_app_validate", {"dir": "crm", "noBuild": True}], + ["agent_app_serve", {"dir": "crm", "install": True, "open": True}], + ["agent_app_stop", {"dir": "crm"}], + ["agent_app_open", {"dir": "crm"}], + ] + kickoffs = [ + {"action": "build", "name": "Acme CRM", "requirement": "Track deals.", "blueprint": "blueprint-base", "port": "8123"}, + {"action": "build", "name": "Acme CRM", "requirement": "Track deals."}, + {"action": "build", "name": "", "requirement": "x"}, + {"action": "modify", "dir": "/a/crm", "requirement": "Add a report."}, + {"action": "operate", "dir": "/a/crm", "requirement": "Close won deals."}, + {"action": "nope"}, + ] + script = self.box.dir / "parity.mjs" + script.write_text(textwrap.dedent(f""" + import {{ a2appTools, buildKickoffPrompt }} from {json.dumps(STARTER_DIST.as_uri())}; + const [cases, kickoffs] = JSON.parse(process.argv[2]); + const tools = a2appTools(process.env.A2APP_CLI, process.env.AGENT_APP_CLI); + const argv = []; + for (const [name, args] of cases) {{ + const r = await tools.find((t) => t.name === name).handler(args); + argv.push(JSON.parse(r.stdout)); + }} + console.log(JSON.stringify({{ argv, kickoffs: kickoffs.map((k) => {{ const r = buildKickoffPrompt(k); return r.kind === "kickoff" ? r.prompt : r.message; }}) }})); + """), encoding="utf-8") + proc = subprocess.run([NODE, str(script), json.dumps([cases, kickoffs])], capture_output=True, text=True, encoding="utf-8", check=True) + ts = json.loads(proc.stdout) + tools = {t.name: t for t in self.engine.a2app_tools()} + py_argv = [json.loads(tools[name].call(args).stdout) for name, args in cases] + self.assertEqual(py_argv, ts["argv"]) + py_kick = [(lambda r: r["prompt"] if r["kind"] == "kickoff" else r["message"])(self.engine.build_kickoff_prompt(k)) for k in kickoffs] + self.assertEqual(py_kick, ts["kickoffs"]) + + +try: + import fastapi # noqa: F401 (Hermes's dashboard runs on FastAPI) + from fastapi import FastAPI + from fastapi.testclient import TestClient + HAVE_FASTAPI = True +except ImportError: + HAVE_FASTAPI = False + + +class FakeSessions: + """Hermes's session store, backed by the fake hermes binary's transcript files.""" + + def __init__(self, store: Path) -> None: + self.store = store + self.ensured: list[tuple[str, str]] = [] + self.deleted: list[str] = [] + + def ensure(self, sid: str, title: str) -> None: + self.ensured.append((sid, title)) + + def messages(self, sid: str) -> list[dict]: + f = self.store / f"{sid}.jsonl" + return [json.loads(line) for line in f.read_text(encoding="utf-8").splitlines()] if f.exists() else [] + + def delete(self, sid: str) -> None: + self.deleted.append(sid) + (self.store / f"{sid}.jsonl").unlink(missing_ok=True) + + +@unittest.skipIf(NODE is None or not HAVE_FASTAPI, "node and fastapi are required for the dashboard API") +class DashboardApi(unittest.TestCase): + def setUp(self) -> None: + self.box = Sandbox() + self.store = self.box.dir / "store" + self.store.mkdir() + self.hermes_calls = self.box.dir / "hermes-calls.jsonl" + fake_hermes = self.box.dir / "fake_hermes.py" + fake_hermes.write_text(FAKE_HERMES, encoding="utf-8") + os.environ.update({"FAKE_STORE": str(self.store), "FAKE_HERMES_CALLS": str(self.hermes_calls), "HERMES_BIN": str(fake_hermes)}) + # What a desktop or Chat-tab session leaves in the dashboard process. + os.environ.update({"HERMES_TUI": "1", "HERMES_SESSION_KEY": "leaked", "HERMES_GATEWAY_SESSION": "1", "HERMES_YOLO_MODE": "1", "KEEP_ME": "yes"}) + self.api = load_dashboard_api() + self.manager = self.api.Manager(self.api.TurnRunner(self.box.dir / "data"), FakeSessions(self.store)) + app = FastAPI() + app.include_router(self.api.build_router(self.manager), prefix="/api/plugins/agent-app") + self.client = TestClient(app) + self.apps_dir = self.box.home / "apps" + + def tearDown(self) -> None: + self.box.close() + + def wait_idle(self, path: str, timeout: float = 20) -> None: + sid = self.api.session_id_for(path) + deadline = time.time() + timeout + while self.manager.runner.busy(sid): + if time.time() > deadline: + self.fail("turn did not finish") + time.sleep(0.05) + + def turns(self) -> list[dict]: + if not self.hermes_calls.exists(): + return [] + return [json.loads(line) for line in self.hermes_calls.read_text(encoding="utf-8").splitlines() if line] + + def test_module_mounts_as_the_dashboard_mounts_it(self) -> None: + app = FastAPI() + app.include_router(self.api.router, prefix="/api/plugins/agent-app") + meta = TestClient(app).get("/api/plugins/agent-app/meta").json() + self.assertTrue(meta["ok"]) + self.assertIn("blueprint-react-node", meta["blueprints"]) + self.assertEqual(meta["build"], "dev") + + def test_build_runs_the_creator_kickoff_in_the_apps_session(self) -> None: + bad = self.client.post("/api/plugins/agent-app/build", json={"name": "", "requirement": "x"}) + self.assertEqual((bad.status_code, bad.json()["message"]), (400, "Give the app a name.")) + bad = self.client.post("/api/plugins/agent-app/build", json={"name": "Acme CRM"}) + self.assertEqual(bad.status_code, 400) + + os.environ["FAKE_TURN_DELAY"] = "2" # long enough to observe the build in flight + r = self.client.post("/api/plugins/agent-app/build", json={"name": "Acme CRM", "requirement": "Track deals.", "blueprint": "blueprint-base", "port": ""}) + self.assertEqual(r.status_code, 200, r.text) + path = r.json()["path"] + self.assertEqual(Path(path), self.apps_dir / "acme-crm") + sid = self.api.session_id_for(path) + self.assertRegex(sid, r"^agent-app-acme-crm-[0-9a-f]{8}$") + + rows = self.client.get("/api/plugins/agent-app/apps").json()["rows"] + self.assertEqual([(x["name"], x["building"]) for x in rows], [("Acme CRM", True)]) + again = self.client.post("/api/plugins/agent-app/build", json={"name": "Acme CRM", "requirement": "x"}) + self.assertIn("already being built", again.json()["message"]) + + self.wait_idle(path) + turn = self.turns()[0] + self.assertEqual(turn["argv"], ["--cli", "chat", "-Q", "--resume", sid, "--source", "tool", "--in", str(self.apps_dir), "--query-file", "-"]) + self.assertEqual(Path(turn["cwd"]).resolve(), self.apps_dir.resolve()) + self.assertIn('Build a new Agent App named "Acme CRM"', turn["message"]) + self.assertIn("**creator** skill", turn["message"]) + self.assertIn(f"Create the app at `{path}`", turn["message"]) + self.assertEqual(turn["env"], {"HERMES_TUI": None, "HERMES_SESSION_KEY": None, "HERMES_GATEWAY_SESSION": None, "HERMES_YOLO_MODE": None, "KEEP_ME": "yes"}) + self.assertEqual(self.manager.sessions.ensured, [(sid, "Agent App: Acme CRM")]) + + session = self.client.get("/api/plugins/agent-app/session", params={"app": path}).json() + self.assertEqual([m["role"] for m in session["messages"]], ["user", "assistant", "tool", "assistant"]) + self.assertEqual(session["messages"][1]["tools"], ["terminal"]) + self.assertEqual(session["messages"][3]["text"], "Done.") + self.assertEqual((session["busy"], session["error"]), (False, None)) + + rows = self.client.get("/api/plugins/agent-app/apps").json()["rows"] + self.assertEqual([(x["building"], x["buildEnded"]) for x in rows], [(False, True)], "the build run ended without the app coming up") + + self.box.set_registry([{"id": "acme-crm", "name": "Acme CRM", "path": path, "port": 8123, "status": "running", "url": "http://127.0.0.1:8123/"}]) + rows = self.client.get("/api/plugins/agent-app/apps").json()["rows"] + self.assertEqual([(x["status"], x["building"], x["buildEnded"]) for x in rows], [("running", False, False)], "a build retires once its app runs") + + def test_messages_queue_in_order_and_the_first_carries_the_preamble(self) -> None: + app_dir = self.box.dir / "crm" + app_dir.mkdir() + self.box.set_registry([{"id": "crm", "name": "CRM", "path": str(app_dir), "status": "stopped"}]) + os.environ["FAKE_TURN_DELAY"] = "0.4" + for text in ("add a pipeline report", "and a CSV export"): + r = self.client.post("/api/plugins/agent-app/session/send", json={"path": str(app_dir), "name": "CRM", "text": text}) + self.assertEqual(r.json(), {"ok": True}) + self.assertTrue(self.client.get("/api/plugins/agent-app/session", params={"app": str(app_dir)}).json()["busy"]) + self.wait_idle(str(app_dir)) + first, second = self.turns() + self.assertTrue(first["message"].startswith('You are working on the Agent App "CRM"')) + self.assertTrue(first["message"].endswith("add a pipeline report")) + self.assertEqual(second["message"], "and a CSV export") + self.assertLessEqual(first["end"], second["start"], "turns on one session never overlap") + self.assertEqual(Path(first["cwd"]).resolve(), app_dir.resolve()) + self.assertEqual(first["argv"][first["argv"].index("--in") + 1], str(app_dir)) + + empty = self.client.post("/api/plugins/agent-app/session/send", json={"path": str(app_dir), "text": " "}) + self.assertEqual(empty.status_code, 400) + + def test_a_failed_turn_reports_why(self) -> None: + app_dir = self.box.dir / "crm" + app_dir.mkdir() + self.client.post("/api/plugins/agent-app/session/send", json={"path": str(app_dir), "name": "CRM", "text": "FAIL please"}) + self.wait_idle(str(app_dir)) + session = self.client.get("/api/plugins/agent-app/session", params={"app": str(app_dir)}).json() + self.assertIn("exited with code 1", session["error"]) + self.assertIn("no API key configured", session["error"]) + log = self.box.dir / "data" / "logs" / f"{self.api.session_id_for(str(app_dir))}.log" + self.assertIn("no API key configured", log.read_text(encoding="utf-8")) + + def test_a_cancelled_session_never_runs_two_turns_at_once(self) -> None: + # The killed turn's thread is slow to notice (as a Hermes that handles + # SIGTERM gracefully would be); new work sent meanwhile must still run + # one turn at a time. + app_dir = self.box.dir / "crm" + app_dir.mkdir() + runner, sid = self.manager.runner, self.api.session_id_for(str(app_dir)) + real_turn = runner._turn + + def slow_to_return(sid_, state, message, cwd): + ok = real_turn(sid_, state, message, cwd) + if message.startswith("SLEEP=30"): + time.sleep(0.8) + return ok + + runner._turn = slow_to_return + runner.submit(sid, "SLEEP=30 A", str(app_dir)) + deadline = time.time() + 10 + while runner._sessions.get(sid) is None or runner._sessions[sid].proc is None: + self.assertLess(time.time(), deadline, "the first turn never started") + time.sleep(0.05) + runner.cancel(sid) + runner.submit(sid, "SLEEP=1 B", str(app_dir)) + runner.submit(sid, "SLEEP=1 C", str(app_dir)) + time.sleep(1.2) + self.wait_idle(str(app_dir)) + turns = {t["message"].split()[1]: t for t in self.turns()} + self.assertEqual(sorted(turns), ["B", "C"], "the cancelled turn was stopped; both new ones ran") + self.assertLessEqual(turns["B"]["end"], turns["C"]["start"], "turns on one session never overlap") + + def test_turns_do_not_inherit_the_hosts_session_or_approval_state(self) -> None: + env = self.api.turn_env({ + "HERMES_YOLO_MODE": "1", "HERMES_SESSION_KEY": "k", "HERMES_UI_SESSION_ID": "u", + "HERMES_CRON_SESSION": "1", "HERMES_BROWSER_CONTROL_PRINCIPAL": "p", + "HERMES_DELEGATED_CHILD_CONTEXT": "1", "HERMES_GATEWAY_SESSION": "1", "HERMES_EXEC_ASK": "1", + "HERMES_HOME": "/h", "KEEP_ME": "yes", + }) + self.assertEqual(env, {"HERMES_HOME": "/h", "KEEP_ME": "yes"}) + + def test_delete_stops_the_turn_before_removing_the_app(self) -> None: + app_dir = self.box.dir / "crm" + app_dir.mkdir() + self.box.set_registry([{"id": "crm", "name": "CRM", "path": str(app_dir), "status": "stopped"}]) + sid = self.api.session_id_for(str(app_dir)) + self.client.post("/api/plugins/agent-app/session/send", json={"path": str(app_dir), "name": "CRM", "text": "SLEEP=60 evolve it"}) + deadline = time.time() + 10 + while self.manager.runner._sessions.get(sid) is None or self.manager.runner._sessions[sid].proc is None: + self.assertLess(time.time(), deadline, "the turn never started") + time.sleep(0.05) + proc = self.manager.runner._sessions[sid].proc + started = time.time() + r = self.client.post("/api/plugins/agent-app/app/remove", json={"path": str(app_dir)}) + self.assertEqual(r.json(), {"ok": True}) + self.assertLess(time.time() - started, 30) + self.assertIsNotNone(proc.poll(), "the agent's turn is gone before the app is removed") + self.assertEqual([c["argv"] for c in self.box.recorded()[-2:]], [[str(app_dir), "stop"], [str(app_dir), "remove", "--yes"]]) + self.assertFalse(self.manager.runner.busy(sid)) + + def test_deleting_a_build_before_it_registers_clears_its_directory(self) -> None: + os.environ["FAKE_TURN_DELAY"] = "60" + r = self.client.post("/api/plugins/agent-app/build", json={"name": "Acme CRM", "requirement": "Track deals."}) + path = Path(r.json()["path"]) + path.mkdir(parents=True) # what the agent's scaffold step would have written + (path / "package.json").write_text("{}", encoding="utf-8") + r = self.client.post("/api/plugins/agent-app/app/remove", json={"path": str(path)}) + self.assertEqual(r.json(), {"ok": True}) + self.assertFalse(path.exists(), "a partial build would refuse a rebuild under the same name") + self.assertEqual(self.client.get("/api/plugins/agent-app/apps").json()["rows"], []) + os.environ["FAKE_TURN_DELAY"] = "0" + again = self.client.post("/api/plugins/agent-app/build", json={"name": "Acme CRM", "requirement": "Track deals."}) + self.assertEqual(again.status_code, 200, again.text) + self.wait_idle(str(path)) + + def test_lifecycle_shells_agent_app(self) -> None: + app_dir = str(self.box.dir / "crm") + unknown = self.client.post("/api/plugins/agent-app/app/serve", json={"path": app_dir}) + self.assertEqual(unknown.status_code, 404) + self.box.set_registry([{"id": "crm", "name": "CRM", "path": app_dir, "status": "stopped"}]) + self.assertEqual(self.client.post("/api/plugins/agent-app/app/serve", json={"path": app_dir}).json(), {"ok": True, "message": ""}) + self.assertEqual(self.box.recorded()[-1]["argv"], [app_dir, "serve"]) + self.assertEqual(self.client.post("/api/plugins/agent-app/app/remove", json={"path": app_dir}).json(), {"ok": True}) + self.assertEqual([c["argv"] for c in self.box.recorded()[-2:]], [[app_dir, "stop"], [app_dir, "remove", "--yes"]]) + self.assertEqual(self.manager.sessions.deleted, [self.api.session_id_for(app_dir)]) + self.assertEqual(self.client.post("/api/plugins/agent-app/app/launch", json={"path": app_dir}).status_code, 404) + + def test_session_ids_are_stable_and_safe(self) -> None: + sid = self.api.session_id_for + self.assertEqual(sid("/x/My App!"), sid("/x/My App!/")) + self.assertNotEqual(sid("/x/crm"), sid("/y/crm")) + self.assertRegex(sid("/x/Ünïcode app"), r"^agent-app-[A-Za-z0-9_-]+$") + + def test_inside_hermes_venv_hermes_is_started_by_the_running_interpreter(self) -> None: + os.environ.pop("HERMES_BIN", None) + with unittest.mock.patch.object(sys, "base_prefix", sys.prefix + "-base"): + self.assertEqual(self.api.hermes_argv(), [sys.executable, "-m", "hermes_cli.main"]) + + def test_a_host_without_hermes_venv_starts_the_hermes_launcher(self) -> None: + # hermes-webui imports Hermes into a bare interpreter, which cannot run + # `-m hermes_cli.main` in a child. + os.environ.pop("HERMES_BIN", None) + bindir = self.box.dir / "bin" + bindir.mkdir() + launcher = bindir / ("hermes.exe" if os.name == "nt" else "hermes") + launcher.write_text("", encoding="utf-8") + launcher.chmod(0o755) + os.environ["PATH"] = str(bindir) + with unittest.mock.patch.object(sys, "base_prefix", sys.prefix): + self.assertEqual(Path(self.api.hermes_argv()[0]).resolve(), launcher.resolve()) + + +def load_module(name: str, path: Path): + sys.modules.pop(name, None) + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +class WebuiBackend(unittest.TestCase): + """The loopback backend the plugin starts inside hermes-webui.""" + + TOKEN = "t" * 43 + + def setUp(self) -> None: + self.mod = load_module("agent_app_webui_backend_under_test", PLUGIN / "webui_backend.py") + self.calls: list[tuple] = [] + + def dispatch(method, path, query, body): + self.calls.append((method, path, query, body)) + return 200, {"ok": True, "path": path} + + self.backend = self.mod.Backend(dispatch, self.TOKEN, self.mod.allowed_origins({"HERMES_WEBUI_PORT": "8787"})) + self.port = self.backend.server.server_address[1] + + def tearDown(self) -> None: + self.backend.stop() + + def request(self, method: str, path: str, *, origin: str | None = "http://127.0.0.1:8787", token: str | None = TOKEN, + host: str | None = None, body: bytes | None = None) -> tuple[int, dict, dict]: + import http.client + + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) + headers = {"Host": host or f"127.0.0.1:{self.port}"} + if origin is not None: + headers["Origin"] = origin + if token is not None: + headers["Authorization"] = f"Bearer {token}" + if body is not None: + headers["Content-Type"] = "application/json" + conn.request(method, path, body=body, headers=headers) + res = conn.getresponse() + raw = res.read() + conn.close() + return res.status, {k.lower(): v for k, v in res.getheaders()}, (json.loads(raw) if raw else {}) + + def test_serves_the_tab_routes_to_the_web_uis_origin(self) -> None: + status, headers, payload = self.request("GET", "/api/plugins/agent-app/apps") + self.assertEqual((status, payload), (200, {"ok": True, "path": "/apps"})) + self.assertEqual(headers["access-control-allow-origin"], "http://127.0.0.1:8787") + status, _, _ = self.request("POST", "/api/plugins/agent-app/build", body=b'{"name": "CRM"}') + self.assertEqual(status, 200) + self.assertEqual(self.calls[-1], ("POST", "/build", "", {"name": "CRM"})) + self.request("GET", "/api/plugins/agent-app/session?app=%2Fa%2Fcrm") + self.assertEqual(self.calls[-1][:3], ("GET", "/session", "app=%2Fa%2Fcrm")) + + def test_a_local_tool_without_an_origin_still_needs_the_token(self) -> None: + self.assertEqual(self.request("GET", "/api/plugins/agent-app/meta", origin=None)[0], 200) + self.assertEqual(self.request("GET", "/api/plugins/agent-app/meta", origin=None, token=None)[0], 401) + + def test_refuses_a_wrong_token_another_origin_and_a_rebinding_host(self) -> None: + status, headers, _ = self.request("GET", "/api/plugins/agent-app/apps", token="nope") + self.assertEqual(status, 401) + self.assertIn("access-control-allow-origin", headers, "the page can read a 401 and re-read runtime.json") + status, headers, _ = self.request("GET", "/api/plugins/agent-app/apps", origin="https://evil.example") + self.assertEqual(status, 403) + self.assertNotIn("access-control-allow-origin", headers) + self.assertEqual(self.request("GET", "/api/plugins/agent-app/apps", host=f"evil.example:{self.port}")[0], 403) + self.assertEqual(self.calls, [], "nothing refused reached the manager") + + def test_preflight_is_answered_only_for_the_web_ui(self) -> None: + status, headers, _ = self.request("OPTIONS", "/api/plugins/agent-app/build", token=None) + self.assertEqual(status, 204) + self.assertIn("Authorization", headers["access-control-allow-headers"]) + self.assertEqual(self.request("OPTIONS", "/api/plugins/agent-app/build", token=None, origin="https://evil.example")[0], 403) + + def test_rejects_a_body_that_is_not_json_and_paths_outside_the_api(self) -> None: + self.assertEqual(self.request("POST", "/api/plugins/agent-app/build", body=b"{nope")[0], 400) + self.assertEqual(self.request("GET", "/etc/passwd")[0], 404) + + def test_starts_only_in_the_web_ui_server(self) -> None: + checkout = Path(tempfile.mkdtemp(prefix="webui-")) + try: + (checkout / "api").mkdir() + (checkout / "api" / "extensions.py").write_text("", encoding="utf-8") + (checkout / "server.py").write_text("", encoding="utf-8") + self.assertTrue(self.mod.in_webui_server([str(checkout / "server.py")])) + self.assertFalse(self.mod.in_webui_server(["/usr/bin/hermes", "--cli", "chat"])) + self.assertFalse(self.mod.in_webui_server([str(PLUGIN / "server.py")]), "some other server.py") + with unittest.mock.patch.dict(os.environ, {"AGENT_APP_WEBUI_BACKEND": "0"}): + self.assertFalse(self.mod.in_webui_server([str(checkout / "server.py")])) + finally: + shutil.rmtree(checkout, ignore_errors=True) + + +@unittest.skipIf(NODE is None, "node is required for the fake framework CLIs") +class WebuiStart(unittest.TestCase): + """maybe_start() end to end: the real Manager behind the real backend.""" + + def setUp(self) -> None: + self.box = Sandbox() + self.state = self.box.dir / "webui-state" + (self.state / "extensions" / "agent-app").mkdir(parents=True) + (self.state / "extensions" / "agent-app" / "manifest.json").write_text("{}", encoding="utf-8") + self.checkout = self.box.dir / "hermes-webui" + (self.checkout / "api").mkdir(parents=True) + (self.checkout / "api" / "extensions.py").write_text("", encoding="utf-8") + (self.checkout / "server.py").write_text("", encoding="utf-8") + os.environ.update({"HERMES_WEBUI_STATE_DIR": str(self.state), "HERMES_WEBUI_PORT": "8787", "HERMES_HOME": str(self.box.dir / "hermes-home")}) + os.environ.pop("HERMES_WEBUI_EXTENSION_DIR", None) + self.mod = load_module("agent_app_webui_backend_start", PLUGIN / "webui_backend.py") + + def tearDown(self) -> None: + holder = sys.modules.pop(self.mod._HOLDER, None) + if holder is not None: + holder.backend.stop() + sys.modules.pop("hermes_agent_app_plugin_api", None) + self.box.close() + + def test_publishes_the_backend_to_the_extension_and_serves_the_manager(self) -> None: + import urllib.request + + with fake_modules({"api.extensions": None}): # not inside a real web UI: resolve the managed dir itself + with unittest.mock.patch.object(sys, "argv", [str(self.checkout / "server.py")]): + info = self.mod.maybe_start() + self.assertIs(self.mod.maybe_start(), info, "a second load reuses the running backend") + runtime = json.loads((self.state / "extensions" / "agent-app" / "runtime.json").read_text(encoding="utf-8")) + self.assertEqual(runtime["base"], info["base"]) + self.assertGreaterEqual(len(runtime["token"]), 32) + req = urllib.request.Request(runtime["base"] + "/api/plugins/agent-app/meta", headers={"Authorization": "Bearer " + runtime["token"]}) + with urllib.request.urlopen(req, timeout=10) as res: + meta = json.loads(res.read()) + self.assertTrue(meta["ok"]) + self.assertIn("blueprint-react-node", meta["blueprints"]) + self.assertEqual(Path(meta["appsDir"]).resolve(), (self.box.home / "apps").resolve()) + + def test_register_never_waits_for_the_backend(self) -> None: + # The web UI loads plugins midway through its own imports and gives + # register() 10s; a backend start that blocks (an import waiting on + # the web UI) must not hold the plugin load. + plugin = load_plugin() + release = threading.Event() + started = threading.Event() + + def blocked_start(): + started.set() + release.wait(30) + return None + + try: + with unittest.mock.patch.object(sys, "argv", [str(self.checkout / "server.py")]), \ + unittest.mock.patch.object(plugin.webui_backend, "maybe_start", blocked_start): + t = time.time() + plugin.register(FakeContext()) + self.assertLess(time.time() - t, 5, "register() returned while the backend was still starting") + self.assertTrue(started.wait(10), "the backend start ran on its own thread") + finally: + release.set() + + def test_does_nothing_without_the_extension(self) -> None: + shutil.rmtree(self.state / "extensions") + with fake_modules({"api.extensions": None}), unittest.mock.patch.object(sys, "argv", [str(self.checkout / "server.py")]): + self.assertIsNone(self.mod.maybe_start()) + + +class WebuiInstall(unittest.TestCase): + def test_installs_beside_other_extensions_and_records_itself(self) -> None: + build = load_module("agent_app_build_under_test", PLUGIN / "scripts" / "build.py") + tmp = Path(tempfile.mkdtemp(prefix="aa-build-")) + try: + build.DIST = tmp / "dist" + build.stage() + state = tmp / "webui" + state.mkdir() + other = {"version": "1.2.0", "files": ["app.js"], "installed_at": "2026-01-01T00:00:00Z"} + (state / "extension-install-manifest.json").write_text(json.dumps({"version": 1, "installed": {"other": other}}), encoding="utf-8") + target = build.install_webui(state) + self.assertEqual(sorted(p.name for p in target.iterdir()), sorted(build.WEBUI_FILES)) + record = json.loads((state / "extension-install-manifest.json").read_text(encoding="utf-8")) + self.assertEqual(record["installed"]["other"], other, "another extension's record is kept") + mine = record["installed"]["agent-app"] + self.assertIn("runtime.json", mine["files"], "the web UI's Uninstall removes the runtime file too") + manifest = json.loads((target / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["scripts"], ["manager.js", "agent-apps.js"], "the manager loads before the glue") + self.assertEqual((target / "manager.js").read_text(encoding="utf-8"), (PLUGIN / "dashboard" / "manager.js").read_text(encoding="utf-8")) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +@unittest.skipIf(NODE is None, "node is required to syntax-check the page") +class DashboardPage(unittest.TestCase): + def test_manifest_points_at_real_files(self) -> None: + manifest = json.loads((PLUGIN / "dashboard" / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["name"], "agent-app", "must equal plugin.yaml's name: the dashboard gates the tab on it") + for key in ("entry", "css", "api"): + self.assertTrue((PLUGIN / "dashboard" / manifest[key]).is_file(), key) + + def test_page_is_a_classic_script_that_registers_the_tab(self) -> None: + page = PLUGIN / "dashboard" / "manager.js" + subprocess.run([NODE, "--check", str(page)], check=True) + probe = textwrap.dedent(f""" + const registered = []; + globalThis.window = {{ + __HERMES_PLUGIN_SDK__: {{ React: {{ createElement: () => null }}, hooks: {{}}, fetchJSON: () => Promise.resolve({{}}) }}, + __HERMES_PLUGINS__: {{ register: (name, c) => registered.push([name, typeof c]) }}, + }}; + globalThis.location = {{ protocol: "http:", hostname: "127.0.0.1" }}; + require({json.dumps(str(page))}); + console.log(JSON.stringify(registered)); + """) + out = subprocess.run([NODE, "-e", probe], capture_output=True, text=True, check=True) + self.assertEqual(json.loads(out.stdout), [["agent-app", "function"]]) + + def test_without_the_dashboard_sdk_the_page_offers_mount_for_another_host(self) -> None: + page = PLUGIN / "dashboard" / "manager.js" + glue = PLUGIN / "webui" / "agent-apps.js" + subprocess.run([NODE, "--check", str(glue)], check=True) + probe = textwrap.dedent(f""" + globalThis.window = {{}}; + globalThis.location = {{ protocol: "http:", hostname: "127.0.0.1" }}; + require({json.dumps(str(page))}); + console.log(typeof window.__AGENT_APP_MANAGER__.mount); + """) + out = subprocess.run([NODE, "-e", probe], capture_output=True, text=True, check=True) + self.assertEqual(out.stdout.strip(), "function") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/harness-plugins/hermes/webui/agent-apps.css b/harness-plugins/hermes/webui/agent-apps.css new file mode 100644 index 0000000..4998b87 --- /dev/null +++ b/harness-plugins/hermes/webui/agent-apps.css @@ -0,0 +1,57 @@ +/* + * Agent Apps inside hermes-webui: size the manager (manager.css) to the web + * UI's plugin page view, and map its colour tokens onto the web UI's theme + * variables so the tab follows whichever theme is active. + */ + +/* While the Agent Apps view is showing (`.layout.aa-active`, set by + agent-apps.js), it gets the whole main area: it has its own tab strip and + session panel, so the left sidebar (still holding the previous view's list) + and the workspace panel's edge toggle make way, the plugin view's header + goes (the app title bar names the view), and its body drops its padding. + The sidebar and edge toggle stay at phone widths, where the sidebar is the + navigation drawer. The web UI's own sidebar-collapsed preference is not + touched. */ +.layout.aa-active #mainPlugin > .main-view-header { + display: none; +} +@media (min-width: 641px) { + .layout.aa-active > .sidebar:not(.mobile-open), + .layout.aa-active > .workspace-panel-edge-toggle, + .layout.aa-active > .rightpanel { + display: none; + } +} +.layout.aa-active #mainPlugin .main-view-body { + padding: 0; +} + +#pluginPageContainer.aa-host { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.aa.aa-webui { + --aa-fg: var(--text); + --aa-muted: var(--muted); + --aa-border: var(--border); + --aa-border-strong: var(--border2, color-mix(in srgb, var(--text) 24%, transparent)); + --aa-base: var(--bg); + --aa-card: var(--surface); + --aa-primary: var(--accent); + --aa-primary-fg: var(--bg); + --aa-ring: var(--accent); + --aa-ok: var(--success); + --aa-warn: var(--warning); + --aa-danger: var(--error); + --aa-radius: var(--radius-md, 8px); + --aa-radius-sm: var(--radius-sm, 4px); + --aa-font: var(--font-ui, system-ui, sans-serif); + --aa-mono: var(--font-mono, ui-monospace, monospace); + flex: 1; + min-height: 0; + height: 100%; + border: none; + border-radius: 0; +} diff --git a/harness-plugins/hermes/webui/agent-apps.js b/harness-plugins/hermes/webui/agent-apps.js new file mode 100644 index 0000000..f03cb00 --- /dev/null +++ b/harness-plugins/hermes/webui/agent-apps.js @@ -0,0 +1,239 @@ +/** + * Agent Apps in hermes-webui: the extension's glue. + * + * hermes-webui hosts a dashboard plugin's tab only as a sandboxed iframe and + * never mounts its API, and an Agent App framed inside that sandbox loses its + * own origin (no cookies or storage, its requests to itself refused). So the + * tab runs here instead, in the web UI's main page, as an extension: + * + * - an "Agent Apps" rail button opens the web UI's own plugin page view, and + * this script fills that view with the manager (manager.js, loaded just + * before it) rather than an iframe. Settings > Plugins > Open for the + * agent-app plugin lands in the same place, since the path is its tab.path; + * - the manager's API calls go to the plugin's loopback backend, which the + * plugin starts inside the web UI process and which publishes its address + * and token in runtime.json beside this file. + * + * Running apps embed only if the web UI's CSP allows framing them: + * HERMES_WEBUI_CSP_FRAME_EXTRA="http://127.0.0.1:* http://localhost:*". When a + * frame is refused, the manager switches to linking to the app instead. + * + * It hooks only what a plugin page already uses: switchPluginPage and + * _loadPluginPage, both globals of the web UI's panels.js. + */ +(function () { + "use strict"; + if (window.__AGENT_APPS_WEBUI__) return; + window.__AGENT_APPS_WEBUI__ = true; + + var TAB_PATH = "/agent-apps"; // the dashboard manifest's tab.path + var PANEL = "agent-apps"; + var LABEL = "Agent Apps"; + var RUNTIME_URL = "/extensions/agent-app/runtime.json"; + var UNREACHABLE = + "The agent-app plugin serves this tab from inside hermes-webui. If you just installed or enabled it, restart hermes-webui (./ctl.sh restart)."; + var NO_FRAME = + "hermes-webui's Content-Security-Policy does not allow framing local apps. To embed them, set HERMES_WEBUI_CSP_FRAME_EXTRA=\"http://127.0.0.1:* http://localhost:*\" and restart hermes-webui."; + + var manager = window.__AGENT_APP_MANAGER__; + var originalLoad = window._loadPluginPage; + if (!manager || typeof originalLoad !== "function" || typeof window.switchPluginPage !== "function") { + console.warn("[agent-app] this hermes-webui has no plugin page view to host Agent Apps in"); + return; + } + + /* ── The backend ── */ + var runtime = null; + function loadRuntime() { + return fetch(RUNTIME_URL, { cache: "no-store", credentials: "same-origin" }) + .then(function (res) { + if (!res.ok) throw new Error("The Agent Apps backend is not running."); + return res.json(); + }) + .then(function (rt) { + if (!rt || typeof rt.base !== "string" || typeof rt.token !== "string") throw new Error("runtime.json is malformed."); + runtime = rt; + return rt; + }); + } + + /** fetchJSON for the manager: the backend, with its token. A backend that + * restarted since the page loaded may have a new port or token, so a + * network failure or a 401 re-reads runtime.json once. Errors carry the + * body on `.body`, as the dashboard SDK's do. */ + function fetchJSON(url, init) { + var retried = false; + function attempt(rt) { + var opts = Object.assign({}, init || {}); + opts.headers = Object.assign({}, (init && init.headers) || {}, { Authorization: "Bearer " + rt.token }); + opts.credentials = "omit"; + opts.cache = "no-store"; + return fetch(rt.base + url, opts).then( + function (res) { + if (res.status === 401 && !retried) { + retried = true; + return loadRuntime().then(attempt); + } + return res.text().then(function (text) { + if (!res.ok) { + var err = new Error(res.status + ": " + text); + err.status = res.status; + err.body = text; + throw err; + } + return text ? JSON.parse(text) : {}; + }); + }, + function (err) { + if (retried) throw err; + retried = true; + return loadRuntime().then(attempt); + } + ); + } + return (runtime ? Promise.resolve(runtime) : loadRuntime()).then(attempt); + } + + /* ── The view ── */ + var mounted = null; // { root, dispose } + var layout = document.querySelector(".layout"); + var mainEl = document.querySelector("main.main"); + + // Whether the Agent Apps view is the one on screen. Read from the page, not + // from our own navigation, so leaving by any route (a rail button, a + // keyboard shortcut, another plugin page) counts. + function showing() { + return !!(mounted && mounted.root.isConnected && mainEl && mainEl.classList.contains("showing-plugin")); + } + + // `.layout.aa-active` while it shows, so agent-apps.css can give it the + // whole main area (no left sidebar, no plugin header, no padding); and the + // app title bar says "Agent Apps" rather than the previous view's name. + var wasShowing = false; + function syncChrome() { + var on = showing(); + if (layout) layout.classList.toggle("aa-active", on); + if (on !== wasShowing) { + wasShowing = on; + if (typeof window.syncAppTitlebar === "function") window.syncAppTitlebar(); + } + } + if (mainEl) new MutationObserver(syncChrome).observe(mainEl, { attributes: true, attributeFilter: ["class"] }); + + // The web UI names the title bar after its current panel, which for any + // plugin page is just "plugin", and it does not refresh the bar when a + // plugin page opens. Every refresh goes through this global, so the + // Agent Apps view answers it and every other view gets the original. + var originalTitlebar = window.syncAppTitlebar; + if (typeof originalTitlebar === "function") { + window.syncAppTitlebar = function () { + if (!showing()) return originalTitlebar.apply(this, arguments); + var titleEl = document.getElementById("appTitlebarTitle"); + var subEl = document.getElementById("appTitlebarSub"); + if (titleEl) { + titleEl.textContent = LABEL; + titleEl.ondblclick = null; // the bar's rename gesture is for chat sessions + } + if (subEl) { + subEl.textContent = ""; + subEl.hidden = true; + } + var bot = typeof window.assistantDisplayName === "function" ? window.assistantDisplayName() : ""; + document.title = bot ? LABEL + " — " + bot : LABEL; + }; + } + + function unmount() { + if (!mounted) return; + mounted.dispose(); + mounted.root.remove(); + mounted = null; + var container = document.getElementById("pluginPageContainer"); + if (container) container.classList.remove("aa-host"); + syncChrome(); + } + function mountInto(container) { + unmount(); + container.replaceChildren(); + container.classList.add("aa-host"); + var root = document.createElement("div"); + root.className = "aa aa-webui"; + container.appendChild(root); + mounted = { root: root, dispose: manager.mount(root, { fetchJSON: fetchJSON, fill: true, unreachableHint: UNREACHABLE }) }; + syncChrome(); + } + + // The web UI loads a plugin page into #pluginPageContainer through this + // global; the Agent Apps path gets the manager, every other path the + // original iframe (which empties the container, so the manager goes first). + window._loadPluginPage = async function (path, label) { + if (path !== TAB_PATH) { + unmount(); + return originalLoad.apply(this, arguments); + } + var container = document.getElementById("pluginPageContainer"); + if (!container) return; + var title = document.getElementById("pluginPageTitle"); + if (title) title.textContent = LABEL; + if (!mounted || !mounted.root.isConnected) mountInto(container); + try { + // panels.js's own record of the loaded page (a shared global binding), + // so switching back and forth does not reload it. + _currentPluginPage = { path: path, label: LABEL }; // eslint-disable-line no-undef + } catch (_) { /* an older web UI without it */ } + }; + + function open(event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + return Promise.resolve(window.switchPluginPage(null, TAB_PATH, LABEL)).then(function () { + document.querySelectorAll("[data-panel]").forEach(function (tab) { + tab.classList.toggle("active", tab.dataset.panel === PANEL); + }); + syncChrome(); + if (mounted) mounted.dispose.refresh(); + }); + } + + /* ── The rail button (desktop) and the sidebar nav entry (mobile) ── */ + var ICON = + ''; + function addButton(bar, className) { + if (!bar || bar.querySelector('[data-panel="' + PANEL + '"]')) return; + var button = document.createElement("button"); + button.type = "button"; + button.className = className; + button.dataset.panel = PANEL; + button.dataset.label = LABEL; + button.dataset.tooltip = LABEL; + button.setAttribute("aria-label", LABEL); + button.innerHTML = ICON; + button.addEventListener("click", open); + // Before the spacer, the dashboard link, or Settings, where the web UI's + // own tab-order code anchors movable tabs. + var anchor = Array.prototype.find.call(bar.children, function (child) { + return ( + child.classList.contains("rail-spacer") || + child.classList.contains("dashboard-link") || + child.hasAttribute("data-dashboard-link") || + child.dataset.panel === "settings" + ); + }); + bar.insertBefore(button, anchor || null); + } + addButton(document.querySelector(".rail"), "rail-btn nav-tab has-tooltip"); + addButton(document.querySelector(".sidebar-nav"), "nav-tab has-tooltip has-tooltip--bottom"); + + // A refused frame of a local app means the CSP has no frame-src for it. + document.addEventListener("securitypolicyviolation", function (e) { + if (!mounted || e.disposition !== "enforce") return; + var directive = e.effectiveDirective || e.violatedDirective || ""; + if (!/^(frame|child)-src/.test(directive)) return; + if (!/^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/.test(e.blockedURI || "")) return; + mounted.dispose.setEmbeddable(false, NO_FRAME); + }); +})(); diff --git a/harness-plugins/hermes/webui/manifest.json b/harness-plugins/hermes/webui/manifest.json new file mode 100644 index 0000000..e40fac1 --- /dev/null +++ b/harness-plugins/hermes/webui/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "agent-app", + "name": "Agent Apps", + "version": "0.1.0", + "description": "The Agent Apps tab of the agent-app Hermes plugin: one tab per Agent App, an embedded view of each running app, and its agent session beside it.", + "scripts": ["manager.js", "agent-apps.js"], + "stylesheets": ["manager.css", "agent-apps.css"] +} diff --git a/harness-plugins/hermes/webui_backend.py b/harness-plugins/hermes/webui_backend.py new file mode 100644 index 0000000..a1571e4 --- /dev/null +++ b/harness-plugins/hermes/webui_backend.py @@ -0,0 +1,330 @@ +"""The Agent Apps tab's backend inside hermes-webui. + +hermes-webui hosts the tab as an extension (webui/), a script in its main +page, and never mounts a plugin's API. So when this plugin loads inside the +hermes-webui server process, it serves the dashboard's routes itself, on a +loopback port, through the same Manager the dashboard uses +(`dashboard/plugin_api.dispatch`), and tells the extension where in +`runtime.json`, written into the extension's own directory. The web UI serves +that file like the extension's scripts, so the backend is exactly as protected +as the web UI itself. + +Every request must carry the token from that file, come from the web UI's own +origin (or carry no Origin, a local tool), and name a loopback Host (a DNS +rebinding page names its own). Nothing here starts outside the web UI server: +Hermes loads the plugin in every process, including the `hermes` turns this +backend starts, and those inherit the web UI's environment. + +Standard library only: it runs in whatever interpreter the web UI runs. +""" +from __future__ import annotations + +import hmac +import importlib.util +import json +import logging +import os +import secrets +import sys +import threading +import types +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +logger = logging.getLogger(__name__) + +PLUGIN_ROOT = Path(__file__).resolve().parent +EXTENSION_ID = "agent-app" +API_PREFIX = "/api/plugins/agent-app" +RUNTIME_FILE = "runtime.json" +MAX_BODY = 1 << 20 +LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "[::1]") + +# The running backend, kept in sys.modules so a second load of the plugin in +# the same process (a fresh copy of this module) finds it instead of starting +# another; and one lock for all copies, so two loads cannot both start one. +_HOLDER = "agent_app_webui_backend_running" +_LOCK = sys.modules.setdefault("agent_app_webui_backend_lock", types.ModuleType("agent_app_webui_backend_lock")).__dict__.setdefault("lock", threading.Lock()) + + +def in_webui_server(argv: list[str] | None = None) -> bool: + """True in the hermes-webui server process, which runs `server.py` from its + checkout (through runpy, with that argv). AGENT_APP_WEBUI_BACKEND=0 opts out.""" + if os.environ.get("AGENT_APP_WEBUI_BACKEND", "").strip() == "0": + return False + argv = sys.argv if argv is None else argv + if not argv or not argv[0]: + return False + script = Path(argv[0]) + try: + return script.name == "server.py" and (script.resolve().parent / "api" / "extensions.py").is_file() + except OSError: + return False + + +def extension_root() -> Path: + """The web UI's extension directory, resolved as the web UI resolves it: + HERMES_WEBUI_EXTENSION_DIR, else its managed STATE_DIR/extensions, where + STATE_DIR is HERMES_WEBUI_STATE_DIR, else $HERMES_HOME/webui. From the + environment only: importing the web UI's own modules from a plugin can + deadlock against the web UI's imports (see maybe_start).""" + raw = os.environ.get("HERMES_WEBUI_EXTENSION_DIR", "").strip() + if raw: + return Path(raw).expanduser() + state = os.environ.get("HERMES_WEBUI_STATE_DIR", "").strip() + if state: + return Path(state).expanduser() / "extensions" + home = os.environ.get("HERMES_HOME", "").strip() + return (Path(home).expanduser() if home else Path.home() / ".hermes") / "webui" / "extensions" + + +def extension_dir() -> Path | None: + """This plugin's installed extension, or None when it is not installed.""" + directory = extension_root() / EXTENSION_ID + return directory if (directory / "manifest.json").is_file() else None + + +def allowed_origins(environ: dict[str, str] | None = None) -> frozenset[str]: + """The web UI's own origins (its loopback host names on its port), plus + AGENT_APP_WEBUI_ORIGINS for a web UI reached some other way.""" + env = os.environ if environ is None else environ + port = env.get("HERMES_WEBUI_PORT", "").strip() or "8787" + extra = env.get("AGENT_APP_WEBUI_ORIGINS", "").replace(",", " ").split() + return frozenset({f"http://{host}:{port}" for host in LOOPBACK_HOSTS} | {o.rstrip("/") for o in extra}) + + +def _data_dir() -> Path: + try: + from hermes_constants import get_hermes_home + + return Path(get_hermes_home()) / "plugin-data" / "agent-app" + except Exception: + return Path.home() / ".hermes" / "plugin-data" / "agent-app" + + +def _write_private(file: Path, text: str) -> None: + """Write via a temp file and a rename, readable only by this user.""" + file.parent.mkdir(parents=True, exist_ok=True) + tmp = file.with_name(f".{file.name}.{os.getpid()}.tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh: + fh.write(text) + os.replace(tmp, file) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +def load_token(data_dir: Path) -> str: + """A token kept across restarts, so an open page's copy stays good.""" + file = data_dir / "webui-backend.token" + try: + token = file.read_text(encoding="utf-8").strip() + if len(token) >= 32: + return token + except OSError: + pass + token = secrets.token_urlsafe(32) + _write_private(file, token + "\n") + return token + + +def _load_plugin_api(): + name = "hermes_agent_app_plugin_api" + if name in sys.modules: + return sys.modules[name] + spec = importlib.util.spec_from_file_location(name, PLUGIN_ROOT / "dashboard" / "plugin_api.py") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + try: + spec.loader.exec_module(module) + except BaseException: + sys.modules.pop(name, None) + raise + return module + + +def _host_name(header: str) -> str: + host = header.strip().lower() + if host.startswith("["): + return host.split("]", 1)[0] + "]" + return host.rsplit(":", 1)[0] + + +def make_handler(dispatch, token: str, origins: frozenset[str]): + """A request handler serving `dispatch(method, path, query, body)` under + API_PREFIX, behind the token, origin, and Host checks.""" + expected = ("Bearer " + token).encode("utf-8") + + class Handler(BaseHTTPRequestHandler): + server_version = "agent-app-webui" + + def log_message(self, fmt: str, *args: Any) -> None: + logger.debug("agent-app webui backend: " + fmt, *args) + + def _origin(self) -> tuple[bool, str | None]: + origin = self.headers.get("Origin") + if origin is None: + return True, None + return origin in origins, origin + + def _host_ok(self) -> bool: + return _host_name(self.headers.get("Host") or "") in LOOPBACK_HOSTS + + def _cors(self, origin: str | None) -> None: + if origin is not None: + self.send_header("Access-Control-Allow-Origin", origin) + self.send_header("Vary", "Origin") + + def _send(self, status: int, payload: dict, origin: str | None = None) -> None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self._cors(origin) + self.end_headers() + self.wfile.write(data) + + def do_OPTIONS(self) -> None: # noqa: N802 (http.server naming) + ok, origin = self._origin() + if not (ok and origin is not None and self._host_ok()): + self._send(403, {"ok": False, "message": "forbidden"}) + return + self.send_response(204) + self._cors(origin) + self.send_header("Access-Control-Allow-Methods", "GET, POST") + self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type") + self.send_header("Access-Control-Max-Age", "600") + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self) -> None: # noqa: N802 + self._handle("GET") + + def do_POST(self) -> None: # noqa: N802 + self._handle("POST") + + def _handle(self, method: str) -> None: + ok, origin = self._origin() + if not ok or not self._host_ok(): + self._send(403, {"ok": False, "message": "forbidden"}) + return + given = (self.headers.get("Authorization") or "").encode("utf-8", "surrogateescape") + if not hmac.compare_digest(given, expected): + self._send(401, {"ok": False, "message": "missing or wrong token"}, origin) + return + parts = urlsplit(self.path) + if not parts.path.startswith(API_PREFIX + "/"): + self._send(404, {"ok": False, "message": "unknown endpoint"}, origin) + return + body: Any = None + if method == "POST": + try: + length = int(self.headers.get("Content-Length") or 0) + except ValueError: + length = -1 + if length < 0 or length > MAX_BODY: + self._send(413, {"ok": False, "message": "request body too large"}, origin) + return + raw = self.rfile.read(length) if length else b"" + try: + body = json.loads(raw) if raw else {} + except ValueError: + self._send(400, {"ok": False, "message": "the body is not JSON"}, origin) + return + status, payload = dispatch(method, parts.path[len(API_PREFIX):], parts.query, body) + self._send(status, payload, origin) + + return Handler + + +class Backend: + """One loopback server for the tab, on a daemon thread.""" + + def __init__(self, dispatch, token: str, origins: frozenset[str], port: int = 0) -> None: + handler = make_handler(dispatch, token, origins) + try: + self.server = ThreadingHTTPServer(("127.0.0.1", port), handler) + except OSError: + if port == 0: + raise + logger.warning("agent-app: port %s is taken; the hermes-webui backend uses a free one", port) + self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + self.server.daemon_threads = True + self.token = token + self.base = f"http://127.0.0.1:{self.server.server_address[1]}" + self.thread = threading.Thread(target=self.server.serve_forever, name="agent-app:webui-backend", daemon=True) + self.thread.start() + + def stop(self) -> None: + self.server.shutdown() + self.server.server_close() + + +def maybe_start() -> dict | None: + """Start the backend when this process is the hermes-webui server and the + extension is installed; publish where it listens. Returns what was + published, or None when there is nothing to do here. + + Never call this from `register()` itself: the web UI loads plugins midway + through its own imports, and Hermes waits at most 10s for `register()`. + This imports (the dashboard module, FastAPI when present), and an import + that needs a module the web UI is still importing would wait on the web + UI while the web UI waits on the plugin. `start_in_background` runs it + off the load path.""" + if not in_webui_server(): + return None + with _LOCK: + running = sys.modules.get(_HOLDER) + if running is not None: + return running.info + return _start() + + +def start_in_background(on_started=None) -> threading.Thread | None: + """maybe_start() on its own thread, when this is the web UI server.""" + if not in_webui_server(): + return None + + def run() -> None: + try: + info = maybe_start() + except Exception as exc: # the tab is optional; the plugin's other surfaces are not + logger.warning("agent-app: the hermes-webui tab backend did not start: %s", exc) + return + if info is not None and on_started is not None: + on_started(info) + + thread = threading.Thread(target=run, name="agent-app:webui-backend-start", daemon=True) + thread.start() + return thread + + +def _start() -> dict | None: + directory = extension_dir() + if directory is None: + logger.info("agent-app: the Agent Apps extension is not installed in hermes-webui; no tab backend") + return None + api = _load_plugin_api() + manager = api.MANAGER + try: + port = int(os.environ.get("AGENT_APP_WEBUI_BACKEND_PORT", "").strip() or 0) + except ValueError: + port = 0 + backend = Backend( + lambda method, path, query, body: api.dispatch(manager, method, path, query, body), + load_token(_data_dir()), + allowed_origins(), + port, + ) + info = {"base": backend.base, "token": backend.token} + _write_private(directory / RUNTIME_FILE, json.dumps(info) + "\n") + holder = types.ModuleType(_HOLDER) + holder.info = {"base": backend.base, "runtime": str(directory / RUNTIME_FILE)} + holder.backend = backend + sys.modules[_HOLDER] = holder + return holder.info