@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.
Files changed (89) hide show
  1. package/.agents/skills/custom-codereview-guide.md +44 -1
  2. package/.claude-plugin/marketplace.json +7 -5
  3. package/.codex-plugin/marketplace.json +7 -5
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/check-extensions.yml +1 -1
  6. package/.github/workflows/tests.yml +1 -1
  7. package/.plugin/marketplace.json +7 -5
  8. package/.release-please-manifest.json +1 -1
  9. package/AGENTS.md +7 -2
  10. package/README.md +3 -3
  11. package/apps/vscode/README.md +8 -6
  12. package/apps/vscode/canvas-extension.json +11 -3
  13. package/apps/vscode/extension.js +60 -12
  14. package/apps/vscode/scripts/package.mjs +20 -74
  15. package/automations/bundle-index.js +9 -7
  16. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  17. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  18. package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
  19. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  20. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  21. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  22. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  23. package/integrations/catalog/atlassian-rovo.json +4 -3
  24. package/integrations/catalog/bitbucket.json +16 -1
  25. package/integrations/catalog/github.json +1 -0
  26. package/integrations/catalog/gitlab.json +1 -1
  27. package/marketplaces/openhands-extensions.json +7 -5
  28. package/package.json +1 -1
  29. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +76 -8
  30. package/plugins/pr-review/README.md +1 -0
  31. package/plugins/pr-review/scripts/prompt.py +44 -11
  32. package/plugins/runtime-api-configs/SKILL.md +1 -1
  33. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
  34. package/pyproject.toml +1 -1
  35. package/python/openhands_extensions/_version.py +1 -1
  36. package/skills/canvas-extension-api/SKILL.md +4 -1
  37. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  38. package/skills/canvas-extension-api/references/native-design.md +250 -0
  39. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  40. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  41. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  42. package/skills/github/scripts/agent_conversation.py +224 -8
  43. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  44. package/skills/github-issue-triage/README.md +73 -14
  45. package/skills/github-issue-triage/SKILL.md +26 -46
  46. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  47. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  48. package/skills/github-issue-triage/scripts/worker.py +153 -103
  49. package/skills/github-pr-reviewer/README.md +103 -1
  50. package/skills/github-pr-reviewer/SKILL.md +35 -8
  51. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  52. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  53. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  54. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  55. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  56. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  57. package/skills/gitlab-issue-to-mr/README.md +10 -3
  58. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  59. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  60. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  61. package/skills/index.js +10 -10
  62. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  63. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  64. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  65. package/skills/jira-issue-to-pr/README.md +18 -11
  66. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  67. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  68. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  69. package/skills/openhands-automation/references/custom-automation.md +76 -8
  70. package/skills/slack-channel-monitor/README.md +6 -1
  71. package/skills/slack-channel-monitor/SKILL.md +6 -3
  72. package/skills/slack-channel-monitor/scripts/main.py +36 -4
  73. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  74. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  75. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  76. package/skills/ticket-to-code-change/SKILL.md +5 -3
  77. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  78. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  79. package/tests/test_agent_conversation_dispatch.py +173 -6
  80. package/tests/test_automation_llm_provenance.py +1209 -0
  81. package/tests/test_github_review_publication.py +422 -0
  82. package/tests/test_github_reviewer_delivery.py +174 -21
  83. package/tests/test_github_triage_delivery.py +411 -238
  84. package/tests/test_gitlab_issue_to_mr.py +284 -0
  85. package/tests/test_jira_issue_to_pr.py +200 -6
  86. package/tests/test_pr_review_prompt_injection.py +353 -0
  87. package/tests/test_slack_channel_monitor.py +32 -1
  88. package/tests/test_vscode_app.py +8 -2
  89. 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, subject: str, delivery: str, prompt: str, head: str = ""
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(new_id, self.profile_id, subject, prompt)
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="/workspace"),
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)], run=True
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-extensions"
3
- version = "0.28.0"
3
+ version = "0.30.0"
4
4
  description = "OpenHands extensions, plugins, and skills (Python bindings for the integration catalog)"
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
@@ -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.28.0" # x-release-please-version
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 a release containing both lands, those exact revisions 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).
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 artifacts 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.
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.