feat: land the Canvas Extensions frontend (load pages, sidebar, customize) (#16895)

This commit is contained in:
Hiep Le
2026-08-27 17:53:40 +00:00
committed by GitHub
parent 732f866ed6
commit 89dc8bd446
38 changed files with 2812 additions and 43 deletions
+2
View File
@@ -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.
@@ -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", () => {
@@ -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" });
});
+93
View File
@@ -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 <http://localhost:3102/extensions>. 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).
+1
View File
@@ -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.
+3
View File
@@ -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",
],
},
+283
View File
@@ -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 `<script src>` or `import(backendUrl)` cannot carry
`X-Session-API-Key`, so it is not the v1 loading path. Importing the bundle does
not activate it; Canvas calls `activate` only for an enabled installation.
## Agent Server API
The API deliberately mirrors plugin distribution management while remaining a
separate runtime:
| Method | Path | Purpose |
| -------- | ------------------------------------------------- | --------------------------------------------------------- |
| `GET` | `/api/canvas-extensions/installed` | List installed extensions and parsed manifests |
| `POST` | `/api/canvas-extensions/install` | Install from git or a backend-local path; always disabled |
| `GET` | `/api/canvas-extensions/installed/{name}` | Read one installation |
| `PATCH` | `/api/canvas-extensions/installed/{name}` | Set enabled state |
| `DELETE` | `/api/canvas-extensions/installed/{name}` | Uninstall |
| `GET` | `/api/canvas-extensions/installed/{name}/bundle` | Return the entrypoint as JavaScript text |
A refresh endpoint (`POST /api/canvas-extensions/installed/{name}/refresh`) is
planned but not part of the current router; the service-layer staged check/apply
flow backs it and is tracked separately.
The list endpoint wraps its results in a `canvas_extensions` array.
Install request:
```json
{
"source": "github:owner/repository",
"ref": "main",
"repo_path": "extensions/example-dashboard",
"force": false
}
```
Installed response:
```json
{
"name": "example-dashboard",
"version": "0.1.0",
"description": "A backend-specific project dashboard.",
"enabled": false,
"source": "github:owner/repository",
"resolved_ref": "6f5f9a...",
"repo_path": "extensions/example-dashboard",
"installed_at": "2026-08-01T12:00:00Z",
"install_path": "/home/user/.openhands/canvas-extensions/installed/example-dashboard",
"manifest": {}
}
```
The `manifest` field (parsed `canvas-extension.json`, including
`contributes.pages`) is not returned by the current router yet; until it ships,
Canvas treats it as optional and renders installations without page
contributions or a display name.
The backend must prevent install requests from setting `enabled: true`. The
frontend's enable control calls the patch endpoint. Backend authorization and
multi-user installation scope follow the Agent Server deployment's existing
session trust model; v1 does not introduce a separate approval credential.
Until the API exists, Canvas treats HTTP 404 as "this backend does not support
Canvas Extensions". Customize shows an upgrade message, while the global runtime
stays silent. It must not collapse unsupported, unreachable, and an empty
inventory into the same state.
## Frontend runtime
The runtime is keyed by backend ID, organization scope, and connection revision.
This prevents an enabled bundle or cached inventory from one backend appearing
on another after a switch or reconnect.
For each enabled installation:
1. Fetch the authenticated entrypoint text from the owning backend.
2. Import the self-contained ESM bundle from a Blob URL.
3. Validate `activate` and its host API version.
4. Create an extension-scoped registry and call `activate`.
5. Admit registrations only when their IDs were declared in the manifest.
6. Render each surface inside an error boundary owned by Canvas.
7. On disable, uninstall, update, or backend switch, unmount every surface,
invoke the extension disposer, and remove its registry entries.
The initial route shape is
`/extensions/{extension-name}/{declared-page-path}`. `/extensions` itself is the
Customize inventory. All routing goes through React Router so `VITE_BASE_PATH`
continues to work.
## Delivery plan
### Slice 1: contract and management
- Land this spec and shared TypeScript manifest/installation types.
- Add a backend-keyed service and query hooks for list, install, enable/disable,
uninstall, and authenticated bundle fetch.
- Add Customize -> Extensions with an unsupported-backend state, source install
form, source/revision/contribution review, and explicit enable control.
- Keep the global runtime silent when the active backend returns 404.
### Slice 2: routed page proof
- Add the versioned host API, module loader, lifecycle registry, and error
boundary.
- Add dynamic page routes and left-rail navigation for enabled page
contributions.
- Build a standalone fixture extension with the same scaffold authors will use.
- Verify enable, hot activation, navigation, disable cleanup, update, backend
switching, session-key-authenticated bundle loading, and `VITE_BASE_PATH`.
### Slice 3: theme proof
- Define a code-free theme contribution with a strict Canvas token schema.
- Validate theme manifests on the backend and surface installed themes in
Settings.
- Keep theme selection browser/user scoped while availability follows the active
backend; fall back cleanly when switching away from the providing backend.
### Slice 4: conversation surfaces
- Replace the closed conversation tab union with namespaced runtime IDs and
centralized persistence admission.
- Add extension tabs/panels, then host-owned header/footer/badge slots.
- Define mobile behavior, ordering, conflicts, missing-extension fallback, and
per-conversation lifecycle context before opening each surface to authors.
### Slice 5: visualizers and authoring loop
- Add augment/replace visualizer registrations with explicit event matching,
precedence, and built-in fallback.
- Publish the stable extension SDK types, bundler template, validation command,
and sample repository.
- Only then ship `/build-canvas-extension`, including backend-local versus remote
workspace guidance, temporary install, manual enable instructions, and git
publication/update flow.
## Verification
Each slice adds service and runtime unit tests. The routed page slice also adds a
mock-LLM end-to-end spec (`tests/e2e/mock-llm/canvas-extensions/`) that drives
the production build through install → enable → page render → disable →
uninstall. Until the pinned agent-server ships the endpoints, the spec serves
the API contract from `src/fixtures/canvas-extensions/demo-page` through
Playwright route interception; once it does, the stub is removed and the same
steps run against the real backend.
Changing the mock-LLM feature layout or mapping requires updating `AGENTS.md` in
the same change. Before merge, run `npm run lint`, `npm test`, `npm run build`,
and `npm run build:lib`.
## Explicit non-goals for v1
- iframe/worker isolation or enforceable fine-grained permissions
- arbitrary lifecycle scripts during installation
- automatic enablement by agents
- marketplace ranking or publisher signing
- conversation tabs, slots, or visualizer replacement in the first vertical
slice
- treating `@openhands/extensions` as an installable runtime format
+173
View File
@@ -0,0 +1,173 @@
import { AgentServerClient } from "@openhands/typescript-client/clients";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import CanvasExtensionsService, {
CanvasExtensionsUnsupportedError,
} from "#/api/canvas-extensions-service";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import type { InstalledCanvasExtensionInfo } from "#/types/canvas-extension";
vi.mock("@openhands/typescript-client/clients", () => ({
AgentServerClient: vi.fn(),
}));
const get = vi.fn();
const post = vi.fn();
const patch = vi.fn();
const remove = vi.fn();
const request = vi.fn();
const localBackend: Backend = {
id: "local",
name: "Local",
host: "http://127.0.0.1:8000",
apiKey: "session-key",
kind: "local",
};
const extension: InstalledCanvasExtensionInfo = {
name: "demo-extension",
version: "0.1.0",
description: "Demo",
enabled: false,
source: "github:example/extensions",
resolved_ref: "abc123",
repo_path: "extensions/demo",
installed_at: "2026-08-01T00:00:00Z",
install_path: "/tmp/extensions/demo-extension",
manifest: {
schema_version: 1,
name: "demo-extension",
version: "0.1.0",
entrypoint: "dist/extension.js",
contributes: {
pages: [{ id: "home", title: "Home", path: "home" }],
},
},
};
beforeEach(() => {
vi.clearAllMocks();
__resetActiveStoreForTests();
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
vi.mocked(AgentServerClient).mockImplementation(
function MockAgentServerClient() {
return {
get,
post,
patch,
delete: remove,
request,
close: vi.fn(),
} as unknown as AgentServerClient;
} as unknown as typeof AgentServerClient,
);
});
afterEach(() => {
setActiveSelection(null);
setRegisteredBackends([]);
__resetActiveStoreForTests();
});
describe("CanvasExtensionsService", () => {
it("lists extensions installed on the active Agent Server", async () => {
get.mockResolvedValue({ canvas_extensions: [extension] });
await expect(CanvasExtensionsService.listInstalled()).resolves.toEqual([
extension,
]);
expect(get).toHaveBeenCalledWith("/api/canvas-extensions/installed");
});
it("preserves install coordinates without allowing an enabled flag", async () => {
post.mockResolvedValue(extension);
await CanvasExtensionsService.install({
source: "github:example/extensions",
ref: "main",
repo_path: "extensions/demo",
});
expect(post).toHaveBeenCalledWith("/api/canvas-extensions/install", {
source: "github:example/extensions",
ref: "main",
repo_path: "extensions/demo",
});
});
it("maps a missing router to an explicit unsupported-backend error", async () => {
get.mockRejectedValue(
Object.assign(new Error("Not Found"), {
name: "HttpError",
status: 404,
}),
);
await expect(CanvasExtensionsService.listInstalled()).rejects.toEqual(
expect.objectContaining<Partial<CanvasExtensionsUnsupportedError>>({
name: "CanvasExtensionsUnsupportedError",
reason: "missing-api",
}),
);
});
it("does not call the Agent Server protocol for a Cloud backend", async () => {
const cloud: Backend = {
...localBackend,
id: "cloud",
name: "Cloud",
kind: "cloud",
};
setRegisteredBackends([cloud]);
setActiveSelection({ backendId: cloud.id });
await expect(CanvasExtensionsService.listInstalled()).rejects.toEqual(
expect.objectContaining({ reason: "cloud-backend" }),
);
expect(AgentServerClient).not.toHaveBeenCalled();
});
it("fetches the bundle as authenticated text from the captured backend", async () => {
get.mockResolvedValue("export const activate = () => {};");
await expect(
CanvasExtensionsService.fetchBundle(extension.name, localBackend),
).resolves.toContain("activate");
expect(AgentServerClient).toHaveBeenCalledWith({
host: localBackend.host,
apiKey: localBackend.apiKey,
timeout: 60000,
});
expect(get).toHaveBeenCalledWith(
"/api/canvas-extensions/installed/demo-extension/bundle",
{ responseType: "text" },
);
});
it("encodes extension names in mutation paths", async () => {
patch.mockResolvedValue({ name: "a/b", enabled: true });
await CanvasExtensionsService.setEnabled("a/b", true);
expect(patch).toHaveBeenCalledWith(
"/api/canvas-extensions/installed/a%2Fb",
{ enabled: true },
);
});
it("does not send the backend session key to an absolute request URL", async () => {
await expect(
CanvasExtensionsService.requestAgentServer({
path: "https://example.com/collect",
}),
).rejects.toThrow("root-relative path");
expect(AgentServerClient).not.toHaveBeenCalled();
});
});
+165
View File
@@ -0,0 +1,165 @@
import { AgentServerClient } from "@openhands/typescript-client/clients";
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
import { isSdkHttpStatusError } from "#/api/agent-server-compatibility";
import {
getActiveBackend,
isNoBackend,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import type {
CanvasExtensionAgentServerRequest,
InstallCanvasExtensionRequest,
InstalledCanvasExtensionInfo,
} from "#/types/canvas-extension";
const CANVAS_EXTENSIONS_BASE_PATH = "/api/canvas-extensions";
export type CanvasExtensionsUnsupportedReason =
| "no-backend"
| "cloud-backend"
| "missing-api";
export class CanvasExtensionsUnsupportedError extends Error {
readonly reason: CanvasExtensionsUnsupportedReason;
constructor(reason: CanvasExtensionsUnsupportedReason) {
const message =
reason === "no-backend"
? "Add an Agent Server backend to use Canvas Extensions."
: reason === "cloud-backend"
? "Canvas Extensions are not available on Cloud backends yet."
: "This Agent Server does not support Canvas Extensions yet. Upgrade the backend and try again.";
super(message);
this.name = "CanvasExtensionsUnsupportedError";
this.reason = reason;
}
}
export function isCanvasExtensionsUnsupportedError(
error: unknown,
): error is CanvasExtensionsUnsupportedError {
return (
error instanceof CanvasExtensionsUnsupportedError ||
(typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "CanvasExtensionsUnsupportedError")
);
}
function requireSupportedBackend(): void {
const { backend } = getActiveBackend();
if (isNoBackend(backend)) {
throw new CanvasExtensionsUnsupportedError("no-backend");
}
if (backend.kind === "cloud") {
throw new CanvasExtensionsUnsupportedError("cloud-backend");
}
}
function getClient(): AgentServerClient {
requireSupportedBackend();
return new AgentServerClient(getAgentServerClientOptions());
}
function getClientForBackend(backend: Backend): AgentServerClient {
if (isNoBackend(backend)) {
throw new CanvasExtensionsUnsupportedError("no-backend");
}
if (backend.kind === "cloud") {
throw new CanvasExtensionsUnsupportedError("cloud-backend");
}
return new AgentServerClient({
host: backend.host,
...(backend.apiKey ? { apiKey: backend.apiKey } : {}),
timeout: 60000,
});
}
async function mapUnsupported<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
if (isSdkHttpStatusError(error, 404)) {
throw new CanvasExtensionsUnsupportedError("missing-api");
}
throw error;
}
}
function installedExtensionPath(name: string): string {
return `${CANVAS_EXTENSIONS_BASE_PATH}/installed/${encodeURIComponent(name)}`;
}
class CanvasExtensionsService {
static async listInstalled(): Promise<InstalledCanvasExtensionInfo[]> {
const client = getClient();
const response = await mapUnsupported(() =>
client.get<{ canvas_extensions: InstalledCanvasExtensionInfo[] }>(
`${CANVAS_EXTENSIONS_BASE_PATH}/installed`,
),
);
return response.canvas_extensions ?? [];
}
static async install(
request: InstallCanvasExtensionRequest,
): Promise<InstalledCanvasExtensionInfo> {
const client = getClient();
return mapUnsupported(() =>
client.post<InstalledCanvasExtensionInfo>(
`${CANVAS_EXTENSIONS_BASE_PATH}/install`,
request,
),
);
}
static async setEnabled(
name: string,
enabled: boolean,
): Promise<{ name: string; enabled: boolean }> {
const client = getClient();
return mapUnsupported(() =>
client.patch<{ name: string; enabled: boolean }>(
installedExtensionPath(name),
{ enabled },
),
);
}
static async uninstall(name: string): Promise<{ message: string }> {
const client = getClient();
return mapUnsupported(() =>
client.delete<{ message: string }>(installedExtensionPath(name)),
);
}
static async fetchBundle(name: string, backend?: Backend): Promise<string> {
const client = backend ? getClientForBackend(backend) : getClient();
return mapUnsupported(() =>
client.get<string>(`${installedExtensionPath(name)}/bundle`, {
responseType: "text",
}),
);
}
static async requestAgentServer<T = unknown>(
request: CanvasExtensionAgentServerRequest,
backend?: Backend,
): Promise<T> {
if (!request.path.startsWith("/") || request.path.startsWith("//")) {
throw new Error(
"Canvas Extension Agent Server requests must use a root-relative path.",
);
}
const client = backend ? getClientForBackend(backend) : getClient();
return client.request<T>({
method: request.method ?? "GET",
path: request.path,
body: request.body,
headers: request.headers,
});
}
}
export default CanvasExtensionsService;
@@ -0,0 +1,115 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalCloseButton } from "#/components/shared/modals/modal-close-button";
import { useInstallCanvasExtension } from "#/hooks/mutation/use-manage-canvas-extensions";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
import { modalTitleLgClassName } from "#/utils/modal-classes";
interface AddCanvasExtensionModalProps {
onClose: () => void;
}
export function AddCanvasExtensionModal({
onClose,
}: AddCanvasExtensionModalProps) {
const { t } = useTranslation("openhands");
const install = useInstallCanvasExtension();
const [source, setSource] = React.useState("");
const [ref, setRef] = React.useState("");
const [repoPath, setRepoPath] = React.useState("");
const trimmedSource = source.trim();
const canSubmit = trimmedSource.length > 0 && !install.isPending;
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!canSubmit) return;
install.mutate(
{
source: trimmedSource,
ref: ref.trim() || null,
repo_path: repoPath.trim() || null,
},
{ onSuccess: onClose },
);
};
return (
<ModalBackdrop
onClose={onClose}
aria-label={t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_ADD_BUTTON)}
>
<form
onSubmit={handleSubmit}
data-testid="add-canvas-extension-modal"
className="relative flex w-[520px] max-w-[90vw] max-h-[85vh] flex-col rounded-xl border border-[var(--oh-border)] bg-base-secondary"
>
<ModalCloseButton
onClose={onClose}
testId="add-canvas-extension-modal-close"
/>
<header className="flex-shrink-0 px-6 pb-4 pt-6">
<h2 className={cn("pr-6", modalTitleLgClassName)}>
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_ADD_BUTTON)}
</h2>
<p className="mt-4 text-sm text-tertiary-light">
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_ADD_MODAL_INTRO)}
</p>
</header>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 custom-scrollbar">
<SettingsInput
testId="add-canvas-extension-source-input"
label={t(I18nKey.SETTINGS$PLUGINS_SOURCE_LABEL)}
type="text"
value={source}
onChange={setSource}
placeholder={t(I18nKey.SETTINGS$PLUGINS_SOURCE_PLACEHOLDER)}
showRequiredTag
/>
<SettingsInput
testId="add-canvas-extension-ref-input"
label={t(I18nKey.SETTINGS$PLUGINS_REF_LABEL)}
type="text"
value={ref}
onChange={setRef}
showOptionalTag
/>
<SettingsInput
testId="add-canvas-extension-repo-path-input"
label={t(I18nKey.SETTINGS$PLUGINS_REPO_PATH_LABEL)}
type="text"
value={repoPath}
onChange={setRepoPath}
showOptionalTag
/>
</div>
<footer className="flex flex-shrink-0 justify-end gap-2 px-6 pb-6 pt-4">
<BrandButton
type="button"
variant="secondary"
onClick={onClose}
testId="add-canvas-extension-dismiss"
>
{t(I18nKey.BUTTON$CLOSE)}
</BrandButton>
<BrandButton
type="submit"
variant="primary"
testId="add-canvas-extension-submit"
isDisabled={!canSubmit}
>
{t(
install.isPending
? I18nKey.SETTINGS$PLUGINS_INSTALLING
: I18nKey.SETTINGS$PLUGINS_INSTALL,
)}
</BrandButton>
</footer>
</form>
</ModalBackdrop>
);
}
@@ -0,0 +1,124 @@
import { useTranslation } from "react-i18next";
import { BrandButton } from "#/components/features/settings/brand-button";
import { ToggleSwitch } from "#/ui/toggle-switch";
import { I18nKey } from "#/i18n/declaration";
import type { InstalledCanvasExtensionInfo } from "#/types/canvas-extension";
import {
extensionModuleCardPillClassName,
extensionModuleCardSurfaceClassName,
} from "#/utils/extension-module-card-classes";
interface CanvasExtensionCardProps {
extension: InstalledCanvasExtensionInfo;
isBusy: boolean;
onToggle: () => void;
onUninstall: () => void;
}
export function CanvasExtensionCard({
extension,
isBusy,
onToggle,
onUninstall,
}: CanvasExtensionCardProps) {
const { t } = useTranslation("openhands");
const pages = extension.manifest?.contributes?.pages ?? [];
const displayName = extension.manifest?.display_name || extension.name;
return (
<article
data-testid={`canvas-extension-card-${extension.name}`}
className={`flex min-w-0 flex-col gap-4 p-4 ${extensionModuleCardSurfaceClassName}`}
>
<header className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold text-white">
{displayName}
</h3>
<p className="mt-0.5 truncate text-xs text-tertiary-alt">
{extension.source}
</p>
</div>
<ToggleSwitch
enabled={extension.enabled}
label={t(
extension.enabled ? I18nKey.COMMON$DISABLE : I18nKey.COMMON$ENABLE,
)}
onToggle={onToggle}
className={isBusy ? "pointer-events-none opacity-50" : undefined}
/>
</header>
{extension.description || extension.manifest?.description ? (
<p className="text-xs leading-relaxed text-tertiary-light">
{extension.description || extension.manifest?.description}
</p>
) : null}
<div className="flex flex-wrap gap-2">
<span className={extensionModuleCardPillClassName}>
{extension.enabled
? t(I18nKey.SETTINGS$SKILLS_ENABLED)
: t(I18nKey.SETTINGS$SKILLS_DISABLED)}
</span>
<span className={extensionModuleCardPillClassName}>
{t(I18nKey.SETTINGS$SKILLS_VERSION, {
version: extension.version,
})}
</span>
{pages.length ? (
<span className={extensionModuleCardPillClassName}>
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_PAGES)}: {pages.length}
</span>
) : null}
</div>
<dl className="grid min-w-0 grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
{extension.resolved_ref ? (
<>
<dt className="text-tertiary-alt">
{t(I18nKey.SETTINGS$PLUGINS_REF_LABEL)}
</dt>
<dd className="min-w-0 truncate font-mono text-tertiary-light">
{extension.resolved_ref}
</dd>
</>
) : null}
{extension.repo_path ? (
<>
<dt className="text-tertiary-alt">
{t(I18nKey.SETTINGS$PLUGINS_REPO_PATH_LABEL)}
</dt>
<dd className="min-w-0 truncate text-tertiary-light">
{extension.repo_path}
</dd>
</>
) : null}
</dl>
{pages.length ? (
<section className="flex min-w-0 flex-col gap-2">
<ul className="flex flex-wrap gap-2">
{pages.map((page) => (
<li key={page.id} className={extensionModuleCardPillClassName}>
{page.title}
</li>
))}
</ul>
</section>
) : null}
<footer className="flex justify-end gap-2 border-t border-[var(--oh-border)] pt-3">
<BrandButton
type="button"
variant="ghost-danger"
testId={`canvas-extension-uninstall-${extension.name}`}
isDisabled={isBusy}
onClick={onUninstall}
>
{t(I18nKey.SETTINGS$PLUGINS_UNINSTALL)}
</BrandButton>
</footer>
</article>
);
}
@@ -0,0 +1,170 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import CanvasExtensionsService from "#/api/canvas-extensions-service";
import {
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import type {
CanvasExtensionHost,
InstalledCanvasExtensionInfo,
} from "#/types/canvas-extension";
import {
CanvasExtensionsRuntimeProvider,
useCanvasExtensionsRuntime,
} from "./canvas-extensions-runtime";
const backend: Backend = {
id: "extension-backend",
name: "Extension backend",
host: "http://127.0.0.1:8000",
apiKey: "test-key",
kind: "local",
};
const extension: InstalledCanvasExtensionInfo = {
name: "demo-extension",
version: "0.1.0",
enabled: true,
source: "github:example/demo",
resolved_ref: "abc123",
installed_at: "2026-08-01T00:00:00Z",
install_path: "/tmp/demo-extension",
manifest: {
schema_version: 1,
name: "demo-extension",
display_name: "Demo extension",
version: "0.1.0",
entrypoint: "dist/extension.js",
contributes: {
pages: [
{
id: "dashboard",
title: "Dashboard",
path: "/dashboard",
nav_label: "Demo dashboard",
},
],
},
},
};
function RuntimeProbe() {
const runtime = useCanvasExtensionsRuntime();
return (
<div>
<span data-testid="page-count">{runtime.pages.length}</span>
<span data-testid="page-href">{runtime.pages[0]?.href}</span>
<span data-testid="runtime-error">
{runtime.errors.get(extension.name)}
</span>
</div>
);
}
function renderRuntime(
moduleLoader: (source: string) => Promise<{
activate: (host: CanvasExtensionHost) => void | (() => void);
}>,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>
<MemoryRouter>
<CanvasExtensionsRuntimeProvider moduleLoader={moduleLoader}>
<RuntimeProbe />
</CanvasExtensionsRuntimeProvider>
</MemoryRouter>
</ActiveBackendProvider>
</QueryClientProvider>,
);
}
describe("CanvasExtensionsRuntimeProvider", () => {
beforeEach(() => {
setRegisteredBackends([backend]);
setActiveSelection({ backendId: backend.id });
vi.spyOn(CanvasExtensionsService, "listInstalled").mockResolvedValue([
extension,
]);
vi.spyOn(CanvasExtensionsService, "fetchBundle").mockResolvedValue(
"fixture source",
);
});
afterEach(() => {
vi.restoreAllMocks();
setActiveSelection(null);
setRegisteredBackends([]);
});
it("activates enabled extensions and admits declared page registrations", async () => {
const disposeActivation = vi.fn();
const moduleLoader = vi.fn().mockResolvedValue({
activate: (host: CanvasExtensionHost) => {
host.registerPage("dashboard", () => undefined);
return disposeActivation;
},
});
const rendered = renderRuntime(moduleLoader);
await waitFor(() =>
expect(screen.getByTestId("page-count")).toHaveTextContent("1"),
);
expect(screen.getByTestId("page-href")).toHaveTextContent(
"/extensions/demo-extension/dashboard",
);
expect(CanvasExtensionsService.fetchBundle).toHaveBeenCalledWith(
extension.name,
expect.objectContaining({ id: backend.id }),
);
rendered.unmount();
expect(disposeActivation).toHaveBeenCalledTimes(1);
});
it("rejects registrations that were not declared in the manifest", async () => {
const moduleLoader = vi.fn().mockResolvedValue({
activate: (host: CanvasExtensionHost) => {
host.registerPage("surprise", () => undefined);
},
});
renderRuntime(moduleLoader);
await waitFor(() =>
expect(screen.getByTestId("runtime-error")).toHaveTextContent(
'registered undeclared page "surprise"',
),
);
expect(screen.getByTestId("page-count")).toHaveTextContent("0");
});
it("degrades gracefully when the backend response has no manifest", async () => {
vi.mocked(CanvasExtensionsService.listInstalled).mockResolvedValue([
{ ...extension, manifest: null },
]);
const moduleLoader = vi.fn().mockResolvedValue({
activate: (host: CanvasExtensionHost) => {
host.registerPage("dashboard", () => undefined);
},
});
renderRuntime(moduleLoader);
await waitFor(() =>
expect(screen.getByTestId("runtime-error")).toHaveTextContent(
'registered undeclared page "dashboard"',
),
);
expect(screen.getByTestId("page-count")).toHaveTextContent("0");
});
});
@@ -0,0 +1,265 @@
import React from "react";
import { useNavigate } from "react-router";
import CanvasExtensionsService from "#/api/canvas-extensions-service";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { loadCanvasExtensionModule } from "#/extensions/canvas-extension-module-loader";
import { useCanvasExtensions } from "#/hooks/query/use-canvas-extensions";
import {
CANVAS_EXTENSION_HOST_API_VERSION,
type CanvasExtensionDispose,
type CanvasExtensionHost,
type CanvasExtensionModule,
type CanvasExtensionPageContribution,
type CanvasExtensionPageMount,
type InstalledCanvasExtensionInfo,
} from "#/types/canvas-extension";
export interface RegisteredCanvasExtensionPage {
extension: InstalledCanvasExtensionInfo;
contribution: CanvasExtensionPageContribution;
mount: CanvasExtensionPageMount;
href: string;
}
interface CanvasExtensionsRuntimeValue {
pages: RegisteredCanvasExtensionPage[];
activating: boolean;
errors: ReadonlyMap<string, string>;
}
const EMPTY_RUNTIME: CanvasExtensionsRuntimeValue = {
pages: [],
activating: false,
errors: new Map(),
};
const CanvasExtensionsRuntimeContext =
React.createContext<CanvasExtensionsRuntimeValue>(EMPTY_RUNTIME);
export function useCanvasExtensionsRuntime(): CanvasExtensionsRuntimeValue {
return React.useContext(CanvasExtensionsRuntimeContext);
}
function isValidSegment(value: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
}
export function buildCanvasExtensionPageHref(
extensionName: string,
contributionPath: string,
): string {
return `/extensions/${encodeURIComponent(extensionName)}/${contributionPath
.split("/")
.map(encodeURIComponent)
.join("/")}`;
}
function getDeclaredPage(
extension: InstalledCanvasExtensionInfo,
contributionId: string,
): CanvasExtensionPageContribution {
const contribution = extension.manifest?.contributes?.pages?.find(
(page) => page.id === contributionId,
);
if (!contribution) {
throw new Error(
`Extension ${extension.name} registered undeclared page "${contributionId}".`,
);
}
// The backend declares page paths as absolute routes (e.g. "/dashboard");
// normalize to the relative form used for hrefs and route matching.
const normalizedPath = contribution.path.replace(/^\/+/, "");
if (
!isValidSegment(extension.name) ||
!isValidSegment(contribution.id) ||
!normalizedPath.split("/").every(isValidSegment)
) {
throw new Error(
`Extension ${extension.name} has an invalid page name, id, or path.`,
);
}
return { ...contribution, path: normalizedPath };
}
type CanvasExtensionModuleLoader = (
source: string,
) => Promise<CanvasExtensionModule>;
interface CanvasExtensionsRuntimeProviderProps {
children: React.ReactNode;
/** Test seam for environments that cannot import browser Blob URLs. */
moduleLoader?: CanvasExtensionModuleLoader;
}
export function CanvasExtensionsRuntimeProvider({
children,
moduleLoader = loadCanvasExtensionModule,
}: CanvasExtensionsRuntimeProviderProps) {
const active = useActiveBackend();
const navigate = useNavigate();
const query = useCanvasExtensions();
const [pages, setPages] = React.useState<RegisteredCanvasExtensionPage[]>([]);
const [errors, setErrors] = React.useState<ReadonlyMap<string, string>>(
new Map(),
);
const [activating, setActivating] = React.useState(false);
const enabledExtensions = React.useMemo(
() => (query.data ?? []).filter((extension) => extension.enabled),
[query.data],
);
// `useActiveBackend` can synthesize a fresh `backend` object on every render
// (e.g. when mounted without an <ActiveBackendProvider>), and refetches
// produce new extension arrays with identical content. The activation effect
// therefore keys on this value signature — backend identity plus the enabled
// inventory — and reads the current objects from refs, so referential churn
// never tears down and re-activates extensions.
const activationSignature = React.useMemo(
() =>
JSON.stringify({
backendId: active.backend.id,
backendKind: active.backend.kind,
connectionRevision: active.backend.connectionRevision ?? 0,
orgId: active.orgId,
extensions: enabledExtensions.map((extension) => ({
name: extension.name,
version: extension.version,
resolvedRef: extension.resolved_ref ?? null,
pages: extension.manifest?.contributes?.pages ?? [],
})),
}),
[
active.backend.id,
active.backend.kind,
active.backend.connectionRevision,
active.orgId,
enabledExtensions,
],
);
const activeRef = React.useRef(active);
activeRef.current = active;
const enabledExtensionsRef = React.useRef(enabledExtensions);
enabledExtensionsRef.current = enabledExtensions;
React.useEffect(() => {
let cancelled = false;
const disposers: CanvasExtensionDispose[] = [];
const { backend, orgId } = activeRef.current;
const extensionsToActivate = enabledExtensionsRef.current;
setPages([]);
setErrors(new Map());
setActivating(extensionsToActivate.length > 0);
const activateExtension = async (
extension: InstalledCanvasExtensionInfo,
) => {
const registeredPages = new Map<string, RegisteredCanvasExtensionPage>();
const registrationDisposers: CanvasExtensionDispose[] = [];
try {
const source = await CanvasExtensionsService.fetchBundle(
extension.name,
backend,
);
if (cancelled) return;
const extensionModule = await moduleLoader(source);
if (cancelled) return;
const host: CanvasExtensionHost = {
apiVersion: CANVAS_EXTENSION_HOST_API_VERSION,
extension: Object.freeze({
name: extension.name,
version: extension.version,
resolvedRef: extension.resolved_ref ?? null,
}),
backend: Object.freeze({
id: backend.id,
kind: backend.kind,
orgId,
}),
registerPage: (contributionId, mount) => {
if (registeredPages.has(contributionId)) {
throw new Error(
`Extension ${extension.name} registered page "${contributionId}" more than once.`,
);
}
const contribution = getDeclaredPage(extension, contributionId);
const page: RegisteredCanvasExtensionPage = {
extension,
contribution,
mount,
href: buildCanvasExtensionPageHref(
extension.name,
contribution.path,
),
};
registeredPages.set(contributionId, page);
const unregister = () => registeredPages.delete(contributionId);
registrationDisposers.push(unregister);
return unregister;
},
navigate: (path) => navigate(path),
agentServer: {
request: (request) =>
CanvasExtensionsService.requestAgentServer(request, backend),
},
};
const disposeActivation = await extensionModule.activate(host);
if (cancelled) {
if (typeof disposeActivation === "function") disposeActivation();
return;
}
if (typeof disposeActivation === "function") {
disposers.push(disposeActivation);
}
disposers.push(...registrationDisposers);
setPages((current) => [
...current.filter((page) => page.extension.name !== extension.name),
...registeredPages.values(),
]);
} catch (error) {
registrationDisposers.forEach((dispose) => dispose());
if (cancelled) return;
const message =
error instanceof Error
? error.message
: "Extension activation failed.";
setErrors((current) => {
const next = new Map(current);
next.set(extension.name, message);
return next;
});
}
};
void Promise.all(extensionsToActivate.map(activateExtension)).finally(
() => {
if (!cancelled) setActivating(false);
},
);
return () => {
cancelled = true;
setPages([]);
for (const dispose of disposers.reverse()) {
try {
dispose();
} catch (error) {
console.error("Canvas Extension cleanup failed", error);
}
}
};
}, [activationSignature, moduleLoader, navigate]);
const value = React.useMemo(
() => ({ pages, activating, errors }),
[pages, activating, errors],
);
return (
<CanvasExtensionsRuntimeContext.Provider value={value}>
{children}
</CanvasExtensionsRuntimeContext.Provider>
);
}
@@ -6,6 +6,7 @@ import {
Plus,
Server,
Settings,
PanelsTopLeft,
} from "lucide-react";
import { OpenHandsLogoButton } from "#/components/shared/buttons/openhands-logo-button";
import { NavigationLink } from "#/components/shared/navigation-link";
@@ -40,6 +41,7 @@ import {
sidebarNavListClassName,
sidebarNavRowClassName,
} from "./sidebar-layout";
import { useCanvasExtensionsRuntime } from "#/components/features/canvas-extensions/canvas-extensions-runtime";
const ICON_SIZE = 18;
const SIDEBAR_LOGO_WIDTH = 34;
@@ -87,6 +89,7 @@ export function SidebarRailBody({
onOpenManageBackends,
}: SidebarRailBodyProps) {
const { t } = useTranslation("openhands");
const { pages: canvasExtensionPages } = useCanvasExtensionsRuntime();
const backendCloseTimerRef = collapsedBackendCloseTimer;
const { isPinnedRoute, togglePinnedRoute } = usePinnedHomeRoute();
@@ -241,6 +244,16 @@ export function SidebarRailBody({
)}
/>
)}
{canvasExtensionPages.map((page) => (
<SidebarNavLink
key={`${page.extension.name}:${page.contribution.id}`}
to={page.href}
label={page.contribution.nav_label || page.contribution.title}
testId={`sidebar-canvas-extension-${page.extension.name}-${page.contribution.id}`}
collapsed={collapsed}
icon={<PanelsTopLeft width={ICON_SIZE} height={ICON_SIZE} />}
/>
))}
</nav>
<SidebarConversationList collapsed={collapsed} />
@@ -181,6 +181,7 @@ export function Sidebar() {
currentPath === "/customize" ||
currentPath.startsWith("/skills") ||
currentPath === "/plugins" ||
currentPath === "/extensions" ||
currentPath === "/mcp";
const railBodyProps = {
@@ -17,8 +17,8 @@ import { EXTENSIONS_NAV_ITEMS } from "./extensions-navigation";
/** Only the Skills item points to a cloud-hosted page today. */
const CLOUD_LINKED_EXTENSION_PATH = "/skills";
/** Plugins are not available on Cloud backends, so this item is hidden there. */
const CLOUD_HIDDEN_EXTENSION_PATH = "/plugins";
/** Backend-installed artifacts are not available on Cloud backends yet. */
const CLOUD_HIDDEN_EXTENSION_PATHS = new Set(["/plugins", "/extensions"]);
export function ExtensionsMobileHub() {
const { t } = useTranslation("openhands");
@@ -35,7 +35,7 @@ export function ExtensionsMobileHub() {
<nav className="flex flex-col gap-0.5">
{EXTENSIONS_NAV_ITEMS.filter(
(item) =>
!(item.to === CLOUD_HIDDEN_EXTENSION_PATH && isCloudBackend),
!(CLOUD_HIDDEN_EXTENSION_PATHS.has(item.to) && isCloudBackend),
).map((item) => {
if (item.to === CLOUD_LINKED_EXTENSION_PATH && isCloudBackend) {
const cloudSkillsUrl = `${backend.host.replace(/\/+$/, "")}/settings/skills`;
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import { Blocks, ExternalLink } from "lucide-react";
import { NavigationLink } from "#/components/shared/navigation-link";
import { cn } from "#/utils/utils";
import SkillsIcon from "#/icons/skills.svg?react";
@@ -15,8 +15,8 @@ import { isNoBackend } from "#/api/backend-registry/active-store";
/** Only the Skills item points to a cloud-hosted page today. */
const CLOUD_LINKED_EXTENSION_PATH = "/skills";
/** Plugins are not available on Cloud backends, so this item is hidden there. */
const CLOUD_HIDDEN_EXTENSION_PATH = "/plugins";
/** Backend-installed artifacts are not available on Cloud backends yet. */
const CLOUD_HIDDEN_EXTENSION_PATHS = new Set(["/plugins", "/extensions"]);
interface ExtensionNavItem {
to: string;
@@ -62,6 +62,12 @@ export const EXTENSIONS_NAV_ITEMS: ExtensionNavItem[] = [
),
end: true,
},
{
to: "/extensions",
label: "Extensions",
icon: <Blocks width={16} height={16} aria-hidden="true" />,
end: true,
},
];
export function ExtensionsNavigation() {
@@ -81,7 +87,7 @@ export function ExtensionsNavigation() {
<div className="flex flex-col gap-0.5 pt-0.5">
{EXTENSIONS_NAV_ITEMS.filter(
(item) =>
!(item.to === CLOUD_HIDDEN_EXTENSION_PATH && isCloudBackend),
!(CLOUD_HIDDEN_EXTENSION_PATHS.has(item.to) && isCloudBackend),
).map((item) => {
const isCloudSkillsLink =
item.to === CLOUD_LINKED_EXTENSION_PATH && isCloudBackend;
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import {
assertCanvasExtensionModule,
InvalidCanvasExtensionModuleError,
} from "#/extensions/canvas-extension-module-loader";
describe("assertCanvasExtensionModule", () => {
it("accepts a module namespace with an activate export", () => {
expect(() =>
assertCanvasExtensionModule({ activate: () => undefined }),
).not.toThrow();
});
it("rejects modules that do not implement the host lifecycle", () => {
expect(() => assertCanvasExtensionModule({ default: {} })).toThrow(
InvalidCanvasExtensionModuleError,
);
});
});
@@ -0,0 +1,42 @@
import type { CanvasExtensionModule } from "#/types/canvas-extension";
export class InvalidCanvasExtensionModuleError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidCanvasExtensionModuleError";
}
}
export function assertCanvasExtensionModule(
value: unknown,
): asserts value is CanvasExtensionModule {
if (
typeof value !== "object" ||
value === null ||
!("activate" in value) ||
typeof value.activate !== "function"
) {
throw new InvalidCanvasExtensionModuleError(
'Canvas Extension entrypoint must export an "activate" function.',
);
}
}
/**
* Import an authenticated, self-contained ESM bundle without putting the
* Agent Server session key in a URL. The source has already been fetched by
* Canvas through its authenticated HTTP client.
*/
export async function loadCanvasExtensionModule(
source: string,
): Promise<CanvasExtensionModule> {
const blob = new Blob([source], { type: "text/javascript" });
const moduleUrl = URL.createObjectURL(blob);
try {
const imported: unknown = await import(/* @vite-ignore */ moduleUrl);
assertCanvasExtensionModule(imported);
return imported;
} finally {
URL.revokeObjectURL(moduleUrl);
}
}
@@ -0,0 +1,10 @@
# Demo page Canvas Extension
This fixture is a standalone, dependency-free implementation of manifest
schema 1 and host API 1. Its JavaScript file is already a self-contained browser
ES module, so an Agent Server test implementation can install this directory and
serve `extension.js` directly from the authenticated bundle endpoint.
For the browser-only installation flow that works before those Agent Server
endpoints exist, see
[`docs/CANVAS_EXTENSIONS_TESTING.md`](../../../../docs/CANVAS_EXTENSIONS_TESTING.md).
@@ -0,0 +1,18 @@
{
"schema_version": 1,
"name": "demo-page",
"display_name": "Demo page",
"version": "0.1.0",
"description": "A dependency-free fixture for the Canvas Extension page ABI.",
"entrypoint": "extension.js",
"contributes": {
"pages": [
{
"id": "hello",
"title": "Hello from an extension",
"path": "/hello",
"nav_label": "Extension demo"
}
]
}
}
@@ -0,0 +1,21 @@
export function activate(host) {
return host.registerPage("hello", ({ container, path }) => {
const wrapper = document.createElement("section");
wrapper.style.cssText =
"min-height:100%;padding:2rem;color:var(--oh-text-primary,#fff);background:var(--oh-color-base,#111);";
const title = document.createElement("h1");
title.textContent = "Hello from a Canvas Extension";
title.style.cssText = "font-size:1.5rem;font-weight:600;";
const detail = document.createElement("p");
detail.textContent = path
? `Nested extension path: ${path}`
: `Host API ${host.apiVersion} on backend ${host.backend.id}`;
detail.style.cssText = "margin-top:0.75rem;opacity:0.75;";
wrapper.append(title, detail);
container.append(wrapper);
return () => wrapper.remove();
});
}
@@ -0,0 +1,53 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import CanvasExtensionsService from "#/api/canvas-extensions-service";
import type { InstallCanvasExtensionRequest } from "#/types/canvas-extension";
import { CANVAS_EXTENSIONS_QUERY_KEYS } from "#/hooks/query/query-keys";
import { I18nKey } from "#/i18n/declaration";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
function useInvalidateCanvasExtensions() {
const queryClient = useQueryClient();
return () =>
queryClient.invalidateQueries({
queryKey: CANVAS_EXTENSIONS_QUERY_KEYS.all,
});
}
export function useInstallCanvasExtension() {
const invalidate = useInvalidateCanvasExtensions();
const { t } = useTranslation("openhands");
return useMutation({
mutationFn: (request: InstallCanvasExtensionRequest) =>
CanvasExtensionsService.install(request),
onSuccess: () => {
void invalidate();
displaySuccessToast(
t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_INSTALL_SUCCESS),
);
},
});
}
export function useSetCanvasExtensionEnabled() {
const invalidate = useInvalidateCanvasExtensions();
return useMutation({
mutationFn: ({ name, enabled }: { name: string; enabled: boolean }) =>
CanvasExtensionsService.setEnabled(name, enabled),
onSuccess: () => void invalidate(),
});
}
export function useUninstallCanvasExtension() {
const invalidate = useInvalidateCanvasExtensions();
const { t } = useTranslation("openhands");
return useMutation({
mutationFn: (name: string) => CanvasExtensionsService.uninstall(name),
onSuccess: () => {
void invalidate();
displaySuccessToast(
t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_UNINSTALL_SUCCESS),
);
},
});
}
+16
View File
@@ -54,6 +54,22 @@ export const PLUGINS_QUERY_KEYS = {
local: ["plugins-local"] as const,
} as const;
export const CANVAS_EXTENSIONS_QUERY_KEYS = {
all: ["canvas-extensions"] as const,
installed: (
backendId: string,
orgId: string | null,
connectionRevision: number,
) =>
[
"canvas-extensions",
"installed",
backendId,
orgId,
connectionRevision,
] as const,
} as const;
export const SETUP_QUERY_KEYS = {
/** What the deployment supports. The same answer for every setup entry. */
capabilities: () => ["setup-capabilities"] as const,
+35
View File
@@ -0,0 +1,35 @@
import { useQuery } from "@tanstack/react-query";
import CanvasExtensionsService from "#/api/canvas-extensions-service";
import { isNoBackend } from "#/api/backend-registry/active-store";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { CANVAS_EXTENSIONS_QUERY_KEYS } from "#/hooks/query/query-keys";
interface UseCanvasExtensionsOptions {
enabled?: boolean;
}
export function useCanvasExtensions(options: UseCanvasExtensionsOptions = {}) {
const active = useActiveBackend();
const connectionRevision = active.backend.connectionRevision ?? 0;
const backendSupported =
!isNoBackend(active.backend) && active.backend.kind === "local";
return useQuery({
queryKey: CANVAS_EXTENSIONS_QUERY_KEYS.installed(
active.backend.id,
active.orgId,
connectionRevision,
),
queryFn: () => CanvasExtensionsService.listInstalled(),
enabled: (options.enabled ?? true) && backendSupported,
retry: false,
staleTime: 0,
refetchOnWindowFocus: true,
meta: {
backendId: active.backend.id,
// An old backend returning 404 is an expected capability result. The
// management page renders it and the global runtime stays quiet.
disableToast: true,
},
});
}
+187
View File
@@ -32435,6 +32435,193 @@
"tr": "Bu eklenti paketindeki yetenekler",
"uk": "Навички в цьому пакеті плагіна"
},
"SETTINGS$CANVAS_EXTENSIONS_PAGE_DESCRIPTION": {
"en": "Install trusted UI that runs inside Canvas for this backend. Installed extensions stay off until you enable them here.",
"ja": "このバックエンドの Canvas 内で動作する信頼済み UI をインストールします。インストールした拡張機能は、ここで有効にするまでオフのままです。",
"zh-CN": "为此后端安装在 Canvas 内运行的受信任 UI。已安装的扩展在您于此处启用前将保持关闭。",
"zh-TW": "為此後端安裝在 Canvas 內執行的受信任 UI。已安裝的擴充功能在您於此處啟用前將保持關閉。",
"ko-KR": "이 백엔드의 Canvas 안에서 실행되는 신뢰할 수 있는 UI를 설치합니다. 설치된 확장 기능은 여기에서 활성화할 때까지 꺼진 상태로 유지됩니다.",
"no": "Installer klarert brukergrensesnitt som kjører i Canvas for denne bakenden. Installerte utvidelser forblir av til du aktiverer dem her.",
"ar": "ثبّت واجهة مستخدم موثوقة تعمل داخل Canvas لهذه الواجهة الخلفية. تظل الامتدادات المثبتة متوقفة حتى تفعّلها هنا.",
"de": "Installieren Sie vertrauenswürdige UI, die für dieses Backend direkt in Canvas ausgeführt wird. Installierte Erweiterungen bleiben deaktiviert, bis Sie sie hier aktivieren.",
"fr": "Installez une interface de confiance qui s’exécute dans Canvas pour ce backend. Les extensions installées restent désactivées jusqu’à ce que vous les activiez ici.",
"it": "Installa un'interfaccia attendibile che viene eseguita in Canvas per questo backend. Le estensioni installate restano disattivate finché non le abiliti qui.",
"pt": "Instale uma interface confiável executada dentro do Canvas para este backend. As extensões instaladas permanecem desativadas até você ativá-las aqui.",
"es": "Instala una interfaz de confianza que se ejecuta dentro de Canvas para este backend. Las extensiones instaladas permanecen desactivadas hasta que las habilites aquí.",
"tr": "Bu arka uç için Canvas içinde çalışan güvenilir bir arayüz kurun. Kurulan uzantılar siz burada etkinleştirene kadar kapalı kalır.",
"uk": "Установлюйте довірений інтерфейс, що працює всередині Canvas для цього бекенда. Установлені розширення залишаються вимкненими, доки ви не ввімкнете їх тут.",
"ca": "Instal·la una interfície de confiança que s'executa dins del Canvas per a aquest backend. Les extensions instal·lades romanen desactivades fins que les activis aquí."
},
"SETTINGS$CANVAS_EXTENSIONS_ADD_BUTTON": {
"en": "Add extension",
"ja": "拡張機能を追加",
"zh-CN": "添加扩展",
"zh-TW": "新增擴充功能",
"ko-KR": "확장 기능 추가",
"no": "Legg til utvidelse",
"ar": "إضافة امتداد",
"de": "Erweiterung hinzufügen",
"fr": "Ajouter une extension",
"it": "Aggiungi estensione",
"pt": "Adicionar extensão",
"es": "Agregar extensión",
"tr": "Uzantı ekle",
"uk": "Додати розширення",
"ca": "Afegeix una extensió"
},
"SETTINGS$CANVAS_EXTENSIONS_EMPTY": {
"en": "No Canvas Extensions are installed on this backend.",
"ja": "このバックエンドには Canvas 拡張機能がインストールされていません。",
"zh-CN": "此后端未安装任何 Canvas 扩展。",
"zh-TW": "此後端未安裝任何 Canvas 擴充功能。",
"ko-KR": "이 백엔드에 설치된 Canvas 확장 기능이 없습니다.",
"no": "Ingen Canvas-utvidelser er installert på denne bakenden.",
"ar": "لا توجد امتدادات Canvas مثبتة على هذه الواجهة الخلفية.",
"de": "Auf diesem Backend sind keine Canvas-Erweiterungen installiert.",
"fr": "Aucune extension Canvas n’est installée sur ce backend.",
"it": "Nessuna estensione Canvas è installata su questo backend.",
"pt": "Nenhuma extensão do Canvas está instalada neste backend.",
"es": "No hay extensiones de Canvas instaladas en este backend.",
"tr": "Bu arka uçta kurulu Canvas Uzantısı yok.",
"uk": "На цьому бекенді не встановлено жодного розширення Canvas.",
"ca": "No hi ha cap extensió del Canvas instal·lada en aquest backend."
},
"SETTINGS$CANVAS_EXTENSIONS_TRUST_NOTICE": {
"en": "Enabled extensions are trusted code with full access to this Canvas session.",
"ja": "有効な拡張機能は、この Canvas セッションに完全にアクセスできる信頼済みコードです。",
"zh-CN": "已启用的扩展是受信任代码,可完全访问此 Canvas 会话。",
"zh-TW": "已啟用的擴充功能是受信任程式碼,可完整存取此 Canvas 工作階段。",
"ko-KR": "활성화된 확장 기능은 이 Canvas 세션에 대한 전체 액세스 권한이 있는 신뢰할 수 있는 코드입니다.",
"no": "Aktiverte utvidelser er klarert kode med full tilgang til denne Canvas-økten.",
"ar": "الامتدادات المفعّلة هي تعليمات برمجية موثوقة تتمتع بوصول كامل إلى جلسة Canvas هذه.",
"de": "Aktivierte Erweiterungen sind vertrauenswürdiger Code mit vollständigem Zugriff auf diese Canvas-Sitzung.",
"fr": "Les extensions activées sont du code de confiance avec un accès complet à cette session Canvas.",
"it": "Le estensioni abilitate sono codice attendibile con accesso completo a questa sessione Canvas.",
"pt": "As extensões ativadas são código confiável com acesso total a esta sessão do Canvas.",
"es": "Las extensiones habilitadas son código de confianza con acceso completo a esta sesión de Canvas.",
"tr": "Etkin uzantılar, bu Canvas oturumuna tam erişimi olan güvenilir kodlardır.",
"uk": "Увімкнені розширення є довіреним кодом із повним доступом до цього сеансу Canvas.",
"ca": "Les extensions activades són codi de confiança amb accés complet a aquesta sessió del Canvas."
},
"SETTINGS$CANVAS_EXTENSIONS_ADD_MODAL_INTRO": {
"en": "Install from a Git source or a path on the active Agent Server. The extension will be installed disabled.",
"ja": "Git ソースまたはアクティブな Agent Server 上のパスからインストールします。拡張機能は無効な状態でインストールされます。",
"zh-CN": "从 Git 来源或活动 Agent Server 上的路径安装。扩展安装后将处于禁用状态。",
"zh-TW": "從 Git 來源或作用中 Agent Server 上的路徑安裝。擴充功能安裝後將處於停用狀態。",
"ko-KR": "Git 소스 또는 활성 Agent Server의 경로에서 설치합니다. 확장 기능은 비활성화된 상태로 설치됩니다.",
"no": "Installer fra en Git-kilde eller en sti på den aktive Agent Server. Utvidelsen installeres deaktivert.",
"ar": "ثبّت من مصدر Git أو مسار على Agent Server النشط. سيتم تثبيت الامتداد في وضع التعطيل.",
"de": "Installieren Sie aus einer Git-Quelle oder von einem Pfad auf dem aktiven Agent Server. Die Erweiterung wird deaktiviert installiert.",
"fr": "Installez depuis une source Git ou un chemin sur l’Agent Server actif. L’extension sera installée désactivée.",
"it": "Installa da una sorgente Git o da un percorso sull'Agent Server attivo. L'estensione verrà installata disattivata.",
"pt": "Instale de uma origem Git ou de um caminho no Agent Server ativo. A extensão será instalada desativada.",
"es": "Instala desde una fuente Git o una ruta en el Agent Server activo. La extensión se instalará desactivada.",
"tr": "Bir Git kaynağından veya etkin Agent Server üzerindeki bir yoldan kurun. Uzantı devre dışı olarak kurulacaktır.",
"uk": "Установіть із джерела Git або шляху на активному Agent Server. Розширення буде встановлено вимкненим.",
"ca": "Instal·la des d'una font Git o un camí de l'Agent Server actiu. L'extensió s'instal·larà desactivada."
},
"SETTINGS$CANVAS_EXTENSIONS_INSTALL_SUCCESS": {
"en": "Extension installed. Review it here, then enable it when you are ready.",
"ja": "拡張機能をインストールしました。ここで内容を確認し、準備ができたら有効にしてください。",
"zh-CN": "扩展已安装。请在此处查看,并在准备好后启用。",
"zh-TW": "擴充功能已安裝。請在此處檢視,並在準備好後啟用。",
"ko-KR": "확장 기능이 설치되었습니다. 여기에서 검토한 후 준비가 되면 활성화하세요.",
"no": "Utvidelsen er installert. Se gjennom den her, og aktiver den når du er klar.",
"ar": "تم تثبيت الامتداد. راجعه هنا، ثم فعّله عندما تكون مستعدًا.",
"de": "Erweiterung installiert. Prüfen Sie sie hier und aktivieren Sie sie, wenn Sie bereit sind.",
"fr": "Extension installée. Vérifiez-la ici, puis activez-la lorsque vous êtes prêt.",
"it": "Estensione installata. Esaminala qui, quindi abilitala quando sei pronto.",
"pt": "Extensão instalada. Revise-a aqui e ative-a quando estiver pronto.",
"es": "Extensión instalada. Revísala aquí y habilítala cuando estés listo.",
"tr": "Uzantı kuruldu. Burada inceleyin ve hazır olduğunuzda etkinleştirin.",
"uk": "Розширення встановлено. Перегляньте його тут і ввімкніть, коли будете готові.",
"ca": "Extensió instal·lada. Revisa-la aquí i activa-la quan estiguis a punt."
},
"SETTINGS$CANVAS_EXTENSIONS_UNINSTALL_SUCCESS": {
"en": "Extension uninstalled.",
"ja": "拡張機能をアンインストールしました。",
"zh-CN": "扩展已卸载。",
"zh-TW": "擴充功能已解除安裝。",
"ko-KR": "확장 기능이 제거되었습니다.",
"no": "Utvidelsen er avinstallert.",
"ar": "تمت إزالة تثبيت الامتداد.",
"de": "Erweiterung deinstalliert.",
"fr": "Extension désinstallée.",
"it": "Estensione disinstallata.",
"pt": "Extensão desinstalada.",
"es": "Extensión desinstalada.",
"tr": "Uzantı kaldırıldı.",
"uk": "Розширення видалено.",
"ca": "Extensió desinstal·lada."
},
"SETTINGS$CANVAS_EXTENSIONS_ENABLE_CONFIRM": {
"en": "Enabled extensions are trusted code with full access to this Canvas session. Enable {{name}}?",
"ja": "有効な拡張機能は、この Canvas セッションに完全にアクセスできる信頼済みコードです。{{name}} を有効にしますか?",
"zh-CN": "已启用的扩展是受信任代码,可完全访问此 Canvas 会话。要启用 {{name}} 吗?",
"zh-TW": "已啟用的擴充功能是受信任程式碼,可完整存取此 Canvas 工作階段。要啟用 {{name}} 嗎?",
"ko-KR": "활성화된 확장 기능은 이 Canvas 세션에 대한 전체 액세스 권한이 있는 신뢰할 수 있는 코드입니다. {{name}}을(를) 활성화하시겠습니까?",
"no": "Aktiverte utvidelser er klarert kode med full tilgang til denne Canvas-økten. Vil du aktivere {{name}}?",
"ar": "الامتدادات المفعّلة هي تعليمات برمجية موثوقة تتمتع بوصول كامل إلى جلسة Canvas هذه. هل تريد تفعيل {{name}}؟",
"de": "Aktivierte Erweiterungen sind vertrauenswürdiger Code mit vollständigem Zugriff auf diese Canvas-Sitzung. {{name}} aktivieren?",
"fr": "Les extensions activées sont du code de confiance avec un accès complet à cette session Canvas. Activer {{name}} ?",
"it": "Le estensioni abilitate sono codice attendibile con accesso completo a questa sessione Canvas. Abilitare {{name}}?",
"pt": "As extensões ativadas são código confiável com acesso total a esta sessão do Canvas. Ativar {{name}}?",
"es": "Las extensiones habilitadas son código de confianza con acceso completo a esta sesión de Canvas. ¿Habilitar {{name}}?",
"tr": "Etkin uzantılar, bu Canvas oturumuna tam erişimi olan güvenilir kodlardır. {{name}} etkinleştirilsin mi?",
"uk": "Увімкнені розширення є довіреним кодом із повним доступом до цього сеансу Canvas. Увімкнути {{name}}?",
"ca": "Les extensions activades són codi de confiança amb accés complet a aquesta sessió del Canvas. Vols activar {{name}}?"
},
"SETTINGS$CANVAS_EXTENSIONS_UNINSTALL_CONFIRM": {
"en": "Uninstall {{name}} from this backend?",
"ja": "このバックエンドから {{name}} をアンインストールしますか?",
"zh-CN": "要从此后端卸载 {{name}} 吗?",
"zh-TW": "要從此後端解除安裝 {{name}} 嗎?",
"ko-KR": "이 백엔드에서 {{name}}을(를) 제거하시겠습니까?",
"no": "Vil du avinstallere {{name}} fra denne backenden?",
"ar": "هل تريد إزالة تثبيت {{name}} من هذه الواجهة الخلفية؟",
"de": "{{name}} von diesem Backend deinstallieren?",
"fr": "Désinstaller {{name}} de ce backend ?",
"it": "Disinstallare {{name}} da questo backend?",
"pt": "Desinstalar {{name}} deste backend?",
"es": "¿Desinstalar {{name}} de este backend?",
"tr": "{{name}} bu arka uçtan kaldırılsın mı?",
"uk": "Видалити {{name}} із цього бекенда?",
"ca": "Vols desinstal·lar {{name}} d'aquest backend?"
},
"SETTINGS$CANVAS_EXTENSIONS_PAGES": {
"en": "Pages",
"ja": "ページ",
"zh-CN": "页面",
"zh-TW": "頁面",
"ko-KR": "페이지",
"no": "Sider",
"ar": "الصفحات",
"de": "Seiten",
"fr": "Pages",
"it": "Pagine",
"pt": "Páginas",
"es": "Páginas",
"tr": "Sayfalar",
"uk": "Сторінки",
"ca": "Pàgines"
},
"SETTINGS$CANVAS_EXTENSIONS_PAGE_UNAVAILABLE": {
"en": "This extension is disabled, missing, or does not provide this page.",
"ja": "この拡張機能は無効、未インストール、またはこのページを提供していません。",
"zh-CN": "此扩展已禁用、缺失或未提供此页面。",
"zh-TW": "此擴充功能已停用、遺失或未提供此頁面。",
"ko-KR": "이 확장 기능은 비활성화되었거나 없거나 이 페이지를 제공하지 않습니다.",
"no": "Denne utvidelsen er deaktivert, mangler eller tilbyr ikke denne siden.",
"ar": "هذا الامتداد معطّل أو مفقود أو لا يوفّر هذه الصفحة.",
"de": "Diese Erweiterung ist deaktiviert, fehlt oder stellt diese Seite nicht bereit.",
"fr": "Cette extension est désactivée, absente ou ne fournit pas cette page.",
"it": "Questa estensione è disabilitata, mancante o non fornisce questa pagina.",
"pt": "Esta extensão está desativada, ausente ou não fornece esta página.",
"es": "Esta extensión está deshabilitada, falta o no proporciona esta página.",
"tr": "Bu uzantı devre dışı, eksik veya bu sayfayı sağlamıyor.",
"uk": "Це розширення вимкнене, відсутнє або не надає цю сторінку.",
"ca": "Aquesta extensió està desactivada, no hi és o no proporciona aquesta pàgina."
},
"SETTINGS$SKILLS_PAGE_DESCRIPTION": {
"en": "Discover skills to add to your workspace. Open a card for prompts, curl, and install flows. Search from the sidebar to filter the list. Enable or disable default skills. Disabled skills will not be loaded into agent context.",
"ja": "ワークスペースに追加するスキルを見つけましょう。カードを開いて、プロンプト、curl、インストール手順を確認できます。サイドバーから検索してリストを絞り込めます。デフォルトスキルの有効化・無効化が可能です。無効化されたスキルはエージェントコンテキストに読み込まれません。",
+15
View File
@@ -39,6 +39,21 @@ export {
type AgentServerUIStyleOverrides,
type AgentServerUITheme,
} from "../styles/agent-server-ui-style-scope";
export {
CANVAS_EXTENSION_HOST_API_VERSION,
CANVAS_EXTENSION_MANIFEST_SCHEMA_VERSION,
type CanvasExtensionAgentServerRequest,
type CanvasExtensionContributions,
type CanvasExtensionDispose,
type CanvasExtensionHost,
type CanvasExtensionManifest,
type CanvasExtensionModule,
type CanvasExtensionPageContribution,
type CanvasExtensionPageMount,
type CanvasExtensionPageMountContext,
type InstallCanvasExtensionRequest,
type InstalledCanvasExtensionInfo,
} from "../types/canvas-extension";
// Telemetry exports
export { useTelemetry, type UseTelemetryReturn } from "../hooks/use-telemetry";
@@ -0,0 +1,83 @@
/* eslint-disable local/no-direct-agent-server-fetch --
These fetches deliberately bypass the typed client to exercise the MSW
network-interception layer that dev:mock relies on. */
import { afterEach, describe, expect, it } from "vitest";
import {
CANVAS_EXTENSION_DEMO_SOURCE,
resetCanvasExtensionsMockData,
} from "#/mocks/canvas-extensions-handlers";
afterEach(() => {
resetCanvasExtensionsMockData();
});
async function installDemo(): Promise<Response> {
return fetch("http://127.0.0.1:8000/api/canvas-extensions/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: CANVAS_EXTENSION_DEMO_SOURCE }),
});
}
describe("Canvas Extension MSW handlers", () => {
it("installs the fixture disabled and then enables it explicitly", async () => {
const installResponse = await installDemo();
expect(installResponse.status).toBe(200);
await expect(installResponse.json()).resolves.toMatchObject({
name: "demo-page",
enabled: false,
source: CANVAS_EXTENSION_DEMO_SOURCE,
});
const enableResponse = await fetch(
"http://127.0.0.1:8000/api/canvas-extensions/installed/demo-page",
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: true }),
},
);
await expect(enableResponse.json()).resolves.toEqual({
name: "demo-page",
enabled: true,
});
const listResponse = await fetch(
"http://127.0.0.1:8000/api/canvas-extensions/installed",
);
await expect(listResponse.json()).resolves.toMatchObject({
canvas_extensions: [{ name: "demo-page", enabled: true }],
});
});
it("serves the fixture as JavaScript text", async () => {
await installDemo();
const response = await fetch(
"http://127.0.0.1:8000/api/canvas-extensions/installed/demo-page/bundle",
);
expect(response.headers.get("content-type")).toContain(
"application/javascript",
);
await expect(response.text()).resolves.toContain(
"export function activate",
);
});
it("rejects arbitrary sources in mock mode", async () => {
const response = await fetch(
"http://127.0.0.1:8000/api/canvas-extensions/install",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: "github:example/unknown" }),
},
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
detail: `Mock mode only installs '${CANVAS_EXTENSION_DEMO_SOURCE}'.`,
});
});
});
+169
View File
@@ -0,0 +1,169 @@
import { http, HttpResponse } from "msw";
import type {
CanvasExtensionManifest,
InstallCanvasExtensionRequest,
InstalledCanvasExtensionInfo,
} from "#/types/canvas-extension";
import demoManifestSource from "#/fixtures/canvas-extensions/demo-page/canvas-extension.json?raw";
// eslint-disable-next-line import-x/extensions -- Vite requires the real filename before ?raw.
import demoBundle from "#/fixtures/canvas-extensions/demo-page/extension.js?raw";
export const CANVAS_EXTENSION_DEMO_SOURCE =
"src/fixtures/canvas-extensions/demo-page";
const demoManifest = JSON.parse(demoManifestSource) as CanvasExtensionManifest;
const MOCK_STORAGE_KEY = "openhands-canvas-extension-dev-installation";
function readPersistedExtension(): InstalledCanvasExtensionInfo | null {
try {
const value = globalThis.sessionStorage?.getItem(MOCK_STORAGE_KEY);
return value ? (JSON.parse(value) as InstalledCanvasExtensionInfo) : null;
} catch {
return null;
}
}
let installedExtension = readPersistedExtension();
function persistInstalledExtension(
extension: InstalledCanvasExtensionInfo | null,
): void {
installedExtension = extension;
try {
if (extension) {
globalThis.sessionStorage?.setItem(
MOCK_STORAGE_KEY,
JSON.stringify(extension),
);
} else {
globalThis.sessionStorage?.removeItem(MOCK_STORAGE_KEY);
}
} catch {
// The module variable still provides an in-memory fallback.
}
}
function isDemoSource(source: string): boolean {
return source.replaceAll("\\", "/").endsWith(CANVAS_EXTENSION_DEMO_SOURCE);
}
function getRequestedName(
value: string | readonly string[] | undefined,
): string {
if (!value) return "";
return decodeURIComponent(Array.isArray(value) ? value[0] : value);
}
function extensionNotFound(name: string) {
return HttpResponse.json(
{ detail: `Canvas extension '${name}' is not installed.` },
{ status: 404 },
);
}
function getInstalledExtension(
name: string,
): InstalledCanvasExtensionInfo | null {
if (installedExtension?.name !== name) return null;
return installedExtension;
}
export function resetCanvasExtensionsMockData(): void {
persistInstalledExtension(null);
}
/**
* Browser-development implementation of the Canvas Extension API contract.
* It intentionally supports only the checked-in demo fixture. Production and
* ordinary dev stacks continue to use the active Agent Server exclusively.
*/
export const CANVAS_EXTENSIONS_HANDLERS = [
http.get("*/api/canvas-extensions/installed", () =>
HttpResponse.json({
canvas_extensions: installedExtension ? [installedExtension] : [],
}),
),
http.post("*/api/canvas-extensions/install", async ({ request }) => {
const body = (await request.json()) as InstallCanvasExtensionRequest;
if (!body.source || !isDemoSource(body.source)) {
return HttpResponse.json(
{
detail: `Mock mode only installs '${CANVAS_EXTENSION_DEMO_SOURCE}'.`,
},
{ status: 400 },
);
}
if (installedExtension && !body.force) {
return HttpResponse.json(
{
detail: `Canvas extension '${demoManifest.name}' is already installed.`,
},
{ status: 409 },
);
}
persistInstalledExtension({
name: demoManifest.name,
version: demoManifest.version,
description: demoManifest.description,
enabled: false,
source: body.source,
resolved_ref: body.ref || "mock-working-tree",
repo_path: body.repo_path || null,
installed_at: new Date().toISOString(),
install_path: `/mock/canvas-extensions/${demoManifest.name}`,
// The real backend only returns `manifest` once OSS-10048 lands; the
// mock previews that shape so the demo fixture's pages work in dev mode.
manifest: demoManifest,
});
return HttpResponse.json(installedExtension);
}),
http.get("*/api/canvas-extensions/installed/:name/bundle", ({ params }) => {
const name = getRequestedName(params.name);
if (!getInstalledExtension(name)) return extensionNotFound(name);
return new HttpResponse(demoBundle, {
headers: {
"Content-Type": "application/javascript; charset=utf-8",
"Cache-Control": "no-cache",
},
});
}),
http.get("*/api/canvas-extensions/installed/:name", ({ params }) => {
const name = getRequestedName(params.name);
const extension = getInstalledExtension(name);
return extension ? HttpResponse.json(extension) : extensionNotFound(name);
}),
http.patch(
"*/api/canvas-extensions/installed/:name",
async ({ params, request }) => {
const name = getRequestedName(params.name);
const extension = getInstalledExtension(name);
if (!extension) return extensionNotFound(name);
const body = (await request.json()) as { enabled?: unknown };
if (typeof body.enabled !== "boolean") {
return HttpResponse.json(
{ detail: "'enabled' must be a boolean." },
{ status: 422 },
);
}
persistInstalledExtension({ ...extension, enabled: body.enabled });
return HttpResponse.json({ name, enabled: body.enabled });
},
),
http.delete("*/api/canvas-extensions/installed/:name", ({ params }) => {
const name = getRequestedName(params.name);
if (!getInstalledExtension(name)) return extensionNotFound(name);
persistInstalledExtension(null);
return HttpResponse.json({ message: "Canvas extension uninstalled." });
}),
];
+6
View File
@@ -24,6 +24,10 @@ import {
WORKSPACES_HANDLERS,
resetMockWorkspaces,
} from "./workspaces-handlers";
import {
CANVAS_EXTENSIONS_HANDLERS,
resetCanvasExtensionsMockData,
} from "./canvas-extensions-handlers";
export const handlers = [
...FILE_SERVICE_HANDLERS,
@@ -38,6 +42,7 @@ export const handlers = [
...AUTOMATION_HANDLERS,
...MCP_HANDLERS,
...WORKSPACES_HANDLERS,
...CANVAS_EXTENSIONS_HANDLERS,
];
export {
@@ -45,6 +50,7 @@ export {
resetTestHandlersMockSettings,
resetAutomationMockData,
resetMockWorkspaces,
resetCanvasExtensionsMockData,
};
export {
+2
View File
@@ -18,6 +18,8 @@ export default [
route("customize", "routes/extensions-hub.tsx"),
route("skills", "routes/skills-settings.tsx"),
route("plugins", "routes/skills-plugins.tsx"),
route("extensions", "routes/canvas-extensions.tsx"),
route("extensions/:extensionName/*", "routes/canvas-extension-page.tsx"),
route("mcp", "routes/mcp.tsx"),
route("settings", "routes/settings.tsx", [
index("routes/settings-index.tsx"),
+109
View File
@@ -0,0 +1,109 @@
import React from "react";
import { useNavigate, useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { useCanvasExtensionsRuntime } from "#/components/features/canvas-extensions/canvas-extensions-runtime";
import { I18nKey } from "#/i18n/declaration";
export default function CanvasExtensionPage() {
const { t } = useTranslation("openhands");
const params = useParams();
const navigate = useNavigate();
const extensionName = params.extensionName ?? "";
const routePath = params["*"] ?? "";
const { pages, activating, errors } = useCanvasExtensionsRuntime();
const containerRef = React.useRef<HTMLDivElement>(null);
const [mountError, setMountError] = React.useState<string | null>(null);
const page = React.useMemo(
() =>
pages
.filter((candidate) => candidate.extension.name === extensionName)
.sort(
(left, right) =>
right.contribution.path.length - left.contribution.path.length,
)
.find(
(candidate) =>
routePath === candidate.contribution.path ||
routePath.startsWith(`${candidate.contribution.path}/`),
),
[extensionName, pages, routePath],
);
React.useEffect(() => {
const container = containerRef.current;
if (!container || !page) return undefined;
let disposed = false;
let disposeMount: (() => void) | undefined;
setMountError(null);
container.replaceChildren();
const remainder = routePath
.slice(page.contribution.path.length)
.replace(/^\/+/, "");
Promise.resolve()
.then(() =>
page.mount({
container,
path: remainder,
navigate: (path) => navigate(path),
}),
)
.then((dispose) => {
if (typeof dispose !== "function") return;
if (disposed) dispose();
else disposeMount = dispose;
})
.catch((error: unknown) => {
if (!disposed) {
setMountError(
error instanceof Error ? error.message : "Extension page failed.",
);
}
});
return () => {
disposed = true;
try {
disposeMount?.();
} catch (error) {
console.error("Canvas Extension page cleanup failed", error);
}
container.replaceChildren();
};
}, [navigate, page, routePath]);
if (activating && !page) {
return (
<div className="flex h-full items-center justify-center">
<LoadingSpinner size="large" />
</div>
);
}
const error = mountError ?? errors.get(extensionName);
if (error || !page) {
return (
<main className="flex h-full items-center justify-center p-8">
<div className="max-w-lg rounded-xl border border-[var(--oh-border)] bg-base-secondary p-6 text-center">
<h1 className="text-lg font-semibold text-white">
{t(I18nKey.SETUP$UNAVAILABLE_TITLE)}
</h1>
<p className="mt-2 text-sm text-tertiary-light">
{error || t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_PAGE_UNAVAILABLE)}
</p>
</div>
</main>
);
}
return (
<main
aria-label={page.contribution.title}
className="h-full min-h-0 overflow-auto"
>
<div ref={containerRef} className="min-h-full" />
</main>
);
}
+202
View File
@@ -0,0 +1,202 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ExtensionsNavigation } from "#/components/features/skills/extensions-navigation";
import { AddCanvasExtensionModal } from "#/components/features/canvas-extensions/add-canvas-extension-modal";
import { CanvasExtensionCard } from "#/components/features/canvas-extensions/canvas-extension-card";
import { BrandButton } from "#/components/features/settings/brand-button";
import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal";
import {
CanvasExtensionsUnsupportedError,
isCanvasExtensionsUnsupportedError,
} from "#/api/canvas-extensions-service";
import { isNoBackend } from "#/api/backend-registry/active-store";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { useCanvasExtensions } from "#/hooks/query/use-canvas-extensions";
import {
useSetCanvasExtensionEnabled,
useUninstallCanvasExtension,
} from "#/hooks/mutation/use-manage-canvas-extensions";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
import { settingsLikeMainScrollClassName } from "#/utils/settings-like-page-layout-classes";
import {
extensionModuleCardGridClassName,
extensionModuleCardGridContainerClassName,
extensionModuleEmptyStateClassName,
} from "#/utils/extension-module-card-classes";
import type { InstalledCanvasExtensionInfo } from "#/types/canvas-extension";
type PendingAction =
| { type: "enable"; extension: InstalledCanvasExtensionInfo }
| { type: "uninstall"; extension: InstalledCanvasExtensionInfo };
export default function CanvasExtensionsScreen() {
const { t } = useTranslation("openhands");
const { backend } = useActiveBackend();
const [showAddModal, setShowAddModal] = React.useState(false);
const [pendingAction, setPendingAction] =
React.useState<PendingAction | null>(null);
const query = useCanvasExtensions();
const setEnabled = useSetCanvasExtensionEnabled();
const uninstall = useUninstallCanvasExtension();
const backendCanSupportExtensions =
!isNoBackend(backend) && backend.kind === "local";
const unsupported =
!backendCanSupportExtensions ||
isCanvasExtensionsUnsupportedError(query.error);
const isBusy = setEnabled.isPending || uninstall.isPending;
const confirmAction = () => {
if (!pendingAction) return;
if (pendingAction.type === "enable") {
setEnabled.mutate(
{ name: pendingAction.extension.name, enabled: true },
{ onSuccess: () => setPendingAction(null) },
);
} else {
uninstall.mutate(pendingAction.extension.name, {
onSuccess: () => setPendingAction(null),
});
}
};
const pendingDisplayName = pendingAction
? pendingAction.extension.manifest?.display_name ||
pendingAction.extension.name
: "";
const confirmationText =
pendingAction?.type === "enable"
? t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_ENABLE_CONFIRM, {
name: pendingDisplayName,
})
: pendingAction?.type === "uninstall"
? t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_UNINSTALL_CONFIRM, {
name: pendingDisplayName,
})
: "";
return (
<div
data-testid="canvas-extensions-screen"
className="flex h-full gap-4 md:gap-6 md:pl-8 lg:gap-10 lg:pl-10"
>
<ExtensionsNavigation />
<main className={cn(settingsLikeMainScrollClassName, "h-full")}>
<div className="mx-auto flex w-full min-w-0 max-w-[800px] flex-col gap-6">
<div className="flex min-w-0 items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<h2 className="text-xl font-semibold leading-6 text-foreground">
{t(I18nKey.NAV$EXTENSIONS)}
</h2>
<p className="max-w-2xl text-sm text-tertiary-light">
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_PAGE_DESCRIPTION)}
</p>
</div>
<BrandButton
type="button"
variant="secondary"
testId="canvas-extensions-add-button"
isDisabled={
!backendCanSupportExtensions || unsupported || query.isLoading
}
className="flex-shrink-0 whitespace-nowrap"
onClick={() => setShowAddModal(true)}
>
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_ADD_BUTTON)}
</BrandButton>
</div>
<div className="rounded-xl border border-amber-400/30 bg-amber-400/5 px-4 py-3 text-sm text-amber-100">
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_TRUST_NOTICE)}
</div>
{unsupported ? (
<div className={extensionModuleEmptyStateClassName}>
<h3 className="text-sm font-semibold text-white">
{t(I18nKey.SETUP$UNAVAILABLE_TITLE)}
</h3>
<p className="mt-2 text-sm text-tertiary-light">
{isCanvasExtensionsUnsupportedError(query.error)
? query.error.message
: backend.kind === "cloud"
? new CanvasExtensionsUnsupportedError("cloud-backend")
.message
: new CanvasExtensionsUnsupportedError("no-backend")
.message}
</p>
</div>
) : query.isLoading ? (
<div className="flex flex-col gap-4">
{[1, 2].map((index) => (
<div
key={index}
className="h-40 animate-pulse rounded-xl bg-base-secondary"
/>
))}
</div>
) : query.isError ? (
<div className={extensionModuleEmptyStateClassName}>
<p className="text-sm text-tertiary-light">
{query.error instanceof Error
? query.error.message
: t(I18nKey.ERROR$GENERIC)}
</p>
<BrandButton
type="button"
variant="secondary"
className="mt-4"
onClick={() => void query.refetch()}
>
{t(I18nKey.AUTOMATIONS$ERROR_RETRY)}
</BrandButton>
</div>
) : query.data?.length ? (
<div className={extensionModuleCardGridContainerClassName}>
<div className={extensionModuleCardGridClassName}>
{query.data.map((extension) => (
<CanvasExtensionCard
key={extension.name}
extension={extension}
isBusy={isBusy}
onToggle={() => {
if (extension.enabled) {
setEnabled.mutate({
name: extension.name,
enabled: false,
});
} else {
setPendingAction({ type: "enable", extension });
}
}}
onUninstall={() =>
setPendingAction({ type: "uninstall", extension })
}
/>
))}
</div>
</div>
) : (
<div className={extensionModuleEmptyStateClassName}>
<p className="text-sm text-tertiary-light">
{t(I18nKey.SETTINGS$CANVAS_EXTENSIONS_EMPTY)}
</p>
</div>
)}
</div>
</main>
{showAddModal ? (
<AddCanvasExtensionModal onClose={() => setShowAddModal(false)} />
) : null}
{pendingAction ? (
<ConfirmationModal
text={confirmationText}
onCancel={() => setPendingAction(null)}
onConfirm={confirmAction}
isConfirming={isBusy}
/>
) : null}
</div>
);
}
+38 -35
View File
@@ -24,6 +24,7 @@ import { useAppTitle } from "#/hooks/use-app-title";
import { ReactRouterNavigationProvider } from "./react-router-navigation-provider";
import { OnboardingHost } from "#/components/features/onboarding";
import { isOnboardingPreviewActive } from "#/components/features/onboarding/onboarding-preview";
import { CanvasExtensionsRuntimeProvider } from "#/components/features/canvas-extensions/canvas-extensions-runtime";
const EnvironmentSwitchOverlay = React.lazy(
() => import("#/components/features/backends/environment-switch-overlay"),
@@ -110,44 +111,46 @@ export default function MainApp() {
return (
<ReactRouterNavigationProvider>
<SidebarMobileNavProvider>
<div
data-testid="root-layout"
className="h-screen lg:min-w-5xl flex flex-col md:flex-row bg-base overflow-hidden p-0"
>
<title>{appTitle}</title>
<Sidebar />
<CanvasExtensionsRuntimeProvider>
<SidebarMobileNavProvider>
<div
data-testid="root-layout"
className="h-screen lg:min-w-5xl flex flex-col md:flex-row bg-base overflow-hidden p-0"
>
<title>{appTitle}</title>
<Sidebar />
<div className="flex min-h-0 flex-col w-full min-w-0 h-full gap-3">
{!hideMobileSidebarMenuBar ? <SidebarMobileMenuBar /> : null}
{config.data &&
(config.data.maintenance_start_time ||
(config.data.faulty_models &&
config.data.faulty_models.length > 0) ||
config.data.error_message) && (
<React.Suspense fallback={null}>
<AlertBanner
maintenanceStartTime={config.data.maintenance_start_time}
faultyModels={config.data.faulty_models}
errorMessage={config.data.error_message}
updatedAt={config.data.updated_at}
/>
</React.Suspense>
)}
<div
id="root-outlet"
className="relative flex-1 overflow-auto px-0 custom-scrollbar"
>
<Outlet />
<div className="flex min-h-0 flex-col w-full min-w-0 h-full gap-3">
{!hideMobileSidebarMenuBar ? <SidebarMobileMenuBar /> : null}
{config.data &&
(config.data.maintenance_start_time ||
(config.data.faulty_models &&
config.data.faulty_models.length > 0) ||
config.data.error_message) && (
<React.Suspense fallback={null}>
<AlertBanner
maintenanceStartTime={config.data.maintenance_start_time}
faultyModels={config.data.faulty_models}
errorMessage={config.data.error_message}
updatedAt={config.data.updated_at}
/>
</React.Suspense>
)}
<div
id="root-outlet"
className="relative flex-1 overflow-auto px-0 custom-scrollbar"
>
<Outlet />
</div>
</div>
</div>
</div>
<React.Suspense fallback={null}>
<EnvironmentSwitchOverlay />
<CommandMenu />
</React.Suspense>
{showOnboardingPreview ? <OnboardingHost /> : null}
</SidebarMobileNavProvider>
<React.Suspense fallback={null}>
<EnvironmentSwitchOverlay />
<CommandMenu />
</React.Suspense>
{showOnboardingPreview ? <OnboardingHost /> : null}
</SidebarMobileNavProvider>
</CanvasExtensionsRuntimeProvider>
</ReactRouterNavigationProvider>
);
}
+97
View File
@@ -0,0 +1,97 @@
export const CANVAS_EXTENSION_MANIFEST_SCHEMA_VERSION = 1 as const;
export const CANVAS_EXTENSION_HOST_API_VERSION = "1" as const;
export interface CanvasExtensionPageContribution {
id: string;
title: string;
path: string;
nav_label?: string | null;
description?: string | null;
}
export interface CanvasExtensionContributions {
pages?: CanvasExtensionPageContribution[] | null;
}
/** Parsed contents of `canvas-extension.json`. */
export interface CanvasExtensionManifest {
schema_version: typeof CANVAS_EXTENSION_MANIFEST_SCHEMA_VERSION;
name: string;
display_name?: string | null;
version: string;
description?: string | null;
entrypoint: string;
contributes?: CanvasExtensionContributions | null;
}
export interface InstalledCanvasExtensionInfo {
name: string;
version: string;
description?: string | null;
enabled: boolean;
source: string;
requested_ref?: string | null;
resolved_ref?: string | null;
repo_path?: string | null;
installed_at: string;
install_path: string;
/** Absent until the backend exposes page contributions (OSS-10048). */
manifest?: CanvasExtensionManifest | null;
}
export interface InstallCanvasExtensionRequest {
source: string;
ref?: string | null;
repo_path?: string | null;
force?: boolean;
}
export type CanvasExtensionDispose = () => void;
export interface CanvasExtensionPageMountContext {
container: HTMLElement;
/** Remainder of the route below the page contribution's declared path. */
path: string;
navigate: (path: string) => void;
}
export type CanvasExtensionPageMount = (
context: CanvasExtensionPageMountContext,
) => void | CanvasExtensionDispose | Promise<void | CanvasExtensionDispose>;
export interface CanvasExtensionAgentServerRequest {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
body?: unknown;
headers?: Record<string, string>;
}
export interface CanvasExtensionHost {
readonly apiVersion: typeof CANVAS_EXTENSION_HOST_API_VERSION;
readonly extension: Readonly<{
name: string;
version: string;
resolvedRef: string | null;
}>;
readonly backend: Readonly<{
id: string;
kind: "local" | "cloud";
orgId: string | null;
}>;
registerPage: (
contributionId: string,
mount: CanvasExtensionPageMount,
) => CanvasExtensionDispose;
navigate: (path: string) => void;
agentServer: {
request: <T = unknown>(
request: CanvasExtensionAgentServerRequest,
) => Promise<T>;
};
}
export interface CanvasExtensionModule {
activate: (
host: CanvasExtensionHost,
) => void | CanvasExtensionDispose | Promise<void | CanvasExtensionDispose>;
}
+5
View File
@@ -3,6 +3,9 @@ import { I18nKey } from "#/i18n/declaration";
const SETTINGS_PREFIX = "/settings";
const CUSTOMIZE_HUB = "/customize";
const EXTENSIONS_DETAIL_PATHS = ["/skills", "/mcp", "/plugins"] as const;
// Exact match only: /extensions/:name/* routes are extension pages, which are
// rail-level destinations rather than Customize details.
const CANVAS_EXTENSIONS_MANAGEMENT_PATH = "/extensions";
export type MobileTopBarMode = "menu" | "back";
@@ -33,6 +36,7 @@ export function getMobileTopBarState(pathname: string): MobileTopBarState {
}
if (
pathname === CANVAS_EXTENSIONS_MANAGEMENT_PATH ||
EXTENSIONS_DETAIL_PATHS.some(
(path) => pathname === path || pathname.startsWith(`${path}/`),
)
@@ -50,6 +54,7 @@ export function getMobileTopBarState(pathname: string): MobileTopBarState {
export function isExtensionsSectionPath(pathname: string): boolean {
return (
pathname === CUSTOMIZE_HUB ||
pathname === CANVAS_EXTENSIONS_MANAGEMENT_PATH ||
EXTENSIONS_DETAIL_PATHS.some(
(path) => pathname === path || pathname.startsWith(`${path}/`),
)
@@ -0,0 +1,234 @@
/**
* Mock-LLM E2E test: Canvas Extensions lifecycle through the production build.
*
* Drives the real agent-canvas stack (pre-built static frontend, ingress,
* backend registry, session auth) through the full extension lifecycle:
*
* 1. Install from a source path → lands disabled, no sidebar item
* 2. Enable behind the trust confirmation → the sidebar item appears and the
* contributed page renders at /extensions/{name}/{path}, including a
* nested remainder path
* 3. Disable → the sidebar item and the page are torn down
* 4. Uninstall → the inventory is empty
*
* This is the only test that imports an extension bundle in a real browser
* (Blob-URL ESM import in `canvas-extension-module-loader.ts`); the unit
* tests inject a module loader stub instead.
*
* The pinned agent-server predates the `/api/canvas-extensions` endpoints
* (software-agent-sdk#4395), so `serveCanvasExtensionApi` answers them at the
* browser level from the checked-in demo fixture. Once the pin includes the
* endpoints, delete that helper and install the fixture by absolute path —
* the UI steps below stay unchanged.
*/
import { readFileSync } from "fs";
import { join, resolve } from "path";
import { test, expect, type Page } from "@playwright/test";
import {
seedLocalStorage,
routeSessionApiKey,
dismissAnalyticsModal,
waitForPath,
waitForTestId,
} from "../utils/mock-llm-helpers";
const DEMO_SOURCE = "src/fixtures/canvas-extensions/demo-page";
const FIXTURE_DIR = resolve(DEMO_SOURCE);
const manifest = JSON.parse(
readFileSync(join(FIXTURE_DIR, "canvas-extension.json"), "utf8"),
) as { name: string; version: string; description: string };
const bundle = readFileSync(join(FIXTURE_DIR, "extension.js"), "utf8");
const CARD = `canvas-extension-card-${manifest.name}`;
const SIDEBAR_ITEM = `sidebar-canvas-extension-${manifest.name}-hello`;
const PAGE_PATH = `/extensions/${manifest.name}/hello`;
const EMPTY_INVENTORY_TEXT =
"No Canvas Extensions are installed on this backend.";
/** In-memory installation state shared by the serial steps below. */
let installed: Record<string, unknown> | null = null;
/**
* Serve the Canvas Extension API contract from the demo fixture. Registered
* after `routeSessionApiKey` so it takes precedence for these routes only;
* every other request still reaches the real stack.
*/
async function serveCanvasExtensionApi(page: Page) {
await page.route("**/api/canvas-extensions/**", async (route) => {
const request = route.request();
const method = request.method();
const path = new URL(request.url()).pathname.replace(
/^.*\/api\/canvas-extensions/,
"",
);
const json = (status: number, body: unknown) =>
route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
if (method === "GET" && path === "/installed") {
return json(200, { canvas_extensions: installed ? [installed] : [] });
}
if (method === "POST" && path === "/install") {
const body = request.postDataJSON() as {
source: string;
ref?: string | null;
repo_path?: string | null;
};
installed = {
name: manifest.name,
version: manifest.version,
description: manifest.description,
enabled: false,
source: body.source,
requested_ref: body.ref ?? null,
resolved_ref: body.ref || "e2e-working-tree",
repo_path: body.repo_path ?? null,
installed_at: new Date().toISOString(),
install_path: `/e2e/canvas-extensions/${manifest.name}`,
manifest,
};
return json(200, installed);
}
if (!installed) {
return json(404, {
detail: `Canvas extension '${manifest.name}' is not installed.`,
});
}
if (method === "GET" && path === `/installed/${manifest.name}/bundle`) {
return route.fulfill({
status: 200,
contentType: "application/javascript; charset=utf-8",
body: bundle,
});
}
if (path === `/installed/${manifest.name}`) {
if (method === "GET") return json(200, installed);
if (method === "PATCH") {
const { enabled } = request.postDataJSON() as { enabled: boolean };
installed = { ...installed, enabled };
return json(200, { name: manifest.name, enabled });
}
if (method === "DELETE") {
installed = null;
return json(200, { message: "Canvas extension uninstalled." });
}
}
return json(404, { detail: `Unhandled ${method} ${path}` });
});
}
async function openExtensionsPage(page: Page) {
await page.goto("/extensions", { waitUntil: "domcontentloaded" });
await dismissAnalyticsModal(page);
await waitForTestId(page, "canvas-extensions-screen");
}
function cardToggle(page: Page) {
return page.getByTestId(CARD).getByRole("switch");
}
function extensionPage(page: Page) {
return page.getByRole("main", { name: "Hello from an extension" });
}
test.describe.configure({ mode: "serial" });
test.describe("Canvas Extensions lifecycle", () => {
test.beforeEach(async ({ page }) => {
await seedLocalStorage(page);
await routeSessionApiKey(page);
await serveCanvasExtensionApi(page);
});
test.afterAll(() => {
installed = null;
});
test("step 1: install from a source path lands disabled with no sidebar item", async ({
page,
}) => {
await openExtensionsPage(page);
await expect(page.getByText(EMPTY_INVENTORY_TEXT)).toBeVisible();
await page.getByTestId("canvas-extensions-add-button").click();
const modal = page.getByTestId("add-canvas-extension-modal");
await expect(modal).toBeVisible();
await page
.getByTestId("add-canvas-extension-source-input")
.fill(DEMO_SOURCE);
await page.getByTestId("add-canvas-extension-submit").click();
await expect(modal).toBeHidden();
const card = page.getByTestId(CARD);
await expect(card).toBeVisible();
await expect(card).toContainText("Demo page");
await expect(cardToggle(page)).toHaveAttribute("aria-checked", "false");
await expect(page.getByTestId(SIDEBAR_ITEM)).toHaveCount(0);
});
test("step 2: enabling behind the trust confirmation registers the sidebar item and renders the page", async ({
page,
}) => {
await openExtensionsPage(page);
await cardToggle(page).click();
const confirmation = page.getByTestId("confirmation-modal");
await expect(confirmation).toBeVisible();
await expect(confirmation).toContainText("Enable Demo page?");
await confirmation.getByTestId("confirm-button").click();
await expect(cardToggle(page)).toHaveAttribute("aria-checked", "true");
const sidebarItem = page.getByTestId(SIDEBAR_ITEM);
await expect(sidebarItem).toBeVisible();
await sidebarItem.click();
await waitForPath(page, new RegExp(`${PAGE_PATH}$`));
await expect(extensionPage(page)).toContainText(
"Hello from a Canvas Extension",
);
await expect(extensionPage(page)).toContainText(
"Host API 1 on backend default-local",
);
await page.goto(`${PAGE_PATH}/nested`, { waitUntil: "domcontentloaded" });
await expect(extensionPage(page)).toContainText(
"Nested extension path: nested",
);
});
test("step 3: disabling tears down the sidebar item and the page", async ({
page,
}) => {
await openExtensionsPage(page);
await expect(page.getByTestId(SIDEBAR_ITEM)).toBeVisible();
// Disabling needs no trust confirmation.
await cardToggle(page).click();
await expect(cardToggle(page)).toHaveAttribute("aria-checked", "false");
await expect(page.getByTestId(SIDEBAR_ITEM)).toHaveCount(0);
await page.goto(PAGE_PATH, { waitUntil: "domcontentloaded" });
await expect(
page.getByText(
"This extension is disabled, missing, or does not provide this page.",
),
).toBeVisible();
});
test("step 4: uninstalling empties the inventory", async ({ page }) => {
await openExtensionsPage(page);
await page
.getByTestId(`canvas-extension-uninstall-${manifest.name}`)
.click();
const confirmation = page.getByTestId("confirmation-modal");
await expect(confirmation).toBeVisible();
await confirmation.getByTestId("confirm-button").click();
await expect(page.getByTestId(CARD)).toHaveCount(0);
await expect(page.getByText(EMPTY_INVENTORY_TEXT)).toBeVisible();
});
});
+14
View File
@@ -100,6 +100,20 @@
"src/mocks/mcp-handlers.ts"
],
"tests": ["mcp"]
},
{
"comment": "Canvas Extensions install, enable, page render, disable, uninstall",
"sources": [
"src/components/features/canvas-extensions/**",
"src/routes/canvas-extension*.tsx",
"src/api/canvas-extensions-service.ts",
"src/extensions/**",
"src/hooks/mutation/use-manage-canvas-extensions.ts",
"src/types/canvas-extension.ts",
"src/fixtures/canvas-extensions/**",
"src/mocks/canvas-extensions-handlers.ts"
],
"tests": ["canvas-extensions"]
}
],