@@ -412,6 +412,35 @@ export default class Serve extends Command {
412412 'RuntimeConfigPlugin' ,
413413 ] ;
414414
415+ /**
416+ * Identities of the marketplace BROWSE surface (`MarketplaceProxyPlugin`),
417+ * matched EXACTLY by {@link Serve.providesCapability}.
418+ *
419+ * Cloud-arm counterpart of {@link Serve.INSTALL_LOCAL_IDENTITIES} (#8357).
420+ * The host's instance carries its own `controlPlaneUrl`, its public-snapshot
421+ * base URL and its LRU cache tuning — none of which the CLI's auto-wiring
422+ * can know, because it constructs from `resolveCloudUrl()` alone.
423+ */
424+ static readonly MARKETPLACE_PROXY_IDENTITIES : readonly string [ ] = [
425+ 'com.objectstack.runtime.marketplace-proxy' ,
426+ 'MarketplaceProxyPlugin' ,
427+ ] ;
428+
429+ /**
430+ * Identities of the same-origin cloud-connection surface
431+ * (`CloudConnectionPlugin`, built by `createCloudConnectionPlugin`), matched
432+ * EXACTLY by {@link Serve.providesCapability}.
433+ *
434+ * Both spellings matter here for a reason the other three do not have: the
435+ * host reaches this plugin through a FACTORY, so the only class name in
436+ * play is the one the factory returns. `createCloudConnectionPlugin` is not
437+ * an identity — a factory function is not what lands in the kernel.
438+ */
439+ static readonly CLOUD_CONNECTION_IDENTITIES : readonly string [ ] = [
440+ 'com.objectstack.cloud.connection' ,
441+ 'CloudConnectionPlugin' ,
442+ ] ;
443+
415444 /**
416445 * Constructor options for the `RuntimeConfigPlugin` the marketplace wiring
417446 * mounts — ONE object, shared by both arms on purpose (#8389).
@@ -466,8 +495,11 @@ export default class Serve extends Command {
466495 * The two arms are deliberately asymmetric, because the two surfaces need
467496 * different things:
468497 *
469- * - `cloudSurfaces` — proxy + cloud-connection + runtime-config. These
470- * *are* the control plane's client, so a resolved URL is their precondition.
498+ * - `cloudSurfaces` — the ARM SELECTOR, not a mount decision: true when this
499+ * boot takes the cloud-connected arm at all (proxy + install-local +
500+ * cloud-connection + runtime-config). Those surfaces *are* the control
501+ * plane's client, so a resolved URL is their precondition. WHICH of them
502+ * the CLI actually mounts is carried by the four `cloud*` flags below.
471503 * - `offlineInstallLocal` — the air-gapped install surface. Its inline
472504 * branch reads no URL at all, so a control plane is precisely what it
473505 * 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 {
493525 * skip the dynamic import) so this function is the whole rule in one place:
494526 * the cloud distribution wires its own marketplace on the host kernel, so
495527 * NO arm mounts there.
528+ *
529+ * ## The cloud arm honours the host too (#8357)
530+ *
531+ * Every mount on BOTH arms is now per-surface, under the same
532+ * `providesCapability` rule: the CLI's auto-wiring is a FALLBACK for hosts
533+ * that wire nothing, never a second opinion about a surface the host already
534+ * composed. `objectos-ee`'s single-environment config is exactly such a host
535+ * — it wires proxy, install-local, cloud-connection and runtime-config
536+ * itself — and it is NOT covered by the `isRuntimeHostKernel` guard above,
537+ * which detects `ObjectOSEnvironmentPlugin`: only the `OS_MULTI_TENANT`
538+ * branch constructs one, via `createObjectOSStack`. Hanging this rule off
539+ * that sentinel would leave the shipped single-environment shape unguarded.
540+ *
541+ * What this fixes is PRECEDENCE, not a live downgrade — say it plainly,
542+ * because the two read alike and only one is true. Measured on this tree:
543+ * the CLI's wiring block runs several hundred lines BEFORE `config.plugins`
544+ * are registered, and `Kernel.use` -> `this.plugins.set(name, meta)`
545+ * overwrites by name, so today the host's instance is the one that survives
546+ * — the CLI's is constructed, registered and then dropped. The host winning
547+ * is therefore an ACCIDENT OF ORDERING between two blocks that never mention
548+ * each other, not a rule anything states or pins; it inverts silently if
549+ * either block moves, and on a kernel whose `use` rejects duplicates
550+ * (`LiteKernel` throws) it is the HOST's registration that fails instead.
551+ * Checking presence makes the outcome independent of all of that, and stops
552+ * the CLI constructing four plugins it is about to discard.
496553 */
497554 static planMarketplaceWiring ( input : {
498555 isRuntimeHostKernel : boolean ;
499556 marketplaceUrl : string ;
500557 plugins : readonly unknown [ ] ;
501- } ) : { cloudSurfaces : boolean ; offlineInstallLocal : boolean ; offlineRuntimeConfig : boolean } {
558+ } ) : {
559+ cloudSurfaces : boolean ;
560+ cloudProxy : boolean ;
561+ cloudInstallLocal : boolean ;
562+ cloudConnection : boolean ;
563+ cloudRuntimeConfig : boolean ;
564+ offlineInstallLocal : boolean ;
565+ offlineRuntimeConfig : boolean ;
566+ } {
567+ const NO_CLOUD_ARM = {
568+ cloudSurfaces : false ,
569+ cloudProxy : false ,
570+ cloudInstallLocal : false ,
571+ cloudConnection : false ,
572+ cloudRuntimeConfig : false ,
573+ } as const ;
574+
502575 if ( input . isRuntimeHostKernel ) {
503- return { cloudSurfaces : false , offlineInstallLocal : false , offlineRuntimeConfig : false } ;
576+ return { ... NO_CLOUD_ARM , offlineInstallLocal : false , offlineRuntimeConfig : false } ;
504577 }
505578 if ( input . marketplaceUrl ) {
506- return { cloudSurfaces : true , offlineInstallLocal : false , offlineRuntimeConfig : false } ;
579+ return {
580+ cloudSurfaces : true ,
581+ // Each surface is guarded on its OWN presence, never on a shared gate:
582+ // a host may compose any subset of the four (objectos-ee wires
583+ // runtime-config unconditionally but the other three only when it has
584+ // a resolved cloud URL), and one gate would either overwrite what the
585+ // host did wire or withhold what it did not.
586+ cloudProxy : ! Serve . providesCapability ( input . plugins , Serve . MARKETPLACE_PROXY_IDENTITIES ) ,
587+ cloudInstallLocal : ! Serve . providesCapability ( input . plugins , Serve . INSTALL_LOCAL_IDENTITIES ) ,
588+ cloudConnection : ! Serve . providesCapability ( input . plugins , Serve . CLOUD_CONNECTION_IDENTITIES ) ,
589+ cloudRuntimeConfig : ! Serve . providesCapability ( input . plugins , Serve . RUNTIME_CONFIG_IDENTITIES ) ,
590+ offlineInstallLocal : false ,
591+ offlineRuntimeConfig : false ,
592+ } ;
507593 }
508594 return {
509- cloudSurfaces : false ,
595+ ... NO_CLOUD_ARM ,
510596 // A host config that wires its own install-local keeps it — see the
511597 // call site for why replacing it would be a silent downgrade.
512598 offlineInstallLocal : ! Serve . providesCapability ( input . plugins , Serve . INSTALL_LOCAL_IDENTITIES ) ,
@@ -1967,15 +2053,43 @@ export default class Serve extends Command {
19672053 const marketplaceUrl = resolveCloudUrl ( ) ;
19682054 const wiring = Serve . planMarketplaceWiring ( { isRuntimeHostKernel, marketplaceUrl, plugins } ) ;
19692055 if ( wiring . cloudSurfaces ) {
1970- await kernel . use ( new MarketplaceProxyPlugin ( { controlPlaneUrl : marketplaceUrl } ) ) ;
1971- await kernel . use ( new MarketplaceInstallLocalPlugin ( { controlPlaneUrl : marketplaceUrl } ) ) ;
2056+ // Every mount here is guarded on what the HOST already wired
2057+ // (#8357), the same rule and the same idiom the offline arm below
2058+ // uses. `kernel.use` keys by name, so an unguarded mount is not a
2059+ // harmless double-mount: one of the two instances is discarded,
2060+ // and WHICH one depends on registration order rather than on any
2061+ // stated rule. The host composed its instance deliberately, with
2062+ // arguments the CLI cannot reconstruct from `resolveCloudUrl()`
2063+ // alone — a distinct control plane, a custom install storageDir, a
2064+ // credential path, cache tuning — so the host's is the one that
2065+ // must stand. See `planMarketplaceWiring` for the measurement.
2066+ let mountedAny = false ;
2067+ if ( wiring . cloudProxy ) {
2068+ await kernel . use ( new MarketplaceProxyPlugin ( { controlPlaneUrl : marketplaceUrl } ) ) ;
2069+ mountedAny = true ;
2070+ }
2071+ if ( wiring . cloudInstallLocal ) {
2072+ await kernel . use ( new MarketplaceInstallLocalPlugin ( { controlPlaneUrl : marketplaceUrl } ) ) ;
2073+ mountedAny = true ;
2074+ }
19722075 // Same-origin /cloud-connection/* surface (status + device-code
19732076 // bind + control-plane catalog views) in single-environment mode.
1974- await kernel . use ( createCloudConnectionPlugin ( { singleEnvironment : true , controlPlaneUrl : marketplaceUrl } ) ) ;
2077+ if ( wiring . cloudConnection ) {
2078+ await kernel . use ( createCloudConnectionPlugin ( { singleEnvironment : true , controlPlaneUrl : marketplaceUrl } ) ) ;
2079+ mountedAny = true ;
2080+ }
19752081 // Server-pushed runtime config so the Console knows marketplace +
19762082 // install-local are live (same-origin; install into THIS kernel).
1977- await kernel . use ( new RuntimeConfigPlugin ( { ...Serve . RUNTIME_CONFIG_OPTIONS } ) ) ;
1978- trackPlugin ( 'Marketplace' ) ;
2083+ if ( wiring . cloudRuntimeConfig ) {
2084+ await kernel . use ( new RuntimeConfigPlugin ( { ...Serve . RUNTIME_CONFIG_OPTIONS } ) ) ;
2085+ mountedAny = true ;
2086+ }
2087+ // Report the banner line only when this block actually mounted
2088+ // something. A host that wires the whole set gets no entry from
2089+ // here — it will report its own plugins through the config-plugin
2090+ // loader — and an unconditional `trackPlugin` would otherwise
2091+ // credit the CLI with a mount it did not make.
2092+ if ( mountedAny ) trackPlugin ( 'Marketplace' ) ;
19792093 } else if ( wiring . offlineInstallLocal || wiring . offlineRuntimeConfig ) {
19802094 // Cloud explicitly disabled -> mount the OFFLINE surfaces only:
19812095 // the install route, and the runtime config that makes it
0 commit comments