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:
imccyu
2026-07-09 19:30:49 +08:00
parent db857f62c0
commit f1e54d737b
2 changed files with 49 additions and 4 deletions

View File

@@ -9,8 +9,9 @@
* 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,
* call an injected service (timers included) — so the façade exposes only those
* verbs and the injected services, each individually wrapped. Every framework
* plumbing member (`root`, `parent`, `scope`, `fiber`, `reflect`, `registry`,
* verbs and the injected services, each object-valued service individually
* 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
* 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
@@ -284,11 +285,17 @@ function sandboxContext(ctx: Context): Context {
// error; a DECLARED one resolves to the guarded service. A declared inject
// is required in cordis (the fiber only activates once every declared
// 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 => {
if (name === 'tools') return tools
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)
return new Proxy({}, {

View File

@@ -91,6 +91,44 @@ describe('cross-mount provide/inject', () => {
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 () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1