fix(tool-cordis): pass primitive provided service values through the façade
cordis provide() accepts any value and cross-mount composition advertises
ctx.provide('name', value), but the façade's readService unconditionally
proxied every declared service — new Proxy('42') throws "Cannot create proxy
with a non-object as target or handler", so a consumer of a primitive-valued
service crashed on first read with an error naming neither the service nor
the fix. A primitive or null value now passes through unwrapped (after the
denyContext check); only object- and function-valued services are proxied —
a primitive has no method that could hand back a Context, so nothing is lost.
New cross-mount spec pins both read paths (ctx.<name> and ctx.get) for a
number and a null provided value.
This commit is contained in:
@@ -9,8 +9,9 @@
|
|||||||
* The façade is a WHITELIST, not a pass-through proxy. Mount code needs to do
|
* The façade is a WHITELIST, not a pass-through proxy. Mount code needs to do
|
||||||
* exactly four things — register a tool, listen to an event, provide a service,
|
* exactly four things — register a tool, listen to an event, provide a service,
|
||||||
* call an injected service (timers included) — so the façade exposes only those
|
* call an injected service (timers included) — so the façade exposes only those
|
||||||
* verbs and the injected services, each individually wrapped. Every framework
|
* verbs and the injected services, each object-valued service individually
|
||||||
* plumbing member (`root`, `parent`, `scope`, `fiber`, `reflect`, `registry`,
|
* wrapped (a primitive provided value passes through as-is — see
|
||||||
|
* {@link sandboxContext}). Every framework plumbing member (`root`, `parent`, `scope`, `fiber`, `reflect`, `registry`,
|
||||||
* `events`, `extend`, `isolate`, `intercept`, `plugin`, `set`, `mixin`, …) is
|
* `events`, `extend`, `isolate`, `intercept`, `plugin`, `set`, `mixin`, …) is
|
||||||
* DENIED with a teaching error rather than passed through. This closes an
|
* DENIED with a teaching error rather than passed through. This closes an
|
||||||
* entire escape class at once: a pass-through proxy that only special-cased
|
* entire escape class at once: a pass-through proxy that only special-cased
|
||||||
@@ -284,11 +285,17 @@ function sandboxContext(ctx: Context): Context {
|
|||||||
// error; a DECLARED one resolves to the guarded service. A declared inject
|
// error; a DECLARED one resolves to the guarded service. A declared inject
|
||||||
// is required in cordis (the fiber only activates once every declared
|
// is required in cordis (the fiber only activates once every declared
|
||||||
// service is live), so at `apply`/`execute` time `ctx.get(name)` is present
|
// service is live), so at `apply`/`execute` time `ctx.get(name)` is present
|
||||||
// for a declared name — no undefined case to handle here.
|
// for a declared name — no undefined case to handle here. `provide()`
|
||||||
|
// accepts ANY value though (cross-mount composition advertises
|
||||||
|
// `ctx.provide('name', value)`), so a primitive or null value passes
|
||||||
|
// through unwrapped: Proxy throws on a non-object target, and only an
|
||||||
|
// object can carry a method that hands back a Context.
|
||||||
const readService = (name: string): unknown => {
|
const readService = (name: string): unknown => {
|
||||||
if (name === 'tools') return tools
|
if (name === 'tools') return tools
|
||||||
if (!declared.has(name)) return denyRead(name)
|
if (!declared.has(name)) return denyRead(name)
|
||||||
return guardedService(ctx.get(name) as object, name)
|
const service = denyContext(ctx.get(name), name)
|
||||||
|
if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
|
||||||
|
return guardedService(service, name)
|
||||||
}
|
}
|
||||||
const get = (name: string): unknown => readService(name)
|
const get = (name: string): unknown => readService(name)
|
||||||
return new Proxy({}, {
|
return new Proxy({}, {
|
||||||
|
|||||||
@@ -91,6 +91,44 @@ describe('cross-mount provide/inject', () => {
|
|||||||
expect(api).toContain('- greeter (provided by greeter-provider, no catalog entry)')
|
expect(api).toContain('- greeter (provided by greeter-provider, no catalog entry)')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('a primitive (or null) provided value passes through the façade unwrapped, on both read paths', async () => {
|
||||||
|
const ctx = await setup()
|
||||||
|
const provider = await call(ctx, 'cordis_mount', {
|
||||||
|
code: `
|
||||||
|
return {
|
||||||
|
name: 'answer-provider',
|
||||||
|
apply(ctx) {
|
||||||
|
ctx.provide('answer', 42)
|
||||||
|
ctx.provide('nothing', null)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
expect(provider.isError).toBe(false)
|
||||||
|
|
||||||
|
const consumer = await call(ctx, 'cordis_mount', {
|
||||||
|
code: `
|
||||||
|
return {
|
||||||
|
name: 'answer-consumer',
|
||||||
|
inject: ['answer', 'nothing', 'tools'],
|
||||||
|
apply(ctx) {
|
||||||
|
harness.registerTool(ctx, harness.defineTool({
|
||||||
|
name: 'answer',
|
||||||
|
description: 'Read the provided primitive services.',
|
||||||
|
parameters: {},
|
||||||
|
async execute() {
|
||||||
|
return [{ type: 'text', text: ctx.answer + '/' + ctx.get('answer') + '/' + ctx.nothing }]
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
expect(consumer.isError).toBe(false)
|
||||||
|
expect(text(consumer)).toContain('state: active')
|
||||||
|
expect(text(await call(ctx, 'answer', {}))).toBe('42/42/null')
|
||||||
|
})
|
||||||
|
|
||||||
it('unmounting the consumer leaves the provider and its service intact', async () => {
|
it('unmounting the consumer leaves the provider and its service intact', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
|
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
|
||||||
|
|||||||
Reference in New Issue
Block a user