@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.
- package/.agents/skills/custom-codereview-guide.md +86 -17
- package/.claude-plugin/marketplace.json +14 -0
- package/.codex-plugin/marketplace.json +14 -0
- package/.github/workflows/check-extensions.yml +6 -6
- package/.github/workflows/deprecation-check.yml +1 -1
- package/.github/workflows/issue-readiness-check.yml +37 -106
- package/.github/workflows/npm-publish.yml +2 -2
- package/.github/workflows/pr-description-check.yml +1 -1
- package/.github/workflows/pypi-publish.yml +3 -3
- package/.github/workflows/tests.yml +2 -2
- package/.plugin/marketplace.json +14 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +32 -2
- package/README.md +4 -3
- package/automations/bundle-index.js +12 -11
- package/automations/catalog/github-issue-triage/manifest.json +2 -2
- package/automations/catalog/github-pr-reviewer/manifest.json +42 -5
- package/automations/catalog.schema.json +0 -4
- package/integrations/catalog/granola.json +38 -0
- package/integrations/catalog-index.js +16 -14
- package/marketplaces/openhands-extensions.json +14 -0
- package/package.json +1 -1
- package/plugins/openhands/skills/openhands-sdk/SKILL.md +3 -1
- package/plugins/pr-review/README.md +2 -1
- package/plugins/pr-review/scripts/agent_script.py +198 -81
- package/plugins/pr-review/scripts/prompt.py +35 -11
- package/plugins/pr-review/skills/code-review/SKILL.md +165 -200
- package/plugins/pr-review/skills/code-review/commands/codereview-roasted.md +1 -1
- package/plugins/pr-review/skills/code-review/commands/codereview.md +1 -1
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +8 -3
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +4 -0
- package/pyproject.toml +2 -2
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/add-skill/SKILL.md +5 -2
- package/skills/canvas-extension-api/.claude-plugin/plugin.json +19 -0
- package/skills/canvas-extension-api/.codex-plugin/plugin.json +19 -0
- package/skills/canvas-extension-api/.plugin/plugin.json +19 -0
- package/skills/canvas-extension-api/README.md +33 -0
- package/skills/canvas-extension-api/SKILL.md +254 -0
- package/skills/canvas-extension-api/references/acceptance-checklist.md +20 -0
- package/skills/canvas-extension-api/references/backend-safety.md +9 -0
- package/skills/canvas-extension-api/references/connections.md +126 -0
- package/skills/canvas-extension-api/references/packaging-recipes.md +43 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -0
- package/skills/canvas-extension-api/references/testing-and-installation.md +286 -0
- package/skills/canvas-extension-api/references/v1-contract.md +250 -0
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +92 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +60 -0
- package/skills/code-review/SKILL.md +165 -200
- package/skills/code-review/commands/codereview-roasted.md +1 -1
- package/skills/code-review/commands/codereview.md +1 -1
- package/skills/github/scripts/agent_conversation.py +8 -3
- package/skills/github/scripts/github_client.py +4 -0
- package/skills/github-agents-md-maintainer/scripts/github_client.py +4 -0
- package/skills/github-delivery-watchdog/scripts/github_client.py +4 -0
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +8 -3
- package/skills/github-issue-to-pr/scripts/github_client.py +4 -0
- package/skills/github-issue-triage/.claude-plugin/plugin.json +1 -1
- package/skills/github-issue-triage/.codex-plugin/plugin.json +1 -1
- package/skills/github-issue-triage/.plugin/plugin.json +1 -1
- package/skills/github-issue-triage/README.md +7 -0
- package/skills/github-issue-triage/SKILL.md +26 -3
- package/skills/github-issue-triage/scripts/agent_conversation.py +8 -3
- package/skills/github-issue-triage/scripts/github_client.py +4 -0
- package/skills/github-issue-triage/scripts/worker.py +142 -26
- package/skills/github-pr-reviewer/README.md +21 -10
- package/skills/github-pr-reviewer/SKILL.md +36 -10
- package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +8 -3
- package/skills/github-pr-reviewer/scripts/github_client.py +4 -0
- package/skills/github-pr-reviewer/scripts/main.py +63 -16
- package/skills/github-pr-reviewer/scripts/maintainer_handoff.py +151 -0
- package/skills/github-pr-reviewer/scripts/worker.py +218 -21
- package/skills/github-pr-reviewer/tests/test_main.py +85 -0
- package/skills/github-repo-monitor/scripts/github_client.py +4 -0
- package/skills/index.js +16 -8
- package/skills/openhands-sdk/SKILL.md +3 -1
- package/tests/fixtures/automations/github-pr-reviewer.json +120 -26
- package/tests/github_automation_helpers.py +1 -0
- package/tests/test_agent_conversation_dispatch.py +19 -4
- package/tests/test_automation_setup.py +15 -0
- package/tests/test_code_review_risk_evaluation.py +2 -2
- package/tests/test_github_reviewer_delivery.py +448 -5
- package/tests/test_github_triage_delivery.py +200 -6
- package/tests/test_maintainer_handoff.py +198 -0
- package/tests/test_pr_review_prompt.py +6 -2
- package/tests/test_pr_review_review_context.py +144 -0
- package/.github/scripts/check_issue_readiness.py +0 -272
- package/.github/scripts/post-readiness-comment.mjs +0 -184
- 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
|
+
});
|