diff --git a/AGENTS.md b/AGENTS.md index dfbca3be37..adc22cbafb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,6 +264,7 @@ you are running inside of — NOT the automation backend. - `home/` — Workspace selection, folder browser (`mock-llm-folder-workspace.spec.ts`) - `mcp/` — MCP marketplace/server management and credential verification (`mock-llm-mcp-github.spec.ts`, `mock-llm-mcp-slack-credentials.spec.ts`) - `skills/` — Skill loading and activation (`mock-llm-skills.spec.ts`) + - `canvas-extensions/` — Canvas Extension install → enable → page render → disable → uninstall lifecycle (`mock-llm-canvas-extensions.spec.ts`). The pinned agent-server predates `/api/canvas-extensions`, so the spec serves that contract from `src/fixtures/canvas-extensions/demo-page` via `page.route()`; delete the stub once the pin ships the endpoints and install the fixture by absolute path instead. - `regressions/` — CSS isolation, event pagination, workspace persistence (`mock-llm-ui-regressions.spec.ts`). Always included in selective runs. - **Selective test execution**: `tests/e2e/mock-llm/test-mapping.json` maps source paths to test subdirectories. The `tests/e2e/mock-llm/scripts/resolve-affected-tests.mjs` script reads the PR's changed files and outputs which test directories to run. Four resolution modes: (1) changed files match specific `mappings` → run only those subdirs + `regressions`; (2) changed mock-LLM spec files → run the containing feature subdirectory + `regressions`, so test-only PRs that add new specs still execute the new tests; (3) changed files match `runAllSources` patterns (cross-cutting files like `src/api/agent-server-adapter.ts`, `package.json`, shared test helpers, or `test-mapping.json`) or are unmapped `src/` files → run full suite (`__ALL__`); (4) changed files are outside the E2E-relevant tree (docs, specs) → nothing; the workflow still starts so required checks do not remain pending, but the heavy test job is skipped by its internal change detector. The CI workflow's "Resolve affected test directories" step runs the script and passes the result to Playwright; `workflow_dispatch` always runs the full suite. - Tests run serially (`workers: 1`, `mode: "serial"` per describe block). Each spec is self-contained (configures its own LLM profile, resets mock LLM in `afterEach`). The `afterEach` hook resets the mock LLM to its default trajectory so subsequent specs start fresh even when a preceding test fails. @@ -541,6 +542,7 @@ When adding code that needs a new string, decide up front which rule it falls un - secrets CRUD: `GET /api/settings/secrets` (list), `GET /api/settings/secrets/:name` (value), `PUT /api/settings/secrets` (upsert), `DELETE /api/settings/secrets/:name` - conversation browsing/loading: `/api/conversations/search`, `/api/conversations?ids=...`, `/api/conversations/:id`, `/api/conversations/:id/events/*` - runtime git panels: `/api/git/changes`, `/api/git/diff` +- Files that MSW handlers import (demo bundles, fixture JSON) must live under `src/fixtures/` and be imported via `#/fixtures/...`. `.dockerignore` excludes `tests/` and `__tests__/` from the Docker build context, so a handler importing from those directories builds locally but fails `npm run build` inside the image (`UNRESOLVED_IMPORT`). - Static mock verification needs a build created with `VITE_MOCK_API=true` (use `npm run build:mock`); the client must start MSW whenever that flag is enabled, even in production/static builds, otherwise routes like `/settings` and the conversations pane fall through to the static server and crash on undefined `.filter`/`.map` assumptions. - Frontend compatibility is enforced by `assertAgentServerVersionIsSupported()` in `src/api/agent-server-compatibility.ts`, using `compatibility.minimumAgentServer` from `config/defaults.json`. `OptionService.getConfig()` calls `loadAgentServerInfo()` to enforce that floor, detect unavailable/auth-failing servers, and cache `usable_tools` for tool gating. - Backend registry: there is no longer a separate "bundled" backend. On first read of the `openhands-backends` localStorage key (`raw === null`), `readStoredBackends()` seeds the registry with one default local backend (`makeDefaultLocalBackend()`, id `BUNDLED_BACKEND_ID = "default-local"`, host/api-key from `agent-server-config`). After that the seed is just an ordinary registered backend — users can rename or remove it like any other. `getEffectiveLocalBackend()` returns the first registered local, falling back to a synthesized default if the registry has no locals (used by API clients that need a baseline `local` target). The "Manage backends" modal and the BackendSelector dropdown both read from the single registered list, so the seeded default appears in both without any special-casing. diff --git a/__tests__/components/features/skills/extensions-navigation.test.tsx b/__tests__/components/features/skills/extensions-navigation.test.tsx index cec57e68a5..6c06c573f5 100644 --- a/__tests__/components/features/skills/extensions-navigation.test.tsx +++ b/__tests__/components/features/skills/extensions-navigation.test.tsx @@ -90,6 +90,7 @@ describe("ExtensionsNavigation", () => { "MCP Servers", "Skills", "Plugins", + "Extensions", ]); }); @@ -126,7 +127,7 @@ describe("ExtensionsNavigation", () => { ); }); - it("hides the Plugins item", () => { + it("hides the Plugins and Extensions items", () => { setRegisteredBackends([cloudBackend]); setActiveSelection({ backendId: cloudBackend.id }); @@ -136,6 +137,9 @@ describe("ExtensionsNavigation", () => { expect( within(nav).queryByTestId("sidebar-extensions-/plugins"), ).not.toBeInTheDocument(); + expect( + within(nav).queryByTestId("sidebar-extensions-/extensions"), + ).not.toBeInTheDocument(); }); it("leaves the MCP Servers item as an in-app link", () => { diff --git a/__tests__/utils/mobile-section-nav.test.ts b/__tests__/utils/mobile-section-nav.test.ts index b0d57f930f..5e2838f80c 100644 --- a/__tests__/utils/mobile-section-nav.test.ts +++ b/__tests__/utils/mobile-section-nav.test.ts @@ -29,6 +29,17 @@ describe("getMobileTopBarState", () => { }); }); + it("backs from the canvas extensions management page but not extension pages", () => { + expect(getMobileTopBarState("/extensions")).toEqual({ + mode: "back", + backTo: "/customize", + backLabelKey: I18nKey.NAV$CUSTOMIZE, + }); + expect(getMobileTopBarState("/extensions/demo-page/hello")).toEqual({ + mode: "menu", + }); + }); + it("shows menu on main app routes", () => { expect(getMobileTopBarState("/conversations")).toEqual({ mode: "menu" }); }); diff --git a/docs/CANVAS_EXTENSIONS_TESTING.md b/docs/CANVAS_EXTENSIONS_TESTING.md new file mode 100644 index 0000000000..1d3cd2f7db --- /dev/null +++ b/docs/CANVAS_EXTENSIONS_TESTING.md @@ -0,0 +1,93 @@ +# Canvas Extensions manual testing + +Canvas Extensions can be exercised locally before the Agent Server implements +the `/api/canvas-extensions` endpoints. Canvas's existing MSW development mode +contains an in-memory implementation of the API and serves the checked-in demo +extension bundle through the same frontend service and runtime used in a real +deployment. + +This path is for frontend development only. It does not test Agent Server +installation, filesystem validation, persistence, authentication, or Git +resolution. + +## Start the mock frontend + +From the repository root, run: + +```sh +VITE_FRONTEND_PORT=3102 \ +VITE_BACKEND_BASE_URL=http://127.0.0.1:8000 \ +VITE_SESSION_API_KEY=canvas-extension-dev \ +npm run dev:mock +``` + +Port `3102` avoids the `3001` Vite process used by the normal local stack. The +backend URL only gives Canvas a local backend identity; MSW intercepts the +extension requests in the browser. The mock also covers the settings and server +information probes needed to mark that backend healthy, so the Agent Server +does not need the extension endpoints and does not need to be running. + +Open . Do not use the normal ingress URL at +`http://localhost:8000` for this test because its `/api` traffic goes directly +to the unmodified Agent Server rather than through the mock browser session. + +If the browser profile already contains incompatible backend or onboarding +state, use a private window or clear local storage for `localhost:3102` and +reload. + +## Install and enable the fixture + +1. In **Customize -> Extensions**, select **Add extension**. +2. Enter this exact source: + + ```text + src/fixtures/canvas-extensions/demo-page + ``` + +3. Leave **Ref** and **Repository path** empty, then select **Install**. +4. Confirm that **Demo page** appears disabled. Installation must not execute + the bundle or add its navigation item. +5. Turn on the extension and accept the trusted-code confirmation. +6. Confirm that **Extension demo** appears in the main left rail. +7. Open it and verify the page says **Hello from a Canvas Extension**. +8. Visit `/extensions/demo-page/hello/nested` directly and verify the page + renders `Nested extension path: nested`. + +## Lifecycle checks + +- **Disable:** turn the extension off. Its rail item should disappear, and its + route should no longer render the contributed page. +- **Re-enable:** turn it on again. The item and page should return without a + Canvas restart. +- **Uninstall:** select **Uninstall** and confirm. The inventory and rail item + should become empty. +- **Reload:** reload the page and confirm the installation and enablement are + retained for this browser tab. The mock uses session storage and clears when + you uninstall it or end the browser session. + +## Test an extension edit + +Edit +`src/fixtures/canvas-extensions/demo-page/extension.js`, restart the mock +frontend if Vite does not rebuild the raw fixture import automatically, then +uninstall and reinstall the fixture. This allows page mounting, cleanup, +subrouting, and use of the host API to be developed before backend support is +available. + +The fixture must remain a self-contained browser ES module: it may not rely on +bare package imports or additional output chunks. + +## What still requires the Agent Server + +Repeat this flow against `http://localhost:8000/extensions` after the backend +contract lands. That test must additionally verify: + +- Git and backend-local-path installation; +- immutable revision resolution; +- manifest, traversal, symlink, and entrypoint validation; +- persistence across Agent Server and browser restarts; +- session-authenticated bundle delivery; +- isolation when switching between active backends. + +The backend contract and acceptance criteria are documented in +[`specs/canvas-extensions.md`](../specs/canvas-extensions.md). diff --git a/docs/README.md b/docs/README.md index cf410ec68c..fc72c9a976 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ This directory contains the project documentation. - [Architecture](./architecture.md): system boundaries, runtime modes, and quality gates. - [Using ACP agents](./ACP_AGENTS.md): onboard and configure external agents (Claude Code, Codex, Gemini CLI). - [Development guide](./DEVELOPMENT.md) +- [Canvas Extensions manual testing](./CANVAS_EXTENSIONS_TESTING.md) - [Self-hosting guide](./SELF_HOSTING.md) - [Integrating DefenseClaw](./DefenseClaw.md): run the DefenseClaw security governance layer alongside the Agent Server. - [Testing matrix](./TESTING_MATRIX.md): release smoke-test coverage across installers, operating systems, and agents. diff --git a/eslint.config.js b/eslint.config.js index 42307db232..6645535212 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -108,6 +108,9 @@ export default [ "test-results/**", "test-results-live/**", "public/mockServiceWorker.js", + // Self-contained browser ES module served to extensions at runtime; + // not part of the TypeScript project. + "src/fixtures/canvas-extensions/**/*.js", "src/i18n/declaration.d.ts", ], }, diff --git a/specs/canvas-extensions.md b/specs/canvas-extensions.md new file mode 100644 index 0000000000..a4c6194053 --- /dev/null +++ b/specs/canvas-extensions.md @@ -0,0 +1,283 @@ +# Canvas Extensions + +## Status + +Implementation plan and v1 contract. The frontend vertical slice may ship behind +Agent Server capability detection while the backend endpoints are implemented. + +## Product definition + +Canvas Extensions are installable packages that change Agent Canvas itself. +They contribute UI and local product behavior such as routed pages, conversation +panels, renderers, slots, and themes. Skills and plugins change the agent; +Canvas Extensions change the app. + +The Customize area remains the single inventory for Skills, Plugins, MCP, and +Canvas Extensions. The inventory item is named **Extensions**; "addon" is an +informal alias only. + +## Decisions + +1. **The active Agent Server owns extensions.** An extension is installed on the + computer or container running the Agent Server. Its source, resolved revision, + files, manifest, and enabled state live there. Canvas only discovers and loads + extensions from the currently active backend. Switching backends replaces the + active extension set. +2. **Extension code is trusted, same-realm code.** There is no iframe, worker + sandbox, or granular permission system. Once enabled, an extension has the + same ambient browser authority as Canvas code. Shadow DOM may be offered later + as optional style isolation, but never as a security boundary. +3. **Install and enable are separate.** Installation always produces a disabled + extension. An agent may install or update an extension, but the user returns + to Customize -> Extensions and explicitly enables it. In v1 this is a product + consent invariant, not proof of human presence against an agent that can call + the same authenticated APIs. A future backend policy may allow agent-driven + enablement. +4. **Enablement is hot.** Enabling loads and activates the extension without an + app or Agent Server restart. Disabling unmounts registered surfaces and calls + lifecycle cleanup. Because code is trusted same-realm JavaScript, cleanup is + best-effort; an extension can create global effects the host cannot revoke. +5. **Distribution follows plugins, not their runtime.** Install coordinates are + `source`, optional `ref`, and optional `repo_path`, resolved and pinned by the + Agent Server. A repository may contain multiple extensions and other artifact + types under subpaths. Backend-local paths are interpreted on the backend + machine, never in the frontend process. +6. **Updates preserve enablement.** Refresh resolves and installs a new revision + atomically and keeps the prior enabled state. The UI shows the resulting + resolved revision. Since v1 is a trusted-code model, there is no misleading + permission-diff approval gate. The staged check/apply flow currently exists + only at the Agent Server service layer; until it is exposed over HTTP, the + Customize UI offers no Refresh action. + +## Trust disclosure + +Before enabling, Canvas says plainly that the extension can access and modify the +Canvas page and can make authenticated requests available to the current browser +session. The review screen shows source, requested ref, resolved revision, +manifest metadata, and contributed surfaces. It does not show fictitious +fine-grained permissions. + +Install and update are still meaningful trust actions because they select the +code revision stored by the backend. Enable is the explicit point at which Canvas +executes that code. + +## Package contract + +The manifest filename is `canvas-extension.json`. + +```json +{ + "schema_version": 1, + "name": "example-dashboard", + "display_name": "Example dashboard", + "version": "0.1.0", + "description": "A backend-specific project dashboard.", + "entrypoint": "dist/extension.js", + "contributes": { + "pages": [ + { + "id": "dashboard", + "title": "Dashboard", + "path": "/dashboard", + "nav_label": "Dashboard" + } + ] + } +} +``` + +Rules for v1: + +- `name` and contribution IDs use lowercase letters, digits, and hyphens. +- Page `path` values are absolute kebab-case routes (leading `/`); Canvas + mounts them relative to `/extensions/{name}`. +- `entrypoint` is a path inside the installed package. +- The entrypoint is one self-contained browser ESM bundle. It must not contain + unresolved bare imports or external chunks; dependencies, CSS, and small + assets are bundled or embedded by the authoring template. +- The backend validates that the manifest, entrypoint, and any future asset path + remain inside the installed extension root. +- Host compatibility fields will be added before marketplace distribution. The + initial schema is intentionally small while the page ABI is proven by a + separately built sample extension. + +The v1 module exports `activate`: + +```ts +export function activate(host: CanvasExtensionHost): void | (() => void) { + return host.registerPage("dashboard", ({ container, path, navigate }) => { + container.textContent = `Extension route: ${path}`; + return () => container.replaceChildren(); + }); +} +``` + +`CanvasExtensionHost` is versioned independently from the manifest. The first +host API contains: + +- `apiVersion: "1"` +- immutable extension/backend metadata +- `registerPage(id, mount)` for page factories declared by the manifest +- `navigate(path)` using Canvas base-path-aware routing +- `agentServer.request(...)`, an authenticated request helper targeting the + extension's owning backend + +The runtime fetches the bundle as authenticated text and imports it through a +temporary Blob URL. A direct `