feat(plugin): support enterprise extension (#861)

This commit is contained in:
Haze
2026-04-16 17:15:25 +08:00
committed by GitHub
Unverified
parent 2fefbf3aba
commit b884db629e
29 changed files with 847 additions and 22 deletions

View File

@@ -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);

View File

@@ -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;

View 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();
}

View 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();
}

View 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);
}

View 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';

View 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}`);
}
}
}
}

View 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();

View 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';
}

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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;
}