fix(typert): harden remote reflection boundaries
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user