Skip to content

Commit 189a732

Browse files
os-zhuangclaude
andauthored
fix(cli): honour host-wired marketplace plugins on the cloud-connected serve arm (#8357) (#8646)
* fix(cli): honour host-wired marketplace plugins on the cloud-connected serve arm (#8357) `objectstack serve` auto-wires MarketplaceProxyPlugin, MarketplaceInstallLocalPlugin, the same-origin cloud-connection surface and RuntimeConfigPlugin whenever `resolveCloudUrl()` resolves. Those four mounts were unconditional: the block never asked whether the loaded host config had already wired any of them. Each mount is now guarded on its own `Serve.providesCapability` presence check -- the idiom the offline arm has carried since #8343 -- so CLI auto-wiring is a fallback for hosts that wire nothing rather than a second opinion about a surface the host already composed. Two new identity registries (MARKETPLACE_PROXY_IDENTITIES, CLOUD_CONNECTION_IDENTITIES) join the two the offline arm already declared, and `planMarketplaceWiring` reports one flag per surface so a host composing any SUBSET keeps exactly what it wired. Measured correction to the card's premise: the CLI's block runs several hundred lines BEFORE `config.plugins` are registered, and `Kernel.use` overwrites by name, so today the HOST's instance is the survivor -- the CLI's four are constructed, registered and dropped. This is therefore precedence restoration, not a live-bug fix: the host winning stops being an accident of block ordering that nothing states or pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj * test(cli): pin cloud-arm host precedence against a real ObjectKernel (#8357) Reads the surviving instance off the kernel rather than trusting the planner alone, with every host fixture constructed from an argument the CLI cannot produce so the two instances are distinguishable at all. Also records a measured gap found while writing the drift guard: esbuild rewrites a class that references itself by name into `class _X`, so the BUILT MarketplaceProxyPlugin reports `_MarketplaceProxyPlugin` and the class-name limb of the identity registry never matches it. The registered-name limb carries the guard; filed separately rather than fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7bccaa commit 189a732

3 files changed

Lines changed: 559 additions & 11 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`serve`: the cloud-connected marketplace arm now leaves a host config's own marketplace and cloud plugins alone.
6+
7+
`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.
8+
9+
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.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 125 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)