@openhands/extensions 0.24.0 → 0.26.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.
- package/.claude-plugin/marketplace.json +30 -0
- package/.codex-plugin/marketplace.json +30 -0
- package/.github/pull_request_template.md +17 -2
- package/.github/scripts/check_pr_description.py +64 -5
- package/.github/scripts/markdown_sections.py +95 -0
- package/.github/workflows/build-vscode-app.yml +30 -0
- package/.github/workflows/check-extensions.yml +3 -0
- package/.github/workflows/tests.yml +26 -0
- package/.plugin/marketplace.json +30 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +3 -2
- package/README.md +10 -3
- package/apps/README.md +20 -0
- package/apps/catalog.json +10 -0
- package/apps/index.d.ts +11 -0
- package/apps/index.js +5 -0
- package/apps/vscode/README.md +13 -0
- package/apps/vscode/canvas-extension.json +19 -0
- package/apps/vscode/extension.js +114 -0
- package/apps/vscode/scripts/package.mjs +84 -0
- package/automations/bundle-index.js +18 -14
- package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
- package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
- package/automations/catalog/news-digest/manifest.json +2 -2
- package/automations/catalog-index.js +2 -0
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/openhands-extensions.json +30 -0
- package/package.json +9 -1
- package/plugins/openhands/skills/openhands-api/README.md +1 -1
- package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
- package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
- package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
- package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
- package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
- package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/README.md +59 -0
- package/plugins/runtime-api-configs/SKILL.md +240 -0
- package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
- package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +183 -15
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/check_plugin_resources.py +116 -0
- package/scripts/sync_extensions.py +32 -5
- package/skills/canvas-extension-api/SKILL.md +34 -3
- package/skills/canvas-extension-api/references/backend-safety.md +14 -0
- package/skills/canvas-extension-api/references/connections.md +24 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
- package/skills/canvas-extension-api/references/v1-contract.md +53 -3
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
- package/skills/github/scripts/agent_conversation.py +223 -43
- package/skills/github/scripts/github_client.py +183 -15
- package/skills/github-agents-md-maintainer/README.md +7 -0
- package/skills/github-agents-md-maintainer/SKILL.md +13 -6
- package/skills/github-agents-md-maintainer/scripts/github_client.py +183 -15
- package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
- package/skills/github-delivery-watchdog/scripts/github_client.py +183 -15
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-to-pr/scripts/github_client.py +183 -15
- package/skills/github-issue-to-pr/scripts/worker.py +10 -1
- package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-triage/scripts/github_client.py +183 -15
- package/skills/github-pr-review/SKILL.md +12 -2
- package/skills/github-pr-reviewer/README.md +36 -0
- package/skills/github-pr-reviewer/SKILL.md +147 -4
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
- package/skills/github-pr-reviewer/scripts/github_client.py +183 -15
- package/skills/github-pr-reviewer/scripts/main.py +17 -1
- package/skills/github-pr-reviewer/scripts/worker.py +842 -57
- package/skills/github-repo-monitor/scripts/github_client.py +183 -15
- package/skills/github-repo-monitor/scripts/main.py +32 -1
- package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/README.md +9 -0
- package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
- package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +508 -0
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +353 -0
- package/skills/gitlab-issue-to-mr/README.md +12 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
- package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
- package/skills/incident-retrospective/SKILL.md +5 -5
- package/skills/index.js +34 -16
- package/skills/jira-issue-to-pr/README.md +12 -4
- package/skills/jira-issue-to-pr/SKILL.md +129 -18
- package/skills/jira-issue-to-pr/references/setup.md +8 -0
- package/skills/jira-issue-to-pr/scripts/main.py +181 -54
- package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
- package/skills/linear-triage/SKILL.md +5 -5
- package/skills/news-digest/README.md +6 -0
- package/skills/news-digest/SKILL.md +19 -9
- package/skills/news-digest/commands/news-digest-setup.md +1 -1
- package/skills/news-digest/scripts/main.py +133 -15
- package/skills/openhands-api/README.md +1 -1
- package/skills/openhands-api/SKILL.md +3 -3
- package/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/skills/openhands-automation/SKILL.md +66 -4
- package/skills/openhands-automation/references/custom-automation.md +21 -7
- package/skills/openhands-sdk/SKILL.md +1 -0
- package/skills/research-brief/SKILL.md +5 -5
- package/skills/slack-standup-digest/SKILL.md +5 -5
- package/skills/ticket-to-code-change/SKILL.md +6 -3
- package/skills/use-jev/.claude-plugin/plugin.json +8 -0
- package/skills/use-jev/.codex-plugin/plugin.json +8 -0
- package/skills/use-jev/.plugin/plugin.json +8 -0
- package/skills/use-jev/README.md +66 -0
- package/skills/use-jev/SKILL.md +66 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
- package/tests/test_agent_conversation_dispatch.py +212 -0
- package/tests/test_apps_catalog.py +26 -0
- package/tests/test_automation_setup.py +44 -0
- package/tests/test_check_pr_description.py +89 -6
- package/tests/test_github_automation_foundation.py +158 -0
- package/tests/test_github_repo_monitor.py +107 -0
- package/tests/test_github_reviewer_delivery.py +2247 -6
- package/tests/test_github_stale_ci_pr_closer.py +56 -0
- package/tests/test_jira_issue_to_pr.py +312 -0
- package/tests/test_jira_setup_webhook.py +309 -0
- package/tests/test_openhands_api_python.py +37 -0
- package/tests/test_skill_plugin_loading.py +25 -15
- package/tests/test_sync_extensions.py +102 -0
- package/tests/test_vscode_app.py +33 -0
- package/tests/vscode-app.test.mjs +43 -0
|
@@ -12,24 +12,62 @@ from urllib.parse import parse_qsl, urlencode, urlsplit
|
|
|
12
12
|
from urllib.request import Request, urlopen
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
# The secret name of the GitHub token an OpenHands Cloud or Enterprise user's
|
|
16
|
+
# connected GitHub integration provides: how this run's sandbox serves it, and
|
|
17
|
+
# how a conversation sees it in its environment.
|
|
18
|
+
CLOUD_GITHUB_TOKEN_SECRET = "github_token"
|
|
19
|
+
CLOUD_GITHUB_TOKEN_ENV = "GITHUB_TOKEN"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_cloud_run() -> bool:
|
|
23
|
+
"""Whether this run is on OpenHands Cloud or Enterprise.
|
|
24
|
+
|
|
25
|
+
The automation service hands a run the Agent Server URL only on a local
|
|
26
|
+
Agent Canvas; elsewhere the run talks to the OpenHands API instead.
|
|
27
|
+
"""
|
|
28
|
+
return bool(os.environ.get("OPENHANDS_CLOUD_API_URL")) and not os.environ.get(
|
|
29
|
+
"AGENT_SERVER_URL"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_cloud_secret(name: str) -> str | None:
|
|
34
|
+
"""Read one named secret of this run's sandbox from the OpenHands API."""
|
|
35
|
+
api = os.environ["OPENHANDS_CLOUD_API_URL"].rstrip("/")
|
|
36
|
+
request = Request(
|
|
37
|
+
f"{api}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}",
|
|
38
|
+
headers={"X-Session-API-Key": os.environ["SESSION_API_KEY"]},
|
|
39
|
+
)
|
|
40
|
+
try:
|
|
41
|
+
with urlopen(request, timeout=90) as response:
|
|
42
|
+
return response.read().decode().strip()
|
|
43
|
+
except HTTPError as exc:
|
|
44
|
+
if exc.code == 404:
|
|
45
|
+
return None
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
|
|
15
49
|
def _load_secret(name: str) -> str:
|
|
16
|
-
"""Read one named secret from the environment or configured Agent
|
|
50
|
+
"""Read one named secret from the environment, or from the configured Agent
|
|
51
|
+
Server on a local run and the OpenHands API on a cloud one."""
|
|
17
52
|
value = os.environ.get(name)
|
|
18
53
|
if value:
|
|
19
54
|
return value
|
|
20
55
|
|
|
21
|
-
|
|
56
|
+
if is_cloud_run():
|
|
57
|
+
value = _load_cloud_secret(name)
|
|
58
|
+
else:
|
|
59
|
+
from openhands.sdk.workspace import RemoteWorkspace
|
|
22
60
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
61
|
+
workspace = RemoteWorkspace(
|
|
62
|
+
host=os.environ["AGENT_SERVER_URL"],
|
|
63
|
+
api_key=os.environ["SESSION_API_KEY"],
|
|
64
|
+
working_dir=os.environ.get("WORKSPACE_BASE", "/workspace"),
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
secret = workspace.get_secrets([name]).get(name)
|
|
68
|
+
value = secret.get_value() if secret else None
|
|
69
|
+
finally:
|
|
70
|
+
workspace.reset_client()
|
|
33
71
|
if not value:
|
|
34
72
|
raise ValueError(f"The GitHub credential {name} is unavailable")
|
|
35
73
|
return value
|
|
@@ -95,11 +133,22 @@ class GitHubRepository:
|
|
|
95
133
|
"Expected the environment variable containing the GitHub token"
|
|
96
134
|
)
|
|
97
135
|
self.token_name = github_token_secret
|
|
98
|
-
|
|
136
|
+
try:
|
|
137
|
+
self.token = _load_secret(github_token_secret)
|
|
138
|
+
except ValueError:
|
|
139
|
+
if not is_cloud_run():
|
|
140
|
+
raise
|
|
141
|
+
# No secret is saved under that name, so use the user's connected
|
|
142
|
+
# GitHub integration, under the name a conversation sees it by.
|
|
143
|
+
self.token = _load_cloud_secret(CLOUD_GITHUB_TOKEN_SECRET)
|
|
144
|
+
if not self.token:
|
|
145
|
+
raise
|
|
146
|
+
self.token_name = CLOUD_GITHUB_TOKEN_ENV
|
|
99
147
|
self.conversation = conversation
|
|
100
148
|
self.conversation_id = str(conversation.id) if conversation else None
|
|
101
149
|
self.dispatcher = dispatcher
|
|
102
|
-
|
|
150
|
+
# A cloud run is not given a workspace base; its sandbox has /workspace.
|
|
151
|
+
self.workspace = Path(os.environ.get("WORKSPACE_BASE", "/workspace"))
|
|
103
152
|
self.project = self.workspace
|
|
104
153
|
self.evidence = self.workspace / "evidence"
|
|
105
154
|
self.evidence.mkdir(exist_ok=True)
|
|
@@ -111,10 +160,13 @@ class GitHubRepository:
|
|
|
111
160
|
|
|
112
161
|
@property
|
|
113
162
|
def github_instructions(self):
|
|
163
|
+
# A cloud conversation runs in a sandbox of its own, so this run's
|
|
164
|
+
# workspace path means nothing to it.
|
|
165
|
+
where = "your working directory" if is_cloud_run() else self.project
|
|
114
166
|
return (
|
|
115
167
|
f"Use `GH_TOKEN=${self.token_name} gh api` for GitHub requests. "
|
|
116
168
|
"Never print the credential value. "
|
|
117
|
-
f"Only {self.repository} is in scope. Work in {
|
|
169
|
+
f"Only {self.repository} is in scope. Work in {where}. "
|
|
118
170
|
"Do not modify the automation bundle or its configuration."
|
|
119
171
|
)
|
|
120
172
|
|
|
@@ -165,6 +217,122 @@ class GitHubRepository:
|
|
|
165
217
|
result.setdefault(item["context"], item["state"])
|
|
166
218
|
return result
|
|
167
219
|
|
|
220
|
+
def check_runs(self, sha):
|
|
221
|
+
"""Return every check run GitHub reported for one commit SHA.
|
|
222
|
+
|
|
223
|
+
Reading check runs needs no branch-protection or ruleset access, so this
|
|
224
|
+
is the head-eligibility signal the automation's own token can always
|
|
225
|
+
see. The endpoint answers with an object rather than a list, so it
|
|
226
|
+
paginates through ``gh`` instead of ``gh_pages``.
|
|
227
|
+
"""
|
|
228
|
+
runs = []
|
|
229
|
+
for page in range(1, 101):
|
|
230
|
+
data = self.gh(
|
|
231
|
+
"GET", f"/commits/{sha}/check-runs?per_page=100&page={page}"
|
|
232
|
+
)
|
|
233
|
+
batch = data.get("check_runs") or []
|
|
234
|
+
runs.extend(batch)
|
|
235
|
+
if len(runs) >= int(data.get("total_count") or 0) or not batch:
|
|
236
|
+
return runs
|
|
237
|
+
raise RuntimeError("GitHub check-run pagination exceeded limit")
|
|
238
|
+
|
|
239
|
+
def required_check_contexts(self, number):
|
|
240
|
+
"""Return the status contexts GitHub marks required on one pull request.
|
|
241
|
+
|
|
242
|
+
`isRequired` is the merge-policy source of truth and is PR-scoped, so it
|
|
243
|
+
is correct for a stacked PR whose symbolic base branch carries no branch
|
|
244
|
+
rules of its own. It is a field on each context in the head's status
|
|
245
|
+
check rollup and takes the pull request number, so a single GraphQL query
|
|
246
|
+
answers with the required check runs and any required commit statuses,
|
|
247
|
+
each already attributed to the exact head. Only contexts GitHub itself
|
|
248
|
+
reports as required are returned, so an optional workflow that fails
|
|
249
|
+
before creating any check run is not present.
|
|
250
|
+
"""
|
|
251
|
+
owner, name = self.repository.split("/", 1)
|
|
252
|
+
query = (
|
|
253
|
+
"query($owner:String!,$name:String!,$number:Int!){"
|
|
254
|
+
"repository(owner:$owner,name:$name){"
|
|
255
|
+
"pullRequest(number:$number){"
|
|
256
|
+
"commits(last:1){"
|
|
257
|
+
"nodes{"
|
|
258
|
+
"commit{"
|
|
259
|
+
"statusCheckRollup{"
|
|
260
|
+
"contexts(first:100){"
|
|
261
|
+
"pageInfo{hasNextPage}"
|
|
262
|
+
"nodes{"
|
|
263
|
+
"__typename"
|
|
264
|
+
" ... on CheckRun{name isRequired(pullRequestNumber:$number)}"
|
|
265
|
+
" ... on StatusContext{context isRequired(pullRequestNumber:$number)}"
|
|
266
|
+
"}"
|
|
267
|
+
"}"
|
|
268
|
+
"}"
|
|
269
|
+
"}"
|
|
270
|
+
"}"
|
|
271
|
+
"}"
|
|
272
|
+
"}"
|
|
273
|
+
"}"
|
|
274
|
+
"}"
|
|
275
|
+
)
|
|
276
|
+
data = self.api(
|
|
277
|
+
"POST",
|
|
278
|
+
"/graphql",
|
|
279
|
+
body={
|
|
280
|
+
"query": query,
|
|
281
|
+
"variables": {"owner": owner, "name": name, "number": number},
|
|
282
|
+
},
|
|
283
|
+
)
|
|
284
|
+
if data.get("errors"):
|
|
285
|
+
raise RuntimeError(
|
|
286
|
+
f"GitHub required-check query failed: {data['errors'][0].get('message')}"
|
|
287
|
+
)
|
|
288
|
+
nodes = (
|
|
289
|
+
(((data.get("data") or {}).get("repository") or {}).get("pullRequest") or {})
|
|
290
|
+
.get("commits", {})
|
|
291
|
+
.get("nodes")
|
|
292
|
+
) or []
|
|
293
|
+
contexts = (
|
|
294
|
+
((nodes[0].get("commit") or {}).get("statusCheckRollup") or {}).get(
|
|
295
|
+
"contexts", {}
|
|
296
|
+
)
|
|
297
|
+
if nodes
|
|
298
|
+
else {}
|
|
299
|
+
)
|
|
300
|
+
if (contexts.get("pageInfo") or {}).get("hasNextPage"):
|
|
301
|
+
raise RuntimeError(
|
|
302
|
+
"GitHub required-check rollup exceeds one page; the required "
|
|
303
|
+
"set may be incomplete"
|
|
304
|
+
)
|
|
305
|
+
return [
|
|
306
|
+
{
|
|
307
|
+
"name": node.get("name") or node.get("context"),
|
|
308
|
+
"kind": node.get("__typename"),
|
|
309
|
+
}
|
|
310
|
+
for node in (contexts.get("nodes") or [])
|
|
311
|
+
if node.get("isRequired") and (node.get("name") or node.get("context"))
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
def workflow_runs(self, sha):
|
|
315
|
+
"""Return every Actions workflow run GitHub reported for one commit SHA.
|
|
316
|
+
|
|
317
|
+
A workflow that fails before any job starts - a workflow-level error, or
|
|
318
|
+
a `pull_request` run whose jobs never materialize - still records a
|
|
319
|
+
failed check suite, but it contributes no check runs, so the commit's
|
|
320
|
+
check-run rollup and `gh pr checks` both report success. Reading the
|
|
321
|
+
workflow runs directly is the only way the gate can see that failure.
|
|
322
|
+
Like the check-run endpoint this answers with an object, so it paginates
|
|
323
|
+
manually.
|
|
324
|
+
"""
|
|
325
|
+
runs = []
|
|
326
|
+
for page in range(1, 101):
|
|
327
|
+
data = self.gh(
|
|
328
|
+
"GET", f"/actions/runs?head_sha={sha}&per_page=100&page={page}"
|
|
329
|
+
)
|
|
330
|
+
batch = data.get("workflow_runs") or []
|
|
331
|
+
runs.extend(batch)
|
|
332
|
+
if len(runs) >= int(data.get("total_count") or 0) or not batch:
|
|
333
|
+
return runs
|
|
334
|
+
raise RuntimeError("GitHub workflow-run pagination exceeded limit")
|
|
335
|
+
|
|
168
336
|
def completed_dependency(self, number):
|
|
169
337
|
if number in self._completed_dependencies:
|
|
170
338
|
return self._completed_dependencies[number]
|
package/pyproject.toml
CHANGED
|
@@ -18,7 +18,7 @@ from importlib.metadata import PackageNotFoundError, version
|
|
|
18
18
|
#: Kept in lock-step with pyproject.toml/package.json by the version test.
|
|
19
19
|
#: release-please only rewrites a semver found on the annotated line itself, so
|
|
20
20
|
#: the annotation must stay on the assignment; on its own line it is a no-op.
|
|
21
|
-
_FALLBACK_VERSION = "0.
|
|
21
|
+
_FALLBACK_VERSION = "0.26.0" # x-release-please-version
|
|
22
22
|
|
|
23
23
|
try:
|
|
24
24
|
__version__: str = version("openhands-extensions")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail if any plugin ships non-source noise in its SDK-visible resource dirs.
|
|
3
|
+
|
|
4
|
+
The OpenHands SDK's skill loader (`openhands.sdk.skills`) walks a fixed set of
|
|
5
|
+
directory names under each plugin and lists everything it finds in that plugin's
|
|
6
|
+
`SkillResources` manifest:
|
|
7
|
+
|
|
8
|
+
>>> from openhands.sdk.skills import RESOURCE_DIRECTORIES
|
|
9
|
+
>>> RESOURCE_DIRECTORIES
|
|
10
|
+
('scripts', 'references', 'assets')
|
|
11
|
+
|
|
12
|
+
Anything that lands in one of those directories becomes visible to the agent at
|
|
13
|
+
skill-load time. A stale ``__pycache__/warm_runtime_configs.cpython-313.pyc``
|
|
14
|
+
left behind by a developer running tests in-tree therefore gets surfaced to
|
|
15
|
+
every downstream consumer that loads the plugin as a skill.
|
|
16
|
+
|
|
17
|
+
This check scans ``plugins/<name>/{scripts,references,assets}`` for files that
|
|
18
|
+
clearly aren't skill content - Python bytecode caches, pytest caches, editor
|
|
19
|
+
backups, OS litter - and exits 1 if any are found. Keep this list narrow: it
|
|
20
|
+
should flag obvious noise, not police plugin contents.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
python scripts/check_plugin_resources.py # scan and report
|
|
24
|
+
python scripts/check_plugin_resources.py --fix # also delete offenders
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import fnmatch
|
|
31
|
+
import shutil
|
|
32
|
+
import sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
36
|
+
PLUGINS_DIR = REPO_ROOT / "plugins"
|
|
37
|
+
|
|
38
|
+
# Mirrors openhands.sdk.skills.RESOURCE_DIRECTORIES. Pinned here rather than
|
|
39
|
+
# imported so the check runs without the SDK installed in CI.
|
|
40
|
+
RESOURCE_DIRECTORIES = ("scripts", "references", "assets")
|
|
41
|
+
|
|
42
|
+
# Names (dirs or files) that should never appear under a resource directory.
|
|
43
|
+
# Dir patterns match against directory basenames; file patterns are fnmatch'd
|
|
44
|
+
# against file basenames.
|
|
45
|
+
_FORBIDDEN_DIR_NAMES = frozenset({"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"})
|
|
46
|
+
_FORBIDDEN_FILE_GLOBS = ("*.pyc", "*.pyo", "*.pyd", "*.swp", "*.swo", ".DS_Store", "Thumbs.db")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_forbidden_file(name: str) -> bool:
|
|
50
|
+
return any(fnmatch.fnmatchcase(name, pat) for pat in _FORBIDDEN_FILE_GLOBS)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def scan() -> list[Path]:
|
|
54
|
+
offenders: list[Path] = []
|
|
55
|
+
if not PLUGINS_DIR.is_dir():
|
|
56
|
+
return offenders
|
|
57
|
+
for plugin_dir in sorted(PLUGINS_DIR.iterdir()):
|
|
58
|
+
if not plugin_dir.is_dir():
|
|
59
|
+
continue
|
|
60
|
+
for resource_name in RESOURCE_DIRECTORIES:
|
|
61
|
+
resource_dir = plugin_dir / resource_name
|
|
62
|
+
if not resource_dir.is_dir():
|
|
63
|
+
continue
|
|
64
|
+
for path in sorted(resource_dir.rglob("*")):
|
|
65
|
+
if path.is_dir() and path.name in _FORBIDDEN_DIR_NAMES:
|
|
66
|
+
offenders.append(path)
|
|
67
|
+
elif path.is_file() and _is_forbidden_file(path.name):
|
|
68
|
+
offenders.append(path)
|
|
69
|
+
return offenders
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main() -> int:
|
|
73
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--fix",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="Delete offending files/dirs instead of just failing.",
|
|
78
|
+
)
|
|
79
|
+
args = parser.parse_args()
|
|
80
|
+
|
|
81
|
+
offenders = scan()
|
|
82
|
+
if not offenders:
|
|
83
|
+
print(f"OK: no non-source files found under plugins/*/{{{','.join(RESOURCE_DIRECTORIES)}}}/")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
print(
|
|
87
|
+
f"ERROR: {len(offenders)} non-source entries found under plugin resource "
|
|
88
|
+
f"directories. These would be surfaced to agents in SkillResources:",
|
|
89
|
+
file=sys.stderr,
|
|
90
|
+
)
|
|
91
|
+
for path in offenders:
|
|
92
|
+
kind = "dir " if path.is_dir() else "file"
|
|
93
|
+
print(f" {kind} {path.relative_to(REPO_ROOT)}", file=sys.stderr)
|
|
94
|
+
|
|
95
|
+
if args.fix:
|
|
96
|
+
# Delete deepest paths first so a child file inside a to-be-removed
|
|
97
|
+
# __pycache__ directory doesn't vanish out from under its own unlink.
|
|
98
|
+
for path in sorted(offenders, key=lambda p: len(p.parts), reverse=True):
|
|
99
|
+
if path.is_dir():
|
|
100
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
101
|
+
else:
|
|
102
|
+
path.unlink(missing_ok=True)
|
|
103
|
+
print(f"\nRemoved {len(offenders)} entries.", file=sys.stderr)
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
print(
|
|
107
|
+
"\nRe-run with --fix to delete, or add the pattern to .gitignore and "
|
|
108
|
+
"clean your working tree. These are typically produced by running tests "
|
|
109
|
+
"in-tree without PYTHONDONTWRITEBYTECODE=1.",
|
|
110
|
+
file=sys.stderr,
|
|
111
|
+
)
|
|
112
|
+
return 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
sys.exit(main())
|
|
@@ -9,7 +9,8 @@ Four sync tasks, runnable individually or all at once:
|
|
|
9
9
|
3. **coverage** — warn when a skill/plugin directory is not listed in any
|
|
10
10
|
marketplace, or a marketplace entry points to a missing directory.
|
|
11
11
|
4. **symlinks** — enforce ``.plugin/`` as the canonical manifest directory
|
|
12
|
-
with vendor
|
|
12
|
+
with vendor manifests (``.claude-plugin``, ``.codex-plugin``), each
|
|
13
|
+
either a symlink or a real directory mirroring ``plugin.json``.
|
|
13
14
|
|
|
14
15
|
Usage:
|
|
15
16
|
python scripts/sync_extensions.py # run all, write changes
|
|
@@ -375,13 +376,30 @@ def sync_coverage(*, check: bool) -> list[str]:
|
|
|
375
376
|
return problems
|
|
376
377
|
|
|
377
378
|
|
|
378
|
-
# ── 4. Vendor
|
|
379
|
+
# ── 4. Vendor manifests ─────────────────────────────────────────────────
|
|
379
380
|
|
|
380
381
|
VENDOR_SYMLINKS = [".claude-plugin", ".codex-plugin"] # add new vendors here
|
|
381
382
|
|
|
382
383
|
|
|
384
|
+
def _vendor_manifest_matches(directory: Path, vendor: str) -> bool:
|
|
385
|
+
"""True when a real vendor directory mirrors the canonical manifest."""
|
|
386
|
+
src = directory / ".plugin" / "plugin.json"
|
|
387
|
+
dst = directory / vendor / "plugin.json"
|
|
388
|
+
return (
|
|
389
|
+
src.is_file()
|
|
390
|
+
and dst.is_file()
|
|
391
|
+
and src.read_bytes() == dst.read_bytes()
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
383
395
|
def _check_vendor_symlinks(directory: Path, check: bool) -> list[str]:
|
|
384
|
-
"""Check/fix vendor
|
|
396
|
+
"""Check/fix vendor manifests for a single directory with .plugin/.
|
|
397
|
+
|
|
398
|
+
A vendor path is either a symlink to ``.plugin/`` or a real directory
|
|
399
|
+
whose ``plugin.json`` mirrors the canonical one. Real directories exist
|
|
400
|
+
because some installers (e.g. Codex ``plugin add``) drop symlinked
|
|
401
|
+
directories instead of copying them; see skills/iterate.
|
|
402
|
+
"""
|
|
385
403
|
problems: list[str] = []
|
|
386
404
|
canon = directory / ".plugin"
|
|
387
405
|
if not canon.is_dir():
|
|
@@ -393,6 +411,15 @@ def _check_vendor_symlinks(directory: Path, check: bool) -> list[str]:
|
|
|
393
411
|
if target == canon.resolve():
|
|
394
412
|
continue
|
|
395
413
|
problems.append(f"wrong target: {link.relative_to(REPO_ROOT)} → {link.readlink()}")
|
|
414
|
+
elif link.is_dir():
|
|
415
|
+
if _vendor_manifest_matches(directory, vendor):
|
|
416
|
+
continue
|
|
417
|
+
problems.append(f"stale manifest copy: {link.relative_to(REPO_ROOT)}")
|
|
418
|
+
if not check:
|
|
419
|
+
(link / "plugin.json").write_bytes(
|
|
420
|
+
(canon / "plugin.json").read_bytes()
|
|
421
|
+
)
|
|
422
|
+
continue
|
|
396
423
|
elif link.exists():
|
|
397
424
|
problems.append(f"not a symlink: {link.relative_to(REPO_ROOT)}")
|
|
398
425
|
continue
|
|
@@ -405,11 +432,11 @@ def _check_vendor_symlinks(directory: Path, check: bool) -> list[str]:
|
|
|
405
432
|
|
|
406
433
|
|
|
407
434
|
def sync_symlinks(*, check: bool) -> list[str]:
|
|
408
|
-
"""Ensure every directory with .plugin/
|
|
435
|
+
"""Ensure every directory with .plugin/ has discoverable vendor manifests.
|
|
409
436
|
|
|
410
437
|
Scans both plugins/ and skills/ directories. Skills that ship a
|
|
411
438
|
``.plugin/`` manifest (e.g. those with ``commands/``) need vendor
|
|
412
|
-
|
|
439
|
+
manifests so that Codex and Claude Code can discover them.
|
|
413
440
|
"""
|
|
414
441
|
problems: list[str] = []
|
|
415
442
|
for base in SKILL_DIRS:
|
|
@@ -22,7 +22,7 @@ Treat apps as trusted, same-realm browser code owned by the active Agent Server.
|
|
|
22
22
|
|
|
23
23
|
## Prefer the proven App authoring loop
|
|
24
24
|
|
|
25
|
-
Treat an App as one authenticated browser dependency graph that Canvas imports from a Blob URL. Do not build a hosted SPA: the production result must be exactly one self-contained browser ESM entrypoint that exports `activate(host)`. Always ship this UI package even when it uses a
|
|
25
|
+
Treat an App as one authenticated browser dependency graph that Canvas imports from a Blob URL. Do not build a hosted SPA: the production result must be exactly one self-contained browser ESM entrypoint that exports `activate(host)`. Always ship this UI package even when it uses an optional package-owned managed backend or a manual/deployment-owned Sidecar. Make the App page the guided UI for service setup after the App is opened. Never treat a service as another browser chunk or an automatic extension install hook.
|
|
26
26
|
|
|
27
27
|
For a new App, create an independent package in the target repository and implement its own UI, tests, and build tooling. Follow the Vite library-build reference in `references/packaging-recipes.md`; do not copy a shared starter or introduce a repository-wide runtime/workspace unless the target repository explicitly requires it.
|
|
28
28
|
|
|
@@ -41,7 +41,9 @@ Treat the static validator as a gate, not a replacement for the browser Blob smo
|
|
|
41
41
|
|
|
42
42
|
Start by locating the target directory instead of assuming the current workspace. Inspect repository instructions, existing package management, build tooling, tests, and git status before editing.
|
|
43
43
|
|
|
44
|
-
Make an early architecture decision: browser-only App, Agent Server-integrated App,
|
|
44
|
+
Make an early architecture decision: browser-only App, Agent Server-integrated App, package-owned managed-backend App, manual/deployment-owned Sidecar App, or explicitly deployment-specific App. Keep the portable host API 1 surface separate from optional Agent Server/host capabilities and deployment-owned behavior.
|
|
45
|
+
|
|
46
|
+
Use the package-owned managed-backend path only when the target stack includes the lifecycle contract from [software-agent-sdk PR #5270](https://github.com/OpenHands/software-agent-sdk/pull/5270) at `ac7b322ddf7d2e0e90a3643c5e26d848236af9a3`, the bridge contract from [PR #5272](https://github.com/OpenHands/software-agent-sdk/pull/5272) at `6d9c82973e765cce4843d6a2fb52f634efa64293`, and the host version named in `references/v1-contract.md`. Until a release containing both SDK PRs lands, those exact revisions are the minimum Agent Server implementation. Feature-detect `canvas_app_backend_bridge_v1` and `app_backend_ingress_url` through `GET /server_info`; absence is an actionable unsupported state. Do not convert manual Sidecars into managed backends unless the App owns checksum-pinned artifacts for every supported platform.
|
|
45
47
|
|
|
46
48
|
Clarify only choices that materially affect implementation:
|
|
47
49
|
|
|
@@ -99,6 +101,33 @@ Use this minimal manifest shape:
|
|
|
99
101
|
}
|
|
100
102
|
```
|
|
101
103
|
|
|
104
|
+
For an optional package-owned managed backend, add this top-level sibling of `contributes`:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
"backend": {
|
|
108
|
+
"schema_version": 1,
|
|
109
|
+
"artifacts": {
|
|
110
|
+
"linux-amd64": {
|
|
111
|
+
"path": "backend/linux-amd64.tar.gz",
|
|
112
|
+
"sha256": "<64 lowercase hex characters>"
|
|
113
|
+
},
|
|
114
|
+
"linux-arm64": {
|
|
115
|
+
"path": "backend/linux-arm64.tar.gz",
|
|
116
|
+
"sha256": "<64 lowercase hex characters>"
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
"argv": [
|
|
120
|
+
"{artifact_dir}/bin/server",
|
|
121
|
+
"--host=127.0.0.1",
|
|
122
|
+
"--port={port}",
|
|
123
|
+
"--data-dir={data_dir}"
|
|
124
|
+
],
|
|
125
|
+
"health": { "path": "/health" }
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
This declaration is optional. Omit it for browser-only Apps and manual/deployment-owned Sidecars. It never authorizes automatic preparation or startup. The server supports Linux amd64 and arm64 initially, verifies package-contained `.tar.gz` artifacts by SHA-256, and executes the structured argv without a shell. `argv[0]` must remain inside `{artifact_dir}`; allowed placeholders are `{artifact_dir}`, `{data_dir}`, and `{port}`.
|
|
130
|
+
|
|
102
131
|
Follow these invariants:
|
|
103
132
|
|
|
104
133
|
- Use lowercase letters, digits, and hyphens for app names and page IDs.
|
|
@@ -146,6 +175,8 @@ Prefer [`@openhands/typescript-client`](https://github.com/OpenHands/software-ag
|
|
|
146
175
|
Respect the host API 1 connection boundary:
|
|
147
176
|
|
|
148
177
|
- Treat `host.agentServer.request` as the portable Agent Server HTTP path; Canvas selects the active backend and supplies authentication.
|
|
178
|
+
- For a managed-backend view, require the feature-detected host helper documented in `references/v1-contract.md`. The helper owns session creation, validated iframe isolation, retry/new-tab behavior, and revocation; the App never reads the app-scoped cookie or constructs the ingress URL. The ingress must be a separate browser origin from Canvas, and the host must preserve the server-returned sandbox including `allow-same-origin`.
|
|
179
|
+
- Installing, enabling, activating, or mounting an App never prepares or starts a managed backend. Show the resolved revision and declaration, obtain explicit lifecycle consent, and call prepare then start with that exact revision. An update invalidates approval.
|
|
149
180
|
- Do not derive an Agent Server URL from `window.location` or extract session keys from Canvas internals.
|
|
150
181
|
- Do not send Automation service requests through `host.agentServer.request` unless the backend explicitly documents an Agent Server proxy for them. Automation is a separate service with deployment-provided base URL and authentication.
|
|
151
182
|
- Do not open a direct Agent Server WebSocket from a portable v1 app. The host currently exposes neither the owning server origin nor a WebSocket/auth capability. Poll through `host.agentServer.request`, add a backend-owned bridge, or feature-detect a future host subscription API.
|
|
@@ -211,7 +242,7 @@ Treat validator warnings as prompts for inspection, not proof of invalidity. The
|
|
|
211
242
|
|
|
212
243
|
## Install and verify safely
|
|
213
244
|
|
|
214
|
-
Install only when requested. Installation and enablement are separate product actions: installation must leave the app disabled, and enabling executes trusted same-realm code.
|
|
245
|
+
Install only when requested. Installation and enablement are separate product actions: installation must leave the app disabled, and enabling executes trusted same-realm code. Managed-backend preparation and startup are additional explicit actions tied to the resolved revision; installation, enablement, activation, and ordinary page mounts never execute backend code.
|
|
215
246
|
|
|
216
247
|
For a backend-local app, install the path as interpreted on the Agent Server machine. For a Git-hosted app, provide `source`, optional `ref`, and optional `repo_path`. Never assume a frontend-local path exists inside a remote or containerized backend.
|
|
217
248
|
|
|
@@ -7,3 +7,17 @@ For command-backed Apps, expose fixed command templates only. Validate paths, pa
|
|
|
7
7
|
Keep browser-local data namespaced by App name and backend ID in IndexedDB/OPFS/localStorage. Put backend-shared mutable state below `<agent-server-home>/.openhands/apps/<app-name>/`, after discovering the home through `GET /api/file/home` and validating the absolute path. Never write mutable state beside the installed manifest or bundle.
|
|
8
8
|
|
|
9
9
|
For a CLI, binary, compiler, database, or Sidecar, read [sidecar-pattern.md](sidecar-pattern.md). Probe without mutation; disclose exact downloads, builds, data paths, checksums, and process behavior; and gate installation behind acknowledgement. Do not expose general command execution, shell interpolation, silent or unpinned downloads, unauthenticated non-loopback binding, mutable state beside the installed bundle, or automatic data deletion when an App is removed.
|
|
10
|
+
|
|
11
|
+
## Package-owned managed backends
|
|
12
|
+
|
|
13
|
+
The managed-backend contract requires the stacked, unreleased Agent Server changes in [software-agent-sdk PR #5270](https://github.com/OpenHands/software-agent-sdk/pull/5270) at `ac7b322ddf7d2e0e90a3643c5e26d848236af9a3` and [PR #5272](https://github.com/OpenHands/software-agent-sdk/pull/5272) at `6d9c82973e765cce4843d6a2fb52f634efa64293`. Until a release containing both lands, those exact revisions are the minimum Agent Server implementation. Feature-detect `canvas_app_backend_bridge_v1` through `GET /server_info`; never infer support from an endpoint failure, Canvas internals, or a guessed port. Use the host helper only at the minimum host version documented in [v1-contract.md](v1-contract.md).
|
|
14
|
+
|
|
15
|
+
A managed backend is declarative, not an install hook. Installation, enablement, activation, and ordinary page mounts never prepare or start it. Show the resolved revision and backend declaration, then require explicit user consent before calling prepare and start with that exact revision. Updating or replacing the package invalidates approval. Never put a credential in the manifest, argv, browser bundle, URL, or log.
|
|
16
|
+
|
|
17
|
+
For embedded backend views, require the configured ingress to be a separate browser origin from Canvas. That origin is the isolation boundary. Apply the server-returned sandbox exactly, including `allow-same-origin`; removing it breaks Origin validation, partitioned-cookie delivery, and Worker creation in Chromium. Do not replace origin isolation with an opaque sandbox origin.
|
|
18
|
+
|
|
19
|
+
The Agent Server selects the current platform artifact, verifies its lowercase SHA-256 checksum, extracts it safely to immutable content-addressed storage, and runs only the declared structured argv without a shell. The executable must remain inside `{artifact_dir}`. Supported placeholders are `{artifact_dir}`, `{data_dir}`, and `{port}`. Inherited environment names are restricted to the server's non-credential allowlist; ambient Agent Server credentials do not flow to the child.
|
|
20
|
+
|
|
21
|
+
Only local Linux amd64 and arm64 artifacts are supported initially. Treat `unsupported` as an explicit UI state. The owned process is constrained by its launch contract and process group but is not OS-isolated; run untrusted backend code only inside a suitably isolated Agent Server runtime.
|
|
22
|
+
|
|
23
|
+
Backend data belongs to the service independently of the installed bundle. Disable and uninstall stop the owned process and revoke browser sessions but preserve mutable data. Data deletion is a separate explicit action. Installed Apps and their backends belong to one Agent Server; do not assume a service or its data is shared across isolated conversation runtimes.
|
|
@@ -48,6 +48,30 @@ Supply the actual Agent Server origin, including any reverse-proxy path prefix.
|
|
|
48
48
|
|
|
49
49
|
If typed high-level clients cover the operation, prefer those over the generic `HttpClient`. Use the generic client for uncovered Agent Server endpoints, not as a reason to duplicate authentication and error handling with `fetch`.
|
|
50
50
|
|
|
51
|
+
## Managed App backend views
|
|
52
|
+
|
|
53
|
+
The managed-backend bridge requires the stacked, unreleased Agent Server changes in [software-agent-sdk PR #5270](https://github.com/OpenHands/software-agent-sdk/pull/5270) at `ac7b322ddf7d2e0e90a3643c5e26d848236af9a3` and [PR #5272](https://github.com/OpenHands/software-agent-sdk/pull/5272) at `6d9c82973e765cce4843d6a2fb52f634efa64293`. Until a release containing both lands, those exact revisions are the minimum Agent Server implementation. Read `GET /server_info`: support is present only when `capabilities` contains `canvas_app_backend_bridge_v1` and `app_backend_ingress_url` supplies the separately configured browser origin. If either is absent, render an actionable unsupported state. Never guess localhost, derive an origin from `window.location`, or read Canvas credentials.
|
|
54
|
+
|
|
55
|
+
The typed `CanvasExtensionsClient`, constructed with the normal Agent Server host and API key plus `appBackendIngressUrl`, exposes `createAppBackendSession(name)` and `revokeAppBackendSession(name)`. Session creation returns `ingress_url`, `expires_at`, and `iframe_sandbox`. The app-scoped credential is an HttpOnly, Secure, SameSite=None, Partitioned cookie; it is not returned to browser JavaScript or placed in a URL.
|
|
56
|
+
|
|
57
|
+
Control calls go to the configured ingress origin:
|
|
58
|
+
|
|
59
|
+
- `POST /app-backends/{name}/session` creates a short-lived session for a ready owned backend.
|
|
60
|
+
- `DELETE /app-backends/{name}/session` revokes it and active sockets.
|
|
61
|
+
|
|
62
|
+
Both calls require normal `X-Session-API-Key` control authentication and an allowed Canvas `Origin`. Use the credential-free returned `ingress_url`, rooted at `/app-backends/{name}/`, for iframe assets, streaming, ranges, and relative WebSocket URLs. Apply the returned sandbox exactly; the v1 bridge returns `allow-forms allow-modals allow-popups allow-same-origin allow-scripts`. The configured ingress must be a separate browser origin from Canvas and is the isolation boundary. Do not remove `allow-same-origin`: real Chromium sends `Origin: null` for mutating fetches and WebSockets, drops the partitioned session cookie, and rejects same-site Worker creation with `SecurityError` when the frame has an opaque sandbox origin. HTTPS, or a loopback secure context, and browser partitioned-cookie support are required for third-party iframe operation.
|
|
63
|
+
|
|
64
|
+
Apps consume this flow through optional `host.appBackendView`, not by calling the control API or constructing an iframe URL. Feature-detect it before rendering a managed view:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
const disposeView = host.appBackendView?.mount({ container });
|
|
68
|
+
if (!disposeView) {
|
|
69
|
+
container.textContent = "This Canvas host cannot open the App backend view.";
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`mount({ container })` returns a disposer. Call it on page unmount; Canvas also disposes views on backend switch and extension deactivation. The helper owns session creation, URL and sandbox validation, loading, retry, safe new-tab fallback, and revocation. Mounting or remounting never starts or stops the service. Use the helper only at the minimum host version documented in [v1-contract.md](v1-contract.md).
|
|
74
|
+
|
|
51
75
|
## Automation backend
|
|
52
76
|
|
|
53
77
|
Treat the Automation service as a separate backend. Its API is normally mounted under `/api/automation/v1`, but the origin, path prefix, and authentication mode are deployment-provided values rather than Agent Server defaults.
|