fix(cli): mount install-local on a runtime with the cloud switched off - #8358
Conversation
#8343) `OS_CLOUD_URL=off` skipped the whole marketplace wiring block, including `MarketplaceInstallLocalPlugin` — whose inline-manifest branch is the documented air-gapped install path and reads no cloud URL at all. A self-hosted EE box measured 404 on both GET and POST /api/v1/marketplace/install-local, with no other install surface served. Split the registration condition by what each surface needs: the control-plane clients (browse proxy, cloud-connection, runtime-config) still require a resolved URL; install-local mounts regardless, pinned to no control plane so its catalog branch degrades to 503 locally instead of dialling out to the public cloud. A host that wires its own keeps it; the runtime host-kernel skip is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016pY4Xb2iDecfDtT3CWoiTW
…d quote (#8343) The card quoted the EE compose file as documenting `OS_CLOUD_URL=off` for 完全自托管. That phrase appears nowhere in the cloud repo — it is the reporter's paraphrase, so it had no business sitting in a code comment as a quotation. What the file actually says is stronger and checkable: `OS_CLOUD_URL: ${OS_CLOUD_URL:-off}`, i.e. `off` is the SHIPPED DEFAULT, so every self-hosted stack that does not override it hit this bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016pY4Xb2iDecfDtT3CWoiTW
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
Part of #8343.
Part ofrather thanFixes: the card's first ask (registration not gated onresolveCloudUrl()) is implemented here; its closing line — "whichever component reportsfeatures.installLocalshould report what is actually mounted" — was excluded by the dispatch scope and went to separate cards, one of which lands in another repo. See "What this PR does not do".The premise held, but not for the reason it was checked
The card was dispatched with a falsifiable doubt attached: the wiring block is wrapped in
if (!isRuntimeHostKernel), checked before the cloud-URL test, and if the measuredobjectos-eeimage ran a runtime host kernel then the diagnosis was wrong and the fix belonged elsewhere. Measured, it does not:ObjectOSEnvironmentPluginis constructed in exactly one place,createObjectOSStack, whichapps/objectos-ee/objectstack.config.tscalls only on itsOS_MULTI_TENANTbranch. The measured deployment isOS_MODE=standalonemulti-org, which is a different axis, so it takes the single-environment branch andisRuntimeHostKernelis false./api/v1/runtime/configcarriesautoPublishAiBuilds: true; the open framework plugin's static default for that key isfalse, and only the cloud subclass injects aresolveFeatureshook that returns true. So the response came from the cloud-wiredRuntimeConfigPlugin, which only that single-environment branch mounts — which is also what proves the branch ran.So the second guard did not fire, the cloud-URL guard is the one that skipped the mount, and the card's diagnosis is correct.
premise_still_valid: true.The fix
The block mounted a control-plane client and the local install surface behind one flag, though only the former needs a URL.
handleInstall's inline-manifest branch reads no cloud URL at all — that is what makesos package install ./dist/objectstack.jsonthe documented offline path. The condition is now split by what each surface actually needs:The decision moved into a pure
Serve.planMarketplaceWiring()static, the same seam-for-testability idiom asServe.providesCapability, because the call site sits deep insiderun()behind a dynamic import where the only way to observe a mounting rule is to boot a kernel.Two details that are easy to get wrong and are pinned by tests:
off, never the empty string already in scope. The plugin re-resolves whatever it is handed throughresolveCloudUrl(), which reads''as unset and substitutes the publicDEFAULT_CLOUD_URL— so the obvious spelling would point an air-gapped runtime's catalog branch at cloud.objectos.ai, the exact opposite of whatoffrequested, and invisible until a box with no egress hangs. It is a named constant so a test asserts the property rather than a comment claiming it.kernel.usekeys plugins by name, so an unguarded mount would replace a host's instance — and a host may have built one with an explicit control-plane URL whileOS_CLOUD_URLsaysoff, making the replacement a silent capability downgrade.Reconciling with the vanilla-dev intent
The gate's comment called itself deliberate — "
OS_CLOUD_URL=offproduces nothing mounted, preserving the vanilla marketplace-lessobjectstack dev" — and each plugin self-registers a Setup nav bundle, so the cost of mounting more had to be weighed rather than waved through. Measured, that cost is much narrower than it reads:objectstack devsets noOS_CLOUD_URL.resolveCloudUrl()then returnsDEFAULT_CLOUD_URL, which is truthy — so it takes the cloud arm and has been mounting install-local and its "Installed Apps" entry all along. Vanilla dev is byte-identical after this change.off/none/local/disabled) — the same population the card is about.marketplace-ui.ts("the entry lives and dies with the capability, so no dead page") is satisfied rather than violated: the entry now appears exactly when a working offline install surface is behind it. Nothing that makes a remote call mounts underoff.offalso is not an exotic setting here: that image's compose file shipsOS_CLOUD_URL: ${OS_CLOUD_URL:-off}, so it is the default every self-hosted stack gets unless it overrides it.And the package's own README already stated the intended contract in as many words — "
OS_CLOUD_URL=offdisables every remote call; air-gapped installs keep working via inline manifests handed toinstall-local" — so this makes the code agree with its documented contract rather than making a new product call.Tests
packages/cli/test/serve-marketplace-offline-install.test.ts(9) pins the split in both directions, because a fix that merely mounts more would be indistinguishable from one that stopped honouringoff: cloud-off mounts the offline surface and nothing that dials out; a resolved URL still mounts the full set; a runtime host kernel mounts neither arm (for both URL states, since the offline arm is a new path through that branch); a host wiring its own is left alone; plus a drift check that the matched identities are the real plugin's, which otherwise fails open.packages/cloud-connection/src/marketplace-install-local-offline-degradation.test.ts(3) pins the property the fix rests on and nothing asserted: with no cloud, the catalog branch degrades locally — asserting codeMARKETPLACE_UNAVAILABLEand status 503 together, since this handler never throws and a 404 from an unmounted route would satisfy a status-only check just as happily as the fix does.Reverse-verified from the committed state, with the direction predicted first: restoring the pre-fix rule turns exactly 1 of 9 red (the offline-mount pin) and leaves 8 green; changing the constant to
''turns the trap pin red, reportingexpected 'https://cloud.objectos.ai' to be ''.What this PR does not do
features.installLocalproducer. Identified conclusively asapps/objectos-ee/objectstack.config.tsin thecloudrepo, which mountsRuntimeConfigPlugin({ installLocal: true })outside the ternary that holds the plugin — filed as objectstack-ai/cloud#1287 with aBlocked-bylink. On the EE image this PR makes that flag truthful again, but only because a lower layer backfills it, which is the part that card fixes.RuntimeConfigPluginhardcodesfeatures.marketplace: true. That is why the offline arm mounts install-local alone and not runtime-config too — mounting it would restore the Console's knowledge of install-local at the price of asserting a browse capability that is definitively absent, trading the reported bug for its mirror image. A cloud-less framework runtime therefore has a working route the Console cannot yet discover.Gates run locally
check:cross-package-test-inputs,check:startup-registry-verdict,check:type-check-coverage,check:type-check-debt(the four named at dispatch), pluscheck:nul-bytesand a control-character self-scan. Re-derived against the final diff withscripts/pm/dispatch-gates.mjs, which surfaced the.changeset-triggered families the dispatch list predated —check:changeset-gate-self-tests,check:objectui-changeset,check:query-options-erasure— all run and green.check:type-check-debtfirst refused to re-measure against an incomplete build closure; built the full closure aslint.ymldoes, after which it reported 33 ledger entries re-measured, none above its recorded number.Also run:
@objectstack/clifull suite (116 files / 1265 tests),@objectstack/cloud-connectionfull suite (17 files / 115 tests),tsc --noEmitfor the CLI.Generated by Claude Code