@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.
- package/.claude-plugin/marketplace.json +24 -0
- package/.codex-plugin/marketplace.json +24 -0
- package/.github/pull_request_template.md +17 -2
- package/.github/scripts/check_pr_description.py +64 -5
- package/.github/scripts/markdown_sections.py +95 -0
- package/.github/workflows/build-vscode-app.yml +30 -0
- package/.github/workflows/check-extensions.yml +3 -0
- package/.github/workflows/tests.yml +26 -0
- package/.plugin/marketplace.json +24 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +3 -2
- package/README.md +9 -3
- package/apps/README.md +20 -0
- package/apps/catalog.json +10 -0
- package/apps/index.d.ts +11 -0
- package/apps/index.js +5 -0
- package/apps/vscode/README.md +13 -0
- package/apps/vscode/canvas-extension.json +19 -0
- package/apps/vscode/extension.js +114 -0
- package/apps/vscode/scripts/package.mjs +84 -0
- package/automations/README.md +7 -6
- package/automations/bundle-index.js +15 -15
- package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
- package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
- package/automations/catalog/news-digest/manifest.json +2 -2
- package/automations/index.d.ts +9 -1
- package/automations/interface.json +9 -0
- package/automations/interface.schema.json +37 -2
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/openhands-extensions.json +24 -0
- package/package.json +9 -1
- package/plugins/openhands/skills/openhands-api/README.md +1 -1
- package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
- package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
- package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/README.md +59 -0
- package/plugins/runtime-api-configs/SKILL.md +240 -0
- package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
- package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +67 -15
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/check_plugin_resources.py +116 -0
- package/scripts/sync_extensions.py +32 -5
- package/skills/canvas-extension-api/SKILL.md +34 -3
- package/skills/canvas-extension-api/references/backend-safety.md +14 -0
- package/skills/canvas-extension-api/references/connections.md +24 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
- package/skills/canvas-extension-api/references/v1-contract.md +53 -3
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
- package/skills/github/scripts/agent_conversation.py +223 -43
- package/skills/github/scripts/github_client.py +67 -15
- package/skills/github-agents-md-maintainer/README.md +7 -0
- package/skills/github-agents-md-maintainer/SKILL.md +13 -6
- package/skills/github-agents-md-maintainer/scripts/github_client.py +67 -15
- package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
- package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-to-pr/scripts/github_client.py +67 -15
- package/skills/github-issue-to-pr/scripts/worker.py +10 -1
- package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-triage/scripts/github_client.py +67 -15
- package/skills/github-pr-reviewer/README.md +5 -4
- package/skills/github-pr-reviewer/SKILL.md +8 -17
- package/skills/github-pr-reviewer/references/state-schema.md +0 -15
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
- package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
- package/skills/github-pr-reviewer/scripts/worker.py +131 -108
- package/skills/github-repo-monitor/scripts/github_client.py +67 -15
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
- package/skills/gitlab-issue-to-mr/README.md +12 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
- package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
- package/skills/incident-retrospective/SKILL.md +5 -5
- package/skills/index.js +23 -14
- package/skills/jira-issue-to-pr/README.md +12 -4
- package/skills/jira-issue-to-pr/SKILL.md +129 -18
- package/skills/jira-issue-to-pr/references/setup.md +8 -0
- package/skills/jira-issue-to-pr/scripts/main.py +181 -54
- package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
- package/skills/linear-triage/SKILL.md +5 -5
- package/skills/news-digest/README.md +6 -0
- package/skills/news-digest/SKILL.md +19 -9
- package/skills/news-digest/commands/news-digest-setup.md +1 -1
- package/skills/news-digest/scripts/main.py +133 -15
- package/skills/openhands-api/README.md +1 -1
- package/skills/openhands-api/SKILL.md +3 -3
- package/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/skills/openhands-automation/SKILL.md +66 -4
- package/skills/openhands-automation/references/custom-automation.md +21 -7
- package/skills/research-brief/SKILL.md +5 -5
- package/skills/slack-standup-digest/SKILL.md +5 -5
- package/skills/ticket-to-code-change/SKILL.md +6 -3
- package/skills/use-jev/.claude-plugin/plugin.json +8 -0
- package/skills/use-jev/.codex-plugin/plugin.json +8 -0
- package/skills/use-jev/.plugin/plugin.json +8 -0
- package/skills/use-jev/README.md +66 -0
- package/skills/use-jev/SKILL.md +66 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +212 -0
- package/tests/test_apps_catalog.py +26 -0
- package/tests/test_automation_setup.py +44 -0
- package/tests/test_check_pr_description.py +89 -6
- package/tests/test_github_reviewer_delivery.py +254 -118
- package/tests/test_github_stale_ci_pr_closer.py +56 -0
- package/tests/test_interface_manifest.py +23 -1
- package/tests/test_jira_issue_to_pr.py +312 -0
- package/tests/test_jira_setup_webhook.py +309 -0
- package/tests/test_openhands_api_python.py +37 -0
- package/tests/test_skill_plugin_loading.py +25 -15
- package/tests/test_sync_extensions.py +102 -0
- package/tests/test_vscode_app.py +33 -0
- package/tests/vscode-app.test.mjs +43 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""End-to-end tests for warm_runtime_configs against an in-process HTTP server.
|
|
2
|
+
|
|
3
|
+
Uses only the standard library. Boots a threading HTTP server, points
|
|
4
|
+
RUNTIME_API_URL at it, and runs each CLI subcommand as if it were a real
|
|
5
|
+
runtime-api install.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import binascii
|
|
11
|
+
import contextlib
|
|
12
|
+
import hashlib
|
|
13
|
+
import importlib.util
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import unittest
|
|
20
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
PLUGIN_DIR = Path(__file__).resolve().parents[1]
|
|
25
|
+
SCRIPT_PATH = PLUGIN_DIR / "scripts" / "warm_runtime_configs.py"
|
|
26
|
+
|
|
27
|
+
spec = importlib.util.spec_from_file_location("wrc", SCRIPT_PATH)
|
|
28
|
+
assert spec and spec.loader
|
|
29
|
+
wrc = importlib.util.module_from_spec(spec)
|
|
30
|
+
spec.loader.exec_module(wrc)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
ADMIN_PASSWORD = "hunter2"
|
|
34
|
+
API_KEY = "test-api-key"
|
|
35
|
+
SALT = "salt-value"
|
|
36
|
+
CHALLENGE = "challenge-value"
|
|
37
|
+
ITERATIONS = 1000
|
|
38
|
+
|
|
39
|
+
EXPECTED_HASH = binascii.hexlify(
|
|
40
|
+
hashlib.pbkdf2_hmac(
|
|
41
|
+
"sha256",
|
|
42
|
+
ADMIN_PASSWORD.encode(),
|
|
43
|
+
(SALT + CHALLENGE).encode(),
|
|
44
|
+
ITERATIONS,
|
|
45
|
+
dklen=32,
|
|
46
|
+
)
|
|
47
|
+
).decode()
|
|
48
|
+
|
|
49
|
+
ADMIN_JWT = "test-jwt"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FakeRuntimeAPI(BaseHTTPRequestHandler):
|
|
53
|
+
"""In-process stand-in for the runtime-api admin endpoints."""
|
|
54
|
+
|
|
55
|
+
configs: dict[str, dict] = {}
|
|
56
|
+
calls: list[tuple[str, str, dict, dict]] = []
|
|
57
|
+
initial_configs: list[dict] = []
|
|
58
|
+
|
|
59
|
+
def log_message(self, format, *args): # silence stdout noise
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def _read_body(self) -> dict:
|
|
63
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
64
|
+
raw = self.rfile.read(length).decode() if length else ""
|
|
65
|
+
return json.loads(raw) if raw else {}
|
|
66
|
+
|
|
67
|
+
def _respond(self, status: int, payload: dict) -> None:
|
|
68
|
+
body = json.dumps(payload).encode()
|
|
69
|
+
self.send_response(status)
|
|
70
|
+
self.send_header("Content-Type", "application/json")
|
|
71
|
+
self.send_header("Content-Length", str(len(body)))
|
|
72
|
+
self.end_headers()
|
|
73
|
+
self.wfile.write(body)
|
|
74
|
+
|
|
75
|
+
def do_GET(self):
|
|
76
|
+
if self.path == "/api/admin/challenge":
|
|
77
|
+
self._respond(200, {"salt": SALT, "challenge": CHALLENGE, "iterations": ITERATIONS})
|
|
78
|
+
return
|
|
79
|
+
if self.path == "/api/admin/api-keys":
|
|
80
|
+
if self.headers.get("Authorization") != f"Bearer {ADMIN_JWT}":
|
|
81
|
+
self._respond(401, {"error": "bad token"})
|
|
82
|
+
return
|
|
83
|
+
self.calls.append(("GET", self.path, dict(self.headers), {}))
|
|
84
|
+
# Real runtime-api returns a top-level JSON array of ApiKeyResponse.
|
|
85
|
+
self._respond_raw(200, json.dumps([
|
|
86
|
+
{"id": 1, "name": "default", "key_value": API_KEY},
|
|
87
|
+
{"id": 2, "name": "other", "key_value": "other-key"},
|
|
88
|
+
]).encode())
|
|
89
|
+
return
|
|
90
|
+
if self.path == "/api/warm-runtime-configs":
|
|
91
|
+
if self.headers.get("X-API-Key") != API_KEY:
|
|
92
|
+
self._respond(401, {"error": "bad api key"})
|
|
93
|
+
return
|
|
94
|
+
merged = list(self.initial_configs) + [
|
|
95
|
+
dict(name=n, source="db", **c) for n, c in self.configs.items()
|
|
96
|
+
]
|
|
97
|
+
self._respond(200, {"configs": merged})
|
|
98
|
+
return
|
|
99
|
+
self._respond(404, {"error": "not found"})
|
|
100
|
+
|
|
101
|
+
def _respond_raw(self, status: int, body: bytes) -> None:
|
|
102
|
+
self.send_response(status)
|
|
103
|
+
self.send_header("Content-Type", "application/json")
|
|
104
|
+
self.send_header("Content-Length", str(len(body)))
|
|
105
|
+
self.end_headers()
|
|
106
|
+
self.wfile.write(body)
|
|
107
|
+
|
|
108
|
+
def do_POST(self):
|
|
109
|
+
if self.path == "/api/admin/login":
|
|
110
|
+
body = self._read_body()
|
|
111
|
+
if body.get("challenge") == CHALLENGE and body.get("hash") == EXPECTED_HASH:
|
|
112
|
+
self._respond(200, {"token": ADMIN_JWT})
|
|
113
|
+
else:
|
|
114
|
+
self._respond(401, {"error": "bad password"})
|
|
115
|
+
return
|
|
116
|
+
self._respond(404, {"error": "not found"})
|
|
117
|
+
|
|
118
|
+
def do_PUT(self):
|
|
119
|
+
prefix = "/api/admin/warm-runtime-configs/"
|
|
120
|
+
if not self.path.startswith(prefix):
|
|
121
|
+
self._respond(404, {"error": "not found"})
|
|
122
|
+
return
|
|
123
|
+
if self.headers.get("Authorization") != f"Bearer {ADMIN_JWT}":
|
|
124
|
+
self._respond(401, {"error": "bad token"})
|
|
125
|
+
return
|
|
126
|
+
name = self.path[len(prefix):]
|
|
127
|
+
body = self._read_body()
|
|
128
|
+
self.configs[name] = body
|
|
129
|
+
self.calls.append(("PUT", name, dict(self.headers), body))
|
|
130
|
+
self._respond(200, {"name": name, **body})
|
|
131
|
+
|
|
132
|
+
def do_DELETE(self):
|
|
133
|
+
prefix = "/api/admin/warm-runtime-configs/"
|
|
134
|
+
if not self.path.startswith(prefix):
|
|
135
|
+
self._respond(404, {"error": "not found"})
|
|
136
|
+
return
|
|
137
|
+
if self.headers.get("Authorization") != f"Bearer {ADMIN_JWT}":
|
|
138
|
+
self._respond(401, {"error": "bad token"})
|
|
139
|
+
return
|
|
140
|
+
name = self.path[len(prefix):]
|
|
141
|
+
removed = self.configs.pop(name, None)
|
|
142
|
+
self.calls.append(("DELETE", name, dict(self.headers), {}))
|
|
143
|
+
if removed is None:
|
|
144
|
+
self._respond(404, {"error": f"no such config {name}"})
|
|
145
|
+
else:
|
|
146
|
+
self._respond(200, {"message": f"deleted {name}"})
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class CliIntegrationTests(unittest.TestCase):
|
|
150
|
+
server: HTTPServer
|
|
151
|
+
thread: threading.Thread
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def setUpClass(cls) -> None:
|
|
155
|
+
cls.server = HTTPServer(("127.0.0.1", 0), FakeRuntimeAPI)
|
|
156
|
+
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
|
157
|
+
cls.thread.start()
|
|
158
|
+
host, port = cls.server.server_address
|
|
159
|
+
os.environ["RUNTIME_API_URL"] = f"http://{host}:{port}"
|
|
160
|
+
os.environ["API_KEY"] = API_KEY
|
|
161
|
+
os.environ["ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def tearDownClass(cls) -> None:
|
|
165
|
+
cls.server.shutdown()
|
|
166
|
+
cls.thread.join(timeout=2)
|
|
167
|
+
|
|
168
|
+
def setUp(self) -> None:
|
|
169
|
+
FakeRuntimeAPI.configs.clear()
|
|
170
|
+
FakeRuntimeAPI.calls.clear()
|
|
171
|
+
FakeRuntimeAPI.initial_configs = [
|
|
172
|
+
{
|
|
173
|
+
"name": "v1_current",
|
|
174
|
+
"source": "file",
|
|
175
|
+
"image": "ghcr.io/openhands/agent-server:1.46.0-python",
|
|
176
|
+
"count": 1,
|
|
177
|
+
"command": ["/usr/local/bin/openhands-agent-server", "--port", "60000"],
|
|
178
|
+
"working_dir": "/workspace/project",
|
|
179
|
+
"environment": {"BASE": "1"},
|
|
180
|
+
}
|
|
181
|
+
]
|
|
182
|
+
os.environ.pop("ADMIN_TOKEN", None)
|
|
183
|
+
|
|
184
|
+
@contextlib.contextmanager
|
|
185
|
+
def _capture_stdout(self):
|
|
186
|
+
buf = io.StringIO()
|
|
187
|
+
old = sys.stdout
|
|
188
|
+
sys.stdout = buf
|
|
189
|
+
try:
|
|
190
|
+
yield buf
|
|
191
|
+
finally:
|
|
192
|
+
sys.stdout = old
|
|
193
|
+
|
|
194
|
+
def test_list_returns_configs(self) -> None:
|
|
195
|
+
with self._capture_stdout() as out:
|
|
196
|
+
wrc.main(["list"])
|
|
197
|
+
configs = json.loads(out.getvalue())
|
|
198
|
+
self.assertEqual([c["name"] for c in configs], ["v1_current"])
|
|
199
|
+
|
|
200
|
+
def test_template_strips_identity_and_applies_overrides(self) -> None:
|
|
201
|
+
with self._capture_stdout() as out:
|
|
202
|
+
wrc.main(
|
|
203
|
+
[
|
|
204
|
+
"template", "v1_current",
|
|
205
|
+
"--image", "ghcr.io/your-org/openhands-php:8.4-v1",
|
|
206
|
+
"--count", "2",
|
|
207
|
+
]
|
|
208
|
+
)
|
|
209
|
+
derived = json.loads(out.getvalue())
|
|
210
|
+
self.assertNotIn("name", derived)
|
|
211
|
+
self.assertNotIn("source", derived)
|
|
212
|
+
self.assertEqual(derived["image"], "ghcr.io/your-org/openhands-php:8.4-v1")
|
|
213
|
+
self.assertEqual(derived["count"], 2)
|
|
214
|
+
self.assertEqual(derived["environment"], {"BASE": "1"})
|
|
215
|
+
|
|
216
|
+
def test_save_upserts_via_admin_auth(self) -> None:
|
|
217
|
+
body = {
|
|
218
|
+
"image": "ghcr.io/your-org/openhands-php:8.4-v1",
|
|
219
|
+
"count": 1,
|
|
220
|
+
"command": ["x"],
|
|
221
|
+
"working_dir": "/w",
|
|
222
|
+
"environment": {},
|
|
223
|
+
}
|
|
224
|
+
sys.stdin = io.StringIO(json.dumps(body))
|
|
225
|
+
try:
|
|
226
|
+
with self._capture_stdout():
|
|
227
|
+
wrc.main(["save", "php-web"])
|
|
228
|
+
finally:
|
|
229
|
+
sys.stdin = sys.__stdin__
|
|
230
|
+
|
|
231
|
+
self.assertIn("php-web", FakeRuntimeAPI.configs)
|
|
232
|
+
method, name, headers, sent = FakeRuntimeAPI.calls[-1]
|
|
233
|
+
self.assertEqual(method, "PUT")
|
|
234
|
+
self.assertEqual(name, "php-web")
|
|
235
|
+
self.assertEqual(headers.get("Authorization"), f"Bearer {ADMIN_JWT}")
|
|
236
|
+
self.assertEqual(sent["image"], body["image"])
|
|
237
|
+
|
|
238
|
+
def test_save_from_file(self) -> None:
|
|
239
|
+
path = Path(self.id().split(".")[-1] + ".json")
|
|
240
|
+
path.write_text(json.dumps({"image": "x", "command": [], "working_dir": "/", "environment": {}, "count": 1}))
|
|
241
|
+
try:
|
|
242
|
+
with self._capture_stdout():
|
|
243
|
+
wrc.main(["save", "from-file", "--file", str(path)])
|
|
244
|
+
finally:
|
|
245
|
+
path.unlink()
|
|
246
|
+
self.assertIn("from-file", FakeRuntimeAPI.configs)
|
|
247
|
+
|
|
248
|
+
def test_delete_removes_config(self) -> None:
|
|
249
|
+
FakeRuntimeAPI.configs["php-web"] = {"image": "x"}
|
|
250
|
+
with self._capture_stdout():
|
|
251
|
+
wrc.main(["delete", "php-web"])
|
|
252
|
+
self.assertNotIn("php-web", FakeRuntimeAPI.configs)
|
|
253
|
+
|
|
254
|
+
def test_admin_token_env_skips_login(self) -> None:
|
|
255
|
+
FakeRuntimeAPI.configs["php-web"] = {"image": "x"}
|
|
256
|
+
os.environ["ADMIN_TOKEN"] = ADMIN_JWT
|
|
257
|
+
os.environ.pop("ADMIN_PASSWORD", None) # would fail if handshake ran
|
|
258
|
+
try:
|
|
259
|
+
with self._capture_stdout():
|
|
260
|
+
wrc.main(["delete", "php-web"])
|
|
261
|
+
# Handshake would have called /api/admin/challenge; verify it didn't.
|
|
262
|
+
self.assertNotIn("php-web", FakeRuntimeAPI.configs)
|
|
263
|
+
self.assertEqual(FakeRuntimeAPI.calls[-1][0], "DELETE")
|
|
264
|
+
finally:
|
|
265
|
+
os.environ["ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
|
266
|
+
|
|
267
|
+
def test_missing_env_fails_fast(self) -> None:
|
|
268
|
+
os.environ.pop("RUNTIME_API_URL", None)
|
|
269
|
+
try:
|
|
270
|
+
with self.assertRaises(SystemExit) as cm:
|
|
271
|
+
wrc.main(["list"])
|
|
272
|
+
self.assertEqual(cm.exception.code, 1)
|
|
273
|
+
finally:
|
|
274
|
+
host, port = self.server.server_address
|
|
275
|
+
os.environ["RUNTIME_API_URL"] = f"http://{host}:{port}"
|
|
276
|
+
|
|
277
|
+
def test_list_falls_back_to_admin_when_api_key_missing(self) -> None:
|
|
278
|
+
"""Without API_KEY set, list must log in as admin and fetch it."""
|
|
279
|
+
os.environ.pop("API_KEY", None)
|
|
280
|
+
try:
|
|
281
|
+
with self._capture_stdout() as out:
|
|
282
|
+
wrc.main(["list"])
|
|
283
|
+
self.assertEqual(
|
|
284
|
+
[c["name"] for c in json.loads(out.getvalue())], ["v1_current"]
|
|
285
|
+
)
|
|
286
|
+
# Fallback path hit /api/admin/api-keys with the admin JWT.
|
|
287
|
+
paths = [c[1] for c in FakeRuntimeAPI.calls]
|
|
288
|
+
self.assertIn("/api/admin/api-keys", paths)
|
|
289
|
+
finally:
|
|
290
|
+
os.environ["API_KEY"] = API_KEY
|
|
291
|
+
|
|
292
|
+
def test_list_fails_when_no_api_key_and_no_admin_creds(self) -> None:
|
|
293
|
+
"""No fallback possible: neither API_KEY nor admin creds set."""
|
|
294
|
+
os.environ.pop("API_KEY", None)
|
|
295
|
+
os.environ.pop("ADMIN_PASSWORD", None)
|
|
296
|
+
os.environ.pop("ADMIN_TOKEN", None)
|
|
297
|
+
try:
|
|
298
|
+
with self.assertRaises(SystemExit) as cm:
|
|
299
|
+
wrc.main(["list"])
|
|
300
|
+
self.assertEqual(cm.exception.code, 1)
|
|
301
|
+
finally:
|
|
302
|
+
os.environ["API_KEY"] = API_KEY
|
|
303
|
+
os.environ["ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
|
304
|
+
|
|
305
|
+
def test_bootstrap_shell_quotes_hostile_secret_values(self) -> None:
|
|
306
|
+
"""Guards against `eval` breakout when a secret contains shell metacharacters."""
|
|
307
|
+
hostile_password = "p@ss word;echo INJECTED"
|
|
308
|
+
hostile_key = "key$(touch /tmp/pwned)"
|
|
309
|
+
|
|
310
|
+
original_which = wrc.shutil.which
|
|
311
|
+
original_decode = wrc._decode_secret
|
|
312
|
+
wrc.shutil.which = lambda _cmd: "/usr/bin/kubectl"
|
|
313
|
+
wrc._decode_secret = lambda _ns, secret, _key: (
|
|
314
|
+
hostile_password if secret == "admin-password" else hostile_key
|
|
315
|
+
)
|
|
316
|
+
try:
|
|
317
|
+
with self._capture_stdout() as out:
|
|
318
|
+
wrc.main(["bootstrap", "--skip-url"])
|
|
319
|
+
rendered = out.getvalue()
|
|
320
|
+
finally:
|
|
321
|
+
wrc.shutil.which = original_which
|
|
322
|
+
wrc._decode_secret = original_decode
|
|
323
|
+
|
|
324
|
+
# Parse the emitted export lines through the shell's own tokenizer,
|
|
325
|
+
# then verify each variable ends up with the exact original value.
|
|
326
|
+
env: dict[str, str] = {}
|
|
327
|
+
for line in rendered.strip().splitlines():
|
|
328
|
+
tokens = __import__("shlex").split(line)
|
|
329
|
+
assert tokens[0] == "export" and len(tokens) == 2, tokens
|
|
330
|
+
key, _, value = tokens[1].partition("=")
|
|
331
|
+
env[key] = value
|
|
332
|
+
|
|
333
|
+
self.assertEqual(env["ADMIN_PASSWORD"], hostile_password)
|
|
334
|
+
self.assertEqual(env["API_KEY"], hostile_key)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
if __name__ == "__main__":
|
|
338
|
+
unittest.main()
|
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
from openhands.sdk
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
from openhands.sdk.
|
|
17
|
-
from openhands.sdk.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|