Skip to content

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

Closed

Description

@os-zhuang

Observation-class finding from #8343. No measured user impact today — filed so it is not rediscovered as a mystery later.

Kernel.use() stores plugins in a Map keyed by plugin.name, so registering a second plugin with the same name replaces the first entry rather than erroring or de-duplicating.

The marketplace wiring block in packages/cli/src/commands/serve.ts mounts MarketplaceProxyPlugin, MarketplaceInstallLocalPlugin, the cloud-connection surface and RuntimeConfigPlugin whenever resolveCloudUrl() is truthy and the kernel is not a runtime host kernel. It does not check whether the loaded host config already wired any of them. A host that mounts its own therefore has it replaced by the CLI's instance, constructed with the CLI's own arguments.

cloud's apps/objectos-ee/objectstack.config.ts is exactly that host: in single-environment mode with a real OS_CLOUD_URL it wires MarketplaceProxyPlugin, MarketplaceInstallLocalPlugin and createCloudConnectionPlugin itself, from the same classes (@objectstack/objectos-runtime re-exports them from @objectstack/cloud-connection, single source since ADR-0008 Phase 2). Today both sides construct them with the same resolveCloudUrl() value, so the replacement is behaviourally invisible. It stops being invisible the moment a host passes anything the CLI does not — a distinct control-plane URL, a custom storageDir, cache tuning.

Note the host-kernel guard does not cover this case: it detects ObjectOSEnvironmentPlugin, which the EE single-environment branch never constructs (only the OS_MULTI_TENANT branch does, via createObjectOSStack).

#8343 added exactly this presence check to the offline arm it introduced, using the existing Serve.providesCapability idiom, because there the replacement would be a genuine downgrade (an off-pinned instance overwriting a host's URL-configured one). The cloud-connected arm was deliberately left as-is: making the host win there is a real behaviour change for connected runtimes and wanted its own card.

Suggested direction

Apply the same identity check to the cloud arm, so a host config that wires its own marketplace plugins keeps them and the CLI auto-wiring stays a fallback for hosts that wire nothing. Alternatively, make Kernel.use() refuse or warn on a duplicate name instead of silently replacing — a broader fix with a much larger blast radius.

Backlink: #8343.


Generated by Claude Code

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 13, 2026
  2. added
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Aug 13, 2026
  3. added theissue type on Aug 13, 2026
  4. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Triage: routed domain:cli — the suggested fix (identity/presence check on the cloud-connected arm, mirroring what #8343 added to the offline arm) lands in packages/cli/src/commands/serve.ts. The broader alternative (Kernel.use() refusing duplicate names) would be domain:engine-core and a much larger blast radius; the grading round should weigh that trade, not this label. Stays finding (observation-class: behaviourally invisible today, becomes real the moment a host passes any argument the CLI does not), type Task. Held for the next findings-grading round.


    Generated by Claude Code

  5. added
    pm:queueReady for the PM dispatch loop
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 13, 2026
  6. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Concentrated findings round (maintainer-directed, 2026-08-13): promoted finding → pm:queue (domain:cli, per the morning routing). Type: Task.

    Grading: the narrow fix is the graded scope — the same presence/identity check #8343 added to the offline arm (Serve.providesCapability idiom), applied to the cloud-connected arm, so host-wired marketplace/cloud plugins keep precedence and CLI auto-wiring stays a fallback for hosts that wire nothing. This restores the config-is-authoritative reading, is behaviourally invisible for every current deployment (both sides construct identical instances today), and has a worked precedent in the sibling arm. The broader alternative (Kernel.use() refusing duplicate names) is explicitly REJECTED at this grading — engine-core blast radius, not this card; if someone wants it, that is a new card with its own measurement.

    Dispatch note: pin the EE single-environment host shape (apps/objectos-ee/objectstack.config.ts wiring its own three plugins) as the test case, and note the host-kernel guard gap the card documents (ObjectOSEnvironmentPlugin is not constructed on that branch) so the check does not get hung off the wrong sentinel.

    Size/model suggestion: S–M, mode:subagent, opus.


    Generated by Claude Code

  7. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 14, 2026
  8. self-assigned this
    on Aug 14, 2026
  9. os-zhuang commented on Aug 14, 2026

    @os-zhuang
    ContributorAuthor

    认领并派工(domain:cli 座位 #6024,session session_01P7vaLs7bhBPi9m3JyzkhDj)。

    • 分支:claude/issue-8357-cloud-arm-host-precedence
    • 模型:opus,mode:subagent(S–M,按评分轮建议)

    ⛔ 发版冻结中:PR 开成 draft 并停在 draft,不合并。 维护者正在准备发版,产出停在「绿着待命」状态,解冻后统一排队。

    派工时的串行状态

    packages/cli/src/commands/serve.ts 无 parked draft 占用 —— #8368(PR #8526)已于冻结之前合并,该文件空出。本卡持有它直到解冻。

    ⚠️ 前提需要复验,因为 serve.ts 刚被大改过

    PR #8526 在本卡写成之后落在同一个文件里(制品引用解析被放到任何 artifact 查找之前,并注册了启动迁移闸门)。卡片里的行号和周边形状可能全部失效。 brief 要求在新分支上按符号复验三件事:Kernel.use() 是否仍以 plugin.name 为键静默替换、cloud 臂是否仍未检查 host 已接线什么、offline 臂的 Serve.providesCapability 检查是否还在(那是要镜像的样板)。若有变化,如实报回来,不要把卡片的形状硬套到已经不同的代码上。

    评分轮的收窄是约束性的

    只做窄修:把 #8343 给 offline 臂加的那个存在性/身份检查,同样加到 cloud 臂。⛔ 明确不做 Kernel.use() 拒绝重名 —— 评分轮已否决(engine-core 爆炸半径),要做是另一张卡带自己的测量。已写进 brief。

    测试必须刻意构造,否则不可能失败

    今天两边用同一个 resolveCloudUrl() 值构造,替换行为上不可见 —— 所以「应用还能跑吗」这类测试改前改后都绿,什么也证明不了。已要求让两个实例可区分:host 接线时传一个 CLI 不会传的东西(不同的 control-plane URL、自定义 storageDir、缓存调参),然后断言活下来的是 host 的那个。改前必须红,改后必须绿。

    同时点明了卡片记录的那个陷阱:现有 host-kernel 守卫认的是 ObjectOSEnvironmentPlugin,而 EE 单环境分支从不构造它(只有 OS_MULTI_TENANT 分支经 createObjectOSStack 会)—— 把检查挂在那个哨兵上,对本卡针对的那个 host 恰好不会触发。

    严重性如实

    卡片自陈今天无可测量的用户影响,是观察类发现,只在 host 传了 CLI 不传的参数时才变成真的。已要求 PR 里如实这么写 —— 这是配置权威性/优先级的恢复,不是活体缺陷修复。


    Generated by Claude Code

  10. os-zhuang commented on Aug 14, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 8357,
      "status": "done",
      "branch": "claude/issue-8357-cloud-arm-host-precedence",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8646",
      "premise_still_valid": false,
      "summary": "Applied the graded narrow fix: each of the cloud-connected arm's four mounts (MarketplaceProxyPlugin, MarketplaceInstallLocalPlugin, createCloudConnectionPlugin, RuntimeConfigPlugin) is now guarded on its own Serve.providesCapability presence check, with two new identity registries (MARKETPLACE_PROXY_IDENTITIES, CLOUD_CONNECTION_IDENTITIES) and one flag per surface out of planMarketplaceWiring, so a host composing any subset keeps exactly what it wired. Kernel.use() untouched, per the grading round's explicit rejection of that option. PREMISE CORRECTION: the mechanism re-verified exactly (ObjectKernel.use is a Map keyed by plugin.name, silent replace, no dedupe; the cloud arm was unguarded; the offline arm's check is still there to mirror), but the DIRECTION is inverted on this tree — the CLI's block runs at serve.ts:1957 and config.plugins are registered at serve.ts:2474, so the later set wins and today the HOST's instance already survives while the CLI's four are constructed and dropped. The card's 'replaced by the CLI's instance' does not happen. The fix still stands and is more precisely motivated: host precedence stops being an accident of block placement that nothing states or pins, inverts silently if either block moves, and fails the other way on a kernel whose use() rejects duplicates (LiteKernel throws). No measured user impact today, stated plainly in the PR — this is precedence restoration, not a live-bug fix. Draft PR, left in draft, no auto-merge armed, not enqueued, per the release freeze.",
      "tests": "New: packages/cli/test/serve-marketplace-cloud-host-precedence.test.ts — 19 tests, all passing. Three layers: per-surface planner rules + the whole EE single-environment shape; identity drift guards against the real classes; and a kernel layer that registers into a REAL ObjectKernel and reads back which instance survived. DISTINGUISHABILITY (the point the dispatch flagged): every host fixture is built with something the CLI cannot pass — proxy controlPlaneUrl https://control-plane.internal.example + cacheMaxEntries 4096; install-local the same private control plane + storageDir /srv/objectos/installed-packages; cloud-connection credentialPath /srv/objectos/cloud-connection.json; runtime-config productName 'Contoso Operations' — and each case asserts the survivor carries the host's value, with a paired precondition assertion so a renamed field goes red instead of vacuously green. REVERSE VERIFICATION, direction predicted and recorded BEFORE the run: ablate the four guards to literal true, predicted 6 red / 13 green with cli-then-host staying GREEN (the host already wins in the real order) and host-then-cli going RED. Measured exactly that: 'Tests 6 failed | 13 passed (19)', with '✓ ... (cli-then-host)' and '× ... (host-then-cli)' and the assertion diff showing the host's instance replaced by the CLI's ('max': 4096 -> 200, cloudUrl 'https://control-plane.internal.example' lost). The template's presumption that an ablation simply goes red is only half true here, and that half-green IS the evidence for the inverted premise. Restored via git restore --source=HEAD, git status --porcelain empty. Suite: pnpm --filter @objectstack/cli exec vitest run -> 'Test Files 120 passed (120) / Tests 1316 passed (1316)'. pnpm --filter @objectstack/cli typecheck clean. Full workspace build then pnpm check:type-check-debt (--self-test && --re-measure) -> 'OK — 33 ledger entr(ies) re-measured ... none above its recorded number'; NO ledger entry raised (the only note is a pre-existing surplus in @objectstack/lint, records 20 measures 19, untouched by this branch). Gates re-derived with scripts/pm/dispatch-gates.mjs against the actual changed paths, which surfaced families the dispatch prompt did not name — all run 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, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs. Changeset added (patch, @objectstack/cli). CI gate conclusions not awaited — reporting at draft-PR time per contract.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #8645: esbuild renames a class that references itself by name, so the built MarketplaceProxyPlugin is `_MarketplaceProxyPlugin` and the class-name limb of Serve.providesCapability's identity registries matches nothing against the shipped package for it — the registered-name limb carries every guard so there is no impact, but the redundancy is dead and serve-capability-identity.test.ts cannot see it (it compares the registry against its own spec string, never against Ctor.name). Labelled `finding` + `domain:cli`, unassigned, no pm:queue."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  11. os-zhuang commented on Aug 14, 2026

    @os-zhuang
    ContributorAuthor

    PR #8646 已开,评级 ACCEPT —— 按发版冻结停在 draft,不合并。 本卡不结单,保持 open + pm:dispatched,解冻后再复验落地。

    ⚠️ 卡片的前提是反的,而这是复验测出来的

    本卡正文写:

    A host that mounts its own therefore has it replaced by the CLI's instance

    dev 实测发现方向反了:CLI 的 marketplace 块在 serve.ts:1957,注册 config.plugins 的循环在 serve.ts:2474 —— 晚约五百行。既然 Map.set 后写者胜,今天活下来的是 host 的实例,CLI 那四个被构造、注册、然后丢弃。

    我在派工时要求「复验前提,若已变化如实报回,不要把卡片的形状硬套到不同的代码上」—— 这正是它该起作用的地方,而 dev 没有顺着卡片的叙述写一个印证叙述的测试。

    但结论不是「卡片作废」,而是范围被磨得更利了。 dev 的论证:host 获胜是一个 4000 行文件里两个互不提及的块的位置意外 —— 没有任何地方陈述它,也没有任何测试钉住它;任一块挪动就会静默反转;而在 use 拒绝重名的内核上(LiteKernel 会抛),失败的反而是 host 的注册。本次改动让结果不再依赖这些,并且顺带不再构造四个马上要丢掉的插件。

    这个判断我认可:把一个未声明的巧合变成一条规则,即使今天行为不变,也是有价值的 —— 这正是「配置权威性恢复」的确切含义。

    反向验证做得比模板更好

    派工模板的默认预期是「消融后全红」。dev 没有照抄这个预期,而是先预测 6 红 13 绿,并明确指出 cli-then-host 会保持绿 —— 因为在那个顺序下 host 本来就赢,没有守卫也赢。实测正是 6 红 13 绿,cli-then-host 绿、host-then-cli 红。

    那一条绿本身就是前提反转的证据。 这比「全红」信息量大得多:它证明了 cli-then-host(即 serve.ts 真实运行的顺序)单独拿来测无法区分修好的和坏掉的构建。测试同时跑两个注册顺序正是为此 —— 要主张的性质是「host 的组合因为是规则而获胜,不是因为块的位置」。

    测试构造符合要求

    今天两边构造出的实例可互换,所以「应用还能启动吗」这类测试改前改后都绿。四个面各自用了 CLI 的自动接线造不出来的东西作区分(私有 control plane、自定义 storageDir、credentialPath、白标 productName),并断言活下来的携带 host 的值。每个身份检查配了前置条件断言,构造参数被改名时测试会变红而不是空绿。

    哨兵陷阱也避开了:检查挂在每个插件自己的身份上,而非 ObjectOSEnvironmentPlugin —— EE 单环境分支从不构造后者。

    范围纪律

    Kernel.use() 未动,符合评分轮的否决。cloudSurfaces 保留原名与原有断言,只在文档注释里收紧含义为「臂选择器」而非挂载决策。

    衍生发现 #8645(已单独归档,未夹带)

    写漂移守卫时测到:构建产物里 MarketplaceProxyPlugin 变成了 _MarketplaceProxyPlugin —— esbuild 会重命名自引用的类,于是这些身份注册表的类名分支对该插件匹配不到任何东西。注册名分支承载了全部守卫,所以无实际影响;但那段冗余是死的,而现有漂移测试看不见它 —— 它拿注册表和自己的 spec 字符串比,从不和 Ctor.name 比。

    这正是「一个不可能失败的检查」。归档为独立卡而非顺手改,处理正确。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions