Skip to content

feat(telemetry): the CLI validated events and delivered none (backend#2217) - #542

Merged
LukasWodka merged 3 commits into
developfrom
feat/2217-cli-host-transport
Aug 20, 2026
Merged

feat(telemetry): the CLI validated events and delivered none (backend#2217)#542
LukasWodka merged 3 commits into
developfrom
feat/2217-cli-host-transport

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Half of backend#2217 — the CLI (Go) half. The installer (bash) half is a separate PR in client; the two are independent and share only the wire mapping, which each repo derives from the receiver.

What was wrong

pendingSink() returned nil, so every command outcome was validated and dropped. The comment said "when the ingest endpoint lands this returns the client that posts to it, and nothing else in this file changes" — which understated it, because there was no spool anywhere in the Go tree and Emitter.Emit calls the sink synchronously on the command's exit path.

The shape — option (c), as decided on the ticket

One inline POST attempt, ~1s budget for the whole step (drain included), spool on any failure, drain at most 20 pending on the next invocation. Explicit non-goals, written into the file so nobody adds them believing they were forgotten: no background daemon, no goroutine outliving the process, no retry loop. The next command is the retry.

The deciding argument against plain fire-and-forget, from the ticket: in a short-lived CLI, async delivery is a lie. A goroutine that outlives main() is killed at exit, so (a)'s only honest forms are "always block" or "always drop on failure" — and dropping on failure discards precisely the partition-time events that are the most valuable thing the CLI can report.

Three things worth a reviewer's attention

1. anyValue switches on reflect.Kind, not on concrete type. The emitter's checkAttrValue deliberately accepts every scalar kind via reflection so an idiomatic caller can pass a named type (type Reason string, or time.Duration through telemetry.Duration). A switch v := value.(type) at the seam matches the dynamic type, misses those, and silently drops at delivery exactly the values the layer above went out of its way to admit — a hole one level below the check that permits them. TestNamedScalarTypesSurviveTheSeam fails if anyone rewrites it that way.

2. A 4xx other than 401/403/408/429 DISCARDS the batch. The endpoint answers 400 when a whole batch is unparseable. Re-spooling that would wedge the spool: the same bad batch re-sent by every future command, forever, pushing good records out at the cap. 401/403 retry instead, because they are a credential state rather than a payload verdict — the next tracebloc login makes those records deliverable.

3. The spool keeps drop-OLDEST, and that is not a contradiction of D7. D7's amended row (rfcs#36) says drop-newest because exporterhelper sheds at the entrance and offers nothing else — a platform constraint on the edge Collector, not a preference. This spool is our own code and can do what D7 originally wanted, so it does: the newest records describe the incident in progress. Same reasoning as scripts/lib/telemetry.sh's tail -n trim, which it mirrors (0600, capped, oldest dropped). Called out in the file so it does not get "fixed" into agreement with a row that describes different machinery.

The wire mapping

Derived from the receiver (common/telemetry/otlp.py::parse_export_logs_request), not from recollection of the OTLP spec:

  • One resourceLogs entry per event, never one per batch.service.instance.id is fresh per run and service.version legitimately differs between runs, so a drained spool carries genuinely different resources; collapsing them attributes every event to whichever run happened to be first. The receiver's parser calls this out by name.
  • int64 as a JSON string ("41230") — the canonical proto3 encoding. The receiver takes both; sending canonical means the payload is also readable by a stock OTLP consumer.
  • boolValue never intValue for booleans. The receiver reads bool first precisely because isinstance(True, int); the sender should not make that necessary.
  • No timeUnixNano. Per the #2213 decision no client-side timing is sent, so the receiver stamps arrival and "how late" is knowingly invisible. Adding an event clock is a contract change, not a drive-by.
  • Conversion happens at the seam: the spool keeps the compact {resource, attributes} shape, so a human reading a spool file gets something readable and older spooled files survive a mapping change.

A defect this PR's own test found

deliver originally computed api.BaseURL(env) internally, which left no seam — and its first test run posted to production. It now takes a resolved URL. That also removes a second failure mode: one resolution point means the record's label and its destination cannot disagree. The first draft also left a t.Setenv("TRACEBLOC_TELEMETRY_URL_FOR_TEST", …) line that was a no-op — a dead assertion, which is the exact class this epic keeps finding; it is gone rather than papered over.

Test plan

make check green — vet, full suite, fmt-check, file-budget, check-style, check-tool-pins. make lint clean — errcheck, ineffassign, misspell, staticcheck (-checks all,-ST1005).

Mutation-proved, 9/9, no survivors and no build-breaks. Each mutation asserts its anchor applied before running, because an inert mutation and real coverage produce identical logs:

mutationtest that must redden
collapse to one resourceLogs for the batchTestEachEventGetsItsOwnResourceLogsEntryKILLED
encode int64 as a JSON numberTestIntegersAreEncodedAsStringsKILLED
swap reflect.Kind for a concrete type switchTestNamedScalarTypesSurviveTheSeamKILLED
invert the trim (keep oldest)TestWriteSpoolKeepsNewestAndDropsOldestKILLED
retry a 400 instead of discardingTestClassifyStatusKILLED
send the legacy Token keywordTestPostBatchSendsBearerAndJSONKILLED
widen the spool to 0644TestSpoolFileIsOwnerOnlyKILLED
drop on retry instead of spoolingTestDeliverSpoolsWhenTheServerIsUnreachableKILLED
drop when unauthenticatedTestDeliverSpoolsWhenNotSignedInKILLED

No test contacts the network: the partition tests aim at a closed listener and a loopback port.

Not in this PR

  • The installer (bash) half — separate PR in client, including the false #1906's forwarder reads both. comment the ticket calls out.
  • Duplicate skew — accepted and documented per the ticket's decision, no tracebloc.event.id. A POST whose 202 is lost re-sends, which double-counts for a failure rate; nobody computes that rate yet (the succeeded denominator does not exist either). Recorded on the ticket rather than solved with a contract change buying precision for an unconsumed metric.

🤖 Generated with Claude Code


Note

Medium Risk
Adds authenticated HTTP delivery and on-disk event queues on every CLI exit path, including token use and env-partitioned files. Failures are silent and bounded, but a bug here can leak events across environments or hang the product.

Overview
CLI command-outcome events are no longer validated-and-dropped. Each invocation now makes one ~1s POST of OTLP/HTTP JSON to /telemetry/v1/records/, then spools failures to disk and drains a bounded backlog on the next run.

Label, spool file, and destination URL are derived from a single resolved env so a stg record cannot be posted to prod. Spools are per-environment (pending-<env>.jsonl, 0600, cap 50, drop-oldest) so a later login --env dev cannot drain prod-labelled events with a dev token.

The compact {resource, attributes} shape stays on disk; conversion to OTLP happens at the seam (one resourceLogs per event, int64 as proto3 JSON strings). Permanent 4xx (except 401/403/408/429) discards the batch so a bad payload cannot wedge the spool. All transport failures stay silent.

Reviewed by Cursor Bugbot for commit c71acc4. Bugbot is set up for automated code reviews on this repo. Configure here.

…#2217)
`pendingSink()` returned nil, so every command outcome was validated and
dropped. The ingest endpoint exists now (backend#1905) and speaks OTLP/HTTP JSON
(backend#2213), so this is the delivery half.
Option (c), as decided on the ticket: one inline POST with a ~1s budget for the
whole step, spool to disk on any failure, drain at most 20 pending on the next
invocation. No background daemon, no goroutine outliving the process, no retry
loop -- the next command IS the retry. The deciding argument against
fire-and-forget is that in a short-lived CLI async delivery is a lie: a goroutine
outliving main() is killed at exit, so the honest options are "always block" or
"always drop on failure", and dropping on failure discards exactly the
partition-time events worth having.
Three things worth a reviewer's attention:
1. `anyValue` switches on reflect.Kind, not on concrete type. The emitter's
`checkAttrValue` deliberately admits named scalar types (`type Reason string`,
time.Duration via telemetry.Duration) by kind; a `switch v := value.(type)`
at the seam would match the dynamic type, miss those, and silently drop the
values the layer above went out of its way to accept.
2. A 4xx other than 401/403/408/429 DISCARDS the batch. The endpoint answers 400
for a wholly unparseable batch; re-spooling that would wedge the spool
forever, re-sent by every future command and pushing good records out at the
cap. 401/403 retry because they are a credential state, not a payload
verdict -- the next login makes them deliverable.
3. The spool keeps drop-OLDEST, which is not a contradiction of D7's amended
drop-newest row. That row says drop-newest because `exporterhelper` sheds at
the entrance and offers nothing else -- a platform constraint on the edge
Collector. This spool is our own code and can do what D7 originally wanted,
so it does, matching the installer's `tail -n` trim.
`deliver` takes a resolved URL rather than an env. Found by its own test: with
`api.BaseURL(env)` computed inside, the test posted to PRODUCTION. Passing the
URL also means the record's label and its destination cannot disagree.
No `timeUnixNano`. Per the #2213 decision no client-side timing is sent, so the
receiver stamps arrival and "how late" is knowingly invisible; adding an event
clock is a contract change, not a drive-by here.
Verified: `make check` green (vet, full test suite, fmt, file-budget, style,
tool-pins) and `make lint` clean (errcheck, ineffassign, misspell, staticcheck).
Nine mutations run against the nine new assertions -- collapse the resourceLogs
entries, encode int64 as a number, swap reflect for a type switch, invert the
trim, retry a 400, send the legacy `Token` keyword, widen the spool to 0644,
drop on retry, drop when unauthenticated -- all nine killed their test, none
survived, none merely broke the build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 20, 2026
Comment threadinternal/cli/telemetry_transport.go

@saqlainsyed007saqlainsyed007 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a careful PR — the "async delivery is a lie in a short-lived CLI" framing, the reflect.Kind seam (I confirmed anyValue switches on kind so named scalar types survive), the drop-oldest spool with the D7 rationale, and the 4xx-discard-vs-401/403-retry split are all well-reasoned and the mutation table is exactly the right way to prove the guards. But I'm concurring with the open Bugbot finding at telemetry_transport.go:108 — it's a real High defect, verified against the code, and it defeats this PR's own wire-mapping guarantee #2 on the path that matters most:

The spool round-trip recodes integers as doubles.spooledEvent.Attributes is map[string]any (telemetry_otlp.go:37), and readSpool does a plain json.Unmarshal (:106), so every JSON number comes back as float64. anyValue then hits reflect.Float64DoubleValue (telemetry_otlp.go:97-99). So:

  • the first, in-memory POST sends tracebloc.cli.exit_code / duration_ms as canonical intValue strings (native int), but
  • every drained retry — the partition-time events this whole transport exists to preserve — sends them as doubleValue. Same event, two encodings, and the wrong one on the path you built the spool for.

TestIntegersAreEncodedAsStrings operates on the in-memory shape and never goes through writeSpool/readSpool, so it stays green while this breaks — the vacuity the house bar rejects.

Fix: decode spool numbers as json.Number (dec := json.NewDecoder(...); dec.UseNumber(), or unmarshal attributes through a decoder configured that way), then add a json.Number case to anyValue that emits intValue when the number is integral and doubleValue otherwise. That also keeps the spool file human-readable, which is a stated goal. Then make TestIntegersAreEncodedAsStrings (or a sibling) round-trip through the spool so the mutation "recode int as double on read-back" reddens.

Nothing else blocking from what I've read; once the spool preserves int-ness and a test covers the drained path, I'll finish the pass and approve. (CI is green; this and the Bugbot thread are the only things holding it.)

…review)
@saqlainsyed007 and Bugbot, both right, and it defeated this PR's own wire-mapping
guarantee on the path the design exists for.
`readSpool` used `json.Unmarshal` into `map[string]any`, so every JSON number came
back as float64 and `anyValue` routed it to `doubleValue`. The in-memory attempt
sent `exit_code` as a canonical `intValue` string; every DRAINED retry -- the
partition-time events the whole spool exists to preserve -- sent the same field as
`doubleValue`. Same event, two encodings, wrong one on the path that matters.
Reproduced before fixing rather than argued from the code:
in-memory : "tracebloc.cli.exit_code":{"intValue":"2"}
round-trip: "tracebloc.cli.exit_code":{"doubleValue":2}
Fix is the one suggested: decode with `dec.UseNumber()` so numbers arrive as
`json.Number`, and handle that in `anyValue` -- `intValue` when integral,
`doubleValue` otherwise. It also keeps the spool file human-readable, which was a
stated goal.
THE ORDERING IN `anyValue` IS LOAD-BEARING, and this is the part worth reviewing:
`json.Number` is a NAMED STRING TYPE, so the existing kind switch would have
matched `reflect.String` and emitted `stringValue` -- turning an exit code into a
string on the drained path, a different wrong answer from the float64 one. The
json.Number branch therefore sits before the switch, not inside it.
Why the existing test stayed green, since that is the reviewable lesson:
`TestIntegersAreEncodedAsStrings` operates on the in-memory shape and never goes
through writeSpool/readSpool. So it could not see this, which is the vacuity the
house bar rejects. Two tests now cover the drained path:
* `TestSpoolRoundTripPreservesIntegerEncoding` asserts the two encodings AGREE,
not merely that the drained one looks right -- a test checking only the drained
payload would be satisfied by both paths being wrong together. It also asserts
the in-memory reference explicitly, so a regression there cannot make the
comparison pass by both sides breaking.
* `TestSpoolRoundTripKeepsRealsAsDoubles`, because a fix for integers that
truncated every float would pass the first test.
One note on the first draft of that test: it asserted "no doubleValue anywhere in
the drained payload" and failed on CORRECT output, because the fixture carries
`sampling_rate: 0.5` which must stay a double. Narrowed to per-attribute
assertions -- an over-broad assertion reported as a code bug is its own defect.
Verified: `make check` green (vet, full suite, fmt, file-budget, style,
tool-pins), `make lint` clean. 13 mutations now, all killed: the original nine
plus dropping `UseNumber` (the reported defect), letting json.Number fall through
to the kind switch, routing integral numbers to doubleValue, and truncating reals
to integers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

@saqlainsyed007 — fixed in 74fe2f1, and you were right that it defeated the PR's own guarantee #2 on exactly the path the design exists for. Reproduced before fixing:

in-memory : "tracebloc.cli.exit_code":{"intValue":"2"}
round-trip: "tracebloc.cli.exit_code":{"doubleValue":2}

Took your suggested fix as given — dec.UseNumber() in readSpool, a json.Number case in anyValue emitting intValue when integral and doubleValue otherwise. It keeps the spool human-readable too, which was a stated goal.

One thing your suggestion surfaced that I want on the record, because it is the same bug wearing a different type: json.Number is a named string type, so simply adding a case to the existing kind switch would have matched reflect.String and emitted stringValue — an exit code as a string on the drained path. A different wrong answer, reached by fixing the first one carelessly. The branch therefore sits before the switch, and the mutation "let json.Number fall through to the kind switch" is in the table and reddens.

On your vacuity point: agreed, and it is the more useful half of the review. TestIntegersAreEncodedAsStrings never went through writeSpool/readSpool, so it was structurally incapable of seeing this. Two tests now drive the drained path, and the first asserts the two encodings agree rather than that the drained payload looks right on its own — a test checking only the drained side would be satisfied by both paths being wrong together, which is the same trap one level up.

I also got the first draft of that test wrong in a way worth naming: it asserted "no doubleValue anywhere in the drained payload" and failed on correct output, because the fixture legitimately carries sampling_rate: 0.5. Narrowed to per-attribute assertions. An over-broad assertion reported as a code bug is its own defect, and it nearly cost a wrong diagnosis.

13 mutations now, all killed — the original nine plus dropping UseNumber, the fall-through above, routing integral numbers to doubleValue, and truncating reals to integers. make check and make lint green. Bugbot thread answered and resolved. Ready for your second pass.

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 74fe2f1. Configure here.

Comment threadinternal/cli/telemetry_transport.go

@saqlainsyed007saqlainsyed007 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The int→double fix I asked for is done and verified — readSpool now decodes with json.NewDecoder(...).UseNumber() and anyValue has a json.Number case first (correctly, since it's a named string type the kind switch would otherwise catch as String), emitting intValue for integral values and doubleValue otherwise. Spooled exit_code/duration_ms now survive the retry path as canonical ints. That concern is closed.

Holding on the new Bugbot finding at :84, which is real and is this PR's own invariant, one level out: the spool is a single host-wide pending.jsonl and deliver always POSTs the drained batch to the current run's URL/token. So after a login --env (or CLIENT_ENV change) with records queued, an event labelled deployment.environment=stg is delivered to prod's ingest and accepted under prod's token — exactly the "tagged stg, sent to prod" mismatch the in-run single-resolution fix was built to prevent, just across invocations rather than inside one. Telemetry-only blast radius, but it pollutes the wrong backend and mis-attributes.

Fix direction: stamp each spooled record with the env (or resolved URL) it was created under, and on drain either group by env and POST each batch to its own endpoint, or drop records whose env ≠ the current one with a note. If the team decides a queued-telemetry-across-env-switch is an acceptable edge, that's a legitimate call too — but then say so on the thread and resolve it, rather than leaving the invariant half-enforced silently.

CI is green; this Bugbot thread is the only thing between here and approval. Close it (fix or reasoned resolve) and I'll finish the pass.

…view)
Bugbot, and it reopened one level up exactly the mismatch `deliver` takes a
resolved URL to prevent.
A single host-wide `pending.jsonl` meant records queued while signed in to one
backend were drained by the NEXT invocation against another -- POSTed to its
endpoint, with its token, still carrying the first one's
`deployment.environment`. `tracebloc login --env dev` after a failed prod command
was enough. Reproduced before fixing: a `deployment.environment=prod` record
arrived at a dev endpoint with a dev token.
The spool is now per environment, `pending-<env>.jsonl`, and `deliver` takes the
resolved spool PATH rather than deriving one -- so label, spool and destination
are three views of a single resolution in `pendingSink` instead of three chances
to disagree. That is the third reason on that function's doc comment, and the
second and third each bit once.
The consequence is stated in the code rather than hidden: records for an
environment the operator never uses again are never delivered. That is the right
trade -- they are capped, there is usually no token for that environment anyway,
and delivering them to the WRONG backend is not a better outcome than not
delivering them.
`spoolEnvSlug` strips anything outside [a-z0-9-] and lowercases. Unreachable with
the closed dev/stg/prod set, and present because a path segment built from a
string is a traversal waiting for that set to open; an unexpected value becomes
one bucket rather than being dropped, since an undeliverable record is still
evidence.
The leak test asserts BOTH halves -- nothing prod-labelled reaches the dev
endpoint, AND the prod spool still holds its record afterwards -- because a fix
that simply discarded the other environment's queue would satisfy the first
assertion alone.
Verified: `make check` green, `make lint` clean, 16 mutations all killed (the
original nine, the four from the int-encoding fix, plus reverting to a host-wide
spool, and the slug ceasing to strip separators or normalise case).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

Second Bugbot finding fixed in c71acc4 — a genuinely good catch, and the same defect one level up from the one I designed against.

deliver takes a resolved URL precisely so a record's label and its destination cannot disagree within an invocation. A single host-wide pending.jsonl reopened exactly that across invocations: tracebloc login --env dev after a failed prod command drained prod-labelled records into dev's endpoint with dev's token. Reproduced before fixing rather than reasoned about.

Spool is now pending-<env>.jsonl, and deliver takes the resolved spool path rather than deriving one — so label, spool and destination are three views of one resolution in pendingSink. Worth noting that is the third reason on that function's doc comment now, and the second and third each bit once; the pattern is that anything deliver derives for itself is something that can disagree with the record.

@saqlainsyed007 this is a second substantive change since your review, so it wants your eyes again rather than just a re-request: 16 mutations all killed, make check and make lint green, both Bugbot threads answered and resolved.

@saqlainsyed007saqlainsyed007 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — both findings are genuinely closed and verified:

  • int→double (my first pass):readSpool decodes with UseNumber() and anyValue handles json.Numberfirst, emitting intValue for integral values — so a drained retry ships canonical ints, not doubles.
  • cross-env leak (:84, my re-review): the spool is now per-envtelemetrySpoolPath(env)pending-<slug>.jsonl — so a record spooled under one env stays in its own file and a login --env switch drains only the new env's file. Records are partitioned, not cross-delivered and not dropped ("still evidence"). That closes the label-vs-destination invariant across invocations, the way it already held within one.

Nicely, the fix is covered by TestTheSpoolDoesNotLeakAcrossEnvironments, and since env now lands in a filename you added TestSpoolEnvSlugRefusesPathTraversal to keep a crafted env from escaping the telemetry dir — good instinct. CI green, no open threads. The whole delivery design (inline-attempt-then-spool, drop-oldest, reflect.Kind seam, 4xx-discard-vs-401/403-retry) is solid. Approving.

@LukasWodka
LukasWodka merged commit c246912 into developAug 20, 2026
26 checks passed
@LukasWodka
LukasWodka deleted the feat/2217-cli-host-transport branch August 20, 2026 19:29
LukasWodka added a commit that referenced this pull request Aug 21, 2026
…d#2217) (#545)
Completes backend#2217. The CLI half landed in #542; this is the installer half,
built as option (b) rather than (a).
WHY NOT (a), THE ROUTE THE TICKET ASSUMED. #2217 says "convert at the seam and
POST", which presumes the installer can authenticate. It cannot: the ingest
endpoint needs `Token`/`Bearer` under `IsAuthenticatedEdge`, and the installer
holds `TRACEBLOC_CLIENT_ID`/`TRACEBLOC_CLIENT_PASSWORD` -- a provisioning pair
with no exchange for a token -- and never reads the CLI's config (zero hits across
`scripts/`). Having it read `~/.tracebloc/config.json` would also deliver NOTHING
for the failures that matter most: `validate_config` and `early_data_dir_guard`
run before provisioning, so no token exists on disk when those events are written,
and those are exactly what the installer's `$TMPDIR` fallback was built to
preserve.
So the CLI carries them. It already owns the token and, since #542, the spool,
drain loop and OTLP mapping -- this adds one more file to read and leaves the
installer with no credential handling at all.
THE UNPREDICTABLE FALLBACK PATH TURNED OUT NOT TO NEED AN INDEX FILE. I had
expected to need one; `_telemetry_fallback_spool` uses
`mktemp .../tracebloc-telemetry-XXXXXX`, so the NAME is unpredictable but the
PATTERN is fixed. A glob over $TMPDIR / $HOME / /tmp finds them, the installer
needs no change, and there is no shared state to keep in step.
EVERY RECORD IS FILTERED BY ITS OWN ENVIRONMENT, and this is the part to review.
Our spool is partitioned by env in the FILENAME; the installer's is not, and its
records carry whatever CLIENT_ENV that run used. Forwarding blind would post a
prod-labelled install failure to whichever backend this invocation points at --
the same leak #542's second finding was about. A record ships only when its
`deployment.environment` matches this run's; the rest stay for a later invocation
against that env. A record with NO environment is never forwarded (the contract
omits rather than empties, so absent means unresolvable) but is also never
dropped: it is still evidence.
Their files are touched only on a path that CONSUMED them, and never on failure
or when unauthenticated. A file we took nothing from is not rewritten at all.
Verified: `make check` green, `make lint` clean, 16 mutations across the three
suites all killed. One of the new nine earned its keep: it proved
`TestInstallerRecordsSkipsAFileWithNothingForUs` VACUOUS -- it compared file
bytes, and a rewrite of unchanged records produces identical bytes, so it passed
under the mutation it existed to catch. It now asserts the real contract (the file
never enters `remainder`) and the mutation kills it.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
saadqbal added a commit that referenced this pull request Aug 21, 2026
…eader (backend#2320) (#551)
* fix(cli): resolve the backend env once per invocation, not once per reader (backend#2320)
The env/base-URL resolution family, not the one site #540 named. Each of the
last recuts produced one more finding about a site that answered "which
backend?" differently from its neighbour (cli#528 review, #542 review, now
#540) — the failure mode is additive, so only the SECOND site is ever a bug.
- telemetry: recordCommandOutcome took the resolved env as a parameter instead
of calling telemetryEnv(signedInEnv()) a second time. The label and the sink
(spool path + POST destination) now derive from one value, which is what the
comment above RecordCommandOutcome already claimed. This is #540's finding.
- sessionEnv is now the single config -> session-env resolution point, and it
normalises (trim + lower-case) like api.ResolveEnv. Returning cfg.CurrentEnv
verbatim made it the one env-resolving function whose output was not
normalised: invisible where the value only reaches api.BaseURL (which
lower-cases again), load-bearing where it is COMPARED, or where one consumer
trims and another does not — api.BaseURL does not trim, so " dev " fell
through to PROD.
- `cluster doctor` built its API client from cfg.CurrentEnv raw and
`auth status --check` compared it raw against an already-normalised target.
Both go through sessionEnv now: a doctor probing prod with a dev token
reports "session expired" for a session that is fine, and the installer,
whose contract is --check's exit code, re-ran login against a working
session.
- internal/doctor.backendHost derives its host from api.BaseURL instead of
restating the same three hosts in a second switch. Behaviour-identical
(BaseURL already lower-cases); it removes the copy that drifts.
- A guard test pins the closed set of sanctioned resolution sites, so the next
one fails a check instead of a review.
NOT changed: api.BaseURL's unknown/empty -> prod fail-open. It is shared with
the installer's _backend_url and contradicted by client-runtime's
controller.py, so it is a three-component decision tracked on backend#2171.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(telemetry): reflow the BaseURL-mirroring note left ragged by the previous edit
Comment-only; no behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(auth): `auth status` must report the env the client dials, not the stored string (backend#2320)
The audit's last site: `auth status` printed cfg.CurrentEnv as its "backend"
field while runAuthCheck — the machine-facing answer to the same question, 40
lines below in the same file — compares the resolved one. A status command that
disagrees with the client is worse than no status command.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(cli): keep the doctor env test off the network (backend#2320)
My own bug, and worth the comment it now carries. Past the session probe,
`cluster doctor` loads the real kubeconfig and calls the real doctor.Run, whose
checkBackendEgress probes backendHost("") — a live GET to
https://api.tracebloc.io/. On a developer machine with a real k3d cluster the
test therefore made a production request; the run time (~16s vs 0.01s stubbed)
is the tell. Stubbing loadClusterFn returns right after the session probe, which
is all this test needs: the env is decided before it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(cli): make the resolution guard actually guard (backend#2320)
Four review findings, all the same class — a check that verifies the FORM of a
thing rather than the property the form exists to guarantee. Which is the class
this PR is about, so the guard having it was the worst possible place for it.
- Scan Go as Go. The hand-rolled comment stripper was fail-open on string
literals: the `//` inside an `https://…` literal started a "comment" that ate
the rest of the line, needle included, and a `/*` inside a literal like
`"/*.json"` swallowed every needle below it to the next `*/` or EOF. Seven
non-test files in internal/cli already carry an https:// literal, so this was
one future line away. go/scanner with mode 0 drops comments and knows literals,
so neither evasion exists. Literals are kept in the output — a needle inside a
string is then a loud false positive, which is the cheap direction.
- Walk the module root, not the test's own package dir. The guard covered 1 of
the 17 packages under internal/, i.e. it was blind exactly where the next site
is most likely to land: a new package written by someone who never reads
internal/cli. Keys are now repo-relative, and internal/api, internal/config and
internal/doctor join the allowlist with the reasons the PR body already gave.
- An inert allowlist entry now FAILS. Checking only that a sanctioned file exists
let my own change turn the telemetry.go entry into a licence: it matched no
needle any more, so it checked nothing while silently pre-approving the next raw
read in the very file whose double resolution this PR removes. The entry is gone
and the staleness assertion stops the next one going inert unnoticed.
- signedInEnv's docstring was false: it can no longer return "". Says so now,
including that `if signedInEnv() == ""` cannot fire — and telemetryEnv's empty
arm is marked production-unreachable-but-test-reachable rather than left to be
traced.
Also corrects an overclaim I made in the first draft of goCodeTokens' comment:
go/scanner is lexical, so the error covers unterminated literals and comments —
the faults that would desynchronise boundary tracking — not `func f( {`, which
scans clean. Stated precisely rather than left flattering.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(cli): one needle set, one matcher, one allowlist — each checked both ways (backend#2320)
Adopts Lukas's unified shape for the guard, which is better than what I pushed in
f33a3d1 in two concrete ways:
- `matchesAnyNeedle` is now THE matcher, called from the detection sweep AND the
allowlist audit. Two copies of "does this file resolve an env?" is the same
shape as the two copies of "which env?" this PR removes, and it would let
detection and allowlisting drift apart exactly where nobody looks.
- The allowlist audit is per ENTRY, not per suite, so staleness, the empty-reason
check and the needles-went-stale backstop all fall out of one loop and the
failure names the entry to delete. Renaming a needle now reports all five
entries by name instead of a global counter hitting zero.
Kept a narrow anchor the per-entry loop genuinely cannot see: an EMPTY allowlist
makes that loop vacuous, so a needle rename plus an empty allowlist would pass in
silence. It asserts both counts are non-zero.
Eight reproductions, all red, all restored green — the control, both string-literal
evasions, the sibling-package site, a re-inerted sanctioned entry, an empty reason,
a needle rename, and a lexical fault elsewhere in the module.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

/fr-pass

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@saqlainsyed007