@openhands/extensions 0.22.1 → 0.24.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 (90) hide show
  1. package/.agents/skills/custom-codereview-guide.md +86 -17
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.codex-plugin/marketplace.json +14 -0
  4. package/.github/workflows/check-extensions.yml +6 -6
  5. package/.github/workflows/deprecation-check.yml +1 -1
  6. package/.github/workflows/issue-readiness-check.yml +37 -106
  7. package/.github/workflows/npm-publish.yml +2 -2
  8. package/.github/workflows/pr-description-check.yml +1 -1
  9. package/.github/workflows/pypi-publish.yml +3 -3
  10. package/.github/workflows/tests.yml +2 -2
  11. package/.plugin/marketplace.json +14 -0
  12. package/.release-please-manifest.json +1 -1
  13. package/AGENTS.md +32 -2
  14. package/README.md +4 -3
  15. package/automations/bundle-index.js +12 -11
  16. package/automations/catalog/github-issue-triage/manifest.json +2 -2
  17. package/automations/catalog/github-pr-reviewer/manifest.json +42 -5
  18. package/automations/catalog.schema.json +0 -4
  19. package/integrations/catalog/granola.json +38 -0
  20. package/integrations/catalog-index.js +16 -14
  21. package/marketplaces/openhands-extensions.json +14 -0
  22. package/package.json +1 -1
  23. package/plugins/openhands/skills/openhands-sdk/SKILL.md +3 -1
  24. package/plugins/pr-review/README.md +2 -1
  25. package/plugins/pr-review/scripts/agent_script.py +198 -81
  26. package/plugins/pr-review/scripts/prompt.py +35 -11
  27. package/plugins/pr-review/skills/code-review/SKILL.md +165 -200
  28. package/plugins/pr-review/skills/code-review/commands/codereview-roasted.md +1 -1
  29. package/plugins/pr-review/skills/code-review/commands/codereview.md +1 -1
  30. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +8 -3
  31. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +4 -0
  32. package/pyproject.toml +2 -2
  33. package/python/openhands_extensions/_version.py +1 -1
  34. package/skills/add-skill/SKILL.md +5 -2
  35. package/skills/canvas-extension-api/.claude-plugin/plugin.json +19 -0
  36. package/skills/canvas-extension-api/.codex-plugin/plugin.json +19 -0
  37. package/skills/canvas-extension-api/.plugin/plugin.json +19 -0
  38. package/skills/canvas-extension-api/README.md +33 -0
  39. package/skills/canvas-extension-api/SKILL.md +254 -0
  40. package/skills/canvas-extension-api/references/acceptance-checklist.md +20 -0
  41. package/skills/canvas-extension-api/references/backend-safety.md +9 -0
  42. package/skills/canvas-extension-api/references/connections.md +126 -0
  43. package/skills/canvas-extension-api/references/packaging-recipes.md +43 -0
  44. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -0
  45. package/skills/canvas-extension-api/references/testing-and-installation.md +286 -0
  46. package/skills/canvas-extension-api/references/v1-contract.md +250 -0
  47. package/skills/canvas-extension-api/scripts/validate-extension.mjs +92 -0
  48. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +60 -0
  49. package/skills/code-review/SKILL.md +165 -200
  50. package/skills/code-review/commands/codereview-roasted.md +1 -1
  51. package/skills/code-review/commands/codereview.md +1 -1
  52. package/skills/github/scripts/agent_conversation.py +8 -3
  53. package/skills/github/scripts/github_client.py +4 -0
  54. package/skills/github-agents-md-maintainer/scripts/github_client.py +4 -0
  55. package/skills/github-delivery-watchdog/scripts/github_client.py +4 -0
  56. package/skills/github-issue-to-pr/scripts/agent_conversation.py +8 -3
  57. package/skills/github-issue-to-pr/scripts/github_client.py +4 -0
  58. package/skills/github-issue-triage/.claude-plugin/plugin.json +1 -1
  59. package/skills/github-issue-triage/.codex-plugin/plugin.json +1 -1
  60. package/skills/github-issue-triage/.plugin/plugin.json +1 -1
  61. package/skills/github-issue-triage/README.md +7 -0
  62. package/skills/github-issue-triage/SKILL.md +26 -3
  63. package/skills/github-issue-triage/scripts/agent_conversation.py +8 -3
  64. package/skills/github-issue-triage/scripts/github_client.py +4 -0
  65. package/skills/github-issue-triage/scripts/worker.py +142 -26
  66. package/skills/github-pr-reviewer/README.md +21 -10
  67. package/skills/github-pr-reviewer/SKILL.md +36 -10
  68. package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
  69. package/skills/github-pr-reviewer/scripts/agent_conversation.py +8 -3
  70. package/skills/github-pr-reviewer/scripts/github_client.py +4 -0
  71. package/skills/github-pr-reviewer/scripts/main.py +63 -16
  72. package/skills/github-pr-reviewer/scripts/maintainer_handoff.py +151 -0
  73. package/skills/github-pr-reviewer/scripts/worker.py +218 -21
  74. package/skills/github-pr-reviewer/tests/test_main.py +85 -0
  75. package/skills/github-repo-monitor/scripts/github_client.py +4 -0
  76. package/skills/index.js +16 -8
  77. package/skills/openhands-sdk/SKILL.md +3 -1
  78. package/tests/fixtures/automations/github-pr-reviewer.json +120 -26
  79. package/tests/github_automation_helpers.py +1 -0
  80. package/tests/test_agent_conversation_dispatch.py +19 -4
  81. package/tests/test_automation_setup.py +15 -0
  82. package/tests/test_code_review_risk_evaluation.py +2 -2
  83. package/tests/test_github_reviewer_delivery.py +448 -5
  84. package/tests/test_github_triage_delivery.py +200 -6
  85. package/tests/test_maintainer_handoff.py +198 -0
  86. package/tests/test_pr_review_prompt.py +6 -2
  87. package/tests/test_pr_review_review_context.py +144 -0
  88. package/.github/scripts/check_issue_readiness.py +0 -272
  89. package/.github/scripts/post-readiness-comment.mjs +0 -184
  90. package/tests/test_check_issue_readiness.py +0 -135
@@ -0,0 +1,286 @@
1
+ # Testing and installing OpenHands Apps
2
+
3
+ Use layered verification: static package checks, unit tests against the real entrypoint, repository build checks, then a manual Canvas lifecycle test.
4
+
5
+ ## Static validation
6
+
7
+ Run:
8
+
9
+ ```sh
10
+ node /path/to/canvas-extension-api/scripts/validate-extension.mjs /path/to/app-package
11
+ ```
12
+
13
+ The validator checks:
14
+
15
+ - app package directory and `canvas-extension.json` existence;
16
+ - valid JSON and supported `schema_version`;
17
+ - required fields and basic semantic version shape;
18
+ - kebab-case app manifest name, contribution IDs, and path segments;
19
+ - unique page IDs and paths;
20
+ - entrypoint containment and existence;
21
+ - `activate` export presence;
22
+ - likely bare imports, remote imports, Node built-ins, dynamic imports, and source-map chunk references;
23
+ - `registerPage` IDs against declared manifest IDs.
24
+
25
+ Treat static import scanning as heuristic. Minified or generated bundles may require manual inspection. Treat a passing result as necessary but not sufficient. When using `--dist`, the validator checks the manifest entrypoint path under `dist/`, so a manifest entrypoint such as `bundle.mjs` requires `dist/bundle.mjs`.
26
+
27
+ ## Sidecar test matrix
28
+
29
+ Test the browser bundle independently from the Sidecar: load the single ESM entrypoint through Blob import and confirm no service secret or origin assumption is embedded. Test the narrow bridge or deployment adapter with real request shapes and error handling. Test real Sidecar health, version, capabilities, and domain operations, including timeouts and structured failures.
30
+
31
+ Exercise `unknown`, `probing`, `missing`, `stopped`, `incompatible`, `ready`, and `unhealthy` onboarding states plus consent, install, start, repair, upgrade, and stop failures. Test repeated lifecycle actions, service restarts, version mismatches, backend switching, and Canvas remounts. Test App removal, service uninstall, and data deletion as distinct operations. Treat an unavailable Sidecar as a supported UI state, not a crash.
32
+
33
+ ## Unit test shape
34
+
35
+ Exercise the actual app entrypoint in a DOM test environment. Keep the host double small and behavior-oriented.
36
+
37
+ ```js
38
+ import { describe, expect, it, vi } from "vitest";
39
+ import { activate } from "./extension.js";
40
+
41
+ describe("example app", () => {
42
+ it("mounts, requests backend data, and cleans up", async () => {
43
+ let mountPage;
44
+ const unregister = vi.fn();
45
+ const navigate = vi.fn();
46
+ const request = vi.fn().mockResolvedValue({ status: "ok" });
47
+ const host = {
48
+ apiVersion: "1",
49
+ extension: {
50
+ name: "example-dashboard",
51
+ version: "0.1.0",
52
+ resolvedRef: "test-ref",
53
+ },
54
+ backend: { id: "local-test", kind: "local", orgId: null },
55
+ registerPage: vi.fn((id, mount) => {
56
+ expect(id).toBe("dashboard");
57
+ mountPage = mount;
58
+ return unregister;
59
+ }),
60
+ navigate,
61
+ agentServer: { request },
62
+ };
63
+
64
+ expect(activate(host)).toBe(unregister);
65
+
66
+ const container = document.createElement("div");
67
+ const cleanup = mountPage({ container, path: "", navigate });
68
+
69
+ await vi.waitFor(() => {
70
+ expect(container.textContent).toContain("ok");
71
+ });
72
+ expect(request).toHaveBeenCalledWith({ path: "/example" });
73
+
74
+ cleanup();
75
+ expect(container.childElementCount).toBe(0);
76
+ });
77
+ });
78
+ ```
79
+
80
+ Add focused cases for nested routes, error states, malformed responses, loading deduplication, timers, and unsupported host versions as applicable.
81
+
82
+ Prefer real timers for lifecycle behavior unless waiting would make the suite impractical. When fake timers are necessary, state why in the test and restore them after each case.
83
+
84
+ ## Build verification
85
+
86
+ For a source-based TypeScript, React, Vue, or other framework implementation, configure the bundler in library mode and emit one browser ESM file matching the manifest entrypoint.
87
+
88
+ Required output properties:
89
+
90
+ - ESM format;
91
+ - one JavaScript file;
92
+ - no unresolved bare imports;
93
+ - no external chunks;
94
+ - no Node runtime assumptions;
95
+ - CSS injected or embedded when the runtime cannot fetch additional package assets;
96
+ - small local assets embedded as data URLs or otherwise included in the single bundle.
97
+
98
+ Inspect output using repository tooling and direct searches. Example checks:
99
+
100
+ ```sh
101
+ node --check dist/extension.js
102
+ rg '^\s*(import|export\s+.*\s+from)\s+.*["'"'][^./]' dist/extension.js
103
+ rg 'import\s*\(' dist/extension.js
104
+ rg 'node:|require\(|module\.exports|process\.env|__dirname|__filename' dist/extension.js
105
+ ```
106
+
107
+ Do not run the browser bundle directly with Node as a functional test. Use a DOM test environment or Canvas.
108
+
109
+ ## Upstream mock frontend workflow
110
+
111
+ The initial OpenHands frontend includes an MSW development mode that can install a checked-in fixture before Agent Server app endpoints are available.
112
+
113
+ From an OpenHands checkout:
114
+
115
+ ```sh
116
+ VITE_FRONTEND_PORT=3102 \
117
+ VITE_BACKEND_BASE_URL=http://127.0.0.1:8000 \
118
+ VITE_SESSION_API_KEY=canvas-extension-dev \
119
+ npm run dev:mock
120
+ ```
121
+
122
+ Open:
123
+
124
+ ```text
125
+ http://localhost:3102/extensions
126
+ ```
127
+
128
+ Install a fixture path that exists inside that OpenHands checkout, such as:
129
+
130
+ ```text
131
+ src/fixtures/canvas-extensions/demo-page
132
+ ```
133
+
134
+ The mock flow validates frontend inventory, enablement, bundle loading, page registration, nested routing, and cleanup. It does not validate Agent Server installation, filesystem boundaries, persistence, authentication, Git resolution, symlinks, or immutable revisions.
135
+
136
+ To test a separately located app with this mock, copy it into a suitable fixture path only when authorized and avoid copying secrets-bearing files. Prefer adding a temporary ignored fixture or using the backend flow once available. Remove temporary copies after testing.
137
+
138
+ ## Real Agent Server installation
139
+
140
+ Use Customize -> Apps when available, or the authenticated management API when explicitly requested.
141
+
142
+ ### Backend-local path
143
+
144
+ Use a path visible from the Agent Server process. A path visible only on the frontend host is insufficient when the backend runs in a container, remote VM, or different filesystem namespace.
145
+
146
+ Install request shape:
147
+
148
+ ```json
149
+ {
150
+ "source": "/absolute/path/on/agent-server",
151
+ "ref": null,
152
+ "repo_path": null,
153
+ "force": false
154
+ }
155
+ ```
156
+
157
+ ### Git repository
158
+
159
+ Use distribution coordinates compatible with the backend:
160
+
161
+ ```json
162
+ {
163
+ "source": "github:owner/repository",
164
+ "ref": "main",
165
+ "repo_path": "extensions/example-dashboard",
166
+ "force": false
167
+ }
168
+ ```
169
+
170
+ For a repository containing the app at its root, omit `repo_path`. Pin or report the resolved revision returned by the backend.
171
+
172
+ ### Multiple apps in one repository
173
+
174
+ Place each app under an independent package root:
175
+
176
+ ```text
177
+ repository/
178
+ ├── extensions/
179
+ │ ├── canvas-pulse/
180
+ │ │ ├── canvas-extension.json
181
+ │ │ ├── extension.js
182
+ │ │ └── extension.test.js
183
+ │ └── project-dashboard/
184
+ │ ├── canvas-extension.json
185
+ │ ├── dist/
186
+ │ │ └── extension.js
187
+ │ └── src/
188
+ ├── packages/
189
+ │ └── shared-source/
190
+ └── package.json
191
+ ```
192
+
193
+ Keep each manifest's `entrypoint` relative to that app's package root. Configure shared build tooling to emit one self-contained browser ESM bundle per app. Do not make one app entrypoint import another app's output at runtime; Blob URL loading prevents package-relative module resolution.
194
+
195
+ Validate each package separately:
196
+
197
+ ```sh
198
+ for extension in extensions/*; do
199
+ node /path/to/validate-extension.mjs "$extension"
200
+ done
201
+ ```
202
+
203
+ The Add app form installs one app per submission. It sends one `source`, optional `ref`, and optional `repo_path` in each install request. It does not scan a repository recursively, discover all `canvas-extension.json` files, or bulk-install nested apps.
204
+
205
+ Install the example repository twice:
206
+
207
+ | Extension | Source | Ref | Repository path |
208
+ | --- | --- | --- | --- |
209
+ | Canvas Pulse | `github:owner/repository` | `main` | `extensions/canvas-pulse` |
210
+ | Project Dashboard | `github:owner/repository` | `main` | `extensions/project-dashboard` |
211
+
212
+ Each installation receives its own resolved revision, enabled state, lifecycle, and inventory entry. Installation leaves each one disabled; enable each separately after review and authorization.
213
+
214
+ When preparing instructions for several apps, generate an install matrix rather than saying to add the repository once. Keep the source/ref identical when appropriate and vary `repo_path` for each package root.
215
+
216
+ Do not upload or publish a local app unless requested. Do not embed credentials in a source URL.
217
+
218
+ ## Manual lifecycle checklist
219
+
220
+ Complete these checks against the target backend:
221
+
222
+ ### Installation
223
+
224
+ - Confirm the app appears in Customize -> Apps.
225
+ - Confirm installation leaves it disabled.
226
+ - Confirm the displayed name, version, description, source, requested ref, resolved ref, and contributions.
227
+ - Confirm no navigation item appears and no app code executes while disabled.
228
+
229
+ ### Enablement
230
+
231
+ - Read the trusted-code disclosure.
232
+ - Enable only with user authorization.
233
+ - Confirm the page navigation item appears without restarting Canvas.
234
+ - Open the page and verify loading, success, empty, and failure states.
235
+ - Verify authenticated Agent Server requests target the active owning backend.
236
+
237
+ ### Routing
238
+
239
+ - Open the declared root route.
240
+ - Open at least one nested route directly.
241
+ - Confirm the mount callback receives only the remainder below the declared page path.
242
+ - Confirm app navigation respects the Canvas base path.
243
+ - Confirm the longest declared page path wins when page paths overlap.
244
+
245
+ ### Cleanup
246
+
247
+ - Navigate away and verify page-level timers, listeners, observers, and DOM are removed.
248
+ - Disable the app and confirm all page registrations and navigation disappear.
249
+ - Re-enable and confirm the app activates cleanly a second time.
250
+ - Update or reinstall when applicable and confirm stale effects do not remain.
251
+ - Switch backend or reconnect and confirm the previous backend's app does not leak into the new context.
252
+
253
+ ### Removal
254
+
255
+ - Uninstall only when explicitly requested.
256
+ - Confirm the inventory, navigation, and routes no longer expose the app.
257
+
258
+ ## Diagnosing common failures
259
+
260
+ ### App is listed but has no page
261
+
262
+ Check that the installed API response includes the parsed manifest. The frontend treats `manifest` as optional for compatibility with Agent Server versions that omit it. Without `manifest.contributes.pages`, Canvas cannot admit page registrations or render navigation.
263
+
264
+ Check that the app is enabled and that `registerPage` uses an ID declared in the manifest.
265
+
266
+ ### Entrypoint fails to import
267
+
268
+ Check for unresolved package imports, extra chunks, remote imports, syntax errors, top-level Node globals, or code that expects a normal URL instead of a Blob module URL.
269
+
270
+ Remember that relative imports from a Blob URL cannot resolve to files in the installed package. Bundle everything into the entrypoint.
271
+
272
+ ### Agent Server request fails before network activity
273
+
274
+ Ensure `path` begins with exactly one `/`. Do not use an absolute URL, protocol-relative URL, or relative path.
275
+
276
+ ### Nested path is wrong
277
+
278
+ Treat the mount `path` as a route remainder without a leading slash. Build navigation URLs from `/extensions/{encoded-extension-name}/{declared-page-root}` and encode user-controlled route segments.
279
+
280
+ ### UI duplicates after re-enable
281
+
282
+ Return all activation and mount disposers. Remove injected style elements, stop timers, detach listeners, and prevent late async callbacks from writing after disposal.
283
+
284
+ ### App works locally but not from Git
285
+
286
+ Confirm the manifest and built entrypoint are committed, the requested ref contains them, `repo_path` points at the directory containing `canvas-extension.json`, and generated output is not excluded from publication.
@@ -0,0 +1,250 @@
1
+ # Canvas Extensions API v1 contract
2
+
3
+ Use this reference for manifest schema 1 and host API 1 as implemented by the initial Apps frontend landing.
4
+
5
+ ## Authoritative sources
6
+
7
+ Upstream repository: <https://github.com/OpenHands/OpenHands>
8
+
9
+ Initial frontend landing: [PR #16895](https://github.com/OpenHands/OpenHands/pull/16895), `feat: land the Canvas Extensions frontend (load pages, sidebar, customize)`.
10
+
11
+ Inspect these files when targeting a newer OpenHands revision:
12
+
13
+ - `specs/canvas-extensions.md` - product decisions and package contract.
14
+ - `src/types/canvas-extension.ts` - TypeScript source of truth for host API types.
15
+ - `src/extensions/canvas-extension-module-loader.ts` - ESM loading and `activate` validation.
16
+ - `src/components/features/canvas-extensions/canvas-extensions-runtime.tsx` - registration, activation, disposal, and backend scoping.
17
+ - `src/routes/canvas-extension-page.tsx` - route matching and mount lifecycle.
18
+ - `src/api/canvas-extensions-service.ts` - management endpoints and authenticated Agent Server request behavior.
19
+ - `src/fixtures/canvas-extensions/demo-page/` - minimal dependency-free fixture.
20
+ - `docs/CANVAS_EXTENSIONS_TESTING.md` - mock frontend workflow.
21
+
22
+ ## Product and trust model
23
+
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
+
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.
27
+
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
+
30
+ ## Manifest schema 1
31
+
32
+ Filename: `canvas-extension.json` at the app package root.
33
+
34
+ ```ts
35
+ interface CanvasExtensionManifest {
36
+ schema_version: 1;
37
+ name: string;
38
+ display_name?: string | null;
39
+ version: string;
40
+ description?: string | null;
41
+ entrypoint: string;
42
+ contributes?: {
43
+ pages?: CanvasExtensionPageContribution[] | null;
44
+ } | null;
45
+ }
46
+
47
+ interface CanvasExtensionPageContribution {
48
+ id: string;
49
+ title: string;
50
+ path: string;
51
+ nav_label?: string | null;
52
+ description?: string | null;
53
+ }
54
+ ```
55
+
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.
57
+
58
+ Apply these rules:
59
+
60
+ - Match app manifest names and contribution IDs against `^[a-z0-9]+(?:-[a-z0-9]+)*$`.
61
+ - Begin each page path with `/`.
62
+ - Use one or more kebab-case route segments for each page path.
63
+ - Keep the entrypoint inside the installed app package root.
64
+ - Produce one self-contained browser ESM entrypoint.
65
+ - Bundle or embed dependencies, CSS, and small assets.
66
+ - Leave no unresolved bare imports or external output chunks.
67
+
68
+ The frontend normalizes the declared leading slash away for internal route matching. It validates the app manifest name, contribution ID, and every path segment before admitting the page.
69
+
70
+ ## Host API 1
71
+
72
+ ```ts
73
+ interface CanvasExtensionHost {
74
+ readonly apiVersion: "1";
75
+ readonly extension: Readonly<{
76
+ name: string;
77
+ version: string;
78
+ resolvedRef: string | null;
79
+ }>;
80
+ readonly backend: Readonly<{
81
+ id: string;
82
+ kind: "local" | "cloud";
83
+ orgId: string | null;
84
+ }>;
85
+ registerPage(
86
+ contributionId: string,
87
+ mount: CanvasExtensionPageMount,
88
+ ): () => void;
89
+ navigate(path: string): void;
90
+ agentServer: {
91
+ request<T = unknown>(request: {
92
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
93
+ path: string;
94
+ body?: unknown;
95
+ headers?: Record<string, string>;
96
+ }): Promise<T>;
97
+ };
98
+ }
99
+
100
+ type CanvasExtensionPageMount = (context: {
101
+ container: HTMLElement;
102
+ path: string;
103
+ navigate(path: string): void;
104
+ }) => void | (() => void) | Promise<void | (() => void)>;
105
+ ```
106
+
107
+ The ESM entrypoint must export:
108
+
109
+ ```ts
110
+ export function activate(
111
+ host: CanvasExtensionHost,
112
+ ): void | (() => void) | Promise<void | (() => void)>;
113
+ ```
114
+
115
+ 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
+
117
+ ## Registration constraints
118
+
119
+ Register each page during activation using exactly the page ID declared in the manifest. The runtime rejects undeclared IDs and duplicate registrations.
120
+
121
+ Return the unregister function from `activate` for a single contribution, or collect multiple unregister functions and return one disposer:
122
+
123
+ ```js
124
+ export function activate(host) {
125
+ const disposers = [
126
+ host.registerPage("overview", mountOverview),
127
+ host.registerPage("settings", mountSettings),
128
+ ];
129
+ return () => {
130
+ for (const dispose of disposers.reverse()) dispose();
131
+ };
132
+ }
133
+ ```
134
+
135
+ The runtime invokes activation cleanup and registration cleanup when disabling, uninstalling, updating, switching backend, reconnecting, or otherwise replacing the activation inventory. Cleanup errors are logged and do not prevent later cleanup attempts.
136
+
137
+ ## Routing
138
+
139
+ Canvas mounts a declared page under:
140
+
141
+ ```text
142
+ /extensions/{extension-name}/{declared-page-path}
143
+ ```
144
+
145
+ For an app `canvas-pulse` and page path `/pulse`, the root URL is:
146
+
147
+ ```text
148
+ /extensions/canvas-pulse/pulse
149
+ ```
150
+
151
+ A visit to:
152
+
153
+ ```text
154
+ /extensions/canvas-pulse/pulse/services/agent_server
155
+ ```
156
+
157
+ selects the longest matching declared page path and passes this remainder to the mount callback:
158
+
159
+ ```text
160
+ services/agent_server
161
+ ```
162
+
163
+ Use the mount callback's `navigate` function, or `host.navigate`, rather than manipulating history directly. Pass a Canvas path, typically an absolute path such as `/extensions/canvas-pulse/pulse`.
164
+
165
+ The route is base-path aware through React Router. Avoid constructing browser origins or assuming Canvas runs at `/`.
166
+
167
+ ## Agent Server requests
168
+
169
+ Use `host.agentServer.request` to target the app-owning backend with Canvas authentication. The helper accepts method, root-relative path, body, and headers.
170
+
171
+ Valid:
172
+
173
+ ```js
174
+ const serverInfo = await host.agentServer.request({
175
+ path: "/server_info",
176
+ });
177
+ ```
178
+
179
+ Invalid:
180
+
181
+ ```js
182
+ host.agentServer.request({ path: "server_info" });
183
+ host.agentServer.request({ path: "//other-host/path" });
184
+ host.agentServer.request({ path: "https://other-host/path" });
185
+ ```
186
+
187
+ The current service rejects paths that do not begin with one `/`. It defaults `method` to `GET`.
188
+
189
+ ## Runtime lifecycle
190
+
191
+ For every enabled installation, Canvas:
192
+
193
+ 1. Fetches authenticated entrypoint text from the owning backend.
194
+ 2. Imports the self-contained ESM bundle from a Blob URL.
195
+ 3. Validates the `activate` export.
196
+ 4. Creates an app-scoped registry and calls `activate`.
197
+ 5. Admits only page registrations declared by the manifest.
198
+ 6. Renders contributed pages under a Canvas-owned error boundary.
199
+ 7. Unmounts pages and invokes cleanup when the activation inventory changes.
200
+
201
+ A page mount may be synchronous or asynchronous. If an async mount resolves after route disposal, Canvas immediately invokes the returned disposer.
202
+
203
+ Build app code to tolerate repeated activation and mounting. Guard asynchronous requests against late state updates. Clear intervals, animation frames, observers, event listeners, and DOM/style nodes.
204
+
205
+ ## Management API
206
+
207
+ The current Canvas frontend service calls:
208
+
209
+ | Method | Path | Purpose |
210
+ | --- | --- | --- |
211
+ | `GET` | `/api/canvas-extensions/installed` | List installed apps |
212
+ | `POST` | `/api/canvas-extensions/install` | Install disabled from Git or backend-local path |
213
+ | `PATCH` | `/api/canvas-extensions/installed/{name}` | Set enabled state |
214
+ | `DELETE` | `/api/canvas-extensions/installed/{name}` | Uninstall |
215
+ | `GET` | `/api/canvas-extensions/installed/{name}/bundle` | Fetch entrypoint JavaScript text |
216
+
217
+ Install request:
218
+
219
+ ```json
220
+ {
221
+ "source": "github:owner/repository",
222
+ "ref": "main",
223
+ "repo_path": "extensions/example-dashboard",
224
+ "force": false
225
+ }
226
+ ```
227
+
228
+ Interpret backend-local paths on the Agent Server machine, not in the frontend process. For a backend-local source, the current Canvas frontend joins a supplied `repo_path` into `source` before sending the install request and sends `repo_path: null`; select the app package directory as seen by the Agent Server. A repository may hold multiple apps under different `repo_path` values.
229
+
230
+ Each install request resolves exactly one canvas extension package root. For a remote repository, the directory selected by `repo_path`, or the repository root when `repo_path` is omitted, must contain that app's `canvas-extension.json`. For a backend-local source, the `source` path is already the selected app package directory. The current Add app form submits one install request and does not recursively discover or bulk-install nested app manifests. Install every remote app separately with the same `source` and `ref` when appropriate and a distinct `repo_path`; submit every local app package directory separately.
231
+
232
+ Treat co-located apps as independent installations: each app has its own manifest name, version, resolved revision record, enabled state, bundle, registrations, and cleanup lifecycle. Shared repository source or build tooling does not combine their runtime identities.
233
+
234
+ Backend endpoint availability may lag behind the frontend. HTTP 404 means the backend lacks Apps support. Cloud backends are not supported by the current frontend service.
235
+
236
+ ## Current v1 limitations
237
+
238
+ Do not promise or implement these features without confirming a newer contract:
239
+
240
+ - conversation tabs or panels;
241
+ - header, footer, badge, or arbitrary host slots;
242
+ - code-free theme contributions;
243
+ - visualizer augmentation or replacement;
244
+ - marketplace signing or publisher verification;
245
+ - enforceable fine-grained permissions;
246
+ - iframe or worker isolation;
247
+ - arbitrary install lifecycle scripts;
248
+ - automatic agent-driven enablement.
249
+
250
+ Page contributions are the supported host API 1 ABI. Host compatibility manifest fields and a published Canvas Extensions API SDK or bundler template are not part of the current contract.
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ const name = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
6
+ const semver = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
7
+ const imports = /(?:^|[;}\n])\s*import\s+(?:[^"'()]*?\s+from\s+)?["']([^"']+)["']|(?:^|[;}\n])\s*export\s+[^"']*?\s+from\s+["']([^"']+)["']/gm;
8
+ const forbidden = [
9
+ [/(?:^|[;}\n])\s*import\s*\(/m, "runtime dynamic import"],
10
+ [/\bexport\s+[^;]*?\sfrom\s*["']/m, "re-exported dependency"],
11
+ [/\bnew\s+URL\(\s*["']\.?\.?\//m, "relative URL asset"],
12
+ [/\b(?:require\s*\(|module\.exports)/, "CommonJS dependency"],
13
+ [/sourceMappingURL=/, "source-map reference"],
14
+ ];
15
+
16
+ const [target, ...options] = process.argv.slice(2);
17
+ if (!target || target === "--help" || target === "-h") {
18
+ console.log("Usage: validate-extension.mjs <app-directory> [--dist] [--marker <text>]");
19
+ process.exit(target ? 0 : 2);
20
+ }
21
+ const checkDist = options.includes("--dist");
22
+ const markers = [];
23
+ for (let i = 0; i < options.length; i += 1) {
24
+ if (options[i] === "--marker" && options[i + 1]) markers.push(options[++i]);
25
+ else if (options[i] !== "--dist") throw new Error(`Unknown option: ${options[i]}`);
26
+ }
27
+ const root = path.resolve(target);
28
+ const errors = [];
29
+ const fail = (message) => errors.push(message);
30
+ const within = (parent, child) => {
31
+ const relative = path.relative(parent, child);
32
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
33
+ };
34
+
35
+ let manifest;
36
+ try {
37
+ if (!(await stat(root)).isDirectory()) throw new Error("not a directory");
38
+ manifest = JSON.parse(await readFile(path.join(root, "canvas-extension.json"), "utf8"));
39
+ } catch (error) {
40
+ console.error(`ERROR: Cannot read App package: ${error.message}`);
41
+ process.exit(1);
42
+ }
43
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) fail("Manifest root must be an object.");
44
+ if (manifest.schema_version !== 1) fail("Manifest schema_version must equal 1.");
45
+ for (const field of ["name", "version", "entrypoint"]) {
46
+ if (typeof manifest[field] !== "string" || !manifest[field].trim()) fail(`Manifest ${field} must be a non-empty string.`);
47
+ }
48
+ if (typeof manifest.name === "string" && !name.test(manifest.name)) fail("Manifest name must use lowercase kebab-case.");
49
+ if (typeof manifest.version === "string" && !semver.test(manifest.version)) fail("Manifest version must use semantic versioning.");
50
+
51
+ const ids = new Set();
52
+ const paths = new Set();
53
+ const pages = manifest.contributes?.pages;
54
+ if (!Array.isArray(pages) || pages.length === 0) fail("Manifest must declare at least one routed page.");
55
+ for (const [index, page] of (pages ?? []).entries()) {
56
+ const label = `contributes.pages[${index}]`;
57
+ if (!page || typeof page !== "object" || Array.isArray(page)) { fail(`${label} must be an object.`); continue; }
58
+ if (typeof page.id !== "string" || !name.test(page.id)) fail(`${label}.id must use lowercase kebab-case.`);
59
+ if (typeof page.title !== "string" || !page.title.trim()) fail(`${label}.title must be a non-empty string.`);
60
+ if (typeof page.path !== "string" || !/^\/(?:[a-z0-9]+(?:-[a-z0-9]+)*)(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/.test(page.path)) fail(`${label}.path must be an absolute kebab-case route.`);
61
+ if (ids.has(page.id)) fail(`Page id ${page.id} is declared more than once.`); ids.add(page.id);
62
+ if (paths.has(page.path)) fail(`Page path ${page.path} is declared more than once.`); paths.add(page.path);
63
+ }
64
+
65
+ const entrypoint = checkDist
66
+ ? path.resolve(root, "dist", manifest.entrypoint ?? "")
67
+ : path.resolve(root, manifest.entrypoint ?? "");
68
+ try {
69
+ const [realRoot, realEntrypoint] = await Promise.all([realpath(root), realpath(entrypoint)]);
70
+ if (!within(realRoot, realEntrypoint)) fail("Entrypoint escapes the App package root.");
71
+ const source = await readFile(realEntrypoint, "utf8");
72
+ if (!source.trim()) fail("Entrypoint is empty.");
73
+ if (!/\bexport\s*(?:\{[^}]*\bactivate\b[^}]*\}|(?:async\s+)?function\s+activate\b)/m.test(source)) fail("Entrypoint must export activate.");
74
+ for (const match of source.matchAll(imports)) {
75
+ const specifier = match[1] ?? match[2];
76
+ if (specifier && !specifier.startsWith("data:") && !specifier.startsWith("blob:")) fail(`Entrypoint contains unresolved module specifier: ${specifier}`);
77
+ }
78
+ for (const [pattern, description] of forbidden) if (pattern.test(source)) fail(`Entrypoint contains ${description}.`);
79
+ for (const marker of markers) if (!source.includes(marker)) fail(`Entrypoint is missing required marker: ${marker}`);
80
+ const registered = new Set([...source.matchAll(/\.registerPage\s*\(\s*["']([^"']+)["']/g)].map((match) => match[1]));
81
+ for (const id of registered) if (!ids.has(id)) fail(`Entrypoint registers undeclared page id ${id}.`);
82
+ } catch (error) { fail(`Cannot read entrypoint: ${error.message}`); }
83
+
84
+ if (checkDist) {
85
+ try {
86
+ const files = await readdir(path.join(root, "dist"), { recursive: true });
87
+ const expected = manifest.entrypoint;
88
+ if (files.length !== 1 || files[0] !== expected) fail(`Expected exactly dist/${expected}; found ${files.join(", ") || "nothing"}.`);
89
+ } catch (error) { fail(`Cannot inspect dist output: ${error.message}`); }
90
+ }
91
+ if (errors.length) { for (const error of errors) console.error(`ERROR: ${error}`); process.exit(1); }
92
+ console.log(`Canvas App validation passed: ${root}${checkDist ? " (dist)" : ""}`);
@@ -0,0 +1,60 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+
8
+ const validator = new URL("./validate-extension.mjs", import.meta.url);
9
+
10
+ async function createApp(entrypoint) {
11
+ const root = await mkdtemp(path.join(os.tmpdir(), "canvas-extension-validator-"));
12
+ await mkdir(path.join(root, "dist"), { recursive: true });
13
+ await writeFile(
14
+ path.join(root, "canvas-extension.json"),
15
+ JSON.stringify({
16
+ schema_version: 1,
17
+ name: "validator-test",
18
+ version: "1.0.0",
19
+ entrypoint,
20
+ contributes: {
21
+ pages: [{ id: "home", title: "Home", path: "/home" }],
22
+ },
23
+ }),
24
+ );
25
+ await writeFile(
26
+ path.join(root, "dist", entrypoint),
27
+ "export function activate() {}",
28
+ );
29
+ return root;
30
+ }
31
+
32
+ test("--dist uses the manifest entrypoint name", async () => {
33
+ const root = await createApp("bundle.mjs");
34
+ try {
35
+ const result = spawnSync(
36
+ process.execPath,
37
+ [validator.pathname, root, "--dist"],
38
+ { encoding: "utf8" },
39
+ );
40
+ assert.equal(result.status, 0, result.stderr);
41
+ } finally {
42
+ await rm(root, { recursive: true, force: true });
43
+ }
44
+ });
45
+
46
+ test("--dist rejects sibling output chunks", async () => {
47
+ const root = await createApp("extension.js");
48
+ try {
49
+ await writeFile(path.join(root, "dist", "lazy.js"), "export {};");
50
+ const result = spawnSync(
51
+ process.execPath,
52
+ [validator.pathname, root, "--dist"],
53
+ { encoding: "utf8" },
54
+ );
55
+ assert.notEqual(result.status, 0);
56
+ assert.match(result.stderr, /Expected exactly dist\/extension\.js/);
57
+ } finally {
58
+ await rm(root, { recursive: true, force: true });
59
+ }
60
+ });