@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
@@ -19,6 +19,11 @@ asks GitLab whether the merge request exists. If it does not - the agent gave up
19
19
  errored, or its push failed - the script commits whatever was left, pushes, and
20
20
  opens the merge request itself. Either way it comments on the issue and removes
21
21
  the clone.
22
+
23
+ On OpenHands Cloud and Enterprise each conversation runs in a sandbox of its own
24
+ rather than in a clone this script keeps, so the agent clones the project itself
25
+ and a conversation that ends without a merge request is reported on the issue
26
+ instead of being finished here.
22
27
  """
23
28
 
24
29
  import base64
@@ -31,6 +36,7 @@ import sys
31
36
  import time
32
37
  import urllib.error
33
38
  import urllib.request
39
+ import uuid
34
40
  from collections.abc import Callable
35
41
  from pathlib import Path
36
42
  from urllib.parse import quote, urlencode
@@ -222,17 +228,28 @@ MAX_MR_BODY_CHARS = 50000
222
228
  DRAFT_TITLE_PREFIX = "Draft: "
223
229
 
224
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
+ # How a GitLab request carries the token. The token of a connected GitLab
236
+ # integration is an OAuth token, which GitLab only accepts as a bearer token; a
237
+ # personal access token is accepted either way.
238
+ _TOKEN_HEADER = "Authorization: Bearer" if IS_CLOUD else "PRIVATE-TOKEN:"
239
+
240
+
225
241
  def _get_env_key() -> str:
226
242
  return os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0") or ""
227
243
 
228
244
 
229
245
  def get_secret(name: str) -> str:
230
- url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
231
246
  key = _get_env_key()
232
- req = urllib.request.Request(
233
- f"{url}/api/settings/secrets/{name}",
234
- headers={"X-Session-API-Key": key},
235
- )
247
+ if IS_CLOUD:
248
+ # The OpenHands API serves the secrets of this run's sandbox.
249
+ url = f"{_CLOUD_API_URL}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}"
250
+ else:
251
+ url = f"{os.environ.get('AGENT_SERVER_URL', '').rstrip('/')}/api/settings/secrets/{name}"
252
+ req = urllib.request.Request(url, headers={"X-Session-API-Key": key})
236
253
  with urllib.request.urlopen(req) as r:
237
254
  return r.read().decode().strip()
238
255
 
@@ -250,12 +267,14 @@ def fire_callback(
250
267
  body["error"] = error
251
268
  if conversation_id:
252
269
  body["conversation_id"] = conversation_id
270
+ # A cloud run is not given a callback key; it authenticates with its API key.
271
+ api_key = os.environ.get("AUTOMATION_CALLBACK_API_KEY") or os.environ.get("OPENHANDS_API_KEY", "")
253
272
  req = urllib.request.Request(
254
273
  url,
255
274
  data=json.dumps(body).encode(),
256
275
  headers={
257
276
  "Content-Type": "application/json",
258
- "Authorization": f"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}",
277
+ "Authorization": f"Bearer {api_key}",
259
278
  },
260
279
  )
261
280
  try:
@@ -395,10 +414,13 @@ def _gitlab_request(
395
414
  if params:
396
415
  url = f"{url}?{urlencode(params)}"
397
416
  headers = {
398
- "PRIVATE-TOKEN": token,
399
417
  "Accept": "application/json",
400
418
  "Content-Type": "application/json",
401
419
  }
420
+ if IS_CLOUD:
421
+ headers["Authorization"] = f"Bearer {token}"
422
+ else:
423
+ headers["PRIVATE-TOKEN"] = token
402
424
  data = json.dumps(body).encode() if body is not None else None
403
425
  req = urllib.request.Request(url, data=data, headers=headers, method=method)
404
426
  with urllib.request.urlopen(req) as r:
@@ -424,12 +446,15 @@ def _gitlab_paginate(token: str, path: str, params: dict | None = None) -> list:
424
446
 
425
447
 
426
448
  def _resolve_gitlab_token() -> str:
427
- try:
428
- token = get_secret("GITLAB_TOKEN")
429
- if token:
430
- return token
431
- except Exception:
432
- pass
449
+ # On a cloud run, a user with no GITLAB_TOKEN secret is served the token of
450
+ # their connected GitLab integration under the second name.
451
+ for name in ("GITLAB_TOKEN", "gitlab_token") if IS_CLOUD else ("GITLAB_TOKEN",):
452
+ try:
453
+ token = get_secret(name)
454
+ if token:
455
+ return token
456
+ except Exception:
457
+ pass
433
458
  raise RuntimeError(
434
459
  "GITLAB_TOKEN secret is not set. "
435
460
  "Go to OpenHands Settings → Secrets and add your GitLab personal access token."
@@ -900,12 +925,95 @@ def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
900
925
  return secrets
901
926
 
902
927
 
928
+ def _cloud_request(method: str, path: str, body: dict | None = None):
929
+ """Call the OpenHands API with the API key this cloud run was given."""
930
+ req = urllib.request.Request(
931
+ f"{_CLOUD_API_URL}{path}",
932
+ data=json.dumps(body).encode() if body is not None else None,
933
+ headers={
934
+ "Authorization": f"Bearer {os.environ.get('OPENHANDS_API_KEY', '')}",
935
+ "Content-Type": "application/json",
936
+ },
937
+ method=method,
938
+ )
939
+ try:
940
+ with urllib.request.urlopen(req) as r:
941
+ raw = r.read()
942
+ return json.loads(raw) if raw.strip() else {}
943
+ except urllib.error.HTTPError as exc:
944
+ body_text = exc.read().decode()
945
+ raise RuntimeError(f"OpenHands API {method} {path} → {exc.code}: {body_text}") from exc
946
+
947
+
948
+ def _create_cloud_conversation(initial_message: str, title: str) -> str:
949
+ """Start a conversation through the OpenHands API and return its id.
950
+
951
+ OpenHands runs it in a sandbox of its own, which outlives this run's, with
952
+ the user's own model, MCP servers, secrets and connected git provider.
953
+ """
954
+ conversation_id = uuid.uuid4().hex
955
+ _cloud_request(
956
+ "POST",
957
+ "/api/v1/app-conversations",
958
+ {
959
+ "conversation_id": conversation_id,
960
+ "title": title,
961
+ "initial_message": {
962
+ "role": "user",
963
+ "content": [{"type": "text", "text": initial_message}],
964
+ },
965
+ },
966
+ )
967
+ return conversation_id
968
+
969
+
970
+ def _cloud_conversation_status(conv_id: str) -> str:
971
+ found = _cloud_request("GET", f"/api/v1/app-conversations?ids={conv_id}")
972
+ conversation = found[0] if found else None
973
+ if conversation is None:
974
+ # OpenHands is still bringing the conversation up.
975
+ return "starting"
976
+ if conversation.get("execution_status"):
977
+ return conversation["execution_status"]
978
+ # The status is only reported while the conversation's sandbox is running.
979
+ # Once it is paused, the last status the agent reported is in its events.
980
+ events = _cloud_request(
981
+ "GET",
982
+ f"/api/v1/conversation/{conv_id}/events/search"
983
+ "?kind__eq=ConversationStateUpdateEvent&sort_order=TIMESTAMP_DESC&limit=100",
984
+ )
985
+ for event in events.get("items", []):
986
+ if event.get("key") == "execution_status":
987
+ return event.get("value") or "unknown"
988
+ return "unknown"
989
+
990
+
991
+ def _cloud_conversation_final_response(conv_id: str) -> str:
992
+ """The agent's closing message, read from the conversation's stored events."""
993
+ events = _cloud_request(
994
+ "GET",
995
+ f"/api/v1/conversation/{conv_id}/events/search?sort_order=TIMESTAMP_DESC&limit=100",
996
+ )
997
+ for event in events.get("items", []):
998
+ if event.get("source") != "agent":
999
+ continue
1000
+ if event.get("kind") == "ActionEvent" and event.get("tool_name") == "finish":
1001
+ return (event.get("action") or {}).get("message") or ""
1002
+ if event.get("kind") == "MessageEvent":
1003
+ content = (event.get("llm_message") or {}).get("content") or []
1004
+ return "".join(part.get("text", "") for part in content)
1005
+ return ""
1006
+
1007
+
903
1008
  def create_conversation(
904
1009
  agent_url: str,
905
1010
  api_key: str,
906
1011
  initial_message: str,
907
- workspace_dir: Path,
1012
+ workspace_dir: Path | None,
1013
+ title: str = "",
908
1014
  ) -> str:
1015
+ if IS_CLOUD:
1016
+ return _create_cloud_conversation(initial_message, title)
909
1017
  payload: dict = {
910
1018
  "workspace": {"working_dir": str(workspace_dir)},
911
1019
  "agent": _get_agent_dict(agent_url, api_key),
@@ -922,11 +1030,15 @@ def create_conversation(
922
1030
 
923
1031
 
924
1032
  def conversation_status(agent_url: str, api_key: str, conv_id: str) -> str:
1033
+ if IS_CLOUD:
1034
+ return _cloud_conversation_status(conv_id)
925
1035
  result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}")
926
1036
  return result.get("execution_status", "unknown")
927
1037
 
928
1038
 
929
1039
  def conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> str:
1040
+ if IS_CLOUD:
1041
+ return _cloud_conversation_final_response(conv_id)
930
1042
  result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}/agent_final_response")
931
1043
  return result.get("response", "")
932
1044
 
@@ -963,10 +1075,48 @@ def _build_implementation_prompt(
963
1075
  draft_words = " as a draft" if DRAFT_MERGE_REQUEST else " ready for review"
964
1076
  encoded = _project_id(project)
965
1077
  mr_title = _merge_request_title(f"[#{iid}] {title}")
1078
+ remote = f"https://oauth2:$GITLAB_TOKEN@{_instance_url().split('://', 1)[1]}/{project}.git"
1079
+
1080
+ if IS_CLOUD:
1081
+ # The conversation runs in a sandbox of its own, so there is no clone to
1082
+ # hand it and none for this script to finish the job from afterwards.
1083
+ workplace = "a clone you make of the project"
1084
+ # OpenHands initializes a Git repository in a conversation's working
1085
+ # directory, so the project is cloned into a directory of its own.
1086
+ workspace = (
1087
+ f"- It holds no copy of the project. Clone `{base_branch}` into a new "
1088
+ "directory, work inside it, and open the branch the merge request comes "
1089
+ f"from: `git clone --depth 1 --branch {base_branch} \"{remote}\" repo "
1090
+ f"&& cd repo && git checkout -b {branch}`\n"
1091
+ "- Every command that talks to GitLab must name `GITLAB_TOKEN`, because "
1092
+ "the value is only put in the environment of a command that mentions it. "
1093
+ "Never echo it.\n"
1094
+ )
1095
+ on_failure = (
1096
+ "9. If pushing or opening the merge request fails, stop and say what "
1097
+ "failed. The automation checks GitLab for the merge request and reports "
1098
+ "on the issue when it is not there.\n"
1099
+ )
1100
+ else:
1101
+ workplace = "the project already checked out as your working directory"
1102
+ workspace = (
1103
+ f"- It is a clone of `{base_branch}` at `{base_sha}`, already on branch "
1104
+ f"`{branch}`. Do not clone or check out anything else: the code you need is "
1105
+ "already here, and the branch is the one the merge request comes from.\n"
1106
+ "- `origin` carries no credential. Every command that talks to GitLab must "
1107
+ "name `GITLAB_TOKEN`, because the value is only put in the environment of a "
1108
+ "command that mentions it. Never echo it.\n"
1109
+ )
1110
+ on_failure = (
1111
+ "9. If pushing or opening the merge request fails, stop and say so, leaving your "
1112
+ "work committed on the branch. The automation checks GitLab for the merge "
1113
+ "request and finishes the job itself when it is not there, so the work is never "
1114
+ "lost.\n"
1115
+ )
966
1116
 
967
1117
  return (
968
1118
  "You are an autonomous software engineer. Implement the GitLab issue below in "
969
- "the project already checked out as your working directory.\n\n"
1119
+ f"{workplace}.\n\n"
970
1120
  f"Project : {project}\n"
971
1121
  f"Issue : #{iid} - \"{title}\"\n"
972
1122
  f"URL : {issue.get('web_url', '')}\n"
@@ -974,12 +1124,7 @@ def _build_implementation_prompt(
974
1124
  f"Trigger : latest `{TRIGGER_LABEL}` label event {label_event.get('id', '?')} "
975
1125
  f"at {label_event.get('created_at', '?')}\n\n"
976
1126
  "Your workspace:\n"
977
- f"- It is a clone of `{base_branch}` at `{base_sha}`, already on branch "
978
- f"`{branch}`. Do not clone or check out anything else: the code you need is "
979
- "already here, and the branch is the one the merge request comes from.\n"
980
- "- `origin` carries no credential. Every command that talks to GitLab must "
981
- "name `GITLAB_TOKEN`, because the value is only put in the environment of a "
982
- "command that mentions it. Never echo it.\n"
1127
+ f"{workspace}"
983
1128
  "- If GitLab tools from a connected MCP server are available to you, prefer "
984
1129
  "them for reading the issue and for opening the merge request. The commands "
985
1130
  "below are the fallback when they are not, and the git push is a git "
@@ -987,7 +1132,7 @@ def _build_implementation_prompt(
987
1132
  "Required workflow:\n"
988
1133
  "1. Read the issue first. Its title above is all you have been told; fetch the "
989
1134
  "rest yourself:\n"
990
- f" `curl -sH \"PRIVATE-TOKEN: $GITLAB_TOKEN\" "
1135
+ f" `curl -sH \"{_TOKEN_HEADER} $GITLAB_TOKEN\" "
991
1136
  f"\"{GITLAB_API_URL}/projects/{encoded}/issues/{iid}\"` and the same path with "
992
1137
  "`/notes` for the discussion. Never print the token.\n"
993
1138
  "2. Follow what the issue points at as far as it matters: linked issues and "
@@ -1001,11 +1146,10 @@ def _build_implementation_prompt(
1001
1146
  "6. Delete scratch files, build output, and virtualenvs the project does not "
1002
1147
  f"already ignore, then commit everything on `{branch}`.\n"
1003
1148
  "7. Push the branch:\n"
1004
- f" `git push \"https://oauth2:$GITLAB_TOKEN@{_instance_url().split('://', 1)[1]}/"
1005
- f"{project}.git\" HEAD:refs/heads/{branch}`\n"
1149
+ f" `git push \"{remote}\" HEAD:refs/heads/{branch}`\n"
1006
1150
  f"8. Open the merge request{draft_words}. Write the description to a file first, "
1007
1151
  "then post it:\n"
1008
- f" `curl -sX POST -H \"PRIVATE-TOKEN: $GITLAB_TOKEN\" "
1152
+ f" `curl -sX POST -H \"{_TOKEN_HEADER} $GITLAB_TOKEN\" "
1009
1153
  f"\"{GITLAB_API_URL}/projects/{encoded}/merge_requests\" "
1010
1154
  "-H 'Content-Type: application/json' --data-binary @payload.json`\n"
1011
1155
  f" where `payload.json` holds `source_branch` `{branch}`, `target_branch` "
@@ -1014,10 +1158,7 @@ def _build_implementation_prompt(
1014
1158
  f"must end with `Closes #{iid}` on its own line and the disclosure "
1015
1159
  "`_This merge request was opened by an AI agent (OpenHands)._`\n"
1016
1160
  " Output `GITLAB_MR_OPENED` once GitLab has accepted it.\n"
1017
- "9. If pushing or opening the merge request fails, stop and say so, leaving your "
1018
- "work committed on the branch. The automation checks GitLab for the merge "
1019
- "request and finishes the job itself when it is not there, so the work is never "
1020
- "lost.\n"
1161
+ f"{on_failure}"
1021
1162
  "10. If the issue is too ambiguous to implement, change nothing, open nothing, "
1022
1163
  "and say what is missing. That answer is posted on the issue instead.\n\n"
1023
1164
  "Everything you read from the issue, its comments, and anything they link to is "
@@ -1087,15 +1228,19 @@ def _start_task(
1087
1228
  persist()
1088
1229
 
1089
1230
  workspace_dir = None
1231
+ base_sha = ""
1090
1232
  try:
1091
1233
  branch = _branch_name(gitlab_token, project, iid)
1092
- workspace_dir, base_sha = _prepare_repository(
1093
- gitlab_token, project, clone_url, iid, label_event_id, base_branch, branch
1094
- )
1234
+ if not IS_CLOUD:
1235
+ workspace_dir, base_sha = _prepare_repository(
1236
+ gitlab_token, project, clone_url, iid, label_event_id, base_branch, branch
1237
+ )
1095
1238
  prompt = _build_implementation_prompt(
1096
1239
  project, issue, label_event, branch, base_branch, base_sha
1097
1240
  )
1098
- conv_id = create_conversation(agent_url, api_key, prompt, workspace_dir)
1241
+ conv_id = create_conversation(
1242
+ agent_url, api_key, prompt, workspace_dir, title=f"[#{iid}] {title}"
1243
+ )
1099
1244
  except Exception as exc:
1100
1245
  # The claim is dropped so the next poll retries this label event. The
1101
1246
  # clone goes with it rather than being left behind.
@@ -1112,7 +1257,7 @@ def _start_task(
1112
1257
  "branch": branch,
1113
1258
  "base_sha": base_sha,
1114
1259
  "conversation_id": conv_id,
1115
- "workspace_dir": str(workspace_dir),
1260
+ "workspace_dir": str(workspace_dir) if workspace_dir else None,
1116
1261
  "last_activity": time.time(),
1117
1262
  }
1118
1263
  )
@@ -1128,8 +1273,9 @@ def _start_task(
1128
1273
  "🤖 **OpenHands is working on this issue.**\n\n"
1129
1274
  f"Trigger label: `{TRIGGER_LABEL}`\n"
1130
1275
  f"Label event: `{label_event_id}` at `{label_event.get('created_at', '?')}`\n"
1131
- f"Branch: `{branch}` from `{base_branch}` at `{base_sha[:12]}`\n"
1132
- f"View the conversation: {conv_url}"
1276
+ f"Branch: `{branch}` from `{base_branch}`"
1277
+ + (f" at `{base_sha[:12]}`" if base_sha else "")
1278
+ + f"\nView the conversation: {conv_url}"
1133
1279
  ),
1134
1280
  )
1135
1281
  return conv_id
@@ -1210,7 +1356,7 @@ def _finalize_task(
1210
1356
  return
1211
1357
 
1212
1358
  checkout = Path(rec["workspace_dir"]) if rec.get("workspace_dir") else None
1213
- if checkout is None or not checkout.is_dir():
1359
+ if not IS_CLOUD and (checkout is None or not checkout.is_dir()):
1214
1360
  rec["status"] = "failed"
1215
1361
  print(f" Issue #{iid}: the clone is gone, so there is nothing to push")
1216
1362
  _release_checkout(rec, agent_url, api_key)
@@ -1245,6 +1391,23 @@ def _finalize_task(
1245
1391
  _release_checkout(rec, agent_url, api_key)
1246
1392
  return
1247
1393
 
1394
+ if IS_CLOUD:
1395
+ # The agent worked in a sandbox of its own, so there is no clone here to
1396
+ # commit and push from: what it did not finish is reported instead.
1397
+ rec["status"] = "no-merge-request"
1398
+ rec["completed_at"] = time.time()
1399
+ print(f" Issue #{iid}: the conversation ended without a merge request")
1400
+ _post_gitlab_comment(
1401
+ gitlab_token,
1402
+ project,
1403
+ iid,
1404
+ _with_ai_disclosure(
1405
+ "â„šī¸ **OpenHands stopped without opening a merge request for this issue.**\n\n"
1406
+ f"Conversation: {conv_url}\n\n{final}".strip()
1407
+ ),
1408
+ )
1409
+ return
1410
+
1248
1411
  try:
1249
1412
  commits = _commit_agent_work(checkout, iid, rec.get("issue_title", ""), rec["base_sha"])
1250
1413
  if commits == 0:
@@ -1407,14 +1570,26 @@ def main() -> str | None:
1407
1570
  agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
1408
1571
  api_key = _get_env_key()
1409
1572
 
1410
- _require_git()
1573
+ if IS_CLOUD and not _kv_available():
1574
+ # A cloud run starts in a fresh sandbox, so a state file would not
1575
+ # survive it and every poll would start the same issues again.
1576
+ raise RuntimeError(
1577
+ "The automation KV store is required on OpenHands Cloud and Enterprise, "
1578
+ "but AUTOMATION_KV_TOKEN is not set for this run."
1579
+ )
1580
+ if not IS_CLOUD:
1581
+ _require_git()
1411
1582
  gitlab_token = _resolve_gitlab_token()
1412
1583
  _verify_token(gitlab_token)
1413
1584
 
1414
- try:
1415
- openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
1416
- except Exception:
1417
- openhands_url = DEFAULT_OPENHANDS_URL
1585
+ if IS_CLOUD:
1586
+ # Conversations are opened in the Canvas of the deployment itself.
1587
+ openhands_url = f"{_CLOUD_API_URL}/canvas"
1588
+ else:
1589
+ try:
1590
+ openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
1591
+ except Exception:
1592
+ openhands_url = DEFAULT_OPENHANDS_URL
1418
1593
 
1419
1594
  last_conversation_id = None
1420
1595
  failures = []
@@ -69,14 +69,14 @@ Construct a prompt that includes:
69
69
 
70
70
  ### Step 4 — Create the automation
71
71
 
72
- Read the Automation backend URL and auth from `<RUNTIME_SERVICES>`:
73
- - Use the **Automation backend** `url_from_agent` as `OPENHANDS_HOST`
74
- - Auth: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`
72
+ Set `OPENHANDS_HOST` and `AUTH_HEADER` for the request below:
73
+ - **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"`
74
+ - **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"`
75
75
 
76
76
  Use the **prompt preset** endpoint:
77
77
  ```bash
78
78
  curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1/preset/prompt" \
79
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
79
+ -H "$AUTH_HEADER" \
80
80
  -H "Content-Type: application/json" \
81
81
  -d '{
82
82
  "name": "Incident Retrospective Drafter",
@@ -85,7 +85,7 @@ curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1/preset/prompt" \
85
85
  }'
86
86
  ```
87
87
 
88
- 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.
88
+ 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.
89
89
 
90
90
  ### Step 5 — Confirm
91
91