@openhands/extensions 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.claude-plugin/marketplace.json +24 -0
  2. package/.codex-plugin/marketplace.json +24 -0
  3. package/.github/pull_request_template.md +17 -2
  4. package/.github/scripts/check_pr_description.py +64 -5
  5. package/.github/scripts/markdown_sections.py +95 -0
  6. package/.github/workflows/build-vscode-app.yml +30 -0
  7. package/.github/workflows/check-extensions.yml +3 -0
  8. package/.github/workflows/tests.yml +26 -0
  9. package/.plugin/marketplace.json +24 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +9 -3
  13. package/apps/README.md +20 -0
  14. package/apps/catalog.json +10 -0
  15. package/apps/index.d.ts +11 -0
  16. package/apps/index.js +5 -0
  17. package/apps/vscode/README.md +13 -0
  18. package/apps/vscode/canvas-extension.json +19 -0
  19. package/apps/vscode/extension.js +114 -0
  20. package/apps/vscode/scripts/package.mjs +84 -0
  21. package/automations/README.md +7 -6
  22. package/automations/bundle-index.js +15 -15
  23. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  24. package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
  25. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
  26. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  27. package/automations/catalog/news-digest/manifest.json +2 -2
  28. package/automations/index.d.ts +9 -1
  29. package/automations/interface.json +9 -0
  30. package/automations/interface.schema.json +37 -2
  31. package/index.d.ts +2 -0
  32. package/index.js +1 -0
  33. package/marketplaces/openhands-extensions.json +24 -0
  34. package/package.json +9 -1
  35. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  36. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  37. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  38. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  39. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  40. package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
  41. package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
  42. package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
  43. package/plugins/runtime-api-configs/README.md +59 -0
  44. package/plugins/runtime-api-configs/SKILL.md +240 -0
  45. package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
  46. package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
  47. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
  48. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +67 -15
  49. package/pyproject.toml +1 -1
  50. package/python/openhands_extensions/_version.py +1 -1
  51. package/scripts/check_plugin_resources.py +116 -0
  52. package/scripts/sync_extensions.py +32 -5
  53. package/skills/canvas-extension-api/SKILL.md +34 -3
  54. package/skills/canvas-extension-api/references/backend-safety.md +14 -0
  55. package/skills/canvas-extension-api/references/connections.md +24 -0
  56. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
  57. package/skills/canvas-extension-api/references/v1-contract.md +53 -3
  58. package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
  59. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
  60. package/skills/github/scripts/agent_conversation.py +223 -43
  61. package/skills/github/scripts/github_client.py +67 -15
  62. package/skills/github-agents-md-maintainer/README.md +7 -0
  63. package/skills/github-agents-md-maintainer/SKILL.md +13 -6
  64. package/skills/github-agents-md-maintainer/scripts/github_client.py +67 -15
  65. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  66. package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
  67. package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
  68. package/skills/github-issue-to-pr/scripts/github_client.py +67 -15
  69. package/skills/github-issue-to-pr/scripts/worker.py +10 -1
  70. package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
  71. package/skills/github-issue-triage/scripts/github_client.py +67 -15
  72. package/skills/github-pr-reviewer/README.md +5 -4
  73. package/skills/github-pr-reviewer/SKILL.md +8 -17
  74. package/skills/github-pr-reviewer/references/state-schema.md +0 -15
  75. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  76. package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
  77. package/skills/github-pr-reviewer/scripts/worker.py +131 -108
  78. package/skills/github-repo-monitor/scripts/github_client.py +67 -15
  79. package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
  80. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
  81. package/skills/gitlab-issue-to-mr/README.md +12 -0
  82. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  83. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  84. package/skills/incident-retrospective/SKILL.md +5 -5
  85. package/skills/index.js +23 -14
  86. package/skills/jira-issue-to-pr/README.md +12 -4
  87. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  88. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  89. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  90. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  91. package/skills/linear-triage/SKILL.md +5 -5
  92. package/skills/news-digest/README.md +6 -0
  93. package/skills/news-digest/SKILL.md +19 -9
  94. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  95. package/skills/news-digest/scripts/main.py +133 -15
  96. package/skills/openhands-api/README.md +1 -1
  97. package/skills/openhands-api/SKILL.md +3 -3
  98. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  99. package/skills/openhands-automation/SKILL.md +66 -4
  100. package/skills/openhands-automation/references/custom-automation.md +21 -7
  101. package/skills/research-brief/SKILL.md +5 -5
  102. package/skills/slack-standup-digest/SKILL.md +5 -5
  103. package/skills/ticket-to-code-change/SKILL.md +6 -3
  104. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  105. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  106. package/skills/use-jev/.plugin/plugin.json +8 -0
  107. package/skills/use-jev/README.md +66 -0
  108. package/skills/use-jev/SKILL.md +66 -0
  109. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  110. package/tests/test_agent_conversation_dispatch.py +212 -0
  111. package/tests/test_apps_catalog.py +26 -0
  112. package/tests/test_automation_setup.py +44 -0
  113. package/tests/test_check_pr_description.py +89 -6
  114. package/tests/test_github_reviewer_delivery.py +254 -118
  115. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  116. package/tests/test_interface_manifest.py +23 -1
  117. package/tests/test_jira_issue_to_pr.py +312 -0
  118. package/tests/test_jira_setup_webhook.py +309 -0
  119. package/tests/test_openhands_api_python.py +37 -0
  120. package/tests/test_skill_plugin_loading.py +25 -15
  121. package/tests/test_sync_extensions.py +102 -0
  122. package/tests/test_vscode_app.py +33 -0
  123. package/tests/vscode-app.test.mjs +43 -0
@@ -0,0 +1,290 @@
1
+ """
2
+ Set up the Jira Cloud webhook of an event-based Jira automation.
3
+
4
+ Registers a custom webhook source in OpenHands and the matching admin webhook in
5
+ Jira Cloud. The two share a signing secret that is generated or rotated here and is
6
+ never printed, so it does not enter the conversation. A webhook that already exists
7
+ on both sides is updated in place and keeps its secret.
8
+
9
+ Usage:
10
+ python3 setup_webhook.py apply --jira-base-url URL --jira-email EMAIL --openhands-host HOST
11
+ python3 setup_webhook.py delete --jira-base-url URL --jira-email EMAIL --openhands-host HOST
12
+
13
+ --jira-token-env NAME environment variable holding the Jira API token (default JIRA_CLOUD_KEY)
14
+ --source NAME OpenHands webhook source (default "jira")
15
+ --label LABEL Jira label the webhook is limited to (default "create-pr")
16
+ --jql JQL JQL filter to use instead of the label
17
+ --name NAME name of the webhook in Jira
18
+ --dry-run apply only: report what would change, change nothing
19
+ --rotate-secret apply only: give an existing pair a new signing secret; without
20
+ it a webhook that exists on both sides keeps the one it has
21
+
22
+ Environment:
23
+ OPENHANDS_API_KEY API key for the OpenHands deployment
24
+ <--jira-token-env> API token of an account with the Administer Jira permission
25
+
26
+ Exit codes: 0 done, 1 failed, 2 bad arguments or environment, 3 the Jira account is
27
+ not an administrator (set the webhook up by hand instead).
28
+ """
29
+ import argparse, base64, hashlib, hmac, json, os, secrets, sys, urllib.error, urllib.parse, urllib.request
30
+
31
+ JIRA_EVENTS = ["jira:issue_created", "jira:issue_updated"]
32
+ # What Jira Cloud sends: the event name in `webhookEvent`, the signature in
33
+ # `X-Hub-Signature` as "sha256=<hex HMAC-SHA256 of the body>".
34
+ OPENHANDS_WEBHOOK = {
35
+ "event_key_expr": "webhookEvent",
36
+ "signature_header": "X-Hub-Signature",
37
+ "signature_scheme": "hmac_sha256_hex",
38
+ }
39
+ # No automation listens for this event name, so the signed check starts no run.
40
+ CHECK_EVENT = "openhands:setup_check"
41
+
42
+ _secret = ""
43
+
44
+
45
+ class Failure(Exception):
46
+ def __init__(self, message, code=1):
47
+ super().__init__(message)
48
+ self.code = code
49
+
50
+
51
+ def _redact(text):
52
+ return text.replace(_secret, "***") if _secret else text
53
+
54
+
55
+ def _call(method, url, headers, body=None):
56
+ """Return (status, parsed JSON or text). An HTTP error status is returned, not raised."""
57
+ data = json.dumps(body).encode() if body is not None else None
58
+ if data is not None:
59
+ headers = {**headers, "Content-Type": "application/json"}
60
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
61
+ try:
62
+ with urllib.request.urlopen(req, timeout=60) as resp:
63
+ status, raw = resp.status, resp.read()
64
+ except urllib.error.HTTPError as exc:
65
+ status, raw = exc.code, exc.read()
66
+ except urllib.error.URLError as exc:
67
+ raise Failure(f"{method} {url} failed: {exc.reason}") from exc
68
+ text = raw.decode("utf-8", errors="replace")
69
+ try:
70
+ return status, json.loads(text) if text.strip() else {}
71
+ except ValueError:
72
+ return status, text
73
+
74
+
75
+ def _expect(what, status, body, ok=(200, 201, 204)):
76
+ if status not in ok:
77
+ raise Failure(f"{what} failed: HTTP {status} {_redact(str(body))[:300]}")
78
+ return body
79
+
80
+
81
+ class OpenHands:
82
+ def __init__(self, host, api_key, source):
83
+ self.base = host.rstrip("/") + "/api/automation/v1/webhooks"
84
+ self.headers = {"Authorization": f"Bearer {api_key}"}
85
+ self.source = source
86
+
87
+ def find(self):
88
+ status, body = _call("GET", f"{self.base}?limit=100", self.headers)
89
+ webhooks = _expect("Listing OpenHands webhooks", status, body).get("webhooks", [])
90
+ return next((w for w in webhooks if w.get("source") == self.source), None)
91
+
92
+ def create(self, secret):
93
+ payload = {"name": "Jira Cloud", "source": self.source, "webhook_secret": secret,
94
+ **OPENHANDS_WEBHOOK}
95
+ status, body = _call("POST", self.base, self.headers, payload)
96
+ return _expect("Registering the OpenHands webhook", status, body)
97
+
98
+ def align(self, webhook):
99
+ """Give an existing webhook the settings Jira needs; return it."""
100
+ wanted = {**OPENHANDS_WEBHOOK, "enabled": True}
101
+ if all(webhook.get(k) == v for k, v in wanted.items()):
102
+ return webhook
103
+ status, body = _call("PATCH", f"{self.base}/{webhook['id']}", self.headers, wanted)
104
+ return _expect("Updating the OpenHands webhook", status, body)
105
+
106
+ def rotate(self, webhook):
107
+ status, body = _call("POST", f"{self.base}/{webhook['id']}/rotate-secret", self.headers)
108
+ if status != 200 or not isinstance(body, dict) or not body.get("webhook_secret"):
109
+ raise Failure(f"Rotating the OpenHands webhook secret failed: HTTP {status}")
110
+ return body["webhook_secret"]
111
+
112
+
113
+ class Jira:
114
+ def __init__(self, base_url, email, token):
115
+ self.site = base_url.rstrip("/")
116
+ self.base = self.site + "/rest/webhooks/1.0/webhook"
117
+ basic = base64.b64encode(f"{email}:{token}".encode()).decode()
118
+ self.headers = {"Authorization": f"Basic {basic}", "Accept": "application/json"}
119
+
120
+ def require_admin(self):
121
+ status, body = _call(
122
+ "GET", f"{self.site}/rest/api/3/mypermissions?permissions=ADMINISTER", self.headers
123
+ )
124
+ if status in (401, 403):
125
+ raise Failure(f"Jira rejected the credentials (HTTP {status}). Check the site URL, "
126
+ "the account email and the API token.")
127
+ body = _expect("Reading Jira permissions", status, body)
128
+ try:
129
+ permission = body["permissions"]["ADMINISTER"]
130
+ except (KeyError, TypeError) as exc:
131
+ raise Failure("Reading Jira permissions returned an unexpected response.") from exc
132
+ if not permission.get("havePermission"):
133
+ raise Failure("This Jira account does not have the Administer Jira permission, "
134
+ "which registering a webhook requires. Ask a Jira administrator to "
135
+ "create the webhook by hand.", code=3)
136
+
137
+ def find(self, url):
138
+ """The webhooks delivering to `url`; none when no URL is known yet."""
139
+ status, body = _call("GET", self.base, self.headers)
140
+ hooks = _expect("Listing Jira webhooks", status, body)
141
+ if not isinstance(hooks, list):
142
+ raise Failure("Listing Jira webhooks returned an unexpected response.")
143
+ return [h for h in hooks if url and h.get("url") == url]
144
+
145
+ @staticmethod
146
+ def _id(hook):
147
+ return str(hook["self"]).rstrip("/").rsplit("/", 1)[-1]
148
+
149
+ def save(self, existing, payload):
150
+ if existing:
151
+ hook_id = self._id(existing[0])
152
+ status, body = _call("PUT", f"{self.base}/{hook_id}", self.headers, payload)
153
+ _expect("Updating the Jira webhook", status, body)
154
+ return hook_id, "updated"
155
+ status, body = _call("POST", self.base, self.headers, payload)
156
+ return self._id(_expect("Creating the Jira webhook", status, body)), "created"
157
+
158
+ def delete(self, hook):
159
+ status, body = _call("DELETE", f"{self.base}/{self._id(hook)}", self.headers)
160
+ _expect("Deleting the Jira webhook", status, body)
161
+ return self._id(hook)
162
+
163
+
164
+ def _signed_check(url, secret):
165
+ """Deliver one request signed the way Jira signs, to prove the pair works."""
166
+ body = json.dumps({"webhookEvent": CHECK_EVENT}).encode()
167
+ signature = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
168
+ req = urllib.request.Request(url, data=body, method="POST", headers={
169
+ "Content-Type": "application/json", "X-Hub-Signature": f"sha256={signature}"})
170
+ try:
171
+ with urllib.request.urlopen(req, timeout=60) as resp:
172
+ return "ok" if resp.status == 200 else f"HTTP {resp.status}"
173
+ except urllib.error.HTTPError as exc:
174
+ return f"HTTP {exc.code} {_redact(exc.read().decode('utf-8', errors='replace'))[:200]}"
175
+ except urllib.error.URLError as exc:
176
+ return f"unreachable: {exc.reason}"
177
+
178
+
179
+ def apply(args, openhands, jira):
180
+ global _secret
181
+ jira.require_admin()
182
+ jql = args.jql or 'labels = "{}"'.format(args.label.replace("\\", "\\\\").replace('"', '\\"'))
183
+ name = args.name or f"OpenHands: issues labelled {args.label}"
184
+ webhook = openhands.find()
185
+ # Asked before anything is written: it proves Jira serves this account the
186
+ # webhook API, and says whether Jira already holds this webhook's secret.
187
+ in_jira = jira.find(webhook["webhook_url"] if webhook else None)
188
+ # A pair that exists on both sides keeps its secret, so a failure half way
189
+ # through cannot leave Jira signing with one OpenHands no longer accepts.
190
+ new_secret = args.rotate_secret or not (webhook and in_jira)
191
+
192
+ if args.dry_run:
193
+ if not webhook:
194
+ openhands_plan = f"register source '{args.source}'"
195
+ elif new_secret:
196
+ openhands_plan = "rotate the secret of the existing one"
197
+ else:
198
+ openhands_plan = "keep the existing one and its secret"
199
+ return {
200
+ "dry_run": True,
201
+ "openhands_webhook": openhands_plan,
202
+ "jira_webhook": "update the existing one" if in_jira else "create",
203
+ "jira": {"site": jira.site, "name": name, "events": JIRA_EVENTS, "jql": jql,
204
+ "url": webhook["webhook_url"] if webhook else "(assigned on registration)"},
205
+ }
206
+
207
+ payload = {"name": name, "events": JIRA_EVENTS, "excludeBody": False,
208
+ "filters": {"issue-related-events-section": jql}}
209
+ if webhook:
210
+ webhook = openhands.align(webhook)
211
+ if new_secret:
212
+ _secret = openhands.rotate(webhook)
213
+ else:
214
+ _secret = secrets.token_urlsafe(32)
215
+ webhook = openhands.create(_secret)
216
+ url = webhook["webhook_url"]
217
+ if not url.startswith("https://"):
218
+ raise Failure(f"Jira only delivers to HTTPS URLs, and this deployment's is {url}")
219
+ if new_secret:
220
+ # Left out otherwise: Jira keeps the secret of a webhook updated without one.
221
+ payload["secret"] = _secret
222
+
223
+ try:
224
+ hook_id, action = jira.save(in_jira, {**payload, "url": url})
225
+ except Failure as exc:
226
+ if not new_secret:
227
+ raise
228
+ raise Failure(f"{exc} OpenHands now holds a signing secret that Jira does not, so "
229
+ "Jira's deliveries are refused until apply succeeds; run it again once "
230
+ "the cause is fixed.", exc.code) from exc
231
+
232
+ return {
233
+ "openhands_webhook": {"id": webhook["id"], "source": webhook["source"], "url": url},
234
+ "jira_webhook": {"id": hook_id, "action": action, "name": name,
235
+ "events": JIRA_EVENTS, "jql": jql},
236
+ "signing_secret": "new" if new_secret else "unchanged",
237
+ "signed_delivery_check": _signed_check(url, _secret) if new_secret
238
+ else "skipped: the secret was not changed",
239
+ }
240
+
241
+
242
+ def delete(args, openhands, jira):
243
+ jira.require_admin()
244
+ webhook = openhands.find()
245
+ if not webhook:
246
+ return {"deleted_jira_webhooks": [],
247
+ "note": f"No OpenHands webhook with source '{args.source}', so no Jira webhook to match."}
248
+ return {"deleted_jira_webhooks": [jira.delete(h) for h in jira.find(webhook["webhook_url"])],
249
+ "note": "The OpenHands webhook is left in place; it receives nothing now."}
250
+
251
+
252
+ def main():
253
+ parser = argparse.ArgumentParser(description="Set up the Jira Cloud webhook of an event-based automation.")
254
+ parser.add_argument("action", choices=["apply", "delete"])
255
+ parser.add_argument("--jira-base-url", required=True)
256
+ parser.add_argument("--jira-email", required=True)
257
+ parser.add_argument("--openhands-host", required=True)
258
+ parser.add_argument("--jira-token-env", default="JIRA_CLOUD_KEY")
259
+ parser.add_argument("--source", default="jira")
260
+ parser.add_argument("--label", default="create-pr")
261
+ parser.add_argument("--jql")
262
+ parser.add_argument("--name")
263
+ parser.add_argument("--dry-run", action="store_true")
264
+ parser.add_argument("--rotate-secret", action="store_true")
265
+ args = parser.parse_args()
266
+
267
+ api_key = os.environ.get("OPENHANDS_API_KEY", "")
268
+ token = os.environ.get(args.jira_token_env, "")
269
+ try:
270
+ if not api_key:
271
+ raise Failure("OPENHANDS_API_KEY is not set.", code=2)
272
+ if not token:
273
+ raise Failure(f"{args.jira_token_env} is not set. Store the Jira API token as an "
274
+ "OpenHands secret and name it in the command.", code=2)
275
+ if urllib.parse.urlparse(args.jira_base_url).scheme != "https":
276
+ raise Failure("--jira-base-url must be an https:// URL.", code=2)
277
+ if urllib.parse.urlparse(args.openhands_host).scheme != "https":
278
+ raise Failure("--openhands-host must be an https:// URL: Jira only delivers "
279
+ "webhooks over HTTPS.", code=2)
280
+ openhands = OpenHands(args.openhands_host, api_key, args.source)
281
+ jira = Jira(args.jira_base_url, args.jira_email, token)
282
+ result = (apply if args.action == "apply" else delete)(args, openhands, jira)
283
+ except Failure as exc:
284
+ print(_redact(str(exc)), file=sys.stderr)
285
+ sys.exit(exc.code)
286
+ print(_redact(json.dumps(result, indent=2)))
287
+
288
+
289
+ if __name__ == "__main__":
290
+ main()
@@ -64,14 +64,14 @@ Construct a prompt that includes:
64
64
 
65
65
  ### Step 4 — Create the automation
66
66
 
67
- Read the Automation backend URL and auth from `<RUNTIME_SERVICES>`:
68
- - Use the **Automation backend** `url_from_agent` as `OPENHANDS_HOST`
69
- - Auth: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`
67
+ Set `OPENHANDS_HOST` and `AUTH_HEADER` for the request below:
68
+ - **OpenHands Cloud or Enterprise** - a `<HOST>` value is in your system prompt: use it as `OPENHANDS_HOST`, with `AUTH_HEADER="Authorization: Bearer $OPENHANDS_API_KEY"`
69
+ - **Local Agent Canvas** - an **Automation backend** is listed in `<RUNTIME_SERVICES>`: use its `url_from_agent` as `OPENHANDS_HOST`, with `AUTH_HEADER="X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY"`
70
70
 
71
71
  Use the **prompt preset** endpoint:
72
72
  ```bash
73
73
  curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1/preset/prompt" \
74
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
74
+ -H "$AUTH_HEADER" \
75
75
  -H "Content-Type: application/json" \
76
76
  -d '{
77
77
  "name": "Linear Issue Triage",
@@ -80,7 +80,7 @@ curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1/preset/prompt" \
80
80
  }'
81
81
  ```
82
82
 
83
- PowerShell note: use `curl.exe` for this exact flag syntax, and replace `${OPENHANDS_HOST}` / `$OPENHANDS_AUTOMATION_API_KEY` with `$env:OPENHANDS_HOST` / `$env:OPENHANDS_AUTOMATION_API_KEY` if running it natively.
83
+ PowerShell note: use `curl.exe` for this exact flag syntax, and replace `${OPENHANDS_HOST}` / `$AUTH_HEADER` with `$env:OPENHANDS_HOST` / `$env:AUTH_HEADER` if running it natively.
84
84
 
85
85
  ### Step 5 — Confirm
86
86
 
@@ -8,6 +8,12 @@ your topics and write a short digest.
8
8
  makes it the automation to run first, before deciding what access you are
9
9
  willing to hand over.
10
10
 
11
+ On a local Agent Canvas the conversation that writes the digest is also started
12
+ with no secrets and no MCP servers. On OpenHands Cloud and Enterprise it is
13
+ started through the OpenHands API with your own settings, so it holds your
14
+ secrets and MCP servers like any conversation you start there, while reading
15
+ feed text nobody vetted.
16
+
11
17
  ## Trigger
12
18
 
13
19
  This skill is activated by:
@@ -4,8 +4,8 @@ description: >
4
4
  Create an automation that reads a list of public RSS and Atom feeds on a
5
5
  schedule - daily by default - keeps what is new and matches the configured
6
6
  topics, and has an agent write a short digest of it. It needs no credentials:
7
- the feeds are public URLs and the conversation is started with no secrets and
8
- no MCP servers.
7
+ the feeds are public URLs and, on a local Agent Canvas, the conversation is
8
+ started with no secrets and no MCP servers.
9
9
  triggers:
10
10
  - /news-digest:setup
11
11
  ---
@@ -154,17 +154,24 @@ python3 -m py_compile /tmp/news-digest-build/main.py && echo "Syntax OK"
154
154
 
155
155
  ### Step 6 - Package and upload
156
156
 
157
- Determine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`
158
- block in your system context:
159
- - **OPENHANDS_HOST**: the Automation backend `url_from_agent`
160
- - **Auth**: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`
157
+ Set `OPENHANDS_HOST` and `AUTH_HEADER` for the commands below:
158
+ - **OpenHands Cloud or Enterprise** - a `<HOST>` value is in your system prompt:
159
+ use it as `OPENHANDS_HOST`, with `AUTH_HEADER="Authorization: Bearer $OPENHANDS_API_KEY"`
160
+ - **Local Agent Canvas** - an **Automation backend** is listed in the
161
+ `<RUNTIME_SERVICES>` block of your system context: use its `url_from_agent` as
162
+ `OPENHANDS_HOST`, with `AUTH_HEADER="X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY"`
163
+
164
+ On OpenHands Cloud or Enterprise each run starts in a fresh sandbox, so the
165
+ automation keeps its state in the KV store: check that `kvStore` is listed under
166
+ `features` in `GET ${OPENHANDS_HOST}/api/automation/v1/capabilities` before
167
+ deploying.
161
168
 
162
169
  ```bash
163
170
  tar -czf /tmp/news-digest.tar.gz -C /tmp/news-digest-build .
164
171
 
165
172
  TARBALL_PATH=$(curl -s -X POST \
166
173
  "${OPENHANDS_HOST}/api/automation/v1/uploads?name=news-digest" \
167
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
174
+ -H "$AUTH_HEADER" \
168
175
  -H "Content-Type: application/gzip" \
169
176
  --data-binary @/tmp/news-digest.tar.gz \
170
177
  | python3 -c "import json,sys; print(json.load(sys.stdin)['tarball_path'])")
@@ -176,7 +183,7 @@ echo "Uploaded: $TARBALL_PATH"
176
183
 
177
184
  ```bash
178
185
  curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1" \
179
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
186
+ -H "$AUTH_HEADER" \
180
187
  -H "Content-Type: application/json" \
181
188
  -d "{
182
189
  \"name\": \"Daily news digest\",
@@ -210,7 +217,7 @@ Then offer to dispatch it once so they can read today's digest immediately:
210
217
 
211
218
  ```bash
212
219
  curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1/{id}/dispatch" \
213
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY"
220
+ -H "$AUTH_HEADER"
214
221
  ```
215
222
 
216
223
  ---
@@ -241,6 +248,9 @@ shipped one and then:
241
248
  with the stories **and the topics** in its prompt, an empty secrets payload
242
249
  and no MCP servers, working in `{WORKSPACE_BASE}/news-digest/{date}`. The
243
250
  agent decides which stories are relevant before it writes anything.
251
+ On OpenHands Cloud and Enterprise the conversation is started through
252
+ the OpenHands API instead, in a sandbox of its own and with the user's own
253
+ settings, so there it does hold the user's secrets and MCP servers.
244
254
  7. When the conversation reaches `idle`, `finished`, `error` or `stuck`:
245
255
  - reads `digest.md` from the working directory, falling back to the agent's
246
256
  final message;
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  # auto-generated by sync_extensions.py
3
- description: Create an automation that reads a list of public RSS and Atom feeds on a schedule - daily by default - keeps what is new and matches the configured topics, and has an agent write a short digest of it. It needs no credentials: the feeds are public URLs and the conversation is started with no secrets and no MCP servers.
3
+ description: Create an automation that reads a list of public RSS and Atom feeds on a schedule - daily by default - keeps what is new and matches the configured topics, and has an agent write a short digest of it. It needs no credentials: the feeds are public URLs and, on a local Agent Canvas, the conversation is started with no secrets and no MCP servers.
4
4
  ---
5
5
 
6
6
  Read and follow the complete instructions in the SKILL.md file located in this skill's directory.
@@ -8,7 +8,10 @@ This automation needs no credentials. It authenticates to nothing: the feeds are
8
8
  public URLs fetched over plain HTTPS, and the conversation is started with an
9
9
  empty secret allow-list and no MCP servers, so there is nothing for it to leak.
10
10
  That is deliberate - it is the automation to reach for when you want to see one
11
- working before you decide which tokens you are willing to hand over.
11
+ working before you decide which tokens you are willing to hand over. The empty
12
+ allow-list holds on a local Agent Canvas. On OpenHands Cloud and Enterprise the
13
+ OpenHands API starts the conversation in a sandbox of its own, with the user's
14
+ own model, secrets and MCP servers; the script itself still uses none of them.
12
15
 
13
16
  The split of duties is the same as the other bundled automations, drawn at what
14
17
  has a right answer. Python owns the schedule, the once-a-day claim, fetching,
@@ -37,6 +40,7 @@ import time
37
40
  import urllib.error
38
41
  import urllib.parse
39
42
  import urllib.request
43
+ import uuid
40
44
  from collections.abc import Callable
41
45
  from datetime import datetime, timezone
42
46
  from email.utils import parsedate_to_datetime
@@ -224,17 +228,24 @@ USER_AGENT = "OpenHands-News-Digest/1.0 (+https://github.com/OpenHands/extension
224
228
  DIGEST_FILENAME = "digest.md"
225
229
 
226
230
 
231
+ # A local Agent Canvas run is handed the Agent Server URL. A run on OpenHands
232
+ # Cloud or Enterprise is handed the OpenHands API instead.
233
+ _CLOUD_API_URL = os.environ.get("OPENHANDS_CLOUD_API_URL", "").rstrip("/")
234
+ IS_CLOUD = bool(_CLOUD_API_URL) and not os.environ.get("AGENT_SERVER_URL")
235
+
236
+
227
237
  def _get_env_key() -> str:
228
238
  return os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0") or ""
229
239
 
230
240
 
231
241
  def get_secret(name: str) -> str:
232
- url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
233
242
  key = _get_env_key()
234
- req = urllib.request.Request(
235
- f"{url}/api/settings/secrets/{name}",
236
- headers={"X-Session-API-Key": key},
237
- )
243
+ if IS_CLOUD:
244
+ # The OpenHands API serves the secrets of this run's sandbox.
245
+ url = f"{_CLOUD_API_URL}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}"
246
+ else:
247
+ url = f"{os.environ.get('AGENT_SERVER_URL', '').rstrip('/')}/api/settings/secrets/{name}"
248
+ req = urllib.request.Request(url, headers={"X-Session-API-Key": key})
238
249
  with urllib.request.urlopen(req) as r:
239
250
  return r.read().decode().strip()
240
251
 
@@ -252,12 +263,14 @@ def fire_callback(
252
263
  body["error"] = error
253
264
  if conversation_id:
254
265
  body["conversation_id"] = conversation_id
266
+ # A cloud run is not given a callback key; it authenticates with its API key.
267
+ api_key = os.environ.get("AUTOMATION_CALLBACK_API_KEY") or os.environ.get("OPENHANDS_API_KEY", "")
255
268
  req = urllib.request.Request(
256
269
  url,
257
270
  data=json.dumps(body).encode(),
258
271
  headers={
259
272
  "Content-Type": "application/json",
260
- "Authorization": f"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}",
273
+ "Authorization": f"Bearer {api_key}",
261
274
  },
262
275
  )
263
276
  try:
@@ -749,12 +762,96 @@ def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
749
762
  return secrets
750
763
 
751
764
 
765
+ def _cloud_request(method: str, path: str, body: dict | None = None):
766
+ """Call the OpenHands API with the API key this cloud run was given."""
767
+ req = urllib.request.Request(
768
+ f"{_CLOUD_API_URL}{path}",
769
+ data=json.dumps(body).encode() if body is not None else None,
770
+ headers={
771
+ "Authorization": f"Bearer {os.environ.get('OPENHANDS_API_KEY', '')}",
772
+ "Content-Type": "application/json",
773
+ },
774
+ method=method,
775
+ )
776
+ try:
777
+ with urllib.request.urlopen(req) as r:
778
+ raw = r.read()
779
+ return json.loads(raw) if raw.strip() else {}
780
+ except urllib.error.HTTPError as exc:
781
+ body_text = exc.read().decode()
782
+ raise RuntimeError(f"OpenHands API {method} {path} → {exc.code}: {body_text}") from exc
783
+
784
+
785
+ def _create_cloud_conversation(initial_message: str, title: str) -> str:
786
+ """Start a conversation through the OpenHands API and return its id.
787
+
788
+ OpenHands runs it in a sandbox of its own, which outlives this run's, with
789
+ the user's own model, MCP servers and secrets: unlike a local run, the
790
+ conversation is not held to the empty secrets payload below.
791
+ """
792
+ conversation_id = uuid.uuid4().hex
793
+ _cloud_request(
794
+ "POST",
795
+ "/api/v1/app-conversations",
796
+ {
797
+ "conversation_id": conversation_id,
798
+ "title": title,
799
+ "initial_message": {
800
+ "role": "user",
801
+ "content": [{"type": "text", "text": initial_message}],
802
+ },
803
+ },
804
+ )
805
+ return conversation_id
806
+
807
+
808
+ def _cloud_conversation_status(conv_id: str) -> str:
809
+ found = _cloud_request("GET", f"/api/v1/app-conversations?ids={conv_id}")
810
+ conversation = found[0] if found else None
811
+ if conversation is None:
812
+ # OpenHands is still bringing the conversation up.
813
+ return "starting"
814
+ if conversation.get("execution_status"):
815
+ return conversation["execution_status"]
816
+ # The status is only reported while the conversation's sandbox is running.
817
+ # Once it is paused, the last status the agent reported is in its events.
818
+ events = _cloud_request(
819
+ "GET",
820
+ f"/api/v1/conversation/{conv_id}/events/search"
821
+ "?kind__eq=ConversationStateUpdateEvent&sort_order=TIMESTAMP_DESC&limit=100",
822
+ )
823
+ for event in events.get("items", []):
824
+ if event.get("key") == "execution_status":
825
+ return event.get("value") or "unknown"
826
+ return "unknown"
827
+
828
+
829
+ def _cloud_conversation_final_response(conv_id: str) -> str:
830
+ """The agent's closing message, read from the conversation's stored events."""
831
+ events = _cloud_request(
832
+ "GET",
833
+ f"/api/v1/conversation/{conv_id}/events/search?sort_order=TIMESTAMP_DESC&limit=100",
834
+ )
835
+ for event in events.get("items", []):
836
+ if event.get("source") != "agent":
837
+ continue
838
+ if event.get("kind") == "ActionEvent" and event.get("tool_name") == "finish":
839
+ return (event.get("action") or {}).get("message") or ""
840
+ if event.get("kind") == "MessageEvent":
841
+ content = (event.get("llm_message") or {}).get("content") or []
842
+ return "".join(part.get("text", "") for part in content)
843
+ return ""
844
+
845
+
752
846
  def create_conversation(
753
847
  agent_url: str,
754
848
  api_key: str,
755
849
  initial_message: str,
756
- workspace_dir: Path,
850
+ workspace_dir: Path | None,
851
+ title: str = "",
757
852
  ) -> str:
853
+ if IS_CLOUD:
854
+ return _create_cloud_conversation(initial_message, title)
758
855
  payload: dict = {
759
856
  "workspace": {"working_dir": str(workspace_dir)},
760
857
  "agent": _get_agent_dict(agent_url, api_key),
@@ -770,11 +867,15 @@ def create_conversation(
770
867
 
771
868
 
772
869
  def conversation_status(agent_url: str, api_key: str, conv_id: str) -> str:
870
+ if IS_CLOUD:
871
+ return _cloud_conversation_status(conv_id)
773
872
  result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}")
774
873
  return result.get("execution_status", "unknown")
775
874
 
776
875
 
777
876
  def conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> str:
877
+ if IS_CLOUD:
878
+ return _cloud_conversation_final_response(conv_id)
778
879
  result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}/agent_final_response")
779
880
  return result.get("response", "")
780
881
 
@@ -1048,9 +1149,14 @@ def _start_task(
1048
1149
 
1049
1150
  workspace_dir = None
1050
1151
  try:
1051
- workspace_dir = _prepare_workspace(period)
1152
+ # A cloud conversation runs in a sandbox of its own, so there is no
1153
+ # directory here for it to work in; its digest is its final message.
1154
+ if not IS_CLOUD:
1155
+ workspace_dir = _prepare_workspace(period)
1052
1156
  prompt = _build_digest_prompt(period, TOPICS, items, feed_errors)
1053
- conv_id = create_conversation(agent_url, api_key, prompt, workspace_dir)
1157
+ conv_id = create_conversation(
1158
+ agent_url, api_key, prompt, workspace_dir, title=f"News digest {period}"
1159
+ )
1054
1160
  except Exception as exc:
1055
1161
  # The claim is dropped so the next run retries today. The workspace goes
1056
1162
  # with it rather than being left behind.
@@ -1065,7 +1171,7 @@ def _start_task(
1065
1171
  {
1066
1172
  "status": "active",
1067
1173
  "conversation_id": conv_id,
1068
- "workspace_dir": str(workspace_dir),
1174
+ "workspace_dir": str(workspace_dir) if workspace_dir else None,
1069
1175
  "last_activity": time.time(),
1070
1176
  }
1071
1177
  )
@@ -1160,10 +1266,22 @@ def main() -> str | None:
1160
1266
  if not FEEDS:
1161
1267
  raise SystemExit("No feeds are configured; nothing to digest")
1162
1268
 
1163
- try:
1164
- openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
1165
- except Exception:
1166
- openhands_url = DEFAULT_OPENHANDS_URL
1269
+ if IS_CLOUD and not _kv_available():
1270
+ # A cloud run starts in a fresh sandbox, so a state file would not
1271
+ # survive it and every run would write today's digest again.
1272
+ raise RuntimeError(
1273
+ "The automation KV store is required on OpenHands Cloud and Enterprise, "
1274
+ "but AUTOMATION_KV_TOKEN is not set for this run."
1275
+ )
1276
+
1277
+ if IS_CLOUD:
1278
+ # Conversations are opened in the Canvas of the deployment itself.
1279
+ openhands_url = f"{_CLOUD_API_URL}/canvas"
1280
+ else:
1281
+ try:
1282
+ openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
1283
+ except Exception:
1284
+ openhands_url = DEFAULT_OPENHANDS_URL
1167
1285
 
1168
1286
  state = load_state()
1169
1287
  tasks: dict = state.setdefault("tasks", {})
@@ -22,7 +22,7 @@ The Python client prefers `OPENHANDS_CLOUD_API_KEY` and falls back to `OPENHANDS
22
22
 
23
23
  ## Delegating work with new Cloud conversations
24
24
 
25
- Use `POST /api/v1/app-conversations` to create a separate OpenHands Cloud conversation for a self-contained task, then poll `GET /api/v1/app-conversations/start-tasks?ids=...` until you have an `app_conversation_id`.
25
+ Use `POST /api/v1/app-conversations` to create a separate OpenHands Cloud conversation for a self-contained task, then poll `GET /api/v1/app-conversations/start-tasks?ids=...` until you have an `app_conversation_id`. Share the Agent Canvas link from `OpenHandsAPI.app_conversation_ui_url(app_conversation_id)` (the conversation's `conversation_ui_url`, falling back to `<base_url>/canvas/conversations/<id>`).
26
26
 
27
27
  Keep delegated prompts self-contained: include the repository, branch, relevant files, constraints, and expected output. Prefer five or fewer concurrently running delegated conversations.
28
28