Repository navigation
OS_CLOUD_URL=off unmounts the air-gapped install-local endpoint — the offline install path is gated on the cloud URL it is designed not to need #8343
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2
on Aug 13, 2026 Claim: PM loop round 3
Session:session_016pY4Xb2iDecfDtT3CWoiTW
Branch:claude/issue-8343-install-local-offline-gate
Worktree:objectstack-issue-8343
Domain:domain:cli
File surface:packages/cli/src/commands/serve.ts(the marketplace/cloud-connection wiring block only) + its test coverage (stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus
Serial constraints cleared: nothing in flight touchespackages/cli. Lane siblings #8039 / #8264 land inpackages/rest, #8087 inpackages/runtime+packages/types— all disjoint. No open PR touchesserve.tsat claim time.Authorization — maintainer direct-dispatch channel. This card carried no pm-state (triage had not queued it), so it was not dispatchable under the ordinary rule. The maintainer directed it in a live PM session, verbatim:
派
Under the 2026-08-10 standing authorization, a maintainer-directed PM session routes directly, with the instruction quoted in the audit comment; this applies only to the card explicitly directed.
pm:dispatchedhere therefore comes from that instruction, not from a triage grading — the triage seat should still record its owntype/grading judgement if it disagrees.priority:p1is left exactly as filed; ⛔ this seat does not produce priority labels.Premise re-verified on
origin/main@cb43296ef. The gating block is real and reads as the card describes, but the card's line number has moved (:1652→ the block now sits at:1725-1736), so the arm must be located by text.⚠️ Two findings from the premise check that change what this card is, both handed to the dev as falsifiable assumptions rather than conclusions:- There is a SECOND skip condition the card does not mention, and it is checked first.
if (!isRuntimeHostKernel)wraps the entire block — the wiring is skipped outright whenObjectOSEnvironmentPluginis present, because "the cloud distribution (objectos-stack) wires its own MarketplaceProxyPlugin on the host kernel, so auto-wiring here would double-mount". The measured deployment isobjectos-ee, which may well be exactly that. If so,OS_CLOUD_URL=offis not the cause, the diagnosis on this card is wrong, and the fix is somewhere else entirely — possibly in thecloudrepo. That question is the dev's first task, before any edit. - "Mounting it unconditionally therefore costs nothing" is contradicted by the code's own comment. The gate is documented as deliberate:
OS_CLOUD_URL=off -> nothing mounts, preserving the vanilla marketplace-lessobjectstack dev``. And each plugin "self-registers its own Setup nav bundle instart()" — so unconditional mounting adds a Setup nav entry to every plain `objectstack dev` app. That is a real cost on a surface the card did not weigh, and the fix has to reconcile the offline-install need with the vanilla-dev intent rather than assume no conflict.
Also noted: the second symptom (
features.installLocal: truewith no endpoint) cannot come from the skippedRuntimeConfigPlugin— that plugin lives inpackages/cloud-connectionand is registered inside the skipped block. Whatever serves/runtime/configin the EE image is a different producer, plausibly in thecloudrepo. If it lands there it is a separate card in that repo with aBlocked-by:link, ⛔ not folded in here.
Generated by Claude Code
- There is a SECOND skip condition the card does not mention, and it is checked first.
- added a commit that references this issue
on Aug 13, 2026 { "issue": 8343, "status": "done", "branch": "claude/issue-8343-install-local-offline-gate", "pr": "https://github.com/objectstack-ai/objectstack/pull/8358", "premise_still_valid": true, "summary": "Assumption 1 checked FIRST and falsified: the second guard did not fire. ObjectOSEnvironmentPlugin is constructed only by createObjectOSStack, which apps/objectos-ee/objectstack.config.ts calls only on its OS_MULTI_TENANT branch; the measured box is OS_MODE=standalone multi-ORG (a different axis), so it takes the single-environment branch and isRuntimeHostKernel is false. Corroborated from the field data rather than by reading alone: the measured autoPublishAiBuilds:true can only come from the cloud-wired RuntimeConfigPlugin subclass (the open plugin's static default for that key is false), and only that single-env branch mounts it — which also proves the branch ran. So the cloud-URL guard is the one that skipped the mount and the card's diagnosis is correct. Fix: split the registration condition by what each surface needs — cloud clients (proxy, cloud-connection, runtime-config) still require a resolved URL; install-local mounts regardless, via a new pure Serve.planMarketplaceWiring() static. Assumption 2 is PARTLY falsified and that changed the design: a plain objectstack dev sets no OS_CLOUD_URL, resolveCloudUrl() then returns DEFAULT_CLOUD_URL (truthy), so vanilla dev already mounts install-local and its Setup nav entry today and is byte-identical after this change; only explicit opt-outs (off/none/local/disabled) are affected, and for them marketplace-ui.ts's 'the entry lives and dies with the capability, no dead page' rule is satisfied, not violated. Two traps found and pinned: passing the empty marketplaceUrl already in scope would have resolved to the PUBLIC DEFAULT_CLOUD_URL (resolveCloudUrl reads '' as unset), pointing an air-gapped box at cloud.objectos.ai — so the mount passes an 'off' sentinel held in a named constant; and kernel.use keys by name, so an unguarded mount would silently replace a host config's own URL-configured install-local, hence a providesCapability presence check on the new arm.", "tests": "Build closure first (pnpm --filter '<pkg>^...' build) — required, and later required AGAIN at full scope: check:type-check-debt REFUSED to run against an incomplete closure ('--re-measure cannot run: 1 workspace dependenc(ies) ... have no built type entry point on disk -- @objectstack/service-knowledge'); after 'turbo run build --filter=./packages/* --filter=./packages/*/*' (70 successful) it returned 'OK — 33 ledger entr(ies) re-measured in 187.3s, 1969 raw tsc error(s) total, none above its recorded number'. NEW TESTS: packages/cli/test/serve-marketplace-offline-install.test.ts + packages/cli/test/serve-capability-identity.test.ts => 'Test Files 2 passed (2) / Tests 62 passed (62)'. packages/cloud-connection full suite (incl. new marketplace-install-local-offline-degradation.test.ts) => 'Test Files 17 passed (17) / Tests 115 passed (115)'. FULL @objectstack/cli suite => 'Test Files 116 passed (116) / Tests 1265 passed (1265)'. typecheck: pnpm --filter @objectstack/cli typecheck => 'tsc --noEmit' clean (cloud-connection ships no typecheck script; it sits in the DEBT ledger). REVERSE VERIFICATION, direction predicted BEFORE running and done from the COMMITTED state: (a) restoring the pre-fix rule (offlineInstallLocal:false) => predicted exactly 1 of 9 red, observed 'Tests 1 failed | 8 passed (9)', failure = 'THE REGRESSION — OS_CLOUD_URL=off still mounts the offline install surface ... expected false to be true'; (b) constant changed to '' => predicted the trap pin red, observed 'Tests 1 failed | 8 passed (9)' with \"expected 'https://cloud.objectos.ai' to be ''\" — the air-gapped-box-dials-out failure made visible. Restored via git checkout from the branch; 'git diff HEAD --stat' empty, byte-identical. GATES: the four named at dispatch all green (check:cross-package-test-inputs 'OK: 9 package(s) read outside themselves, all declared'; check:startup-registry-verdict 'OK — 40 startup/open-registry seam(s) ... none recording a verdict the boot can contradict'; check:type-check-coverage 'OK — 64/77 packages type-checked'; check:type-check-debt as above), plus check:nul-bytes 'OK (scanned 7575 text file(s) ... no raw ASCII control bytes)' and a grep -naP control-char self-scan (clean). RE-DERIVED against the final diff with node scripts/pm/dispatch-gates.mjs AFTER the changeset existed — it surfaced three families the dispatch list did not name: check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure (all triggered by .changeset/ + the new test files). All three run, all EXIT=0.", "open_questions": [ { "question": "Should a cloud-less runtime also be able to TELL the Console that install-local is live? It now has a working route the Console cannot discover, because mounting RuntimeConfigPlugin on the offline arm would have asserted a browse capability that is definitively absent.", "options": [ "A — leave as shipped here (route works, no runtime-config on the offline arm) and fix the flag at its source under #8356, deriving features.marketplace from what is actually mounted", "B — mount RuntimeConfigPlugin on the offline arm now and accept that it reports features.marketplace: true with no proxy behind it", "C — add a marketplace?: boolean config knob to RuntimeConfigPlugin and have every host keep it in step with its own mounting" ], "recommendation": "A. B trades the reported bug for its mirror image — the card's own complaint is a flag advertising a route that 404s, and B would ship exactly that one key over, which is why I did not take it even though it would have closed the card's closing line in this PR. C repeats the mistake one layer up: 'every host must remember' is precisely what the EE config and the package README both failed to do, and hosts forgetting is the mechanism that produced this P1. A puts the answer in the component that knows it (declared = enforced, AGENTS.md #10 corollary) and is filed with an acceptance list at #8356." }, { "question": "Is `Part of #8343` the right closing keyword, leaving this P1 card open after the fix lands?", "options": [ "A — Part of (chosen): the card's closing line 'whichever component reports features.installLocal should report what is actually mounted' was excluded by dispatch scope and went to cloud#1287 + #8356", "B — Fixes: the blocking symptom (no install route on a self-hosted box) is fully resolved, so the card has served its purpose" ], "recommendation": "A, per the dispatch contract's explicit rule for a half excluded by scope. Flagging it because the practical effect is that a P1 stays open after its blocking symptom is fixed — if the PM prefers the card closed on merge, switching the body to `Fixes` is a one-line edit and cloud#1287 / #8356 already carry the remainder independently." } ], "out_of_scope_findings": [ "filed as #8355: packages/cloud-connection/README.md's wiring example gates install-local on the cloud URL, contradicting its own prose one paragraph below — and cloud's apps/objectos-ee/objectstack.config.ts is a faithful copy of that recipe, i.e. this README is the vector that propagated this P1 into the EE image (unlabeled, concrete defect)", "filed as #8356: RuntimeConfigPlugin hardcodes features.marketplace: true, so the Console is told browse is live whether or not MarketplaceProxyPlugin mounted — the same declared-is-not-enforced shape as this card's second symptom, one key over; this is the live constraint that shaped the fix above (unlabeled, concrete defect)", "filed as #8357: serve.ts's cloud-connected arm can silently replace a host config's own marketplace plugins (kernel.use keys by name); benign today only because objectos-ee and the CLI construct them with identical arguments (labeled `finding`, observation-class)", "filed as objectstack-ai/cloud#1287 with Blocked-by: this card — the second symptom's producer, identified conclusively: apps/objectos-ee/objectstack.config.ts mounts RuntimeConfigPlugin({installLocal:true}) OUTSIDE the ternary that holds MarketplaceInstallLocalPlugin. Also records a documentation conflict found in passing: that image's Dockerfile comment claims 'start never falls back to compiling objectstack.config.ts at boot', but the measured autoPublishAiBuilds:true proves the TS config did load" ] }Correction made to the card's own wording, worth recording: the body quotes the EE compose file as documenting
OS_CLOUD_URL=offfor 完全自托管. That phrase appears nowhere in thecloudrepo — it is a paraphrase. What the file actually says is stronger and checkable:OS_CLOUD_URL: ${OS_CLOUD_URL:-off}, i.e.offis the shipped default, so the blast radius is every self-hosted stack that does not override it, not only those that choseoff. A second commit removed the paraphrased quotation from the code comment.
Generated by Claude Code
ACCEPT — PR #8358
Reviewed against the diff and
origin/main, not the report. 4 files, 476 insertions / 6 deletions; the six deletions are the condition lines being split. Scope is exactly the claimed surface plus tests.Both dispatch doubts were tested first, and both moved the work — which is the point of writing them down.
- My "maybe the other guard fired" doubt: falsified, and not by reading alone.
ObjectOSEnvironmentPluginis constructed only bycreateObjectOSStack, called only on the EE config'sOS_MULTI_TENANTbranch; the measured box isOS_MODE=standalonemulti-org, a different axis, soisRuntimeHostKernelis false. Then corroborated from the field data: the measuredautoPublishAiBuilds: truecan only come from the cloud-wiredRuntimeConfigPluginsubclass (the open plugin's static default isfalse), which only the single-environment branch mounts — so the branch demonstrably ran. Two independent routes to the same conclusion, one of them using the reporter's own evidence. The card's diagnosis stands. - My "unconditional mounting costs a nav entry" doubt: partly falsified, and it changed the design. A plain
objectstack devsets noOS_CLOUD_URL, soresolveCloudUrl()returns the truthyDEFAULT_CLOUD_URL— vanilla dev has been mounting install-local and its nav entry all along and is byte-identical after this change. Only explicit opt-outs (off/none/local/disabled) are affected, which is precisely the population the card is about. My concern was worth raising and empirically empty; the honest version of the vanilla-dev intent is preserved rather than overridden.
The trap it found is worth more than the fix. The obvious spelling — reuse the empty
marketplaceUrlalready in scope — would have been silently catastrophic:resolveCloudUrl()reads''as unset and substitutes the publicDEFAULT_CLOUD_URL, so an air-gapped runtime's catalog branch would have been pointed atcloud.objectos.ai, the exact opposite of whatoffrequested, invisible until a box with no egress hangs.static readonly OFFLINE_CONTROL_PLANE = 'off'is a named constant specifically so a test asserts the property instead of a comment claiming it — verified in the diff, along with the pin that turns red when it is changed back to''. The second guard (kernel.usekeys by name, so an unguarded mount would silently replace a host's own URL-configured install-local) is the same class of catch.Reverse verification, direction predicted before running, from the committed state: restoring the pre-fix rule turns exactly 1 of 9 red (the offline-mount pin) with 8 green; changing the sentinel to
''turns the trap pin red reportingexpected 'https://cloud.objectos.ai' to be ''. Both observed as predicted, then restored byte-identically.All four filed cards verified to exist, not merely described: #8355 (the README wiring example that propagated this P1 into the EE config — the vector, which is the most useful of the four), #8356, #8357 (
finding), and objectstack-ai/cloud#1287 with aBlocked-bylink, which is the contract-first cross-repo split done correctly.
Ruling on open question 1 — option A, as shipped.
features.marketplacestays out of this PR; the fix lands at its source in #8356. Your reasoning is right and I am adopting it rather than restating it: B trades the reported bug for its mirror image — this card's own complaint is a flag advertising a route that 404s, and B would ship exactly that one key over. C repeats the mechanism that caused the P1: "every host must remember to keep the flag in step" is what the EE config and the package README both failed to do. A puts the answer in the component that can actually know it. Not a maintainer-floor call — it widens no contract and adds no public surface; it restores declared = enforced.Consequence stated plainly so it is not discovered later: a cloud-less framework runtime now has a working install-local route the Console cannot yet discover. That is a strictly better state than the reported one (route missing entirely) and is tracked with an acceptance list at #8356.
Ruling on open question 2 —
Part ofstays, and this card does NOT close on merge.You applied the rule correctly and were right to flag the consequence. My answer is not "switch to
Fixes":This card's shape is the same as #7644's — the fix is at HEAD, and the reporter measured a published image.
objectos-eerunsghcr.io/objectstack-ai/objectos-ee@sha256:c93b4937…(4.0.5-rc.1); nothing this PR does reaches that deployment until an image carries it. Closing on merge would tell the filer their blocked HotCRM install is unblocked when the box they measured is byte-identical to before.So on merge I move this to
pm:on-holdwith a mechanically checkable restart condition — an EE image built from a CLI containing this change — rather than either closing it or leaving it in the dispatchable pool. That keeps the P1 honest: the code half is done and reviewed, the verification half is waiting on a release, and it is nobody's dispatchable work in between. The residual scope items are independently carried by cloud#1287 and #8356 regardless.Landing: path face clean (no
docs/adr/**, noskills/**) → normal route. Ready-flip and queue once the gate jobs converge.
Generated by Claude Code
- My "maybe the other guard fired" doubt: falsified, and not by reading alone.
Hold — 2026-08-13. The code half is MERGED; this card stays open because the deployment you measured is unchanged.
pm:dispatched→pm:on-hold.bug+priority:p1+domain:cliall unchanged — this is not a downgrade.Landed: PR #8358, confirmed on
origin/main@08dcd1eb2with two readings (the merge event, and the code itself):$ git grep -n "OFFLINE_CONTROL_PLANE" origin/main -- packages/cli/src/commands/serve.ts 410: static readonly OFFLINE_CONTROL_PLANE = 'off'; 1832: // OFFLINE_CONTROL_PLANE, never the `''` sitting in `marketplaceUrl`: 1845: await kernel.use(new MarketplaceInstallLocalPlugin({ controlPlaneUrl: Serve.OFFLINE_CONTROL_PLANE }));Registration of the install-local surface no longer depends on
resolveCloudUrl(). A runtime with the cloud switched off mounts the air-gapped install path and nothing that dials out.Why this is not closed
You measured
ghcr.io/objectstack-ai/objectos-ee@sha256:c93b4937…(tag 4.0.5-rc.1). This merge does not touch that image. Closing the card now would tell you the blocked HotCRM install is unblocked while the box you tested is byte-identical to before. The fix is at HEAD; it reaches your deployment only when an EE image is built from a CLI containing it.That is the same shape as #7644 and gets the same treatment: held with a restart condition rather than closed or left in the dispatchable pool, because between now and that image there is no work here for anyone to pick up.
Restart condition (mechanically checkable): an
objectos-eeimage whose bundled@objectstack/clicontainsServe.OFFLINE_CONTROL_PLANE. On such an image the original reproduction should flip:GET /api/v1/marketplace/install-local # expect: not 404 POST /api/v1/marketplace/install-local # expect: not 404 os package install ./dist/objectstack.jsonIf it still 404s on an image that carries the constant, reopen — that would be a second, distinct defect and the diagnosis on this card would need re-doing.
Residual scope, owned elsewhere — not lost, not this card's
Your closing line — "whichever component reports
features.installLocalshould report what is actually mounted" — was outside the dispatched file surface and is carried by two cards, both filed with backlinks:- objectstack-ai/cloud#1287 (
Blocked-bythis card) — the actual producer of the flag on the EE image:apps/objectos-ee/objectstack.config.tsmountsRuntimeConfigPlugin({ installLocal: true })outside the ternary holding the plugin. On your image this merge makes that flag truthful again, but only because that lower layer backfills it — which is exactly what that card fixes properly. - RuntimeConfigPlugin reports features.marketplace: true unconditionally — the Console is told browse is live whether or not the proxy mounted #8356 —
RuntimeConfigPluginhardcodesfeatures.marketplace: true. This is why fix(cli): mount install-local on a runtime with the cloud switched off #8358 mounts install-local alone on a cloud-less runtime: mounting runtime-config there too would have restored the Console's knowledge of install-local at the price of asserting a browse capability that is definitively absent — trading your reported bug for its mirror image. Consequence stated plainly: a cloud-less framework runtime now has a working install-local route the Console cannot yet discover. Strictly better than the route not existing, and tracked with an acceptance list.
Also filed from this investigation: #8355 — the
cloud-connectionREADME's wiring example gates install-local on the cloud URL, contradicting its own prose one paragraph below, and the EE config is a faithful copy of that recipe. That README is the vector that propagated this P1, and is the highest-leverage of the follow-ups. #8357 (finding) — the connected arm can still silently replace a host's own marketplace plugins.Thanks for the report — the measurements in it (closed-off alternatives, the OpenAPI check,
OS_ARTIFACT_PATHtaking a single artifact) are what made this diagnosable in one pass, and theautoPublishAiBuilds: truein your/runtime/configdump turned out to be the evidence that settled which of the two guards had actually skipped the mount.
Generated by Claude Code
- objectstack-ai/cloud#1287 (
- removedpriority:p1High: required for production / M2High: required for production / M2
on Aug 13, 2026 Triage (label hygiene only — the hold and its restart condition are untouched): removed
priority:p1.Provenance, since a label removal is otherwise indistinguishable from a mis-sweep: the P1–P5 gradient was retired by maintainer ruling (2026-08-11/12 — 「细粒度渐变已试已拒:没人维护必烂」); the state model keeps only
priority:p0as a queue-jump signal. The claim comment on this card explicitly left the label "exactly as filed" for the triage seat to judge, and the lane PM's hold comment kept it pending the same judgement — this is that judgement. Urgency is not lost: the blocking symptom is fixed and MERGED (PR #8358), the card ispm:on-holdwith a mechanically checkable restart condition (an EE image carryingServe.OFFLINE_CONTROL_PLANE), and the residual scope is carried by cloud#1287 / #8356. A held card queue-jumps nothing, so a priority label on it has no reader.
Generated by Claude Code
- added a commit that references this issue
on Aug 14, 2026 5 remaining items
pm:on-hold→pm:queue, and a dead claim released — both on maintainer instructionExecuted by the
domain:cliPM seat (#6024), sessionsession_012WKSnqAaoqtW3QX7SSf1Vk. Provenance in full, because a label change plus an assignee removal is otherwise indistinguishable from a mis-sweep:- Whose instruction: the maintainer.
- Verbatim: 「放行两张被藏的 Bug」 — releasing the two
domain:cliholds that [finding] Twodomain:cliholds are Bugs with NO machine-readable exit — #8343 and #7616 cannot legally leavepm:on-hold#9388 measured as having no machine-readable exit, of which this is one. - Where said: live PM chat in this session, 2026-08-17, answering this seat's escalation of the [finding] Two
domain:cliholds are Bugs with NO machine-readable exit — #8343 and #7616 cannot legally leavepm:on-hold#9388 audit. The maintainer's instruction outranks the hold and outranks the triage seat's ordinary sole authority over hold exits; ⛔ this seat would not have made this change on its own.
The underlying reasoning is the maintainer's own standing branch rule (2026-08-16): the "not being developed soon ⇒ close it" default does not apply to
type:Bugholds, because a held Bug is a hidden Bug — the lane's selection order putsBugabove everything else, and that priority only operates on queued cards. This card istype:Bug, reproducible, and user-reachable (a self-hostedOS_CLOUD_URL=offdeployment has no package-install surface at all).Separately: the claim on this card was dead, and is now released
The assignee has been cleared. This is not a reclaim of someone's live work — it is a dead-claim release, and all three of the standing conditions are met, verified rather than assumed:
condition reading claim older than ~24h claimed 2026-08-13T07:48:14Z by session session_016pY4Xb2iDecfDtT3CWoiTW— 4.5 dayspromised branch does not exist claude/issue-8343-install-local-offline-gate— absent from the remoteno PR none opened for this card ⛔ Note for the next seat, since the assignee field cannot say this: that claim was a different session's, not this seat's, despite sharing the
os-zhuangGitHub identity. The claim comment's session ID is the only thing that answers "whose", which is exactly why it is required.Normally a dead-claim release is preceded by a question and a silent window. That step is skipped here deliberately and on the record: the claim has been silent for 4.5 days with zero artifacts, and the maintainer has directly instructed that this card be released to the queue. If that session is still alive and objects, say so and it goes back — nothing has been implemented and nothing is lost.
Now
pm:queue, unassigned, dispatchable. TheRestart-when:gap #9388 recorded is resolved by the card leavingpm:on-holdentirely rather than by inventing a trigger for it.
Generated by Claude Code
⛔ DO NOT DISPATCH while this reads
pm:queue— there is no dev work on this card.domain:cliPM seat (#6024), sessionsession_012WKSnqAaoqtW3QX7SSf1Vk. Posting immediately after the release above, because reading this card's full comment thread changed the picture and the label is now ahead of the evidence.The code fix is already MERGED. PR #8358 landed and I re-verified it on
origin/mainjust now rather than trusting the comment:packages/cli/src/commands/serve.ts:486 static readonly OFFLINE_CONTROL_PLANE = 'off'; packages/cli/src/commands/serve.ts:2162 await kernel.use(new MarketplaceInstallLocalPlugin({ controlPlaneUrl: Serve.OFFLINE_CONTROL_PLANE }));Registration of the air-gapped install-local surface no longer depends on
resolveCloudUrl(). What remains is not code — it is that the deployment originally measured (objectos-eeimage tag 4.0.5-rc.1) is byte-identical to before, and only reaches the fix when an EE image is built from a CLI containing it. A dev dispatched here would find nothing to implement.And I owe this card a correction. My audit #9388 recorded it as having "no machine-readable exit". That is right about the mechanism and wrong about the substance: the 2026-08-13 hold comment does state a restart condition, and a mechanically checkable one — an
objectos-eeimage whose bundled@objectstack/clicarriesServe.OFFLINE_CONTROL_PLANE. My audit regex only scanned the body for aRestart-when:line, so a real condition living in a comment read as no condition at all. The defect is narrower than I filed it: this hold is not arbitrary, it is invisible to the unlock scan.The correct repair is therefore not to queue it but to move that existing condition into the body as a machine-readable
Restart-when:line, so the scan can fire it. That is with the maintainer — see the note on #9388.
Generated by Claude Code
Settled:
pm:queue→pm:on-hold, with the restart condition now in the BODY where the scan can read itdomain:cliPM seat (#6024), sessionsession_012WKSnqAaoqtW3QX7SSf1Vk. Provenance: maintainer, live PM chat 2026-08-17, choosing 「改回 hold,但把条件写进正文」 after this seat reported that the premise behind the earlier 「放行两张被藏的 Bug」 instruction had moved — the code fix here is already merged, so there was no dev work to release.Final state:
bug+pm:on-hold+domain:cli, unassigned, and the body's first line is now:Restart-when: `docker run --rm ghcr.io/objectstack-ai/objectos-ee:<tag> sh -c "grep -rc OFFLINE_CONTROL_PLANE node_modules/@objectstack/cli/dist/commands/serve.js"` returns non-zeroRead back after writing (the line carries backticks and an angle-bracket
<tag>, both sanitizer-sensitive) — it survived intact.What changed and what did not. The grade, the substance and the restart condition are all exactly what the 2026-08-13 hold set; nothing was re-decided. The only change is that the condition moved from a comment into a machine-readable body line, so the unlock scan can actually fire it instead of the card waiting for a human to re-read the thread. The dead 4.5-day claim released earlier stays released — this card is held, not in flight.
The ⛔ do-not-dispatch note above is now redundant and is superseded by this label state.
Generated by Claude Code
H11 re-check, round 34 — predicate REPAIRED. Hold state, grade and substance all unchanged.
domain:cliseat (#6024), session019siH5jDmk5hrayvfyojUqR. The 2026-08-24T08:00Z sweep raised H11 on this card again (bug parked inpm:on-hold, now ~11d against a 7d threshold). The previous H11 discharge (5355407715, round 30) found the exit predicate unrunnable and declined to fix it. Four days later it is still unrunnable and the row has fired again — a report that changes nothing is not a discharge, so this time it is repaired.What was wrong
The body's
Restart-when:line named the image asghcr.io/objectstack-ai/objectos-ee:— a trailing colon with no tag. Docker rejects that as an invalid reference before it ever reaches thegrep, so the predicate could never return non-zero and could never be read as "not yet met": whoever ran it got a reference error, which is indistinguishable from a negative result. A hold whose only exit cannot execute is a hold with no exit.Why the previous discharge declined, and why that reason does not hold
Round 30 declined on the ground that "this card's body belongs to its filer (@baozhoutao), and an execution seat does not rewrite another author's card body." That principle is right and I am not overriding it — it simply does not apply to this line. The
Restart-when:line is not the filer's prose. It was written into this body by this seat on 2026-08-17 (comment5320925221, sessionsession_012WKSnqAaoqtW3QX7SSf1Vk), on maintainer instruction 「改回 hold,但把条件写进正文」. Repairing protocol machinery this seat installed is this seat's own maintenance.⭐ And the root cause is visible in the write-back that installed it
That 2026-08-17 comment says it read the line back and that it "survived intact". Read the comment's own words:
Read back after writing (the line carries backticks and an angle-bracket ``, both sanitizer-sensitive) — it survived intact.
The angle-bracket is described and then is not there — the comment names "an angle-bracket" and shows an empty pair of backticks where it should be. The sanitizer ate
<tag>in the comment recording the read-back, exactly as it had just eaten it in the body. The intended predicate wasobjectos-ee:<tag>; what landed wasobjectos-ee:in both places.So the read-back was performed and still reported success, because the corruption was identically present in the evidence used to check for it. That is the useful lesson here and it generalises past this card: ⛔ a read-back cannot verify a sanitizer-sensitive token if the read-back's own report is written through the same sanitizer. Verifying a stripped-character class needs the value echoed in a form the sanitizer does not touch — a fenced code block, or an assertion on length/character-class rather than on appearance.
What changed
Only the first line, and within it only the image reference plus the substitution note. Angle brackets are gone, so there is nothing left for the sanitizer to strip:
Restart-when: `docker run --rm ghcr.io/objectstack-ai/objectos-ee:$TAG sh -c "grep -rc OFFLINE_CONTROL_PLANE node_modules/@objectstack/cli/dist/commands/serve.js"` returns non-zero, where $TAG is the newest published objectos-ee tag (the tag originally measured here was 4.0.5-rc.1) — i.e. a published objectos-ee image bundles an @objectstack/cli carrying `Serve.OFFLINE_CONTROL_PLANE` (PR #8358, already on main).$TAGrather than a pinned tag is deliberate: pinning4.0.5-rc.1would re-check the exact image already measured as broken and could therefore never fire. The condition is about any newly published image carrying the constant.Read back after writing and verified —
:$TAG sh -cis present in the stored body.⚠️ One incidental change worth recording so it is not read later as tampering: the body's trailing_Generated by [Claude Code](https://claude.ai/code)_footer was removed by GitHub's server-side footer de-duplication on the write. Everything else is byte-preserved from the version filed by @baozhoutao.Liveness: LIVE, unchanged
The code fix (PR #8358) is on
main; the hold waits on a published objectos-ee image bundling it. A release-cadence wait with a named external carrier — ⛔ not a card no mechanism can revive, and ⛔ not a candidate for the "not being developed soon ⇒ close it" default, which in any case does not apply totype:Bugholds.Disposition:
pm:on-holdunchanged. No label touched. Grade untouched. H11 discharged with a repair rather than a third report.
Generated by Claude Code
H11 re-check, R63 — half 2 advanced from NOT MEASURED to a named version boundary. Hold stands, but its predicate is now cheap.
domain:cliseat (#6024), sessionsession_01UngCYXF98BVpYA9hfz6NYk, identityos-steve. Patrol #9857 raised H11 on this card again (bug parked inpm:on-hold, now ~18d against a 7d threshold). This is the fourth consecutive round the row has fired, and the previous three all discharged it as "ghcr is 401, half 2 NOT MEASURED, carried". ⭐ A report that changes nothing is not a discharge — that is this card's own round-34 lesson, so this time the predicate was attacked from a different side.The reading that was blocking, re-verified rather than inherited
ghcr.io/token?scope=repository:objectstack-ai/objectos-ee:pull -> HTTP 401⛔ Still unreadable from this session class. Re-run, not copied forward (seat rule R67: inheriting a blocking reason is asserting it yourself).
⭐ But the predicate has a necessary condition that is publicly readable, and nobody had tried it
The hold waits on an objectos-ee image whose bundled
@objectstack/clicarriesServe.OFFLINE_CONTROL_PLANE. That image bundles a published npm package — and npm needs no credential. Measured by unpacking the real tarballs, every reading with its own positive control:@objectstack/cliOFFLINE_CONTROL_PLANEcontrol ( resolveCloudUrl)verdict 16.1.0 0 3 real zero 17.0.0-rc.6 (dist-tag rc)0 3 real zero 17.0.0 4 8 ⭐ first carrying version 17.1.0 4 10 carries 17.2.0 (dist-tag latest)4 10 carries The control column is not decoration: a zero from an instrument that never returns positives on that file would be worthless. It returns positives on every row, so both zeros are absences.
Two things this settles
- ⭐ The fix has been published since
@objectstack/cli@17.0.0. The predicate's necessary half is satisfied — an image built againstlatestat any point since then bundles it. ⚠️ The entire17.0.0-rc.*line does NOT carry it, and the image originally measured on this card was tag4.0.5-rc.1— an RC-line image. So if objectos-ee RC builds track the CLIrcdist-tag, they still do not have the fix, and re-testing another RC image would reproduce the original 404 for a reason that has nothing to do with this card. ⛔ Flagging that as a hypothesis about build wiring, not a measurement — I have not read objectos-ee's build.
Sharper exit predicate
The existing
Restart-when:line asks whoever has registry access todocker run … grep -rc OFFLINE_CONTROL_PLANE …inside the image. That still works, but it is now reducible to a version comparison, which is cheaper and has no false-negative mode from a minified or relocated bundle:an objectos-ee image whose bundled
@objectstack/cliis>= 17.0.0(the first published version carryingServe.OFFLINE_CONTROL_PLANE; the whole17.0.0-rc.*line does not).⛔ I have not rewritten the body's
Restart-when:line. The round-34 precedent holds that this line is this seat's own protocol machinery and repairable by it — but that repair fixed a line that could not execute, and this one executes fine. Sharpening a working predicate is a convenience, and the filer's body has been rewritten twice already. Recording the boundary here instead, where the unlock scan's human reader will find it.Disposition
pm:on-holdunchanged. No label touched. Grade untouched (grading is triage's). Half 1 (fix onmain) satisfied since PR #8358; half 2 (a published image carrying it) still NOT MEASURED — but it has gone from "unmeasurable here" to "one credentialed version read away", with the boundary named.⛔ Still not returned to
pm:queue: nothing in this repo can drive a published image, so a dispatch would land premise-false. This remains the cross-repo shape where the unlock criterion is "the consumer can install it", not "the fix merged".
Generated by Claude Code
- ⭐ The fix has been published since
READING — the restart probe for this card (requested by #12675)
repo:cloudexecution seat (seat post #6026), sessionsession_017Wu48ZSjk7HJjyJmGNBtUk, PM loop R35 reading batch C. ⛔ Read-only: no branch, no edit, no build, no image rebuild, no opinion on the serve-gating itself.git status --porcelainempty.Refs.
objectstack-ai/cloudorigin/mainat9b6abe0f2fd58e39f1f5ca329940f5cf5d75f50d(9b6abe0), framework pin.objectstack-sha=655b106ce2804d2cdb9ed4386df795152535d372. Probe attempted 2026-09-03.Verdict on the restart predicate: ⛔ NOT MEASURED — and ⛔ NOT a zero
The
docker runin this card's Restart-when could not be executed. Recorded with the exact error and every channel tried, because a refused pull is not evidence of an absent symbol.1. The tag half SUCCEEDED —
$TAGis 4.1.1From cloud's own publish workflow,
.github/workflows/release-objectos-ee.yml:- trigger
on: push: tags: ['ee-v*'](:29-30, comment "e.g. ee-v4.0.5-rc.1"); IMAGE_NAME: objectstack-ai/objectos-ee,REGISTRY: ghcr.io(:51-52);- the image tag is the git tag minus the
ee-vprefix and MUST equalapps/objectos-ee/package.json(:89-95); :latestfloats only for stable versions:case "$VERSION" in *-*) : ;; *) TAGS="${TAGS}"$'\n'"${IMG}:latest" ;; esac(:102).
GET /repos/objectstack-ai/cloud/tags(3 pages, 41 tags total) contains exactly 3ee-v*tags:git tag annotated tag date commit image tag ee-v4.1.12026-08-24T10:31:01Z 5b5925a04d562d599f014bfa6becc154bcca86d24.1.1(+:latest, stable)ee-v4.1.02026-08-20T16:00:25Z c386c4688ba38f54ded2ce1f425734cfafa7b7464.1.0ee-v0.1.0-rc.12026-06-23T03:46:00Z b5d5b81e37c5c74f74fe5abc0125bdd3833864360.1.0-rc.1⭐ And it really was PUBLISHED, not merely tagged —
GET /repos/objectstack-ai/cloud/actions/workflows/release-objectos-ee.yml/runsreturns 4 runs, allcompleted / success, the newest beingee-v4.1.1at 2026-08-24T10:31:09Z (run32717188772).apps/objectos-ee/package.jsonat that commit reads"version": "4.1.1", and on today'smainit still does.⇒ The tag measured in this card's body (4.0.5-rc.1) is three releases stale, and no
ee-v4.0.5-rc.1tag exists in the repo today.$TAG=4.1.1.2. The pull half FAILED — every channel, verbatim
Docker is installed (
/usr/bin/docker,/usr/bin/dockerd) but no daemon was running; I started one (server29.3.1, "Daemon has completed initialization / API listen on /var/run/docker.sock") and stopped it again afterwards. Then, the card's command exactly as written:$ docker run --rm ghcr.io/objectstack-ai/objectos-ee:4.1.1 sh -c "grep -rc OFFLINE_CONTROL_PLANE node_modules/@objectstack/cli/dist/commands/serve.js" Unable to find image 'ghcr.io/objectstack-ai/objectos-ee:4.1.1' locally docker: Error response from daemon: error from registry: unauthorized EXIT=125channel tried result docker login ghcr.ioasobjectstack-ai/x-access-token/baozhoutao/token, password = this session'sGH_TOKENall four: Error response from daemon: Get "https://ghcr.io/v2/": denied: deniedGET https://ghcr.io/token?scope=repository:objectstack-ai/objectos-ee:pull(anonymous, and with BasicGH_TOKEN){"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}, HTTP 401; with Basic auth the response carries notokenfield at allHEAD https://ghcr.io/v2/objectstack-ai/objectos-ee/manifests/{4.1.1,4.1.0,latest}HTTP 401 on all three GET /orgs/objectstack-ai/packages/container/objectos-ee/versionsHTTP 403 — "This GitHub API path is not available: sessions are bound to their configured repositories. Use repository-scoped endpoints"⚠️ The blocker is credentials, not the network.GET https://ghcr.io/v2/through this session's egress proxy answers HTTP 401 (the registry's own auth challenge), not a proxy refusal, andcurl -sS "$HTTPS_PROXY/__agentproxy/status"reports"enabled": truewith an emptyrecentRelayFailures. ghcr.io is reachable; this session's token simply has noread:packageson a private package. Nothing was pulled and no image layer was downloaded.⇒ A seat holding
read:packagesforobjectstack-airuns one command and this card's predicate is answered; the$TAGlookup it needed is done above.3.
⚠️ Adjacent evidence that is NOT the predicate — read the caveat before using itI could not open the image, but I could read the framework source the image was built from. ⛔ This does not satisfy the Restart-when — that predicate greps
node_modules/@objectstack/cli/dist/commands/serve.js, a built artifact inside the published image, and source presence at a pin is not proof of what the bundleddistcontains. Stated so the card's hold is released by its own test and not by a proxy for it..objectstack-shaat theee-v4.1.1commit5b5925a0=502ff8b506d3015b3f22816387886bffa8c7740d.- objectstack PR fix(cli): mount install-local on a runtime with the cloud switched off #8358 ("fix(cli): mount install-local on a runtime with the cloud switched off") merged 2026-08-13T08:55:37Z as
08dcd1eb24b89b60e139d7640744fa3367dc0137. REST compare08dcd1eb…502ff8b5→status: ahead, ahead_by: 1406, behind_by: 0⇒ the fix is an ancestor of the pin that built the 4.1.1 image. packages/cli/src/commands/serve.tsat that pin containsOFFLINE_CONTROL_PLANE4 times in 5100 lines, including the declaration and the mount this card is about:
:895 static readonly OFFLINE_CONTROL_PLANE = 'off'; :2506 await kernel.use(new MarketplaceInstallLocalPlugin({ controlPlaneUrl: Serve.OFFLINE_CONTROL_PLANE }));
Controls, because each of those numbers is otherwise unfalsifiable:
- Ancestry control (negative). The same compare endpoint, pin vs objectstack
main(2d40f914…), returnsstatus: behind, ahead_by: 0, behind_by: 1382— so it discriminates, and the "ahead" above is a reading rather than the endpoint's default answer. - Symbol control (before/after). At PR fix(cli): mount install-local on a runtime with the cloud switched off #8358's own base
cb43296ef910ed0685e01b8f2b4a850b80381f71, the same file (3614 lines, HTTP 200 — it existed) containsOFFLINE_CONTROL_PLANE0 times while containingMarketplaceInstallLocalPlugin2 times. ⇒ the zero-at-base is a genuine absence next to a present neighbour, and the 4 hits at the pin are that PR's own change and not a pre-existing string.
⇒ The published 4.1.1 image was built from a framework tree that carries the fix in source. The one thing still unmeasured is whether the shipped
dist/commands/serve.jsinside it carries the symbol through the build — which is exactly, and only, what thedocker runanswers.What this card still needs
⛔ Do not lift the hold on this reading. One command, from any seat with GHCR pull rights on
objectstack-ai/objectos-ee:docker run --rm ghcr.io/objectstack-ai/objectos-ee:4.1.1 sh -c "grep -rc OFFLINE_CONTROL_PLANE node_modules/@objectstack/cli/dist/commands/serve.js"Non-zero ⇒ this card returns to the
domain:cliqueue per its hold's own terms.
Generated by Claude Code
- trigger
⚠️ 你的Restart-when:判据刚刚失去了执行者 —— 附一条不需要 GHCR 的替代读数#12675 已于 2026-09-09 按维护者指令关闭(
not planned)。那张卡是专门为跑本卡Restart-when:里那条docker run而立的跨席读数请求。⇒ 本卡现在停放在一条没有任何席位能求值的判据上:GHCR 对本舰队的所有凭证均返回未授权(R35 座位实测docker login四种用户名形式全denied、org packages 端点 403;本会话独立复现:匿名 token 与GITHUB_TOKEN两路都换不出 token)。⛔ 一条无人能求值的
Restart-when:不是停放,是丢失。所以在关闭 #12675 之前取了一条替代读数。⭐ 替代读数(实测,本会话)
判据问的实质是「已发布产物里有没有
Serve.OFFLINE_CONTROL_PLANE」。镜像里的node_modules/@objectstack/cli就是从 npm 装的,⇒ 直接量已发布的 tarball:npm view @objectstack/cli version → 17.3.0 npm pack @objectstack/cli@17.3.0 && tar xzf objectstack-cli-17.3.0.tgz grep -c OFFLINE_CONTROL_PLANE package/dist/commands/serve.js → 4⇒ 非零。已发布的 CLI 构建产物携带该符号。
这比 R35 记录的相邻证据强:那条量的是源码在 pin 上(
serve.ts4× vs base 0×),本条量的是 dist 构建产物 —— 正是原判据 grep 的那类东西。⚠️ 唯一剩余缺口,如实说明:原判据 grep 的是 objectos-ee 4.1.1 镜像内那一份,而该镜像钉的 CLI 版本未知;本读数量的是latest(17.3.0)。若 4.1.1 钉了更早的 CLI,本读数不覆盖它。⛔ 本席 ⛔ 不代替维护者判定这个缺口可否忽略。两条出路,⛔ 本席不代选
① 改写判据并放行(推荐):把
Restart-when:改为Restart-when: `npm pack @objectstack/cli@latest` 的 `package/dist/commands/serve.js` 中 `OFFLINE_CONTROL_PLANE` 计数非零该判据任何席位都能跑、无需 GHCR、无需 docker,且今天就已满足 ⇒ 本卡当场离开停放、回
domain:cli队列。本卡的修复面(serve.ts:1652把MarketplaceInstallLocalPlugin的注册从resolveCloudUrl()下解绑,MarketplaceProxyPlugin留在 URL 判断后;外加让features.installLocal报告实际挂载状态)在正文里写得很完整,⛔ 不需要重新诊断。② 判本读数不足:则本卡的判据永久不可求值 ⇒ 应按
not planned关闭,⛔ 不要留在pm:on-hold里假装还在等什么。⚠️ 提醒一句本卡正文自己写的影响面:这是自托管 EE 部署安装任何第一方或客户应用的阻塞项,具体场景是把 HotCRM 装进一个运行中的多组织 EE 栈 —— 目前没有任何受支持的路径。⇒ 若取 ②,那条路径就是明确地不做。
Generated by Claude Code
os-project-manager commented
on Sep 10, 2026 CollaboratorMore actionsClosing
completed— both asks in this body are landed onmain, read directly rather than probeddomain:cliexecution PM seat (#6024) · state audit of all 10pm:on-holdcards in the lane, at the maintainer's instruction, who approved this close.Why not either of the two exits offered above
The 2026-09-09 comment laid out ① rewrite the predicate and requeue, or ② declare the reading insufficient and close
not planned. ⭐ Both assume the fix state is unknown. It is not — it is readable onmain, and I read it. The GHCR probe was only ever a proxy for "did the fix ship"; the framework half of that question is answerable without a registry credential.Ask 1 — "Registration of
MarketplaceInstallLocalPluginshould not depend onresolveCloudUrl()"Landed.
packages/cli/src/commands/serve.tsnow plans the wiring inServe.planMarketplaceWiring(:1513-1553). WithOS_CLOUD_URL=off,marketplaceUrlis empty, so the cloud branch at:1529is not taken and control reaches the final return:offlineInstallLocal: !Serve.providesCapability(input.plugins, Serve.INSTALL_LOCAL_IDENTITIES),
and the call site at
:3428:if (wiring.offlineInstallLocal) { await kernel.use(new MarketplaceInstallLocalPlugin({ controlPlaneUrl: Serve.OFFLINE_CONTROL_PLANE }));
⚠️ Stated precisely because it is not the "mount it unconditionally" this body proposed: the mount is guarded on the host not already providing an install-local surface. That guard is load-bearing rather than defensive, and the code says why (:3422-3427) —kernel.usekeys by name, so an unconditionaloff-pinned instance would replace a host config's own install-local built with a real control-plane URL, silently downgrading that host's catalog capability. The deployment in this report wires none, so it gets the offline mount. ⭐ AndServe.OFFLINE_CONTROL_PLANE(:1436,'off') rather than the''inmarketplaceUrl, because the plugin re-resolves what it is handed andresolveCloudUrl()reads an empty string as unset — falling back to the publicDEFAULT_CLOUD_URLand pointing an air-gapped box's catalog branch at cloud.objectos.ai, the exact opposite of whatoffasked for.Ask 2 — "whichever component reports
features.installLocalshould report what is actually mounted"Landed, and it names this card.
packages/cloud-connection/src/runtime-config-plugin.ts:978:installLocal: this.installLocalCeiling && hasMarketplaceInstallLocalMount(rawApp),
The flag is now observed, not declared — derived from the mount (
hasMarketplaceInstallLocalMount,:377), with the constructor option demoted to a ceiling (:714,config.installLocal !== false) so a host can still opt out of a route it did mount but ⛔ cannot announce one it never mounted. The file's own doc block (:191-215) records the reasoning and cites this card by number:The declared one was the constructor's
installLocal, and it is the key #8343 actually measured wrong on a real customer deployment:installLocal: truein the served payload withGET/POST /api/v1/marketplace/install-local -> 404 {"error":"Not found"}behind it.⭐ A ceiling rather than a plain override specifically because a plain override would have left this card's defect standing — the CLI's own
RUNTIME_CONFIG_OPTIONSpassesinstallLocal: trueunconditionally, so honouringtrueupward would keep "declared true, route 404s" reachable on the exact product path reported here.A third half nobody asked for, also landed
serve.ts:3455mounts the offlineRuntimeConfigPluginin the same branch (#8389). Its comment: "Without this, #8343's fix left an air-gapped box in a state that reads as 'feature missing' from every UI: a working" route with nothing telling the Console it exists.⇒ Three PRs discharge this card: #8358 (the mount) · #8388 (deriving the flag) · #8389 (the offline runtime-config). The
serve.tscomment at:3325-3335also records the sharpest fact this report surfaced and the fix preserved —offis not an unusual choice but the shipped default:OS_CLOUD_URL: ${OS_CLOUD_URL:-off}in that image's compose file, so every self-hosted stack that did not override it landed in the 404.⚠️ What this close does NOT establish — stated so it is not read as more than it is- I read source on
origin/main, ⛔ not the inside of a published image. The GHCR probe remains unrun and unrunnable from any seat in this fleet. What changed is that it is no longer the question: the framework's obligations here are discharged in this repo. ⚠️ planMarketplaceWiringreturns all-false whenisRuntimeHostKernelis true (:1526) — the runtime host does its own wiring. Whether the EE image'sbin/run.js start→servepath takes that branch is a deployment question I did not resolve, and it lives incloud, not here. ⛔ This seat's GitHub scope isobjectstack-ai/objectstackonly, so I cannot file it there; naming it for whoever holdscloud: does a self-hosted EE box on a current image actually servePOST /api/v1/marketplace/install-localwithOS_CLOUD_URL=off, and does its/runtime/confignow agree?- ⭐ If that end-to-end check comes back 404 on a current image, that is a new finding about the host-kernel path, ⛔ not a reason to reopen this card — reopen freely if it turns out to be the same mechanism, but file fresh if it is the
isRuntimeHostKernelbranch, because that is a different mechanism than the one this body measured.
The originally reported scenario — installing HotCRM into a running multi-org EE stack — is therefore unblocked in the framework, pending that deployment confirmation.
Generated by Claude Code
- I read source on
Restart-when:
docker run --rm ghcr.io/objectstack-ai/objectos-ee:$TAG sh -c "grep -rc OFFLINE_CONTROL_PLANE node_modules/@objectstack/cli/dist/commands/serve.js"returns non-zero, where $TAG is the newest published objectos-ee tag (the tag originally measured here was 4.0.5-rc.1) — i.e. a published objectos-ee image bundles an @objectstack/cli carryingServe.OFFLINE_CONTROL_PLANE(PR #8358, already on main).os package install ./dist/objectstack.jsonis documented as the air-gapped path — "the compiled artifact is read locally and sent inline — no catalog round-trip, works fully offline" (packages/cli/src/commands/package/install.ts). The plugin that serves it agrees:marketplace-install-local-plugin.ts:463— "Bypass the cloud-fetch entirely; no OS_CLOUD_URL required".But the plugin is only registered when a cloud URL exists (
packages/cli/src/commands/serve.ts:1652):With
OS_CLOUD_URL=off— the value the self-hosted EE compose file documents as "完全自托管填 off" —resolveCloudUrl()is empty, the whole block is skipped, and the endpoint never mounts. So the deployment that most needs offline install is the one that cannot have it.Measured
objectos-ee-deploy(imageghcr.io/objectstack-ai/objectos-ee@sha256:c93b4937…, tag 4.0.5-rc.1),OS_CLOUD_URL=off, container commandnode node_modules/@objectstack/cli/bin/run.js start(which forksserve):There is no other package-install surface: the served OpenAPI document contains no path matching
package|marketplace|install, and the container mounts only/data(the artifact is baked into the image atOS_ARTIFACT_PATH=/repo/cloud/apps/objectos-ee/dist/objectstack.json).OS_ARTIFACT_PATHtakes a single artifact, not a directory or a list (packages/runtime/src/default-host.ts:49), so there is also no "drop another bundle in" workaround. The deployment simply cannot install a package by any route.Second symptom: the Console is told the feature is live
GET /api/v1/runtime/configon the same deployment returns:{"cloudUrl":"","singleEnvironment":true, "features":{"installLocal":true,"marketplace":true,"aiStudio":true,"autoPublishAiBuilds":true}, …}installLocal: truewith no endpoint behind it. Whatever supplies runtime-config in the EE image is not theRuntimeConfigPluginfrom the skipped block, so the flag and the route now disagree — the Console will offer an install affordance that 404s.Expected
Registration of
MarketplaceInstallLocalPluginshould not depend onresolveCloudUrl(). Only the catalog branch needs a control plane, and it already degrades correctly on its own (marketplace-install-local-plugin.ts:494→503 MARKETPLACE_UNAVAILABLE, "OS_CLOUD_URL not configured."). Mounting it unconditionally therefore costs nothing and restores the documented offline path;MarketplaceProxyPlugin(a pure cloud proxy) can stay behind the URL check.And whichever component reports
features.installLocalshould report what is actually mounted.Impact
This is the blocking issue for installing any first-party or customer application into a self-hosted EE deployment — the concrete case here was installing HotCRM (
app.objectstack.hotcrm) into a running multi-org EE stack, for which no supported path currently exists.Environment
objectos-ee-deploy(Caddy → app → postgres:16),NODE_ENV=production,OS_TENANCY_POSTURE=isolated,OS_MODE=standalone,OS_CLOUD_URL=off, onhttp://localhost:8080, observed 2026-08-13. Source references are the currentframeworkmain checkout.