Skip to content

cloud-connection: serve a fail-closed SPA telemetry permission on /api/v1/runtime/config (#10805) - #11382

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-10805-runtime-config-telemetry-posture
Aug 23, 2026
Merged

cloud-connection: serve a fail-closed SPA telemetry permission on /api/v1/runtime/config (#10805)#11382
os-zhuang merged 1 commit into
mainfrom
claude/issue-10805-runtime-config-telemetry-posture

Conversation

@claude

@claudeclaudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#10805

Upstream half of objectstack-ai/cloud#1508 (p0 / security). An air-gapped on-premises EE Console was measured sending 14 Sentry envelopes per session to sentry.io, carrying IP and User-Agent PII, with no way for the customer to turn it off. objectui closed the half it owns (objectui#5559 — a build that never opts in now issues no third-party request at all) and documented the half it could not, in its shipped app-shell/src/observability/sentry.ts:

a build that DID opt in still has no post-build off switch, because the only server-to-SPA channel is /api/v1/runtime/config and a telemetry key on that payload is an objectstack contract change, not objectui's to make. Filed upstream; see the issue for the fork.

This is that key. Implemented against maintainer ruling Option A (2026-08-22): server-authoritative, fail-closed, composed / air-gap defaults off.

The contract

GET /api/v1/runtime/config gains one top-level namespace, alongside features and branding:

{ "telemetry": { "allowClientErrorReporting": false } }

Operators grant it with OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true; hosts with new RuntimeConfigPlugin({ allowClientErrorReporting: true }). The switch answers to the repo's established truthy vocabulary (1 / true / on / yes), an explicit falsy spelling is a deliberate silent denial, and an unrecognised spelling is refused and named at mount time rather than coerced — the asPlatformStage discipline this file already applies to branding.stage.

Four decisions worth a reviewer's attention:

  1. A permission, not a kill switch. Spelling it telemetry: { disabled: true } would read undefined on every server too old to know the key, on every malformed payload and on every failed fetch — falsy, therefore "not disabled", therefore send. The gate would be vacuous exactly on the runtimes leaking today. Phrased as a permission, every one of those states collapses onto "not true". A boolean rather than an allowed / denied union for the same reason: the laziest consumer expression, if (allowed), is already the safe one, while the laziest string test (!== 'denied') fails open on absence.
  2. A permission, never a source. The server supplies no DSN and cannot start telemetry for a build that carries none; true means only "this deployment does not object to the sink you were compiled with". A server that could start a third-party data flow in someone's browser would be a worse surface than the one being fixed.
  3. Denied on every posture until granted, not only the air-gapped one. Deriving "connected therefore allowed" would leave the reported injury class open one deployment over: an internet-connected on-prem box runs the same build artifact as the hosted console, so the DSN cannot tell them apart and its customer has equally never heard of Sentry. A universal opt-in satisfies "air-gap defaults off" strictly, and satisfies it without having to identify the posture correctly — which matters, because a posture predicate wrong in the allow direction is this card's own defect.
  4. The key is always present. Absence is reserved for payloads that did not come from a runtime that knows it (older ObjectStack, third party, 404, network error), and every one of those must read as denial. That is the branding.stage discipline applied rather than copied: stage is absent when unset because the consumer owns its default, whereas here absence must be unambiguous in one direction only, so the server states its answer and never leaves a falsy placeholder or a present-and-undefined property behind.

Where the deployment posture is read from

Triage flagged this as uninvestigated and the ruling handed it to the implementer. No posture source is introduced.

There is no general deployment-posture signal in this repo, and the two nearest candidates are a different axis: OS_TENANCY_POSTURE (single / group / isolated, ADR-0105) is about organization walls, and OS_MODE (standalone / cloud) is a kernel boot mode read only inside the CLI, defaulting to standalone for every objectstack dev.

What does exist is exactly one network-posture declaration, and it is already this package's: the OS_CLOUD_URL decline spellings (off / none / local / disabled), whose contract the package README states in as many words — "OS_CLOUD_URL=off disables every remote call; air-gapped installs keep working via inline manifests". It is the right signal precisely because the air-gapped operator already sets it without being told: as measured and recorded in packages/cli/src/commands/serve.ts, the EE image's compose file reads OS_CLOUD_URL: ${OS_CLOUD_URL:-off}, so off there is the shipped default rather than an unusual choice. A new OS_DEPLOYMENT_POSTURE variable would have failed the ruling's actual requirement — zero configuration — by making the operator learn it exists.

So a runtime that declared its control plane off refuses the grant, loudly, at mount time. Two properties make that a sound read rather than a guess:

  • It is notresolveCloudUrl(...) === ''. That test conflates two opposite deployments: '' is also how a host says "this runtime IS the cloud" (same origin), and Serve.RUNTIME_CONFIG_OPTIONS hands the plugin controlPlaneUrl: '' on both arms of the CLI's marketplace wiring — so on the product path the constructor argument carries no posture information at all. A read built on it would report every hosted console as air-gapped and every air-gapped box as hosted, the second silently. isControlPlaneDeclined() asks the different, answerable question: was a decline spelling actually said, at either door.
  • The correspondence with the CLI's own arm selection is exact: resolveCloudUrl() maps an unset variable to the public default (truthy), so the offline arm is taken if and only if OS_CLOUD_URL is a decline spelling — the same condition the refusal reads.

isControlPlaneDeclined() is factored out of cloud-url.ts so "what counts as off" has one definition shared by URL resolution and the telemetry refusal. resolveCloudUrl() is unchanged in behaviour.

The fail-closed reading ships with the contract

New export isClientErrorReportingAllowed(payload). "Absent means do not send" is a claim about consumer code, and a consumer writing its own optional-chain is one !== false away from re-opening the leak on exactly the legacy payloads the guarantee is for. It accepts unknown so that "the fetch failed" is spelled by passing undefined and reaches the same answer through the same function, rather than being a catch block someone forgot to write. The test is === true, not truthiness.

The permission is also deliberately not a member of features: that map is open-ended and a host's resolveFeatures hook merges arbitrary keys into it verbatim, so a distribution's billing-tier code could otherwise grant it by returning one boolean. Pinned.

Verification

All readings below are from commit 8682f4b0, which is this branch's head.

CommandResult
pnpm --filter @objectstack/cloud-connection testTest Files 25 passed (25) · Tests 268 passed (268)
pnpm --filter @objectstack/cli exec vitest run test/serve-marketplace-offline-runtime-config.test.tsTest Files 1 passed (1) · Tests 14 passed (14)
pnpm --filter @objectstack/cli typechecktsc --noEmit, exit 0
pnpm check:type-check-debt--re-measure: OK — 33 ledger entr(ies) re-measured, 1897 raw tsc error(s) total, none above its recorded number
pnpm lint (whole repo, eslint . --no-inline-config)exit 0, no findings
31 path-derived gate families + the 5 convention-triggered onesall exit 0 (list derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack)

check:route-envelope and check:published-readme-exports are in that set and both pass; packages/cloud-connection's entry in the type-check DEBT ledger did not move.

Anti-vacuity

A fail-closed gate that never actually closes is the defect class this card exists to fix, so every pin was shown failing without the behaviour it pins. Four mutations, each applied to the committed tree, proved on disk (removed text at 0 hits, injected marker at 1 hit), run, then restored under an EXIT trap — the restore verified by the same greps. No rebuild is involved and none is owed: this suite imports its subjects by relative source path, so vitest resolves src/, not dist/ — and the mutations changing the result with no build is itself the control proving that.

MutationWrong implementation it modelsReading
M1 — reader returns key presence instead of the value"the key is there, so telemetry is configured"8 failed / 50 passed. Red: an opted-in BUILD sends nothing when the runtime declines, declining, and the whole "does not accept a truthy value" family
M2 — reader becomes ?.telemetry?.allowClientErrorReporting !== falsethe negative kill-switch spelling this design rejects16 failed / 42 passed. Red: every absent reads as do-not-send case and every failed or erroring fetch case, while the present-and-declining pin stays green — exactly the asymmetry the vocabulary argument predicts
M3 — plugin drops the posture ceilingthe grant is honoured on an air-gapped box8 failed / 50 passed. Red: all six decline spellings, the host-option door, and the host-argument door
M4 — plugin defaults the grant open"connected therefore allowed"16 failed / 42 passed. Red: denies with zero configuration, denies on a CONNECTED posture too, the explicit-denial family and the refusal family

Post-fix, restored: 58 / 58 pass in that file, 268 / 268 in the package.

The CLI acceptance test carries its own positive control rather than an ablation: the same grant on the cloud arm is honoured, so the air-gapped refusal cannot be the fixture answering false to everything.

Acceptance

cloud#1508's criterion is a composed / air-gap posture producing zero third-party envelopes for a build that carries a DSN, via an off-switch that needs no rebuild. The server half is pinned end to end in packages/cli/test/serve-marketplace-offline-runtime-config.test.ts: a real OS_CLOUD_URL=off boot through the CLI's actual offline-arm wiring serves allowClientErrorReporting: false with zero configuration, and still serves false when an explicit grant is present. The remaining half — the browser making no request — is objectui's and is out of scope here.

Scope

Clause ②

This widens a published payload. The PR stays draft, needs:contract-review stays on both the card and this PR, and this seat neither reviews nor enqueues its own dispatch. Not armed for auto-merge, not queued.

One question is left open for that review, stated rather than decided here: should a runtime that declared its control plane off be able to opt in anyway? This PR says no — the declaration is a runtime declining outbound calls and the ruling is that a declining runtime wins, and the deployment it protects (a hosted configuration copied onto an air-gapped box) is cloud#1508's own shape. The counter-case is an on-prem box that disables the ObjectStack control plane while its browsers do have internet; no such deployment is measured today. The strict direction was chosen because relaxing a refusal later is additive, while tightening a permission later breaks deployments.


Generated by Claude Code

…i/v1/runtime/config (#10805)
Upstream half of cloud#1508 (p0/security). An air-gapped on-prem EE Console
was measured sending 14 Sentry envelopes per session to sentry.io carrying IP
and User-Agent PII, with no way to turn it off: every telemetry knob in the
SPA is a Vite build-time variable frozen into the bundle, so a build that DID
opt in had no post-build off switch. The only server-to-SPA channel is
GET /api/v1/runtime/config, which this package owns.
The payload now carries `telemetry: { allowClientErrorReporting: boolean }`,
denied unless a runtime positively grants it via
OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED or the matching host option.
- A permission, not a source: the server supplies no DSN and cannot start
telemetry for a build that carries none.
- Denied on EVERY posture until granted, not only the air-gapped one: an
internet-connected on-prem box runs the same build artifact as the hosted
console, so the DSN cannot tell them apart.
- A runtime that declared its control plane off (OS_CLOUD_URL=off/none/local/
disabled) refuses the grant and says so at mount time. That is the repo's
one existing network-posture declaration and the EE image already defaults
to it, so the operator this failed needs no configuration.
- Absence is denial, and the reading ships with the contract:
`isClientErrorReportingAllowed(payload)` answers false for a legacy payload,
a malformed body, a 404 and a failed fetch.
`isControlPlaneDeclined()` is factored out of cloud-url.ts so "what counts as
off" has one definition; resolveCloudUrl() is unchanged in behaviour.
The consumer half (gating initSentry) is objectui's and out of scope here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cloud-connection, touching 21 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/environment-variables.mdx(via /api/v1/runtime/config (route))
What this run could not see
  • 2 changed file(s) yielded no anchor (packages/cloud-connection/README.md, packages/cloud-connection/src/index.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 82b65ec90dfeba9f0006807c32672547e9e57a0fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b15effe2c652f23a575dbb9368222337e534ac20 — the merge of head 8682f4b02b702597bfade67ffa1d2cd231d64491 into base 82b65ec90dfeba9f0006807c32672547e9e57a0f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b15effe2c652f23a575dbb9368222337e534ac20 && git checkout b15effe2c652f23a575dbb9368222337e534ac20
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 82b65ec90dfeba9f0006807c32672547e9e57a0f 8682f4b02b702597bfade67ffa1d2cd231d64491 && git checkout -B drift-repro 82b65ec90dfeba9f0006807c32672547e9e57a0f && git merge --no-ff 8682f4b02b702597bfade67ffa1d2cd231d64491
node scripts/docs-audit/affected-docs.mjs --json 82b65ec90dfeba9f0006807c32672547e9e57a0f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 82b65ec90dfeba9f0006807c32672547e9e57a0f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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.

Runtime config has no telemetry key, so an opted-in console build still has no post-build off switch (upstream half of objectui#5522, cloud#1508 p0)

2 participants

@os-zhuang@claude