diff --git a/docs/proposals/web-mcp-shadow-envs.md b/docs/proposals/web-mcp-shadow-envs.md index 3ce1fc7..4b52fe6 100644 --- a/docs/proposals/web-mcp-shadow-envs.md +++ b/docs/proposals/web-mcp-shadow-envs.md @@ -649,7 +649,7 @@ Behaviour: - Rejects with what `registerTool()` rejected with -- a `NotAllowedError` under a Permissions Policy that disables `tools` is the caller's business to handle, and swallowing it would hide a deployment mistake. - Registers every tool with one internal `AbortController` whose signal is chained to `options.signal`; `dispose()` aborts it. Registration is all-or-nothing: a rejection midway aborts the controller so that the tools already registered are taken back. - A second call while the first handle is live registers a second, independent set; with the same prefix `registerTool()` rejects on the duplicate name, and the rejection is passed through. The first set stays intact. The docs say to keep one handle. -- *As built, 2026-09-06:* one registration per model context and prefix, shared -- every call and every `` is a share of it, the first opens it, a later one joins instead of failing on the duplicate name, `dispose()` takes one share back and the tools leave with the last. `namespaces` (`NamespaceRule`) is the new option; the tools see the union of the shares' namespaces and apply the union of their `redactProps`, both read at the start of every call. `limits` and `exposedTo` are the opener's. +- *As built, 2026-09-06:* one registration per model context and prefix, shared -- every call and every `` is a share of it, the first opens it, a later one joins instead of failing on the duplicate name, `dispose()` takes one share back and the tools leave with the last. `namespaces` (`NamespaceRule`) is the new option; the tools see the union of the shares' namespaces and apply the union of their `redactProps`, both read at the start of every call. `limits` and `exposedTo` are the opener's. *Amended 2026-10-01:* the join compares only what a share actually passed -- a share that leaves `limits` and `exposedTo` out, as every `` does, accepts what is in effect, and only a value of its own that differs is reported. - *As built:* a `signal` already aborted at call time registers nothing and resolves `{available: true, tools: []}`; a signal that aborts midway stops the loop the same way. `dispose()` also removes the listener from the caller's signal. A successful registration logs one `info` line with the count, a rejected one logs one `error` line before the rejection is passed through. `redactProps` is the one knob this proposal adds for privacy; §12 explains why it is an option and not a default. *As built* it covers property values on both halves of the snapshot and nothing else: an Entity Context value is not redacted, and the `routes` flag of a redacted property stays. The reference and the best-practices page say so. Alongside the function, the subpath exports `DefaultToolPrefix`, `findModelContext()`, `isModelContextLike()` and the types `RedactRule`, `EnvSummary`, `EntityMatchEntry`, `FindEntitiesEntry` and `RegistryEntry` -- the per-tool output entries an agent-side consumer types against. diff --git a/packages/shadow-objects/CHANGELOG.md b/packages/shadow-objects/CHANGELOG.md index 859c472..fe9325b 100644 --- a/packages/shadow-objects/CHANGELOG.md +++ b/packages/shadow-objects/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > Build-system, monorepo, lint/format, and dev-workflow changes that don't affect the shipped package are tracked in the top-level [`CHANGELOG.md`](https://github.com/spearwolf/shadow-objects/blob/main/CHANGELOG.md). +## [Unreleased] + +### Bugfixes + +- **Fixed (model-context):** a `` that joins a registration opened with `limits` or `exposedTo` no longer reports `the tools under "shae-" are already registered with other limits or exposedTo`. The join compared the settings by value, and `exposeShadowEnvsToModelContext()` normalised a missing `limits` to `{}` before it, so a share that asked for nothing read as a conflict with whatever the opener had set — once per exposing element, and with no way for the element to opt out. A setting left out now accepts what the registration runs with; only a value the caller actually passed and that differs from the opener's is reported, and the report's `ignored` payload names just that. The opener's semantics are unchanged. Documented in `docs/api-reference.md` and `docs/best-practices.md`. + ## [0.35.2] - 2026-10-01 > **Patch, and nothing breaks.** One bugfix and no new API — an application written against diff --git a/packages/shadow-objects/docs/api-reference.md b/packages/shadow-objects/docs/api-reference.md index bcb1097..d448f81 100644 --- a/packages/shadow-objects/docs/api-reference.md +++ b/packages/shadow-objects/docs/api-reference.md @@ -1986,7 +1986,7 @@ A subpath rather than an `index.ts` export, so that the module stays out of the The promise resolves with `{available: false, tools: [], dispose}` where the platform has no model context -- a worker, Node, a browser without WebMCP, a plain `http://` origin outside `localhost` -- and logs one `info` line through a `ConsoleLogger` named `ModelContext`. It rejects with what `registerTool()` rejected with, a `NotAllowedError` under a Permissions Policy that disables `tools` among them. Registration is all-or-nothing -- a rejection midway takes back what was registered before it, and the next call opens afresh. -**One registration per model context and prefix, shared.** Every call of this function and every [``](#shae-worker) is a *share* of it: the first share opens the registration, a later one joins without registering anything, and the tools leave with the last share. The tools answer from the union over the shares -- the namespaces every share exposes, the properties every share hides -- read at the start of every call, so a share joining or leaving is seen by the next call. `limits` and `exposedTo` are the opener's: a later share with other values is reported through the `ModelContext` logger and joins under the opener's. Another prefix or another model context is another registration. +**One registration per model context and prefix, shared.** Every call of this function and every [``](#shae-worker) is a *share* of it: the first share opens the registration, a later one joins without registering anything, and the tools leave with the last share. The tools answer from the union over the shares -- the namespaces every share exposes, the properties every share hides -- read at the start of every call, so a share joining or leaving is seen by the next call. `limits` and `exposedTo` are the opener's: a later share that asks for other values is reported through the `ModelContext` logger and joins under the opener's, while a share that leaves them out -- `` never sets them -- accepts what is in effect, without a report. Another prefix or another model context is another registration. `ExposeHandle` carries `available`, `tools` (the names of the shared registration, with the prefix) and `dispose()`, which takes this share back and is idempotent. diff --git a/packages/shadow-objects/docs/best-practices.md b/packages/shadow-objects/docs/best-practices.md index 534123d..ed7954c 100644 --- a/packages/shadow-objects/docs/best-practices.md +++ b/packages/shadow-objects/docs/best-practices.md @@ -513,6 +513,6 @@ if (import.meta.env.DEV) { **Name the properties that must not leave the page.** `redactProps` replaces their values by `{$type: 'redacted'}` in every answer, on the Kernel's and the View's side. It takes a list of names or a predicate over name and Entity uuid. There is no default list on purpose: a guess would suggest a coverage it cannot have, and a property that carries a secret in one application is harmless in the next. Entity Context values are not covered; a secret that travels as a context is a secret the agent sees. -**Shares add up.** A second call under the same prefix joins the registration of the first: one set of tools, the union of both calls' `namespaces` and `redactProps`, and the tools stay until the last share -- a call's `dispose()`, or the last `` leaving -- is gone. `limits` and `exposedTo` are the opener's; a later call with other values is reported and joins under them. A page that opens the tools on a route should still close its share on leaving it. +**Shares add up.** A second call under the same prefix joins the registration of the first: one set of tools, the union of both calls' `namespaces` and `redactProps`, and the tools stay until the last share -- a call's `dispose()`, or the last `` leaving -- is gone. `limits` and `exposedTo` are the opener's; a later call that passes other values is reported and joins under them, and a call -- or an element -- that passes neither accepts what is in effect, so setting the limits once and letting the elements join stays quiet. A page that opens the tools on a route should still close its share on leaving it. **Set `limits` for the agent, not for yourself.** An agent's context window is the budget. The defaults -- depth 4, 250 nodes, values cut at depth 3 -- are conservative; `shae-find-entities` exists so that a search does not ship the tree, and `shae-list-envs` carries counts only. Loosen them for a small scene, tighten them for a large one; the agent's own input wins over them field by field. diff --git a/packages/shadow-objects/src/elements/ShaeWorkerElement.modelContext.spec.ts b/packages/shadow-objects/src/elements/ShaeWorkerElement.modelContext.spec.ts index c972192..f03c7d1 100644 --- a/packages/shadow-objects/src/elements/ShaeWorkerElement.modelContext.spec.ts +++ b/packages/shadow-objects/src/elements/ShaeWorkerElement.modelContext.spec.ts @@ -183,4 +183,30 @@ describe('', () => { const el = mount(`ns="${nextNs()}" ${ATTR_EXPOSE_TO_MODEL_CONTEXT}`); expect(await el.modelContextExposure).toMatchObject({available: false, tools: []}); }); + + it('joins a registration that was opened with limits and exposedTo, without a warning', async () => { + const fake = installFakeModelContext(); + const opener = await exposeShadowEnvsToModelContext({ + namespaces: [], + limits: {include: ['shadowObjects', 'props']}, + exposedTo: ['https://agent.example'], + }); + + const warnings: unknown[][] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args); + try { + const el = mount(`ns="${nextNs()}" ${ATTR_EXPOSE_TO_MODEL_CONTEXT}`); + await el.modelContextExposure; + + expect(warnings, 'the element asks for no settings of its own').toEqual([]); + expect(fake.registrations, 'one registration, joined').toBe(5); + + opener.dispose(); + el.removeAttribute(ATTR_EXPOSE_TO_MODEL_CONTEXT); + expect(fake.tools.size).toBe(0); + } finally { + console.warn = originalWarn; + } + }); }); diff --git a/packages/shadow-objects/src/model-context/exposeShadowEnvsToModelContext.spec.ts b/packages/shadow-objects/src/model-context/exposeShadowEnvsToModelContext.spec.ts index bd4b053..77d84f5 100644 --- a/packages/shadow-objects/src/model-context/exposeShadowEnvsToModelContext.spec.ts +++ b/packages/shadow-objects/src/model-context/exposeShadowEnvsToModelContext.spec.ts @@ -404,4 +404,78 @@ describe('exposeShadowEnvsToModelContext', () => { scene.dispose(); } }); + + it('a later share that leaves limits and exposedTo out accepts what is in effect, without a warning', async () => { + const mc = fakeModelContext(); + const scene = await makeEnv('mc-accept'); + new ViewComponent('thing', {parent: scene.root, context: ComponentContext.get('mc-accept')}); + await scene.env.syncWait(); + + const warnings: unknown[][] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args); + try { + const opener = await exposeShadowEnvsToModelContext({ + modelContext: mc, + namespaces: [], + limits: {include: ['shadowObjects', 'props']}, + exposedTo: ['https://a.example'], + }); + // the shape of an element join: namespaces and redactProps, no settings of its own + const joiner = await exposeShadowEnvsToModelContext({modelContext: mc}); + + expect(warnings, 'nothing to report: the joiner asked for nothing').toEqual([]); + expect(mc.calls[0]?.exposedTo).toEqual(['https://a.example']); + const root = (await execute(mc, 'shae-get-entity-tree', {namespace: 'mc-accept'})).envs[0].kernel.roots[0]; + expect(root.props, "the opener's limits apply: props is included").toBeDefined(); + expect(root.contexts, "the opener's limits apply: contexts is not included").toBeUndefined(); + + joiner.dispose(); + opener.dispose(); + const after = await exposeShadowEnvsToModelContext({modelContext: mc}); + const fresh = (await execute(mc, 'shae-get-entity-tree', {namespace: 'mc-accept'})).envs[0].kernel.roots[0]; + expect(fresh.contexts, 'the limit left with the opener, so the joiner had not asked for it').toBeDefined(); + after.dispose(); + } finally { + console.warn = originalWarn; + scene.dispose(); + } + }); + + it('a later share reports only the settings it actually passed, and only when they differ', async () => { + const mc = fakeModelContext(); + + const warnings: unknown[][] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args); + try { + const opener = await exposeShadowEnvsToModelContext({ + modelContext: mc, + limits: {maxDepth: 0}, + exposedTo: ['https://a.example'], + }); + + const sameExposedTo = await exposeShadowEnvsToModelContext({modelContext: mc, exposedTo: ['https://a.example']}); + expect(warnings, 'the value asked for is the value in effect').toEqual([]); + + const otherExposedTo = await exposeShadowEnvsToModelContext({modelContext: mc, exposedTo: ['https://b.example']}); + expect(warnings, 'exposedTo was asked for and differs').toHaveLength(1); + expect(warnings[0]?.at(-1)).toMatchObject({ignored: {exposedTo: ['https://b.example']}}); + + const sameLimits = await exposeShadowEnvsToModelContext({modelContext: mc, limits: {maxDepth: 0}}); + expect(warnings, 'still only the one').toHaveLength(1); + + const emptyLimits = await exposeShadowEnvsToModelContext({modelContext: mc, limits: {}}); + expect(warnings, 'an explicit {} asks for no limits and differs from the opener’s').toHaveLength(2); + expect(warnings[1]?.at(-1)).toMatchObject({ignored: {limits: {}}}); + + emptyLimits.dispose(); + sameLimits.dispose(); + otherExposedTo.dispose(); + sameExposedTo.dispose(); + opener.dispose(); + } finally { + console.warn = originalWarn; + } + }); }); diff --git a/packages/shadow-objects/src/model-context/exposeShadowEnvsToModelContext.ts b/packages/shadow-objects/src/model-context/exposeShadowEnvsToModelContext.ts index e623237..4aefb71 100644 --- a/packages/shadow-objects/src/model-context/exposeShadowEnvsToModelContext.ts +++ b/packages/shadow-objects/src/model-context/exposeShadowEnvsToModelContext.ts @@ -61,7 +61,11 @@ export async function exposeShadowEnvsToModelContext(options: ExposeOptions = {} const {signal} = options; if (signal?.aborted) return {available: true, tools: [], dispose() {}}; - const settings = {limits: options.limits ?? {}, ...(options.exposedTo !== undefined ? {exposedTo: options.exposedTo} : {})}; + // what this call asks for, and nothing more: a setting left out accepts whatever the registration already runs with + const settings = { + ...(options.limits !== undefined ? {limits: options.limits} : {}), + ...(options.exposedTo !== undefined ? {exposedTo: options.exposedTo} : {}), + }; const membership = joinSharedExposure( modelContext, options.toolPrefix ?? DefaultToolPrefix, diff --git a/packages/shadow-objects/src/model-context/sharedExposure.ts b/packages/shadow-objects/src/model-context/sharedExposure.ts index 350e41d..60ad852 100644 --- a/packages/shadow-objects/src/model-context/sharedExposure.ts +++ b/packages/shadow-objects/src/model-context/sharedExposure.ts @@ -19,6 +19,9 @@ export interface ExposureSettings { exposedTo?: string[]; } +/** What one member asked for. A field left out accepts whatever the registration already has. */ +export type RequestedExposureSettings = ExposureSettings | {limits?: Partial; exposedTo?: string[]}; + export interface ExposureMembership { /** * The names the registration ended with -- empty when it was closed while it was still @@ -103,7 +106,11 @@ const registerAll = async (modelContext: ModelContextLike, ctx: ToolContext, exp return registered; }; -const open = (modelContext: ModelContextLike, prefix: string, settings: ExposureSettings): Exposure => { +const open = (modelContext: ModelContextLike, prefix: string, requested: RequestedExposureSettings): Exposure => { + const settings: ExposureSettings = { + limits: requested.limits ?? {}, + ...(requested.exposedTo !== undefined ? {exposedTo: requested.exposedTo} : {}), + }; const members = new Set(); const exposure: Exposure = { members, @@ -138,20 +145,23 @@ const close = (modelContext: ModelContextLike, prefix: string, exposure: Exposur if (byPrefix?.get(prefix) === exposure) byPrefix.delete(prefix); }; -const sameSettings = (a: ExposureSettings, b: ExposureSettings): boolean => - JSON.stringify(a.limits) === JSON.stringify(b.limits) && JSON.stringify(a.exposedTo) === JSON.stringify(b.exposedTo); +/** What the joiner asked for and the registration does not run with. A setting left out accepts what is in effect. */ +const conflicts = (inEffect: ExposureSettings, requested: RequestedExposureSettings): boolean => + (requested.limits !== undefined && JSON.stringify(requested.limits) !== JSON.stringify(inEffect.limits)) || + (requested.exposedTo !== undefined && JSON.stringify(requested.exposedTo) !== JSON.stringify(inEffect.exposedTo)); /** * Joins the registration under `prefix` on `modelContext`, opening it when there is none. The * member's rules count from the next tool call on; `settings` count only for the member that - * opens -- a later member with other values is reported and joins under the opener's, because - * `exposedTo` has gone to the platform by then and one set of `limits` is all a tool has. + * opens -- a later member that asks for other values is reported and joins under the opener's, + * because `exposedTo` has gone to the platform by then and one set of `limits` is all a tool + * has. A setting the later member leaves out accepts what the registration already runs with. */ export function joinSharedExposure( modelContext: ModelContextLike, prefix: string, member: ExposureMember, - settings: ExposureSettings, + settings: RequestedExposureSettings, ): ExposureMembership { let byPrefix = registry.get(modelContext); if (byPrefix === undefined) { @@ -163,7 +173,7 @@ export function joinSharedExposure( if (exposure === undefined) { exposure = open(modelContext, prefix, settings); byPrefix.set(prefix, exposure); - } else if (!sameSettings(exposure.settings, settings)) { + } else if (conflicts(exposure.settings, settings)) { logger.warn( `the tools under "${prefix}" are already registered with other limits or exposedTo; the values of the first call apply`, {inEffect: exposure.settings, ignored: settings},