From e5c7517d5e719ef6c424019f01c957c6fd56e0e1 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 19 Oct 2026 20:45:29 -0300 Subject: [PATCH] fix(hello-world-automation): use script runner so plugin-spec curl works without a pre-provisioned agent profile The conversation-runner manifest declared a required runtime.agent_profile with default_name "hello-world-agent" and allowed_tools ["terminal"]. The automation service resolves that profile against the caller's org at plugin-spec creation time, so POST /api/automation/v1/plugin-spec fails with 422 "Agent profile 'hello-world-agent' not found" on any org that hasn't pre-provisioned a profile with that exact name and tool set. Switch the entrypoint to the script runner (runner: "script", command: "python3 scripts/main.py") and drop the runtime.agent_profile requirement. The script runner packages a deterministic entrypoint with no conversation and no LLM, so no agent/LLM profile needs to exist for the curl to succeed and a dispatched run to produce output. Add scripts/main.py: reads AUTOMATION_CONFIG_PATH for the greeting, echoes injected profile/env context, notes any event payload, and fires the completion callback (COMPLETED/FAILED) via AUTOMATION_CALLBACK_URL. Update README and marketplace/catalog description to match. Co-authored-by: openhands --- README.md | 2 +- marketplaces/openhands-extensions.json | 2 +- .../.plugin/plugin.json | 13 +-- plugins/hello-world-automation/README.md | 33 +++++- .../hello-world-automation/scripts/main.py | 107 ++++++++++++++++++ 5 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 plugins/hello-world-automation/scripts/main.py diff --git a/README.md b/README.md index 2a3aeb97..1745098a 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ Official skills and plugins for OpenHands — the open-source AI software engine | github-stale-ci-pr-closer | skill | Warn and close abandoned pull requests whose required CI remains failing. | `/github-stale-ci-pr-closer` | | gitlab | skill | Interact with GitLab repositories, merge requests, and APIs using the GITLAB_TOKEN environment variable. Use when wor... | — | | gitlab-issue-to-mr | skill | Create an automation that implements GitLab issues when a configurable trigger label is applied. Polls the projects d... | `/issue-to-mr:setup` | -| hello-world-automation | plugin | Example automation plugin that runs a plugin command through the stock conversation runner. | — | +| hello-world-automation | plugin | Example automation plugin that runs a deterministic script entrypoint with no required agent profile. | — | | incident-retrospective | skill | Create an automation that drafts incident retrospectives by gathering incident-channel messages from Slack, collectin... | `/incident-retro:setup` | | iterate | skill | Iterate on a GitHub pull request — drive it through CI, code review, and QA until merge-ready. Monitors state, fixes ... | `/iterate`, `/verify`, `/babysit` | | jira-issue-to-pr | skill | Deploy a cron-based OpenHands automation that opens a GitHub pull request, a GitLab merge request or a Bitbucket pull... | — | diff --git a/marketplaces/openhands-extensions.json b/marketplaces/openhands-extensions.json index e70fa869..0e359965 100644 --- a/marketplaces/openhands-extensions.json +++ b/marketplaces/openhands-extensions.json @@ -95,7 +95,7 @@ { "name": "hello-world-automation", "source": "./plugins/hello-world-automation", - "description": "Example automation plugin that runs a plugin command through the stock conversation runner.", + "description": "Example automation plugin that runs a deterministic script entrypoint with no required agent profile.", "category": "automations", "keywords": [ "automation", diff --git a/plugins/hello-world-automation/.plugin/plugin.json b/plugins/hello-world-automation/.plugin/plugin.json index cb579289..f08553c7 100644 --- a/plugins/hello-world-automation/.plugin/plugin.json +++ b/plugins/hello-world-automation/.plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hello-world-automation", "version": "0.1.0", - "description": "Example automation plugin that runs a plugin command through the stock conversation runner.", + "description": "Example automation plugin that runs a deterministic script entrypoint with no required agent profile.", "author": { "name": "OpenHands", "email": "contact@all-hands.dev" @@ -17,17 +17,10 @@ "automation": { "schema_version": "openhands.automation.plugin.v1", "entrypoint": { - "runner": "conversation", - "command": "hello" + "runner": "script", + "command": "python3 scripts/main.py" }, "runtime": { - "agent_profile": { - "required": true, - "default_name": "hello-world-agent", - "allowed_tools": [ - "terminal" - ] - }, "plugins": [] }, "config_schema": { diff --git a/plugins/hello-world-automation/README.md b/plugins/hello-world-automation/README.md index 1c17edfa..37cd2eef 100644 --- a/plugins/hello-world-automation/README.md +++ b/plugins/hello-world-automation/README.md @@ -1,15 +1,20 @@ # Hello World Automation Plugin -This is a minimal plugin-backed automation that uses the automation service stock runner. +This is a minimal plugin-backed **executable** automation. It uses the `script` +runner: the plugin ships a small Python entrypoint (`scripts/main.py`) that the +automation service packages and runs inside the sandbox on every trigger. The +entrypoint reads the resolved plugin config, prints a greeting, and fires the +completion callback. -The automation metadata is embedded in `.plugin/plugin.json` under the `automation` key. It points at a plugin command instead of a custom Python entrypoint: +The automation metadata is embedded in `.plugin/plugin.json` under the +`automation` key: ```json "automation": { "schema_version": "openhands.automation.plugin.v1", "entrypoint": { - "runner": "conversation", - "command": "hello" + "runner": "script", + "command": "python3 scripts/main.py" }, "suggested_trigger": { "type": "cron", @@ -19,6 +24,22 @@ The automation metadata is embedded in `.plugin/plugin.json` under the `automati } ``` -At run time, the automation service reads the embedded automation metadata and packages the stock SDK runner. The runner loads this same plugin as a conversation plugin and sends `/hello-world-automation:hello` as the prompt. The command prompt lives in `commands/hello.md`. +At run time, the automation service reads the embedded automation metadata, +packages the script-runner tarball (config, execution metadata, plugin list), +and the agent server runs `scripts/main.py` with `AUTOMATION_PLUGIN_ROOT` +pointing at the fetched plugin root. -The manifest also demonstrates `runtime.agent_profile.allowed_tools`: the automation service rejects an agent profile unless it explicitly enables exactly the tools the plugin allows. The same allowlist model applies to MCP server refs and secret refs when a plugin declares them. +### Why the `script` runner? + +A `conversation` runner entrypoint requires a pre-provisioned +`runtime.agent_profile` (a named agent profile that must already exist in the +caller's org, and — if `allowed_tools` is set — must enable exactly those +tools). That makes a conversation-runner example impossible to create on a +fresh org without first provisioning the named profile, so a `curl` against +`POST /api/automation/v1/plugin-spec` fails with `422 Agent profile +'hello-world-agent' not found`. + +The `script` runner needs no agent profile at all — it runs a deterministic +entrypoint with no conversation and no LLM. That keeps the example +self-contained: the plugin-spec `curl` succeeds and a dispatched run produces +output out of the box. See `scripts/main.py` for the entrypoint. diff --git a/plugins/hello-world-automation/scripts/main.py b/plugins/hello-world-automation/scripts/main.py new file mode 100644 index 00000000..4ef5d0d5 --- /dev/null +++ b/plugins/hello-world-automation/scripts/main.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Deterministic hello-world entrypoint for the hello-world-automation plugin. + +This script is the entrypoint for a plugin-backed executable automation that +uses the ``script`` runner (``entrypoint.runner == "script"``). It reads the +plugin config, prints a greeting, and fires the automation completion +callback. Because it does not start an agent conversation, it needs no +agent/LLM profile to be pre-provisioned — the automation is created and +dispatched without any ``runtime.agent_profile`` requirement. + +Environment variables consumed: + AUTOMATION_CONFIG_PATH - path to the resolved plugin config JSON + AUTOMATION_CALLBACK_URL - completion callback endpoint (optional) + AUTOMATION_RUN_ID - run id for the callback payload (optional) + AUTOMATION_EVENT_PAYLOAD - JSON event payload, if event-triggered (optional) + AUTOMATION_AGENT_PROFILE_NAME / AUTOMATION_LLM_PROFILE_NAME - injected when + present; echoed for observability. +""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request + + +def load_config() -> dict: + path = os.environ.get("AUTOMATION_CONFIG_PATH") + if not path or not os.path.isfile(path): + return {} + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError) as exc: + print(f"WARNING: could not read config at {path}: {exc}", file=sys.stderr) + return {} + + +def fire_callback(status: str, error: str | None = None) -> None: + url = os.environ.get("AUTOMATION_CALLBACK_URL", "") + if not url: + return + body: dict[str, object] = { + "status": status, + "run_id": os.environ.get("AUTOMATION_RUN_ID", ""), + } + if error: + body["error"] = error + token = ( + os.environ.get("AUTOMATION_CALLBACK_API_KEY") + or os.environ.get("OPENHANDS_API_KEY") + or "" + ) + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + try: + req = urllib.request.Request( + url, data=json.dumps(body).encode("utf-8"), headers=headers + ) + urllib.request.urlopen(req, timeout=10) + except (urllib.error.URLError, OSError) as exc: + print(f"WARNING: callback failed: {exc}", file=sys.stderr) + + +def main() -> int: + config = load_config() + message = config.get("message") or "Hello from the automation plugin" + + print("=== HELLO WORLD AUTOMATION ===") + print(f" message: {message}") + print( + " agent_profile_name: " + f"{os.environ.get('AUTOMATION_AGENT_PROFILE_NAME') or '(none)'}" + ) + print( + " llm_profile_name: " + f"{os.environ.get('AUTOMATION_LLM_PROFILE_NAME') or '(none)'}" + ) + + event_payload = os.environ.get("AUTOMATION_EVENT_PAYLOAD", "") + if event_payload: + try: + event = json.loads(event_payload) + event_type = event.get("type") or event.get("event_type") or "(unknown)" + print(f" event_context: {event_type}") + print(" (responding with the standard greeting plus event notice)") + except json.JSONDecodeError: + print(" event_context: (unparseable)") + else: + print(" event_context: (none)") + + print(f"\n{message} \U0001f44b") + return 0 + + +if __name__ == "__main__": + try: + code = main() + except Exception as exc: # pragma: no cover - defensive top-level guard + fire_callback("FAILED", str(exc)) + raise + fire_callback("COMPLETED" if code == 0 else "FAILED") + sys.exit(code)