From f66dc00a6240474fd99a4057b701d2a61aac853b Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 20 Oct 2026 13:22:58 -0300 Subject: [PATCH] feat(hello-world-automation): add script-runner automation plugin example Adds plugins/hello-world-automation, a minimal plugin-backed executable automation. Uses the 'script' runner (python3 scripts/main.py) so the plugin-spec create flow works without a pre-provisioned agent profile. Squashed from branch openhands/automation-plugin-hello-world-script-runner (head e5c7517, PR #797) which originated from a now-deleted fork and was never merged. Co-authored-by: openhands --- plugins/hello-world-automation/.claude-plugin | 1 + plugins/hello-world-automation/.codex-plugin | 1 + .../.plugin/plugin.json | 42 +++++++ plugins/hello-world-automation/README.md | 45 ++++++++ .../hello-world-automation/commands/hello.md | 11 ++ .../hello-world-automation/scripts/main.py | 107 ++++++++++++++++++ 6 files changed, 207 insertions(+) create mode 120000 plugins/hello-world-automation/.claude-plugin create mode 120000 plugins/hello-world-automation/.codex-plugin create mode 100644 plugins/hello-world-automation/.plugin/plugin.json create mode 100644 plugins/hello-world-automation/README.md create mode 100644 plugins/hello-world-automation/commands/hello.md create mode 100644 plugins/hello-world-automation/scripts/main.py diff --git a/plugins/hello-world-automation/.claude-plugin b/plugins/hello-world-automation/.claude-plugin new file mode 120000 index 00000000..665797f0 --- /dev/null +++ b/plugins/hello-world-automation/.claude-plugin @@ -0,0 +1 @@ +.plugin \ No newline at end of file diff --git a/plugins/hello-world-automation/.codex-plugin b/plugins/hello-world-automation/.codex-plugin new file mode 120000 index 00000000..665797f0 --- /dev/null +++ b/plugins/hello-world-automation/.codex-plugin @@ -0,0 +1 @@ +.plugin \ No newline at end of file diff --git a/plugins/hello-world-automation/.plugin/plugin.json b/plugins/hello-world-automation/.plugin/plugin.json new file mode 100644 index 00000000..f08553c7 --- /dev/null +++ b/plugins/hello-world-automation/.plugin/plugin.json @@ -0,0 +1,42 @@ +{ + "name": "hello-world-automation", + "version": "0.1.0", + "description": "Example automation plugin that runs a deterministic script entrypoint with no required agent profile.", + "author": { + "name": "OpenHands", + "email": "contact@all-hands.dev" + }, + "homepage": "https://github.com/OpenHands/extensions", + "repository": "https://github.com/OpenHands/extensions", + "license": "MIT", + "keywords": [ + "automation", + "example", + "hello-world" + ], + "automation": { + "schema_version": "openhands.automation.plugin.v1", + "entrypoint": { + "runner": "script", + "command": "python3 scripts/main.py" + }, + "runtime": { + "plugins": [] + }, + "config_schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "message": { + "type": "string", + "default": "Hello from the automation plugin" + } + } + }, + "suggested_trigger": { + "type": "cron", + "schedule": "0 9 * * *", + "timezone": "UTC" + } + } +} diff --git a/plugins/hello-world-automation/README.md b/plugins/hello-world-automation/README.md new file mode 100644 index 00000000..37cd2eef --- /dev/null +++ b/plugins/hello-world-automation/README.md @@ -0,0 +1,45 @@ +# Hello World Automation Plugin + +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: + +```json +"automation": { + "schema_version": "openhands.automation.plugin.v1", + "entrypoint": { + "runner": "script", + "command": "python3 scripts/main.py" + }, + "suggested_trigger": { + "type": "cron", + "schedule": "0 9 * * *", + "timezone": "UTC" + } +} +``` + +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. + +### 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/commands/hello.md b/plugins/hello-world-automation/commands/hello.md new file mode 100644 index 00000000..50016f74 --- /dev/null +++ b/plugins/hello-world-automation/commands/hello.md @@ -0,0 +1,11 @@ +--- +description: Say hello from the automation stock runner +--- + +# Hello World Automation + +Print a short hello-world response for the automation run. + +If event context is present in the task, mention that the run received event context. Otherwise, respond with: Hello from the automation plugin. + +$ARGUMENTS 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)