@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.
Files changed (133) hide show
  1. package/.claude-plugin/marketplace.json +30 -0
  2. package/.codex-plugin/marketplace.json +30 -0
  3. package/.github/pull_request_template.md +17 -2
  4. package/.github/scripts/check_pr_description.py +64 -5
  5. package/.github/scripts/markdown_sections.py +95 -0
  6. package/.github/workflows/build-vscode-app.yml +30 -0
  7. package/.github/workflows/check-extensions.yml +3 -0
  8. package/.github/workflows/tests.yml +26 -0
  9. package/.plugin/marketplace.json +30 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +10 -3
  13. package/apps/README.md +20 -0
  14. package/apps/catalog.json +10 -0
  15. package/apps/index.d.ts +11 -0
  16. package/apps/index.js +5 -0
  17. package/apps/vscode/README.md +13 -0
  18. package/apps/vscode/canvas-extension.json +19 -0
  19. package/apps/vscode/extension.js +114 -0
  20. package/apps/vscode/scripts/package.mjs +84 -0
  21. package/automations/bundle-index.js +18 -14
  22. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  23. package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
  24. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
  25. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  26. package/automations/catalog/news-digest/manifest.json +2 -2
  27. package/automations/catalog-index.js +2 -0
  28. package/index.d.ts +2 -0
  29. package/index.js +1 -0
  30. package/marketplaces/openhands-extensions.json +30 -0
  31. package/package.json +9 -1
  32. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  33. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  34. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  35. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  36. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  37. package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
  38. package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
  39. package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
  40. package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
  41. package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
  42. package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
  43. package/plugins/runtime-api-configs/README.md +59 -0
  44. package/plugins/runtime-api-configs/SKILL.md +240 -0
  45. package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
  46. package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
  47. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
  48. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +183 -15
  49. package/pyproject.toml +1 -1
  50. package/python/openhands_extensions/_version.py +1 -1
  51. package/scripts/check_plugin_resources.py +116 -0
  52. package/scripts/sync_extensions.py +32 -5
  53. package/skills/canvas-extension-api/SKILL.md +34 -3
  54. package/skills/canvas-extension-api/references/backend-safety.md +14 -0
  55. package/skills/canvas-extension-api/references/connections.md +24 -0
  56. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
  57. package/skills/canvas-extension-api/references/v1-contract.md +53 -3
  58. package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
  59. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
  60. package/skills/github/scripts/agent_conversation.py +223 -43
  61. package/skills/github/scripts/github_client.py +183 -15
  62. package/skills/github-agents-md-maintainer/README.md +7 -0
  63. package/skills/github-agents-md-maintainer/SKILL.md +13 -6
  64. package/skills/github-agents-md-maintainer/scripts/github_client.py +183 -15
  65. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  66. package/skills/github-delivery-watchdog/scripts/github_client.py +183 -15
  67. package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
  68. package/skills/github-issue-to-pr/scripts/github_client.py +183 -15
  69. package/skills/github-issue-to-pr/scripts/worker.py +10 -1
  70. package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
  71. package/skills/github-issue-triage/scripts/github_client.py +183 -15
  72. package/skills/github-pr-review/SKILL.md +12 -2
  73. package/skills/github-pr-reviewer/README.md +36 -0
  74. package/skills/github-pr-reviewer/SKILL.md +147 -4
  75. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  76. package/skills/github-pr-reviewer/scripts/github_client.py +183 -15
  77. package/skills/github-pr-reviewer/scripts/main.py +17 -1
  78. package/skills/github-pr-reviewer/scripts/worker.py +842 -57
  79. package/skills/github-repo-monitor/scripts/github_client.py +183 -15
  80. package/skills/github-repo-monitor/scripts/main.py +32 -1
  81. package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
  82. package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
  83. package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
  84. package/skills/github-stale-ci-pr-closer/README.md +9 -0
  85. package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
  86. package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
  87. package/skills/github-stale-ci-pr-closer/scripts/worker.py +508 -0
  88. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +353 -0
  89. package/skills/gitlab-issue-to-mr/README.md +12 -0
  90. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  91. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  92. package/skills/incident-retrospective/SKILL.md +5 -5
  93. package/skills/index.js +34 -16
  94. package/skills/jira-issue-to-pr/README.md +12 -4
  95. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  96. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  97. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  98. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  99. package/skills/linear-triage/SKILL.md +5 -5
  100. package/skills/news-digest/README.md +6 -0
  101. package/skills/news-digest/SKILL.md +19 -9
  102. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  103. package/skills/news-digest/scripts/main.py +133 -15
  104. package/skills/openhands-api/README.md +1 -1
  105. package/skills/openhands-api/SKILL.md +3 -3
  106. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  107. package/skills/openhands-automation/SKILL.md +66 -4
  108. package/skills/openhands-automation/references/custom-automation.md +21 -7
  109. package/skills/openhands-sdk/SKILL.md +1 -0
  110. package/skills/research-brief/SKILL.md +5 -5
  111. package/skills/slack-standup-digest/SKILL.md +5 -5
  112. package/skills/ticket-to-code-change/SKILL.md +6 -3
  113. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  114. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  115. package/skills/use-jev/.plugin/plugin.json +8 -0
  116. package/skills/use-jev/README.md +66 -0
  117. package/skills/use-jev/SKILL.md +66 -0
  118. package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
  119. package/tests/test_agent_conversation_dispatch.py +212 -0
  120. package/tests/test_apps_catalog.py +26 -0
  121. package/tests/test_automation_setup.py +44 -0
  122. package/tests/test_check_pr_description.py +89 -6
  123. package/tests/test_github_automation_foundation.py +158 -0
  124. package/tests/test_github_repo_monitor.py +107 -0
  125. package/tests/test_github_reviewer_delivery.py +2247 -6
  126. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  127. package/tests/test_jira_issue_to_pr.py +312 -0
  128. package/tests/test_jira_setup_webhook.py +309 -0
  129. package/tests/test_openhands_api_python.py +37 -0
  130. package/tests/test_skill_plugin_loading.py +25 -15
  131. package/tests/test_sync_extensions.py +102 -0
  132. package/tests/test_vscode_app.py +33 -0
  133. package/tests/vscode-app.test.mjs +43 -0
@@ -1,29 +1,68 @@
1
1
  # Sidecar-backed Apps
2
2
 
3
- Use this reference when an App needs a separately installed service. Host API 1 supports a browser App package and authenticated Agent Server HTTP requests; it does not provide Sidecar installation, process management, connection brokering, Automation adaptation, arbitrary install hooks, or an Agent Server WebSocket capability.
3
+ Use this reference when an App needs a service in addition to its browser bundle. Choose either an optional package-owned managed backend, when the target Agent Server and Canvas host expose that capability, or a manual/deployment-owned Sidecar. Browser-only Apps remain valid.
4
+
5
+ The managed-backend contract is available on the stacked, unreleased Agent Server changes in [software-agent-sdk PR #5270](https://github.com/OpenHands/software-agent-sdk/pull/5270) at `ac7b322ddf7d2e0e90a3643c5e26d848236af9a3` and [PR #5272](https://github.com/OpenHands/software-agent-sdk/pull/5272) at `6d9c82973e765cce4843d6a2fb52f634efa64293`. Until a release containing both lands, those exact revisions are the minimum Agent Server implementation. Feature-detect `canvas_app_backend_bridge_v1` in `GET /server_info`; absence means the managed view path is unsupported, not that the App should guess a port or fall back to hidden Canvas state. Use the host helper only at the minimum host version documented in [v1-contract.md](v1-contract.md).
4
6
 
5
7
  ## Keep three boundaries
6
8
 
7
- 1. **Canvas App package**: ship `canvas-extension.json` and exactly one self-contained browser ESM entrypoint exporting `activate(host)`. Bundle UI dependencies, CSS, and required assets into that entrypoint.
8
- 2. **Sidecar distribution**: distribute the service, its versioned artifacts, and its service-specific installation and operation mechanism separately.
9
- 3. **Onboarding and operator layer**: implement this as a guided UI in the App view. Present status, disclosures, explicit consent, and lifecycle actions there. Do not infer that installing or enabling the Canvas App authorizes any Sidecar mutation.
9
+ 1. **Canvas App UI**: always ship `canvas-extension.json` and exactly one self-contained browser ESM entrypoint exporting `activate(host)`. Bundle UI dependencies, CSS, and required assets into that entrypoint.
10
+ 2. **Service distribution and ownership**: for a managed backend, declare checksum-pinned platform artifacts in the same package and let the owning Agent Server prepare and run them. For a manual Sidecar, distribute and operate the service separately. Do not combine the models or add a general install hook.
11
+ 3. **Onboarding and operator layer**: implement this as guided UI in the App view. Present status, disclosures, explicit revision-bound consent, and lifecycle actions there. Installing or enabling the App never authorizes backend preparation or startup.
12
+
13
+ ## Declare a package-owned managed backend
14
+
15
+ Add the optional `backend` block only when the App owns its service artifacts:
16
+
17
+ ```json
18
+ {
19
+ "backend": {
20
+ "schema_version": 1,
21
+ "artifacts": {
22
+ "linux-amd64": {
23
+ "path": "backend/linux-amd64.tar.gz",
24
+ "sha256": "<64 lowercase hex characters>"
25
+ },
26
+ "linux-arm64": {
27
+ "path": "backend/linux-arm64.tar.gz",
28
+ "sha256": "<64 lowercase hex characters>"
29
+ }
30
+ },
31
+ "argv": [
32
+ "{artifact_dir}/bin/server",
33
+ "--host=127.0.0.1",
34
+ "--port={port}",
35
+ "--data-dir={data_dir}"
36
+ ],
37
+ "health": {
38
+ "path": "/health",
39
+ "timeout_seconds": 30,
40
+ "interval_seconds": 0.1
41
+ },
42
+ "inherit_environment": ["LANG", "PATH", "TZ"]
43
+ }
44
+ }
45
+ ```
46
+
47
+ Artifact paths are package-relative `.tar.gz` files. The executable in `argv[0]` must resolve inside `{artifact_dir}`. The argv is a structured string list, never shell source; supported placeholders are `{artifact_dir}`, `{data_dir}`, and `{port}`. The current server supports Linux amd64 and arm64 and reports other platforms as `unsupported`.
10
48
 
11
49
  ## Choose a connection boundary
12
50
 
13
51
  Prefer connection patterns in this order:
14
52
 
15
- 1. **Narrow Agent Server bridge**: add a backend-owned, authenticated endpoint for the required Sidecar operations and call it through `host.agentServer.request` with root-relative paths. Keep Sidecar credentials and routing on the backend.
16
- 2. **Documented future or deployment capability**: require a host or deployment adapter that deliberately exposes Sidecar access, feature-detect it, and document the required version and deployment contract.
17
- 3. **Direct browser connection only when explicitly non-portable**: document its origin, authentication, exposure, and deployment ownership. Do not represent this design as a portable host API 1 App.
18
- 4. **Bounded polling through the Agent Server**: use a narrow bridge for status where live events are unavailable in v1. Bound intervals, respect visibility, abort on disposal, and show stale state.
53
+ 1. **Managed backend view**: for a package-owned backend, feature-detect `host.appBackendView` and call `host.appBackendView.mount({ container })`. Canvas owns session creation, iframe isolation, ingress validation, and revocation.
54
+ 2. **Narrow Agent Server bridge**: for a manual Sidecar, add a backend-owned, authenticated endpoint for only the required operations and call it through `host.agentServer.request` with root-relative paths. Keep Sidecar credentials and routing on the backend.
55
+ 3. **Documented deployment capability**: require a deployment adapter that deliberately exposes Sidecar access, feature-detect it, and document its version and contract.
56
+ 4. **Direct browser connection only when explicitly non-portable**: document its origin, authentication, exposure, and deployment ownership. Do not represent this design as a portable host API 1 App.
57
+ 5. **Bounded polling through the Agent Server**: use a narrow bridge for status where live events are unavailable in v1. Bound intervals, respect visibility, abort on disposal, and show stale state.
19
58
 
20
59
  Reject guessed localhost names or ports, `window.location` derivation, Canvas internals, undocumented globals, and embedded credentials. Do not attempt to route a Sidecar or Automation call through `host.agentServer.request` without a documented Agent Server bridge.
21
60
 
22
61
  ## Model onboarding and operations
23
62
 
24
- Represent service state explicitly: `unknown`, `probing`, `missing`, `stopped`, `incompatible`, `ready`, and `unhealthy`. Preserve actionable failures and transition deliberately for consent, install, start, repair, upgrade, and stop failures. Treat unavailable Sidecar status as a supported UI state.
63
+ For a managed backend, use the server states exactly: `missing`, `stopped`, `starting`, `ready`, `unhealthy`, and `unsupported`. For a manual Sidecar, model additional UI-only states such as `unknown`, `probing`, and `incompatible` when needed. Preserve actionable failures and transition deliberately for consent, prepare/install, start, repair, upgrade, and stop failures.
25
64
 
26
- Before any mutating action, disclose the source, version, checksum, install and data paths, ports and network exposure, process behavior, credentials, and rollback or uninstall behavior. Obtain explicit consent for each applicable action.
65
+ Before any mutating action, disclose the source, resolved revision, version, checksums, install and data paths, ports and network exposure, process behavior, inherited environment names, and rollback or uninstall behavior. Obtain explicit consent for each applicable action. Managed-backend prepare and start calls carry the exact installed revision; updating the package invalidates prior approval.
27
66
 
28
67
  Keep these actions separate and idempotent where meaningful:
29
68
 
@@ -23,7 +23,7 @@ Inspect these files when targeting a newer OpenHands revision:
23
23
 
24
24
  Treat the active Agent Server as the owner of installed apps. Source, resolved revision, files, manifest, and enabled state live on that backend. Switching backend replaces the active app inventory.
25
25
 
26
- Treat enabled code as trusted same-realm JavaScript. No iframe, worker sandbox, granular permission boundary, or CSS security boundary exists in v1. Enabled code has the ambient browser authority available to Canvas and can use authenticated helpers for the current backend.
26
+ Treat enabled code as trusted same-realm JavaScript. No iframe, worker sandbox, granular permission boundary, or CSS security boundary exists in v1 for App-authored code or the App's own frame. Enabled code has the ambient browser authority available to Canvas and can use authenticated helpers for the current backend. The only frame in v1 is the host-owned managed-backend view, created by the `host.appBackendView` helper described below and never by the App; it isolates the backend's own content on a separate ingress origin and grants the App no authority it did not already have.
27
27
 
28
28
  Keep install and enable separate. Installation produces a disabled app. Enabling is the explicit execution consent point. Enablement and disablement are hot and require no restart.
29
29
 
@@ -42,6 +42,22 @@ interface CanvasExtensionManifest {
42
42
  contributes?: {
43
43
  pages?: CanvasExtensionPageContribution[] | null;
44
44
  } | null;
45
+ backend?: CanvasExtensionBackend | null;
46
+ }
47
+
48
+ interface CanvasExtensionBackend {
49
+ schema_version: 1;
50
+ artifacts: Partial<Record<"linux-amd64" | "linux-arm64", {
51
+ path: string;
52
+ sha256: string;
53
+ }>>;
54
+ argv: string[];
55
+ health?: {
56
+ path?: string;
57
+ timeout_seconds?: number;
58
+ interval_seconds?: number;
59
+ };
60
+ inherit_environment?: string[];
45
61
  }
46
62
 
47
63
  interface CanvasExtensionPageContribution {
@@ -53,7 +69,23 @@ interface CanvasExtensionPageContribution {
53
69
  }
54
70
  ```
55
71
 
56
- Required manifest fields are `schema_version`, `name`, `version`, and `entrypoint`. A useful page package also supplies `display_name`, `description`, and at least one `contributes.pages` entry.
72
+ Required manifest fields are `schema_version`, `name`, `version`, and `entrypoint`. A useful page package also supplies `display_name`, `description`, and at least one `contributes.pages` entry. `backend` is optional: omitting it preserves a browser-only App or a manual/deployment-owned Sidecar.
73
+
74
+ The managed-backend contract requires the stacked, unreleased Agent Server changes in [software-agent-sdk PR #5270](https://github.com/OpenHands/software-agent-sdk/pull/5270) at `ac7b322ddf7d2e0e90a3643c5e26d848236af9a3` and [PR #5272](https://github.com/OpenHands/software-agent-sdk/pull/5272) at `6d9c82973e765cce4843d6a2fb52f634efa64293`. Until a release containing both lands, those exact revisions are the minimum Agent Server implementation. Feature-detect `canvas_app_backend_bridge_v1` in `GET /server_info`; the same response must provide `app_backend_ingress_url`.
75
+
76
+ The minimum host is the Agent Canvas revision that adds the optional `host.appBackendView` helper specified under Host API 1 below — the frontend change in OpenHands/OpenHands#17641, which this document's PR lists as a merge prerequisite. No upstream release pins that helper yet, so there is no release number to name; until there is, the minimum host is exactly "the build whose `host.appBackendView` is present", and feature-detecting that helper is the only supported test. Absence is an actionable unsupported state, not a reason to guess an ingress port or read Canvas internals.
77
+
78
+ All references in this bundle to "the minimum host version documented in `v1-contract.md`" mean this paragraph.
79
+
80
+ For a managed backend:
81
+
82
+ - Key `artifacts` only by `linux-amd64` or `linux-arm64`; one matching entry is sufficient.
83
+ - Keep each artifact `path` package-relative, contained, and ending in `.tar.gz`.
84
+ - Set `sha256` to exactly 64 lowercase hexadecimal characters.
85
+ - Use a non-empty structured `argv`; `argv[0]` must resolve inside `{artifact_dir}`.
86
+ - Use only `{artifact_dir}`, `{data_dir}`, and `{port}` placeholders.
87
+ - Keep `health.path` root-relative. Defaults are `/health`, 30 seconds, and 0.1 seconds.
88
+ - Request only `LANG`, `LC_ALL`, `LC_CTYPE`, `PATH`, `TMPDIR`, or `TZ` in `inherit_environment`, without duplicates. Ambient Agent Server credentials are never inherited.
57
89
 
58
90
  Apply these rules:
59
91
 
@@ -95,6 +127,9 @@ interface CanvasExtensionHost {
95
127
  headers?: Record<string, string>;
96
128
  }): Promise<T>;
97
129
  };
130
+ readonly appBackendView?: {
131
+ mount(options: { container: HTMLElement }): () => void;
132
+ };
98
133
  }
99
134
 
100
135
  type CanvasExtensionPageMount = (context: {
@@ -114,6 +149,11 @@ export function activate(
114
149
 
115
150
  The loader fetches authenticated JavaScript text, creates a temporary Blob URL, imports it as ESM, verifies the `activate` export, and revokes the Blob URL. Import does not activate a disabled installation.
116
151
 
152
+ `appBackendView` is additive and optional. Feature-detect it before mounting. It is absent for host builds without the helper, no-backend and cloud selections, or selected Agent Servers without `canvas_app_backend_bridge_v1`, `app_backend_ingress_url`, and the compatible typed client. `mount({ container })` returns a disposer. The helper obtains an app-scoped session, validates the HTTP(S) ingress and sandbox, renders loading/error/retry and safe new-tab fallback, and revokes the session on disposal. It does not prepare, start, stop, or delete backend data.
153
+
154
+ Every App API call is scoped to the active backend. When the user changes backend, Canvas disposes the old activation, mounted backend views, and sessions before activating the App with a fresh host snapshot.
155
+
156
+
117
157
  ## Registration constraints
118
158
 
119
159
  Register each page during activation using exactly the page ID declared in the manifest. The runtime rejects undeclared IDs and duplicate registrations.
@@ -213,6 +253,16 @@ The current Canvas frontend service calls:
213
253
  | `PATCH` | `/api/canvas-extensions/installed/{name}` | Set enabled state |
214
254
  | `DELETE` | `/api/canvas-extensions/installed/{name}` | Uninstall |
215
255
  | `GET` | `/api/canvas-extensions/installed/{name}/bundle` | Fetch entrypoint JavaScript text |
256
+ | `GET` | `/api/canvas-extensions/installed/{name}/backend` | Read managed-backend status and revision |
257
+ | `POST` | `/api/canvas-extensions/installed/{name}/backend/prepare` | Verify and prepare the approved revision |
258
+ | `POST` | `/api/canvas-extensions/installed/{name}/backend/start` | Start the prepared approved revision |
259
+ | `POST` | `/api/canvas-extensions/installed/{name}/backend/stop` | Stop the owned process group |
260
+ | `GET` | `/api/canvas-extensions/installed/{name}/backend/logs` | Read bounded backend logs |
261
+ | `DELETE` | `/api/canvas-extensions/installed/{name}/backend/data` | Separately delete preserved mutable data |
262
+
263
+ Prepare and start bodies are `{"revision":"<exact installed revision>"}`. Status is exactly `missing`, `stopped`, `starting`, `ready`, `unhealthy`, or `unsupported`. Installation, enablement, activation, and page mounting never prepare or start a backend. Updating invalidates approval. Disable and uninstall stop owned processes and revoke bridge access but preserve backend data; data deletion is separate.
264
+
265
+ The bridge advertises `canvas_app_backend_bridge_v1` and `app_backend_ingress_url` through `GET /server_info`. Its control endpoints are `POST` and `DELETE` on `/app-backends/{name}/session` at the configured ingress, which must be a separate browser origin from Canvas. Session responses contain `ingress_url`, `expires_at`, and `iframe_sandbox`; the credential is an HttpOnly cookie and never appears in the response or URL. The v1 sandbox is `allow-forms allow-modals allow-popups allow-same-origin allow-scripts`. Preserve `allow-same-origin`: the separate ingress origin is the isolation boundary, while an opaque sandbox origin causes Chromium to send `Origin: null` for mutating fetches and WebSockets, omit the partitioned cookie, and reject Worker creation with `SecurityError`.
216
266
 
217
267
  Install request:
218
268
 
@@ -243,7 +293,7 @@ Do not promise or implement these features without confirming a newer contract:
243
293
  - visualizer augmentation or replacement;
244
294
  - marketplace signing or publisher verification;
245
295
  - enforceable fine-grained permissions;
246
- - iframe or worker isolation;
296
+ - iframe or worker isolation for App-authored frames: an App must not create its own frames or Workers to obtain an isolation boundary. (A host-owned managed-backend view is the one exception and is not App-authored: it is reachable only through the feature-detected `host.appBackendView` helper, which owns the ingress origin, sandbox attributes, and session lifecycle. The App never constructs that frame, picks its URL, or reads its credential.)
247
297
  - arbitrary install lifecycle scripts;
248
298
  - automatic agent-driven enablement.
249
299
 
@@ -48,6 +48,58 @@ for (const field of ["name", "version", "entrypoint"]) {
48
48
  if (typeof manifest.name === "string" && !name.test(manifest.name)) fail("Manifest name must use lowercase kebab-case.");
49
49
  if (typeof manifest.version === "string" && !semver.test(manifest.version)) fail("Manifest version must use semantic versioning.");
50
50
 
51
+ if (manifest.backend !== undefined && manifest.backend !== null) {
52
+ const backend = manifest.backend;
53
+ if (typeof backend !== "object" || Array.isArray(backend)) {
54
+ fail("Manifest backend must be an object.");
55
+ } else {
56
+ if (backend.schema_version !== 1) fail("Manifest backend.schema_version must equal 1.");
57
+ const artifacts = backend.artifacts;
58
+ if (!artifacts || typeof artifacts !== "object" || Array.isArray(artifacts) || Object.keys(artifacts).length === 0) {
59
+ fail("Manifest backend.artifacts must be a non-empty platform map.");
60
+ } else {
61
+ for (const [platform, artifact] of Object.entries(artifacts)) {
62
+ const label = `backend.artifacts[${platform}]`;
63
+ if (!new Set(["linux-amd64", "linux-arm64"]).has(platform)) fail(`${label} uses an unsupported platform.`);
64
+ if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) { fail(`${label} must be an object.`); continue; }
65
+ if (typeof artifact.path !== "string" || !artifact.path || artifact.path.startsWith("/") || artifact.path.split(/[\\/]/).includes("..") || !artifact.path.endsWith(".tar.gz")) fail(`${label}.path must be a contained relative .tar.gz path.`);
66
+ if (typeof artifact.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(artifact.sha256)) fail(`${label}.sha256 must be a lowercase SHA-256 checksum.`);
67
+ }
68
+ }
69
+ if (!Array.isArray(backend.argv) || backend.argv.length === 0 || backend.argv.some((argument) => typeof argument !== "string" || !argument || argument.includes("\0"))) {
70
+ fail("Manifest backend.argv must be a non-empty array of strings.");
71
+ } else {
72
+ if (!backend.argv[0].startsWith("{artifact_dir}/")) {
73
+ fail("Manifest backend.argv[0] must execute from {artifact_dir}.");
74
+ } else if (backend.argv[0].slice("{artifact_dir}/".length).split(/[\\/]/).includes("..")) {
75
+ fail("Manifest backend.argv[0] must resolve inside {artifact_dir}.");
76
+ }
77
+ const allowed = new Set(["{port}", "{data_dir}", "{artifact_dir}"]);
78
+ for (const argument of backend.argv) {
79
+ for (const placeholder of argument.match(/\{[^{}]+\}/g) ?? []) {
80
+ if (!allowed.has(placeholder)) fail(`Manifest backend.argv contains unsupported placeholder ${placeholder}.`);
81
+ }
82
+ }
83
+ }
84
+ if (backend.health !== undefined) {
85
+ const health = backend.health;
86
+ if (!health || typeof health !== "object" || Array.isArray(health)) {
87
+ fail("Manifest backend.health must be an object.");
88
+ } else {
89
+ if (health.path !== undefined && (typeof health.path !== "string" || !/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/.test(health.path))) fail("Manifest backend.health.path must be a root-relative HTTP path.");
90
+ for (const [field, maximum] of [["timeout_seconds", 300], ["interval_seconds", 10]]) {
91
+ if (health[field] !== undefined && (typeof health[field] !== "number" || health[field] <= 0 || health[field] > maximum)) fail(`Manifest backend.health.${field} must be greater than 0 and at most ${maximum}.`);
92
+ }
93
+ }
94
+ }
95
+ if (backend.inherit_environment !== undefined) {
96
+ const environment = backend.inherit_environment;
97
+ const allowed = new Set(["LANG", "LC_ALL", "LC_CTYPE", "PATH", "TMPDIR", "TZ"]);
98
+ if (!Array.isArray(environment) || environment.some((variable) => typeof variable !== "string" || !allowed.has(variable)) || new Set(environment).size !== environment.length) fail("Manifest backend.inherit_environment must contain unique names from the supported non-credential allowlist.");
99
+ }
100
+ }
101
+ }
102
+
51
103
  const ids = new Set();
52
104
  const paths = new Set();
53
105
  const pages = manifest.contributes?.pages;
@@ -7,7 +7,7 @@ import test from "node:test";
7
7
 
8
8
  const validator = new URL("./validate-extension.mjs", import.meta.url);
9
9
 
10
- async function createApp(entrypoint) {
10
+ async function createApp(entrypoint, manifestOverrides = {}) {
11
11
  const root = await mkdtemp(path.join(os.tmpdir(), "canvas-extension-validator-"));
12
12
  await mkdir(path.join(root, "dist"), { recursive: true });
13
13
  await writeFile(
@@ -20,6 +20,7 @@ async function createApp(entrypoint) {
20
20
  contributes: {
21
21
  pages: [{ id: "home", title: "Home", path: "/home" }],
22
22
  },
23
+ ...manifestOverrides,
23
24
  }),
24
25
  );
25
26
  await writeFile(
@@ -29,6 +30,29 @@ async function createApp(entrypoint) {
29
30
  return root;
30
31
  }
31
32
 
33
+ function validate(root, ...options) {
34
+ return spawnSync(process.execPath, [validator.pathname, root, ...options], {
35
+ encoding: "utf8",
36
+ });
37
+ }
38
+
39
+ const backend = {
40
+ schema_version: 1,
41
+ artifacts: {
42
+ "linux-amd64": {
43
+ path: "backend/linux-amd64.tar.gz",
44
+ sha256: "a".repeat(64),
45
+ },
46
+ "linux-arm64": {
47
+ path: "backend/linux-arm64.tar.gz",
48
+ sha256: "b".repeat(64),
49
+ },
50
+ },
51
+ argv: ["{artifact_dir}/bin/service", "--port", "{port}", "--data", "{data_dir}"],
52
+ health: { path: "/health", timeout_seconds: 30, interval_seconds: 0.1 },
53
+ inherit_environment: ["LANG", "TZ"],
54
+ };
55
+
32
56
  test("--dist uses the manifest entrypoint name", async () => {
33
57
  const root = await createApp("bundle.mjs");
34
58
  try {
@@ -58,3 +82,170 @@ test("--dist rejects sibling output chunks", async () => {
58
82
  await rm(root, { recursive: true, force: true });
59
83
  }
60
84
  });
85
+
86
+ test("accepts a package-owned backend declaration", async () => {
87
+ const root = await createApp("extension.js", { backend });
88
+ try {
89
+ const result = validate(root, "--dist");
90
+ assert.equal(result.status, 0, result.stderr);
91
+ } finally {
92
+ await rm(root, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ test("rejects a backend artifact path outside the package", async () => {
97
+ const root = await createApp("extension.js", {
98
+ backend: {
99
+ ...backend,
100
+ artifacts: {
101
+ "linux-amd64": {
102
+ path: "../service.tar.gz",
103
+ sha256: "a".repeat(64),
104
+ },
105
+ },
106
+ },
107
+ });
108
+ try {
109
+ const result = validate(root, "--dist");
110
+ assert.notEqual(result.status, 0);
111
+ assert.match(
112
+ result.stderr,
113
+ /backend\.artifacts\[linux-amd64\]\.path must be a contained relative \.tar\.gz path/,
114
+ );
115
+ } finally {
116
+ await rm(root, { recursive: true, force: true });
117
+ }
118
+ });
119
+
120
+ test("rejects a backend argument vector that is not a structured list", async () => {
121
+ const root = await createApp("extension.js", {
122
+ backend: { ...backend, argv: "service --port {port}" },
123
+ });
124
+ try {
125
+ const result = validate(root, "--dist");
126
+ assert.notEqual(result.status, 0);
127
+ assert.match(
128
+ result.stderr,
129
+ /backend\.argv must be a non-empty array of strings/,
130
+ );
131
+ } finally {
132
+ await rm(root, { recursive: true, force: true });
133
+ }
134
+ });
135
+
136
+ test("rejects a backend executable outside the prepared artifact", async () => {
137
+ const root = await createApp("extension.js", {
138
+ backend: { ...backend, argv: ["bin/service", "--port", "{port}"] },
139
+ });
140
+ try {
141
+ const result = validate(root, "--dist");
142
+ assert.notEqual(result.status, 0);
143
+ assert.match(
144
+ result.stderr,
145
+ /backend\.argv\[0\] must execute from \{artifact_dir\}/,
146
+ );
147
+ } finally {
148
+ await rm(root, { recursive: true, force: true });
149
+ }
150
+ });
151
+
152
+ test("rejects a backend executable that traverses outside the artifact", async () => {
153
+ const root = await createApp("extension.js", {
154
+ backend: { ...backend, argv: ["{artifact_dir}/../outside"] },
155
+ });
156
+ try {
157
+ const result = validate(root, "--dist");
158
+ assert.notEqual(result.status, 0);
159
+ assert.match(
160
+ result.stderr,
161
+ /backend\.argv\[0\] must resolve inside \{artifact_dir\}/,
162
+ );
163
+ } finally {
164
+ await rm(root, { recursive: true, force: true });
165
+ }
166
+ });
167
+
168
+ test("rejects unsupported backend argument placeholders", async () => {
169
+ const root = await createApp("extension.js", {
170
+ backend: { ...backend, argv: ["{artifact_dir}/bin/service", "{secret}"] },
171
+ });
172
+ try {
173
+ const result = validate(root, "--dist");
174
+ assert.notEqual(result.status, 0);
175
+ assert.match(result.stderr, /backend\.argv contains unsupported placeholder \{secret\}/);
176
+ } finally {
177
+ await rm(root, { recursive: true, force: true });
178
+ }
179
+ });
180
+
181
+ test("rejects invalid backend health settings", async () => {
182
+ const root = await createApp("extension.js", {
183
+ backend: { ...backend, health: { path: "health", timeout_seconds: 0 } },
184
+ });
185
+ try {
186
+ const result = validate(root, "--dist");
187
+ assert.notEqual(result.status, 0);
188
+ assert.match(
189
+ result.stderr,
190
+ /backend\.health\.path must be a root-relative HTTP path/,
191
+ );
192
+ assert.match(
193
+ result.stderr,
194
+ /backend\.health\.timeout_seconds must be greater than 0 and at most 300/,
195
+ );
196
+ } finally {
197
+ await rm(root, { recursive: true, force: true });
198
+ }
199
+ });
200
+
201
+
202
+
203
+ test("accepts omitted managed backend health defaults", async () => {
204
+ const root = await createApp("extension.js", {
205
+ backend: { ...backend, health: {} },
206
+ });
207
+ try {
208
+ const result = validate(root, "--dist");
209
+ assert.equal(result.status, 0, result.stderr);
210
+ } finally {
211
+ await rm(root, { recursive: true, force: true });
212
+ }
213
+ });
214
+
215
+ test("rejects disallowed or duplicate inherited environment names", async () => {
216
+ const root = await createApp("extension.js", {
217
+ backend: { ...backend, inherit_environment: ["LANG", "SECRET", "LANG"] },
218
+ });
219
+ try {
220
+ const result = validate(root, "--dist");
221
+ assert.notEqual(result.status, 0);
222
+ assert.match(
223
+ result.stderr,
224
+ /backend\.inherit_environment must contain unique names from the supported non-credential allowlist/,
225
+ );
226
+ } finally {
227
+ await rm(root, { recursive: true, force: true });
228
+ }
229
+ });
230
+
231
+ test("preserves browser-only packages without a backend declaration", async () => {
232
+ const root = await createApp("extension.js");
233
+ try {
234
+ const result = validate(root, "--dist");
235
+ assert.equal(result.status, 0, result.stderr);
236
+ } finally {
237
+ await rm(root, { recursive: true, force: true });
238
+ }
239
+ });
240
+
241
+ test("preserves manual sidecars with no managed backend declaration", async () => {
242
+ const root = await createApp("extension.js", {
243
+ description: "Connects to a deployment-owned sidecar configured by an operator.",
244
+ });
245
+ try {
246
+ const result = validate(root, "--dist");
247
+ assert.equal(result.status, 0, result.stderr);
248
+ } finally {
249
+ await rm(root, { recursive: true, force: true });
250
+ }
251
+ });