Found while fixing #8356 (deriving features.marketplace from what is mounted). Filed rather than fixed: that card's acceptance list is scoped to marketplace, and this is a second behaviour change on the same published surface with its own blast radius.
After #8356, packages/cloud-connection/src/runtime-config-plugin.ts builds its response like this:
features: {installLocal: this.installLocal,// still a constructor flagmarketplace: hasMarketplaceBrowseMount(rawApp),// observed
...features,},installLocal is whatever the host passed to the constructor. Nothing checks that MarketplaceInstallLocalPlugin is actually mounted on the kernel serving the response — so new RuntimeConfigPlugin({ installLocal: true }) on a runtime that never mounted the install-local plugin reports a capability whose route 404s.
That is not hypothetical. It is #8343's literally measured second symptom, on a real customer deployment:
{"cloudUrl":"","singleEnvironment":true,
"features":{"installLocal":true,"marketplace":true,"aiStudio":true,"autoPublishAiBuilds":true}}with
GET /api/v1/marketplace/install-local -> 404 {"error":"Not found"}
POST /api/v1/marketplace/install-local -> 404 {"error":"Not found"}
#8343 says so in as many words: "whichever component reports features.installLocal should report what is actually mounted." #8356 did that for marketplace and stopped there, so the two keys in one object are now derived by different rules — one observed, one declared.
Why this is the same bug, not a smaller one
The argument the #8343 ACCEPT ruling of 2026-08-13 adopted against a marketplace?: boolean knob applies unchanged: a hand-maintained flag makes every host responsible for keeping it in step with its own mounting, and the objectos-ee config and this package's README both already failed at exactly that. installLocal is that knob, still in place — it is simply the one the ruling was not asked about.
The seam already exists
#8356 built hasMarketplaceBrowseMount(rawApp) for this: it reads the raw app's route ledger, which is the union of adapter-registered and framework-native getRawApp() mounts. MarketplaceInstallLocalPlugin mounts /api/v1/marketplace/install-local (and /:manifestId sub-paths) through that same raw app, so the same ledger already answers this question — and #8356 deliberately excludes those paths from the browse predicate, so the two derivations would not collide. The work is a sibling predicate plus tests, not a new mechanism.
Suggested acceptance
features.installLocal reflects whether an install-local surface is mounted on the kernel serving the response.- Coverage pinning both directions (mounted install-local plugin reports
true; nothing mounted reports false), with the mounted case driving the real plugin rather than a hand-spelled route. - The
resolveFeatures escape hatch keeps merging over the derived base, as it does for marketplace.
One thing to decide when triaging: whether the installLocal constructor option is then retired, kept as a documented override, or kept as a ceiling (a host that passes false gets false even if the plugin is mounted). #8356 left the analogous question moot by never adding a knob; here one already exists and hosts pass it, so removing it is a breaking change for them.
Backlink: #8343, #8356.
Blocked-by: #8356
Found while fixing #8356 (deriving
features.marketplacefrom what is mounted). Filed rather than fixed: that card's acceptance list is scoped tomarketplace, and this is a second behaviour change on the same published surface with its own blast radius.After #8356,
packages/cloud-connection/src/runtime-config-plugin.tsbuilds its response like this:installLocalis whatever the host passed to the constructor. Nothing checks thatMarketplaceInstallLocalPluginis actually mounted on the kernel serving the response — sonew RuntimeConfigPlugin({ installLocal: true })on a runtime that never mounted the install-local plugin reports a capability whose route 404s.That is not hypothetical. It is #8343's literally measured second symptom, on a real customer deployment:
{"cloudUrl":"","singleEnvironment":true, "features":{"installLocal":true,"marketplace":true,"aiStudio":true,"autoPublishAiBuilds":true}}with
#8343 says so in as many words: "whichever component reports
features.installLocalshould report what is actually mounted." #8356 did that formarketplaceand stopped there, so the two keys in one object are now derived by different rules — one observed, one declared.Why this is the same bug, not a smaller one
The argument the #8343 ACCEPT ruling of 2026-08-13 adopted against a
marketplace?: booleanknob applies unchanged: a hand-maintained flag makes every host responsible for keeping it in step with its own mounting, and the objectos-ee config and this package's README both already failed at exactly that.installLocalis that knob, still in place — it is simply the one the ruling was not asked about.The seam already exists
#8356 built
hasMarketplaceBrowseMount(rawApp)for this: it reads the raw app's route ledger, which is the union of adapter-registered and framework-nativegetRawApp()mounts.MarketplaceInstallLocalPluginmounts/api/v1/marketplace/install-local(and/:manifestIdsub-paths) through that same raw app, so the same ledger already answers this question — and #8356 deliberately excludes those paths from the browse predicate, so the two derivations would not collide. The work is a sibling predicate plus tests, not a new mechanism.Suggested acceptance
features.installLocalreflects whether an install-local surface is mounted on the kernel serving the response.true; nothing mounted reportsfalse), with the mounted case driving the real plugin rather than a hand-spelled route.resolveFeaturesescape hatch keeps merging over the derived base, as it does formarketplace.One thing to decide when triaging: whether the
installLocalconstructor option is then retired, kept as a documented override, or kept as a ceiling (a host that passesfalsegetsfalseeven if the plugin is mounted). #8356 left the analogous question moot by never adding a knob; here one already exists and hosts pass it, so removing it is a breaking change for them.Backlink: #8343, #8356.
Blocked-by: #8356