Commit Graph

14 Commits

Author SHA1 Message Date
Saket Aryan ed7b09884c Merge branch 'pr3/spool-delivery' into pr4/install-marker-and-identity 2026-09-16 21:08:45 +05:30
Saket Aryan 0377b9a85e Merge branch 'pr2/source-at-record-time' into pr3/spool-delivery 2026-09-16 21:08:45 +05:30
Saket Aryan 3fd4949040 fix(plugins): keep the salt working where hardlinks are not supported
Self-review of the atomic-publish fix. Some network mounts and container volumes
reject os.link, and the outer handler swallowed that into "no salt", which meant
repo_hash and session_hash were dropped on every run for that whole cohort. The
race being closed is narrow; losing the hashes for an entire filesystem is not a
fair trade.

Falls back to claiming the name with O_CREAT|O_EXCL and writing, which is what
this did before. The empty-file window reopens there, but it is benign now: a
reader landing in it gets "" and omits the hash for that process rather than
caching a guessable path digest, which was the actual defect.

266 passed, 8 skipped.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-16 21:08:43 +05:30
Saket Aryan 6d89b3b33e fix(plugins): rotate the anonymous id when the account goes, and verify legacy rows
Three review findings from @kartik-mem0 on this PR.

Anonymous id reuse, reported twice and one defect. The id is offered to PostHog
as $anon_distinct_id on first sign-in and that merge is permanent, so keeping it
after a logout or a key change puts every later anonymous event on the account
that just left. It is now rotated on both routes, and 'aliased' is cleared with
it so the fresh id can be merged into whatever account comes next. Rotation is
deliberately not triggered by a plain lookup failure with no cached email: there
is no previous account to leak to, and churning ids there would fragment the
person for anyone offline on first run.

Legacy rows are verified instead of adopted. A row written before fingerprints
existed carries an email and no fingerprint; adopting the current key bound that
key to the previous account's email permanently, and every run after agreed with
itself. It now resolves once and takes the answer. If the lookup fails it keeps
the cached email and retries next flush rather than dropping a real attribution,
which is safe because the network that failed /v1/ping/ is about to fail the
PostHog POST too. My original comment justifying the shortcut claimed the check
would cost a request on every flush forever; that was wrong, the fingerprint is
stored after one success.

A failed upgrade claim is released. The sentinel was created before the marker
rewrite and left behind if the rewrite failed, so claim_version_change returned
early on every later run and that version's upgrade was never recorded again.

Five tests, covering both rotation routes, legacy verification, the firewalled
legacy case, and retrying a failed upgrade claim.

300 passed, 8 skipped.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-16 20:36:23 +05:30
Saket Aryan 88bd5f164f Merge branch 'pr3/spool-delivery' into pr4/install-marker-and-identity 2026-09-16 20:35:23 +05:30
Saket Aryan 26760b00b1 Merge branch 'pr2/source-at-record-time' into pr3/spool-delivery 2026-09-16 20:33:57 +05:30
Saket Aryan 7710a4e180 fix(plugins): publish the salt atomically, and omit the hash when there is none
Review finding from @kartik-mem0 on this PR.

O_CREAT|O_EXCL then write leaves a window where the salt file exists and is
empty. Hooks are short-lived processes firing on every tool call and people run
several agent windows, so a concurrent reader lands in that window, reads
nothing, and falls back to a digest of the salt file's own path, memoized for
its whole run. That path is guessable, so the race silently replaced the privacy
control with something an attacker can compute, and hashed the same repository
two ways depending on timing.

The value is now written to a private temp file, fsynced, and published with
os.link, which is atomic and fails if another process already published one.
Link rather than replace, so losing the race adopts their salt instead of
clobbering it. The temp file is removed either way.

The derived fallback is gone rather than fixed. _scoped_digest returns "" when
there is no salt and record() omits the property, because an unsalted digest
over a git remote or a home-directory path is close to plaintext, and shipping
one under a name that says hash is worse than sending nothing.

Three tests: the racing reader never sees the name half-written, a second writer
adopts the first's salt and leaves no temp file, and an unwritable data
directory drops the property instead of emitting a weak one. The old test
asserted the fallback behaviour and is replaced.

265 passed, 8 skipped.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-16 20:33:54 +05:30
Saket Aryan faa3f029a1 Merge branch 'pr3/spool-delivery' into pr4/install-marker-and-identity 2026-09-15 00:33:09 +05:30
Saket Aryan 7bcb9bd124 Merge branch 'pr2/source-at-record-time' into pr3/spool-delivery 2026-09-15 00:32:58 +05:30
Saket Aryan 95d4fc27e2 fix(plugins): make the telemetry salt stable, its own file, and memoized
Review found three ways the first cut produced worse data than no salt at all.
All three came from keeping the salt as a key in the identity dict and doing an
unlocked read-modify-write.

Hooks are short-lived separate processes firing on every tool call, and people
run more than one agent window, so several processes would read {}, each mint
its own uuid4, and each hash with it. One repository hashed several ways in the
window before a writer won.

resolve_distinct_id holds a copy of that same dict across a network call to
/v1/ping/ with a 5s timeout, so whichever write landed second erased the other's
key: losing the salt changes repo_hash mid-stream, losing the email fires a
second $identify and splits the person.

_write_identity swallows OSError, and nothing memoized, so on a read-only or
full data directory every single event got a brand-new random salt — unbounded
cardinality in PostHog, which is strictly worse than the unsalted value it
replaced.

The salt now lives in its own file claimed with O_CREAT|O_EXCL, so exactly one
process wins and the losers read the winner's value, and it is memoized per
process. When it cannot be persisted the fallback is derived from the data
directory path: stable for the machine rather than random per call.

Its own file also means record() no longer creates telemetry-identity.json as a
side effect. is_first_run keys off that file, so the first cut would have
silently suppressed the install event — a production metric change hidden in a
docs PR.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-15 00:25:12 +05:30
Saket Aryan 0d2b20c03d fix(plugins): count installs once, and re-resolve the email when the key changes
code.install counted upgrades and repeat sessions. Session start records install
whenever is_first_run() is true, and that only checked whether
telemetry-identity.json exists. Recording install does not create that file —
only the first successful flush does. So install fired for every 0.2.x user on
their first 0.3.x session (0.2.x never wrote the file, and the data directory
survives the upgrade), again for any session starting before that first flush
finished, and — this is the part that makes it unbounded rather than a race —
on every single session, forever, for anyone whose flush never succeeds. An
offline or firewalled user reported a new install every time they opened an
editor, which is exactly the population hardest to see in the data.

A dedicated install-state.json is now claimed with O_CREAT|O_EXCL at the moment
install is recorded, so two sessions starting together cannot both win, and the
marker is not coupled to identity. Deliberately not the identity file: writing
that from a recording process would race the sender, which writes it during
resolve_distinct_id, and overloading it is what caused this.

Upgrade detection keys on the data directory already having content. A fresh
install has an empty one; anything else predates this session. That is a firmer
predicate than looking for 0.2.x's venv/ and requirements.txt, which is a guess
about files another part of the plugin may or may not have written and only ever
works for this one upgrade. The version is stored in the marker so later changes
record code.upgrade with a real from_version.

A cached email outlived an API key change. resolve_distinct_id kept the first
email it resolved and never looked again, so switching to a key from another
account kept attributing events to the previous one. It now stores a fingerprint
of the key the email came from and re-resolves when the current key differs, and
falls back to the anonymous id when no key is configured rather than continuing
to attribute to an account it cannot verify.

The dangerous part is the alias. resolve_distinct_id's second return value
becomes a PostHog $identify with $anon_distinct_id, and aliasing one account
email to another merges two real person profiles irreversibly. The re-resolve
path returns no alias; aliasing runs anonymous to email only, and never
email to email.

One existing test asserted that is_first_run flips when the identity file is
written, which is the defect itself. Rewritten, along with coverage for atomic
claiming, upgrade detection and version changes.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-15 00:18:38 +05:30
Saket Aryan cfdfe40e09 fix(plugins): stop delivering telemetry events twice, and stop losing parked ones
Two defects, one cause: the spool protocol infers ownership instead of holding
it, and never records progress.

Duplicate delivery after a partial failure. flush() posts the claim in batches of
100 and returns on the first failure, keeping the whole file. The retry then
posts every batch again, including the ones that already arrived — 150 recorded
events were delivered 250 times. Progress is now written back to the claim after
each successful batch, so a retry resumes where the send stopped and a crash
repeats at most one batch.

Duplicate delivery when two senders overlap. spool.replace(claim) is os.rename,
which preserves mtime, so a claim created after a quiet minute inherited the
spool's last-write time and looked abandoned the instant it existed. A second
sender starting while the first was still posting took it over and sent it too —
most likely at session end, when the MCP server's exit sender and the SessionEnd
flush worker both drain. Claims are now touched at claim time, and the per-batch
rewrite doubles as a lease heartbeat. _post makes one attempt with SEND_TIMEOUT
and no retry, so a heartbeat lands well inside the 120s lease; a test asserts
that margin so adding a retry loop to _post cannot silently break it.

Parked batches starved. _claim_spool only looked at parked .sending files when
no spool existed, and because sessions keep recording there usually was one — so
a batch parked by a failed send waited until the 7-day expiry deleted it unsent,
despite its own presence being what starts the sender in the first place.
flush() now drains the live spool and then parked claims in the same run, oldest
first, bounded. Expiry applies only after a genuine retry has failed, with the
attempt count carried in the filename.

A sender that gives up releases its lease rather than heartbeating on the way
out, so the next run picks the batch up promptly instead of waiting a full stale
window for a batch nobody is working on. A failing send stops the run, so one
broken connection cannot burn every parked batch's attempt budget at once.

Two existing tests asserted the old lifecycle and are updated in place, each
with a comment saying what changed.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-15 00:18:21 +05:30
Kartik 73e7b8763a refactor(integrations): shared agent plugin runtimes and native adapters (#7203) 2026-09-08 23:32:25 +05:30
Kartik 71fba8d464 feat(claude-code-plugin): move the Claude Code plugin to its own integration and ship it as 0.3.0 (#7106) 2026-09-01 02:34:45 +05:30