From a8a8592c51dc7cbf871b203bcae6786f1d3e8c06 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 08:08:53 +0000 Subject: [PATCH 1/2] fix(cli): mount install-local on a runtime with the cloud switched off (#8343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_016pY4Xb2iDecfDtT3CWoiTW --- .changeset/tidy-donkeys-shave.md | 11 + packages/cli/src/commands/serve.ts | 121 ++++++++++- .../serve-marketplace-offline-install.test.ts | 191 ++++++++++++++++++ ...-install-local-offline-degradation.test.ts | 157 ++++++++++++++ 4 files changed, 474 insertions(+), 6 deletions(-) create mode 100644 .changeset/tidy-donkeys-shave.md create mode 100644 packages/cli/test/serve-marketplace-offline-install.test.ts create mode 100644 packages/cloud-connection/src/marketplace-install-local-offline-degradation.test.ts diff --git a/.changeset/tidy-donkeys-shave.md b/.changeset/tidy-donkeys-shave.md new file mode 100644 index 0000000000..f298af44e9 --- /dev/null +++ b/.changeset/tidy-donkeys-shave.md @@ -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 compose file documents for a fully self-hosted box — 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. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index a9c5e6adf4..0b3e1d1e46 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -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 @@ -1695,13 +1761,37 @@ 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 (whose compose file documents `OS_CLOUD_URL=off` + // for 完全自托管) 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`. + // 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 @@ -1723,7 +1813,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 @@ -1733,6 +1824,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}`)); diff --git a/packages/cli/test/serve-marketplace-offline-install.test.ts b/packages/cli/test/serve-marketplace-offline-install.test.ts new file mode 100644 index 0000000000..28105f953f --- /dev/null +++ b/packages/cli/test/serve-marketplace-offline-install.test.ts @@ -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(''); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-offline-degradation.test.ts b/packages/cloud-connection/src/marketplace-install-local-offline-degradation.test.ts new file mode 100644 index 0000000000..699795fd9e --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-offline-degradation.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8343 — what an `OS_CLOUD_URL=off` runtime gets when the CLI mounts this + * plugin anyway. + * + * The CLI now mounts the local-install surface on a runtime with no control + * plane (framework `serve.ts`), because its inline-manifest branch is the + * documented air-gapped install path and needs no cloud. That fix rests on a + * property of THIS plugin which nothing here asserted: with no cloud URL, the + * CATALOG branch degrades locally instead of dialling out. Untested, the fix + * would be one refactor away from pointing an air-gapped box at the public + * cloud and hanging on an install it can never complete. + * + * So this pins the pair that makes "mount it unconditionally" safe: + * + * - no inline manifest + no cloud -> 503 MARKETPLACE_UNAVAILABLE, locally, + * - an inline manifest -> served without consulting a cloud URL. + * + * The rejection assertion names the CODE and the STATUS (ADR-0112 envelope). + * `expect(...).toThrow()`-shaped coverage would be blind here in both + * directions: this handler never throws, and a 404 from an unmounted route + * would satisfy a status-only check just as happily as the fix does. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; + +type Handler = (c: any) => Promise; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +function makeCtx(rawApp: any) { + const hooks = new Map(); + const services: Record = { + 'http-server': { getRawApp: () => rawApp }, + manifest: { register: vi.fn() }, + auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, + objectql: { syncSchemas: async () => undefined, find: vi.fn(async () => []) }, + metadata: {}, + }; + return { + ctx: { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }, + fire: async () => { await hooks.get('kernel:ready')?.(); }, + }; +} + +function makeC(body: any) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url: 'http://localhost:3000/api/v1/marketplace/install-local', + raw: new Request('http://localhost:3000/x'), + json: async () => body, + param: () => undefined, + header: () => undefined, + }, + json, + }; +} + +/** Boot the plugin with the cloud explicitly OFF and hand back its POST route. */ +async function offlinePostRoute(dir: string): Promise { + const rawApp = makeRawApp(); + const { ctx, fire } = makeCtx(rawApp); + // Exactly how the CLI constructs it on a cloud-less runtime: the `off` + // sentinel, never '' — resolveCloudUrl() reads '' as "unset" and would + // substitute the PUBLIC default. + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx as any); + await fire(); + return rawApp.routes.get('POST /api/v1/marketplace/install-local')!; +} + +let dir: string; +const OLD_CLOUD_URL = process.env.OS_CLOUD_URL; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mil-offline-')); + // The measured deployment's own setting. Set explicitly so the assertions + // cannot be carried by a stray ambient value on a developer's machine. + process.env.OS_CLOUD_URL = 'off'; +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + if (OLD_CLOUD_URL === undefined) delete process.env.OS_CLOUD_URL; + else process.env.OS_CLOUD_URL = OLD_CLOUD_URL; + vi.restoreAllMocks(); +}); + +describe('#8343 — install-local on a runtime with no control plane', () => { + it('mounts its routes at all (the surface the fix restores)', async () => { + const rawApp = makeRawApp(); + const { ctx, fire } = makeCtx(rawApp); + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx as any); + await fire(); + + // The 404 pair the customer measured was the ABSENCE of exactly these. + expect([...rawApp.routes.keys()]).toEqual( + expect.arrayContaining([ + 'POST /api/v1/marketplace/install-local', + 'GET /api/v1/marketplace/install-local', + ]), + ); + }); + + it('a CATALOG install degrades locally — 503 MARKETPLACE_UNAVAILABLE', async () => { + const post = await offlinePostRoute(dir); + + // No inline manifest: this is the branch that genuinely needs a cloud. + const res = await post(makeC({ packageId: 'app.objectstack.hotcrm' })); + + expect(res.status).toBe(503); + expect(res.payload?.error?.code).toBe('MARKETPLACE_UNAVAILABLE'); + expect(res.payload?.success).toBe(false); + }); + + it('an INLINE manifest install is served — no cloud consulted', async () => { + const post = await offlinePostRoute(dir); + + const res = await post(makeC({ + manifest: { + id: 'app.objectstack.hotcrm', + version: '1.0.0', + objects: [{ name: 'hotcrm_lead', fields: { name: { type: 'text' } } }], + }, + })); + + // The point is that it did NOT take the cloud branch. Asserting "not + // 503" rather than a success envelope keeps this test about the + // routing decision instead of re-testing the installer the sibling + // suites already cover end to end. + expect(res.status).not.toBe(503); + expect(res.payload?.error?.code).not.toBe('MARKETPLACE_UNAVAILABLE'); + }); +}); From c32fbef0bbd5d1dc0ca61256eba076caf2fe266b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 08:25:46 +0000 Subject: [PATCH 2/2] docs(cli): state the measured compose default instead of a paraphrased quote (#8343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016pY4Xb2iDecfDtT3CWoiTW --- .changeset/tidy-donkeys-shave.md | 2 +- packages/cli/src/commands/serve.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.changeset/tidy-donkeys-shave.md b/.changeset/tidy-donkeys-shave.md index f298af44e9..cda521e598 100644 --- a/.changeset/tidy-donkeys-shave.md +++ b/.changeset/tidy-donkeys-shave.md @@ -4,7 +4,7 @@ `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 compose file documents for a fully self-hosted box — 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. +`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. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 0b3e1d1e46..d37da67aa4 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1772,10 +1772,12 @@ export default class Serve extends Command { // 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 (whose compose file documents `OS_CLOUD_URL=off` - // for 完全自托管) could not install a package by ANY route: measured on - // objectos-ee 4.0.5-rc.1, both GET and POST /marketplace/install-local + // 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