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:
Haozhe 2026-08-12 12:35:01 +08:00 committed by GitHub
parent dc8db90cdd
commit 26a37f30a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 66 additions and 21 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
);
}
/**

View file

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

View file

@ -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',