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

@@ -28,6 +28,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate extension bridge
run: pnpm run ext:bridge
- name: Run linter
run: pnpm run lint
@@ -57,5 +60,8 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate extension bridge
run: pnpm run ext:bridge
- name: Build
run: pnpm run build:vite

View File

@@ -40,6 +40,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate extension bridge
run: pnpm run ext:bridge
- name: Rebuild Electron binary
run: pnpm rebuild electron

4
.gitignore vendored
View File

@@ -69,3 +69,7 @@ docs/pr-session-notes-*.md
.claude/
.pnpm-store/
package-lock.json
# Generated extension bridges (created by scripts/generate-ext-bridge.mjs)
electron/extensions/_ext-bridge.generated.ts
src/extensions/_ext-bridge.generated.ts

9
clawx-extensions.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extensions": {
"main": [
"builtin/clawhub-marketplace",
"builtin/diagnostics"
],
"renderer": []
}
}

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

View File

@@ -33,9 +33,10 @@
"private": true,
"scripts": {
"init": "pnpm install && pnpm run uv:download",
"predev": "zx scripts/prepare-preinstalled-skills-dev.mjs",
"predev": "node scripts/generate-ext-bridge.mjs && zx scripts/prepare-preinstalled-skills-dev.mjs",
"dev": "vite",
"build": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder",
"ext:bridge": "node scripts/generate-ext-bridge.mjs",
"build": "node scripts/generate-ext-bridge.mjs && vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder",
"build:vite": "vite build",
"bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs",
"bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs",
@@ -80,6 +81,7 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@larksuite/openclaw-lark": "2026.4.7",
"@playwright/test": "^1.56.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
@@ -92,7 +94,6 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@playwright/test": "^1.56.1",
"@soimy/dingtalk": "^3.5.3",
"@tencent-weixin/openclaw-weixin": "^2.1.8",
"@testing-library/jest-dom": "^6.9.1",

10
pnpm-lock.yaml generated
View File

@@ -9921,7 +9921,7 @@ snapshots:
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 25.5.0
'@types/node': 25.6.0
'@types/bun@1.3.11':
dependencies:
@@ -9946,7 +9946,7 @@ snapshots:
'@types/connect@3.4.38':
dependencies:
'@types/node': 25.5.0
'@types/node': 25.6.0
'@types/debug@4.1.13':
dependencies:
@@ -9966,7 +9966,7 @@ snapshots:
'@types/express-serve-static-core@5.1.1':
dependencies:
'@types/node': 25.5.0
'@types/node': 25.6.0
'@types/qs': 6.15.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -10056,12 +10056,12 @@ snapshots:
'@types/send@1.2.1':
dependencies:
'@types/node': 25.5.0
'@types/node': 25.6.0
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 25.5.0
'@types/node': 25.6.0
'@types/unist@2.0.11': {}

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env node
/**
* Generates extension bridge files based on clawx-extensions.json and
* which packages are actually installed in node_modules.
*
* Outputs:
* electron/extensions/_ext-bridge.generated.ts (main process)
* src/extensions/_ext-bridge.generated.ts (renderer)
*
* Both files are .gitignore'd. When no external extensions are installed,
* they export no-op functions so the core compiles cleanly.
*
* Usage: node scripts/generate-ext-bridge.mjs
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const MANIFEST_PATH = resolve(ROOT, 'clawx-extensions.json');
const MAIN_OUT = resolve(ROOT, 'electron/extensions/_ext-bridge.generated.ts');
const RENDERER_OUT = resolve(ROOT, 'src/extensions/_ext-bridge.generated.ts');
function readManifest() {
if (!existsSync(MANIFEST_PATH)) return { extensions: {} };
try {
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'));
} catch {
return { extensions: {} };
}
}
function isExternalId(id) {
return id && !id.startsWith('builtin/');
}
function resolvePackageName(extensionId) {
// Convention: extension IDs like "@scope/pkg/sub" map to package "@scope/pkg"
// IDs like "@scope/pkg" map to package "@scope/pkg"
const parts = extensionId.split('/');
if (parts[0].startsWith('@') && parts.length >= 2) {
return parts.slice(0, 2).join('/');
}
return parts[0];
}
function isPackageInstalled(pkgName) {
return existsSync(resolve(ROOT, 'node_modules', ...pkgName.split('/')));
}
function generateMainBridge(manifest) {
const externalMain = (manifest.extensions?.main ?? []).filter(isExternalId);
const installedExts = externalMain.filter((id) => isPackageInstalled(resolvePackageName(id)));
if (installedExts.length === 0) {
return [
'// Auto-generated — no external main-process extensions installed.',
'// To add extensions, configure clawx-extensions.json and link the package.',
'export function loadExternalMainExtensions(): void { /* no-op */ }',
'',
].join('\n');
}
const lines = [
'// Auto-generated by scripts/generate-ext-bridge.mjs — do not edit.',
"import { extensionRegistry } from './registry';",
'',
];
installedExts.forEach((id, i) => {
const pkg = resolvePackageName(id);
const subpath = id.slice(pkg.length + 1); // e.g. "enterprise-auth"
const importPath = subpath ? `${pkg}/${subpath}` : pkg;
const factoryName = `ext${i}`;
lines.push(`import { ${guessFactoryExport(subpath || id)} as ${factoryName} } from '${importPath}';`);
});
lines.push('');
lines.push('export function loadExternalMainExtensions(): void {');
installedExts.forEach((_id, i) => {
lines.push(` const e${i} = ext${i}();`);
lines.push(` if (e${i}) extensionRegistry.register(e${i});`);
});
lines.push('}');
lines.push('');
return lines.join('\n');
}
function generateRendererBridge(manifest) {
const externalRenderer = (manifest.extensions?.renderer ?? []).filter(isExternalId);
const installedExts = externalRenderer.filter((id) => isPackageInstalled(resolvePackageName(id)));
if (installedExts.length === 0) {
return [
'// Auto-generated — no external renderer extensions installed.',
'// To add extensions, configure clawx-extensions.json and link the package.',
'export function loadExternalRendererExtensions(): void { /* no-op */ }',
'',
].join('\n');
}
const lines = [
'// Auto-generated by scripts/generate-ext-bridge.mjs — do not edit.',
"import { rendererExtensionRegistry } from './registry';",
'',
];
installedExts.forEach((id, i) => {
const pkg = resolvePackageName(id);
const subpath = id.slice(pkg.length + 1);
const importPath = subpath ? `${pkg}/${subpath}` : pkg;
const factoryName = `ext${i}`;
lines.push(`import { ${guessFactoryExport(subpath || id)} as ${factoryName} } from '${importPath}';`);
});
lines.push('');
lines.push('export function loadExternalRendererExtensions(): void {');
installedExts.forEach((_id, i) => {
lines.push(` const e${i} = ext${i}();`);
lines.push(` if (e${i}) rendererExtensionRegistry.register(e${i});`);
});
lines.push('}');
lines.push('');
return lines.join('\n');
}
function guessFactoryExport(subpath) {
// "enterprise-auth" → "createEnterpriseAuthExtension"
// "enterprise-ui" → "createEnterpriseUIExtension"
// "skillshub-marketplace" → "createSkillshubMarketplaceExtension"
const camel = subpath
.replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase())
.replace(/^./, (c) => c.toUpperCase());
return `create${camel}Extension`;
}
// ─── Main ───
const manifest = readManifest();
mkdirSync(dirname(MAIN_OUT), { recursive: true });
mkdirSync(dirname(RENDERER_OUT), { recursive: true });
writeFileSync(MAIN_OUT, generateMainBridge(manifest));
writeFileSync(RENDERER_OUT, generateRendererBridge(manifest));
const mainExts = (manifest.extensions?.main ?? []).filter(isExternalId);
const rendererExts = (manifest.extensions?.renderer ?? []).filter(isExternalId);
const totalExternal = mainExts.length + rendererExts.length;
if (totalExternal === 0) {
console.log('[ext-bridge] No external extensions configured — generated empty stubs.');
} else {
const installedMain = mainExts.filter((id) => isPackageInstalled(resolvePackageName(id)));
const installedRenderer = rendererExts.filter((id) => isPackageInstalled(resolvePackageName(id)));
console.log(
`[ext-bridge] Generated bridges: ${installedMain.length}/${mainExts.length} main, ${installedRenderer.length}/${rendererExts.length} renderer extensions resolved.`,
);
}

View File

@@ -21,6 +21,8 @@ import { useSettingsStore } from './stores/settings';
import { useGatewayStore } from './stores/gateway';
import { useProviderStore } from './stores/providers';
import { applyGatewayTransportPreference } from './lib/api-client';
import { rendererExtensionRegistry } from './extensions/registry';
import { loadExternalRendererExtensions } from './extensions/_ext-bridge.generated';
/**
@@ -164,6 +166,16 @@ function App() {
applyGatewayTransportPreference();
}, []);
// Load external renderer extensions (generated by scripts/generate-ext-bridge.mjs)
// and initialize all registered extensions.
useEffect(() => {
loadExternalRendererExtensions();
void rendererExtensionRegistry.initializeAll();
return () => rendererExtensionRegistry.teardownAll();
}, []);
const extraRoutes = rendererExtensionRegistry.getExtraRoutes();
return (
<ErrorBoundary>
<TooltipProvider delayDuration={300}>
@@ -180,6 +192,9 @@ function App() {
<Route path="/skills" element={<Skills />} />
<Route path="/cron" element={<Cron />} />
<Route path="/settings/*" element={<Settings />} />
{extraRoutes.map((r) => (
<Route key={r.path} path={r.path} element={<r.component />} />
))}
</Route>
</Routes>

View File

@@ -20,6 +20,7 @@ import {
Cpu,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { rendererExtensionRegistry } from '@/extensions/registry';
import { useSettingsStore } from '@/stores/settings';
import { useChatStore } from '@/stores/chat';
import { useGatewayStore } from '@/stores/gateway';
@@ -208,7 +209,10 @@ export function Sidebar() {
sessionBucketMap[bucketKey].sessions.push(session);
}
const navItems = [
const hiddenRoutes = rendererExtensionRegistry.getHiddenRoutes();
const extraNavItems = rendererExtensionRegistry.getExtraNavItems();
const coreNavItems = [
{ to: '/models', icon: <Cpu className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.models'), testId: 'sidebar-nav-models' },
{ to: '/agents', icon: <Bot className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.agents'), testId: 'sidebar-nav-agents' },
{ to: '/channels', icon: <Network className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.channels'), testId: 'sidebar-nav-channels' },
@@ -216,6 +220,16 @@ export function Sidebar() {
{ to: '/cron', icon: <Clock className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.cronTasks'), testId: 'sidebar-nav-cron' },
];
const navItems = [
...coreNavItems.filter((item) => !hiddenRoutes.has(item.to)),
...extraNavItems.map((item) => ({
to: item.to,
icon: <item.icon className="h-[18px] w-[18px]" strokeWidth={2} />,
label: item.labelI18nKey ? t(item.labelI18nKey) : item.label,
testId: item.testId,
})),
];
return (
<aside
data-testid="sidebar"

12
src/extensions/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export { rendererExtensionRegistry } from './registry';
export { registerRendererExtensionModule, loadRendererExtensions } from './loader';
export type {
RendererExtension,
NavItemDef,
RouteDef,
SettingsSectionDef,
SidebarExtension,
RouteExtension,
SettingsSectionExtension,
I18nResources,
} from './types';

34
src/extensions/loader.ts Normal file
View File

@@ -0,0 +1,34 @@
import { rendererExtensionRegistry } from './registry';
import type { RendererExtension } from './types';
interface RendererExtensionManifest {
extensions?: {
renderer?: string[];
};
}
const registeredModules = new Map<string, () => RendererExtension>();
export function registerRendererExtensionModule(id: string, factory: () => RendererExtension): void {
registeredModules.set(id, factory);
}
export function loadRendererExtensions(manifest?: RendererExtensionManifest): void {
const extensionIds = manifest?.extensions?.renderer;
if (!extensionIds || extensionIds.length === 0) {
for (const [, factory] of registeredModules) {
rendererExtensionRegistry.register(factory());
}
return;
}
for (const id of extensionIds) {
const factory = registeredModules.get(id);
if (factory) {
rendererExtensionRegistry.register(factory());
} else {
console.warn(`[extensions] Renderer extension "${id}" not found in registered modules`);
}
}
}

View File

@@ -0,0 +1,81 @@
import i18n from '@/i18n';
import type {
RendererExtension,
NavItemDef,
RouteDef,
SettingsSectionDef,
} from './types';
class RendererExtensionRegistry {
private extensions: RendererExtension[] = [];
register(extension: RendererExtension): void {
if (this.extensions.some((e) => e.id === extension.id)) {
console.warn(`[extensions] Renderer extension "${extension.id}" already registered`);
return;
}
this.extensions.push(extension);
if (extension.i18nResources) {
this.loadI18nResources(extension.i18nResources);
}
}
private loadI18nResources(resources: Record<string, Record<string, unknown>>): void {
for (const [lang, namespaces] of Object.entries(resources)) {
for (const [ns, bundle] of Object.entries(namespaces)) {
i18n.addResourceBundle(lang, ns, bundle, true, true);
}
}
}
getAll(): RendererExtension[] {
return [...this.extensions];
}
getExtraNavItems(): NavItemDef[] {
return this.extensions.flatMap((ext) => ext.sidebar?.navItems ?? []);
}
getHiddenRoutes(): Set<string> {
const hidden = new Set<string>();
for (const ext of this.extensions) {
for (const route of ext.sidebar?.hiddenRoutes ?? []) {
hidden.add(route);
}
}
return hidden;
}
getExtraRoutes(): RouteDef[] {
return this.extensions.flatMap((ext) => ext.routes?.routes ?? []);
}
getExtraSettingsSections(): SettingsSectionDef[] {
return this.extensions
.flatMap((ext) => ext.settings?.sections ?? [])
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
}
async initializeAll(): Promise<void> {
for (const ext of this.extensions) {
try {
await ext.setup?.();
} catch (err) {
console.error(`[extensions] Renderer extension "${ext.id}" setup failed:`, err);
}
}
}
teardownAll(): void {
for (const ext of this.extensions) {
try {
ext.teardown?.();
} catch (err) {
console.warn(`[extensions] Renderer extension "${ext.id}" teardown failed:`, err);
}
}
this.extensions = [];
}
}
export const rendererExtensionRegistry = new RendererExtensionRegistry();

48
src/extensions/types.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { ComponentType } from 'react';
export interface NavItemDef {
to: string;
icon: ComponentType<{ className?: string; strokeWidth?: number }>;
label: string;
labelI18nKey?: string;
testId?: string;
}
export type I18nResources = Record<string, Record<string, unknown>>;
export interface SidebarExtension {
id: string;
navItems?: NavItemDef[];
hiddenRoutes?: string[];
}
export interface RouteDef {
path: string;
component: ComponentType;
}
export interface RouteExtension {
id: string;
routes: RouteDef[];
}
export interface SettingsSectionDef {
id: string;
component: ComponentType;
order?: number;
}
export interface SettingsSectionExtension {
id: string;
sections: SettingsSectionDef[];
}
export interface RendererExtension {
id: string;
sidebar?: SidebarExtension;
routes?: RouteExtension;
settings?: SettingsSectionExtension;
i18nResources?: I18nResources;
setup?(): void | Promise<void>;
teardown?(): void;
}

View File

@@ -114,21 +114,22 @@ function removeDeletedTarget(groups: ChannelGroupItem[], target: DeleteTarget):
return groups.filter((group) => group.channelType !== target.channelType);
}
const DEFAULT_GATEWAY_HEALTH: GatewayHealthSummary = {
state: 'healthy',
reasons: [],
consecutiveHeartbeatMisses: 0,
};
export function Channels() {
const { t } = useTranslation('channels');
const gatewayStatus = useGatewayStore((state) => state.status);
const lastGatewayStateRef = useRef(gatewayStatus.state);
const defaultGatewayHealth = useMemo<GatewayHealthSummary>(() => ({
state: 'healthy',
reasons: [],
consecutiveHeartbeatMisses: 0,
}), []);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [channelGroups, setChannelGroups] = useState<ChannelGroupItem[]>([]);
const [agents, setAgents] = useState<AgentItem[]>([]);
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealthSummary>(defaultGatewayHealth);
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealthSummary>(DEFAULT_GATEWAY_HEALTH);
const [diagnosticsSnapshot, setDiagnosticsSnapshot] = useState<GatewayDiagnosticSnapshot | null>(null);
const [showDiagnostics, setShowDiagnostics] = useState(false);
const [diagnosticsLoading, setDiagnosticsLoading] = useState(false);
@@ -208,7 +209,7 @@ export function Channels() {
setChannelGroups(channelsPayload.channels || []);
setAgents(agentsRes.agents || []);
setGatewayHealth(channelsPayload.gatewayHealth || defaultGatewayHealth);
setGatewayHealth(channelsPayload.gatewayHealth || DEFAULT_GATEWAY_HEALTH);
setDiagnosticsSnapshot(null);
setShowDiagnostics(false);
console.info(

View File

@@ -5,6 +5,30 @@
import { vi } from 'vitest';
import '@testing-library/jest-dom';
// Provide a minimal `electron` mock so tests that transitively import
// main-process code (logger, store, etc.) don't blow up when the Electron
// binary is not present (e.g. CI with ELECTRON_SKIP_BINARY_DOWNLOAD=1).
// Individual test files can override with their own vi.mock('electron', ...).
vi.mock('electron', () => ({
app: {
getPath: vi.fn().mockReturnValue('/tmp/clawx-test'),
getVersion: vi.fn().mockReturnValue('0.0.0-test'),
getName: vi.fn().mockReturnValue('clawx-test'),
isPackaged: false,
isReady: vi.fn().mockResolvedValue(true),
on: vi.fn(),
off: vi.fn(),
quit: vi.fn(),
whenReady: vi.fn().mockResolvedValue(undefined),
},
BrowserWindow: vi.fn(),
ipcMain: { on: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() },
dialog: { showOpenDialog: vi.fn(), showMessageBox: vi.fn() },
shell: { openExternal: vi.fn() },
session: { defaultSession: { webRequest: { onBeforeSendHeaders: vi.fn() } } },
utilityProcess: {},
}));
// Mock window.electron API
const mockElectron = {
ipcRenderer: {

View File

@@ -28,5 +28,6 @@
}
},
"include": ["src"],
"exclude": ["node_modules"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -3,11 +3,38 @@ import react from '@vitejs/plugin-react';
import electron from 'vite-plugin-electron';
import renderer from 'vite-plugin-electron-renderer';
import { resolve } from 'path';
import { existsSync, readFileSync } from 'fs';
function getExtensionPackages(): Set<string> {
try {
const manifestPath = resolve(__dirname, 'clawx-extensions.json');
if (!existsSync(manifestPath)) return new Set();
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
const allIds: string[] = [
...(manifest.extensions?.main ?? []),
...(manifest.extensions?.renderer ?? []),
];
const pkgs = new Set<string>();
for (const id of allIds) {
if (id.startsWith('builtin/')) continue;
const parts = id.split('/');
pkgs.add(parts[0].startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]);
}
return pkgs;
} catch {
return new Set();
}
}
const extensionPackages = getExtensionPackages();
function isMainProcessExternal(id: string): boolean {
if (!id || id.startsWith('\0')) return false;
if (id.startsWith('.') || id.startsWith('/') || /^[A-Za-z]:[\\/]/.test(id)) return false;
if (id.startsWith('@/') || id.startsWith('@electron/')) return false;
for (const pkg of extensionPackages) {
if (id === pkg || id.startsWith(pkg + '/')) return false;
}
return true;
}