diff --git a/packages/agent-core-v2/src/_base/di/instantiation.ts b/packages/agent-core-v2/src/_base/di/instantiation.ts index 370dad62ff..f059ea7a5f 100644 --- a/packages/agent-core-v2/src/_base/di/instantiation.ts +++ b/packages/agent-core-v2/src/_base/di/instantiation.ts @@ -130,10 +130,6 @@ export function createDecorator(name: string): ServiceIdentifier { return id; } -export function lookupServiceDecorator(name: string): ServiceIdentifier | undefined { - return _util.serviceIds.get(name); -} - const SERVICE_IDENTIFIER_MARK = Symbol('serviceIdentifier'); export function isServiceIdentifier(thing: unknown): thing is ServiceIdentifier { diff --git a/packages/agent-core-v2/src/features/debugEvents/debugEvents.ts b/packages/agent-core-v2/src/features/debugEvents/debugEvents.ts index 698c42b355..65dd6f1112 100644 --- a/packages/agent-core-v2/src/features/debugEvents/debugEvents.ts +++ b/packages/agent-core-v2/src/features/debugEvents/debugEvents.ts @@ -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'; diff --git a/packages/agent-core-v2/src/features/debugEvents/debugEventsFeature.ts b/packages/agent-core-v2/src/features/debugEvents/debugEventsFeature.ts index f5ab880bb3..8e530d225e 100644 --- a/packages/agent-core-v2/src/features/debugEvents/debugEventsFeature.ts +++ b/packages/agent-core-v2/src/features/debugEvents/debugEventsFeature.ts @@ -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'; diff --git a/packages/agent-core-v2/src/features/feature.ts b/packages/agent-core-v2/src/features/feature.ts index f3bd956dcd..b62a6666b0 100644 --- a/packages/agent-core-v2/src/features/feature.ts +++ b/packages/agent-core-v2/src/features/feature.ts @@ -43,6 +43,8 @@ import { type AnyAgentTool, } from '#/agent/toolRegistry/toolContribution'; +import { recordContributedService } from './featureRegistry'; + export abstract class Feature extends Service { contribute(token: CollectionToken, 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 { diff --git a/packages/agent-core-v2/src/features/featureRegistry.ts b/packages/agent-core-v2/src/features/featureRegistry.ts index 4a22b5af61..b268a473b8 100644 --- a/packages/agent-core-v2/src/features/featureRegistry.ts +++ b/packages/agent-core-v2/src/features/featureRegistry.ts @@ -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 }[] = []; + +export function recordContributedService(scope: string, id: ServiceIdentifier): void { + if (_contributedServices.some((entry) => entry.scope === scope && entry.id === id)) { + return; + } + _contributedServices.push({ scope, id }); +} + +export function getContributedServices(): ReadonlyArray<{ + scope: string; + id: ServiceIdentifier; +}> { + return _contributedServices; +} + +export function _clearContributedServicesForTests(): void { + _contributedServices.length = 0; +} diff --git a/packages/agent-core-v2/test/features/debugEvents/debugEvents.test.ts b/packages/agent-core-v2/test/features/debugEvents/debugEvents.test.ts index e489989787..7d6457a3d6 100644 --- a/packages/agent-core-v2/test/features/debugEvents/debugEvents.test.ts +++ b/packages/agent-core-v2/test/features/debugEvents/debugEvents.test.ts @@ -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({ diff --git a/packages/kap-server/AGENTS.md b/packages/kap-server/AGENTS.md index c743d7b06f..5be4c47126 100644 --- a/packages/kap-server/AGENTS.md +++ b/packages/kap-server/AGENTS.md @@ -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 diff --git a/packages/kap-server/src/transport/channelRegistry.ts b/packages/kap-server/src/transport/channelRegistry.ts index 48f84530c7..58cd41e76d 100644 --- a/packages/kap-server/src/transport/channelRegistry.ts +++ b/packages/kap-server/src/transport/channelRegistry.ts @@ -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> { /** Resolve a wire name to its `ServiceIdentifier` anywhere in the DI registry. */ export function resolveAnyScopedServiceId(name: string): ServiceIdentifier | undefined { - return scopedServiceNameIndex().get(name) ?? lookupServiceDecorator(name); + return ( + scopedServiceNameIndex().get(name) ?? + getContributedServices().find((entry) => entry.id.toString() === name)?.id + ); } /** diff --git a/packages/kap-server/src/transport/serviceDispatcherRoutes.ts b/packages/kap-server/src/transport/serviceDispatcherRoutes.ts index 4a17799131..59327f1c15 100644 --- a/packages/kap-server/src/transport/serviceDispatcherRoutes.ts +++ b/packages/kap-server/src/transport/serviceDispatcherRoutes.ts @@ -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 diff --git a/packages/kap-server/test/rpc.test.ts b/packages/kap-server/test/rpc.test.ts index b0154f3188..b0bbb7ab93 100644 --- a/packages/kap-server/test/rpc.test.ts +++ b/packages/kap-server/test/rpc.test.ts @@ -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('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',