@openhands/extensions 0.16.0 → 0.17.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 (44) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/AGENTS.md +1 -0
  3. package/automations/README.md +75 -9
  4. package/automations/bundle-index.js +9 -0
  5. package/automations/catalog/github-pr-reviewer/manifest.json +23 -7
  6. package/automations/catalog/github-repo-monitor/manifest.json +28 -34
  7. package/automations/catalog/jira-issue-to-pr/manifest.json +3 -3
  8. package/automations/catalog.schema.json +102 -1
  9. package/automations/index.d.ts +48 -0
  10. package/automations/index.js +14 -0
  11. package/automations/interface.json +3 -1
  12. package/automations/interface.schema.json +6 -2
  13. package/index.d.ts +1 -1
  14. package/index.js +1 -1
  15. package/integrations/catalog/posthog.json +41 -3
  16. package/integrations/catalog/sonarqube.json +78 -0
  17. package/integrations/catalog-index.js +65 -63
  18. package/marketplaces/openhands-extensions.json +11 -0
  19. package/package.json +1 -1
  20. package/plugins/qa-changes/action.yml +1 -1
  21. package/pyproject.toml +1 -1
  22. package/python/openhands_extensions/_version.py +1 -1
  23. package/scripts/build-automation-catalog.mjs +30 -0
  24. package/scripts/build-skills-catalog.mjs +24 -7
  25. package/skills/add-skill/SKILL.md +1 -1
  26. package/skills/github-pr-reviewer/README.md +12 -6
  27. package/skills/github-pr-reviewer/SKILL.md +93 -31
  28. package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
  29. package/skills/github-pr-reviewer/references/state-schema.md +68 -18
  30. package/skills/github-pr-reviewer/scripts/main.py +540 -103
  31. package/skills/github-pr-reviewer/tests/test_main.py +503 -0
  32. package/skills/index.d.ts +6 -0
  33. package/skills/index.js +29 -17
  34. package/skills/jira-issue-to-pr/scripts/main.py +4 -2
  35. package/skills/openhands-sdk/SKILL.md +2 -0
  36. package/skills/skill-creator/SKILL.md +1 -1
  37. package/skills/slack-channel-monitor/scripts/main.py +3 -2
  38. package/tests/fixtures/automations/capabilities.json +30 -0
  39. package/tests/fixtures/automations/github-pr-reviewer.json +319 -55
  40. package/tests/fixtures/automations/github-repo-monitor.json +153 -62
  41. package/tests/test_automation_setup.py +226 -31
  42. package/tests/test_catalogs.py +43 -0
  43. package/tests/test_skills_catalog.py +182 -36
  44. package/tests/test_slack_channel_monitor.py +1 -1
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "automationId": "github-repo-monitor",
3
- "description": "Direct GitHub-event setup. Every create request body below is verified against the live CreatePromptAutomationRequest model, and every filter expression against validate_filter() in OpenHands/automation.",
3
+ "description": "Direct scheduled setup. Every create request body below is verified against the live CreatePromptAutomationRequest model in OpenHands/automation (openhands/automation/preset_router.py).",
4
4
  "capabilities": "supported",
5
5
  "blockedBy": [
6
- "cronOnly",
7
6
  "notReady"
8
7
  ],
9
8
  "scenarios": [
10
9
  {
11
10
  "id": "happy-path",
12
- "description": "Watch issue and pull request comments for the default mention phrase.",
11
+ "description": "Defaults plus a repository and a base branch. Preflight passes, the automation is created.",
13
12
  "formValues": {
14
13
  "repository": "OpenHands/agent-server-gui",
15
14
  "triggerPhrase": "@openhands",
16
- "on": "issue_comment.created",
17
- "ref": "main"
15
+ "ref": "main",
16
+ "schedule": "*/15 * * * *",
17
+ "timezone": "UTC"
18
18
  },
19
19
  "preflight": {
20
20
  "request": {
@@ -25,19 +25,18 @@
25
25
  "endpoint": "/v1/preset/prompt",
26
26
  "draft": {
27
27
  "name": "GitHub repository monitor - OpenHands/agent-server-gui",
28
- "prompt": "A comment in OpenHands/agent-server-gui mentions '@openhands'. Read the surrounding issue or pull request context from the event payload, then post a helpful reply as a comment on the same thread.",
28
+ "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
29
29
  "repos": [
30
30
  {
31
31
  "url": "OpenHands/agent-server-gui",
32
- "ref": "main",
33
- "provider": "github"
32
+ "provider": "github",
33
+ "ref": "main"
34
34
  }
35
35
  ],
36
36
  "trigger": {
37
- "type": "event",
38
- "source": "github",
39
- "on": "issue_comment.created",
40
- "filter": "icontains(comment.body, '@openhands') && glob(repository.full_name, 'OpenHands/agent-server-gui')"
37
+ "type": "cron",
38
+ "schedule": "*/15 * * * *",
39
+ "timezone": "UTC"
41
40
  }
42
41
  }
43
42
  }
@@ -56,19 +55,18 @@
56
55
  "path": "/v1/preset/prompt",
57
56
  "body": {
58
57
  "name": "GitHub repository monitor - OpenHands/agent-server-gui",
59
- "prompt": "A comment in OpenHands/agent-server-gui mentions '@openhands'. Read the surrounding issue or pull request context from the event payload, then post a helpful reply as a comment on the same thread.",
58
+ "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
60
59
  "repos": [
61
60
  {
62
61
  "url": "OpenHands/agent-server-gui",
63
- "ref": "main",
64
- "provider": "github"
62
+ "provider": "github",
63
+ "ref": "main"
65
64
  }
66
65
  ],
67
66
  "trigger": {
68
- "type": "event",
69
- "source": "github",
70
- "on": "issue_comment.created",
71
- "filter": "icontains(comment.body, '@openhands') && glob(repository.full_name, 'OpenHands/agent-server-gui')"
67
+ "type": "cron",
68
+ "schedule": "*/15 * * * *",
69
+ "timezone": "UTC"
72
70
  }
73
71
  }
74
72
  },
@@ -80,12 +78,11 @@
80
78
  "org_id": "1a2b3c4d-0000-4000-8000-000000000002",
81
79
  "model": null,
82
80
  "name": "GitHub repository monitor - OpenHands/agent-server-gui",
83
- "prompt": "A comment in OpenHands/agent-server-gui mentions '@openhands'. Read the surrounding issue or pull request context from the event payload, then post a helpful reply as a comment on the same thread.",
81
+ "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
84
82
  "trigger": {
85
- "type": "event",
86
- "source": "github",
87
- "on": "issue_comment.created",
88
- "filter": "icontains(comment.body, '@openhands') && glob(repository.full_name, 'OpenHands/agent-server-gui')"
83
+ "type": "cron",
84
+ "schedule": "*/15 * * * *",
85
+ "timezone": "UTC"
89
86
  },
90
87
  "tarball_path": "oh-internal://uploads/c41e7b28-9d05-4a6b-8f13-2e5c7a90d146",
91
88
  "setup_script_path": "setup.sh",
@@ -101,36 +98,14 @@
101
98
  }
102
99
  },
103
100
  {
104
- "id": "quote-in-trigger-phrase-blocked-locally",
105
- "description": "The trigger phrase is interpolated into a JMESPath string literal, so an apostrophe would break the expression. The field's safeExpressionLiteral constraint rejects it before any request is made. Without that constraint the service returns 422 with: Bad jmespath expression: Unknown token.",
106
- "formValues": {
107
- "repository": "OpenHands/agent-server-gui",
108
- "triggerPhrase": "@open'hands",
109
- "on": "issue_comment.created",
110
- "ref": "main"
111
- },
112
- "localValidation": {
113
- "valid": false,
114
- "errors": [
115
- {
116
- "field": "triggerPhrase",
117
- "constraint": "format",
118
- "message": "Quotes and backslashes are not allowed in the trigger phrase."
119
- }
120
- ]
121
- },
122
- "expectedFieldErrors": {
123
- "triggerPhrase": "Quotes and backslashes are not allowed in the trigger phrase."
124
- }
125
- },
126
- {
127
- "id": "event-type-not-delivered-by-deployment",
128
- "description": "Preflight rejects an event type the deployment does not receive. Nothing is created, so there is no half-broken automation to clean up.",
101
+ "id": "interval-below-deployment-minimum",
102
+ "description": "A one-minute schedule. The create model accepts it, so only the deployment can reject it - this is precisely why preflight exists and why local validation is not authoritative.",
129
103
  "formValues": {
130
104
  "repository": "OpenHands/agent-server-gui",
131
105
  "triggerPhrase": "@openhands",
132
- "on": "pull_request_review_comment.created",
133
- "ref": "main"
106
+ "ref": "main",
107
+ "schedule": "*/1 * * * *",
108
+ "timezone": "UTC"
134
109
  },
135
110
  "preflight": {
136
111
  "request": {
@@ -141,19 +116,18 @@
141
116
  "endpoint": "/v1/preset/prompt",
142
117
  "draft": {
143
118
  "name": "GitHub repository monitor - OpenHands/agent-server-gui",
144
- "prompt": "A comment in OpenHands/agent-server-gui mentions '@openhands'. Read the surrounding issue or pull request context from the event payload, then post a helpful reply as a comment on the same thread.",
119
+ "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
145
120
  "repos": [
146
121
  {
147
122
  "url": "OpenHands/agent-server-gui",
148
- "ref": "main",
149
- "provider": "github"
123
+ "provider": "github",
124
+ "ref": "main"
150
125
  }
151
126
  ],
152
127
  "trigger": {
153
- "type": "event",
154
- "source": "github",
155
- "on": "pull_request_review_comment.created",
156
- "filter": "icontains(comment.body, '@openhands') && glob(repository.full_name, 'OpenHands/agent-server-gui')"
128
+ "type": "cron",
129
+ "schedule": "*/1 * * * *",
130
+ "timezone": "UTC"
157
131
  }
158
132
  }
159
133
  }
@@ -164,16 +138,133 @@
164
138
  "valid": false,
165
139
  "errors": [
166
140
  {
167
- "field": "trigger.on",
168
- "code": "event_type_not_delivered",
169
- "message": "No GitHub webhook delivering pull_request_review_comment.created is registered for this organization."
141
+ "field": "trigger.schedule",
142
+ "code": "interval_too_short",
143
+ "message": "Minimum interval for this deployment is 5 minutes."
170
144
  }
171
145
  ]
172
146
  }
173
147
  }
174
148
  },
175
149
  "expectedFieldErrors": {
176
- "on": "No GitHub webhook delivering pull_request_review_comment.created is registered for this organization."
150
+ "schedule": "Minimum interval for this deployment is 5 minutes."
151
+ }
152
+ },
153
+ {
154
+ "id": "unfireable-cron-rejected-at-create",
155
+ "description": "31 February. Syntactically valid, so a local check passes, but the service rejects it. Shows the real 422 body: detail is a list and the loc path carries the trigger discriminator tag.",
156
+ "formValues": {
157
+ "repository": "OpenHands/agent-server-gui",
158
+ "triggerPhrase": "@openhands",
159
+ "ref": "main",
160
+ "schedule": "0 0 31 2 *",
161
+ "timezone": "UTC"
162
+ },
163
+ "create": {
164
+ "request": {
165
+ "method": "POST",
166
+ "path": "/v1/preset/prompt",
167
+ "body": {
168
+ "name": "GitHub repository monitor - OpenHands/agent-server-gui",
169
+ "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
170
+ "repos": [
171
+ {
172
+ "url": "OpenHands/agent-server-gui",
173
+ "provider": "github",
174
+ "ref": "main"
175
+ }
176
+ ],
177
+ "trigger": {
178
+ "type": "cron",
179
+ "schedule": "0 0 31 2 *",
180
+ "timezone": "UTC"
181
+ }
182
+ }
183
+ },
184
+ "response": {
185
+ "status": 422,
186
+ "body": {
187
+ "detail": [
188
+ {
189
+ "type": "value_error",
190
+ "loc": [
191
+ "body",
192
+ "trigger",
193
+ "cron",
194
+ "schedule"
195
+ ],
196
+ "msg": "Value error, Cron expression cannot produce any future fire times: 0 0 31 2 *"
197
+ }
198
+ ]
199
+ }
200
+ }
201
+ },
202
+ "expectedFieldErrors": {
203
+ "schedule": "Value error, Cron expression cannot produce any future fire times: 0 0 31 2 *"
204
+ }
205
+ },
206
+ {
207
+ "id": "short-repo-url-without-provider-is-rejected",
208
+ "description": "The mapping bug this fixture set exists to prevent. A short owner/repo URL without an explicit provider is a hard 422, because CreatePromptAutomationRequest is extra-forbid and RepoSource requires the provider. The setup block's payload always sets it; this records what happens if that is ever dropped.",
209
+ "formValues": {
210
+ "repository": "OpenHands/agent-server-gui",
211
+ "triggerPhrase": "@openhands",
212
+ "ref": "main",
213
+ "schedule": "*/15 * * * *",
214
+ "timezone": "UTC"
215
+ },
216
+ "create": {
217
+ "request": {
218
+ "method": "POST",
219
+ "path": "/v1/preset/prompt",
220
+ "body": {
221
+ "name": "GitHub repository monitor - OpenHands/agent-server-gui",
222
+ "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
223
+ "repos": [
224
+ {
225
+ "url": "OpenHands/agent-server-gui"
226
+ }
227
+ ],
228
+ "trigger": {
229
+ "type": "cron",
230
+ "schedule": "*/15 * * * *",
231
+ "timezone": "UTC"
232
+ }
233
+ }
234
+ },
235
+ "response": {
236
+ "status": 422,
237
+ "body": {
238
+ "detail": [
239
+ {
240
+ "type": "value_error",
241
+ "loc": [
242
+ "body",
243
+ "repos",
244
+ 0
245
+ ],
246
+ "msg": "Value error, Short URL format 'OpenHands/agent-server-gui' requires explicit 'provider' field. Use: {\"url\": \"owner/repo\", \"provider\": \"github\"} or provide a full URL like https://github.com/owner/repo"
247
+ }
248
+ ]
249
+ }
250
+ }
251
+ },
252
+ "matchesSetupPayload": false
253
+ },
254
+ {
255
+ "id": "fallback-conversation-when-direct-unavailable",
256
+ "description": "A deployment that cannot run the scheduled monitor seeds the setup conversation with this message; the agent confirms the repository, trigger phrase, and polling schedule there.",
257
+ "formValues": {
258
+ "repository": "OpenHands/agent-server-gui",
259
+ "triggerPhrase": "@openhands",
260
+ "ref": "main",
261
+ "schedule": "*/15 * * * *",
262
+ "timezone": "UTC"
263
+ },
264
+ "conversation": {
265
+ "request": {
266
+ "message": "This deployment cannot run the scheduled monitor directly. Set it up in this conversation instead: confirm the repository to watch, the trigger phrase, and the polling schedule, then create the automation."
267
+ }
177
268
  }
178
269
  }
179
270
  ]
@@ -32,15 +32,27 @@ ROOT = Path(__file__).resolve().parents[1]
32
32
  SCHEMA_PATH = ROOT / "automations" / "catalog.schema.json"
33
33
  CATALOG_DIR = ROOT / "automations" / "catalog"
34
34
  CATALOG_INDEX = ROOT / "automations" / "catalog-index.js"
35
+ BUNDLE_INDEX = ROOT / "automations" / "bundle-index.js"
35
36
  BUILD_SCRIPT = ROOT / "scripts" / "build-automation-catalog.mjs"
36
37
  FIXTURE_DIR = ROOT / "tests" / "fixtures" / "automations"
37
38
  CAPABILITIES_PATH = FIXTURE_DIR / "capabilities.json"
38
39
 
39
40
  # The standardized parts of a direct setup, identical for every automation and
40
- # therefore not declared in any entry.
41
+ # therefore not declared in any entry. Which create endpoint is used follows
42
+ # from what the entry produces: a prompt is a preset, a bundle is a tarball the
43
+ # host uploads and then creates from, which is the raw endpoint.
41
44
  CREATE_PATH = "/v1/preset/prompt"
45
+ BUNDLE_CREATE_PATH = "/v1"
46
+ BUNDLE_UPLOAD_PATH = "/v1/uploads"
42
47
  PREFLIGHT_PATH = "/v1/validate"
43
48
 
49
+ # Preflight runs while the form is being filled in and the upload happens once,
50
+ # at submit, so a bundle draft has no tarball path to send yet. The service
51
+ # checks that field's scheme at preflight and its ownership only at creation,
52
+ # so a well-formed stand-in validates what preflight is for - the rest of the
53
+ # body - without uploading an archive per keystroke.
54
+ PREFLIGHT_TARBALL_PATH = "oh-internal://uploads/00000000-0000-0000-0000-000000000000"
55
+
44
56
  # The trigger properties the service accepts, per kind. A form field named
45
57
  # after one of them fills it; the rest are inputs to the declared filter.
46
58
  TRIGGER_PROPERTIES = {"cron": ("schedule", "timezone"), "event": ("on",)}
@@ -83,10 +95,30 @@ def _fixture_bundles():
83
95
  yield pytest.param(path, id=path.stem)
84
96
 
85
97
 
98
+ def _inlined_bundles() -> dict:
99
+ """The generated bundle index, read as data rather than executed."""
100
+ body = BUNDLE_INDEX.read_text()
101
+ marker = "export const AUTOMATION_BUNDLE_FILES = "
102
+ return json.loads(body[body.index(marker) + len(marker) :].rstrip().rstrip(";"))
103
+
104
+
86
105
  def _entry_for(bundle: dict) -> dict:
87
106
  return _load(CATALOG_DIR / bundle["automationId"] / "manifest.json")
88
107
 
89
108
 
109
+ def _uploaded_path(scenario: dict) -> str:
110
+ """Where a bundle scenario's tarball landed, as the upload step reported it.
111
+
112
+ A preset scenario has no upload step and never reads this.
113
+ """
114
+ return (
115
+ scenario.get("upload", {})
116
+ .get("response", {})
117
+ .get("body", {})
118
+ .get("tarball_path", "")
119
+ )
120
+
121
+
90
122
  def _integration_catalog_ids() -> set[str]:
91
123
  return {path.stem for path in (ROOT / "integrations" / "catalog").glob("*.json")}
92
124
 
@@ -132,30 +164,46 @@ def _repo_picker(setup: dict) -> tuple[str | None, dict | None]:
132
164
  return None, None
133
165
 
134
166
 
135
- def _render_payload(entry: dict, form_values: dict) -> dict:
136
- """The create request body these form values produce.
167
+ def _is_bundle(entry: dict) -> bool:
168
+ """Whether this entry ships a script tarball instead of a prompt."""
169
+ return "bundle" in entry.get("setup", {})
137
170
 
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
171
 
148
- body: dict = {
149
- "name": f"{entry['name']} - {repo}" if repo else entry["name"],
150
- "prompt": _interpolate(setup["prompt"], context),
151
- }
172
+ def _create_path(entry: dict) -> str:
173
+ return BUNDLE_CREATE_PATH if _is_bundle(entry) else CREATE_PATH
152
174
 
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
175
 
176
+ def _repo_values(setup: dict, form_values: dict) -> list[str]:
177
+ """Every repository the form collected, whether it collects one or many.
178
+
179
+ A `multiple` picker holds a list; a single one holds a string. Reading both
180
+ as a list is what keeps the rest of the derivation from branching.
181
+ """
182
+ name, field = _repo_picker(setup)
183
+ if not name:
184
+ return []
185
+ value = form_values.get(name)
186
+ if value is None or value == "":
187
+ return []
188
+ if field and field.get("multiple"):
189
+ return list(value)
190
+ return [value]
191
+
192
+
193
+ def _derive_name(entry: dict, form_values: dict) -> str:
194
+ """The created automation's name. One repository is worth naming; several
195
+ are not, so the count stands in rather than a list that never fits."""
196
+ repos = _repo_values(entry["setup"], form_values)
197
+ if not repos:
198
+ return entry["name"]
199
+ if len(repos) == 1:
200
+ return f"{entry['name']} - {repos[0]}"
201
+ return f"{entry['name']} - {len(repos)} repositories"
202
+
203
+
204
+ def _derive_trigger(entry: dict, form_values: dict) -> dict:
205
+ """The `trigger` object, read off the key and fields under form.triggers."""
206
+ setup = entry["setup"]
159
207
  kind, trigger_fields = next(iter(setup["form"]["triggers"].items()))
160
208
  trigger = {"type": kind}
161
209
  # A field under a trigger kind fills the trigger property of the same name.
@@ -164,9 +212,71 @@ def _render_payload(entry: dict, form_values: dict) -> dict:
164
212
  if name in TRIGGER_PROPERTIES[kind]:
165
213
  trigger[name] = form_values[name]
166
214
  if kind == "event":
215
+ _, repo_field = _repo_picker(setup)
167
216
  trigger["source"] = repo_field["provider"]
168
- trigger["filter"] = _interpolate(setup["filter"], context)
169
- body["trigger"] = trigger
217
+ trigger["filter"] = _interpolate(setup["filter"], _context(entry, form_values))
218
+ return trigger
219
+
220
+
221
+ def _render_bundle_payload(entry: dict, form_values: dict, tarball_path: str) -> dict:
222
+ """The raw create body a bundle entry produces.
223
+
224
+ `tarball_path` is the one value that is neither declared nor derived: the
225
+ host uploads the packed bundle first and creates from what came back. The
226
+ rest follows the same rule as the preset path - only `config` is declared,
227
+ because only the entry knows which key of its own script each field fills.
228
+ """
229
+ bundle = entry["setup"]["bundle"]
230
+ body: dict = {
231
+ "name": _derive_name(entry, form_values),
232
+ "trigger": _derive_trigger(entry, form_values),
233
+ "tarball_path": tarball_path,
234
+ "entrypoint": bundle["entrypoint"],
235
+ }
236
+ if "setupScript" in bundle:
237
+ body["setup_script_path"] = bundle["setupScript"]
238
+ if "timeout" in bundle:
239
+ body["timeout"] = bundle["timeout"]
240
+ body["template"] = {
241
+ "id": entry["id"],
242
+ "version": bundle["version"],
243
+ "config": _interpolate(bundle["config"], _context(entry, form_values)),
244
+ }
245
+ return body
246
+
247
+
248
+ def _render_payload(entry: dict, form_values: dict, tarball_path: str = "") -> dict:
249
+ """The create request body these form values produce.
250
+
251
+ No entry declares this. `name` comes from the entry, `repos` from the
252
+ repo-picker field and its provider, and `trigger` from the key and fields
253
+ under `form.triggers`. Only `prompt` (or, for a bundle, `config`) and an
254
+ event `filter` are declared, because only they cannot be read off the form.
255
+ """
256
+ if _is_bundle(entry):
257
+ return _render_bundle_payload(entry, form_values, tarball_path)
258
+
259
+ setup = entry["setup"]
260
+ context = _context(entry, form_values)
261
+ _, repo_field = _repo_picker(setup)
262
+ repos = _repo_values(setup, form_values)
263
+
264
+ body: dict = {
265
+ "name": _derive_name(entry, form_values),
266
+ "prompt": _interpolate(setup["prompt"], context),
267
+ }
268
+
269
+ if repos:
270
+ body["repos"] = [
271
+ {
272
+ "url": repo,
273
+ "provider": repo_field["provider"],
274
+ **({"ref": form_values["ref"]} if "ref" in form_values else {}),
275
+ }
276
+ for repo in repos
277
+ ]
278
+
279
+ body["trigger"] = _derive_trigger(entry, form_values)
170
280
 
171
281
  return body
172
282
 
@@ -176,8 +286,8 @@ def _derive_preflight_body(entry: dict, form_values: dict) -> dict:
176
286
  so no entry declares it."""
177
287
  return {
178
288
  "automationId": entry["id"],
179
- "endpoint": CREATE_PATH,
180
- "draft": _render_payload(entry, form_values),
289
+ "endpoint": _create_path(entry),
290
+ "draft": _render_payload(entry, form_values, PREFLIGHT_TARBALL_PATH),
181
291
  }
182
292
 
183
293
 
@@ -190,7 +300,7 @@ def _derive_error_map(entry: dict) -> dict[str, list[str]]:
190
300
  an entry does not declare it.
191
301
  """
192
302
  mapping: dict[str, list[str]] = {}
193
- if "prompt" not in entry["setup"]:
303
+ if "prompt" not in entry["setup"] and not _is_bundle(entry):
194
304
  return mapping
195
305
 
196
306
  stand_ins = {name: f"{{{{form.{name}}}}}" for name in _field_names(entry["setup"])}
@@ -279,8 +389,8 @@ def _reported_fields(entry: dict, scenario: dict) -> dict[str, str]:
279
389
  setup = entry["setup"]
280
390
  error_map = _derive_error_map(entry)
281
391
  payload = (
282
- _render_payload(entry, scenario["formValues"])
283
- if "prompt" in setup and "formValues" in scenario
392
+ _render_payload(entry, scenario["formValues"], _uploaded_path(scenario))
393
+ if ("prompt" in setup or _is_bundle(entry)) and "formValues" in scenario
284
394
  else {}
285
395
  )
286
396
 
@@ -368,7 +478,7 @@ def test_schema_rejects_content_a_setup_block_must_never_carry() -> None:
368
478
  rejected: list[tuple[str, dict]] = []
369
479
 
370
480
  with_markup = deepcopy(entry)
371
- with_markup["setup"]["form"]["args"]["repository"]["label"] = (
481
+ with_markup["setup"]["form"]["args"]["repositories"]["label"] = (
372
482
  "Repository <script>steal()</script>"
373
483
  )
374
484
  rejected.append(("<script>steal()</script>", with_markup))
@@ -435,10 +545,10 @@ def test_derived_body_reproduces_the_create_request(
435
545
  where that reconstruction is pinned to a body the service accepts."""
436
546
  entry = _entry_for(bundle)
437
547
 
438
- derived = _render_payload(entry, scenario["formValues"])
548
+ derived = _render_payload(entry, scenario["formValues"], _uploaded_path(scenario))
439
549
 
440
550
  assert derived == scenario["create"]["request"]["body"]
441
- assert scenario["create"]["request"]["path"] == CREATE_PATH
551
+ assert scenario["create"]["request"]["path"] == _create_path(entry)
442
552
 
443
553
 
444
554
  @pytest.mark.parametrize(("bundle", "scenario"), list(_scenarios("preflight")))
@@ -506,12 +616,97 @@ def test_blocked_by_lists_exactly_the_unsatisfiable_deployments(
506
616
  def test_generated_catalog_index_is_up_to_date() -> None:
507
617
  """Re-running the codegen script should produce identical output."""
508
618
  before = CATALOG_INDEX.read_text()
619
+ bundles_before = BUNDLE_INDEX.read_text()
509
620
  subprocess.run(
510
621
  ["node", str(BUILD_SCRIPT)], cwd=str(ROOT), check=True, capture_output=True
511
622
  )
512
623
  assert CATALOG_INDEX.read_text() == before, (
513
624
  "automations/catalog-index.js is out of date - run: npm run build:automations"
514
625
  )
626
+ assert BUNDLE_INDEX.read_text() == bundles_before, (
627
+ "automations/bundle-index.js is out of date - run: npm run build:automations"
628
+ )
629
+
630
+
631
+ @pytest.mark.parametrize("entry_path", list(_setup_paths()))
632
+ def test_bundle_files_exist_and_are_inlined(entry_path: Path) -> None:
633
+ """A bundle names repository paths; the package ships their contents.
634
+
635
+ The host packing the tarball has the published package but not this
636
+ repository, so a path that resolves here and nowhere else would produce an
637
+ entry that installs in CI and fails for every user.
638
+ """
639
+ entry = _load(entry_path)
640
+ if not _is_bundle(entry):
641
+ pytest.skip("not a bundle entry")
642
+
643
+ bundle = entry["setup"]["bundle"]
644
+ inlined = _inlined_bundles()
645
+
646
+ assert set(inlined) >= {entry["id"]}
647
+ for packed_path, source in bundle["files"].items():
648
+ assert (ROOT / source).is_file(), f"{source} does not exist"
649
+ assert inlined[entry["id"]][packed_path] == (ROOT / source).read_text()
650
+
651
+ # The entrypoint has to name something the tarball actually contains.
652
+ packed = set(bundle["files"]) | {"config.json"}
653
+ assert any(word in packed for word in bundle["entrypoint"].split()), (
654
+ f"entrypoint {bundle['entrypoint']!r} names no packed file"
655
+ )
656
+ if "setupScript" in bundle:
657
+ assert bundle["setupScript"] in packed
658
+
659
+
660
+ # A bundle is the one part of a manifest naming files and a command the host
661
+ # acts on, so the schema is what stands between an entry and the host doing it.
662
+ BUNDLE_REJECTIONS = [
663
+ ("a packed path that climbs out of the archive", {"files": {"../main.py": "skills/github-pr-reviewer/scripts/main.py"}}),
664
+ ("a packed path that is a bare dot segment", {"files": {"./main.py": "skills/github-pr-reviewer/scripts/main.py"}}),
665
+ ("a source that traverses out of the repository", {"files": {"main.py": "skills/../../etc/passwd"}}),
666
+ ("a source outside skills/ and automations/", {"files": {"main.py": "python/main.py"}}),
667
+ ("an entrypoint carrying a shell metacharacter", {"entrypoint": "python3 main.py && curl evil.sh"}),
668
+ ("a setup script that climbs out of the archive", {"setupScript": "../setup.sh"}),
669
+ ("a version that is not a semantic version", {"version": "latest"}),
670
+ ("no files at all", {"files": {}}),
671
+ ]
672
+
673
+
674
+ @pytest.mark.parametrize(
675
+ ("case", "override"),
676
+ [pytest.param(case, override, id=case) for case, override in BUNDLE_REJECTIONS],
677
+ )
678
+ def test_schema_refuses_an_unsafe_bundle(case: str, override: dict) -> None:
679
+ entry = deepcopy(_load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json"))
680
+ entry["setup"]["bundle"].update(override)
681
+
682
+ assert list(VALIDATOR.iter_errors(entry)), f"schema admitted {case}"
683
+
684
+
685
+ def test_a_direct_entry_declares_a_prompt_or_a_bundle_but_not_both() -> None:
686
+ entry = deepcopy(_load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json"))
687
+ entry["setup"]["prompt"] = "Review pull requests."
688
+
689
+ assert list(VALIDATOR.iter_errors(entry))
690
+
691
+ del entry["setup"]["prompt"]
692
+ del entry["setup"]["bundle"]
693
+
694
+ assert list(VALIDATOR.iter_errors(entry))
695
+
696
+
697
+ @pytest.mark.parametrize("entry_path", list(_setup_paths()))
698
+ def test_bundle_config_only_reads_declared_form_fields(entry_path: Path) -> None:
699
+ """config.json is the bundle's analogue of the prompt, and the same rule
700
+ applies: a placeholder must name an input the form actually collects."""
701
+ entry = _load(entry_path)
702
+ if not _is_bundle(entry):
703
+ pytest.skip("not a bundle entry")
704
+
705
+ names = _field_names(entry["setup"])
706
+ for value in _iter_strings(entry["setup"]["bundle"]["config"]):
707
+ for namespace, key in PLACEHOLDER_RE.findall(value):
708
+ if namespace == "form":
709
+ assert key in names, f"config references unknown field: {key}"
515
710
 
516
711
 
517
712
  def test_no_catalog_entry_or_fixture_carries_a_credential_value() -> None: