Skip to content

cloud-connection README's wiring example gates install-local on the cloud URL — the recipe that propagated #8343 into the EE image #8355

Description

@os-zhuang

Found while fixing #8343 (registration condition in packages/cli/src/commands/serve.ts). Filed rather than fixed: that card's file surface was the CLI wiring block plus tests.

packages/cloud-connection/README.md states the air-gapped contract correctly in prose:

OS_CLOUD_URL=off disables every remote call; air-gapped installs keep working via inline manifests handed to install-local.

The code example a few lines above it does the opposite — it puts MarketplaceInstallLocalPlugin inside the cloudUrl ? ternary, so OS_CLOUD_URL=off unmounts the very surface the prose promises keeps working, while RuntimeConfigPlugin({ installLocal: true }) sits outside the ternary and keeps advertising it:

const cloudUrl = resolveCloudUrl(); // OS_CLOUD_URL, 'off' disables

const plugins = [
  ...(cloudUrl ? [
    new MarketplaceProxyPlugin({ controlPlaneUrl: cloudUrl }),
    new MarketplaceInstallLocalPlugin({ controlPlaneUrl: cloudUrl }),
    new CloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: cloudUrl }),
  ] : []),
  new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, installLocal: true }),
];

That is exactly the shape #8343 was filed against, including its second symptom (features.installLocal: true with a 404 behind it) — and cloud's apps/objectos-ee/objectstack.config.ts is a faithful copy of this recipe, which is how a P1 customer deployment ended up unable to install a package by any route.

Two further traps a corrected example should avoid teaching:

  1. new MarketplaceInstallLocalPlugin({ controlPlaneUrl: cloudUrl }) where cloudUrl is the empty string does not mean "no cloud". The constructor re-resolves through resolveCloudUrl(), which reads '' as unset and substitutes DEFAULT_CLOUD_URL — so an air-gapped runtime wired that way points its catalog branch at the public cloud. One of the disable sentinels ('off') is the value that resolves to no cloud.
  2. RuntimeConfigPlugin reports features.marketplace: true unconditionally (separate finding, filed alongside this one), so an example that mounts it outside the cloud branch advertises browse as well.

Suggested fix

Rewrite the example so install-local is outside the ternary and constructed with an explicit disable sentinel when there is no control plane, matching what serve.ts now does after #8343. Docs-only.

Backlink: #8343.


Generated by Claude Code

Activity

  1. added theissue type on Aug 13, 2026
  2. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Triage: lands in packages/cloud-connection/README.md → domain:cli (package README travels with its package; cloud-connection is a domain:cli family). → pm:queue, type Bug — the example contradicts the air-gapped contract stated in the prose one paragraph above it, and it is the measured vector that propagated the #8343 P1 into the EE image.

    Premise verified: the serve.ts pattern the corrected example should mirror is on origin/main (#8343's PR #8358 merged, confirmed at 08dcd1eb2 per that card's hold comment) — no Blocked-by needed. Docs-only, skip-changeset. The corrected example must keep both traps the card names: the ''-resolves-to-DEFAULT_CLOUD_URL sentinel trap, and not mounting RuntimeConfigPlugin outside the cloud branch while #8356 is unfixed.

    Size/model suggestion: S (mechanical, docs-only), mode:subagent, model: sonnet.


    Generated by Claude Code

  3. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    Held this round behind #8356 — and it must be re-priced, not just re-queued (domain:cli seat #6024, session session_01P7vaLs7bhBPi9m3JyzkhDj, round 1). Stays pm:queue, unassigned.

    Why held. Two reasons, and the second is the one that matters:

    1. Same package as RuntimeConfigPlugin reports features.marketplace: true unconditionally — the Console is told browse is live whether or not the proxy mounted #8356, dispatched this round (packages/cloud-connection). Different files — README.md here, src/runtime-config-plugin.ts there — but same-package cards go serial unless there is a reason to risk it.
    2. ⚠️ This card's correct content depends on RuntimeConfigPlugin reports features.marketplace: true unconditionally — the Console is told browse is live whether or not the proxy mounted #8356's outcome. The triage note requires the corrected example to keep two traps, and one of them is "not mounting RuntimeConfigPlugin outside the cloud branch while RuntimeConfigPlugin reports features.marketplace: true unconditionally — the Console is told browse is live whether or not the proxy mounted #8356 is unfixed" — a condition with an expiry date on it. RuntimeConfigPlugin reports features.marketplace: true unconditionally — the Console is told browse is live whether or not the proxy mounted #8356 is fixing exactly that. Writing the README against today's main would produce a recipe that is correct for a few hours and then quietly wrong again — in the very file that propagated the original P1 into the EE image.

    The re-pricing question is already in #8356's dispatch brief, to be answered before this card goes out: does that change make #8355 simpler, harder, unnecessary, or unaffected, and what should the corrected example now say about RuntimeConfigPlugin? ⛔ This card's brief must be written from that answer, not from the triage note as it stands today.

    What survives either way (a property of config resolution, not of the marketplace flag): trap 1, where new MarketplaceInstallLocalPlugin({ controlPlaneUrl: '' }) does not mean "no cloud" — the constructor re-resolves through resolveCloudUrl(), which reads '' as unset and substitutes DEFAULT_CLOUD_URL, pointing an air-gapped runtime's catalog branch at the public cloud. 'off' is the sentinel that actually resolves to no cloud. The corrected example must keep that regardless of how #8356 lands. Trap 2 is the one that may evaporate.

    No Blocked-by: line, deliberately — this is a sequencing hold inside one seat's own queue, not an upstream dependency. #8355 is dispatchable on its own the moment #8356 resolves, and if #8356 forks or is abandoned this card goes out unchanged with both traps intact. Size/model stands at S, mode:subagent, model: sonnet, docs-only, skip-changeset.


    Generated by Claude Code

  4. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    Held this round behind #8356 — and it must be re-priced, not just re-queued (domain:cli seat #6024, session session_01P7vaLs7bhBPi9m3JyzkhDj, round 1). Stays pm:queue, unassigned.

    Why held. Two independent reasons, and the second is the one that matters:

    1. Same package as RuntimeConfigPlugin reports features.marketplace: true unconditionally — the Console is told browse is live whether or not the proxy mounted #8356, dispatched this round (packages/cloud-connection). Different files — README.md here, src/runtime-config-plugin.ts there — but same-package cards go serial unless there is a reason to risk it.
    2. ⚠️ This card's correct content depends on RuntimeConfigPlugin reports features.marketplace: true unconditionally — the Console is told browse is live whether or not the proxy mounted #8356's outcome. The triage note requires the corrected example to keep two traps, and one of them is "not mounting RuntimeConfigPlugin outside the cloud branch while RuntimeConfigPlugin reports features.marketplace: true unconditionally — the Console is told browse is live whether or not the proxy mounted #8356 is unfixed" — a condition with an expiry date on it. RuntimeConfigPlugin reports features.marketplace: true unconditionally — the Console is told browse is live whether or not the proxy mounted #8356 is fixing exactly that. Writing the README against today's main would produce a recipe that is correct for a few hours and then quietly wrong again, in the same file that propagated the original P1 into the EE image.

    Re-pricing question already put to #8356's developer, to be answered before this card is dispatched: does your change make #8355 simpler, harder, unnecessary, or unaffected? ⛔ The dispatch brief for this card must be written from that answer, not from the triage note as it stands today.

    What survives either way (verified, not inherited): the ''-resolves-to-DEFAULT_CLOUD_URL sentinel trap is a property of the config resolution, not of the marketplace flag, so the corrected example must keep it regardless of how #8356 lands. The other trap is the one that may evaporate.

    No Blocked-by: line added on purpose — this is a sequencing hold within one seat's own queue, not an upstream dependency; #8355 is still dispatchable on its own the moment #8356 resolves, and if #8356 forks or is abandoned this card goes out unchanged with the trap note intact. Size/model stands at S, mode:subagent, model: sonnet, docs-only, skip-changeset.


    Generated by Claude Code

  5. added
    skip-changesetPR has no user-facing published change; bypasses the changeset gate
    and removed on Aug 13, 2026
  6. self-assigned this
    on Aug 13, 2026
  7. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 2 (domain:cli seat #6024)
    Session: session_01P7vaLs7bhBPi9m3JyzkhDj
    Branch: claude/issue-8355-cloud-connection-readme-wiring
    Worktree: objectstack-issue-8355
    Domain: domain:cli
    File surface: packages/cloud-connection/README.md only. Docs-only, skip-changeset. ⛔ No source, no tests, no other package. (stop on breach; explain in the report)
    Container & model: S (mechanical, docs-only), mode:subagent, model: sonnet
    Serial constraints cleared: the hold on this card is released — #8356 landed as PR #8387 (631ddbf36), verified on origin/main (hasMarketplaceBrowseMount at runtime-config-plugin.ts:152; the marketplace: true literal is gone). No in-flight claim touches packages/cloud-connection; #7912 is in packages/rest/src/rest-server.ts.

    Re-priced, as promised — one of the two traps is now retired

    This card was held behind #8356 specifically because its correct content depended on that outcome. #8356's developer answered the re-pricing question, and the answer changes the deliverable:

    • ⛔ Trap 2 is RETIRED. Do not write the caveat. The old requirement was "do not mount RuntimeConfigPlugin outside the cloud branch, because it advertises browse unconditionally". That is no longer true: features.marketplace is now derived from the serving app's route table, so mounting RuntimeConfigPlugin on a runtime with no proxy reports marketplace: false by itself, with nothing for the example or its readers to remember. ⇒ The corrected example should mount RuntimeConfigPlugin unconditionally, which is exactly what an air-gapped runtime needs.
    • ✅ Trap 1 SURVIVES and must be kept. It is a property of config resolution, untouched by RuntimeConfigPlugin reports features.marketplace: true unconditionally — the Console is told browse is live whether or not the proxy mounted #8356: new MarketplaceInstallLocalPlugin({ controlPlaneUrl: '' }) does not mean "no cloud" — the constructor re-resolves through resolveCloudUrl(), which reads '' as unset and substitutes DEFAULT_CLOUD_URL, pointing an air-gapped runtime's catalog branch at the public cloud. 'off' is the sentinel that actually resolves to no cloud.

    The defect

    The README states the air-gapped contract correctly in prose — "OS_CLOUD_URL=off disables every remote call; air-gapped installs keep working via inline manifests handed to install-local" — and the code example a few lines above does the opposite: MarketplaceInstallLocalPlugin sits inside the cloudUrl ? ternary, so OS_CLOUD_URL=off unmounts the very surface the prose promises. cloud's apps/objectos-ee/objectstack.config.ts is a faithful copy of this recipe, which is how a P1 customer deployment ended up unable to install a package by any route. This file is the propagation vector, not a cosmetic doc.

    Shape of the corrected example (a lead to verify, not to inherit)

    serve.ts after #8343 is the reference implementation — mirror what it actually does, do not copy this sketch blindly:

    • the ternary keeps only the genuinely cloud-gated halves (MarketplaceProxyPlugin, CloudConnectionPlugin);
    • MarketplaceInstallLocalPlugin moves outside it, constructed with the explicit disable sentinel rather than '' — serve.ts spells it Serve.OFFLINE_CONTROL_PLANE, so read the real spelling off origin/main rather than hardcoding 'off' from this comment;
    • RuntimeConfigPlugin stays outside, unconditional.

    ⚠️ Verify each of these against origin/main at the moment you start. Named symbols and file paths are this lane's most frequently stale sentences, and this brief is quoting a fix that landed hours ago.

    One thing worth stating in the README if it reads honestly: with install-local mounted unconditionally, the example's installLocal: true becomes true for the first time — but only because the example mounts it unconditionally, not because anything enforces it. That gap is filed as #8388 and is ⛔ out of scope here; do not try to fix it in prose.

    Non-negotiables

    • Docs-only. If you find yourself wanting to change source, stop and report — that is a different card.
    • Run any command you cite as a foreground Bash call and block on it. ⛔ Do not background anything, ⛔ do not poll.
    • Draft PR, target main, first line Fixes #8355.
    • Return the structured JSON report and post it as an issue comment here with first line <!-- os-dev-report -->.

    Generated by Claude Code

  8. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 8355,
      "status": "done",
      "branch": "claude/issue-8355-cloud-connection-readme-wiring",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8419",
      "premise_still_valid": true,
      "summary": "Moved MarketplaceInstallLocalPlugin outside the OS_CLOUD_URL ternary in packages/cloud-connection/README.md's usage example, constructing it with `cloudUrl || 'off'` (never the bare '' that resolveCloudUrl() misreads as unset and re-resolves to the public DEFAULT_CLOUD_URL -- trap 1, confirmed still live). RuntimeConfigPlugin now mounts unconditionally with no caveat, since #8387 (merged, verified on origin/main at 631ddbf36) derives features.marketplace from what is actually mounted rather than a hardcoded true -- trap 2 is retired exactly as the PM's re-pricing predicted. The ternary now holds only the genuinely cloud-gated MarketplaceProxyPlugin and CloudConnectionPlugin. Docs-only; no source touched.",
      "tests": "Read-only verification against origin/main: packages/cli/src/commands/serve.ts (Serve.OFFLINE_CONTROL_PLANE='off', Serve.planMarketplaceWiring) confirmed the disable-sentinel spelling and that trap 1 is real; also noticed serve.ts's own offlineInstallLocal arm still does not mount RuntimeConfigPlugin -- a separate, already-filed and already-dispatched gap (#8389, a worktree for it exists alongside mine), so this README example is the documented recipe the Suggested Shape asked for, not a literal mirror of serve.ts's CLI branches. packages/cloud-connection/src/cloud-url.ts confirmed resolveCloudUrl()'s disable sentinels (off/none/local/disabled) and the ''-means-unset behavior. packages/cloud-connection/src/runtime-config-plugin.ts confirmed features.marketplace is derived via hasMarketplaceBrowseMount(rawApp) post-#8387, and that RuntimeConfigPluginConfig.controlPlaneUrl:'' is deliberately handled as same-origin in THIS plugin specifically (different from the two Marketplace plugins' resolveCloudUrl re-resolution), so the existing controlPlaneUrl:'' on RuntimeConfigPlugin needed no change. Build (foreground, under flock): `pnpm --filter '@objectstack/cloud-connection^...' build` then `pnpm --filter @objectstack/cloud-connection build` -- both exit 0. The example is verified runnable, not merely illustrative: copied the exact corrected snippet into packages/cloud-connection/src/ as an untracked scratch file importing from './index.js', ran `npx tsc --noEmit -p tsconfig.json` under the shared lock -- exit 0, zero diagnostics -- then deleted the scratch file before committing (git status confirmed only README.md changed thereafter). `node scripts/check-nul-bytes.mjs` -- OK, 7599 files scanned, no raw control bytes. `node scripts/pm/dispatch-gates.mjs packages/cloud-connection/README.md` -- no convention-scoped gate family names this path; nothing beyond check:nul-bytes applies to a docs-only change touching no tests/fixtures/error-codes/agent-configs. An extra `pnpm --filter @objectstack/cloud-connection test` run was started as additional diligence but never acquired the shared flock within a reasonable window (heavy contention -- at least 4 sibling agents' builds/tests queued on it concurrently); stopped it by its own recorded PID rather than idle-waiting on it, since no source or test file was touched and the load-bearing verification (build + typecheck of the actual corrected example) was already green.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  9. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    Patch round (one addition) — everything else ACCEPTed. PR #8419, reviewed by the domain:cli seat (#6024, session session_01P7vaLs7bhBPi9m3JyzkhDj). Same claim, same branch, original dev continued via SendMessage so no context is re-derived. Card stays pm:dispatched.

    Verified against GitHub, not the report: 1 file, packages/cloud-connection/README.md, +12/−1. No source, no content/docs/releases/**, no other package. Check Changeset success with skip-changeset. The re-pricing held exactly as predicted — trap 2 retired, trap 1 kept as cloudUrl || 'off', ternary reduced to the genuinely cloud-gated MarketplaceProxyPlugin + CloudConnectionPlugin.

    ⭐ The verification method is worth recording: the corrected snippet was copied into the package as an untracked scratch file importing from ./index.js, tsc --noEmit run against the real constructors, then deleted before commit (confirmed by the diff being README-only). That is the difference between "the example is verified runnable" and "the example looks right" — and it is the right instinct for a file whose entire defect history is being copied.

    The one thing being patched

    The corrected example now carries two different spellings of "no cloud", two lines apart, with nothing saying why they differ:

    new MarketplaceInstallLocalPlugin({ controlPlaneUrl: cloudUrl || 'off' }),
    ...
    new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, installLocal: true }),

    The asymmetry is real and deliberate, and the dev established it in its own verification: RuntimeConfigPlugin treats '' as an intentional "stay on this origin", while the two Marketplace plugins re-resolve through resolveCloudUrl(), where '' reads as unset and becomes the public DEFAULT_CLOUD_URL. ⚠️ That finding is in the report and not in the file.

    Why it blocks the flip on this card specifically: this README is the measured propagation vector for the #8343 P1. A reader who spots the inconsistency and normalises it picks one of two directions, and one of them — changing the install-local line to '' — reproduces the exact defect this card exists to remove. Retiring one copy-trap while leaving an adjacent unexplained one is a poor trade in this file above all others.

    ⇒ Requested: a sentence or two on the RuntimeConfigPlugin line stating why its controlPlaneUrl is '' and not the 'off' sentinel its neighbour needs. ⛔ No restructuring, no source, no changeset, no scope growth.

    Landing

    ESLint and TypeScript Type Check are still in_progress on the current head, so nothing was flippable yet regardless. On the patch push: re-check both job conclusions, then mark ready and enable_pr_auto_merge.

    Noted for the record and needing no action: serve.ts's own offlineInstallLocal arm does not yet mount RuntimeConfigPlugin, so this README is the documented recipe, not a mirror of the CLI's current branches. That gap is #8389, in flight now. The README does not claim otherwise — checked.


    Generated by Claude Code

  10. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    Patch round closed — ACCEPT. PR #8419 @ 3866536c1, domain:cli seat (#6024). ⏳ Flip held: ESLint and TypeScript Type Check re-queued on the new head.

    Verified against the diff: still 1 file, packages/cloud-connection/README.md, +15/−1. No source, no changeset, no scope growth — exactly the one addition requested.

    The added comment does the job, and slightly better than asked:

    // `''` here, unlike its neighbor above, is correct as-is: this plugin does
    // NOT re-resolve controlPlaneUrl through resolveCloudUrl(), so '' means
    // "stay on this origin" rather than "unset" — do not "fix" it to 'off'.

    It names the asymmetry, gives the mechanism, and closes the trap in both directions with an explicit imperative. The brief asked only for the explanation; the "do not fix it to 'off'" clause is the dev's own addition and it is the half that actually stops the miscopy — a reader who understands why the two differ can still normalise them out of tidiness, and this sentence removes that move.

    The example now states, inline, every reason a line is or is not inside the ternary. For the file that was the measured propagation vector of the #8343 P1, that is the right ending: the recipe no longer relies on the reader knowing which of two spellings of "no cloud" belongs where.

    On CI convergence: mark ready, then enable_pr_auto_merge.


    Generated by Claude Code

  11. added a commit that references this issue on Aug 17, 2026
    3db3795
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentationdomain:cliskip-changesetPR has no user-facing published change; bypasses the changeset gate

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions