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
4 changes: 0 additions & 4 deletions packages/agent-core-v2/src/_base/di/instantiation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core-v2/src/features/feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 {
Expand Down
26 changes: 25 additions & 1 deletion packages/agent-core-v2/src/features/featureRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
/**
* `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
* container — so feature modules stay importable in any bootstrap order.
*/

import type { ServiceClassRecipe } from '#/_base/di/fiber';
import type { ServiceIdentifier } from '#/_base/di/instantiation';

const _featureRecipes: ServiceClassRecipe[] = [];

Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion packages/kap-server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 10 additions & 6 deletions packages/kap-server/src/transport/channelRegistry.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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';
Expand Down Expand Up @@ -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
);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/kap-server/src/transport/serviceDispatcherRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/kap-server/test/rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
IAppendLogStore,
IDebugEventsService,
IEventService,
IInstantiationService,
IPluginService,
ISessionIndex,
ISessionMetadata,
Expand Down Expand Up @@ -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',
Expand Down
Loading