Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/core/src/kernel-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,21 @@ export abstract class ObjectKernelBase {
return this.services.get<T>(name);
}
},
replaceService: <T>(name: string, implementation: T): void => {
if (this.services instanceof Map) {
if (!this.services.has(name)) {
throw new Error(`[Kernel] Service '${name}' not found. Use registerService() to add new services.`);
}
this.services.set(name, implementation);
} else {
// IServiceRegistry implementation
if (!this.services.has(name)) {
throw new Error(`[Kernel] Service '${name}' not found. Use registerService() to add new services.`);
}
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the IServiceRegistry branch, replaceService calls this.services.register(name, implementation) after confirming the service already exists. IServiceRegistry.register() is specified to throw if the name is already registered, so this path will fail whenever services is an IServiceRegistry. Use unregister(name) (or a dedicated replace/set API) before re-registering.

Suggested change
}
}
// unregister existing service before re-registering to avoid duplicate registration errors
this.services.unregister(name);

Copilot uses AI. Check for mistakes.
this.services.register(name, implementation);
}
this.logger.info(`Service '${name}' replaced`, { service: name });
},
hook: (name, handler) => {
if (!this.hooks.has(name)) {
this.hooks.set(name, []);
Expand Down
87 changes: 87 additions & 0 deletions packages/core/src/kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,4 +534,91 @@ describe('ObjectKernel', () => {
}).rejects.toThrow('not running');
});
});

describe('Service Replacement', () => {
it('should replace an existing service via replaceService', async () => {
const originalService = { value: 'original' };
const replacementService = { value: 'replaced' };

const plugin: Plugin = {
name: 'register-plugin',
version: '1.0.0',
init: async (ctx) => {
ctx.registerService('metadata', originalService);
},
};

const optimizationPlugin: Plugin = {
name: 'optimization-plugin',
version: '1.0.0',
dependencies: ['register-plugin'],
init: async (ctx) => {
const existing = ctx.getService('metadata');
expect(existing).toBe(originalService);
ctx.replaceService('metadata', replacementService);
},
};

await kernel.use(plugin);
await kernel.use(optimizationPlugin);
await kernel.bootstrap();

const result = kernel.getService('metadata');
expect(result).toBe(replacementService);

await kernel.shutdown();
});

it('should throw when replacing a non-existent service', async () => {
const plugin: Plugin = {
name: 'bad-replace-plugin',
version: '1.0.0',
init: async (ctx) => {
expect(() => {
ctx.replaceService('nonexistent', { value: 'test' });
}).toThrow("Service 'nonexistent' not found");
},
};

await kernel.use(plugin);
await kernel.bootstrap();
await kernel.shutdown();
});

it('should allow decorator pattern via replaceService', async () => {
const original = {
getData: () => 'raw-data',
};

const plugin: Plugin = {
name: 'data-plugin',
version: '1.0.0',
init: async (ctx) => {
ctx.registerService('data', original);
},
};

const wrapperPlugin: Plugin = {
name: 'wrapper-plugin',
version: '1.0.0',
dependencies: ['data-plugin'],
init: async (ctx) => {
const existing = ctx.getService<typeof original>('data');
const decorated = {
getData: () => `cached(${existing.getData()})`,
};
ctx.replaceService('data', decorated);
},
};

await kernel.use(plugin);
await kernel.use(wrapperPlugin);
await kernel.bootstrap();

const result = kernel.getService<typeof original>('data');
expect(result.getData()).toBe('cached(raw-data)');

await kernel.shutdown();
});
});
});
9 changes: 9 additions & 0 deletions packages/core/src/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ export class ObjectKernel {
throw new Error(`[Kernel] Service '${name}' not found`);
}
},
replaceService: <T>(name: string, implementation: T): void => {
const hasService = this.services.has(name) || this.pluginLoader.hasService(name);
if (!hasService) {
throw new Error(`[Kernel] Service '${name}' not found. Use registerService() to add new services.`);
}
this.services.set(name, implementation);
this.pluginLoader.replaceService(name, implementation);
this.logger.info(`Service '${name}' replaced`, { service: name });
Comment on lines +122 to +129
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ObjectKernel’s replaceService currently allows replacement when the service exists only as a factory (via pluginLoader.hasService). However, without additional changes in PluginLoader, the replacement won’t affect kernel.getServiceAsync() (factory path still wins). Either constrain replaceService to instance-registered services only, or update the loader semantics so a replacement overrides factory resolution too.

Copilot uses AI. Check for mistakes.
},
hook: (name, handler) => {
if (!this.hooks.has(name)) {
this.hooks.set(name, []);
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/lite-kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,52 @@ describe('LiteKernel with Configurable Logger', () => {
await browserKernel.shutdown();
});
});

describe('Service Replacement', () => {
it('should replace an existing service via replaceService', async () => {
const originalService = { value: 'original' };
const replacementService = { value: 'replaced' };

const plugin: Plugin = {
name: 'register-plugin',
init: async (ctx) => {
ctx.registerService('metadata', originalService);
},
};

const optimizationPlugin: Plugin = {
name: 'optimization-plugin',
dependencies: ['register-plugin'],
init: async (ctx) => {
const existing = ctx.getService('metadata');
expect(existing).toBe(originalService);
ctx.replaceService('metadata', replacementService);
},
};

kernel.use(plugin);
kernel.use(optimizationPlugin);
await kernel.bootstrap();

const result = kernel.getService('metadata');
expect(result).toBe(replacementService);

await kernel.shutdown();
});

it('should throw when replacing a non-existent service', async () => {
const plugin: Plugin = {
name: 'bad-replace-plugin',
init: async (ctx) => {
expect(() => {
ctx.replaceService('nonexistent', { value: 'test' });
}).toThrow("Service 'nonexistent' not found");
},
};

kernel.use(plugin);
await kernel.bootstrap();
await kernel.shutdown();
});
});
});
12 changes: 12 additions & 0 deletions packages/core/src/plugin-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,18 @@ export class PluginLoader {
this.serviceInstances.set(name, service);
}

/**
* Replace an existing service instance.
* Used by optimization plugins to swap kernel internals.
* @throws Error if service does not exist
*/
replaceService(name: string, service: any): void {
if (!this.hasService(name)) {
throw new Error(`Service '${name}' not found`);
}
this.serviceInstances.set(name, service);
}
Comment on lines +256 to +261
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PluginLoader.replaceService() sets serviceInstances, but PluginLoader.getService() always prefers a serviceFactory when one exists. That means replacing a service that was registered via registerServiceFactory() will not take effect for async consumers (getService/getServiceAsync). Consider making getService() prefer an explicitly replaced instance, or have replaceService() remove/override the factory registration and clear any scoped caches for that service.

Copilot uses AI. Check for mistakes.

/**
* Check if a service is registered (either as instance or factory)
*/
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/security/plugin-permission-enforcer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,12 @@ export class SecurePluginContext implements PluginContext {
return this.baseContext.getService<T>(name);
}

replaceService<T>(name: string, implementation: T): void {
// Check permission before replacing service
this.permissionEnforcer.enforceServiceAccess(this.pluginName, name);
this.baseContext.replaceService(name, implementation);
}
Comment on lines +397 to +401
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SecurePluginContext.replaceService uses enforceServiceAccess, which appears to represent read/access permission. Replacing a service is a much higher-privilege operation (it can hijack kernel internals even if a plugin can only “access” the service). Consider introducing and enforcing a distinct capability for service replacement (or restrict replacement to trusted/core plugins) rather than reusing the read-access check.

Copilot uses AI. Check for mistakes.

getServices(): Map<string, any> {
// Return all services (no permission check for listing)
return this.baseContext.getServices();
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ export interface PluginContext {
*/
getService<T>(name: string): T;

/**
* Replace an existing service with a new implementation.
* Useful for optimization plugins that wrap or swap kernel internals
* (e.g., metadata registry, connection pooling).
*
* @param name - Service name to replace
* @param implementation - New service implementation
* @throws Error if the service does not exist
*/
replaceService<T>(name: string, implementation: T): void;
Comment on lines +36 to +40
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PluginContext now requires replaceService, but several typed test mocks in the repo construct PluginContext objects without this method (e.g. packages/plugins/plugin-auth/src/auth-plugin.test.ts and packages/core/src/security/plugin-permission-enforcer.test.ts). This will cause TypeScript compile errors; update those mocks to include a replaceService stub (or use a helper factory for PluginContext test doubles).

Suggested change
* @param name - Service name to replace
* @param implementation - New service implementation
* @throws Error if the service does not exist
*/
replaceService<T>(name: string, implementation: T): void;
* NOTE: This is optional for backward compatibility with existing plugins
* and test contexts that may not implement service replacement.
*
* @param name - Service name to replace
* @param implementation - New service implementation
* @throws Error if the service does not exist
*/
replaceService?<T>(name: string, implementation: T): void;

Copilot uses AI. Check for mistakes.

/**
* Get all registered services
*/
Expand Down
Loading