mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-14 11:16:19 +00:00
fix(kap-server): restrict debug RPC fallback to feature-contributed services (#2808)
The decorator-registry fallback resolved every decorator name, including kernel tokens like instantiationService — a call to instantiationService/dispose would tear down the root container. Record Feature.contributeService tokens in a contributed-service table and fall back to that table only, so runtime-contributed services stay callable while unregistered kernel tokens remain unreachable.
This commit is contained in:
parent
dc8db90cdd
commit
26a37f30a3
10 changed files with 66 additions and 21 deletions
|
|
@ -130,10 +130,6 @@ export function createDecorator<T>(name: string): ServiceIdentifier<T> {
|
|||
return id;
|
||||
}
|
||||
|
||||
export function lookupServiceDecorator(name: string): ServiceIdentifier<unknown> | undefined {
|
||||
return _util.serviceIds.get(name);
|
||||
}
|
||||
|
||||
const SERVICE_IDENTIFIER_MARK = Symbol('serviceIdentifier');
|
||||
|
||||
export function isServiceIdentifier(thing: unknown): thing is ServiceIdentifier<unknown> {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@
|
|||
* the caller never registered on a unit book. Unmaterialized on-demand units
|
||||
* and anonymous fiber units are not enumerable and are simply absent.
|
||||
* Contributed at App scope through `DebugEventsFeature` — reachable over the
|
||||
* debug RPC surface by decorator name, but absent from the static scoped
|
||||
* registry (`GET /api/v1/debug/channels`). All payloads are JSON-serializable
|
||||
* wire data.
|
||||
* debug RPC surface through the contributed-service fallback, but absent from
|
||||
* the static scoped registry (`GET /api/v1/debug/channels`). All payloads are
|
||||
* JSON-serializable wire data.
|
||||
*/
|
||||
|
||||
import { createDecorator } from '#/_base/di/instantiation';
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
* Contributes the App-scope `IDebugEventsService` (OnDemand) through the
|
||||
* `features` base-class seam; retracting the unit withdraws the service
|
||||
* across the scope tree. The service is intentionally absent from the static
|
||||
* scoped registry — the debug RPC dispatcher reaches it by decorator-name
|
||||
* fallback. Registered into the feature table at import.
|
||||
* scoped registry — the debug RPC dispatcher reaches it through the
|
||||
* contributed-service fallback. Registered into the feature table at import.
|
||||
*/
|
||||
|
||||
import { ScopeActivation } from '#/_base/di/instantiation';
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ import {
|
|||
type AnyAgentTool,
|
||||
} from '#/agent/toolRegistry/toolContribution';
|
||||
|
||||
import { recordContributedService } from './featureRegistry';
|
||||
|
||||
export abstract class Feature extends Service {
|
||||
contribute<T>(token: CollectionToken<T>, value: T): FiberHandle {
|
||||
return this.provide(token, value);
|
||||
|
|
@ -66,6 +68,7 @@ export abstract class Feature extends Service {
|
|||
ctor: ServiceClassRecipe,
|
||||
opts?: FiberProvideOptions,
|
||||
): FiberHandle {
|
||||
recordContributedService(scope, id);
|
||||
return this.provide(ScopeUnits(scope), {
|
||||
name: `${this.name}:${String(id)}`,
|
||||
apply(fiber: Fiber): void {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
/**
|
||||
* `features` domain — the module-level feature recipe table ("import =
|
||||
* register").
|
||||
* register") plus the contributed-service table (one entry per
|
||||
* `Feature.contributeService` call — the record that lets the debug RPC
|
||||
* dispatcher reach runtime-contributed Services without opening the door to
|
||||
* arbitrary decorator names).
|
||||
*
|
||||
* Each feature module calls `registerFeature(Recipe)` at its top level; the
|
||||
* assembly drains the table once at App-scope creation. Pure data — no DI, no
|
||||
|
|
@ -8,6 +11,7 @@
|
|||
*/
|
||||
|
||||
import type { ServiceClassRecipe } from '#/_base/di/fiber';
|
||||
import type { ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
const _featureRecipes: ServiceClassRecipe[] = [];
|
||||
|
||||
|
|
@ -22,3 +26,23 @@ export function getFeatureRecipes(): readonly ServiceClassRecipe[] {
|
|||
export function _clearFeatureRecipesForTests(): void {
|
||||
_featureRecipes.length = 0;
|
||||
}
|
||||
|
||||
const _contributedServices: { scope: string; id: ServiceIdentifier<unknown> }[] = [];
|
||||
|
||||
export function recordContributedService(scope: string, id: ServiceIdentifier<unknown>): void {
|
||||
if (_contributedServices.some((entry) => entry.scope === scope && entry.id === id)) {
|
||||
return;
|
||||
}
|
||||
_contributedServices.push({ scope, id });
|
||||
}
|
||||
|
||||
export function getContributedServices(): ReadonlyArray<{
|
||||
scope: string;
|
||||
id: ServiceIdentifier<unknown>;
|
||||
}> {
|
||||
return _contributedServices;
|
||||
}
|
||||
|
||||
export function _clearContributedServicesForTests(): void {
|
||||
_contributedServices.length = 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ import { FeatureManagerService } from '#/app/feature/featureManagerService';
|
|||
import { LifecycleScope } from '#/app/scopes';
|
||||
import { IFeatureAssemblyService } from '#/features/featureAssembly';
|
||||
import { FeatureAssemblyService } from '#/features/featureAssemblyService';
|
||||
import { _clearFeatureRecipesForTests, registerFeature } from '#/features/featureRegistry';
|
||||
import {
|
||||
_clearFeatureRecipesForTests,
|
||||
getContributedServices,
|
||||
registerFeature,
|
||||
} from '#/features/featureRegistry';
|
||||
|
||||
import { IDebugEventsService } from '#/features/debugEvents/debugEvents';
|
||||
import { DebugEventsFeature } from '#/features/debugEvents/debugEventsFeature';
|
||||
|
|
@ -48,6 +52,11 @@ describe('DebugEventsFeature — App-scope introspection service', () => {
|
|||
const host = createScopedTestHost();
|
||||
const manager = host.app.accessor.get(IFeatureManager);
|
||||
expect(manager.units().map((unit) => unit.name)).toContain('debugEvents');
|
||||
expect(
|
||||
getContributedServices().some(
|
||||
(entry) => entry.scope === LifecycleScope.App && entry.id === IDebugEventsService,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const result = host.app.accessor.get(IDebugEventsService).subscriptions();
|
||||
expect(result).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ The Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agen
|
|||
## Routes
|
||||
|
||||
- Session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `ISessionLifecycleService`, and the fs routes resolve session → handler → the Workspace-scope fs services. One exception: `fs:search` also accepts a workspace reference (registered id or absolute root) in the `{session_id}` slot, so a not-yet-created draft session's `@` file mention resolves the workspace handler directly; the first-class session-less form is `POST /api/v1/workspace/fs:search` (the workspace reference travels in the body).
|
||||
- The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Lookup falls back to the global decorator registry, so runtime-contributed Services that bypass the static scoped registry (e.g. a Feature's `contributeService`) stay callable even though `GET /channels` does not list them.
|
||||
- The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Lookup falls back to the Feature contributed-service table (`features/featureRegistry`), so Feature-contributed Services (`contributeService`, which bypasses the static scoped registry) stay callable even though `GET /channels` does not list them; kernel tokens registered neither way (e.g. `instantiationService`) stay unreachable.
|
||||
|
||||
## `/api/v2` surface
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
/**
|
||||
* `/api/v1/debug` channel registry — the set of Services exposed over the
|
||||
* wire: the ENTIRE scoped DI registry (no whitelist), plus any Service
|
||||
* resolvable by decorator name as a fallback, so runtime-contributed units
|
||||
* (Feature `contributeService`, which bypasses the static scoped registry)
|
||||
* stay callable.
|
||||
* wire: the ENTIRE scoped DI registry (no whitelist), plus Services
|
||||
* runtime-contributed through the Feature `contributeService` seam (the
|
||||
* contributed-service table in `features/featureRegistry`), which bypasses
|
||||
* the static registry. Kernel tokens that were never registered either way
|
||||
* stay unreachable.
|
||||
*
|
||||
* In VS Code's `registerChannel` model a Service is registered once, keyed by
|
||||
* its decorator id (the public channel name), and from then on all of its
|
||||
|
|
@ -14,9 +15,9 @@
|
|||
|
||||
import {
|
||||
Disposable,
|
||||
getContributedServices,
|
||||
getScopedServiceDescriptors,
|
||||
LifecycleScope,
|
||||
lookupServiceDecorator,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
|
||||
import type { ScopedEntry, ServiceIdentifier } from '@moonshot-ai/agent-core-v2';
|
||||
|
|
@ -87,7 +88,10 @@ function scopedServiceNameIndex(): Map<string, ServiceIdentifier<unknown>> {
|
|||
|
||||
/** Resolve a wire name to its `ServiceIdentifier` anywhere in the DI registry. */
|
||||
export function resolveAnyScopedServiceId(name: string): ServiceIdentifier<unknown> | undefined {
|
||||
return scopedServiceNameIndex().get(name) ?? lookupServiceDecorator(name);
|
||||
return (
|
||||
scopedServiceNameIndex().get(name) ??
|
||||
getContributedServices().find((entry) => entry.id.toString() === name)?.id
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
*
|
||||
* Mounts the reflection dispatcher under `basePath`: the routes mirror the
|
||||
* scope tree; all share one handler. `:service` is a decorator id (channel
|
||||
* name) resolved against the scoped DI registry, then the global decorator
|
||||
* registry (runtime-contributed Services); `:method` is invoked by
|
||||
* reflection. Reads use `GET`, writes use `POST`.
|
||||
* name) resolved against the scoped DI registry, then the Feature
|
||||
* contributed-service table (runtime-contributed Services); `:method` is
|
||||
* invoked by reflection. Reads use `GET`, writes use `POST`.
|
||||
*
|
||||
* GET|POST {basePath}/:service/:method
|
||||
* GET|POST {basePath}/workspace/:workspace_id/:service/:method
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
IAppendLogStore,
|
||||
IDebugEventsService,
|
||||
IEventService,
|
||||
IInstantiationService,
|
||||
IPluginService,
|
||||
ISessionIndex,
|
||||
ISessionMetadata,
|
||||
|
|
@ -210,6 +211,14 @@ describe('server-v2 /api/v1/debug RPC', () => {
|
|||
expect(typeof body.data.globalListeners).toBe('number');
|
||||
});
|
||||
|
||||
it('rejects kernel tokens registered neither statically nor by a feature (40001)', async () => {
|
||||
// instantiationService is seeded into every container; a request like
|
||||
// instantiationService/dispose would tear down the root container. The
|
||||
// contributed-service fallback must not widen the surface to it.
|
||||
const { body } = await call<null>('POST', rpc('core', IInstantiationService, 'dispose'));
|
||||
expect(body.code).toBe(40001);
|
||||
});
|
||||
|
||||
it('lists sessions via GET', async () => {
|
||||
const { body } = await call<{ items: unknown[]; has_more: boolean }>(
|
||||
'GET',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue