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"; const REMOTE_EXTENSION_SOURCE_PATTERN = /^(?:github:|https?:\/\/|git:\/\/|file:\/\/|[\w.-]+@[\w.-]+:)/i; 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 Apps." : reason === "cloud-backend" ? "Apps are not available on Cloud backends yet." : "This Agent Server does not support Apps 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(operation: () => Promise): Promise { 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)}`; } function joinLocalExtensionPath(source: string, repoPath: string): string { const separator = source.includes("\\") && !source.includes("/") ? "\\" : "/"; const basePath = source.replace(/[\\/]+$/, ""); const childPath = repoPath.replace(/^[\\/]+/, ""); if (!childPath) return source; if (!basePath) return `${separator}${childPath}`; return `${basePath}${separator}${childPath}`; } function normalizeInstallRequest( request: InstallCanvasExtensionRequest, ): InstallCanvasExtensionRequest { const repoPath = request.repo_path?.trim(); if (!repoPath || REMOTE_EXTENSION_SOURCE_PATTERN.test(request.source)) { return request; } return { ...request, source: joinLocalExtensionPath(request.source, repoPath), repo_path: null, }; } class CanvasExtensionsService { static async listInstalled(): Promise { 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 { const client = getClient(); return mapUnsupported(() => client.post( `${CANVAS_EXTENSIONS_BASE_PATH}/install`, normalizeInstallRequest(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 { const client = backend ? getClientForBackend(backend) : getClient(); return mapUnsupported(() => client.get(`${installedExtensionPath(name)}/bundle`, { responseType: "text", }), ); } static async requestAgentServer( request: CanvasExtensionAgentServerRequest, backend?: Backend, ): Promise { 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({ method: request.method ?? "GET", path: request.path, body: request.body, headers: request.headers, }); } } export default CanvasExtensionsService;