@openhands/extensions 0.10.0 → 0.12.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 (54) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/AGENTS.md +2 -1
  3. package/MIGRATION.md +31 -0
  4. package/README.md +12 -9
  5. package/automations/README.md +251 -8
  6. package/automations/catalog/github-pr-reviewer/manifest.json +82 -0
  7. package/automations/catalog/github-repo-monitor/manifest.json +82 -0
  8. package/automations/catalog/incident-retrospective-drafter/manifest.json +100 -0
  9. package/automations/catalog/{jira-issue-to-pr.json → jira-issue-to-pr/manifest.json} +10 -5
  10. package/automations/catalog/{linear-triage-assistant.json → linear-triage-assistant/manifest.json} +8 -4
  11. package/automations/catalog/{research-brief-writer.json → research-brief-writer/manifest.json} +11 -5
  12. package/automations/catalog/{slack-channel-monitor.json → slack-channel-monitor/manifest.json} +7 -2
  13. package/automations/catalog/{slack-standup-digest.json → slack-standup-digest/manifest.json} +8 -5
  14. package/automations/catalog-index.js +23 -0
  15. package/automations/catalog.schema.json +273 -0
  16. package/automations/index.d.ts +111 -3
  17. package/automations/index.js +21 -18
  18. package/index.d.ts +16 -1
  19. package/integrations/catalog/atlassian-rovo.json +44 -0
  20. package/integrations/catalog/datadog.json +3 -3
  21. package/integrations/catalog/github.json +1 -1
  22. package/integrations/catalog/linear.json +1 -1
  23. package/integrations/catalog/slack.json +2 -2
  24. package/integrations/catalog-index.js +30 -28
  25. package/integrations/index.d.ts +1 -1
  26. package/marketplaces/openhands-extensions.json +9 -0
  27. package/package.json +6 -3
  28. package/pyproject.toml +1 -1
  29. package/python/openhands_extensions/__init__.py +0 -4
  30. package/python/openhands_extensions/_version.py +3 -2
  31. package/python/openhands_extensions/integrations.py +8 -30
  32. package/scripts/build-automation-catalog.mjs +51 -0
  33. package/skills/bitbucket/SKILL.md +0 -1
  34. package/skills/github/SKILL.md +0 -1
  35. package/skills/gitlab/SKILL.md +0 -1
  36. package/skills/index.js +11 -8
  37. package/skills/iterate/README.md +4 -1
  38. package/skills/iterate/SKILL.md +22 -2
  39. package/skills/openhands-sdk/SKILL.md +2 -0
  40. package/skills/technical-writing/.plugin/plugin.json +15 -0
  41. package/skills/technical-writing/README.md +14 -0
  42. package/skills/technical-writing/SKILL.md +106 -0
  43. package/tests/fixtures/automations/capabilities.json +92 -0
  44. package/tests/fixtures/automations/github-pr-reviewer.json +251 -0
  45. package/tests/fixtures/automations/github-repo-monitor.json +180 -0
  46. package/tests/fixtures/automations/incident-retrospective-drafter.json +91 -0
  47. package/tests/test_automation_setup.py +526 -0
  48. package/tests/test_catalogs.py +104 -12
  49. package/tests/test_deprecations.py +39 -4
  50. package/tests/test_integration_catalog_in_sync.py +50 -60
  51. package/tests/test_live_integration_smoke.py +366 -0
  52. package/automations/catalog/github-pr-reviewer.json +0 -13
  53. package/automations/catalog/github-repo-monitor.json +0 -11
  54. package/automations/catalog/incident-retrospective-drafter.json +0 -15
@@ -0,0 +1,526 @@
1
+ """Contract tests for the `setup` block in automations/catalog/*/manifest.json.
2
+
3
+ Three things are checked here that nothing else can catch:
4
+
5
+ 1. Every catalog entry validates against automations/catalog.schema.json, the way
6
+ integration catalog entries validate against integrations/catalog.schema.json.
7
+ 2. Running a fixture's form values through an entry's declared mapping reproduces
8
+ the fixture's request body, byte for byte.
9
+ 3. The parts of that request an entry no longer declares - the preflight body and
10
+ the payload-path-to-field mapping - still come out right when derived. An entry
11
+ states only what varies between automations; everything else is the same code
12
+ for every automation, and these tests are where that code is pinned.
13
+
14
+ (2) is the point of the fixtures. Form shape and API shape genuinely differ, the
15
+ create endpoint is declared extra="forbid", and a mapping mistake is a 422 that
16
+ only shows up at creation time. Pinning the mapping to a worked example is what
17
+ keeps OpenHands/agent-canvas and OpenHands/automation building against the same
18
+ contract.
19
+ """
20
+
21
+ import json
22
+ import re
23
+ import subprocess
24
+ from copy import deepcopy
25
+ from pathlib import Path
26
+
27
+ import pytest
28
+ from jsonschema import Draft202012Validator
29
+
30
+
31
+ ROOT = Path(__file__).resolve().parents[1]
32
+ SCHEMA_PATH = ROOT / "automations" / "catalog.schema.json"
33
+ CATALOG_DIR = ROOT / "automations" / "catalog"
34
+ CATALOG_INDEX = ROOT / "automations" / "catalog-index.js"
35
+ BUILD_SCRIPT = ROOT / "scripts" / "build-automation-catalog.mjs"
36
+ FIXTURE_DIR = ROOT / "tests" / "fixtures" / "automations"
37
+ CAPABILITIES_PATH = FIXTURE_DIR / "capabilities.json"
38
+
39
+ # The standardized parts of a direct setup, identical for every automation and
40
+ # therefore not declared in any entry.
41
+ CREATE_PATH = "/v1/preset/prompt"
42
+ PREFLIGHT_PATH = "/v1/validate"
43
+
44
+ # The trigger properties the service accepts, per kind. A form field named
45
+ # after one of them fills it; the rest are inputs to the declared filter.
46
+ TRIGGER_PROPERTIES = {"cron": ("schedule", "timezone"), "event": ("on",)}
47
+
48
+ _SCHEMA = json.loads(SCHEMA_PATH.read_text())
49
+ VALIDATOR = Draft202012Validator(_SCHEMA)
50
+
51
+ PLACEHOLDER_RE = re.compile(r"\{\{([a-z]+)\.([A-Za-z0-9_.]+)\}\}")
52
+
53
+ # Anything that looks like a real credential rather than a credential's name.
54
+ CREDENTIAL_VALUE_RE = re.compile(
55
+ r"(gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9]{20,})"
56
+ )
57
+
58
+
59
+ def _load(path: Path) -> dict:
60
+ return json.loads(path.read_text())
61
+
62
+
63
+ def _manifests():
64
+ return sorted(CATALOG_DIR.glob("*/manifest.json"))
65
+
66
+
67
+ def _catalog_paths():
68
+ for path in _manifests():
69
+ yield pytest.param(path, id=path.parent.name)
70
+
71
+
72
+ def _setup_paths():
73
+ """Only the entries that ship a setup block. It is optional by design."""
74
+ for path in _manifests():
75
+ if "setup" in _load(path):
76
+ yield pytest.param(path, id=path.parent.name)
77
+
78
+
79
+ def _fixture_bundles():
80
+ for path in sorted(FIXTURE_DIR.glob("*.json")):
81
+ if path == CAPABILITIES_PATH:
82
+ continue
83
+ yield pytest.param(path, id=path.stem)
84
+
85
+
86
+ def _entry_for(bundle: dict) -> dict:
87
+ return _load(CATALOG_DIR / bundle["automationId"] / "manifest.json")
88
+
89
+
90
+ def _integration_catalog_ids() -> set[str]:
91
+ return {path.stem for path in (ROOT / "integrations" / "catalog").glob("*.json")}
92
+
93
+
94
+ def _resolve(namespace: str, key: str, context: dict):
95
+ """Resolve one {{namespace.key}} placeholder against the render context."""
96
+ if namespace not in context:
97
+ raise KeyError(f"unknown placeholder namespace: {namespace}")
98
+ value = context[namespace]
99
+ for part in key.split("."):
100
+ if not isinstance(value, dict) or part not in value:
101
+ raise KeyError(f"unresolved placeholder: {{{{{namespace}.{key}}}}}")
102
+ value = value[part]
103
+ return value
104
+
105
+
106
+ def _interpolate(node, context: dict):
107
+ """Apply placeholder substitution to a setup fragment."""
108
+ if isinstance(node, dict):
109
+ return {key: _interpolate(value, context) for key, value in node.items()}
110
+ if isinstance(node, list):
111
+ return [_interpolate(item, context) for item in node]
112
+ if not isinstance(node, str):
113
+ return node
114
+
115
+ whole = PLACEHOLDER_RE.fullmatch(node)
116
+ if whole:
117
+ return _resolve(whole.group(1), whole.group(2), context)
118
+ return PLACEHOLDER_RE.sub(
119
+ lambda match: str(_resolve(match.group(1), match.group(2), context)), node
120
+ )
121
+
122
+
123
+ def _context(entry: dict, form_values: dict) -> dict:
124
+ """`automation` resolves against the catalog entry the setup block sits in."""
125
+ return {"form": form_values, "automation": entry}
126
+
127
+
128
+ def _repo_picker(setup: dict) -> tuple[str | None, dict | None]:
129
+ for name, field in _fields(setup).items():
130
+ if field["type"] == "repo-picker":
131
+ return name, field
132
+ return None, None
133
+
134
+
135
+ def _render_payload(entry: dict, form_values: dict) -> dict:
136
+ """The create request body these form values produce.
137
+
138
+ No entry declares this. `name` comes from the entry, `repos` from the
139
+ repo-picker field and its provider, and `trigger` from the key and fields
140
+ under `form.triggers`. Only `prompt` and an event `filter` are declared,
141
+ because only they cannot be read off the form.
142
+ """
143
+ setup = entry["setup"]
144
+ context = _context(entry, form_values)
145
+ repo_name, repo_field = _repo_picker(setup)
146
+ repo = form_values.get(repo_name) if repo_name else None
147
+
148
+ body: dict = {
149
+ "name": f"{entry['name']} - {repo}" if repo else entry["name"],
150
+ "prompt": _interpolate(setup["prompt"], context),
151
+ }
152
+
153
+ if repo:
154
+ source = {"url": repo, "provider": repo_field["provider"]}
155
+ if "ref" in form_values:
156
+ source["ref"] = form_values["ref"]
157
+ body["repos"] = [source]
158
+
159
+ kind, trigger_fields = next(iter(setup["form"]["triggers"].items()))
160
+ trigger = {"type": kind}
161
+ # A field under a trigger kind fills the trigger property of the same name.
162
+ # Anything else there, such as a phrase to match, is an input to `filter`.
163
+ for name in trigger_fields:
164
+ if name in TRIGGER_PROPERTIES[kind]:
165
+ trigger[name] = form_values[name]
166
+ if kind == "event":
167
+ trigger["source"] = repo_field["provider"]
168
+ trigger["filter"] = _interpolate(setup["filter"], context)
169
+ body["trigger"] = trigger
170
+
171
+ return body
172
+
173
+
174
+ def _derive_preflight_body(entry: dict, form_values: dict) -> dict:
175
+ """The preflight body the host sends. The same shape for every automation,
176
+ so no entry declares it."""
177
+ return {
178
+ "automationId": entry["id"],
179
+ "endpoint": CREATE_PATH,
180
+ "draft": _render_payload(entry, form_values),
181
+ }
182
+
183
+
184
+ def _derive_error_map(entry: dict) -> dict[str, list[str]]:
185
+ """Which form fields built each payload path.
186
+
187
+ Preflight and the create endpoint reject a draft by payload path, and the
188
+ host has to turn that back into a highlighted input. Building the body with
189
+ each field standing in for its own value recovers the mapping exactly, so
190
+ an entry does not declare it.
191
+ """
192
+ mapping: dict[str, list[str]] = {}
193
+ if "prompt" not in entry["setup"]:
194
+ return mapping
195
+
196
+ stand_ins = {name: f"{{{{form.{name}}}}}" for name in _field_names(entry["setup"])}
197
+ template = _render_payload(entry, stand_ins)
198
+
199
+ def walk(node, path: str) -> None:
200
+ if isinstance(node, dict):
201
+ for key, value in node.items():
202
+ walk(value, f"{path}.{key}" if path else key)
203
+ elif isinstance(node, list):
204
+ for index, item in enumerate(node):
205
+ walk(item, f"{path}[{index}]")
206
+ elif isinstance(node, str):
207
+ names = [
208
+ key
209
+ for namespace, key in PLACEHOLDER_RE.findall(node)
210
+ if namespace == "form"
211
+ ]
212
+ if names:
213
+ mapping[path] = list(dict.fromkeys(names))
214
+
215
+ walk(template, "")
216
+ return mapping
217
+
218
+
219
+ def _payload_path_exists(payload, path: str) -> bool:
220
+ """Whether an error path such as `repos[0].ref` addresses the payload."""
221
+ node = payload
222
+ for segment in path.split("."):
223
+ name, _, indexes = segment.partition("[")
224
+ if not isinstance(node, dict) or name not in node:
225
+ return False
226
+ node = node[name]
227
+ for index in re.findall(r"\d+", indexes):
228
+ if not isinstance(node, list) or int(index) >= len(node):
229
+ return False
230
+ node = node[int(index)]
231
+ return True
232
+
233
+
234
+ def _fields(setup: dict) -> dict[str, dict]:
235
+ """Every input the form declares, keyed by name, whichever half it is in."""
236
+ fields = {}
237
+ for group in setup["form"].get("triggers", {}).values():
238
+ fields.update(group)
239
+ fields.update(setup["form"]["args"])
240
+ return fields
241
+
242
+
243
+ def _field_names(setup: dict) -> set[str]:
244
+ return set(_fields(setup))
245
+
246
+
247
+ def _join_loc(parts: list[str]) -> str:
248
+ path = ""
249
+ for part in parts:
250
+ if part.isdigit():
251
+ path += f"[{part}]"
252
+ else:
253
+ path += f".{part}" if path else part
254
+ return path
255
+
256
+
257
+ def _loc_to_payload_path(loc: list, payload) -> str:
258
+ """Turn a 422 `loc` into the payload path an error is keyed by.
259
+
260
+ FastAPI prefixes `body`, and Pydantic inserts the discriminated-union tag,
261
+ so an invalid cron arrives as ["body", "trigger", "cron", "schedule"] while
262
+ the payload path is `trigger.schedule`. Dropping the segment that does not
263
+ address the payload is what makes the error field-addressable.
264
+ """
265
+ parts = [str(part) for part in loc]
266
+ if parts and parts[0] == "body":
267
+ parts = parts[1:]
268
+
269
+ candidates = [parts] + [parts[:i] + parts[i + 1 :] for i in range(len(parts))]
270
+ for candidate in candidates:
271
+ path = _join_loc(candidate)
272
+ if _payload_path_exists(payload, path):
273
+ return path
274
+ return _join_loc(parts)
275
+
276
+
277
+ def _reported_fields(entry: dict, scenario: dict) -> dict[str, str]:
278
+ """Apply the derived error map to whatever rejected this scenario."""
279
+ setup = entry["setup"]
280
+ error_map = _derive_error_map(entry)
281
+ payload = (
282
+ _render_payload(entry, scenario["formValues"])
283
+ if "prompt" in setup and "formValues" in scenario
284
+ else {}
285
+ )
286
+
287
+ reported: dict[str, str] = {}
288
+
289
+ for error in scenario.get("localValidation", {}).get("errors", []):
290
+ reported[error["field"]] = error["message"]
291
+
292
+ for error in scenario.get("preflight", {}).get("response", {}).get("body", {}).get(
293
+ "errors", []
294
+ ):
295
+ for name in error_map.get(error["field"], [error["field"]]):
296
+ reported[name] = error["message"]
297
+
298
+ response = scenario.get("create", {}).get("response", {})
299
+ if response.get("status") == 422:
300
+ for detail in response["body"]["detail"]:
301
+ path = _loc_to_payload_path(detail["loc"], payload)
302
+ for name in error_map.get(path, [path]):
303
+ reported[name] = detail["msg"]
304
+
305
+ return reported
306
+
307
+
308
+ def _capabilities_satisfied(entry: dict, deployment: dict) -> bool:
309
+ """A deployment can run this automation when it offers every feature the
310
+ entry requires and every trigger kind the form configures."""
311
+ needed_features = set(entry["requires"].get("features", []))
312
+ if not needed_features.issubset(set(deployment.get("features", []))):
313
+ return False
314
+ needed_kinds = set(entry.get("setup", {}).get("form", {}).get("triggers", {}))
315
+ return needed_kinds.issubset(set(deployment.get("triggerKinds", [])))
316
+
317
+
318
+ def _iter_strings(node):
319
+ if isinstance(node, dict):
320
+ for key, value in node.items():
321
+ yield key
322
+ yield from _iter_strings(value)
323
+ elif isinstance(node, list):
324
+ for item in node:
325
+ yield from _iter_strings(item)
326
+ elif isinstance(node, str):
327
+ yield node
328
+
329
+
330
+ def _scenarios(kind: str):
331
+ """Every fixture scenario carrying the given block, as test params."""
332
+ for path in sorted(FIXTURE_DIR.glob("*.json")):
333
+ if path == CAPABILITIES_PATH:
334
+ continue
335
+ bundle = _load(path)
336
+ for scenario in bundle["scenarios"]:
337
+ if kind in scenario and scenario.get("matchesSetupPayload", True):
338
+ yield pytest.param(bundle, scenario, id=f"{path.stem}-{scenario['id']}")
339
+
340
+
341
+ @pytest.mark.parametrize("entry_path", list(_catalog_paths()))
342
+ def test_catalog_entry_validates_against_schema(entry_path: Path) -> None:
343
+ entry = _load(entry_path)
344
+
345
+ errors = sorted(VALIDATOR.iter_errors(entry), key=lambda e: list(e.path))
346
+
347
+ if errors:
348
+ rendered = "\n".join(
349
+ f" - at {'/'.join(map(str, error.path)) or '<root>'}: {error.message}"
350
+ for error in errors
351
+ )
352
+ pytest.fail(f"{entry_path.parent.name} failed schema validation:\n{rendered}")
353
+
354
+
355
+ def test_schema_file_is_valid_draft_2020_12() -> None:
356
+ Draft202012Validator.check_schema(_SCHEMA)
357
+
358
+
359
+ def test_schema_rejects_content_a_setup_block_must_never_carry() -> None:
360
+ """The format constraints are the trust boundary, so they are asserted here.
361
+
362
+ A setup block is data that tells the host what to render and what request to
363
+ build. These are the mutations that would turn it into code, an arbitrary
364
+ request, or a credential leak.
365
+ """
366
+ entry = _load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json")
367
+
368
+ rejected: list[tuple[str, dict]] = []
369
+
370
+ with_markup = deepcopy(entry)
371
+ with_markup["setup"]["form"]["args"]["repository"]["label"] = (
372
+ "Repository <script>steal()</script>"
373
+ )
374
+ rejected.append(("<script>steal()</script>", with_markup))
375
+
376
+ with_unknown_placeholder = deepcopy(entry)
377
+ with_unknown_placeholder["setup"]["prompt"] = "Use {{env.GITHUB_TOKEN}}"
378
+ rejected.append(("{{env.GITHUB_TOKEN}}", with_unknown_placeholder))
379
+
380
+ with_secret_value = deepcopy(entry)
381
+ with_secret_value["requires"]["integrations"]["github"]["value"] = (
382
+ "ghp_notarealtokenvalue00"
383
+ )
384
+ rejected.append(("value", with_secret_value))
385
+
386
+ with_repeated_identity = deepcopy(entry)
387
+ with_repeated_identity["setup"]["description"] = "a second description"
388
+ rejected.append(("description", with_repeated_identity))
389
+
390
+ for expected_fragment, invalid in rejected:
391
+ errors = list(VALIDATOR.iter_errors(invalid))
392
+ assert any(expected_fragment in error.message for error in errors), (
393
+ f"schema accepted an entry it must reject ({expected_fragment}): {errors}"
394
+ )
395
+
396
+
397
+ @pytest.mark.parametrize("entry_path", list(_setup_paths()))
398
+ def test_form_placeholders_reference_declared_fields(entry_path: Path) -> None:
399
+ """A {{form.x}} that names no field renders as an empty value at runtime."""
400
+ setup = _load(entry_path)["setup"]
401
+ fields = _field_names(setup)
402
+
403
+ referenced = {
404
+ key
405
+ for value in _iter_strings(setup)
406
+ for namespace, key in PLACEHOLDER_RE.findall(value)
407
+ if namespace == "form"
408
+ }
409
+
410
+ assert referenced - fields == set()
411
+
412
+
413
+ @pytest.mark.parametrize("entry_path", list(_setup_paths()))
414
+ def test_select_fields_offer_options(entry_path: Path) -> None:
415
+ """A select without options is an empty dropdown the user cannot get past.
416
+ A field whose options come from the deployment declares a semantic type
417
+ instead, so the host knows to fill it."""
418
+ setup = _load(entry_path)["setup"]
419
+
420
+ unusable = [
421
+ name
422
+ for name, field in _fields(setup).items()
423
+ if field["type"] == "select" and "options" not in field
424
+ ]
425
+
426
+ assert unusable == []
427
+
428
+
429
+ @pytest.mark.parametrize(("bundle", "scenario"), list(_scenarios("create")))
430
+ def test_derived_body_reproduces_the_create_request(
431
+ bundle: dict, scenario: dict
432
+ ) -> None:
433
+ """An entry declares the prompt and, for an event trigger, the filter. The
434
+ name, the repository and the trigger come out of the form, and this is
435
+ where that reconstruction is pinned to a body the service accepts."""
436
+ entry = _entry_for(bundle)
437
+
438
+ derived = _render_payload(entry, scenario["formValues"])
439
+
440
+ assert derived == scenario["create"]["request"]["body"]
441
+ assert scenario["create"]["request"]["path"] == CREATE_PATH
442
+
443
+
444
+ @pytest.mark.parametrize(("bundle", "scenario"), list(_scenarios("preflight")))
445
+ def test_derived_preflight_body_reproduces_the_preflight_request(
446
+ bundle: dict, scenario: dict
447
+ ) -> None:
448
+ """No entry declares the preflight call any more. It has to come out of the
449
+ entry id and the payload, and this is where that is pinned."""
450
+ entry = _entry_for(bundle)
451
+
452
+ derived = _derive_preflight_body(entry, scenario["formValues"])
453
+
454
+ assert derived == scenario["preflight"]["request"]["body"]
455
+ assert scenario["preflight"]["request"]["path"] == PREFLIGHT_PATH
456
+
457
+
458
+ @pytest.mark.parametrize(("bundle", "scenario"), list(_scenarios("conversation")))
459
+ def test_seed_message_reproduces_the_conversation_request(
460
+ bundle: dict, scenario: dict
461
+ ) -> None:
462
+ entry = _entry_for(bundle)
463
+
464
+ rendered = _interpolate(
465
+ entry["setup"]["message"], _context(entry, scenario["formValues"])
466
+ )
467
+
468
+ assert rendered == scenario["conversation"]["request"]["message"]
469
+
470
+
471
+ @pytest.mark.parametrize(("bundle", "scenario"), list(_scenarios("expectedFieldErrors")))
472
+ def test_derived_error_map_turns_rejections_into_highlighted_inputs(
473
+ bundle: dict, scenario: dict
474
+ ) -> None:
475
+ """The whole two-tier validation design rests on this translation: whoever
476
+ rejects the draft, the user must end up looking at the input at fault. The
477
+ mapping is derived from the payload, so it cannot drift from it."""
478
+ entry = _entry_for(bundle)
479
+
480
+ reported = _reported_fields(entry, scenario)
481
+
482
+ assert set(reported) <= _field_names(entry["setup"])
483
+ assert reported == scenario["expectedFieldErrors"]
484
+
485
+
486
+ @pytest.mark.parametrize("fixture_path", list(_fixture_bundles()))
487
+ def test_blocked_by_lists_exactly_the_unsatisfiable_deployments(
488
+ fixture_path: Path,
489
+ ) -> None:
490
+ """Keeps requires honest: an entry that claims to work everywhere would
491
+ silently offer a card the deployment cannot run."""
492
+ bundle = _load(fixture_path)
493
+ entry = _entry_for(bundle)
494
+ responses = _load(CAPABILITIES_PATH)["responses"]
495
+
496
+ unsatisfiable = {
497
+ name
498
+ for name, response in responses.items()
499
+ if not _capabilities_satisfied(entry, response["body"])
500
+ }
501
+
502
+ assert unsatisfiable == set(bundle["blockedBy"])
503
+ assert _capabilities_satisfied(entry, responses[bundle["capabilities"]]["body"])
504
+
505
+
506
+ def test_generated_catalog_index_is_up_to_date() -> None:
507
+ """Re-running the codegen script should produce identical output."""
508
+ before = CATALOG_INDEX.read_text()
509
+ subprocess.run(
510
+ ["node", str(BUILD_SCRIPT)], cwd=str(ROOT), check=True, capture_output=True
511
+ )
512
+ assert CATALOG_INDEX.read_text() == before, (
513
+ "automations/catalog-index.js is out of date - run: npm run build:automations"
514
+ )
515
+
516
+
517
+ def test_no_catalog_entry_or_fixture_carries_a_credential_value() -> None:
518
+ """Credentials come from a connected integration, so no entry or fixture
519
+ has any reason to carry one."""
520
+ offenders = []
521
+ for path in _manifests() + sorted(FIXTURE_DIR.glob("*.json")):
522
+ for value in _iter_strings(_load(path)):
523
+ if CREDENTIAL_VALUE_RE.search(value):
524
+ offenders.append(f"{path.name}: {value[:40]}")
525
+
526
+ assert offenders == []
@@ -7,18 +7,24 @@ from pathlib import Path
7
7
  ROOT = Path(__file__).resolve().parents[1]
8
8
 
9
9
 
10
- def load_catalog_entries(relative_path: str):
10
+ def load_catalog_entries(relative_path: str, pattern: str = "*.json"):
11
+ """Integrations are `<id>.json`; automations are `<id>/manifest.json`."""
11
12
  entries = []
12
- for entry_path in sorted((ROOT / relative_path).glob("*.json")):
13
+ for entry_path in sorted((ROOT / relative_path).glob(pattern)):
13
14
  entry = json.loads(entry_path.read_text())
14
- assert entry["id"] == entry_path.stem
15
+ expected_id = (
16
+ entry_path.parent.name
17
+ if entry_path.name == "manifest.json"
18
+ else entry_path.stem
19
+ )
20
+ assert entry["id"] == expected_id
15
21
  entries.append(entry)
16
22
  return entries
17
23
 
18
24
 
19
- def test_catalog_ids_are_unique_and_automations_reference_existing_integrations():
25
+ def test_catalog_ids_are_unique_and_automations_reference_things_that_exist():
20
26
  integrations = load_catalog_entries("integrations/catalog")
21
- automations = load_catalog_entries("automations/catalog")
27
+ automations = load_catalog_entries("automations/catalog", "*/manifest.json")
22
28
 
23
29
  integration_ids = [entry["id"] for entry in integrations]
24
30
  automation_ids = [entry["id"] for entry in automations]
@@ -28,11 +34,16 @@ def test_catalog_ids_are_unique_and_automations_reference_existing_integrations(
28
34
 
29
35
  known_integration_ids = set(integration_ids)
30
36
  for automation in automations:
31
- assert automation["requiredIntegrationIds"]
32
- missing_ids = (
33
- set(automation["requiredIntegrationIds"]) - known_integration_ids
37
+ required = automation["requires"]["integrations"]
38
+ assert required
39
+ assert set(required) - known_integration_ids == set()
40
+
41
+ # The launch command is looked up from this skill rather than stored on
42
+ # the entry, so a broken link is a card that launches nothing.
43
+ skill = automation.get("skill", automation["id"])
44
+ assert (ROOT / "skills" / skill / "SKILL.md").is_file(), (
45
+ f"{automation['id']}: skill '{skill}' has no SKILL.md"
34
46
  )
35
- assert missing_ids == set()
36
47
 
37
48
 
38
49
  def test_catalog_entries_have_required_fields():
@@ -63,10 +74,9 @@ def test_catalog_entries_have_required_fields():
63
74
  else:
64
75
  assert option["transport"]["url"].startswith("https://")
65
76
 
66
- for entry in load_catalog_entries("automations/catalog"):
77
+ for entry in load_catalog_entries("automations/catalog", "*/manifest.json"):
67
78
  assert entry["id"]
68
79
  assert entry["name"]
69
- assert entry["prompt"]
70
80
  assert entry["exampleImplementation"]
71
81
  assert isinstance(entry["popularityRank"], int)
72
82
  assert isinstance(entry["estimatedSetupMinutes"], int)
@@ -81,7 +91,7 @@ def test_remote_no_auth_mcp_entries_are_intentionally_public():
81
91
  transport = option.get("transport", {})
82
92
  # An option with `headerFields` still requires the user to supply
83
93
  # credentials (just via named headers, e.g. Datadog's
84
- # DD-API-KEY/DD-APPLICATION-KEY), so it is NOT "public/no-auth".
94
+ # DD_API_KEY/DD_APPLICATION_KEY), so it is NOT "public/no-auth".
85
95
  has_header_credentials = bool(transport.get("headerFields"))
86
96
  if (
87
97
  option["provider"] == "mcp"
@@ -95,6 +105,21 @@ def test_remote_no_auth_mcp_entries_are_intentionally_public():
95
105
  assert actual == public_remote_mcp_ids
96
106
 
97
107
 
108
+ def test_datadog_mcp_uses_documented_api_key_headers():
109
+ datadog = next(
110
+ entry
111
+ for entry in load_catalog_entries("integrations/catalog")
112
+ if entry["id"] == "datadog"
113
+ )
114
+ api_option = next(
115
+ option for option in datadog["connectionOptions"] if option["id"] == "api"
116
+ )
117
+
118
+ assert [
119
+ field["key"] for field in api_option["transport"]["headerFields"]
120
+ ] == ["DD_API_KEY", "DD_APPLICATION_KEY"]
121
+
122
+
98
123
  def test_credential_fields_have_helper_text_and_link():
99
124
  """All password fields must have helperText plus a link (either a helperLink field or a
100
125
  markdown link embedded in helperText) so users know how to get credentials."""
@@ -156,3 +181,70 @@ def test_http_connectors_have_openapi_url():
156
181
  if not url or not url.startswith("https://"):
157
182
  offenders.append(f"{entry['id']}.{option['id']}")
158
183
  assert not offenders, f"HTTP options missing openApiUrl: {offenders}"
184
+
185
+
186
+ def _installable_option(entry):
187
+ """First option a local Agent Canvas install can use — the GUI filters out
188
+ provider-OAuth options (isLocallyInstallableMcpOption in agent-canvas)."""
189
+ for option in entry["connectionOptions"]:
190
+ if option["auth"]["strategy"] != "oauth2":
191
+ return option
192
+ raise AssertionError(f"{entry['id']}: no locally installable connection option")
193
+
194
+
195
+ def test_daily_workflow_entries_are_locally_installable():
196
+ """GitHub, Linear, and Slack back the daily-workflow release (OSS-5193):
197
+ each must keep a non-OAuth MCP option that local Agent Canvas can install,
198
+ with the connectivity details its Test-connection probe relies on."""
199
+ entries = {e["id"]: e for e in load_catalog_entries("integrations/catalog")}
200
+
201
+ for entry_id, option_id in (("github", "api"), ("linear", "api-key"), ("slack", "api")):
202
+ entry = entries[entry_id]
203
+ assert entry["docsUrl"].startswith("https://")
204
+ option = _installable_option(entry)
205
+ assert option["id"] == option_id
206
+ assert option["provider"] == "mcp"
207
+
208
+ github = _installable_option(entries["github"])
209
+ assert github["transport"]["kind"] == "shttp"
210
+ assert github["transport"]["url"] == "https://api.githubcopilot.com/mcp/"
211
+ assert github["auth"]["credentialSecretName"] == "GITHUB_PERSONAL_ACCESS_TOKEN"
212
+
213
+ linear = _installable_option(entries["linear"])
214
+ assert linear["transport"]["kind"] == "shttp"
215
+ assert linear["transport"]["url"] == "https://mcp.linear.app/mcp"
216
+
217
+ slack = _installable_option(entries["slack"])
218
+ assert slack["transport"]["kind"] == "stdio"
219
+ assert slack["transport"]["command"] == "npx"
220
+ required_env = {
221
+ field["key"] for field in slack["transport"]["envFields"] if field.get("required")
222
+ }
223
+ assert required_env == {"SLACK_TEAM_ID", "SLACK_BOT_TOKEN"}
224
+
225
+
226
+ def test_daily_workflow_entries_state_scopes_and_setup_paths():
227
+ """The locally installable options must state the required scopes and link a
228
+ public setup path (OSS-5193). The OAuth options' scope lists never reach a
229
+ local Canvas install, so this metadata has to live on these options."""
230
+ entries = {e["id"]: e for e in load_catalog_entries("integrations/catalog")}
231
+
232
+ github_help = _installable_option(entries["github"])["auth"]["credentialHelp"]
233
+ assert re.search(r"\brepo\b", github_help)
234
+ assert "read:user" in github_help
235
+ assert re.search(r"\[.+?\]\(https://github\.com/settings[^)]*\)", github_help)
236
+
237
+ linear_help = _installable_option(entries["linear"])["auth"]["credentialHelp"]
238
+ assert "Bearer" in linear_help
239
+ assert re.search(r"\[.+?\]\(https://linear\.app/settings[^)]*\)", linear_help)
240
+
241
+ slack_hint = entries["slack"]["installHint"]
242
+ for scope in (
243
+ "channels:history",
244
+ "channels:read",
245
+ "chat:write",
246
+ "reactions:write",
247
+ "users:read",
248
+ "users.profile:read",
249
+ ):
250
+ assert scope in slack_hint, f"slack installHint missing bot scope {scope}"