@openhands/extensions 0.28.0 → 0.30.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/.agents/skills/custom-codereview-guide.md +44 -1
- package/.claude-plugin/marketplace.json +7 -5
- package/.codex-plugin/marketplace.json +7 -5
- package/.github/workflows/build-vscode-app.yml +3 -3
- package/.github/workflows/check-extensions.yml +1 -1
- package/.github/workflows/tests.yml +1 -1
- package/.plugin/marketplace.json +7 -5
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +7 -2
- package/README.md +3 -3
- package/apps/vscode/README.md +8 -6
- package/apps/vscode/canvas-extension.json +11 -3
- package/apps/vscode/extension.js +60 -12
- package/apps/vscode/scripts/package.mjs +20 -74
- package/automations/bundle-index.js +9 -7
- package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
- package/automations/catalog/github-issue-triage/manifest.json +4 -3
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
- package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
- package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
- package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
- package/integrations/catalog/atlassian-rovo.json +4 -3
- package/integrations/catalog/bitbucket.json +16 -1
- package/integrations/catalog/github.json +1 -0
- package/integrations/catalog/gitlab.json +1 -1
- package/marketplaces/openhands-extensions.json +7 -5
- package/package.json +1 -1
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +76 -8
- package/plugins/pr-review/README.md +1 -0
- package/plugins/pr-review/scripts/prompt.py +44 -11
- package/plugins/runtime-api-configs/SKILL.md +1 -1
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/canvas-extension-api/SKILL.md +4 -1
- package/skills/canvas-extension-api/references/backend-safety.md +3 -3
- package/skills/canvas-extension-api/references/native-design.md +250 -0
- package/skills/canvas-extension-api/references/v1-contract.md +7 -4
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
- package/skills/github/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/README.md +73 -14
- package/skills/github-issue-triage/SKILL.md +26 -46
- package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
- package/skills/github-issue-triage/scripts/worker.py +153 -103
- package/skills/github-pr-reviewer/README.md +103 -1
- package/skills/github-pr-reviewer/SKILL.md +35 -8
- package/skills/github-pr-reviewer/references/state-schema.md +8 -0
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
- package/skills/github-pr-reviewer/scripts/main.py +163 -39
- package/skills/github-pr-reviewer/scripts/publication.py +193 -0
- package/skills/github-pr-reviewer/scripts/worker.py +297 -8
- package/skills/github-pr-reviewer/tests/test_main.py +73 -5
- package/skills/gitlab-issue-to-mr/README.md +10 -3
- package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
- package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
- package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
- package/skills/index.js +10 -10
- package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/README.md +18 -11
- package/skills/jira-issue-to-pr/SKILL.md +72 -23
- package/skills/jira-issue-to-pr/references/setup.md +43 -12
- package/skills/jira-issue-to-pr/scripts/main.py +136 -26
- package/skills/openhands-automation/references/custom-automation.md +76 -8
- package/skills/slack-channel-monitor/README.md +6 -1
- package/skills/slack-channel-monitor/SKILL.md +6 -3
- package/skills/slack-channel-monitor/scripts/main.py +36 -4
- package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/SKILL.md +5 -3
- package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +173 -6
- package/tests/test_automation_llm_provenance.py +1209 -0
- package/tests/test_github_review_publication.py +422 -0
- package/tests/test_github_reviewer_delivery.py +174 -21
- package/tests/test_github_triage_delivery.py +411 -238
- package/tests/test_gitlab_issue_to_mr.py +284 -0
- package/tests/test_jira_issue_to_pr.py +200 -6
- package/tests/test_pr_review_prompt_injection.py +353 -0
- package/tests/test_slack_channel_monitor.py +32 -1
- package/tests/test_vscode_app.py +8 -2
- package/tests/vscode-app.test.mjs +8 -2
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import time
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from urllib.error import HTTPError
|
|
8
|
+
from urllib.parse import urlencode
|
|
7
9
|
from urllib.request import Request, urlopen
|
|
8
10
|
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
|
9
11
|
|
|
@@ -113,6 +115,10 @@ class CloudConversations:
|
|
|
113
115
|
},
|
|
114
116
|
)
|
|
115
117
|
|
|
118
|
+
def agent_profiles(self) -> list[dict]:
|
|
119
|
+
listing = self._request("GET", "/api/agent-profiles")
|
|
120
|
+
return (listing or {}).get("profiles") or []
|
|
121
|
+
|
|
116
122
|
def run(self, conversation: dict) -> None:
|
|
117
123
|
"""Start the next turn of an idle conversation on its own Agent Server."""
|
|
118
124
|
try:
|
|
@@ -152,6 +158,17 @@ def _user_message(text: str) -> dict:
|
|
|
152
158
|
return {"role": "user", "content": [{"type": "text", "text": text}]}
|
|
153
159
|
|
|
154
160
|
|
|
161
|
+
def _render(prompt, conversation_id) -> str:
|
|
162
|
+
"""The text of `prompt` for the conversation it is delivered to.
|
|
163
|
+
|
|
164
|
+
A caller that must name the receiving conversation in its own prompt (a
|
|
165
|
+
marker the deterministic side later looks for, say) passes a function of
|
|
166
|
+
the conversation id, because only delivery decides which conversation that
|
|
167
|
+
is.
|
|
168
|
+
"""
|
|
169
|
+
return prompt(str(conversation_id)) if callable(prompt) else prompt
|
|
170
|
+
|
|
171
|
+
|
|
155
172
|
class AgentConversationDispatcher:
|
|
156
173
|
"""Deliver one revision at a time to a stable conversation for each subject."""
|
|
157
174
|
|
|
@@ -189,7 +206,11 @@ class AgentConversationDispatcher:
|
|
|
189
206
|
return self._workspace.__exit__(*args)
|
|
190
207
|
|
|
191
208
|
def deliver(
|
|
192
|
-
self,
|
|
209
|
+
self,
|
|
210
|
+
subject: str,
|
|
211
|
+
delivery: str,
|
|
212
|
+
prompt: str | Callable[[str], str],
|
|
213
|
+
head: str = "",
|
|
193
214
|
) -> dict[str, str]:
|
|
194
215
|
"""Deliver one revision of `subject`, deduping a repeated head.
|
|
195
216
|
|
|
@@ -202,6 +223,7 @@ class AgentConversationDispatcher:
|
|
|
202
223
|
A conversation already running for this head is reported `in_progress`
|
|
203
224
|
and left alone, whatever the new delivery says. Callers with no revision
|
|
204
225
|
identity leave `head` empty and keep the delivery-key-only behavior.
|
|
226
|
+
`prompt` may be a function of the receiving conversation's id.
|
|
205
227
|
"""
|
|
206
228
|
conversation_id = uuid5(NAMESPACE_URL, f"{self.automation_id}:{subject}")
|
|
207
229
|
state_key = f"{_CONVERSATION_KEY_PREFIX}{conversation_id}"
|
|
@@ -252,6 +274,67 @@ class AgentConversationDispatcher:
|
|
|
252
274
|
"conversation_id": str(conversation_id),
|
|
253
275
|
}
|
|
254
276
|
|
|
277
|
+
def subject_conversation(self, subject: str) -> str | None:
|
|
278
|
+
"""The conversation this automation currently delivers `subject` to."""
|
|
279
|
+
conversation_id = uuid5(NAMESPACE_URL, f"{self.automation_id}:{subject}")
|
|
280
|
+
record = _kv_request(f"{_CONVERSATION_KEY_PREFIX}{conversation_id}", "GET")
|
|
281
|
+
return (record or {}).get("conversation_id") or None
|
|
282
|
+
|
|
283
|
+
def llm_provenance(self, conversation_id: str) -> tuple[str, str] | None:
|
|
284
|
+
"""The LLM profile name and model a conversation runs, or None if it is gone.
|
|
285
|
+
|
|
286
|
+
Both are read back from the conversation, never from the automation's
|
|
287
|
+
current settings: the server resolves the agent profile when it starts
|
|
288
|
+
the conversation, and a reused conversation keeps that LLM after the
|
|
289
|
+
profile is edited. The model is the conversation's own. The LLM profile
|
|
290
|
+
name comes from the launching agent profile only while that profile is
|
|
291
|
+
still at its launch revision, and is `unknown` otherwise.
|
|
292
|
+
"""
|
|
293
|
+
if self._cloud:
|
|
294
|
+
conversation = self._cloud.get(conversation_id)
|
|
295
|
+
if conversation is None:
|
|
296
|
+
return None
|
|
297
|
+
model = conversation.get("llm_model")
|
|
298
|
+
else:
|
|
299
|
+
try:
|
|
300
|
+
conversation = self._agent_server_get(
|
|
301
|
+
f"/api/conversations/{conversation_id}"
|
|
302
|
+
)
|
|
303
|
+
except HTTPError as exc:
|
|
304
|
+
if exc.code == 404:
|
|
305
|
+
return None
|
|
306
|
+
raise
|
|
307
|
+
agent = conversation.get("agent") or {}
|
|
308
|
+
model = conversation.get("current_model_id")
|
|
309
|
+
if model is None and agent.get("kind") != "ACPAgent":
|
|
310
|
+
model = (agent.get("llm") or {}).get("model")
|
|
311
|
+
launched = conversation.get("launched_agent_profile") or {}
|
|
312
|
+
profile = "unknown"
|
|
313
|
+
if launched.get("agent_profile_id"):
|
|
314
|
+
try:
|
|
315
|
+
if self._cloud:
|
|
316
|
+
profiles = self._cloud.agent_profiles()
|
|
317
|
+
else:
|
|
318
|
+
listing = self._agent_server_get("/api/agent-profiles")
|
|
319
|
+
profiles = (listing or {}).get("profiles") or []
|
|
320
|
+
except Exception as exc: # noqa: BLE001 - the model is still known
|
|
321
|
+
print(f"Could not read agent profiles: {type(exc).__name__}: {exc}")
|
|
322
|
+
profiles = []
|
|
323
|
+
for item in profiles:
|
|
324
|
+
if str(item.get("id")) == str(launched["agent_profile_id"]):
|
|
325
|
+
if item.get("revision") == launched.get("revision"):
|
|
326
|
+
profile = item.get("llm_profile_ref") or "unknown"
|
|
327
|
+
break
|
|
328
|
+
return profile, model or "unknown"
|
|
329
|
+
|
|
330
|
+
def _agent_server_get(self, path: str) -> dict:
|
|
331
|
+
request = Request(
|
|
332
|
+
f"{self.agent_url.rstrip('/')}{path}",
|
|
333
|
+
headers={"X-Session-API-Key": self.api_key},
|
|
334
|
+
)
|
|
335
|
+
with urlopen(request, timeout=90) as response:
|
|
336
|
+
return json.load(response)
|
|
337
|
+
|
|
255
338
|
def _deliver_cloud(
|
|
256
339
|
self,
|
|
257
340
|
record,
|
|
@@ -287,7 +370,9 @@ class AgentConversationDispatcher:
|
|
|
287
370
|
|
|
288
371
|
if conversation is None:
|
|
289
372
|
new_id = uuid4() if record else conversation_id
|
|
290
|
-
self._cloud.start(
|
|
373
|
+
self._cloud.start(
|
|
374
|
+
new_id, self.profile_id, subject, _render(prompt, new_id)
|
|
375
|
+
)
|
|
291
376
|
return "created", new_id
|
|
292
377
|
|
|
293
378
|
status = conversation.get("execution_status")
|
|
@@ -303,10 +388,10 @@ class AgentConversationDispatcher:
|
|
|
303
388
|
# The API reports an execution status only while the sandbox
|
|
304
389
|
# runs, so an errored conversation whose sandbox was already
|
|
305
390
|
# paused is not seen here and keeps reporting deduplicated.
|
|
306
|
-
self._cloud.send(conversation, prompt)
|
|
391
|
+
self._cloud.send(conversation, _render(prompt, current_id))
|
|
307
392
|
return "retried", current_id
|
|
308
393
|
return "deduplicated", current_id
|
|
309
|
-
self._cloud.send(conversation, prompt)
|
|
394
|
+
self._cloud.send(conversation, _render(prompt, current_id))
|
|
310
395
|
return "resumed", current_id
|
|
311
396
|
|
|
312
397
|
def _deliver_local(
|
|
@@ -326,12 +411,13 @@ class AgentConversationDispatcher:
|
|
|
326
411
|
conversation = RemoteConversation.create(
|
|
327
412
|
self._workspace,
|
|
328
413
|
StartConversationRequest(
|
|
329
|
-
workspace=LocalWorkspace(working_dir=
|
|
414
|
+
workspace=LocalWorkspace(working_dir=self._workspace.working_dir),
|
|
330
415
|
conversation_id=conversation_id,
|
|
331
416
|
agent_profile_id=self.profile_id,
|
|
332
417
|
secrets=self._secrets,
|
|
333
418
|
initial_message=SendMessageRequest(
|
|
334
|
-
content=[TextContent(text=prompt)],
|
|
419
|
+
content=[TextContent(text=_render(prompt, conversation_id))],
|
|
420
|
+
run=True,
|
|
335
421
|
),
|
|
336
422
|
),
|
|
337
423
|
visualizer=None,
|
|
@@ -365,7 +451,7 @@ class AgentConversationDispatcher:
|
|
|
365
451
|
# the work finished, so send the revision again rather
|
|
366
452
|
# than strand it.
|
|
367
453
|
conversation.update_secrets(self._secrets)
|
|
368
|
-
conversation.send_message(prompt)
|
|
454
|
+
conversation.send_message(_render(prompt, conversation_id))
|
|
369
455
|
conversation.run(blocking=False)
|
|
370
456
|
disposition = "retried"
|
|
371
457
|
else:
|
|
@@ -375,8 +461,138 @@ class AgentConversationDispatcher:
|
|
|
375
461
|
# work on the same subject: reuse the conversation and send
|
|
376
462
|
# the revision as its next turn.
|
|
377
463
|
conversation.update_secrets(self._secrets)
|
|
378
|
-
conversation.send_message(prompt)
|
|
464
|
+
conversation.send_message(_render(prompt, conversation_id))
|
|
379
465
|
conversation.run(blocking=False)
|
|
380
466
|
finally:
|
|
381
467
|
conversation.close()
|
|
382
468
|
return disposition
|
|
469
|
+
|
|
470
|
+
def review_snapshot(self, conversation_id):
|
|
471
|
+
if self._cloud:
|
|
472
|
+
conversation = self._cloud.get(conversation_id)
|
|
473
|
+
if conversation is None:
|
|
474
|
+
return "missing", ""
|
|
475
|
+
status = conversation.get("execution_status", "unknown")
|
|
476
|
+
if status != "finished":
|
|
477
|
+
return status, ""
|
|
478
|
+
result = self._cloud._request(
|
|
479
|
+
"GET",
|
|
480
|
+
f"{conversation['conversation_url']}/agent_final_response",
|
|
481
|
+
headers={"X-Session-API-Key": conversation["session_api_key"]},
|
|
482
|
+
)
|
|
483
|
+
return status, result.get("response", "")
|
|
484
|
+
headers = {"X-Session-API-Key": self.api_key}
|
|
485
|
+
base = f"{self.agent_url.rstrip('/')}/api/conversations/{conversation_id}"
|
|
486
|
+
try:
|
|
487
|
+
with urlopen(Request(base, headers=headers), timeout=90) as response:
|
|
488
|
+
status = json.load(response).get("execution_status", "unknown")
|
|
489
|
+
except HTTPError as exc:
|
|
490
|
+
if exc.code == 404:
|
|
491
|
+
return "missing", ""
|
|
492
|
+
raise
|
|
493
|
+
if status != "finished":
|
|
494
|
+
return status, ""
|
|
495
|
+
with urlopen(
|
|
496
|
+
Request(base + "/agent_final_response", headers=headers), timeout=90
|
|
497
|
+
) as response:
|
|
498
|
+
return status, json.load(response).get("response", "")
|
|
499
|
+
|
|
500
|
+
def start_review_work(self, conversation_id, subject, prompt):
|
|
501
|
+
if self._cloud:
|
|
502
|
+
self._cloud.start(UUID(conversation_id), self.profile_id, subject, prompt)
|
|
503
|
+
return
|
|
504
|
+
if self._workspace is None:
|
|
505
|
+
raise RuntimeError("Dispatcher must be used as a context")
|
|
506
|
+
conversation = RemoteConversation.create(
|
|
507
|
+
self._workspace,
|
|
508
|
+
StartConversationRequest(
|
|
509
|
+
workspace=LocalWorkspace(working_dir=self._workspace.working_dir),
|
|
510
|
+
conversation_id=UUID(conversation_id),
|
|
511
|
+
agent_profile_id=self.profile_id,
|
|
512
|
+
secrets=self._secrets,
|
|
513
|
+
initial_message=SendMessageRequest(
|
|
514
|
+
content=[TextContent(text=prompt)], run=True
|
|
515
|
+
),
|
|
516
|
+
),
|
|
517
|
+
visualizer=None,
|
|
518
|
+
)
|
|
519
|
+
conversation.close()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class ReviewCoordination:
|
|
523
|
+
"""CAS against the automation service's shared, user-authorized KV namespace."""
|
|
524
|
+
|
|
525
|
+
def __init__(self):
|
|
526
|
+
namespace = os.environ.get("REVIEW_COORDINATION_AUTOMATION_ID")
|
|
527
|
+
key = os.environ.get("REVIEW_COORDINATION_API_KEY")
|
|
528
|
+
if not namespace or not key:
|
|
529
|
+
raise RuntimeError(
|
|
530
|
+
"Structured reviews require shared coordination namespace and API key"
|
|
531
|
+
)
|
|
532
|
+
self.query = urlencode({"automation_id": str(UUID(namespace))})
|
|
533
|
+
self.base = os.environ["AUTOMATION_API_URL"].rstrip("/")
|
|
534
|
+
self.headers = {"X-Session-API-Key": key, "Content-Type": "application/json"}
|
|
535
|
+
|
|
536
|
+
def request(self, key, method="GET", value=None, condition=""):
|
|
537
|
+
request = Request(
|
|
538
|
+
f"{self.base}/v1/kv/{key}?{self.query}&{condition}",
|
|
539
|
+
data=None if value is None else json.dumps(value).encode(),
|
|
540
|
+
headers=self.headers,
|
|
541
|
+
method=method,
|
|
542
|
+
)
|
|
543
|
+
try:
|
|
544
|
+
with urlopen(request, timeout=90) as response:
|
|
545
|
+
return json.load(response)
|
|
546
|
+
except HTTPError as exc:
|
|
547
|
+
if method == "GET" and exc.code == 404:
|
|
548
|
+
return None
|
|
549
|
+
raise
|
|
550
|
+
|
|
551
|
+
def claim(self, subject, *, existing_only=False):
|
|
552
|
+
key = "review-work-" + str(uuid5(NAMESPACE_URL, subject))
|
|
553
|
+
for _ in range(5):
|
|
554
|
+
current = self.request(key, condition="meta=true")
|
|
555
|
+
if current is None and existing_only:
|
|
556
|
+
return None
|
|
557
|
+
value = (current or {}).get("value") or {}
|
|
558
|
+
if value.get("lease_until", 0) > time.time():
|
|
559
|
+
return None
|
|
560
|
+
value = {**value, "owner": str(uuid4()), "lease_until": time.time() + 180}
|
|
561
|
+
condition = f"if_version={current['version']}" if current else "nx=true"
|
|
562
|
+
try:
|
|
563
|
+
self.request(key, "PUT", value, condition)
|
|
564
|
+
return ReviewLease(self, key, value)
|
|
565
|
+
except HTTPError as exc:
|
|
566
|
+
if exc.code != 409:
|
|
567
|
+
raise
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class ReviewLease:
|
|
572
|
+
def __init__(self, store, key, value):
|
|
573
|
+
self.store, self.key, self.value = store, key, value
|
|
574
|
+
self.owner = value["owner"]
|
|
575
|
+
|
|
576
|
+
def save(self, **changes):
|
|
577
|
+
for _ in range(5):
|
|
578
|
+
current = self.store.request(self.key, condition="meta=true")
|
|
579
|
+
value = current["value"]
|
|
580
|
+
if (
|
|
581
|
+
value.get("owner") != self.owner
|
|
582
|
+
or value.get("lease_until", 0) <= time.time()
|
|
583
|
+
):
|
|
584
|
+
raise RuntimeError("Review lease lost; refusing side effects")
|
|
585
|
+
updated = {**self.value, **changes}
|
|
586
|
+
try:
|
|
587
|
+
self.store.request(
|
|
588
|
+
self.key, "PUT", updated, f"if_version={current['version']}"
|
|
589
|
+
)
|
|
590
|
+
self.value = updated
|
|
591
|
+
return
|
|
592
|
+
except HTTPError as exc:
|
|
593
|
+
if exc.code != 409:
|
|
594
|
+
raise
|
|
595
|
+
raise RuntimeError("Review coordination contention; retry next scan")
|
|
596
|
+
|
|
597
|
+
def release(self):
|
|
598
|
+
self.save(lease_until=0)
|
package/pyproject.toml
CHANGED
|
@@ -18,7 +18,7 @@ from importlib.metadata import PackageNotFoundError, version
|
|
|
18
18
|
#: Kept in lock-step with pyproject.toml/package.json by the version test.
|
|
19
19
|
#: release-please only rewrites a semver found on the annotated line itself, so
|
|
20
20
|
#: the annotation must stay on the assignment; on its own line it is a no-op.
|
|
21
|
-
_FALLBACK_VERSION = "0.
|
|
21
|
+
_FALLBACK_VERSION = "0.30.0" # x-release-please-version
|
|
22
22
|
|
|
23
23
|
try:
|
|
24
24
|
__version__: str = version("openhands-extensions")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: canvas-extension-api
|
|
3
|
-
description: This skill should be used when the user asks to "create an OpenHands App", "scaffold a Canvas App", "build an app with the Canvas Extensions API", "build an Agent Canvas App", "bundle a single-file Canvas App", "build a Sidecar-backed Canvas App", "add onboarding for an App service", "add a custom interface to Agent Canvas", "validate an OpenHands App", or mentions Canvas Apps, Agent Canvas extensions, Blob-importable app bundles, registerPage, app pages, Sidecars, or canvas extension packages.
|
|
3
|
+
description: This skill should be used when the user asks to "create an OpenHands App", "scaffold a Canvas App", "build an app with the Canvas Extensions API", "build an Agent Canvas App", "bundle a single-file Canvas App", "build a Sidecar-backed Canvas App", "add onboarding for an App service", "add a custom interface to Agent Canvas", "validate an OpenHands App", "make a Canvas App look native", "match Agent Canvas styles", "style a canvas extension", "reuse OpenHands design tokens", "add native Tailwind classes to an app", or mentions Canvas Apps, Agent Canvas extensions, Blob-importable app bundles, registerPage, app pages, Sidecars, or canvas extension packages.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Canvas Extensions API
|
|
@@ -195,6 +195,8 @@ Assume activation, mounting, and disposal may happen repeatedly during hot enabl
|
|
|
195
195
|
|
|
196
196
|
Render within the supplied `container`; do not replace unrelated Canvas DOM. Scope CSS under an app-specific root class. Prefer Canvas CSS variables with sensible fallbacks rather than copying host implementation classes.
|
|
197
197
|
|
|
198
|
+
Read `references/native-design.md` before styling an App page. It covers the core rule (reuse tokens, not host internals), the three implementation paths (dependency-free, Tailwind-bundled, existing CSS), the Tailwind v4 theme mapping to host variables, layout and density conventions, copy-ready component recipes, the raw scoped CSS alternative, and a mount-safe style helper. Treat it as the authoritative design reference for Apps.
|
|
199
|
+
|
|
198
200
|
Provide:
|
|
199
201
|
|
|
200
202
|
- semantic structure and headings inside the supplied container. Canvas already owns the outer `<main aria-label={page title}>`, so do not add a nested `main` or duplicate page landmark label;
|
|
@@ -281,5 +283,6 @@ Summarize:
|
|
|
281
283
|
- `references/testing-and-installation.md` - test strategy, manual Canvas workflow, and installation coordinates.
|
|
282
284
|
- `references/packaging-recipes.md` - one-file Vite, CSS, assets, Workers, and WASM rules.
|
|
283
285
|
- `references/backend-safety.md` - authenticated requests, command safety, persistence, and prerequisite onboarding.
|
|
286
|
+
- `references/native-design.md` - native Agent Canvas styling: token reuse, Tailwind mapping, layout, component recipes, scoped CSS, and style injection.
|
|
284
287
|
- `references/acceptance-checklist.md` - automated checks and the local install/enable/reload lifecycle.
|
|
285
288
|
- `scripts/validate-extension.mjs` - dependency-free static artifact validator for an App package directory.
|
|
@@ -10,14 +10,14 @@ For a CLI, binary, compiler, database, or Sidecar, read [sidecar-pattern.md](sid
|
|
|
10
10
|
|
|
11
11
|
## Package-owned managed backends
|
|
12
12
|
|
|
13
|
-
The managed-backend contract requires the stacked, unreleased Agent Server changes in [software-agent-sdk PR #5270](https://github.com/OpenHands/software-agent-sdk/pull/5270) at `ac7b322ddf7d2e0e90a3643c5e26d848236af9a3` and [PR #5272](https://github.com/OpenHands/software-agent-sdk/pull/5272) at `6d9c82973e765cce4843d6a2fb52f634efa64293`. Until
|
|
13
|
+
The managed-backend contract requires the stacked, unreleased Agent Server changes in [software-agent-sdk PR #5270](https://github.com/OpenHands/software-agent-sdk/pull/5270) at `ac7b322ddf7d2e0e90a3643c5e26d848236af9a3` and [PR #5272](https://github.com/OpenHands/software-agent-sdk/pull/5272) at `6d9c82973e765cce4843d6a2fb52f634efa64293`. Remote artifact sources additionally require [software-agent-sdk PR #5503](https://github.com/OpenHands/software-agent-sdk/pull/5503). Until releases containing the needed changes land, those revisions or PR heads are the minimum Agent Server implementation. Feature-detect `canvas_app_backend_bridge_v1` through `GET /server_info`; never infer support from an endpoint failure, Canvas internals, or a guessed port. Use the host helper only at the minimum host version documented in [v1-contract.md](v1-contract.md).
|
|
14
14
|
|
|
15
15
|
A managed backend is declarative, not an install hook. Installation, enablement, activation, and ordinary page mounts never prepare or start it. Show the resolved revision and backend declaration, then require explicit user consent before calling prepare and start with that exact revision. Updating or replacing the package invalidates approval. Never put a credential in the manifest, argv, browser bundle, URL, or log.
|
|
16
16
|
|
|
17
17
|
For embedded backend views, require the configured ingress to be a separate browser origin from Canvas. That origin is the isolation boundary. Apply the server-returned sandbox exactly, including `allow-same-origin`; removing it breaks Origin validation, partitioned-cookie delivery, and Worker creation in Chromium. Do not replace origin isolation with an opaque sandbox origin.
|
|
18
18
|
|
|
19
|
-
The Agent Server selects the current platform artifact, verifies its lowercase SHA-256 checksum, extracts it safely to immutable content-addressed storage, and runs only the declared structured argv without a shell. The executable must remain inside `{artifact_dir}`. Supported placeholders are `{artifact_dir}`, `{data_dir}`, and `{port}`. Inherited environment names are restricted to the server's non-credential allowlist; ambient Agent Server credentials do not flow to the child.
|
|
19
|
+
The Agent Server selects the current platform artifact, verifies its lowercase SHA-256 checksum, extracts it safely to immutable content-addressed storage, and runs only the declared structured argv without a shell. An artifact may use either a contained package-relative `path` or a credential-free HTTPS `url`, never both. Remote downloads enforce redirect, size, scheme, checksum, and archive-entry constraints; `strip_components` is bounded from 0 through 16. The executable must remain inside `{artifact_dir}`. Supported placeholders are `{artifact_dir}`, `{data_dir}`, and `{port}`. The child receives an App-owned `HOME` below `{data_dir}`. Inherited environment names are restricted to the server's non-credential allowlist; ambient Agent Server credentials do not flow to the child.
|
|
20
20
|
|
|
21
|
-
Only local Linux amd64 and arm64
|
|
21
|
+
Only local Agent Server hosts on Linux amd64 and arm64 are supported initially. Treat `unsupported` as an explicit UI state. The owned process is constrained by its launch contract and process group but is not OS-isolated; run untrusted backend code only inside a suitably isolated Agent Server runtime.
|
|
22
22
|
|
|
23
23
|
Backend data belongs to the service independently of the installed bundle. Disable and uninstall stop the owned process and revoke browser sessions but preserve mutable data. Data deletion is a separate explicit action. Installed Apps and their backends belong to one Agent Server; do not assume a service or its data is shared across isolated conversation runtimes.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Native OpenHands App design
|
|
2
|
+
|
|
3
|
+
Build Apps for Agent Canvas that visually belong in the host while remaining a self-contained, safe extension package. Use this reference together with the main `SKILL.md` workflow before changing an App's UI.
|
|
4
|
+
|
|
5
|
+
## Core rule: reuse tokens, not host internals
|
|
6
|
+
|
|
7
|
+
Treat an App bundle as an independent frontend. Do not import Agent Canvas React components, utility modules, CSS files, font files, Tailwind configuration, aliases, or private DOM classes. The host imports the App as a self-contained Blob ESM module, so those imports will fail or create fragile coupling.
|
|
8
|
+
|
|
9
|
+
Reuse the host's stable CSS custom properties instead. Scope every selector under a unique App root class and provide literal fallbacks so the page stays legible outside a current Canvas build. Tailwind utility names such as `bg-base` or `text-contrast` are **not globally available** to an App: they work only after the App's own Tailwind build maps them to the host variables.
|
|
10
|
+
|
|
11
|
+
Use semantic names (`surface`, `foreground`, `border`, `primary`) rather than copying cool-grey hex values.
|
|
12
|
+
|
|
13
|
+
## Choose the implementation path
|
|
14
|
+
|
|
15
|
+
Choose the smallest path that matches the App package:
|
|
16
|
+
|
|
17
|
+
| App implementation | Recommended styling approach |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| Dependency-free JavaScript | Inject one scoped CSS string from the entrypoint. Use host custom properties directly. |
|
|
20
|
+
| React/TypeScript with Tailwind | Add an App-local Tailwind theme mapping to the host variables, then compile every class into the single `extension.js` artifact. |
|
|
21
|
+
| Existing CSS module or CSS-in-JS setup | Define the semantic colors and component recipes in the App's own scoped stylesheet. |
|
|
22
|
+
|
|
23
|
+
Never expect the host Tailwind compiler to scan or compile App source. Never use a CDN Tailwind runtime. Never attach App styles to `:root`, `html`, `body`, or global element selectors.
|
|
24
|
+
|
|
25
|
+
## Tailwind setup for a bundled App
|
|
26
|
+
|
|
27
|
+
For Tailwind v4, place an App-local mapping in the App stylesheet. This lets the App use Canvas-like classes while preserving portability:
|
|
28
|
+
|
|
29
|
+
```css
|
|
30
|
+
@import "tailwindcss";
|
|
31
|
+
|
|
32
|
+
@theme inline {
|
|
33
|
+
--color-base: var(--oh-color-base);
|
|
34
|
+
--color-base-secondary: var(--oh-color-base-secondary);
|
|
35
|
+
--color-surface: var(--oh-surface);
|
|
36
|
+
--color-surface-raised: var(--oh-surface-raised);
|
|
37
|
+
--color-foreground: var(--oh-foreground);
|
|
38
|
+
--color-contrast: var(--oh-contrast);
|
|
39
|
+
--color-muted: var(--oh-muted);
|
|
40
|
+
--color-tertiary-alt: var(--oh-text-dim);
|
|
41
|
+
--color-border: var(--oh-border);
|
|
42
|
+
--color-border-subtle: var(--oh-border-subtle);
|
|
43
|
+
--color-focus: var(--oh-focus);
|
|
44
|
+
--color-interactive-hover: var(--oh-interactive-hover);
|
|
45
|
+
--color-primary: var(--oh-color-primary);
|
|
46
|
+
--color-on-primary: var(--oh-accent-foreground);
|
|
47
|
+
--color-danger: var(--oh-danger);
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Compile this stylesheet into `extension.js`; do not leave a linked CSS asset. For an App without Tailwind, use the matching raw CSS below instead. Use utility recipes only after adding this mapping.
|
|
52
|
+
|
|
53
|
+
## Layout and density
|
|
54
|
+
|
|
55
|
+
Let Canvas own the outer page and scrolling region. Do not add a second page shell, `h-screen`, global background, sidebar, or top navigation. Start with `min-h-full`, use `p-4 sm:p-6 lg:p-8`, and constrain dense content with `mx-auto w-full max-w-5xl`. Use `gap-4` for related controls, `gap-6` within sections, and `gap-8` between major sections. Use `rounded-lg` for controls and `rounded-xl` for cards and messages.
|
|
56
|
+
|
|
57
|
+
Avoid gradients, glass effects, oversize headings, floating shadows, dense borders, and hard-coded dark greys. The native visual language is restrained: dark layered surfaces, cool-grey borders, warm primary actions, direct typography, and short transitions.
|
|
58
|
+
|
|
59
|
+
## Interaction and state checklist
|
|
60
|
+
|
|
61
|
+
- Apply `text-contrast` to headings and key labels; use `text-muted` or `text-tertiary-alt` for supporting copy.
|
|
62
|
+
- Use the primary recipe for one dominant action. Use a bordered secondary action for alternatives. Do not make every button primary.
|
|
63
|
+
- Use a 36px (`h-9`) control height and `rounded-lg` for input, select, and standard button controls.
|
|
64
|
+
- Use `focus-visible` rather than removing outlines. Preserve disabled affordance with `disabled:cursor-not-allowed disabled:opacity-60`.
|
|
65
|
+
- Animate only background, border, box-shadow, opacity, or transforms and include `motion-reduce:transition-none`.
|
|
66
|
+
- Scope icon sizing and color within the App. Do not assume HeroUI, Lucide, or host icon packages exist.
|
|
67
|
+
- Use `textContent`, framework escaping, or safe DOM creation for data from requests. Never inject response text with `innerHTML`.
|
|
68
|
+
|
|
69
|
+
## Semantic token map
|
|
70
|
+
|
|
71
|
+
| Intent | Host variable | Fallback |
|
|
72
|
+
| --- | --- | --- |
|
|
73
|
+
| Deep page base | `--oh-color-base` | `#0B0E14` |
|
|
74
|
+
| Standard panel | `--oh-color-base-secondary` | `#21252F` |
|
|
75
|
+
| Raised surface | `--oh-surface-raised` | `#2C313F` |
|
|
76
|
+
| Main text | `--oh-contrast` | `#FFFFFF` |
|
|
77
|
+
| Standard text | `--oh-foreground` | `#EEF2F7` |
|
|
78
|
+
| Secondary text | `--oh-muted` | `#A3B0C4` |
|
|
79
|
+
| Muted placeholder text | `--oh-text-dim` | `#7E8A9E` |
|
|
80
|
+
| Standard border | `--oh-border` | `#4B5468` |
|
|
81
|
+
| Subtle divider | `--oh-border-subtle` | `#383F50` |
|
|
82
|
+
| Focus ring | `--oh-focus` | `#FFFFFF` |
|
|
83
|
+
| Primary surface | `--oh-color-primary` | `#C9B974` |
|
|
84
|
+
| Primary text | `--oh-accent-foreground` | `#0B0E14` |
|
|
85
|
+
| Danger | `--oh-danger` | `#E76A5E` |
|
|
86
|
+
| Success | `--oh-success` | `#A5E75E` |
|
|
87
|
+
|
|
88
|
+
## Copy-ready Tailwind recipes
|
|
89
|
+
|
|
90
|
+
Use these recipes only after mapping the semantic colors in the App-local Tailwind configuration above.
|
|
91
|
+
|
|
92
|
+
### Content shell
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
<div className="min-h-full p-4 sm:p-6 lg:p-8 text-foreground">
|
|
96
|
+
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8">...</div>
|
|
97
|
+
</div>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Do not use `h-screen`, `min-h-screen`, a host-like global background, or a nested `<main>`; Canvas supplies the page landmark and scroll context.
|
|
101
|
+
|
|
102
|
+
### Page heading
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
<header className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
|
106
|
+
<div className="min-w-0">
|
|
107
|
+
<h1 className="text-xl font-semibold tracking-tight text-contrast">Page title</h1>
|
|
108
|
+
<p className="mt-1 text-sm text-muted">Short supporting description.</p>
|
|
109
|
+
</div>
|
|
110
|
+
</header>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Card or section
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
<section className="rounded-xl border border-border bg-base-secondary p-4 sm:p-6">
|
|
117
|
+
<h2 className="text-base font-medium text-contrast">Section title</h2>
|
|
118
|
+
<p className="mt-1 text-sm text-muted">Explain the section briefly.</p>
|
|
119
|
+
<div className="mt-5 flex flex-col gap-4">...</div>
|
|
120
|
+
</section>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Use `border-border-subtle` for dividers inside an existing card. Avoid default card shadows.
|
|
124
|
+
|
|
125
|
+
### Controls and buttons
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
const control =
|
|
129
|
+
"h-9 min-h-9 rounded-lg transition-[background-color,border-color,box-shadow,opacity] duration-75 motion-reduce:transition-none";
|
|
130
|
+
|
|
131
|
+
const field = `${control} w-full min-w-0 border border-border bg-base-secondary px-3 text-sm text-contrast placeholder:text-tertiary-alt focus:border-contrast/40 focus:ring-1 focus:ring-contrast/20 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60`;
|
|
132
|
+
|
|
133
|
+
const primaryButton = `${control} inline-flex w-fit items-center justify-center gap-2 bg-primary px-3 text-sm font-normal text-on-primary hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-30`;
|
|
134
|
+
|
|
135
|
+
const secondaryButton = `${control} inline-flex w-fit items-center justify-center gap-2 border border-border bg-base-secondary px-3 text-sm font-normal text-contrast hover:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-30`;
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Use `label` plus `htmlFor`, keep field hints in `text-xs text-muted`, and express validation with `aria-invalid`, a `border-red-500`, and a linked `role="alert"` message using `text-xs text-red-400`.
|
|
139
|
+
|
|
140
|
+
### Toolbar and responsive grid
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
144
|
+
<div className="min-w-0">...</div>
|
|
145
|
+
<div className="flex flex-wrap items-center gap-2">...</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">...</div>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Informational, error, and empty state
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
<div className="rounded-xl border border-border bg-base-secondary p-6 text-center">
|
|
155
|
+
<h2 className="text-base font-medium text-contrast">Nothing here yet</h2>
|
|
156
|
+
<p className="mt-2 text-sm text-muted">Describe the next useful action.</p>
|
|
157
|
+
<button className={`${primaryButton} mt-4`}>Create item</button>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div role="alert" className="rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
|
161
|
+
The request could not be completed.
|
|
162
|
+
</div>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Use `animate-pulse` with `motion-reduce:animate-none` only for short loading placeholders; reserve space with shapes matching loaded cards or rows.
|
|
166
|
+
|
|
167
|
+
### Compact tabs
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
<div role="tablist" aria-label="View options" className="flex w-fit gap-1 rounded-lg border border-border bg-base-secondary p-1">
|
|
171
|
+
<button role="tab" aria-selected="true" className="rounded-md bg-interactive-hover px-3 py-1.5 text-sm text-contrast">Overview</button>
|
|
172
|
+
<button role="tab" aria-selected="false" className="rounded-md px-3 py-1.5 text-sm text-muted hover:bg-surface-raised hover:text-contrast">Activity</button>
|
|
173
|
+
</div>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Implement keyboard tab behavior when tabs change content; use regular buttons for simple filters.
|
|
177
|
+
|
|
178
|
+
## Raw scoped CSS alternative
|
|
179
|
+
|
|
180
|
+
Use this approach for dependency-free Apps. Apply it only after inserting an App root such as `.acme-dashboard`.
|
|
181
|
+
|
|
182
|
+
```css
|
|
183
|
+
.acme-dashboard {
|
|
184
|
+
color: var(--oh-foreground, #EEF2F7);
|
|
185
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
186
|
+
}
|
|
187
|
+
.acme-dashboard .card {
|
|
188
|
+
border: 1px solid var(--oh-border, #4B5468);
|
|
189
|
+
border-radius: 12px;
|
|
190
|
+
background: var(--oh-color-base-secondary, #21252F);
|
|
191
|
+
padding: 1.5rem;
|
|
192
|
+
}
|
|
193
|
+
.acme-dashboard .field,
|
|
194
|
+
.acme-dashboard .button {
|
|
195
|
+
min-height: 2.25rem;
|
|
196
|
+
border-radius: 8px;
|
|
197
|
+
transition: background-color 75ms, border-color 75ms, box-shadow 75ms, opacity 75ms;
|
|
198
|
+
}
|
|
199
|
+
.acme-dashboard .field {
|
|
200
|
+
width: 100%; border: 1px solid var(--oh-border, #4B5468);
|
|
201
|
+
background: var(--oh-color-base-secondary, #21252F);
|
|
202
|
+
color: var(--oh-contrast, #FFF); padding: 0 0.75rem;
|
|
203
|
+
}
|
|
204
|
+
.acme-dashboard .field:focus-visible,
|
|
205
|
+
.acme-dashboard .button:focus-visible {
|
|
206
|
+
outline: none; border-color: color-mix(in srgb, var(--oh-contrast, #FFF) 40%, transparent);
|
|
207
|
+
box-shadow: 0 0 0 1px color-mix(in srgb, var(--oh-contrast, #FFF) 20%, transparent);
|
|
208
|
+
}
|
|
209
|
+
.acme-dashboard .button--primary {
|
|
210
|
+
border: 0; background: var(--oh-color-primary, #C9B974);
|
|
211
|
+
color: var(--oh-accent-foreground, #0B0E14); padding: 0 0.75rem;
|
|
212
|
+
}
|
|
213
|
+
@media (prefers-reduced-motion: reduce) {
|
|
214
|
+
.acme-dashboard .field, .acme-dashboard .button { transition: none; }
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Mount-safe injected style helper
|
|
219
|
+
|
|
220
|
+
Use one marker per mounted App root and remove the node during cleanup.
|
|
221
|
+
|
|
222
|
+
```js
|
|
223
|
+
function installStyles(css, marker) {
|
|
224
|
+
const style = document.createElement("style");
|
|
225
|
+
style.dataset.canvasAppStyle = marker;
|
|
226
|
+
style.textContent = css;
|
|
227
|
+
document.head.append(style);
|
|
228
|
+
return () => style.remove();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// During mount:
|
|
232
|
+
const disposeStyles = installStyles(STYLES, "acme-dashboard");
|
|
233
|
+
const root = document.createElement("div");
|
|
234
|
+
root.className = "acme-dashboard";
|
|
235
|
+
container.append(root);
|
|
236
|
+
return () => {
|
|
237
|
+
disposeStyles();
|
|
238
|
+
root.remove();
|
|
239
|
+
};
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
If more than one page can mount concurrently, ref-count the marker or inject its stylesheet once in `activate` and remove it in the activation disposer.
|
|
243
|
+
|
|
244
|
+
## Verify before delivery
|
|
245
|
+
|
|
246
|
+
- Confirm extension markup uses a unique root class and no App CSS leaks outside it.
|
|
247
|
+
- Confirm styles survive a real Canvas mount, route remount, disable/re-enable cycle, and host dark surface backgrounds.
|
|
248
|
+
- Confirm `extension.js` has no unresolved CSS import, bare package import, extra CSS chunk, or Node global.
|
|
249
|
+
- Run the Canvas extension static validator plus the App's lint, tests, and build.
|
|
250
|
+
- Report whether the App uses direct host variables or an App-local Tailwind mapping, and list the tested states.
|