@openhands/extensions 0.29.0 → 0.31.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 (97) hide show
  1. package/.agents/skills/custom-codereview-guide.md +47 -5
  2. package/.claude-plugin/marketplace.json +20 -5
  3. package/.codex-plugin/marketplace.json +20 -5
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/check-extensions.yml +1 -1
  6. package/.github/workflows/pr-artifacts.yml +4 -83
  7. package/.github/workflows/tests.yml +14 -1
  8. package/.plugin/marketplace.json +20 -5
  9. package/.release-please-manifest.json +1 -1
  10. package/AGENTS.md +4 -3
  11. package/README.md +6 -5
  12. package/apps/vscode/README.md +8 -6
  13. package/apps/vscode/canvas-extension.json +11 -3
  14. package/apps/vscode/extension.js +60 -12
  15. package/apps/vscode/scripts/package.mjs +20 -74
  16. package/automations/bundle-index.js +10 -8
  17. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  18. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  19. package/automations/catalog/github-pr-reviewer/manifest.json +7 -6
  20. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  21. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  22. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  23. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  24. package/integrations/catalog/atlassian-rovo.json +4 -3
  25. package/integrations/catalog/bitbucket.json +16 -1
  26. package/integrations/catalog/github.json +1 -0
  27. package/integrations/catalog/gitlab.json +1 -1
  28. package/marketplaces/openhands-extensions.json +20 -5
  29. package/package.json +1 -1
  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/references/backend-safety.md +3 -3
  37. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  38. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  39. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  40. package/skills/github/scripts/agent_conversation.py +224 -8
  41. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  42. package/skills/github-issue-triage/README.md +73 -14
  43. package/skills/github-issue-triage/SKILL.md +26 -46
  44. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  45. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  46. package/skills/github-issue-triage/scripts/worker.py +153 -103
  47. package/skills/github-pr-reviewer/README.md +109 -1
  48. package/skills/github-pr-reviewer/SKILL.md +42 -8
  49. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  50. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  51. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  52. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  53. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  54. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  55. package/skills/github-stale-ci-pr-closer/scripts/worker.py +8 -2
  56. package/skills/gitlab-issue-to-mr/README.md +10 -3
  57. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  58. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  59. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  60. package/skills/index.js +18 -8
  61. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  62. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  63. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  64. package/skills/jira-issue-to-pr/README.md +18 -11
  65. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  66. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  67. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  68. package/skills/slack-channel-monitor/README.md +12 -3
  69. package/skills/slack-channel-monitor/SKILL.md +35 -20
  70. package/skills/slack-channel-monitor/references/windows.md +6 -2
  71. package/skills/slack-channel-monitor/scripts/main.py +189 -12
  72. package/skills/software-factory/.claude-plugin/plugin.json +19 -0
  73. package/skills/software-factory/.codex-plugin/plugin.json +19 -0
  74. package/skills/software-factory/.plugin/plugin.json +19 -0
  75. package/skills/software-factory/README.md +64 -0
  76. package/skills/software-factory/SKILL.md +229 -0
  77. package/skills/software-factory/commands/software-factory.md +8 -0
  78. package/skills/software-factory/scripts/factory.py +630 -0
  79. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  80. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  81. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  82. package/skills/ticket-to-code-change/SKILL.md +5 -3
  83. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  84. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  85. package/tests/test_agent_conversation_dispatch.py +173 -6
  86. package/tests/test_automation_llm_provenance.py +1209 -0
  87. package/tests/test_github_review_publication.py +422 -0
  88. package/tests/test_github_reviewer_delivery.py +174 -21
  89. package/tests/test_github_stale_ci_pr_closer.py +52 -1
  90. package/tests/test_github_triage_delivery.py +411 -238
  91. package/tests/test_gitlab_issue_to_mr.py +284 -0
  92. package/tests/test_jira_issue_to_pr.py +200 -6
  93. package/tests/test_pr_review_prompt_injection.py +353 -0
  94. package/tests/test_slack_channel_monitor.py +32 -1
  95. package/tests/test_software_factory_script.py +231 -0
  96. package/tests/test_vscode_app.py +8 -2
  97. 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.29.0"
3
+ version = "0.31.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.29.0" # x-release-please-version
21
+ _FALLBACK_VERSION = "0.31.0" # x-release-please-version
22
22
 
23
23
  try:
24
24
  __version__: str = version("openhands-extensions")
@@ -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.
@@ -48,8 +48,10 @@ interface CanvasExtensionManifest {
48
48
  interface CanvasExtensionBackend {
49
49
  schema_version: 1;
50
50
  artifacts: Partial<Record<"linux-amd64" | "linux-arm64", {
51
- path: string;
51
+ path?: string;
52
+ url?: string;
52
53
  sha256: string;
54
+ strip_components?: number;
53
55
  }>>;
54
56
  argv: string[];
55
57
  health?: {
@@ -71,7 +73,7 @@ interface CanvasExtensionPageContribution {
71
73
 
72
74
  Required manifest fields are `schema_version`, `name`, `version`, and `entrypoint`. A useful page package also supplies `display_name`, `description`, and at least one `contributes.pages` entry. `backend` is optional: omitting it preserves a browser-only App or a manual/deployment-owned Sidecar.
73
75
 
74
- 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` in `GET /server_info`; the same response must provide `app_backend_ingress_url`.
76
+ 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` in `GET /server_info`; the same response must provide `app_backend_ingress_url`.
75
77
 
76
78
  The minimum host is the Agent Canvas revision that adds the optional `host.appBackendView` helper specified under Host API 1 below — the frontend change in OpenHands/OpenHands#17641, which this document's PR lists as a merge prerequisite. No upstream release pins that helper yet, so there is no release number to name; until there is, the minimum host is exactly "the build whose `host.appBackendView` is present", and feature-detecting that helper is the only supported test. Absence is an actionable unsupported state, not a reason to guess an ingress port or read Canvas internals.
77
79
 
@@ -80,8 +82,9 @@ All references in this bundle to "the minimum host version documented in `v1-con
80
82
  For a managed backend:
81
83
 
82
84
  - Key `artifacts` only by `linux-amd64` or `linux-arm64`; one matching entry is sufficient.
83
- - Keep each artifact `path` package-relative, contained, and ending in `.tar.gz`.
84
- - Set `sha256` to exactly 64 lowercase hexadecimal characters.
85
+ - Declare exactly one artifact source: either a package-relative, contained `.tar.gz` `path`, or a credential-free HTTPS `.tar.gz` `url`.
86
+ - For remote archives, optionally set `strip_components` to an integer from 0 through 16; it defaults to 0 and is invalid for local paths.
87
+ - Set `sha256` to exactly 64 lowercase hexadecimal characters. Remote downloads are accepted only when this digest matches.
85
88
  - Use a non-empty structured `argv`; `argv[0]` must resolve inside `{artifact_dir}`.
86
89
  - Use only `{artifact_dir}`, `{data_dir}`, and `{port}` placeholders.
87
90
  - Keep `health.path` root-relative. Defaults are `/health`, 30 seconds, and 0.1 seconds.
@@ -62,7 +62,23 @@ if (manifest.backend !== undefined && manifest.backend !== null) {
62
62
  const label = `backend.artifacts[${platform}]`;
63
63
  if (!new Set(["linux-amd64", "linux-arm64"]).has(platform)) fail(`${label} uses an unsupported platform.`);
64
64
  if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) { fail(`${label} must be an object.`); continue; }
65
- if (typeof artifact.path !== "string" || !artifact.path || artifact.path.startsWith("/") || artifact.path.split(/[\\/]/).includes("..") || !artifact.path.endsWith(".tar.gz")) fail(`${label}.path must be a contained relative .tar.gz path.`);
65
+ const hasPath = typeof artifact.path === "string" && artifact.path.length > 0;
66
+ const hasUrl = typeof artifact.url === "string" && artifact.url.length > 0;
67
+ if (hasPath === hasUrl) {
68
+ fail(`${label} must declare exactly one of path or url.`);
69
+ } else if (hasPath && (artifact.path.startsWith("/") || artifact.path.split(/[\\/]/).includes("..") || !artifact.path.endsWith(".tar.gz"))) {
70
+ fail(`${label}.path must be a contained relative .tar.gz path.`);
71
+ } else if (hasUrl) {
72
+ let url;
73
+ try { url = new URL(artifact.url); } catch { url = null; }
74
+ if (!url || url.protocol !== "https:" || url.username || url.password || url.hash || !url.pathname.endsWith(".tar.gz")) {
75
+ fail(`${label}.url must be a credential-free HTTPS .tar.gz URL.`);
76
+ }
77
+ if (artifact.strip_components !== undefined && (!Number.isInteger(artifact.strip_components) || artifact.strip_components < 0 || artifact.strip_components > 16)) {
78
+ fail(`${label}.strip_components must be an integer from 0 through 16.`);
79
+ }
80
+ }
81
+ if (hasPath && artifact.strip_components !== undefined && artifact.strip_components !== 0) fail(`${label}.strip_components is supported only for remote artifacts.`);
66
82
  if (typeof artifact.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(artifact.sha256)) fail(`${label}.sha256 must be a lowercase SHA-256 checksum.`);
67
83
  }
68
84
  }
@@ -93,6 +93,55 @@ test("accepts a package-owned backend declaration", async () => {
93
93
  }
94
94
  });
95
95
 
96
+ test("accepts checksum-pinned remote backend artifacts", async () => {
97
+ const root = await createApp("extension.js", {
98
+ backend: {
99
+ ...backend,
100
+ artifacts: {
101
+ "linux-amd64": {
102
+ url: "https://example.com/service.tar.gz",
103
+ sha256: "a".repeat(64),
104
+ strip_components: 1,
105
+ },
106
+ },
107
+ },
108
+ });
109
+ try {
110
+ const result = validate(root, "--dist");
111
+ assert.equal(result.status, 0, result.stderr);
112
+ } finally {
113
+ await rm(root, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ test("rejects insecure or ambiguous remote backend artifacts", async () => {
118
+ const artifacts = [
119
+ { url: "http://example.com/service.tar.gz", sha256: "a".repeat(64) },
120
+ {
121
+ path: "backend/service.tar.gz",
122
+ url: "https://example.com/service.tar.gz",
123
+ sha256: "a".repeat(64),
124
+ },
125
+ {
126
+ url: "https://example.com/service.tar.gz",
127
+ sha256: "a".repeat(64),
128
+ strip_components: 17,
129
+ },
130
+ ];
131
+ for (const artifact of artifacts) {
132
+ const root = await createApp("extension.js", {
133
+ backend: { ...backend, artifacts: { "linux-amd64": artifact } },
134
+ });
135
+ try {
136
+ const result = validate(root, "--dist");
137
+ assert.notEqual(result.status, 0);
138
+ } finally {
139
+ await rm(root, { recursive: true, force: true });
140
+ }
141
+ }
142
+ });
143
+
144
+
96
145
  test("rejects a backend artifact path outside the package", async () => {
97
146
  const root = await createApp("extension.js", {
98
147
  backend: {