feat(plugin): support enterprise extension (#861)
This commit is contained in:
@@ -31,6 +31,18 @@ export async function handleSkillRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/capability' && req.method === 'GET') {
|
||||
try {
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
capability: await ctx.clawHubService.getMarketplaceCapability(),
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/search' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<Record<string, unknown>>(req);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import { getPort } from '../utils/config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { extensionRegistry } from '../extensions/registry';
|
||||
import type { HostApiContext } from './context';
|
||||
import { handleAppRoutes } from './routes/app';
|
||||
import { handleGatewayRoutes } from './routes/gateway';
|
||||
@@ -25,7 +26,7 @@ type RouteHandler = (
|
||||
ctx: HostApiContext,
|
||||
) => Promise<boolean>;
|
||||
|
||||
const routeHandlers: RouteHandler[] = [
|
||||
const coreRouteHandlers: RouteHandler[] = [
|
||||
handleAppRoutes,
|
||||
handleGatewayRoutes,
|
||||
handleSettingsRoutes,
|
||||
@@ -41,6 +42,11 @@ const routeHandlers: RouteHandler[] = [
|
||||
handleUsageRoutes,
|
||||
];
|
||||
|
||||
function buildRouteHandlers(): RouteHandler[] {
|
||||
const extensionHandlers = extensionRegistry.getRouteHandlers();
|
||||
return [...coreRouteHandlers, ...extensionHandlers];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session secret token used to authenticate Host API requests.
|
||||
* Generated once at server start and shared with the renderer via IPC.
|
||||
@@ -96,6 +102,7 @@ export function startHostApiServer(ctx: HostApiContext, port = getPort('CLAWX_HO
|
||||
return;
|
||||
}
|
||||
|
||||
const routeHandlers = buildRouteHandlers();
|
||||
for (const handler of routeHandlers) {
|
||||
if (await handler(req, res, requestUrl, ctx)) {
|
||||
return;
|
||||
|
||||
43
electron/extensions/builtin/clawhub-marketplace.ts
Normal file
43
electron/extensions/builtin/clawhub-marketplace.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
MarketplaceProviderExtension,
|
||||
MarketplaceCapability,
|
||||
} from '../types';
|
||||
import type {
|
||||
ClawHubSearchParams,
|
||||
ClawHubInstallParams,
|
||||
ClawHubSkillResult,
|
||||
} from '../../gateway/clawhub';
|
||||
|
||||
class ClawHubMarketplaceExtension implements MarketplaceProviderExtension {
|
||||
readonly id = 'builtin/clawhub-marketplace';
|
||||
|
||||
setup(_ctx: ExtensionContext): void {
|
||||
// No setup needed -- search/install delegates to the ClawHubService CLI runner
|
||||
}
|
||||
|
||||
async getCapability(): Promise<MarketplaceCapability> {
|
||||
return {
|
||||
mode: 'clawhub',
|
||||
canSearch: true,
|
||||
canInstall: true,
|
||||
};
|
||||
}
|
||||
|
||||
async search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]> {
|
||||
const { ClawHubService } = await import('../../gateway/clawhub');
|
||||
const svc = new ClawHubService();
|
||||
return svc.search(params);
|
||||
}
|
||||
|
||||
async install(params: ClawHubInstallParams): Promise<void> {
|
||||
const { ClawHubService } = await import('../../gateway/clawhub');
|
||||
const svc = new ClawHubService();
|
||||
return svc.install(params);
|
||||
}
|
||||
}
|
||||
|
||||
export function createClawHubMarketplaceExtension(): Extension {
|
||||
return new ClawHubMarketplaceExtension();
|
||||
}
|
||||
25
electron/extensions/builtin/diagnostics.ts
Normal file
25
electron/extensions/builtin/diagnostics.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
HostApiRouteExtension,
|
||||
RouteHandler,
|
||||
} from '../types';
|
||||
|
||||
class DiagnosticsExtension implements HostApiRouteExtension {
|
||||
readonly id = 'builtin/diagnostics';
|
||||
|
||||
setup(_ctx: ExtensionContext): void {
|
||||
// Diagnostics routes are stateless; no setup needed.
|
||||
}
|
||||
|
||||
getRouteHandler(): RouteHandler {
|
||||
return async (req, res, url, ctx) => {
|
||||
const { handleDiagnosticsRoutes } = await import('../../api/routes/diagnostics');
|
||||
return handleDiagnosticsRoutes(req, res, url, ctx);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createDiagnosticsExtension(): Extension {
|
||||
return new DiagnosticsExtension();
|
||||
}
|
||||
8
electron/extensions/builtin/index.ts
Normal file
8
electron/extensions/builtin/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerBuiltinExtension } from '../loader';
|
||||
import { createClawHubMarketplaceExtension } from './clawhub-marketplace';
|
||||
import { createDiagnosticsExtension } from './diagnostics';
|
||||
|
||||
export function registerAllBuiltinExtensions(): void {
|
||||
registerBuiltinExtension('builtin/clawhub-marketplace', createClawHubMarketplaceExtension);
|
||||
registerBuiltinExtension('builtin/diagnostics', createDiagnosticsExtension);
|
||||
}
|
||||
17
electron/extensions/index.ts
Normal file
17
electron/extensions/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { extensionRegistry } from './registry';
|
||||
export { registerBuiltinExtension, loadExtensionsFromManifest } from './loader';
|
||||
export type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
HostApiRouteExtension,
|
||||
MarketplaceProviderExtension,
|
||||
MarketplaceCapability,
|
||||
AuthProviderExtension,
|
||||
AuthStatus,
|
||||
RouteHandler,
|
||||
} from './types';
|
||||
export {
|
||||
isHostApiRouteExtension,
|
||||
isMarketplaceProviderExtension,
|
||||
isAuthProviderExtension,
|
||||
} from './types';
|
||||
76
electron/extensions/loader.ts
Normal file
76
electron/extensions/loader.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { app } from 'electron';
|
||||
import { logger } from '../utils/logger';
|
||||
import { extensionRegistry } from './registry';
|
||||
import type { Extension } from './types';
|
||||
|
||||
interface ExtensionManifest {
|
||||
extensions?: {
|
||||
main?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const builtinModules = new Map<string, () => Extension>();
|
||||
|
||||
export function registerBuiltinExtension(id: string, factory: () => Extension): void {
|
||||
builtinModules.set(id, factory);
|
||||
}
|
||||
|
||||
function resolveManifestPath(): string {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, 'clawx-extensions.json');
|
||||
}
|
||||
return join(app.getAppPath(), 'clawx-extensions.json');
|
||||
}
|
||||
|
||||
export async function loadExtensionsFromManifest(): Promise<void> {
|
||||
const manifestPath = resolveManifestPath();
|
||||
let manifest: ExtensionManifest = {};
|
||||
|
||||
if (existsSync(manifestPath)) {
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as ExtensionManifest;
|
||||
logger.info(`[extensions] Loaded manifest from ${manifestPath}`);
|
||||
} catch (err) {
|
||||
logger.warn(`[extensions] Failed to parse ${manifestPath}, using defaults:`, err);
|
||||
}
|
||||
} else {
|
||||
logger.debug('[extensions] No clawx-extensions.json found, loading all builtin extensions');
|
||||
}
|
||||
|
||||
const mainExtensions = manifest.extensions?.main;
|
||||
|
||||
if (!mainExtensions || mainExtensions.length === 0) {
|
||||
for (const [id, factory] of builtinModules) {
|
||||
extensionRegistry.register(factory());
|
||||
logger.debug(`[extensions] Auto-registered builtin extension "${id}"`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extensionId of mainExtensions) {
|
||||
if (builtinModules.has(extensionId)) {
|
||||
extensionRegistry.register(builtinModules.get(extensionId)!());
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require(extensionId) as { default?: Extension; extension?: Extension };
|
||||
const ext = mod.default ?? mod.extension;
|
||||
if (ext && typeof ext.setup === 'function') {
|
||||
extensionRegistry.register(ext);
|
||||
} else {
|
||||
logger.warn(`[extensions] Module "${extensionId}" does not export a valid Extension`);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('Cannot find module')) {
|
||||
logger.debug(`[extensions] "${extensionId}" not loadable at runtime (expected when using ext-bridge)`);
|
||||
} else {
|
||||
logger.warn(`[extensions] Failed to load extension "${extensionId}": ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
electron/extensions/registry.ts
Normal file
76
electron/extensions/registry.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
HostApiRouteExtension,
|
||||
MarketplaceProviderExtension,
|
||||
RouteHandler,
|
||||
} from './types';
|
||||
import {
|
||||
isHostApiRouteExtension,
|
||||
isMarketplaceProviderExtension,
|
||||
} from './types';
|
||||
|
||||
class ExtensionRegistry {
|
||||
private extensions = new Map<string, Extension>();
|
||||
private ctx: ExtensionContext | null = null;
|
||||
|
||||
async initialize(ctx: ExtensionContext): Promise<void> {
|
||||
this.ctx = ctx;
|
||||
for (const ext of this.extensions.values()) {
|
||||
try {
|
||||
await ext.setup(ctx);
|
||||
logger.info(`[extensions] Extension "${ext.id}" initialized`);
|
||||
} catch (err) {
|
||||
logger.error(`[extensions] Extension "${ext.id}" failed to initialize:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register(extension: Extension): void {
|
||||
if (this.extensions.has(extension.id)) {
|
||||
logger.warn(`[extensions] Extension "${extension.id}" is already registered; skipping duplicate`);
|
||||
return;
|
||||
}
|
||||
this.extensions.set(extension.id, extension);
|
||||
logger.debug(`[extensions] Registered extension "${extension.id}"`);
|
||||
|
||||
if (this.ctx) {
|
||||
void Promise.resolve(extension.setup(this.ctx)).catch((err) => {
|
||||
logger.error(`[extensions] Late-registered extension "${extension.id}" failed to initialize:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get(id: string): Extension | undefined {
|
||||
return this.extensions.get(id);
|
||||
}
|
||||
|
||||
getAll(): Extension[] {
|
||||
return [...this.extensions.values()];
|
||||
}
|
||||
|
||||
getRouteHandlers(): RouteHandler[] {
|
||||
return this.getAll()
|
||||
.filter(isHostApiRouteExtension)
|
||||
.map((ext: HostApiRouteExtension) => ext.getRouteHandler());
|
||||
}
|
||||
|
||||
getMarketplaceProvider(): MarketplaceProviderExtension | undefined {
|
||||
return this.getAll().find(isMarketplaceProviderExtension) as MarketplaceProviderExtension | undefined;
|
||||
}
|
||||
|
||||
async teardownAll(): Promise<void> {
|
||||
for (const ext of this.extensions.values()) {
|
||||
try {
|
||||
await ext.teardown?.();
|
||||
} catch (err) {
|
||||
logger.warn(`[extensions] Extension "${ext.id}" teardown failed:`, err);
|
||||
}
|
||||
}
|
||||
this.extensions.clear();
|
||||
this.ctx = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionRegistry = new ExtensionRegistry();
|
||||
69
electron/extensions/types.ts
Normal file
69
electron/extensions/types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { HostEventBus } from '../api/event-bus';
|
||||
import type { HostApiContext } from '../api/context';
|
||||
import type {
|
||||
ClawHubSearchParams,
|
||||
ClawHubInstallParams,
|
||||
ClawHubSkillResult,
|
||||
} from '../gateway/clawhub';
|
||||
|
||||
export type RouteHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
) => Promise<boolean>;
|
||||
|
||||
export interface ExtensionContext {
|
||||
gatewayManager: GatewayManager;
|
||||
eventBus: HostEventBus;
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
id: string;
|
||||
setup(ctx: ExtensionContext): void | Promise<void>;
|
||||
teardown?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface HostApiRouteExtension extends Extension {
|
||||
getRouteHandler(): RouteHandler;
|
||||
}
|
||||
|
||||
export interface MarketplaceCapability {
|
||||
mode: string;
|
||||
canSearch: boolean;
|
||||
canInstall: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceProviderExtension extends Extension {
|
||||
getCapability(): Promise<MarketplaceCapability>;
|
||||
search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]>;
|
||||
install(params: ClawHubInstallParams): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
authenticated: boolean;
|
||||
expired: boolean;
|
||||
user: { username: string; displayName: string; email: string } | null;
|
||||
}
|
||||
|
||||
export interface AuthProviderExtension extends Extension {
|
||||
getAuthStatus(): Promise<AuthStatus>;
|
||||
onStartup?(mainWindow: BrowserWindow): Promise<void>;
|
||||
}
|
||||
|
||||
export function isHostApiRouteExtension(ext: Extension): ext is HostApiRouteExtension {
|
||||
return 'getRouteHandler' in ext && typeof (ext as HostApiRouteExtension).getRouteHandler === 'function';
|
||||
}
|
||||
|
||||
export function isMarketplaceProviderExtension(ext: Extension): ext is MarketplaceProviderExtension {
|
||||
return 'getCapability' in ext && 'search' in ext && 'install' in ext;
|
||||
}
|
||||
|
||||
export function isAuthProviderExtension(ext: Extension): ext is AuthProviderExtension {
|
||||
return 'getAuthStatus' in ext && typeof (ext as AuthProviderExtension).getAuthStatus === 'function';
|
||||
}
|
||||
@@ -40,12 +40,30 @@ export interface ClawHubInstalledSkillResult {
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceProvider {
|
||||
getCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }>;
|
||||
search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]>;
|
||||
install(params: ClawHubInstallParams): Promise<void>;
|
||||
}
|
||||
|
||||
export class ClawHubService {
|
||||
private workDir: string;
|
||||
private cliPath: string;
|
||||
private cliEntryPath: string;
|
||||
private useNodeRunner: boolean;
|
||||
private ansiRegex: RegExp;
|
||||
private marketplaceProvider: MarketplaceProvider | null = null;
|
||||
|
||||
setMarketplaceProvider(provider: MarketplaceProvider): void {
|
||||
this.marketplaceProvider = provider;
|
||||
}
|
||||
|
||||
async getMarketplaceCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.getCapability();
|
||||
}
|
||||
return { mode: 'clawhub', canSearch: true, canInstall: true };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Use the user's OpenClaw config directory (~/.openclaw) for skill management
|
||||
@@ -194,9 +212,13 @@ export class ClawHubService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for skills
|
||||
* Search for skills. Delegates to the marketplace provider if one is set,
|
||||
* otherwise falls back to the local ClawHub CLI.
|
||||
*/
|
||||
async search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.search(params);
|
||||
}
|
||||
try {
|
||||
// If query is empty, use 'explore' to show trending skills
|
||||
if (!params.query || params.query.trim() === '') {
|
||||
@@ -298,9 +320,13 @@ export class ClawHubService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill
|
||||
* Install a skill. Delegates to the marketplace provider if one is set,
|
||||
* otherwise falls back to the local ClawHub CLI.
|
||||
*/
|
||||
async install(params: ClawHubInstallParams): Promise<void> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.install(params);
|
||||
}
|
||||
const args = ['install', params.slug];
|
||||
|
||||
if (params.version) {
|
||||
|
||||
@@ -16,6 +16,10 @@ import { warmupNetworkOptimization } from '../utils/uv-env';
|
||||
import { initTelemetry } from '../utils/telemetry';
|
||||
|
||||
import { ClawHubService } from '../gateway/clawhub';
|
||||
import { extensionRegistry } from '../extensions/registry';
|
||||
import { loadExtensionsFromManifest } from '../extensions/loader';
|
||||
import { registerAllBuiltinExtensions } from '../extensions/builtin';
|
||||
import { loadExternalMainExtensions } from '../extensions/_ext-bridge.generated';
|
||||
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
|
||||
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
|
||||
import { isQuitting, setQuitting } from './app-state';
|
||||
@@ -340,6 +344,19 @@ async function initialize(): Promise<void> {
|
||||
mainWindow: window,
|
||||
});
|
||||
|
||||
// Initialize extension system
|
||||
await extensionRegistry.initialize({
|
||||
gatewayManager,
|
||||
eventBus: hostEventBus,
|
||||
getMainWindow: () => mainWindow,
|
||||
});
|
||||
|
||||
// Wire marketplace provider to ClawHubService if an extension provides one
|
||||
const marketplaceProvider = extensionRegistry.getMarketplaceProvider();
|
||||
if (marketplaceProvider) {
|
||||
clawHubService.setMarketplaceProvider(marketplaceProvider);
|
||||
}
|
||||
|
||||
// Register update handlers
|
||||
registerUpdateHandlers(appUpdater, window);
|
||||
|
||||
@@ -521,6 +538,13 @@ if (gotTheLock) {
|
||||
clawHubService = new ClawHubService();
|
||||
hostEventBus = new HostEventBus();
|
||||
|
||||
// Register builtin extensions and load manifest
|
||||
registerAllBuiltinExtensions();
|
||||
loadExternalMainExtensions();
|
||||
void loadExtensionsFromManifest().catch((err) => {
|
||||
logger.warn('Failed to load extensions from manifest:', err);
|
||||
});
|
||||
|
||||
// When a second instance is launched, focus the existing window instead.
|
||||
app.on('second-instance', () => {
|
||||
logger.info('Second ClawX instance detected; redirecting to the existing window');
|
||||
@@ -578,6 +602,7 @@ if (gotTheLock) {
|
||||
|
||||
hostEventBus.closeAll();
|
||||
hostApiServer?.close();
|
||||
void extensionRegistry.teardownAll();
|
||||
|
||||
const stopPromise = gatewayManager.stop().catch((err) => {
|
||||
logger.warn('gatewayManager.stop() error during quit:', err);
|
||||
|
||||
@@ -180,8 +180,7 @@ const electronAPI = {
|
||||
'openclaw:cli-installed',
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
// Wrap the callback to strip the event
|
||||
if (validChannels.includes(channel) || channel.startsWith('ext:')) {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => {
|
||||
callback(...args);
|
||||
};
|
||||
@@ -228,7 +227,7 @@ const electronAPI = {
|
||||
'oauth:error',
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
if (validChannels.includes(channel) || channel.startsWith('ext:')) {
|
||||
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user