@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
@@ -2,22 +2,37 @@
2
2
 
3
3
  import json
4
4
  import os
5
+ import time
5
6
  from urllib.error import HTTPError
6
7
  from urllib.request import Request, urlopen
7
- from uuid import NAMESPACE_URL, UUID, uuid5
8
-
9
- import httpx
10
- from openhands.sdk import RemoteConversation
11
- from openhands.sdk.conversation.request import (
12
- SendMessageRequest,
13
- StartConversationRequest,
14
- )
15
- from openhands.sdk.conversation.state import ConversationExecutionStatus
16
- from openhands.sdk.llm.message import TextContent
17
- from openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace
8
+ from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
9
+
10
+ try:
11
+ import httpx
12
+ from openhands.sdk import RemoteConversation
13
+ from openhands.sdk.conversation.request import (
14
+ SendMessageRequest,
15
+ StartConversationRequest,
16
+ )
17
+ from openhands.sdk.conversation.state import ConversationExecutionStatus
18
+ from openhands.sdk.llm.message import TextContent
19
+ from openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace
20
+ except ImportError:
21
+ # A run on OpenHands Cloud or Enterprise has no SDK on its Python path and
22
+ # does not need one: CloudConversations speaks the OpenHands API instead.
23
+ httpx = RemoteConversation = SendMessageRequest = None
24
+ StartConversationRequest = ConversationExecutionStatus = None
25
+ TextContent = LocalWorkspace = RemoteWorkspace = None
18
26
 
19
27
  _CONVERSATION_KEY_PREFIX = "agent-conversation-"
20
28
 
29
+ # How long a conversation the OpenHands API was asked to start may take to
30
+ # appear before the start is taken to have failed and is made again.
31
+ _CLOUD_START_GRACE_SECONDS = 10 * 60
32
+ # How long to wait for a paused conversation's sandbox to come back up.
33
+ _CLOUD_RESUME_TIMEOUT_SECONDS = 120
34
+ _CLOUD_POLL_SECONDS = 3
35
+
21
36
 
22
37
  def _register_tools() -> None:
23
38
  """Register tool models needed to deserialize an attached agent."""
@@ -50,19 +65,106 @@ def _kv_request(key: str, method: str, value: dict | None = None) -> dict | None
50
65
  return body.get("value") if method == "GET" else body
51
66
 
52
67
 
68
+ class CloudConversations:
69
+ """The conversations of a run on OpenHands Cloud or Enterprise.
70
+
71
+ Such a run has no Agent Server to attach to. The OpenHands API starts each
72
+ conversation in a sandbox of its own, which outlives the run's, with the
73
+ agent profile's model, tools and secrets resolved on the server.
74
+ """
75
+
76
+ def __init__(self) -> None:
77
+ self.api = os.environ["OPENHANDS_CLOUD_API_URL"].rstrip("/")
78
+ self.headers = {
79
+ "Authorization": f"Bearer {os.environ['OPENHANDS_API_KEY']}",
80
+ "Content-Type": "application/json",
81
+ }
82
+
83
+ def _request(self, method: str, url: str, body=None, headers=None):
84
+ request = Request(
85
+ url if url.startswith("http") else f"{self.api}{url}",
86
+ data=json.dumps(body).encode() if body is not None else None,
87
+ headers=headers or self.headers,
88
+ method=method,
89
+ )
90
+ with urlopen(request, timeout=90) as response:
91
+ raw = response.read()
92
+ return json.loads(raw) if raw.strip() else {}
93
+
94
+ def get(self, conversation_id) -> dict | None:
95
+ """The conversation, or None while the API has no record of it."""
96
+ found = self._request("GET", f"/api/v1/app-conversations?ids={conversation_id}")
97
+ return found[0] if found else None
98
+
99
+ def start(self, conversation_id, profile_id, title: str, prompt: str) -> None:
100
+ self._request(
101
+ "POST",
102
+ "/api/v1/app-conversations",
103
+ {
104
+ "conversation_id": str(conversation_id),
105
+ "agent_profile_id": str(profile_id),
106
+ "title": title,
107
+ "initial_message": _user_message(prompt),
108
+ },
109
+ )
110
+
111
+ def run(self, conversation: dict) -> None:
112
+ """Start the next turn of an idle conversation on its own Agent Server."""
113
+ try:
114
+ self._request(
115
+ "POST",
116
+ f"{conversation['conversation_url']}/run",
117
+ headers={"X-Session-API-Key": conversation["session_api_key"]},
118
+ )
119
+ except HTTPError as exc:
120
+ if exc.code != 409: # 409: a turn is already running
121
+ raise
122
+
123
+ def send(self, conversation: dict, prompt: str) -> None:
124
+ """Send the next turn, waking the conversation's sandbox if it is paused."""
125
+ conversation_id = conversation["id"]
126
+ if conversation.get("sandbox_status") != "RUNNING":
127
+ self._request(
128
+ "POST", f"/api/v1/sandboxes/{conversation['sandbox_id']}/resume"
129
+ )
130
+ deadline = time.monotonic() + _CLOUD_RESUME_TIMEOUT_SECONDS
131
+ # The execution status is only reported once the sandbox's Agent
132
+ # Server answers, which is when it can take the message.
133
+ while not (self.get(conversation_id) or {}).get("execution_status"):
134
+ if time.monotonic() > deadline:
135
+ raise RuntimeError(
136
+ f"Conversation {conversation_id} did not resume in time"
137
+ )
138
+ time.sleep(_CLOUD_POLL_SECONDS)
139
+ self._request(
140
+ "POST",
141
+ f"/api/v1/app-conversations/{conversation_id}/send-message",
142
+ {**_user_message(prompt), "run": True},
143
+ )
144
+
145
+
146
+ def _user_message(text: str) -> dict:
147
+ return {"role": "user", "content": [{"type": "text", "text": text}]}
148
+
149
+
53
150
  class AgentConversationDispatcher:
54
151
  """Deliver one revision at a time to a stable conversation for each subject."""
55
152
 
56
153
  def __init__(self) -> None:
57
- self.agent_url = os.environ["AGENT_SERVER_URL"]
154
+ # The automation service hands a run the Agent Server URL only on a
155
+ # local Agent Canvas; elsewhere conversations go through the OpenHands API.
156
+ self.agent_url = os.environ.get("AGENT_SERVER_URL")
58
157
  self.api_key = os.environ["SESSION_API_KEY"]
59
158
  self.profile_id = UUID(os.environ["AUTOMATION_AGENT_PROFILE_ID"])
60
159
  payload = json.loads(os.environ["AUTOMATION_EVENT_PAYLOAD"])
61
160
  self.automation_id = str(payload["automation_id"])
161
+ self._cloud = None if self.agent_url else CloudConversations()
62
162
  self._workspace: RemoteWorkspace | None = None
63
163
  self._secrets = {}
64
164
 
65
165
  def __enter__(self):
166
+ if self._cloud:
167
+ return self
66
168
  _register_tools()
67
169
  self._workspace = RemoteWorkspace(
68
170
  host=self.agent_url,
@@ -76,17 +178,107 @@ class AgentConversationDispatcher:
76
178
  return self
77
179
 
78
180
  def __exit__(self, *args):
181
+ if self._cloud:
182
+ return None
79
183
  assert self._workspace is not None
80
184
  return self._workspace.__exit__(*args)
81
185
 
82
- def deliver(self, subject: str, delivery: str, prompt: str) -> dict[str, str]:
186
+ def deliver(
187
+ self, subject: str, delivery: str, prompt: str, head: str = ""
188
+ ) -> dict[str, str]:
189
+ """Deliver one revision of `subject`, deduping a repeated head.
190
+
191
+ `delivery` keys the revision: a second trigger for the same revision
192
+ reuses the conversation without a new turn, and a changed head becomes a
193
+ new delivery. `head` is the revision's commit, and it is the guard that
194
+ matters when two triggers name the same commit - a re-request after the
195
+ bot's own handoff, say - because a delivery key that only counts triggers
196
+ would send a second turn and publish a second review of identical code.
197
+ A conversation already running for this head is reported `in_progress`
198
+ and left alone, whatever the new delivery says. Callers with no revision
199
+ identity leave `head` empty and keep the delivery-key-only behavior.
200
+ """
83
201
  conversation_id = uuid5(NAMESPACE_URL, f"{self.automation_id}:{subject}")
84
202
  state_key = f"{_CONVERSATION_KEY_PREFIX}{conversation_id}"
85
203
  record = _kv_request(state_key, "GET") or {}
86
- if self._workspace is None:
87
- raise RuntimeError("AgentConversationDispatcher must be used as a context")
88
204
 
89
205
  same_delivery = record.get("delivery") == delivery
206
+ same_head = bool(head) and record.get("head") == head
207
+
208
+ if self._cloud:
209
+ disposition, conversation_id = self._deliver_cloud(
210
+ record, conversation_id, subject, prompt, same_delivery, same_head
211
+ )
212
+ else:
213
+ disposition = self._deliver_local(
214
+ conversation_id, prompt, same_delivery, same_head
215
+ )
216
+
217
+ if disposition in ("deduplicated", "in_progress"):
218
+ return {
219
+ "disposition": disposition,
220
+ "conversation_id": str(conversation_id),
221
+ }
222
+
223
+ new_record = {
224
+ "subject": subject,
225
+ "conversation_id": str(conversation_id),
226
+ "delivery": delivery,
227
+ "head": head or record.get("head") or "",
228
+ }
229
+ if disposition == "created" and self._cloud:
230
+ new_record["started_at"] = time.time()
231
+ _kv_request(state_key, "PUT", new_record)
232
+ return {
233
+ "disposition": disposition,
234
+ "conversation_id": str(conversation_id),
235
+ }
236
+
237
+ def _deliver_cloud(
238
+ self, record, conversation_id, subject, prompt, same_delivery, same_head
239
+ ):
240
+ """Deliver through the OpenHands API; return the disposition and the id.
241
+
242
+ The id is the subject's stable one until its conversation can no longer
243
+ take a turn - its start never completed, or its sandbox is gone - and a
244
+ fresh id from then on, because the API does not start an id twice. The
245
+ KV record carries whichever is current.
246
+ """
247
+ current_id = record.get("conversation_id") or conversation_id
248
+ conversation = self._cloud.get(current_id)
249
+
250
+ if conversation is None:
251
+ started = time.time() - float(record.get("started_at") or 0)
252
+ if started < _CLOUD_START_GRACE_SECONDS:
253
+ # The API is still bringing the subject's conversation up and
254
+ # does not list it yet. Starting another would run two for one
255
+ # subject, so even a new revision waits: the record is left as
256
+ # it is and the next trigger delivers it.
257
+ return "in_progress", current_id
258
+ elif conversation.get("sandbox_status") in ("ERROR", "MISSING"):
259
+ if same_delivery:
260
+ return "deduplicated", current_id
261
+ conversation = None
262
+
263
+ if conversation is None:
264
+ new_id = uuid4() if record else conversation_id
265
+ self._cloud.start(new_id, self.profile_id, subject, prompt)
266
+ return "created", new_id
267
+
268
+ status = conversation.get("execution_status")
269
+ if status == "running" and (same_delivery or same_head):
270
+ return "in_progress", current_id
271
+ if same_delivery:
272
+ if status in ("idle", "paused"):
273
+ self._cloud.run(conversation)
274
+ return "resumed", current_id
275
+ return "deduplicated", current_id
276
+ self._cloud.send(conversation, prompt)
277
+ return "resumed", current_id
278
+
279
+ def _deliver_local(self, conversation_id, prompt, same_delivery, same_head) -> str:
280
+ if self._workspace is None:
281
+ raise RuntimeError("AgentConversationDispatcher must be used as a context")
90
282
 
91
283
  try:
92
284
  conversation = RemoteConversation.attach(
@@ -112,14 +304,18 @@ class AgentConversationDispatcher:
112
304
  disposition = "created"
113
305
  try:
114
306
  if disposition == "resumed":
115
- if same_delivery:
116
- if (
117
- conversation.state.execution_status
118
- == ConversationExecutionStatus.RUNNING
119
- ):
120
- conversation.update_secrets(self._secrets)
121
- disposition = "in_progress"
122
- elif conversation.state.execution_status in (
307
+ running = (
308
+ conversation.state.execution_status
309
+ == ConversationExecutionStatus.RUNNING
310
+ )
311
+ if running and (same_delivery or same_head):
312
+ # A turn is already running for this very revision, so a new
313
+ # trigger is not new work: report the live conversation
314
+ # rather than starting a second pass beside it.
315
+ conversation.update_secrets(self._secrets)
316
+ disposition = "in_progress"
317
+ elif same_delivery:
318
+ if conversation.state.execution_status in (
123
319
  ConversationExecutionStatus.IDLE,
124
320
  ConversationExecutionStatus.PAUSED,
125
321
  ):
@@ -128,28 +324,12 @@ class AgentConversationDispatcher:
128
324
  else:
129
325
  disposition = "deduplicated"
130
326
  else:
327
+ # A changed head, or a head a human clarified since, is new
328
+ # work on the same subject: reuse the conversation and send
329
+ # the revision as its next turn.
131
330
  conversation.update_secrets(self._secrets)
132
331
  conversation.send_message(prompt)
133
332
  conversation.run(blocking=False)
134
333
  finally:
135
334
  conversation.close()
136
-
137
- if disposition in ("deduplicated", "in_progress"):
138
- return {
139
- "disposition": disposition,
140
- "conversation_id": str(conversation_id),
141
- }
142
-
143
- _kv_request(
144
- state_key,
145
- "PUT",
146
- {
147
- "subject": subject,
148
- "conversation_id": str(conversation_id),
149
- "delivery": delivery,
150
- },
151
- )
152
- return {
153
- "disposition": disposition,
154
- "conversation_id": str(conversation_id),
155
- }
335
+ return disposition
@@ -12,24 +12,62 @@ from urllib.parse import parse_qsl, urlencode, urlsplit
12
12
  from urllib.request import Request, urlopen
13
13
 
14
14
 
15
+ # The secret name of the GitHub token an OpenHands Cloud or Enterprise user's
16
+ # connected GitHub integration provides: how this run's sandbox serves it, and
17
+ # how a conversation sees it in its environment.
18
+ CLOUD_GITHUB_TOKEN_SECRET = "github_token"
19
+ CLOUD_GITHUB_TOKEN_ENV = "GITHUB_TOKEN"
20
+
21
+
22
+ def is_cloud_run() -> bool:
23
+ """Whether this run is on OpenHands Cloud or Enterprise.
24
+
25
+ The automation service hands a run the Agent Server URL only on a local
26
+ Agent Canvas; elsewhere the run talks to the OpenHands API instead.
27
+ """
28
+ return bool(os.environ.get("OPENHANDS_CLOUD_API_URL")) and not os.environ.get(
29
+ "AGENT_SERVER_URL"
30
+ )
31
+
32
+
33
+ def _load_cloud_secret(name: str) -> str | None:
34
+ """Read one named secret of this run's sandbox from the OpenHands API."""
35
+ api = os.environ["OPENHANDS_CLOUD_API_URL"].rstrip("/")
36
+ request = Request(
37
+ f"{api}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}",
38
+ headers={"X-Session-API-Key": os.environ["SESSION_API_KEY"]},
39
+ )
40
+ try:
41
+ with urlopen(request, timeout=90) as response:
42
+ return response.read().decode().strip()
43
+ except HTTPError as exc:
44
+ if exc.code == 404:
45
+ return None
46
+ raise
47
+
48
+
15
49
  def _load_secret(name: str) -> str:
16
- """Read one named secret from the environment or configured Agent Server."""
50
+ """Read one named secret from the environment, or from the configured Agent
51
+ Server on a local run and the OpenHands API on a cloud one."""
17
52
  value = os.environ.get(name)
18
53
  if value:
19
54
  return value
20
55
 
21
- from openhands.sdk.workspace import RemoteWorkspace
56
+ if is_cloud_run():
57
+ value = _load_cloud_secret(name)
58
+ else:
59
+ from openhands.sdk.workspace import RemoteWorkspace
22
60
 
23
- workspace = RemoteWorkspace(
24
- host=os.environ["AGENT_SERVER_URL"],
25
- api_key=os.environ["SESSION_API_KEY"],
26
- working_dir=os.environ.get("WORKSPACE_BASE", "/workspace"),
27
- )
28
- try:
29
- secret = workspace.get_secrets([name]).get(name)
30
- value = secret.get_value() if secret else None
31
- finally:
32
- workspace.reset_client()
61
+ workspace = RemoteWorkspace(
62
+ host=os.environ["AGENT_SERVER_URL"],
63
+ api_key=os.environ["SESSION_API_KEY"],
64
+ working_dir=os.environ.get("WORKSPACE_BASE", "/workspace"),
65
+ )
66
+ try:
67
+ secret = workspace.get_secrets([name]).get(name)
68
+ value = secret.get_value() if secret else None
69
+ finally:
70
+ workspace.reset_client()
33
71
  if not value:
34
72
  raise ValueError(f"The GitHub credential {name} is unavailable")
35
73
  return value
@@ -95,11 +133,22 @@ class GitHubRepository:
95
133
  "Expected the environment variable containing the GitHub token"
96
134
  )
97
135
  self.token_name = github_token_secret
98
- self.token = _load_secret(github_token_secret)
136
+ try:
137
+ self.token = _load_secret(github_token_secret)
138
+ except ValueError:
139
+ if not is_cloud_run():
140
+ raise
141
+ # No secret is saved under that name, so use the user's connected
142
+ # GitHub integration, under the name a conversation sees it by.
143
+ self.token = _load_cloud_secret(CLOUD_GITHUB_TOKEN_SECRET)
144
+ if not self.token:
145
+ raise
146
+ self.token_name = CLOUD_GITHUB_TOKEN_ENV
99
147
  self.conversation = conversation
100
148
  self.conversation_id = str(conversation.id) if conversation else None
101
149
  self.dispatcher = dispatcher
102
- self.workspace = Path(os.environ["WORKSPACE_BASE"])
150
+ # A cloud run is not given a workspace base; its sandbox has /workspace.
151
+ self.workspace = Path(os.environ.get("WORKSPACE_BASE", "/workspace"))
103
152
  self.project = self.workspace
104
153
  self.evidence = self.workspace / "evidence"
105
154
  self.evidence.mkdir(exist_ok=True)
@@ -111,10 +160,13 @@ class GitHubRepository:
111
160
 
112
161
  @property
113
162
  def github_instructions(self):
163
+ # A cloud conversation runs in a sandbox of its own, so this run's
164
+ # workspace path means nothing to it.
165
+ where = "your working directory" if is_cloud_run() else self.project
114
166
  return (
115
167
  f"Use `GH_TOKEN=${self.token_name} gh api` for GitHub requests. "
116
168
  "Never print the credential value. "
117
- f"Only {self.repository} is in scope. Work in {self.project}. "
169
+ f"Only {self.repository} is in scope. Work in {where}. "
118
170
  "Do not modify the automation bundle or its configuration."
119
171
  )
120
172
 
@@ -24,6 +24,13 @@ This skill is activated by:
24
24
  - Opens draft pull requests by default, titled `docs: update AGENTS.md` or
25
25
  `docs: add AGENTS.md`
26
26
 
27
+ On OpenHands Cloud and Enterprise the conversation is started through the
28
+ OpenHands API in a sandbox of its own, with the user's own settings. The agent
29
+ clones the repository itself, and a conversation that ends without a pull
30
+ request is recorded as unfinished: the script has no clone to open one from. A
31
+ user with no `GITHUB_PERSONAL_ACCESS_TOKEN` secret is served the token of their
32
+ connected GitHub integration instead.
33
+
27
34
  ## What the agent is asked to do
28
35
 
29
36
  Read the repository - layout, the build, test, lint and format commands as they
@@ -167,17 +167,24 @@ python3 -m py_compile /tmp/agents-md-build/main.py && echo "Syntax OK"
167
167
 
168
168
  ### Step 8 - Package and upload
169
169
 
170
- Determine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`
171
- block in your system context:
172
- - **OPENHANDS_HOST**: the Automation backend `url_from_agent`
173
- - **Auth**: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`
170
+ Set `OPENHANDS_HOST` and `AUTH_HEADER` for the commands below:
171
+ - **OpenHands Cloud or Enterprise** - a `<HOST>` value is in your system prompt:
172
+ use it as `OPENHANDS_HOST`, with `AUTH_HEADER="Authorization: Bearer $OPENHANDS_API_KEY"`
173
+ - **Local Agent Canvas** - an **Automation backend** is listed in the
174
+ `<RUNTIME_SERVICES>` block of your system context: use its `url_from_agent` as
175
+ `OPENHANDS_HOST`, with `AUTH_HEADER="X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY"`
176
+
177
+ On OpenHands Cloud or Enterprise each run starts in a fresh sandbox, so the
178
+ automation keeps its state in the KV store: check that `kvStore` is listed under
179
+ `features` in `GET ${OPENHANDS_HOST}/api/automation/v1/capabilities` before
180
+ deploying.
174
181
 
175
182
  ```bash
176
183
  tar -czf /tmp/agents-md.tar.gz -C /tmp/agents-md-build .
177
184
 
178
185
  TARBALL_PATH=$(curl -s -X POST \
179
186
  "${OPENHANDS_HOST}/api/automation/v1/uploads?name=github-agents-md-maintainer" \
180
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
187
+ -H "$AUTH_HEADER" \
181
188
  -H "Content-Type: application/gzip" \
182
189
  --data-binary @/tmp/agents-md.tar.gz \
183
190
  | python3 -c "import json,sys; print(json.load(sys.stdin)['tarball_path'])")
@@ -189,7 +196,7 @@ echo "Uploaded: $TARBALL_PATH"
189
196
 
190
197
  ```bash
191
198
  curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1" \
192
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
199
+ -H "$AUTH_HEADER" \
193
200
  -H "Content-Type: application/json" \
194
201
  -d "{
195
202
  \"name\": \"AGENTS.md Maintainer: {repo_summary}\",
@@ -12,24 +12,62 @@ from urllib.parse import parse_qsl, urlencode, urlsplit
12
12
  from urllib.request import Request, urlopen
13
13
 
14
14
 
15
+ # The secret name of the GitHub token an OpenHands Cloud or Enterprise user's
16
+ # connected GitHub integration provides: how this run's sandbox serves it, and
17
+ # how a conversation sees it in its environment.
18
+ CLOUD_GITHUB_TOKEN_SECRET = "github_token"
19
+ CLOUD_GITHUB_TOKEN_ENV = "GITHUB_TOKEN"
20
+
21
+
22
+ def is_cloud_run() -> bool:
23
+ """Whether this run is on OpenHands Cloud or Enterprise.
24
+
25
+ The automation service hands a run the Agent Server URL only on a local
26
+ Agent Canvas; elsewhere the run talks to the OpenHands API instead.
27
+ """
28
+ return bool(os.environ.get("OPENHANDS_CLOUD_API_URL")) and not os.environ.get(
29
+ "AGENT_SERVER_URL"
30
+ )
31
+
32
+
33
+ def _load_cloud_secret(name: str) -> str | None:
34
+ """Read one named secret of this run's sandbox from the OpenHands API."""
35
+ api = os.environ["OPENHANDS_CLOUD_API_URL"].rstrip("/")
36
+ request = Request(
37
+ f"{api}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}",
38
+ headers={"X-Session-API-Key": os.environ["SESSION_API_KEY"]},
39
+ )
40
+ try:
41
+ with urlopen(request, timeout=90) as response:
42
+ return response.read().decode().strip()
43
+ except HTTPError as exc:
44
+ if exc.code == 404:
45
+ return None
46
+ raise
47
+
48
+
15
49
  def _load_secret(name: str) -> str:
16
- """Read one named secret from the environment or configured Agent Server."""
50
+ """Read one named secret from the environment, or from the configured Agent
51
+ Server on a local run and the OpenHands API on a cloud one."""
17
52
  value = os.environ.get(name)
18
53
  if value:
19
54
  return value
20
55
 
21
- from openhands.sdk.workspace import RemoteWorkspace
56
+ if is_cloud_run():
57
+ value = _load_cloud_secret(name)
58
+ else:
59
+ from openhands.sdk.workspace import RemoteWorkspace
22
60
 
23
- workspace = RemoteWorkspace(
24
- host=os.environ["AGENT_SERVER_URL"],
25
- api_key=os.environ["SESSION_API_KEY"],
26
- working_dir=os.environ.get("WORKSPACE_BASE", "/workspace"),
27
- )
28
- try:
29
- secret = workspace.get_secrets([name]).get(name)
30
- value = secret.get_value() if secret else None
31
- finally:
32
- workspace.reset_client()
61
+ workspace = RemoteWorkspace(
62
+ host=os.environ["AGENT_SERVER_URL"],
63
+ api_key=os.environ["SESSION_API_KEY"],
64
+ working_dir=os.environ.get("WORKSPACE_BASE", "/workspace"),
65
+ )
66
+ try:
67
+ secret = workspace.get_secrets([name]).get(name)
68
+ value = secret.get_value() if secret else None
69
+ finally:
70
+ workspace.reset_client()
33
71
  if not value:
34
72
  raise ValueError(f"The GitHub credential {name} is unavailable")
35
73
  return value
@@ -95,11 +133,22 @@ class GitHubRepository:
95
133
  "Expected the environment variable containing the GitHub token"
96
134
  )
97
135
  self.token_name = github_token_secret
98
- self.token = _load_secret(github_token_secret)
136
+ try:
137
+ self.token = _load_secret(github_token_secret)
138
+ except ValueError:
139
+ if not is_cloud_run():
140
+ raise
141
+ # No secret is saved under that name, so use the user's connected
142
+ # GitHub integration, under the name a conversation sees it by.
143
+ self.token = _load_cloud_secret(CLOUD_GITHUB_TOKEN_SECRET)
144
+ if not self.token:
145
+ raise
146
+ self.token_name = CLOUD_GITHUB_TOKEN_ENV
99
147
  self.conversation = conversation
100
148
  self.conversation_id = str(conversation.id) if conversation else None
101
149
  self.dispatcher = dispatcher
102
- self.workspace = Path(os.environ["WORKSPACE_BASE"])
150
+ # A cloud run is not given a workspace base; its sandbox has /workspace.
151
+ self.workspace = Path(os.environ.get("WORKSPACE_BASE", "/workspace"))
103
152
  self.project = self.workspace
104
153
  self.evidence = self.workspace / "evidence"
105
154
  self.evidence.mkdir(exist_ok=True)
@@ -111,10 +160,13 @@ class GitHubRepository:
111
160
 
112
161
  @property
113
162
  def github_instructions(self):
163
+ # A cloud conversation runs in a sandbox of its own, so this run's
164
+ # workspace path means nothing to it.
165
+ where = "your working directory" if is_cloud_run() else self.project
114
166
  return (
115
167
  f"Use `GH_TOKEN=${self.token_name} gh api` for GitHub requests. "
116
168
  "Never print the credential value. "
117
- f"Only {self.repository} is in scope. Work in {self.project}. "
169
+ f"Only {self.repository} is in scope. Work in {where}. "
118
170
  "Do not modify the automation bundle or its configuration."
119
171
  )
120
172