Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/tidy-donkeys-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/cli": patch
---

`objectstack serve` now mounts the local-install surface on a runtime with the cloud switched off (#8343).

`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.

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.

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.
123 changes: 117 additions & 6 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,72 @@ export default class Serve extends Command {
});
}

/**
* Identities of the local-install surface (`MarketplaceInstallLocalPlugin`),
* matched EXACTLY by {@link Serve.providesCapability}.
*
* Used by the offline arm of the marketplace wiring (#8343) to leave a host
* config that already wires its own install-local strictly alone. Declared
* here rather than inline so the same drift test that pins
* CAPABILITY_PROVIDERS can pin these against the real plugin.
*/
static readonly INSTALL_LOCAL_IDENTITIES: readonly string[] = [
'com.objectstack.runtime.marketplace-install-local',
'MarketplaceInstallLocalPlugin',
];

/**
* The `controlPlaneUrl` the offline install-local mount is constructed with.
*
* A named constant rather than an inline `'off'` so a test can assert the
* property the call site depends on — `resolveCloudUrl(this) === ''`. The
* tempting value is the empty `marketplaceUrl` the wiring block already
* holds, and it is wrong in a way no local reading reveals: `resolveCloudUrl`
* treats `''` as "unset" and substitutes the PUBLIC default cloud, so an
* air-gapped runtime's catalog branch would dial out instead of answering
* 503. Spelled as one of the documented disable sentinels, it resolves to
* no cloud at all.
*/
static readonly OFFLINE_CONTROL_PLANE = 'off';

/**
* Which half of the marketplace wiring this boot should mount (#8343).
*
* Pure + static so the decision is readable and testable on its own, the
* same reason {@link Serve.providesCapability} is: the call site sits deep
* inside `run()` behind a dynamic import, where the only way to observe a
* mounting rule is to boot a kernel.
*
* The two arms are deliberately asymmetric, because the two surfaces need
* different things:
*
* - `cloudSurfaces` — proxy + cloud-connection + runtime-config. These
* *are* the control plane's client, so a resolved URL is their precondition.
* - `offlineInstallLocal` — the air-gapped install surface. Its inline
* branch reads no URL at all, so a control plane is precisely what it
* does NOT need; gating it on one is what left a self-hosted EE box with
* no install route at all.
*
* `isRuntimeHostKernel` is restated here (the call site checks it too, to
* skip the dynamic import) so this function is the whole rule in one place:
* the cloud distribution wires its own marketplace on the host kernel, so
* NEITHER arm mounts there.
*/
static planMarketplaceWiring(input: {
isRuntimeHostKernel: boolean;
marketplaceUrl: string;
plugins: readonly unknown[];
}): { cloudSurfaces: boolean; offlineInstallLocal: boolean } {
if (input.isRuntimeHostKernel) return { cloudSurfaces: false, offlineInstallLocal: false };
if (input.marketplaceUrl) return { cloudSurfaces: true, offlineInstallLocal: false };
return {
cloudSurfaces: false,
// A host config that wires its own install-local keeps it — see the
// call site for why replacing it would be a silent downgrade.
offlineInstallLocal: !Serve.providesCapability(input.plugins, Serve.INSTALL_LOCAL_IDENTITIES),
};
}

/**
* Registry of `requires` token → built-in service-plugin provider for the
* standalone serve path. Keys are canonical kebab-case platform capability
Expand Down Expand Up @@ -1695,13 +1761,39 @@ export default class Serve extends Command {
// marketplace" but, with no config/artifact, has no host to carry the
// wiring (the only place it can come from is the CLI itself).
//
// Mirrors the objectos-ee single-env host wiring: proxy + install-local
// + cloud-connection only when `resolveCloudUrl()` is truthy
// (OS_CLOUD_URL=off -> nothing mounts, preserving the vanilla
// marketplace-less `objectstack dev`). Each plugin self-registers its
// own Setup nav bundle in start(), so no manual bundle registration is
// Mirrors the objectos-ee single-env host wiring: the CLOUD-DEPENDENT
// surfaces (proxy + cloud-connection + runtime-config) only when
// `resolveCloudUrl()` is truthy. Each plugin self-registers its own
// Setup nav bundle in start(), so no manual bundle registration is
// needed here.
//
// install-local is DELIBERATELY NOT on that gate (#8343). It is the
// documented air-gapped path — `os package install ./dist/objectstack.json`
// hands the compiled artifact over inline, and `handleInstall`'s inline
// branch never reads `this.cloudUrl` at all — so gating it on a control
// plane withheld it from the one deployment that cannot have one. A
// self-hosted EE box could not install a package by ANY route: measured
// on objectos-ee 4.0.5-rc.1, both GET and POST /marketplace/install-local
// 404, while its own /runtime/config advertised `installLocal: true`.
// Note `off` is not an unusual choice there but the SHIPPED DEFAULT --
// that image's compose file reads `OS_CLOUD_URL: ${OS_CLOUD_URL:-off}`,
// so every self-hosted stack that does not override it landed here.
// The package README states the intended contract in as many words —
// "`OS_CLOUD_URL=off` disables every remote call; air-gapped installs
// keep working via inline manifests handed to `install-local`" — so the
// gate contradicted the contract rather than expressing it.
//
// What "preserving the vanilla marketplace-less `objectstack dev`" is
// worth here, measured rather than assumed: a plain `objectstack dev`
// sets NO OS_CLOUD_URL, and `resolveCloudUrl()` then returns
// DEFAULT_CLOUD_URL — truthy — so it already mounts install-local (and
// its "Installed Apps" nav) today. The only runs this changes are those
// that explicitly opted out (`off`/`none`/`local`/`disabled`), and for
// them the nav-ownership rule in marketplace-ui.ts ("the entry lives and
// dies with the capability -> no dead page") is SATISFIED, not violated:
// the entry now appears exactly when a working offline install surface
// is behind it. Nothing that makes a remote call mounts under `off`.
//
// SKIPPED in runtime/host-kernel mode: the cloud distribution
// (objectos-stack) wires its own MarketplaceProxyPlugin on the host
// kernel, so auto-wiring here would double-mount. Detect runtime mode by
Expand All @@ -1723,7 +1815,8 @@ export default class Serve extends Command {
resolveCloudUrl,
} = await import(/* webpackIgnore: true */ ccPkg);
const marketplaceUrl = resolveCloudUrl();
if (marketplaceUrl) {
const wiring = Serve.planMarketplaceWiring({ isRuntimeHostKernel, marketplaceUrl, plugins });
if (wiring.cloudSurfaces) {
await kernel.use(new MarketplaceProxyPlugin({ controlPlaneUrl: marketplaceUrl }));
await kernel.use(new MarketplaceInstallLocalPlugin({ controlPlaneUrl: marketplaceUrl }));
// Same-origin /cloud-connection/* surface (status + device-code
Expand All @@ -1733,6 +1826,24 @@ export default class Serve extends Command {
// install-local are live (same-origin; install into THIS kernel).
await kernel.use(new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, installLocal: true }));
trackPlugin('Marketplace');
} else if (wiring.offlineInstallLocal) {
// Cloud explicitly disabled -> mount the OFFLINE half only.
//
// OFFLINE_CONTROL_PLANE, never the `''` sitting in `marketplaceUrl`:
// the plugin re-resolves whatever it is handed through
// `resolveCloudUrl()`, which treats an EMPTY string as "unset" and
// falls back to the PUBLIC DEFAULT_CLOUD_URL — pointing an
// air-gapped runtime's catalog branch at cloud.objectos.ai, the
// opposite of what `off` asked for. See the constant's own note.
//
// The presence check is load-bearing, not defensive: `kernel.use`
// keys plugins by name, so mounting unconditionally would let this
// `off`-pinned instance REPLACE a host config's own install-local
// that was constructed with an explicit control-plane URL —
// silently downgrading that host's catalog capability. A host that
// wires its own keeps it.
await kernel.use(new MarketplaceInstallLocalPlugin({ controlPlaneUrl: Serve.OFFLINE_CONTROL_PLANE }));
trackPlugin('MarketplaceInstallLocal');
}
} catch (err: any) {
console.warn(chalk.yellow(` \u26a0 Marketplace/cloud-connection wiring failed: ${err?.message ?? err}`));
Expand Down
191 changes: 191 additions & 0 deletions packages/cli/test/serve-marketplace-offline-install.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #8343 — the air-gapped install surface must not be gated on the control
* plane it is designed not to need.
*
* The defect, measured on a customer's self-hosted objectos-ee 4.0.5-rc.1 with
* `OS_CLOUD_URL=off` (the value that image's own compose file documents for a
* fully self-hosted box): `GET` and `POST /api/v1/marketplace/install-local`
* both 404, with no other package-install surface in the served OpenAPI. The
* deployment could not install a package by any route — while its
* `/api/v1/runtime/config` advertised `features.installLocal: true`.
*
* Why the whole wiring block sat behind ONE flag: it mounts a control-plane
* client (browse proxy, cloud-connection, runtime-config) AND the local
* install surface, and only the former needs a URL. `handleInstall`'s
* inline-manifest branch reads no URL at all, which is exactly what makes
* `os package install ./dist/objectstack.json` the documented offline path.
*
* These tests pin the SPLIT, in both directions — a fix that merely mounts
* more would be indistinguishable here from one that stopped honouring `off`:
*
* - explicitly-disabled cloud mounts the offline surface and NOTHING that
* dials out,
* - a resolved cloud URL still mounts the full set (no capability lost),
* - a runtime host kernel still mounts NEITHER (the cloud distribution wires
* its own — the guard that was checked first and is easy to disturb),
* - a host config that wires its own install-local is left alone.
*/

import { describe, expect, it } from 'vitest';
import Serve from '../src/commands/serve.js';

/** Minimal stand-in for a loaded plugin: what the resolver actually reads. */
function plugin(name: string, ctorName: string): { name: string } {
const Ctor = { [ctorName]: class { name: string; constructor(n: string) { this.name = n; } } }[ctorName]!;
return new Ctor(name) as { name: string };
}

/** The host-kernel signal the cloud distribution is detected by. */
const OBJECTOS_ENVIRONMENT = plugin(
'com.objectstack.runtime.objectos-environment',
'ObjectOSEnvironmentPlugin',
);

const INSTALL_LOCAL = plugin(
'com.objectstack.runtime.marketplace-install-local',
'MarketplaceInstallLocalPlugin',
);

describe('#8343: install-local mounts on a runtime with the cloud switched off', () => {
it('THE REGRESSION — `OS_CLOUD_URL=off` still mounts the offline install surface', () => {
// resolveCloudUrl() maps every disable sentinel to '' — that empty string
// is what reaches this decision, and it used to mean "mount nothing".
const wiring = Serve.planMarketplaceWiring({
isRuntimeHostKernel: false,
marketplaceUrl: '',
plugins: [],
});

expect(
wiring.offlineInstallLocal,
'a self-hosted box with no control plane is the deployment that most needs the offline install path',
).toBe(true);
});

it('and mounts NOTHING that talks to a control plane', () => {
// The other half of the fix. `off` still has to mean off: the browse
// proxy, the cloud-connection surface and the pushed runtime-config are
// all control-plane clients and must stay unmounted.
const wiring = Serve.planMarketplaceWiring({
isRuntimeHostKernel: false,
marketplaceUrl: '',
plugins: [],
});

expect(wiring.cloudSurfaces).toBe(false);
});

it('a resolved cloud URL still mounts the full set — nothing was traded away', () => {
// The regression a narrowed rule invites: mounting only the offline half
// everywhere would pass the two tests above while silently deleting
// marketplace browse from every connected runtime.
const wiring = Serve.planMarketplaceWiring({
isRuntimeHostKernel: false,
marketplaceUrl: 'https://cloud.objectos.ai',
plugins: [],
});

expect(wiring.cloudSurfaces).toBe(true);
// The cloud arm already carries its own install-local, so the offline arm
// must not fire on top of it.
expect(wiring.offlineInstallLocal).toBe(false);
});

it('vanilla `objectstack dev` is UNCHANGED — it never reached the offline arm', () => {
// Worth pinning because the reading that makes this change look expensive
// is "unconditional mounting adds a Setup nav entry to every plain dev
// app". It does not: with OS_CLOUD_URL unset, resolveCloudUrl() returns
// the public DEFAULT_CLOUD_URL, so a plain dev app takes the CLOUD arm —
// and has mounted install-local (and its "Installed Apps" nav) all along.
// Only runs that explicitly opted out see any difference at all.
const wiring = Serve.planMarketplaceWiring({
isRuntimeHostKernel: false,
marketplaceUrl: 'https://cloud.objectos.ai', // what an unset env resolves to
plugins: [],
});

expect(wiring.cloudSurfaces).toBe(true);
expect(wiring.offlineInstallLocal).toBe(false);
});

it('a runtime host kernel mounts NEITHER arm, cloud off or on', () => {
// The guard that is checked FIRST and is the easy casualty of editing this
// block: the cloud distribution (objectos-stack) wires its own marketplace
// on the host kernel, so auto-wiring here double-mounts. Pinned for both
// URL states, because the offline arm is a new path through this branch.
for (const marketplaceUrl of ['', 'https://cloud.objectos.ai']) {
const wiring = Serve.planMarketplaceWiring({
isRuntimeHostKernel: true,
marketplaceUrl,
plugins: [OBJECTOS_ENVIRONMENT],
});

expect(wiring.cloudSurfaces, `cloudSurfaces for url='${marketplaceUrl}'`).toBe(false);
expect(wiring.offlineInstallLocal, `offlineInstallLocal for url='${marketplaceUrl}'`).toBe(false);
}
});

it('a host that wires its OWN install-local keeps it', () => {
// `kernel.use` keys plugins by name, so an unguarded mount would REPLACE a
// host's own instance. That is not a redundant-mount tidy-up: a host may
// have constructed one with an explicit control-plane URL while
// OS_CLOUD_URL says `off`, and the replacement — pinned to `off` — would
// silently drop that host's catalog capability.
const wiring = Serve.planMarketplaceWiring({
isRuntimeHostKernel: false,
marketplaceUrl: '',
plugins: [INSTALL_LOCAL],
});

expect(wiring.offlineInstallLocal).toBe(false);
});
});

describe('#8343: the identities the offline arm matches on are the real ones', () => {
it('matches the plugin by registered name AND by class name', () => {
expect(Serve.providesCapability([INSTALL_LOCAL], Serve.INSTALL_LOCAL_IDENTITIES)).toBe(true);
expect(
Serve.providesCapability(
[plugin('some.other.plugin', 'MarketplaceInstallLocalPlugin')],
Serve.INSTALL_LOCAL_IDENTITIES,
),
).toBe(true);
});

it('drift check — the identities still match the plugin the CLI actually mounts', async () => {
// Same discipline as serve-capability-identity.test.ts: a registry of
// identities that has drifted from the class it names fails OPEN (nothing
// matches -> the guard never fires -> the host's instance gets replaced),
// and nothing else in the suite would notice.
const { MarketplaceInstallLocalPlugin } = await import('@objectstack/cloud-connection');
const real = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off' });

expect(Serve.INSTALL_LOCAL_IDENTITIES).toContain(real.name);
expect(Serve.INSTALL_LOCAL_IDENTITIES).toContain(MarketplaceInstallLocalPlugin.name);
expect(Serve.providesCapability([real], Serve.INSTALL_LOCAL_IDENTITIES)).toBe(true);
});
});

describe('#8343: why the offline mount passes `off` and never an empty string', () => {
it('an empty controlPlaneUrl resolves to the PUBLIC cloud, `off` resolves to none', async () => {
// The trap behind the call site's literal. The plugin re-resolves whatever
// it is constructed with through resolveCloudUrl(), which treats '' as
// "unset" and substitutes DEFAULT_CLOUD_URL. Handing it the '' that the
// wiring block already has in `marketplaceUrl` would therefore point an
// air-gapped runtime's catalog branch at cloud.objectos.ai — the exact
// opposite of what `off` requested, and invisible until a box with no
// egress hangs on an install.
const { resolveCloudUrl, DEFAULT_CLOUD_URL } = await import('@objectstack/cloud-connection');

expect(resolveCloudUrl(''), "'' means 'unset', NOT 'disabled'").toBe(DEFAULT_CLOUD_URL);

// The call site's actual value, not a restatement of it — this goes red if
// anyone "simplifies" the constant to the empty marketplaceUrl in scope.
expect(
resolveCloudUrl(Serve.OFFLINE_CONTROL_PLANE),
'the value the offline mount is constructed with must resolve to NO cloud',
).toBe('');
});
});
Loading
Loading