diff --git a/.changeset/cloud-arm-host-marketplace-precedence.md b/.changeset/cloud-arm-host-marketplace-precedence.md new file mode 100644 index 0000000000..4279245eb5 --- /dev/null +++ b/.changeset/cloud-arm-host-marketplace-precedence.md @@ -0,0 +1,9 @@ +--- +'@objectstack/cli': patch +--- + +`serve`: the cloud-connected marketplace arm now leaves a host config's own marketplace and cloud plugins alone. + +`objectstack serve` auto-wires `MarketplaceProxyPlugin`, `MarketplaceInstallLocalPlugin`, the same-origin cloud-connection surface and `RuntimeConfigPlugin` whenever a cloud URL resolves. Each of those four mounts is now guarded on whether the loaded host config already wired that surface — the same presence check the offline arm has carried since the install-local fix — so CLI auto-wiring is a fallback for hosts that wire nothing rather than a second opinion about a surface the host already composed. + +No behaviour changes for any current deployment: `Kernel.use()` keys plugins by `plugin.name` and the host's registration runs after the CLI's, so the host's instance already won by ordering. What changes is that it now wins by rule instead of by the relative position of two blocks that never referenced each other, and the CLI stops constructing four plugins it was about to discard. It becomes visible the moment a host passes an argument the CLI cannot — a private control plane, a custom install `storageDir`, a credential path, white-label branding. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 85edac7b72..0900264f7d 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -412,6 +412,35 @@ export default class Serve extends Command { 'RuntimeConfigPlugin', ]; + /** + * Identities of the marketplace BROWSE surface (`MarketplaceProxyPlugin`), + * matched EXACTLY by {@link Serve.providesCapability}. + * + * Cloud-arm counterpart of {@link Serve.INSTALL_LOCAL_IDENTITIES} (#8357). + * The host's instance carries its own `controlPlaneUrl`, its public-snapshot + * base URL and its LRU cache tuning — none of which the CLI's auto-wiring + * can know, because it constructs from `resolveCloudUrl()` alone. + */ + static readonly MARKETPLACE_PROXY_IDENTITIES: readonly string[] = [ + 'com.objectstack.runtime.marketplace-proxy', + 'MarketplaceProxyPlugin', + ]; + + /** + * Identities of the same-origin cloud-connection surface + * (`CloudConnectionPlugin`, built by `createCloudConnectionPlugin`), matched + * EXACTLY by {@link Serve.providesCapability}. + * + * Both spellings matter here for a reason the other three do not have: the + * host reaches this plugin through a FACTORY, so the only class name in + * play is the one the factory returns. `createCloudConnectionPlugin` is not + * an identity — a factory function is not what lands in the kernel. + */ + static readonly CLOUD_CONNECTION_IDENTITIES: readonly string[] = [ + 'com.objectstack.cloud.connection', + 'CloudConnectionPlugin', + ]; + /** * Constructor options for the `RuntimeConfigPlugin` the marketplace wiring * mounts — ONE object, shared by both arms on purpose (#8389). @@ -466,8 +495,11 @@ export default class Serve extends Command { * The two arms are deliberately asymmetric, because the two surfaces need * different things: * - * - `cloudSurfaces` — proxy + cloud-connection + runtime-config. These - * *are* the control plane's client, so a resolved URL is their precondition. + * - `cloudSurfaces` — the ARM SELECTOR, not a mount decision: true when this + * boot takes the cloud-connected arm at all (proxy + install-local + + * cloud-connection + runtime-config). Those surfaces *are* the control + * plane's client, so a resolved URL is their precondition. WHICH of them + * the CLI actually mounts is carried by the four `cloud*` flags below. * - `offlineInstallLocal` — the air-gapped install surface. Its inline * branch reads no URL at all, so a control plane is precisely what it * does NOT need; gating it on one is what left a self-hosted EE box with @@ -493,20 +525,74 @@ export default class Serve extends Command { * skip the dynamic import) so this function is the whole rule in one place: * the cloud distribution wires its own marketplace on the host kernel, so * NO arm mounts there. + * + * ## The cloud arm honours the host too (#8357) + * + * Every mount on BOTH arms is now per-surface, under the same + * `providesCapability` rule: the CLI's auto-wiring is a FALLBACK for hosts + * that wire nothing, never a second opinion about a surface the host already + * composed. `objectos-ee`'s single-environment config is exactly such a host + * — it wires proxy, install-local, cloud-connection and runtime-config + * itself — and it is NOT covered by the `isRuntimeHostKernel` guard above, + * which detects `ObjectOSEnvironmentPlugin`: only the `OS_MULTI_TENANT` + * branch constructs one, via `createObjectOSStack`. Hanging this rule off + * that sentinel would leave the shipped single-environment shape unguarded. + * + * What this fixes is PRECEDENCE, not a live downgrade — say it plainly, + * because the two read alike and only one is true. Measured on this tree: + * the CLI's wiring block runs several hundred lines BEFORE `config.plugins` + * are registered, and `Kernel.use` -> `this.plugins.set(name, meta)` + * overwrites by name, so today the host's instance is the one that survives + * — the CLI's is constructed, registered and then dropped. The host winning + * is therefore an ACCIDENT OF ORDERING between two blocks that never mention + * each other, not a rule anything states or pins; it inverts silently if + * either block moves, and on a kernel whose `use` rejects duplicates + * (`LiteKernel` throws) it is the HOST's registration that fails instead. + * Checking presence makes the outcome independent of all of that, and stops + * the CLI constructing four plugins it is about to discard. */ static planMarketplaceWiring(input: { isRuntimeHostKernel: boolean; marketplaceUrl: string; plugins: readonly unknown[]; - }): { cloudSurfaces: boolean; offlineInstallLocal: boolean; offlineRuntimeConfig: boolean } { + }): { + cloudSurfaces: boolean; + cloudProxy: boolean; + cloudInstallLocal: boolean; + cloudConnection: boolean; + cloudRuntimeConfig: boolean; + offlineInstallLocal: boolean; + offlineRuntimeConfig: boolean; + } { + const NO_CLOUD_ARM = { + cloudSurfaces: false, + cloudProxy: false, + cloudInstallLocal: false, + cloudConnection: false, + cloudRuntimeConfig: false, + } as const; + if (input.isRuntimeHostKernel) { - return { cloudSurfaces: false, offlineInstallLocal: false, offlineRuntimeConfig: false }; + return { ...NO_CLOUD_ARM, offlineInstallLocal: false, offlineRuntimeConfig: false }; } if (input.marketplaceUrl) { - return { cloudSurfaces: true, offlineInstallLocal: false, offlineRuntimeConfig: false }; + return { + cloudSurfaces: true, + // Each surface is guarded on its OWN presence, never on a shared gate: + // a host may compose any subset of the four (objectos-ee wires + // runtime-config unconditionally but the other three only when it has + // a resolved cloud URL), and one gate would either overwrite what the + // host did wire or withhold what it did not. + cloudProxy: !Serve.providesCapability(input.plugins, Serve.MARKETPLACE_PROXY_IDENTITIES), + cloudInstallLocal: !Serve.providesCapability(input.plugins, Serve.INSTALL_LOCAL_IDENTITIES), + cloudConnection: !Serve.providesCapability(input.plugins, Serve.CLOUD_CONNECTION_IDENTITIES), + cloudRuntimeConfig: !Serve.providesCapability(input.plugins, Serve.RUNTIME_CONFIG_IDENTITIES), + offlineInstallLocal: false, + offlineRuntimeConfig: false, + }; } return { - cloudSurfaces: false, + ...NO_CLOUD_ARM, // A host config that wires its own install-local keeps it — see the // call site for why replacing it would be a silent downgrade. offlineInstallLocal: !Serve.providesCapability(input.plugins, Serve.INSTALL_LOCAL_IDENTITIES), @@ -1967,15 +2053,43 @@ export default class Serve extends Command { const marketplaceUrl = resolveCloudUrl(); const wiring = Serve.planMarketplaceWiring({ isRuntimeHostKernel, marketplaceUrl, plugins }); if (wiring.cloudSurfaces) { - await kernel.use(new MarketplaceProxyPlugin({ controlPlaneUrl: marketplaceUrl })); - await kernel.use(new MarketplaceInstallLocalPlugin({ controlPlaneUrl: marketplaceUrl })); + // Every mount here is guarded on what the HOST already wired + // (#8357), the same rule and the same idiom the offline arm below + // uses. `kernel.use` keys by name, so an unguarded mount is not a + // harmless double-mount: one of the two instances is discarded, + // and WHICH one depends on registration order rather than on any + // stated rule. The host composed its instance deliberately, with + // arguments the CLI cannot reconstruct from `resolveCloudUrl()` + // alone — a distinct control plane, a custom install storageDir, a + // credential path, cache tuning — so the host's is the one that + // must stand. See `planMarketplaceWiring` for the measurement. + let mountedAny = false; + if (wiring.cloudProxy) { + await kernel.use(new MarketplaceProxyPlugin({ controlPlaneUrl: marketplaceUrl })); + mountedAny = true; + } + if (wiring.cloudInstallLocal) { + await kernel.use(new MarketplaceInstallLocalPlugin({ controlPlaneUrl: marketplaceUrl })); + mountedAny = true; + } // Same-origin /cloud-connection/* surface (status + device-code // bind + control-plane catalog views) in single-environment mode. - await kernel.use(createCloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: marketplaceUrl })); + if (wiring.cloudConnection) { + await kernel.use(createCloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: marketplaceUrl })); + mountedAny = true; + } // Server-pushed runtime config so the Console knows marketplace + // install-local are live (same-origin; install into THIS kernel). - await kernel.use(new RuntimeConfigPlugin({ ...Serve.RUNTIME_CONFIG_OPTIONS })); - trackPlugin('Marketplace'); + if (wiring.cloudRuntimeConfig) { + await kernel.use(new RuntimeConfigPlugin({ ...Serve.RUNTIME_CONFIG_OPTIONS })); + mountedAny = true; + } + // Report the banner line only when this block actually mounted + // something. A host that wires the whole set gets no entry from + // here — it will report its own plugins through the config-plugin + // loader — and an unconditional `trackPlugin` would otherwise + // credit the CLI with a mount it did not make. + if (mountedAny) trackPlugin('Marketplace'); } else if (wiring.offlineInstallLocal || wiring.offlineRuntimeConfig) { // Cloud explicitly disabled -> mount the OFFLINE surfaces only: // the install route, and the runtime config that makes it diff --git a/packages/cli/test/serve-marketplace-cloud-host-precedence.test.ts b/packages/cli/test/serve-marketplace-cloud-host-precedence.test.ts new file mode 100644 index 0000000000..037751b6e3 --- /dev/null +++ b/packages/cli/test/serve-marketplace-cloud-host-precedence.test.ts @@ -0,0 +1,425 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8357 — the CLOUD-CONNECTED marketplace arm must leave a host config's own + * marketplace / cloud plugins alone, the same way #8343 taught the offline arm + * to. + * + * ## What was measured before writing these tests + * + * The card describes the CLI's instance REPLACING the host's. On this tree it + * is the other way round, and saying so is the point of these tests rather + * than an aside: + * + * - `ObjectKernel.use()` is `this.plugins.set(pluginMeta.name, meta)` — a + * Map keyed by `plugin.name`. No error, no dedupe, last write wins. + * - The CLI's marketplace block runs several hundred lines BEFORE the loop + * that registers `config.plugins`. So today the HOST's instance is the + * last write and the host wins; the CLI's four instances are constructed, + * registered, and then dropped. + * + * That makes this a PRECEDENCE fix, not a live-bug fix — there is no measured + * user impact today, and both sides currently construct their instances from + * the same `resolveCloudUrl()` value anyway. What the guard buys is that the + * host winning stops being an accident of where two blocks sit relative to + * each other, in a file where neither block mentions the other. The last + * `describe` pins exactly that, and is the only assertion here that would + * behave differently under the two orders. + * + * ## Why the fixtures are built to be DISTINGUISHABLE + * + * A test that asks "does the app still work?" passes before and after the fix + * and proves nothing, because two identically-constructed instances are + * interchangeable. Every host fixture below is therefore constructed with + * something the CLI's auto-wiring cannot pass — a private control plane, a + * custom install `storageDir`, a credential path, white-label branding — and + * each case asserts the SURVIVOR carries the host's value. + */ + +import { describe, expect, it } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { + MarketplaceProxyPlugin, + MarketplaceInstallLocalPlugin, + RuntimeConfigPlugin, + createCloudConnectionPlugin, +} from '@objectstack/cloud-connection'; +import Serve from '../src/commands/serve.js'; + +/** A resolved cloud URL — the state that selects the cloud-connected arm. */ +const CLOUD_URL = 'https://cloud.objectos.ai'; + +/** + * The control plane a HOST would point at and the CLI never could: the CLI + * constructs from `resolveCloudUrl()` alone, so any value that is not the + * resolved `OS_CLOUD_URL` can only have come from the host config. + */ +const HOST_CONTROL_PLANE = 'https://control-plane.internal.example'; + +/** Minimal stand-in for a loaded plugin: what the resolver actually reads. */ +function plugin(name: string, ctorName: string): { name: string } { + const Ctor = { [ctorName]: class { name: string; constructor(n: string) { this.name = n; } } }[ctorName]!; + return new Ctor(name) as { name: string }; +} + +/** The host-kernel signal the cloud distribution is detected by. */ +const OBJECTOS_ENVIRONMENT = plugin( + 'com.objectstack.runtime.objectos-environment', + 'ObjectOSEnvironmentPlugin', +); + +/** + * Read a value a plugin was CONSTRUCTED with, past TypeScript's `private`. + * + * Deliberate, and load-bearing: `private` is erased at runtime, and these + * constructor arguments are the only thing that distinguishes the host's + * instance from the CLI's. Nothing on the public surface exposes them without + * booting a kernel and issuing HTTP requests. Every use is paired with the + * `not.toBe` precondition below, so if a field is ever renamed both readings + * collapse to `undefined` and the precondition fails loudly — "this test no + * longer measures anything" rather than a silent pass. + */ +function constructedWith(plugin: unknown, field: string): T | undefined { + return (plugin as Record | null | undefined)?.[field]; +} + +/** + * The plugin instances an `ObjectKernel` has registered, keyed by name. + * + * `ObjectKernel` publishes `hasPlugin(name)` but nothing that hands back the + * registered INSTANCE — and which instance survived is the entire question + * here. Same justification as {@link constructedWith}, and every read below is + * paired with the public `hasPlugin`, so if this field is ever renamed the + * pairing fails loudly instead of quietly reporting "not registered". + */ +function registeredPlugins(kernel: ObjectKernel): Map { + return (kernel as unknown as { plugins: Map }).plugins; +} + +/** + * The four surfaces the cloud-connected arm mounts, each with: + * - the host's instance, built with an argument the CLI cannot produce, + * - the CLI's instance, built EXACTLY as `serve.ts` builds it, + * - the constructor-derived field that tells the two apart. + * + * Keep the `cli` factories in step with the mounts in `serve.ts`; they are the + * same expressions on purpose. + */ +const SURFACES = [ + { + label: 'marketplace browse proxy', + flag: 'cloudProxy', + pluginName: 'com.objectstack.runtime.marketplace-proxy', + identities: () => Serve.MARKETPLACE_PROXY_IDENTITIES, + field: 'cloudUrl', + host: () => new MarketplaceProxyPlugin({ + controlPlaneUrl: HOST_CONTROL_PLANE, + cacheMaxEntries: 4096, + }), + cli: () => new MarketplaceProxyPlugin({ controlPlaneUrl: CLOUD_URL }), + }, + { + label: 'install-local', + flag: 'cloudInstallLocal', + pluginName: 'com.objectstack.runtime.marketplace-install-local', + identities: () => Serve.INSTALL_LOCAL_IDENTITIES, + field: 'cloudUrl', + host: () => new MarketplaceInstallLocalPlugin({ + controlPlaneUrl: HOST_CONTROL_PLANE, + storageDir: '/srv/objectos/installed-packages', + }), + cli: () => new MarketplaceInstallLocalPlugin({ controlPlaneUrl: CLOUD_URL }), + }, + { + label: 'cloud-connection', + flag: 'cloudConnection', + pluginName: 'com.objectstack.cloud.connection', + identities: () => Serve.CLOUD_CONNECTION_IDENTITIES, + field: 'cfg', + host: () => createCloudConnectionPlugin({ + singleEnvironment: true, + controlPlaneUrl: HOST_CONTROL_PLANE, + credentialPath: '/srv/objectos/cloud-connection.json', + }), + cli: () => createCloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: CLOUD_URL }), + }, + { + label: 'runtime-config', + flag: 'cloudRuntimeConfig', + pluginName: 'com.objectstack.runtime.runtime-config', + identities: () => Serve.RUNTIME_CONFIG_IDENTITIES, + field: 'productName', + host: () => new RuntimeConfigPlugin({ + ...Serve.RUNTIME_CONFIG_OPTIONS, + productName: 'Contoso Operations', + }), + cli: () => new RuntimeConfigPlugin({ ...Serve.RUNTIME_CONFIG_OPTIONS }), + }, +] as const; + +type CloudFlag = (typeof SURFACES)[number]['flag']; + +const ALL_CLOUD_FLAGS: readonly CloudFlag[] = SURFACES.map((s) => s.flag); + +/** + * The whole set the objectos-ee single-environment config wires itself — the + * host shape this card is about. Note what is NOT here: an + * `ObjectOSEnvironmentPlugin`. Only the `OS_MULTI_TENANT` branch constructs + * one (via `createObjectOSStack`), so the `isRuntimeHostKernel` guard never + * fires for the shipped single-environment shape, and a rule hung off that + * sentinel would not fire for the exact host it was written for. + */ +function eeSingleEnvironmentHostPlugins(): unknown[] { + return SURFACES.map((s) => s.host()); +} + +describe('#8357: the cloud-connected arm mounts only what the host did NOT wire', () => { + it('a host that wires nothing still gets the full set — no capability traded away', () => { + const wiring = Serve.planMarketplaceWiring({ + isRuntimeHostKernel: false, + marketplaceUrl: CLOUD_URL, + plugins: [], + }); + + expect(wiring.cloudSurfaces, 'a resolved cloud URL still selects the cloud arm').toBe(true); + for (const flag of ALL_CLOUD_FLAGS) { + expect(wiring[flag], `${flag} for a host that wires nothing`).toBe(true); + } + }); + + for (const surface of SURFACES) { + it(`a host that wires its own ${surface.label} keeps it — and only it is skipped`, () => { + const wiring = Serve.planMarketplaceWiring({ + isRuntimeHostKernel: false, + marketplaceUrl: CLOUD_URL, + plugins: [surface.host()], + }); + + expect(wiring.cloudSurfaces).toBe(true); + expect(wiring[surface.flag], `${surface.flag} must NOT be mounted by the CLI`).toBe(false); + + // The other three are untouched. One shared gate would either overwrite + // what the host wired or withhold what it did not — objectos-ee wires + // runtime-config unconditionally and the other three only behind its own + // URL check, so partial host composition is the SHIPPED shape, not a + // hypothetical. + for (const other of ALL_CLOUD_FLAGS) { + if (other === surface.flag) continue; + expect(wiring[other], `${other} must still be auto-wired`).toBe(true); + } + }); + } + + it('the EE single-environment host shape keeps ALL FOUR of its own plugins', () => { + const wiring = Serve.planMarketplaceWiring({ + isRuntimeHostKernel: false, + marketplaceUrl: CLOUD_URL, + plugins: eeSingleEnvironmentHostPlugins(), + }); + + expect(wiring.cloudSurfaces, 'the arm is still selected — the URL resolved').toBe(true); + for (const flag of ALL_CLOUD_FLAGS) { + expect(wiring[flag], `${flag} for the EE single-environment host`).toBe(false); + } + }); + + it('a runtime host kernel still mounts NOTHING, cloud on or off', () => { + // The guard checked FIRST and the easy casualty of editing this block. + for (const marketplaceUrl of ['', CLOUD_URL]) { + const wiring = Serve.planMarketplaceWiring({ + isRuntimeHostKernel: true, + marketplaceUrl, + plugins: [OBJECTOS_ENVIRONMENT], + }); + + expect(wiring.cloudSurfaces, `cloudSurfaces for url='${marketplaceUrl}'`).toBe(false); + for (const flag of ALL_CLOUD_FLAGS) { + expect(wiring[flag], `${flag} for url='${marketplaceUrl}'`).toBe(false); + } + expect(wiring.offlineInstallLocal).toBe(false); + expect(wiring.offlineRuntimeConfig).toBe(false); + } + }); + + it('the OFFLINE arm is unchanged — no cloud surface leaks into a cloud-less boot', () => { + // #8343 / #8389 regression guard: adding per-surface cloud flags must not + // make any of them true on the arm that exists precisely because there is + // no control plane to talk to. + const wiring = Serve.planMarketplaceWiring({ + isRuntimeHostKernel: false, + marketplaceUrl: '', + plugins: [], + }); + + expect(wiring.cloudSurfaces).toBe(false); + for (const flag of ALL_CLOUD_FLAGS) { + expect(wiring[flag], `${flag} on the offline arm`).toBe(false); + } + expect(wiring.offlineInstallLocal).toBe(true); + expect(wiring.offlineRuntimeConfig).toBe(true); + }); +}); + +describe('#8357: the identities the cloud arm matches on are the real ones', () => { + // Same discipline as the #8343 drift test: a registry of identities that has + // drifted from the class it names fails OPEN — nothing matches, the guard + // never fires, the host's instance is silently overwritten again — and + // nothing else in the suite would notice. + for (const surface of SURFACES) { + it(`${surface.label}: registered name AND class name both match the real plugin`, () => { + const real = surface.host() as { name: string; constructor: { name: string } }; + + expect(surface.identities()).toContain(real.name); + expect(Serve.providesCapability([real], surface.identities())).toBe(true); + + // The class-name limb, compared modulo ONE leading underscore. Measured, + // not defensive: esbuild rewrites `export class X { … X.prototype … }` + // — a class that references itself by name inside its own body — into + // `var X = class _X { … _X.prototype … }`, so the BUILT class reports + // `_MarketplaceProxyPlugin`. The registry deliberately keeps the source + // spelling; pinning the bundler's is pinning an artifact. Stripping one + // underscore still catches a genuine rename, which is what this guard is + // for. See the `name`-limb test below for why the guard holds anyway. + expect(surface.identities()).toContain(real.constructor.name.replace(/^_/, '')); + }); + } + + it('the registered NAME alone satisfies every guard — the limb that survives bundling', () => { + // Load-bearing given the underscore above: against the shipped build the + // class-name limb is dead for at least one of these four, so the guard has + // to fire on `plugin.name` by itself or it does not fire at all on the + // deployments that matter. + for (const surface of SURFACES) { + expect( + Serve.providesCapability([{ name: surface.pluginName }], surface.identities()), + `${surface.label} must be recognised by its registered name alone`, + ).toBe(true); + } + }); + + it('the cloud-connection identity names the CLASS the factory returns, not the factory', () => { + // The one surface reached through a factory. `createCloudConnectionPlugin` + // is not an identity — a function is not what lands in the kernel — so the + // entry has to name `CloudConnectionPlugin`, which no call site spells out. + expect(Serve.CLOUD_CONNECTION_IDENTITIES).not.toContain('createCloudConnectionPlugin'); + expect(createCloudConnectionPlugin({}).constructor.name).toBe('CloudConnectionPlugin'); + }); + + it('an unrelated plugin does not satisfy any of the four', () => { + const bystander = plugin('com.objectstack.connector.marketplace-mirror', 'MarketplaceMirrorConnector'); + for (const surface of SURFACES) { + expect( + Serve.providesCapability([bystander], surface.identities()), + `${surface.label} must not be satisfied by a consumer named after it`, + ).toBe(false); + } + }); +}); + +describe('#8357: the host instance survives REGARDLESS of registration order', () => { + /** + * Mount the cloud arm the way `serve.ts` does — one guarded `kernel.use` + * per surface, in the same order, with the same constructor arguments. + */ + async function mountCliCloudArm( + kernel: ObjectKernel, + wiring: ReturnType, + ): Promise { + for (const surface of SURFACES) { + if (wiring[surface.flag]) await kernel.use(surface.cli() as never); + } + } + + /** + * Both orders are tested because only one of them is reachable today, and + * that is the whole point: `cli-then-host` is the order `serve.ts` really + * runs in, and under it the host already wins WITHOUT any guard — so it + * cannot tell a fixed build from a broken one. `host-then-cli` is the order + * in which the missing check bites, and the one that goes red when the guard + * is removed. Passing BOTH is the property being claimed: the host's + * composition wins because it is a rule, not because of where two blocks + * happen to sit in one 4000-line file. + */ + for (const order of ['cli-then-host', 'host-then-cli'] as const) { + it(`EE single-environment host keeps its own four instances (${order})`, async () => { + const kernel = new ObjectKernel({ gracefulShutdown: false, logger: { level: 'error' } }); + const hostPlugins = eeSingleEnvironmentHostPlugins(); + + const wiring = Serve.planMarketplaceWiring({ + isRuntimeHostKernel: false, + marketplaceUrl: CLOUD_URL, + plugins: hostPlugins, + }); + + const registerHost = async () => { + for (const p of hostPlugins) await kernel.use(p as never); + }; + + if (order === 'cli-then-host') { + await mountCliCloudArm(kernel, wiring); + await registerHost(); + } else { + await registerHost(); + await mountCliCloudArm(kernel, wiring); + } + + const registered = registeredPlugins(kernel); + + for (const [index, surface] of SURFACES.entries()) { + const hostInstance = hostPlugins[index]!; + expect(kernel.hasPlugin(surface.pluginName), `${surface.label} must be registered`).toBe(true); + const survivor = registered.get(surface.pluginName); + + // Precondition: the two instances really ARE distinguishable. Without + // this the identity assertion below could pass for the wrong reason, + // and a renamed private field would make it vacuous rather than red. + expect( + constructedWith(hostInstance, surface.field), + `${surface.label}: host fixture must differ from the CLI's instance`, + ).not.toEqual(constructedWith(surface.cli(), surface.field)); + + expect(survivor, `${surface.label}: the surviving instance must be the HOST's`).toBe(hostInstance); + expect( + constructedWith(survivor, surface.field), + `${surface.label}: the survivor must carry the host's construction argument`, + ).toEqual(constructedWith(hostInstance, surface.field)); + } + }); + } + + it('with no host wiring, the CLI still supplies all four instances', async () => { + // The other direction: the guard must not turn into "never auto-wire". + const kernel = new ObjectKernel({ gracefulShutdown: false, logger: { level: 'error' } }); + const wiring = Serve.planMarketplaceWiring({ + isRuntimeHostKernel: false, + marketplaceUrl: CLOUD_URL, + plugins: [], + }); + + await mountCliCloudArm(kernel, wiring); + + for (const surface of SURFACES) { + expect(kernel.hasPlugin(surface.pluginName), `${surface.label} must be auto-wired`).toBe(true); + } + }); + + it('THE MECHANISM — an unguarded second mount really does overwrite by name', async () => { + // The premise the whole card rests on, measured rather than quoted, and + // the reason the guard is the fix rather than "kernel.use should refuse" + // (that alternative was explicitly rejected at grading: engine-core blast + // radius, its own card). `Kernel.use` is a Map keyed by `plugin.name`: + // no error, no dedupe, last write wins — so an unguarded mount is never a + // harmless duplicate, it is a silent choice of winner made by ordering. + const kernel = new ObjectKernel({ gracefulShutdown: false, logger: { level: 'error' } }); + const host = SURFACES[0].host(); + const cli = SURFACES[0].cli(); + + await kernel.use(host as never); + await kernel.use(cli as never); + + const registered = registeredPlugins(kernel); + expect(registered.size, 'two plugins, one name, one entry').toBe(1); + expect(registered.get(SURFACES[0].pluginName)).toBe(cli); + expect(constructedWith(registered.get(SURFACES[0].pluginName), 'cloudUrl')).toBe(CLOUD_URL); + }); +});