Skip to content

fix(cli): honour host-wired marketplace plugins on the cloud-connected serve arm (#8357) - #8646

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8357-cloud-arm-host-precedence
Aug 14, 2026
Merged

fix(cli): honour host-wired marketplace plugins on the cloud-connected serve arm (#8357)#8646
os-zhuang merged 2 commits into
mainfrom
claude/issue-8357-cloud-arm-host-precedence

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Fixes#8357

What changed

objectstack serve auto-wires four surfaces whenever resolveCloudUrl() resolves and the kernel is not a runtime host kernel: MarketplaceProxyPlugin, MarketplaceInstallLocalPlugin, the same-origin cloud-connection surface, and RuntimeConfigPlugin. All four mounts were unconditional — the block never asked what the loaded host config had already wired.

Each is now guarded on its own Serve.providesCapability presence check, the idiom the offline arm has carried since the install-local change in #8343. planMarketplaceWiring reports one flag per surface (cloudProxy, cloudInstallLocal, cloudConnection, cloudRuntimeConfig), so a host composing any subset keeps exactly what it wired and the CLI supplies only the rest. Two new identity registries — MARKETPLACE_PROXY_IDENTITIES and CLOUD_CONNECTION_IDENTITIES — join the two the offline arm already declared.

cloudSurfaces keeps its name and its existing assertions, with its meaning tightened in the doc comment: it is the arm selector (does this boot take the cloud-connected arm at all), not a mount decision.

Per the grading round, Kernel.use() is not touched. Making it refuse or warn on a duplicate name was the card's alternative option and was explicitly rejected there — engine-core blast radius, and its own card if anyone wants it.

The sentinel trap the card documents is avoided: the check keys off each plugin's own identity, never off ObjectOSEnvironmentPlugin. The EE single-environment branch — the host this card is about — never constructs one; only the OS_MULTI_TENANT branch does, via createObjectOSStack. A rule hung off that sentinel would not fire for the exact host it was written for.

Premise re-verification — one part of the card is inverted

#8526 (artifact-pinned boot) landed in serve.ts after this card was written, so everything below was re-measured on this branch rather than taken from the card.

Confirmed as stated:

  • ObjectKernel.use() is this.plugins.set(pluginMeta.name, meta) (packages/core/src/kernel.ts) — a Map keyed by plugin.name. No error, no dedupe, last write wins. PluginLoader.loadPlugin has no duplicate check either.
  • The cloud-connected arm mounted all four surfaces with no check of the host.
  • The offline arm's Serve.providesCapability check is still there to mirror.

Not confirmed — the direction of the replacement. The CLI's marketplace block runs at serve.ts:1957; the loop that registers config.plugins runs at serve.ts:2474, roughly five hundred lines later. Since the later set wins, today the host's instance is the survivor and the CLI's four are constructed, registered, and dropped. The card's "a host that mounts its own therefore has it replaced by the CLI's instance" is backwards on this tree.

That does not weaken the graded scope, it sharpens it. The host winning is an accident of where two blocks sit in one 4000-line file, neither of which mentions the other; nothing states it and nothing pinned it. It inverts silently if either block moves, and on a kernel whose use rejects duplicates (LiteKernel throws) it is the host's registration that fails instead. This change makes the outcome independent of all of that, and stops the CLI constructing four plugins it is about to discard.

Severity — read this before reviewing it as a bug fix

There is no measured user impact today, and the card says so. Both sides currently construct their instances from the same resolveCloudUrl() value, so the mounts are interchangeable; combined with the ordering above, nothing observable differs. This is precedence and config-authority restoration, not a live-bug fix. It becomes real the moment a host passes an argument the CLI cannot: a private control plane, a custom install storageDir, a credential path, white-label branding.

The test, and why it is shaped the way it is

Because both sides build identical instances today, the naive test — "does the app still boot?" — passes before and after and proves nothing. Every host fixture in packages/cli/test/serve-marketplace-cloud-host-precedence.test.ts is therefore constructed with something the CLI's auto-wiring cannot produce, and each case asserts the survivor carries the host's value:

surfacehost fixture is distinguishable by
browse proxycontrolPlaneUrl: https://control-plane.internal.example + cacheMaxEntries: 4096
install-localthe same private control plane + storageDir: /srv/objectos/installed-packages
cloud-connectioncredentialPath: /srv/objectos/cloud-connection.json
runtime-configproductName: Contoso Operations (white-label branding)

19 tests over three layers: the planner rule per surface and for the whole EE shape; drift guards that the declared identities still match the real classes; and a kernel layer that registers into a real ObjectKernel and reads back which instance survived. A precondition assertion pairs with each identity check, so if a construction argument is ever renamed the test goes red rather than vacuously green.

The kernel layer runs both registration orders on purpose. cli-then-host is the order serve.ts really runs in — and it passes with or without this change, so on its own it cannot tell a fixed build from a broken one. host-then-cli is the order in which the missing guard bites. Passing both is the property being claimed: the host's composition wins because it is a rule, not because of block placement.

Reverse verification

Ablation: replace the four !Serve.providesCapability(...) guards in the cloud branch with literal true — the pre-change behaviour, with the flag shape kept so the tests still compile. The fix was committed first, so the restore came out of a real commit.

Predicted before running (recorded ahead of the run, and deliberately not the template's presumption that everything goes red): 6 red, 13 green — the four per-surface tests, the whole-EE-shape test, and host-then-cli. cli-then-host predicted to stay GREEN, because the host already wins in that order without any guard; that green is the evidence for the inverted direction above.

Measured: exactly that split — 6 failed, 13 passed, cli-then-host green and host-then-cli red:

✓ EE single-environment host keeps its own four instances (cli-then-host)
× EE single-environment host keeps its own four instances (host-then-cli)
AssertionError: marketplace browse proxy: the surviving instance must be the HOST's
- Expected
+ Received
"cache": LruTtlCache {
- "max": 4096,
+ "max": 200,
},
- "cloudUrl": "https://control-plane.internal.example",

Restored with git restore --source=HEAD, git status --porcelain empty.

Verification

  • pnpm --filter @objectstack/cli typecheck — clean
  • pnpm --filter @objectstack/cli exec vitest run120 files, 1316 tests, all passing
  • Full workspace build, then pnpm check:type-check-debt (--self-test && --re-measure, the strict job CI runs) — OK, 33 ledger entries re-measured, none above its recorded number. No ledger entry was raised. The only note is a pre-existing surplus in @objectstack/lint (records 20, measures 19), untouched by this branch.
  • Gates re-derived against the actual changed paths with scripts/pm/dispatch-gates.mjs, which surfaced several the dispatch prompt did not name; all run and green: check:changeset-gate-self-tests, check:objectui-changeset, check:cross-package-test-inputs, check:query-options-erasure, check:type-check-coverage, check:nul-bytes, plus check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs.

Changeset: .changeset/cloud-arm-host-marketplace-precedence.md (patch, @objectstack/cli).

Out of scope — filed as its own finding, not changed here

#8645 remains open and is not addressed by this PR. While writing the drift guard, the built MarketplaceProxyPlugin measured as _MarketplaceProxyPlugin: esbuild renames a class that references itself by name, so the class-name limb of these identity registries matches nothing against the shipped package for that plugin. The registered-name limb carries every guard, so there is no impact and the change here is correct as landed, but the redundancy is dead and the existing drift test cannot see it (it compares the registry against its own spec string, never against Ctor.name). The new test compares modulo one leading underscore and says why.

Release freeze

Opened as a draft and staying that way. No auto-merge armed, not enqueued.


Generated by Claude Code

…d serve arm (#8357)
`objectstack serve` auto-wires MarketplaceProxyPlugin,
MarketplaceInstallLocalPlugin, the same-origin cloud-connection surface and
RuntimeConfigPlugin whenever `resolveCloudUrl()` resolves. Those four mounts
were unconditional: the block never asked whether the loaded host config had
already wired any of them.
Each mount is now guarded on its own `Serve.providesCapability` presence check
-- the idiom the offline arm has carried since #8343 -- so CLI auto-wiring is a
fallback for hosts that wire nothing rather than a second opinion about a
surface the host already composed. Two new identity registries
(MARKETPLACE_PROXY_IDENTITIES, CLOUD_CONNECTION_IDENTITIES) join the two the
offline arm already declared, and `planMarketplaceWiring` reports one flag per
surface so a host composing any SUBSET keeps exactly what it wired.
Measured correction to the card's premise: the CLI's block runs several hundred
lines BEFORE `config.plugins` are registered, and `Kernel.use` overwrites by
name, so today the HOST's instance is the survivor -- the CLI's four are
constructed, registered and dropped. This is therefore precedence restoration,
not a live-bug fix: the host winning stops being an accident of block ordering
that nothing states or pins.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj
…8357)
Reads the surviving instance off the kernel rather than trusting the planner
alone, with every host fixture constructed from an argument the CLI cannot
produce so the two instances are distinguishable at all.
Also records a measured gap found while writing the drift guard: esbuild
rewrites a class that references itself by name into `class _X`, so the BUILT
MarketplaceProxyPlugin reports `_MarketplaceProxyPlugin` and the class-name
limb of the identity registry never matches it. The registered-name limb
carries the guard; filed separately rather than fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj
@vercel

vercelBot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 14, 2026 7:10am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli.

17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via packages/cli)
  • content/docs/api/client-sdk.mdx(via @objectstack/cli)
  • content/docs/api/data-flow.mdx(via @objectstack/cli)
  • content/docs/api/environment-routing.mdx(via @objectstack/cli)
  • content/docs/api/error-catalog.mdx(via @objectstack/cli)
  • content/docs/automation/hook-bodies.mdx(via packages/cli)
  • content/docs/deployment/backup-restore.mdx(via @objectstack/cli)
  • content/docs/deployment/cli.mdx(via @objectstack/cli)
  • content/docs/deployment/self-hosting.mdx(via @objectstack/cli)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/cli)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/cli)
  • content/docs/kernel/runtime-services/index.mdx(via packages/cli)
  • content/docs/permissions/authentication.mdx(via @objectstack/cli)
  • content/docs/plugins/index.mdx(via @objectstack/cli)
  • content/docs/plugins/packages.mdx(via @objectstack/cli)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx(via @objectstack/cli)

3 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/cli)
  • content/docs/releases/v16.mdx(via @objectstack/cli)
  • content/docs/releases/v17.mdx(via @objectstack/cli)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 14, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 14, 2026 08:31
@os-zhuang
os-zhuang merged commit 189a732 into mainAug 14, 2026
29 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8357-cloud-arm-host-precedence branch August 14, 2026 08:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding: serve.ts's cloud-connected marketplace arm can silently replace a host config's own marketplace plugins

2 participants

@os-zhuang@claude