Skip to content

Commit 08dcd1e

Browse files
os-zhuangclaude
andauthored
fix(cli): mount install-local on a runtime with the cloud switched off (#8358)
* fix(cli): mount install-local on a runtime with the cloud switched off (#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 * docs(cli): state the measured compose default instead of a paraphrased 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 720fe14 commit 08dcd1e

4 files changed

Lines changed: 476 additions & 6 deletions

File tree

.changeset/tidy-donkeys-shave.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`objectstack serve` now mounts the local-install surface on a runtime with the cloud switched off (#8343).
6+
7+
`OS_CLOUD_URL=off` — the value the self-hosted EE image's compose file ships as its default (`${OS_CLOUD_URL:-off}`) — used to skip the entire marketplace wiring block, including `MarketplaceInstallLocalPlugin`. That plugin serves `os package install ./dist/objectstack.json`, the documented air-gapped path, whose inline-manifest branch reads no cloud URL at all. The result, measured on a customer deployment: `GET` and `POST /api/v1/marketplace/install-local` both 404 with no other package-install surface available, so the deployment could not install a package by any route.
8+
9+
The registration condition is now split by what each surface actually needs. The control-plane clients (marketplace browse proxy, cloud-connection, pushed runtime-config) still require a resolved cloud URL; the local install surface mounts regardless, pinned to no control plane so its catalog branch answers `503 MARKETPLACE_UNAVAILABLE` locally rather than dialling out. A host config that wires its own install-local keeps it, and the runtime host-kernel skip is unchanged.
10+
11+
Only runtimes that explicitly disabled the cloud (`off`/`none`/`local`/`disabled`) change behaviour: they gain the install-local routes and the "Installed Apps" Setup entry that ships with them. A plain `objectstack dev` sets no `OS_CLOUD_URL`, which resolves to the public default cloud, so it already mounted both and is unaffected.

packages/cli/src/commands/serve.ts

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,72 @@ export default class Serve extends Command {
381381
});
382382
}
383383

384+
/**
385+
* Identities of the local-install surface (`MarketplaceInstallLocalPlugin`),
386+
* matched EXACTLY by {@link Serve.providesCapability}.
387+
*
388+
* Used by the offline arm of the marketplace wiring (#8343) to leave a host
389+
* config that already wires its own install-local strictly alone. Declared
390+
* here rather than inline so the same drift test that pins
391+
* CAPABILITY_PROVIDERS can pin these against the real plugin.
392+
*/
393+
static readonly INSTALL_LOCAL_IDENTITIES: readonly string[] = [
394+
'com.objectstack.runtime.marketplace-install-local',
395+
'MarketplaceInstallLocalPlugin',
396+
];
397+
398+
/**
399+
* The `controlPlaneUrl` the offline install-local mount is constructed with.
400+
*
401+
* A named constant rather than an inline `'off'` so a test can assert the
402+
* property the call site depends on — `resolveCloudUrl(this) === ''`. The
403+
* tempting value is the empty `marketplaceUrl` the wiring block already
404+
* holds, and it is wrong in a way no local reading reveals: `resolveCloudUrl`
405+
* treats `''` as "unset" and substitutes the PUBLIC default cloud, so an
406+
* air-gapped runtime's catalog branch would dial out instead of answering
407+
* 503. Spelled as one of the documented disable sentinels, it resolves to
408+
* no cloud at all.
409+
*/
410+
static readonly OFFLINE_CONTROL_PLANE = 'off';
411+
412+
/**
413+
* Which half of the marketplace wiring this boot should mount (#8343).
414+
*
415+
* Pure + static so the decision is readable and testable on its own, the
416+
* same reason {@link Serve.providesCapability} is: the call site sits deep
417+
* inside `run()` behind a dynamic import, where the only way to observe a
418+
* mounting rule is to boot a kernel.
419+
*
420+
* The two arms are deliberately asymmetric, because the two surfaces need
421+
* different things:
422+
*
423+
* - `cloudSurfaces` — proxy + cloud-connection + runtime-config. These
424+
* *are* the control plane's client, so a resolved URL is their precondition.
425+
* - `offlineInstallLocal` — the air-gapped install surface. Its inline
426+
* branch reads no URL at all, so a control plane is precisely what it
427+
* does NOT need; gating it on one is what left a self-hosted EE box with
428+
* no install route at all.
429+
*
430+
* `isRuntimeHostKernel` is restated here (the call site checks it too, to
431+
* skip the dynamic import) so this function is the whole rule in one place:
432+
* the cloud distribution wires its own marketplace on the host kernel, so
433+
* NEITHER arm mounts there.
434+
*/
435+
static planMarketplaceWiring(input: {
436+
isRuntimeHostKernel: boolean;
437+
marketplaceUrl: string;
438+
plugins: readonly unknown[];
439+
}): { cloudSurfaces: boolean; offlineInstallLocal: boolean } {
440+
if (input.isRuntimeHostKernel) return { cloudSurfaces: false, offlineInstallLocal: false };
441+
if (input.marketplaceUrl) return { cloudSurfaces: true, offlineInstallLocal: false };
442+
return {
443+
cloudSurfaces: false,
444+
// A host config that wires its own install-local keeps it — see the
445+
// call site for why replacing it would be a silent downgrade.
446+
offlineInstallLocal: !Serve.providesCapability(input.plugins, Serve.INSTALL_LOCAL_IDENTITIES),
447+
};
448+
}
449+
384450
/**
385451
* Registry of `requires` token → built-in service-plugin provider for the
386452
* standalone serve path. Keys are canonical kebab-case platform capability
@@ -1695,13 +1761,39 @@ export default class Serve extends Command {
16951761
// marketplace" but, with no config/artifact, has no host to carry the
16961762
// wiring (the only place it can come from is the CLI itself).
16971763
//
1698-
// Mirrors the objectos-ee single-env host wiring: proxy + install-local
1699-
// + cloud-connection only when `resolveCloudUrl()` is truthy
1700-
// (OS_CLOUD_URL=off -> nothing mounts, preserving the vanilla
1701-
// marketplace-less `objectstack dev`). Each plugin self-registers its
1702-
// own Setup nav bundle in start(), so no manual bundle registration is
1764+
// Mirrors the objectos-ee single-env host wiring: the CLOUD-DEPENDENT
1765+
// surfaces (proxy + cloud-connection + runtime-config) only when
1766+
// `resolveCloudUrl()` is truthy. Each plugin self-registers its own
1767+
// Setup nav bundle in start(), so no manual bundle registration is
17031768
// needed here.
17041769
//
1770+
// install-local is DELIBERATELY NOT on that gate (#8343). It is the
1771+
// documented air-gapped path — `os package install ./dist/objectstack.json`
1772+
// hands the compiled artifact over inline, and `handleInstall`'s inline
1773+
// branch never reads `this.cloudUrl` at all — so gating it on a control
1774+
// plane withheld it from the one deployment that cannot have one. A
1775+
// self-hosted EE box could not install a package by ANY route: measured
1776+
// on objectos-ee 4.0.5-rc.1, both GET and POST /marketplace/install-local
1777+
// 404, while its own /runtime/config advertised `installLocal: true`.
1778+
// Note `off` is not an unusual choice there but the SHIPPED DEFAULT --
1779+
// that image's compose file reads `OS_CLOUD_URL: ${OS_CLOUD_URL:-off}`,
1780+
// so every self-hosted stack that does not override it landed here.
1781+
// The package README states the intended contract in as many words —
1782+
// "`OS_CLOUD_URL=off` disables every remote call; air-gapped installs
1783+
// keep working via inline manifests handed to `install-local`" — so the
1784+
// gate contradicted the contract rather than expressing it.
1785+
//
1786+
// What "preserving the vanilla marketplace-less `objectstack dev`" is
1787+
// worth here, measured rather than assumed: a plain `objectstack dev`
1788+
// sets NO OS_CLOUD_URL, and `resolveCloudUrl()` then returns
1789+
// DEFAULT_CLOUD_URL — truthy — so it already mounts install-local (and
1790+
// its "Installed Apps" nav) today. The only runs this changes are those
1791+
// that explicitly opted out (`off`/`none`/`local`/`disabled`), and for
1792+
// them the nav-ownership rule in marketplace-ui.ts ("the entry lives and
1793+
// dies with the capability -> no dead page") is SATISFIED, not violated:
1794+
// the entry now appears exactly when a working offline install surface
1795+
// is behind it. Nothing that makes a remote call mounts under `off`.
1796+
//
17051797
// SKIPPED in runtime/host-kernel mode: the cloud distribution
17061798
// (objectos-stack) wires its own MarketplaceProxyPlugin on the host
17071799
// kernel, so auto-wiring here would double-mount. Detect runtime mode by
@@ -1723,7 +1815,8 @@ export default class Serve extends Command {
17231815
resolveCloudUrl,
17241816
} = await import(/* webpackIgnore: true */ ccPkg);
17251817
const marketplaceUrl = resolveCloudUrl();
1726-
if (marketplaceUrl) {
1818+
const wiring = Serve.planMarketplaceWiring({ isRuntimeHostKernel, marketplaceUrl, plugins });
1819+
if (wiring.cloudSurfaces) {
17271820
await kernel.use(new MarketplaceProxyPlugin({ controlPlaneUrl: marketplaceUrl }));
17281821
await kernel.use(new MarketplaceInstallLocalPlugin({ controlPlaneUrl: marketplaceUrl }));
17291822
// Same-origin /cloud-connection/* surface (status + device-code
@@ -1733,6 +1826,24 @@ export default class Serve extends Command {
17331826
// install-local are live (same-origin; install into THIS kernel).
17341827
await kernel.use(new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, installLocal: true }));
17351828
trackPlugin('Marketplace');
1829+
} else if (wiring.offlineInstallLocal) {
1830+
// Cloud explicitly disabled -> mount the OFFLINE half only.
1831+
//
1832+
// OFFLINE_CONTROL_PLANE, never the `''` sitting in `marketplaceUrl`:
1833+
// the plugin re-resolves whatever it is handed through
1834+
// `resolveCloudUrl()`, which treats an EMPTY string as "unset" and
1835+
// falls back to the PUBLIC DEFAULT_CLOUD_URL — pointing an
1836+
// air-gapped runtime's catalog branch at cloud.objectos.ai, the
1837+
// opposite of what `off` asked for. See the constant's own note.
1838+
//
1839+
// The presence check is load-bearing, not defensive: `kernel.use`
1840+
// keys plugins by name, so mounting unconditionally would let this
1841+
// `off`-pinned instance REPLACE a host config's own install-local
1842+
// that was constructed with an explicit control-plane URL —
1843+
// silently downgrading that host's catalog capability. A host that
1844+
// wires its own keeps it.
1845+
await kernel.use(new MarketplaceInstallLocalPlugin({ controlPlaneUrl: Serve.OFFLINE_CONTROL_PLANE }));
1846+
trackPlugin('MarketplaceInstallLocal');
17361847
}
17371848
} catch (err: any) {
17381849
console.warn(chalk.yellow(` \u26a0 Marketplace/cloud-connection wiring failed: ${err?.message ?? err}`));
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #8343 — the air-gapped install surface must not be gated on the control
5+
* plane it is designed not to need.
6+
*
7+
* The defect, measured on a customer's self-hosted objectos-ee 4.0.5-rc.1 with
8+
* `OS_CLOUD_URL=off` (the value that image's own compose file documents for a
9+
* fully self-hosted box): `GET` and `POST /api/v1/marketplace/install-local`
10+
* both 404, with no other package-install surface in the served OpenAPI. The
11+
* deployment could not install a package by any route — while its
12+
* `/api/v1/runtime/config` advertised `features.installLocal: true`.
13+
*
14+
* Why the whole wiring block sat behind ONE flag: it mounts a control-plane
15+
* client (browse proxy, cloud-connection, runtime-config) AND the local
16+
* install surface, and only the former needs a URL. `handleInstall`'s
17+
* inline-manifest branch reads no URL at all, which is exactly what makes
18+
* `os package install ./dist/objectstack.json` the documented offline path.
19+
*
20+
* These tests pin the SPLIT, in both directions — a fix that merely mounts
21+
* more would be indistinguishable here from one that stopped honouring `off`:
22+
*
23+
* - explicitly-disabled cloud mounts the offline surface and NOTHING that
24+
* dials out,
25+
* - a resolved cloud URL still mounts the full set (no capability lost),
26+
* - a runtime host kernel still mounts NEITHER (the cloud distribution wires
27+
* its own — the guard that was checked first and is easy to disturb),
28+
* - a host config that wires its own install-local is left alone.
29+
*/
30+
31+
import { describe, expect, it } from 'vitest';
32+
import Serve from '../src/commands/serve.js';
33+
34+
/** Minimal stand-in for a loaded plugin: what the resolver actually reads. */
35+
function plugin(name: string, ctorName: string): { name: string } {
36+
const Ctor = { [ctorName]: class { name: string; constructor(n: string) { this.name = n; } } }[ctorName]!;
37+
return new Ctor(name) as { name: string };
38+
}
39+
40+
/** The host-kernel signal the cloud distribution is detected by. */
41+
const OBJECTOS_ENVIRONMENT = plugin(
42+
'com.objectstack.runtime.objectos-environment',
43+
'ObjectOSEnvironmentPlugin',
44+
);
45+
46+
const INSTALL_LOCAL = plugin(
47+
'com.objectstack.runtime.marketplace-install-local',
48+
'MarketplaceInstallLocalPlugin',
49+
);
50+
51+
describe('#8343: install-local mounts on a runtime with the cloud switched off', () => {
52+
it('THE REGRESSION — `OS_CLOUD_URL=off` still mounts the offline install surface', () => {
53+
// resolveCloudUrl() maps every disable sentinel to '' — that empty string
54+
// is what reaches this decision, and it used to mean "mount nothing".
55+
const wiring = Serve.planMarketplaceWiring({
56+
isRuntimeHostKernel: false,
57+
marketplaceUrl: '',
58+
plugins: [],
59+
});
60+
61+
expect(
62+
wiring.offlineInstallLocal,
63+
'a self-hosted box with no control plane is the deployment that most needs the offline install path',
64+
).toBe(true);
65+
});
66+
67+
it('and mounts NOTHING that talks to a control plane', () => {
68+
// The other half of the fix. `off` still has to mean off: the browse
69+
// proxy, the cloud-connection surface and the pushed runtime-config are
70+
// all control-plane clients and must stay unmounted.
71+
const wiring = Serve.planMarketplaceWiring({
72+
isRuntimeHostKernel: false,
73+
marketplaceUrl: '',
74+
plugins: [],
75+
});
76+
77+
expect(wiring.cloudSurfaces).toBe(false);
78+
});
79+
80+
it('a resolved cloud URL still mounts the full set — nothing was traded away', () => {
81+
// The regression a narrowed rule invites: mounting only the offline half
82+
// everywhere would pass the two tests above while silently deleting
83+
// marketplace browse from every connected runtime.
84+
const wiring = Serve.planMarketplaceWiring({
85+
isRuntimeHostKernel: false,
86+
marketplaceUrl: 'https://cloud.objectos.ai',
87+
plugins: [],
88+
});
89+
90+
expect(wiring.cloudSurfaces).toBe(true);
91+
// The cloud arm already carries its own install-local, so the offline arm
92+
// must not fire on top of it.
93+
expect(wiring.offlineInstallLocal).toBe(false);
94+
});
95+
96+
it('vanilla `objectstack dev` is UNCHANGED — it never reached the offline arm', () => {
97+
// Worth pinning because the reading that makes this change look expensive
98+
// is "unconditional mounting adds a Setup nav entry to every plain dev
99+
// app". It does not: with OS_CLOUD_URL unset, resolveCloudUrl() returns
100+
// the public DEFAULT_CLOUD_URL, so a plain dev app takes the CLOUD arm —
101+
// and has mounted install-local (and its "Installed Apps" nav) all along.
102+
// Only runs that explicitly opted out see any difference at all.
103+
const wiring = Serve.planMarketplaceWiring({
104+
isRuntimeHostKernel: false,
105+
marketplaceUrl: 'https://cloud.objectos.ai', // what an unset env resolves to
106+
plugins: [],
107+
});
108+
109+
expect(wiring.cloudSurfaces).toBe(true);
110+
expect(wiring.offlineInstallLocal).toBe(false);
111+
});
112+
113+
it('a runtime host kernel mounts NEITHER arm, cloud off or on', () => {
114+
// The guard that is checked FIRST and is the easy casualty of editing this
115+
// block: the cloud distribution (objectos-stack) wires its own marketplace
116+
// on the host kernel, so auto-wiring here double-mounts. Pinned for both
117+
// URL states, because the offline arm is a new path through this branch.
118+
for (const marketplaceUrl of ['', 'https://cloud.objectos.ai']) {
119+
const wiring = Serve.planMarketplaceWiring({
120+
isRuntimeHostKernel: true,
121+
marketplaceUrl,
122+
plugins: [OBJECTOS_ENVIRONMENT],
123+
});
124+
125+
expect(wiring.cloudSurfaces, `cloudSurfaces for url='${marketplaceUrl}'`).toBe(false);
126+
expect(wiring.offlineInstallLocal, `offlineInstallLocal for url='${marketplaceUrl}'`).toBe(false);
127+
}
128+
});
129+
130+
it('a host that wires its OWN install-local keeps it', () => {
131+
// `kernel.use` keys plugins by name, so an unguarded mount would REPLACE a
132+
// host's own instance. That is not a redundant-mount tidy-up: a host may
133+
// have constructed one with an explicit control-plane URL while
134+
// OS_CLOUD_URL says `off`, and the replacement — pinned to `off` — would
135+
// silently drop that host's catalog capability.
136+
const wiring = Serve.planMarketplaceWiring({
137+
isRuntimeHostKernel: false,
138+
marketplaceUrl: '',
139+
plugins: [INSTALL_LOCAL],
140+
});
141+
142+
expect(wiring.offlineInstallLocal).toBe(false);
143+
});
144+
});
145+
146+
describe('#8343: the identities the offline arm matches on are the real ones', () => {
147+
it('matches the plugin by registered name AND by class name', () => {
148+
expect(Serve.providesCapability([INSTALL_LOCAL], Serve.INSTALL_LOCAL_IDENTITIES)).toBe(true);
149+
expect(
150+
Serve.providesCapability(
151+
[plugin('some.other.plugin', 'MarketplaceInstallLocalPlugin')],
152+
Serve.INSTALL_LOCAL_IDENTITIES,
153+
),
154+
).toBe(true);
155+
});
156+
157+
it('drift check — the identities still match the plugin the CLI actually mounts', async () => {
158+
// Same discipline as serve-capability-identity.test.ts: a registry of
159+
// identities that has drifted from the class it names fails OPEN (nothing
160+
// matches -> the guard never fires -> the host's instance gets replaced),
161+
// and nothing else in the suite would notice.
162+
const { MarketplaceInstallLocalPlugin } = await import('@objectstack/cloud-connection');
163+
const real = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off' });
164+
165+
expect(Serve.INSTALL_LOCAL_IDENTITIES).toContain(real.name);
166+
expect(Serve.INSTALL_LOCAL_IDENTITIES).toContain(MarketplaceInstallLocalPlugin.name);
167+
expect(Serve.providesCapability([real], Serve.INSTALL_LOCAL_IDENTITIES)).toBe(true);
168+
});
169+
});
170+
171+
describe('#8343: why the offline mount passes `off` and never an empty string', () => {
172+
it('an empty controlPlaneUrl resolves to the PUBLIC cloud, `off` resolves to none', async () => {
173+
// The trap behind the call site's literal. The plugin re-resolves whatever
174+
// it is constructed with through resolveCloudUrl(), which treats '' as
175+
// "unset" and substitutes DEFAULT_CLOUD_URL. Handing it the '' that the
176+
// wiring block already has in `marketplaceUrl` would therefore point an
177+
// air-gapped runtime's catalog branch at cloud.objectos.ai — the exact
178+
// opposite of what `off` requested, and invisible until a box with no
179+
// egress hangs on an install.
180+
const { resolveCloudUrl, DEFAULT_CLOUD_URL } = await import('@objectstack/cloud-connection');
181+
182+
expect(resolveCloudUrl(''), "'' means 'unset', NOT 'disabled'").toBe(DEFAULT_CLOUD_URL);
183+
184+
// The call site's actual value, not a restatement of it — this goes red if
185+
// anyone "simplifies" the constant to the empty marketplaceUrl in scope.
186+
expect(
187+
resolveCloudUrl(Serve.OFFLINE_CONTROL_PLANE),
188+
'the value the offline mount is constructed with must resolve to NO cloud',
189+
).toBe('');
190+
});
191+
});

0 commit comments

Comments
 (0)