fix(typert): harden remote reflection boundaries

This commit is contained in:
imccyu
2026-08-06 17:49:35 +08:00
parent 88385a658e
commit 9b63d72c94
28 changed files with 813 additions and 116 deletions

View File

@@ -17,6 +17,7 @@ import type {
TypeRTHostContextProvider,
TypeRTLocalRegistry,
TypeRTLookupHost,
TypeRTLookupDefinition,
TypeRTLookupMap,
TypeRTLookupProvider,
TypeRTLookupRegistry,
@@ -212,6 +213,7 @@ class RemoteStore {
class LookupStore {
private readonly providers = new Map<string, ProviderEntry<TypeRTLookupProvider>>()
private readonly definitions = new Map<string, TypeRTLookupDefinition>()
private readonly changes: ChangeSource
constructor(report: ReportObserverError) {
@@ -228,6 +230,7 @@ class LookupStore {
>,
) => this.register(ctx, key, provider),
get: key => this.providers.get(key)?.provider,
definitions: () => [...this.definitions.values()],
keys: () => [...this.providers.keys()],
subscribe: listener => this.changes.subscribe(ctx, listener),
}
@@ -240,10 +243,22 @@ class LookupStore {
validateNonempty('lookup Host type symbol', provider.hostTypeSymbol)
validateNonempty('lookup wire type symbol', provider.wireTypeSymbol)
if (this.providers.has(key)) throw new Error(`typert: lookup "${key}" is already registered`)
const definition: TypeRTLookupDefinition = {
key,
parameter: provider.parameter,
wire: provider.wire,
hostTypeSymbol: provider.hostTypeSymbol,
wireTypeSymbol: provider.wireTypeSymbol,
}
const known = this.definitions.get(key)
if (known !== undefined && !lookupDefinitionEquals(known, definition)) {
throw new Error(`typert: lookup "${key}" changed its wire declaration during this registry lifetime`)
}
const owner = {}
const entry: ProviderEntry<TypeRTLookupProvider> = { provider, owner }
const { providers, changes } = this
const { definitions, providers, changes } = this
return ctx.effect(function* () {
definitions.set(key, definition)
providers.set(key, entry)
changes.emit({ kind: 'lookup', key })
yield () => {
@@ -256,6 +271,13 @@ class LookupStore {
}
}
function lookupDefinitionEquals(left: TypeRTLookupDefinition, right: TypeRTLookupDefinition): boolean {
return left.parameter === right.parameter
&& left.wire === right.wire
&& left.hostTypeSymbol === right.hostTypeSymbol
&& left.wireTypeSymbol === right.wireTypeSymbol
}
class ContextStore {
private readonly hosts = new Map<string, ProviderEntry<TypeRTHostContextProvider>>()
private readonly clients = new Map<string, ProviderEntry<TypeRTClientContextBinder>>()
@@ -377,7 +399,7 @@ export class TypertRegistry extends Service implements TypeRTService {
register(contribution: TypertContribution): TypeRTDisposer {
const packageRecord = this.validatePackage(contribution)
const schemaRecords = this.validateSchemas(contribution)
const invocations = contribution.invocations ?? []
const invocations = contribution.invocations
this.localStore.validate(invocations)
const owner = {}
const { schemas, packages, localStore } = this

View File

@@ -83,12 +83,7 @@ export interface TypertContribution {
readonly face: TypertFace
readonly schemas: readonly TypertSchema[]
readonly model: TypertPackageModel
/** Host invocation definitions; absent on artifacts generated before Remote support. */
readonly invocations?: readonly InvocationDescriptor[]
}
/** Generated Host contribution with strict Remote invocation definitions. */
export interface TypertLocalContribution extends TypertContribution {
/** Host invocation definitions, empty when the package exports no Remote methods. */
readonly invocations: readonly InvocationDescriptor[]
}

View File

@@ -36,6 +36,7 @@ function toolsContribution(schema: z.ZodType = z.object({ name: z.string() })):
package: '@deepseek-ai/dsh-tools',
face: 'host',
schemas: [{ name: 'ToolInput', schema }],
invocations: [],
model: {
services: [{
key: 'tools',
@@ -329,11 +330,19 @@ describe('TypertRegistry', () => {
})
expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object)
expect(ctx.typert.lookups.definitions()).toEqual([{
key: 'fixture',
parameter: 'agent',
wire: 'agentId',
hostTypeSymbol: '@fixture/agent#Agent',
wireTypeSymbol: '@fixture/session#SessionId',
}])
expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped)
expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1')
await Promise.all([disposeClient(), disposeHost(), disposeLookup()])
expect(ctx.typert.lookups.keys()).toEqual([])
expect(ctx.typert.lookups.definitions()).toHaveLength(1)
expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined()
expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined()
})
@@ -378,6 +387,15 @@ describe('TypertRegistry', () => {
])
await Promise.all([disposeLookupSubscription(), disposeContextSubscription()])
for (const changed of [
{ ...lookup, parameter: 'session' },
{ ...lookup, wire: 'sessionId' },
{ ...lookup, hostTypeSymbol: '@fixture#Session' },
{ ...lookup, wireTypeSymbol: '@fixture#SessionId' },
]) {
expect(() => ctx.typert.lookups.register('fixture', changed))
.toThrow('changed its wire declaration during this registry lifetime')
}
ctx.typert.lookups.register('fixture', lookup)
expect(changes).toHaveLength(6)
})