@openhands/extensions 0.5.0 → 0.7.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 (135) hide show
  1. package/.github/workflows/pypi-publish.yml +104 -0
  2. package/.github/workflows/tests.yml +6 -0
  3. package/.release-please-manifest.json +1 -1
  4. package/AGENTS.md +1 -1
  5. package/README.md +34 -4
  6. package/integrations/README.md +25 -9
  7. package/integrations/catalog/airtable.json +16 -10
  8. package/integrations/catalog/apify.json +0 -2
  9. package/integrations/catalog/asana.json +33 -0
  10. package/integrations/catalog/atlassian.json +5 -8
  11. package/integrations/catalog/bitbucket.json +34 -0
  12. package/integrations/catalog/box.json +34 -0
  13. package/integrations/catalog/brave-search.json +1 -2
  14. package/integrations/catalog/browser-mcp.json +0 -3
  15. package/integrations/catalog/canva.json +31 -0
  16. package/integrations/catalog/clickhouse.json +1 -2
  17. package/integrations/catalog/cloudflare-bindings.json +5 -8
  18. package/integrations/catalog/cloudflare-browser-rendering.json +5 -8
  19. package/integrations/catalog/cloudflare-builds.json +6 -9
  20. package/integrations/catalog/cloudflare-docs.json +4 -7
  21. package/integrations/catalog/cloudflare-observability.json +5 -8
  22. package/integrations/catalog/confluence.json +33 -0
  23. package/integrations/catalog/datadog.json +34 -0
  24. package/integrations/catalog/deepwiki.json +3 -7
  25. package/integrations/catalog/discord.json +34 -0
  26. package/integrations/catalog/elevenlabs.json +27 -28
  27. package/integrations/catalog/everything.json +0 -2
  28. package/integrations/catalog/exa.json +0 -2
  29. package/integrations/catalog/fetch.json +0 -2
  30. package/integrations/catalog/figma.json +35 -9
  31. package/integrations/catalog/filesystem.json +0 -3
  32. package/integrations/catalog/firecrawl.json +0 -2
  33. package/integrations/catalog/git.json +0 -3
  34. package/integrations/catalog/github.json +34 -10
  35. package/integrations/catalog/gmail.json +33 -0
  36. package/integrations/catalog/google-calendar.json +33 -0
  37. package/integrations/catalog/google-docs.json +33 -0
  38. package/integrations/catalog/google-drive.json +33 -0
  39. package/integrations/catalog/google-sheets.json +33 -0
  40. package/integrations/catalog/hubspot.json +34 -0
  41. package/integrations/catalog/huggingface.json +1 -2
  42. package/integrations/catalog/intercom.json +34 -0
  43. package/integrations/catalog/jira.json +33 -0
  44. package/integrations/catalog/kagi.json +2 -3
  45. package/integrations/catalog/linear.json +24 -16
  46. package/integrations/catalog/memory.json +0 -2
  47. package/integrations/catalog/microsoft-outlook.json +33 -0
  48. package/integrations/catalog/microsoft-teams.json +33 -0
  49. package/integrations/catalog/miro.json +42 -0
  50. package/integrations/catalog/monday.json +42 -0
  51. package/integrations/catalog/mongodb.json +1 -2
  52. package/integrations/catalog/neon.json +0 -2
  53. package/integrations/catalog/netlify.json +33 -0
  54. package/integrations/catalog/notion.json +30 -10
  55. package/integrations/catalog/obsidian.json +1 -3
  56. package/integrations/catalog/okta.json +33 -0
  57. package/integrations/catalog/onedrive.json +33 -0
  58. package/integrations/catalog/ordinal.json +33 -0
  59. package/integrations/catalog/paypal.json +5 -8
  60. package/integrations/catalog/playwright.json +0 -2
  61. package/integrations/catalog/posthog.json +38 -0
  62. package/integrations/catalog/quickbooks.json +86 -0
  63. package/integrations/catalog/redis.json +1 -3
  64. package/integrations/catalog/resend.json +1 -2
  65. package/integrations/catalog/salesforce.json +44 -0
  66. package/integrations/catalog/sentry.json +47 -14
  67. package/integrations/catalog/sequential-thinking.json +0 -2
  68. package/integrations/catalog/sharepoint.json +33 -0
  69. package/integrations/catalog/slack.json +49 -10
  70. package/integrations/catalog/stripe.json +39 -14
  71. package/integrations/catalog/supabase.json +31 -9
  72. package/integrations/catalog/tavily.json +0 -2
  73. package/integrations/catalog/time.json +0 -2
  74. package/integrations/catalog/trello.json +34 -0
  75. package/integrations/catalog/vercel.json +33 -0
  76. package/integrations/catalog/webflow.json +34 -0
  77. package/integrations/catalog/xero.json +33 -0
  78. package/integrations/catalog/zoom.json +33 -0
  79. package/integrations/catalog-index.js +151 -0
  80. package/integrations/catalog.schema.json +255 -0
  81. package/integrations/index.d.ts +49 -69
  82. package/integrations/index.js +36 -206
  83. package/marketplaces/openhands-extensions.json +40 -2
  84. package/package.json +4 -7
  85. package/plugins/cobol-modernization/README.md +3 -3
  86. package/plugins/cobol-modernization/skills/{cobol-modernization-overview → cobol-modernization}/SKILL.md +1 -1
  87. package/plugins/magic-test/skills/magic-word/SKILL.md +1 -0
  88. package/plugins/migration-scoring/README.md +1 -1
  89. package/plugins/pr-review/scripts/prompt.py +1 -1
  90. package/pyproject.toml +18 -3
  91. package/python/openhands_extensions/__init__.py +24 -0
  92. package/python/openhands_extensions/_version.py +25 -0
  93. package/python/openhands_extensions/integrations.py +92 -0
  94. package/release-please-config.json +2 -1
  95. package/scripts/build-integration-catalog.mjs +45 -0
  96. package/skills/agent-canvas-environment/.plugin/plugin.json +19 -0
  97. package/skills/agent-canvas-environment/README.md +5 -0
  98. package/skills/agent-canvas-environment/SKILL.md +202 -0
  99. package/skills/bitbucket/README.md +23 -40
  100. package/skills/bitbucket/SKILL.md +43 -31
  101. package/skills/bitbucket-cloud/.plugin/plugin.json +18 -0
  102. package/skills/bitbucket-cloud/README.md +15 -0
  103. package/skills/bitbucket-cloud/SKILL.md +46 -0
  104. package/skills/bitbucket-data-center/.plugin/plugin.json +19 -0
  105. package/skills/bitbucket-data-center/README.md +20 -0
  106. package/skills/bitbucket-data-center/SKILL.md +53 -0
  107. package/skills/code-review/SKILL.md +1 -1
  108. package/skills/code-review/references/supply-chain-security.md +2 -2
  109. package/skills/github-pr-reviewer/references/state-schema.md +18 -4
  110. package/skills/github-pr-reviewer/scripts/main.py +66 -12
  111. package/skills/github-repo-monitor/references/state-schema.md +11 -4
  112. package/skills/github-repo-monitor/scripts/main.py +67 -19
  113. package/skills/github-repo-monitor/tests/test_main.py +67 -8
  114. package/skills/index.js +34 -8
  115. package/skills/openhands-api/README.md +5 -1
  116. package/skills/openhands-api/SKILL.md +74 -5
  117. package/skills/openhands-automation/SKILL.md +3 -1
  118. package/skills/openhands-automation/references/custom-automation.md +166 -0
  119. package/skills/openhands-sdk/SKILL.md +8 -3
  120. package/skills/slack-channel-monitor/README.md +3 -3
  121. package/skills/slack-channel-monitor/SKILL.md +3 -2
  122. package/skills/slack-channel-monitor/references/state-schema.md +11 -4
  123. package/skills/slack-channel-monitor/scripts/main.py +111 -41
  124. package/tests/test_catalog_schema.py +47 -0
  125. package/tests/test_catalogs.py +47 -3
  126. package/tests/test_integration_catalog_in_sync.py +252 -0
  127. package/tests/test_slack_channel_monitor.py +142 -0
  128. package/tests/test_version_alignment.py +64 -0
  129. package/integrations/logos.d.ts +0 -5
  130. package/integrations/logos.js +0 -94
  131. package/integrations/oauth-provider-catalog.js +0 -553
  132. package/integrations/oauth-provider-registration-defaults.js +0 -610
  133. /package/plugins/cobol-modernization/skills/{mainfraime-removal → mainframe-removal}/SKILL.md +0 -0
  134. /package/plugins/cobol-modernization/skills/{mainfraime-removal → mainframe-removal}/references/cics-transformation-examples.md +0 -0
  135. /package/plugins/migration-scoring/skills/{migration-scoring-overview → migration-scoring}/SKILL.md +0 -0
@@ -40,11 +40,10 @@ import urllib.request
40
40
  from datetime import datetime, timezone
41
41
  from urllib.parse import urlencode
42
42
 
43
- # ── Debug logging to a persistent file ────────────────────────────────────────
43
+ # ── Debug logging to a per-run file ───────────────────────────────────────────
44
44
  _DEBUG_LOG_PATH = os.path.join(
45
- os.path.dirname(os.path.dirname(os.path.abspath(
46
- os.environ.get("WORKSPACE_BASE", "/tmp")))),
47
- "automation-state", "slack_poller_debug.log",
45
+ os.environ.get("WORKSPACE_BASE", "/tmp"),
46
+ "slack_poller_debug.log",
48
47
  )
49
48
  os.makedirs(os.path.dirname(_DEBUG_LOG_PATH), exist_ok=True)
50
49
  _debug_log_fh = open(_DEBUG_LOG_PATH, "a")
@@ -198,14 +197,47 @@ def fire_callback(
198
197
  print(f"Callback error (non-fatal): {exc}")
199
198
 
200
199
 
201
- # ── State management ───────────────────────────────────────────────────────────
200
+ # ── State persistence (KV store with local-file fallback) ─────────────────────
202
201
 
203
- def _state_file_path() -> str:
204
- """Derive a persistent storage path from WORKSPACE_BASE.
202
+ _KV_TOKEN = os.environ.get("AUTOMATION_KV_TOKEN", "")
203
+ _KV_BASE = os.environ.get("AUTOMATION_API_URL", "").rstrip("/")
204
+ _STATE_KEY = "state"
205
205
 
206
- WORKSPACE_BASE = {root}/automation-runs/{run_id}
207
- State lives two levels up at {root}/automation-state/.
208
- """
206
+
207
+ def _kv_available() -> bool:
208
+ return bool(_KV_TOKEN and _KV_BASE)
209
+
210
+
211
+ def _kv_get(key: str) -> dict | None:
212
+ req = urllib.request.Request(
213
+ f"{_KV_BASE}/v1/kv/{key}",
214
+ headers={"Authorization": f"Bearer {_KV_TOKEN}"},
215
+ )
216
+ try:
217
+ with urllib.request.urlopen(req) as r:
218
+ return json.loads(r.read())["value"]
219
+ except urllib.error.HTTPError as exc:
220
+ if exc.code == 404:
221
+ return None
222
+ raise
223
+
224
+
225
+ def _kv_set(key: str, value: dict) -> None:
226
+ req = urllib.request.Request(
227
+ f"{_KV_BASE}/v1/kv/{key}",
228
+ data=json.dumps(value).encode(),
229
+ headers={
230
+ "Authorization": f"Bearer {_KV_TOKEN}",
231
+ "Content-Type": "application/json",
232
+ },
233
+ method="PUT",
234
+ )
235
+ with urllib.request.urlopen(req) as r:
236
+ r.read()
237
+
238
+
239
+ def _state_file_path() -> str:
240
+ """Derive a persistent storage path from WORKSPACE_BASE (file fallback only)."""
209
241
  workspace_base = os.environ.get("WORKSPACE_BASE", "")
210
242
  event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
211
243
  automation_id = event_payload.get("automation_id", "default")
@@ -220,22 +252,42 @@ def _state_file_path() -> str:
220
252
  return os.path.join(state_dir, f"slack_poller_{automation_id}.json")
221
253
 
222
254
 
223
- def load_state(path: str) -> dict:
224
- if os.path.exists(path):
225
- return json.load(open(path))
255
+ def _default_state() -> dict:
226
256
  return {
227
257
  "version": 1,
228
258
  "bot_user_id": None,
229
- "last_poll": {}, # channel_id → float timestamp string
230
- "conversations": {}, # conv_key → ConversationRecord (see schema docs)
231
- "bot_message_ts": [], # ts strings of messages posted by this bot
232
- "processed_ts": [], # ts strings of messages already handled (dedup)
259
+ "last_poll": {},
260
+ "conversations": {},
261
+ "bot_message_ts": [],
262
+ "processed_ts": [],
233
263
  }
234
264
 
235
265
 
236
- def save_state(path: str, state: dict) -> None:
266
+ def load_state() -> dict:
267
+ if _kv_available():
268
+ data = _kv_get(_STATE_KEY)
269
+ if data is not None:
270
+ print("State loaded from KV store")
271
+ return data
272
+ return _default_state()
273
+ path = _state_file_path()
274
+ if os.path.exists(path):
275
+ try:
276
+ return json.load(open(path))
277
+ except Exception as exc:
278
+ print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
279
+ return _default_state()
280
+
281
+
282
+ def save_state(state: dict) -> None:
283
+ if _kv_available():
284
+ _kv_set(_STATE_KEY, state)
285
+ print("State saved to KV store")
286
+ return
287
+ path = _state_file_path()
237
288
  with open(path, "w") as f:
238
289
  json.dump(state, f, indent=2)
290
+ print(f"State saved to {path}")
239
291
 
240
292
 
241
293
  # ── Slack API helpers ──────────────────────────────────────────────────────────
@@ -311,7 +363,12 @@ def add_reaction(token: str, channel: str, ts: str, emoji: str = "eyes") -> None
311
363
 
312
364
  def post_message(token: str, channel: str, text: str, thread_ts: str | None = None) -> str:
313
365
  """Post a Slack message and return its timestamp."""
314
- body: dict = {"channel": channel, "text": text}
366
+ body: dict = {
367
+ "channel": channel,
368
+ "markdown_text": text,
369
+ "unfurl_links": False,
370
+ "unfurl_media": False,
371
+ }
315
372
  if thread_ts:
316
373
  body["thread_ts"] = thread_ts
317
374
  return slack_post(token, "chat.postMessage", body).get("ts", "")
@@ -665,16 +722,19 @@ def _gather_channel_context(
665
722
  return context_lines
666
723
 
667
724
 
668
- def _expire_inactive_thread_watches(active_convs: dict[str, dict], now: float) -> None:
669
- for conv_key, rec in active_convs.items():
670
- if rec.get("status") != "watching":
671
- continue
672
- watch_until = float(rec.get("watch_until") or 0)
673
- if watch_until and now >= watch_until:
674
- rec["status"] = "closed"
675
- rec["closed_reason"] = "followup_watch_expired"
676
- rec["closed_at"] = now
677
- print(f" Follow-up watch expired for {conv_key}")
725
+ def _close_thread_watch(rec: dict, conv_key: str, now: float) -> None:
726
+ rec["status"] = "closed"
727
+ rec["closed_reason"] = "followup_watch_expired"
728
+ rec["closed_at"] = now
729
+ print(f" Follow-up watch expired for {conv_key}")
730
+
731
+
732
+ def _next_reply_poll_at(rec: dict, now: float, delay: int) -> float:
733
+ next_poll_at = now + delay
734
+ watch_until = float(rec.get("watch_until") or 0)
735
+ if rec.get("status") == "watching" and watch_until:
736
+ next_poll_at = min(next_poll_at, watch_until)
737
+ return next_poll_at
678
738
 
679
739
 
680
740
  def _poll_due_thread_replies(
@@ -684,12 +744,14 @@ def _poll_due_thread_replies(
684
744
  bot_message_ts: list[str],
685
745
  ) -> list[tuple[str, dict]]:
686
746
  now = time.time()
687
- _expire_inactive_thread_watches(active_convs, now)
688
747
  due: list[tuple[float, str, dict]] = []
689
748
  for conv_key, rec in active_convs.items():
690
749
  if rec.get("status") not in {"active", "watching"}:
691
750
  continue
692
751
  next_poll = float(rec.get("next_reply_poll_at") or 0)
752
+ watch_until = float(rec.get("watch_until") or 0)
753
+ if rec.get("status") == "watching" and watch_until and now >= watch_until:
754
+ next_poll = min(next_poll or watch_until, watch_until)
693
755
  if next_poll <= now:
694
756
  due.append((next_poll, conv_key, rec))
695
757
 
@@ -698,6 +760,8 @@ def _poll_due_thread_replies(
698
760
  cid = rec["channel_id"]
699
761
  thread_ts = rec["thread_ts"]
700
762
  oldest = rec.get("last_seen_reply_ts") or thread_ts
763
+ watch_until = float(rec.get("watch_until") or 0)
764
+ watch_expired = rec.get("status") == "watching" and watch_until and now >= watch_until
701
765
  try:
702
766
  replies = thread_replies(slack_token, cid, thread_ts, oldest)
703
767
  except Exception as exc:
@@ -710,11 +774,14 @@ def _poll_due_thread_replies(
710
774
  retry_after = max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, int(raw))
711
775
  except ValueError:
712
776
  pass
713
- rec["next_reply_poll_at"] = now + retry_after
714
- rec["reply_poll_backoff_seconds"] = min(
715
- THREAD_REPLY_MAX_BACKOFF_SECONDS,
716
- max(retry_after, int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)),
717
- )
777
+ if watch_expired:
778
+ _close_thread_watch(rec, conv_key, now)
779
+ else:
780
+ rec["next_reply_poll_at"] = _next_reply_poll_at(rec, now, retry_after)
781
+ rec["reply_poll_backoff_seconds"] = min(
782
+ THREAD_REPLY_MAX_BACKOFF_SECONDS,
783
+ max(retry_after, int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)),
784
+ )
718
785
  print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}")
719
786
  continue
720
787
 
@@ -733,6 +800,10 @@ def _poll_due_thread_replies(
733
800
  for r in triggered_replies:
734
801
  reply_messages.append((cid, r))
735
802
  print(f" {conv_key}: {len(triggered_replies)} triggered follow-up reply/replies")
803
+ elif watch_expired:
804
+ if human_replies:
805
+ print(f" {conv_key}: ignored {len(human_replies)} follow-up reply/replies without trigger")
806
+ _close_thread_watch(rec, conv_key, now)
736
807
  else:
737
808
  if human_replies:
738
809
  print(f" {conv_key}: ignored {len(human_replies)} follow-up reply/replies without trigger")
@@ -742,8 +813,9 @@ def _poll_due_thread_replies(
742
813
  max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, current * THREAD_REPLY_BACKOFF_MULTIPLIER),
743
814
  )
744
815
  rec["reply_poll_backoff_seconds"] = next_backoff
745
- rec["next_reply_poll_at"] = now + next_backoff
746
- print(f" {conv_key}: no follow-ups; next reply poll in {next_backoff}s")
816
+ rec["next_reply_poll_at"] = _next_reply_poll_at(rec, now, next_backoff)
817
+ next_delay = max(0, int(rec["next_reply_poll_at"] - now))
818
+ print(f" {conv_key}: no follow-ups; next reply poll in {next_delay}s")
747
819
 
748
820
  return reply_messages
749
821
 
@@ -951,8 +1023,7 @@ def _check_conversation_completion(
951
1023
 
952
1024
  def main() -> str | None:
953
1025
  """Run one polling cycle. Returns the last conversation ID created, if any."""
954
- state_path = _state_file_path()
955
- state = load_state(state_path)
1026
+ state = load_state()
956
1027
 
957
1028
  agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
958
1029
  api_key = _get_env_key()
@@ -1139,8 +1210,7 @@ def main() -> str | None:
1139
1210
  state["processed_ts"] = processed_list[-MAX_PROCESSED_TS:]
1140
1211
 
1141
1212
  state["conversations"] = active_convs
1142
- save_state(state_path, state)
1143
- print(f"State saved to {state_path}")
1213
+ save_state(state)
1144
1214
  return last_conversation_id
1145
1215
 
1146
1216
 
@@ -0,0 +1,47 @@
1
+ """Strict JSON-Schema validation for integrations/catalog/*.json.
2
+
3
+ Every catalog entry must validate against integrations/catalog.schema.json.
4
+ The schema encodes the catalog contract from integrations/index.d.ts plus the
5
+ cross-field connectability rules (e.g. an oauth2 HTTP option must carry an
6
+ authorizationUrl + tokenUrl; an HTTP option must ship an openApiUrl) so the
7
+ class of "entry looks shaped but cannot actually be connected" regressions the
8
+ AI reviewer kept catching is now caught in CI.
9
+ """
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+ from jsonschema import Draft202012Validator
16
+
17
+ ROOT = Path(__file__).resolve().parents[1]
18
+ SCHEMA_PATH = ROOT / "integrations" / "catalog.schema.json"
19
+ CATALOG_DIR = ROOT / "integrations" / "catalog"
20
+
21
+ _SCHEMA = json.loads(SCHEMA_PATH.read_text())
22
+ VALIDATOR = Draft202012Validator(_SCHEMA)
23
+
24
+
25
+ def _catalog_entries():
26
+ for entry_path in sorted(CATALOG_DIR.glob("*.json")):
27
+ yield pytest.param(
28
+ entry_path,
29
+ id=entry_path.stem,
30
+ )
31
+
32
+
33
+ @pytest.mark.parametrize("entry_path", list(_catalog_entries()))
34
+ def test_catalog_entry_validates_against_schema(entry_path: Path) -> None:
35
+ entry = json.loads(entry_path.read_text())
36
+ errors = sorted(VALIDATOR.iter_errors(entry), key=lambda e: list(e.path))
37
+ if errors:
38
+ rendered = "\n".join(
39
+ f" - at {'/'.join(map(str, e.path)) or '<root>'}: {e.message}"
40
+ for e in errors
41
+ )
42
+ pytest.fail(f"{entry_path.stem} failed schema validation:\n{rendered}")
43
+
44
+
45
+ def test_schema_file_is_valid_draft_2020_12() -> None:
46
+ """The schema itself must be a valid Draft 2020-12 document."""
47
+ Draft202012Validator.check_schema(_SCHEMA)
@@ -40,10 +40,10 @@ def test_catalog_entries_have_required_fields():
40
40
  assert entry["id"]
41
41
  assert entry["name"]
42
42
  assert entry["description"]
43
- assert entry["kind"] in {"mcp", "http"}
44
- assert entry["iconBg"]
43
+ # iconBg/iconColor are optional UI styling hints (OAuth-only entries may
44
+ # ship without a bespoke icon background).
45
+ assert "iconBg" not in entry or entry["iconBg"]
45
46
  assert entry["connectionOptions"]
46
- assert entry["defaultConnectionOptionId"]
47
47
  for option in entry["connectionOptions"]:
48
48
  assert option["id"]
49
49
  assert option["provider"] in {"mcp", "http"}
@@ -72,6 +72,24 @@ def test_catalog_entries_have_required_fields():
72
72
  assert isinstance(entry["estimatedSetupMinutes"], int)
73
73
 
74
74
 
75
+ def test_remote_no_auth_mcp_entries_are_intentionally_public():
76
+ public_remote_mcp_ids = {"cloudflare-docs", "deepwiki", "huggingface"}
77
+
78
+ actual = set()
79
+ for entry in load_catalog_entries("integrations/catalog"):
80
+ for option in entry["connectionOptions"]:
81
+ transport = option.get("transport", {})
82
+ if (
83
+ option["provider"] == "mcp"
84
+ and option["auth"]["strategy"] == "none"
85
+ and transport.get("url", "").startswith("https://")
86
+ ):
87
+ actual.add(entry["id"])
88
+ assert transport["kind"] == "shttp"
89
+
90
+ assert actual == public_remote_mcp_ids
91
+
92
+
75
93
  def test_credential_fields_have_helper_text_and_link():
76
94
  """All password fields must have helperText plus a link (either a helperLink field or a
77
95
  markdown link embedded in helperText) so users know how to get credentials."""
@@ -107,3 +125,29 @@ def test_node_package_exports_catalogs():
107
125
  if (!AUTOMATION_CATALOG.some((entry) => entry.id === 'github-pr-reviewer')) process.exit(1);
108
126
  """
109
127
  subprocess.run(["node", "--input-type=module", "-e", script], cwd=ROOT, check=True)
128
+
129
+
130
+ def test_no_default_tool_in_catalog():
131
+ """The defaultTool field was removed; tools now come from MCP tools/list or
132
+ HTTP OpenAPI regeneration. No connection option may carry it."""
133
+ offenders = []
134
+ for entry in load_catalog_entries("integrations/catalog"):
135
+ for option in entry["connectionOptions"]:
136
+ http_cfg = option.get("http", {})
137
+ if "defaultTool" in http_cfg:
138
+ offenders.append(f"{entry['id']}.{option['id']}")
139
+ assert not offenders, f"defaultTool still present in: {offenders}"
140
+
141
+
142
+ def test_http_connectors_have_openapi_url():
143
+ """An HTTP connector without an openApiUrl has no tool-discovery path once
144
+ defaultTool is gone, so every HTTP option must ship an openApiUrl."""
145
+ offenders = []
146
+ for entry in load_catalog_entries("integrations/catalog"):
147
+ for option in entry["connectionOptions"]:
148
+ if option["provider"] == "http":
149
+ http_cfg = option.get("http", {})
150
+ url = http_cfg.get("openApiUrl")
151
+ if not url or not url.startswith("https://"):
152
+ offenders.append(f"{entry['id']}.{option['id']}")
153
+ assert not offenders, f"HTTP options missing openApiUrl: {offenders}"
@@ -0,0 +1,252 @@
1
+ """Assert integration catalog files drive the generated JS/Python assets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import openhands_extensions
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ CATALOG_DIR = ROOT / "integrations" / "catalog"
13
+ CATALOG_INDEX = ROOT / "integrations" / "catalog-index.js"
14
+ AGGREGATE_ASSETS = [
15
+ ROOT / "integrations" / "integration-catalog.json",
16
+ ROOT / "python" / "openhands_extensions" / "integration-catalog.json",
17
+ ]
18
+
19
+
20
+ def _catalog_files() -> dict[str, dict]:
21
+ return {p.stem: json.loads(p.read_text()) for p in CATALOG_DIR.glob("*.json")}
22
+
23
+
24
+ def _catalog_entries() -> list[dict]:
25
+ entries = list(_catalog_files().values())
26
+ return sorted(entries, key=lambda entry: (-(entry.get("popularityRank") if entry.get("popularityRank") is not None else -1), entry["id"]))
27
+
28
+
29
+ def _supports_mcp(entry: dict) -> bool:
30
+ return any(option.get("provider") == "mcp" for option in entry["connectionOptions"])
31
+
32
+
33
+ def _supports_oauth(entry: dict) -> bool:
34
+ return any(option.get("auth", {}).get("strategy") == "oauth2" for option in entry["connectionOptions"])
35
+
36
+
37
+
38
+ def test_catalog_directory_is_hand_authored_source_of_truth() -> None:
39
+ for integration_id, entry in _catalog_files().items():
40
+ assert entry["id"] == integration_id
41
+ assert "supportsMcp" not in entry
42
+ assert "supportsOauth" not in entry
43
+ assert "oauthProvider" not in entry
44
+ assert "registrationDefaults" not in entry
45
+ assert "managedConnectorSlug" not in entry
46
+ assert "authStrategy" not in entry
47
+ assert "defaultConnectionOptionId" not in entry
48
+ assert "kind" not in entry
49
+ assert "catalogStatus" not in entry
50
+ assert "availability" not in entry
51
+ assert "runtimeAvailability" not in entry
52
+
53
+
54
+ def test_no_aggregate_catalog_json_exists() -> None:
55
+ for asset in AGGREGATE_ASSETS:
56
+ assert not asset.exists()
57
+
58
+
59
+ def test_generated_js_index_references_catalog_directory() -> None:
60
+ body = CATALOG_INDEX.read_text()
61
+ for file in sorted(path.name for path in CATALOG_DIR.glob("*.json")):
62
+ assert f"./catalog/{file}" in body
63
+ assert "integration-catalog.json" not in body
64
+
65
+
66
+ def test_python_snapshot_is_built_from_catalog_directory() -> None:
67
+ assert openhands_extensions.INTEGRATION_CATALOG_SNAPSHOT == {
68
+ "integrations": _catalog_entries()
69
+ }
70
+
71
+
72
+ def test_python_list_integration_catalog_returns_raw_entries() -> None:
73
+ entries = openhands_extensions.list_integration_catalog()
74
+ assert entries == _catalog_entries()
75
+ for entry in entries:
76
+ assert "supportsMcp" not in entry
77
+ assert "supportsOauth" not in entry
78
+ assert "registrationDefaults" not in entry
79
+ assert "managedConnectorSlug" not in entry
80
+ assert "authStrategy" not in entry
81
+ assert "defaultConnectionOptionId" not in entry
82
+ assert "catalogStatus" not in entry
83
+ assert "availability" not in entry
84
+ assert "runtimeAvailability" not in entry
85
+
86
+
87
+ def test_logo_metadata_is_serializable_and_language_agnostic() -> None:
88
+ entries = openhands_extensions.list_integration_catalog()
89
+ with_logo = [entry for entry in entries if entry.get("logoUrl")]
90
+ assert with_logo
91
+ assert any(entry["id"].startswith("cloudflare-") for entry in with_logo)
92
+ for entry in with_logo:
93
+ assert isinstance(entry["logoUrl"], str)
94
+ assert entry["logoUrl"].startswith("https://")
95
+ assert "react" not in entry["logoUrl"].lower()
96
+
97
+
98
+ def test_get_integration_catalog_entry_round_trip() -> None:
99
+ sample_id = openhands_extensions.list_integration_catalog()[0]["id"]
100
+ sample = openhands_extensions.get_integration_catalog_entry(sample_id)
101
+ assert sample is not None
102
+ assert sample == next(
103
+ entry
104
+ for entry in openhands_extensions.list_integration_catalog()
105
+ if entry["id"] == sample_id
106
+ )
107
+ assert openhands_extensions.get_integration_catalog_entry("nope") is None
108
+
109
+
110
+ def _js_call(expr: str) -> str:
111
+ result = subprocess.run(
112
+ ["node", "--input-type=module", "-e", expr],
113
+ capture_output=True,
114
+ text=True,
115
+ check=True,
116
+ cwd=ROOT,
117
+ )
118
+ return result.stdout
119
+
120
+
121
+ def test_js_reads_the_same_catalog_as_python() -> None:
122
+ integrations = _js_call(
123
+ "import { listIntegrationCatalog } from './integrations/index.js';\n"
124
+ "process.stdout.write(JSON.stringify(listIntegrationCatalog()));"
125
+ )
126
+ assert json.loads(integrations) == openhands_extensions.list_integration_catalog()
127
+
128
+ sample_id = openhands_extensions.list_integration_catalog()[0]["id"]
129
+ sample = _js_call(
130
+ "import { getIntegrationCatalogEntry } from './integrations/index.js';\n"
131
+ f"process.stdout.write(JSON.stringify(getIntegrationCatalogEntry({json.dumps(sample_id)})));"
132
+ )
133
+ assert json.loads(sample) == openhands_extensions.get_integration_catalog_entry(sample_id)
134
+
135
+
136
+ def _js_filter(mcp, oauth) -> list[str]:
137
+ expr = (
138
+ "import { listIntegrationCatalog } from './integrations/index.js';\n"
139
+ f"const f = listIntegrationCatalog({{ mcp: {mcp}, oauth: {oauth} }});\n"
140
+ "process.stdout.write(JSON.stringify(f.map((e) => e.id)));"
141
+ )
142
+ return json.loads(_js_call(expr))
143
+
144
+
145
+ def test_filter_mcp_only() -> None:
146
+ py_ids = {e["id"] for e in openhands_extensions.list_integration_catalog(mcp=True)}
147
+ js_ids = set(_js_filter("true", "undefined"))
148
+ all_mcp = {
149
+ e["id"]
150
+ for e in openhands_extensions.list_integration_catalog()
151
+ if _supports_mcp(e)
152
+ }
153
+ assert py_ids == js_ids == all_mcp
154
+ assert "filesystem" in py_ids
155
+
156
+
157
+ def test_filter_oauth_only() -> None:
158
+ py_ids = {e["id"] for e in openhands_extensions.list_integration_catalog(oauth=True)}
159
+ js_ids = set(_js_filter("undefined", "true"))
160
+ all_oauth = {
161
+ e["id"]
162
+ for e in openhands_extensions.list_integration_catalog()
163
+ if _supports_oauth(e)
164
+ }
165
+ assert py_ids == js_ids == all_oauth
166
+ assert py_ids
167
+
168
+
169
+ def test_filter_oauth_not_mcp() -> None:
170
+ py_ids = {
171
+ e["id"] for e in openhands_extensions.list_integration_catalog(oauth=True, mcp=False)
172
+ }
173
+ js_ids = set(_js_filter("false", "true"))
174
+ expected = {
175
+ e["id"]
176
+ for e in openhands_extensions.list_integration_catalog()
177
+ if _supports_oauth(e) and not _supports_mcp(e)
178
+ }
179
+ assert py_ids == js_ids == expected
180
+
181
+
182
+ def test_filter_none_returns_all() -> None:
183
+ assert len(openhands_extensions.list_integration_catalog()) == len(
184
+ openhands_extensions.list_integration_catalog(mcp=None, oauth=None)
185
+ )
186
+
187
+
188
+ def test_accessors_return_independent_copies() -> None:
189
+ catalog_a = openhands_extensions.list_integration_catalog()
190
+ catalog_b = openhands_extensions.list_integration_catalog()
191
+ assert catalog_a == catalog_b
192
+ assert catalog_a is not catalog_b
193
+ catalog_a[0]["__mutated"] = True
194
+ assert "__mutated" not in openhands_extensions.list_integration_catalog()[0]
195
+
196
+ sample_id = openhands_extensions.list_integration_catalog()[0]["id"]
197
+ sample = openhands_extensions.get_integration_catalog_entry(sample_id)
198
+ assert sample is not None
199
+ sample["__mutated"] = True
200
+ assert "__mutated" not in openhands_extensions.get_integration_catalog_entry(sample_id)
201
+
202
+ snapshot = openhands_extensions.INTEGRATION_CATALOG_SNAPSHOT
203
+ snapshot["integrations"][0]["__mutated"] = True
204
+ assert "__mutated" not in openhands_extensions.list_integration_catalog()[0]
205
+
206
+
207
+ def test_js_accessors_return_independent_copies() -> None:
208
+ sample_id = openhands_extensions.list_integration_catalog()[0]["id"]
209
+ expr = f"""
210
+ import {{
211
+ INTEGRATION_CATALOG,
212
+ getIntegrationCatalogEntry,
213
+ listIntegrationCatalog,
214
+ }} from './integrations/index.js';
215
+ const sampleId = {json.dumps(sample_id)};
216
+ const first = listIntegrationCatalog();
217
+ const second = listIntegrationCatalog();
218
+ first[0].__mutated = true;
219
+ const entry = getIntegrationCatalogEntry(sampleId);
220
+ entry.__mutated = true;
221
+ INTEGRATION_CATALOG[0].__mutated = true;
222
+ process.stdout.write(JSON.stringify({{
223
+ listsAreIndependent:
224
+ !('__mutated' in second[0]) &&
225
+ !('__mutated' in listIntegrationCatalog()[0]),
226
+ entryIsIndependent: !('__mutated' in getIntegrationCatalogEntry(sampleId)),
227
+ }}));
228
+ """
229
+ result = json.loads(_js_call(expr))
230
+ assert result == {"listsAreIndependent": True, "entryIsIndependent": True}
231
+
232
+
233
+ def test_oauth_config_lives_on_connection_options() -> None:
234
+ oauth_entries = [
235
+ entry
236
+ for entry in openhands_extensions.list_integration_catalog()
237
+ if _supports_oauth(entry)
238
+ ]
239
+ assert oauth_entries
240
+ for entry in oauth_entries:
241
+ assert "registrationDefaults" not in entry
242
+ oauth_options = [
243
+ option
244
+ for option in entry["connectionOptions"]
245
+ if option.get("auth", {}).get("strategy") == "oauth2"
246
+ ]
247
+ assert oauth_options
248
+ for option in oauth_options:
249
+ assert "registrationDefaults" not in option
250
+ oauth = option["auth"].get("oauth")
251
+ if oauth is not None:
252
+ assert isinstance(oauth, dict)