diff --git a/integrations/agent-plugin-core/python/telemetry.py b/integrations/agent-plugin-core/python/telemetry.py index 2cb1056e0..d86873d3d 100644 --- a/integrations/agent-plugin-core/python/telemetry.py +++ b/integrations/agent-plugin-core/python/telemetry.py @@ -147,6 +147,19 @@ def _install_salt() -> str: os.link(temporary, path) except FileExistsError: pass + except OSError: + # No hardlinks here (some network mounts, some container volumes). + # Claim the name directly instead. That reopens the empty-file + # window, but the window is now benign: a reader that lands in it + # gets "" and omits the hash for that process rather than caching a + # guessable one. Losing the hashes on every run of an entire + # filesystem is the worse failure. + try: + fallback = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fallback, "w", encoding="utf-8") as stream: + stream.write(temporary.read_text(encoding="utf-8")) + except OSError: + pass except OSError: pass finally: diff --git a/integrations/antigravity-plugin/core/telemetry.py b/integrations/antigravity-plugin/core/telemetry.py index 2cb1056e0..d86873d3d 100644 --- a/integrations/antigravity-plugin/core/telemetry.py +++ b/integrations/antigravity-plugin/core/telemetry.py @@ -147,6 +147,19 @@ def _install_salt() -> str: os.link(temporary, path) except FileExistsError: pass + except OSError: + # No hardlinks here (some network mounts, some container volumes). + # Claim the name directly instead. That reopens the empty-file + # window, but the window is now benign: a reader that lands in it + # gets "" and omits the hash for that process rather than caching a + # guessable one. Losing the hashes on every run of an entire + # filesystem is the worse failure. + try: + fallback = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fallback, "w", encoding="utf-8") as stream: + stream.write(temporary.read_text(encoding="utf-8")) + except OSError: + pass except OSError: pass finally: diff --git a/integrations/claude-code-plugin/core/telemetry.py b/integrations/claude-code-plugin/core/telemetry.py index 2cb1056e0..d86873d3d 100644 --- a/integrations/claude-code-plugin/core/telemetry.py +++ b/integrations/claude-code-plugin/core/telemetry.py @@ -147,6 +147,19 @@ def _install_salt() -> str: os.link(temporary, path) except FileExistsError: pass + except OSError: + # No hardlinks here (some network mounts, some container volumes). + # Claim the name directly instead. That reopens the empty-file + # window, but the window is now benign: a reader that lands in it + # gets "" and omits the hash for that process rather than caching a + # guessable one. Losing the hashes on every run of an entire + # filesystem is the worse failure. + try: + fallback = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fallback, "w", encoding="utf-8") as stream: + stream.write(temporary.read_text(encoding="utf-8")) + except OSError: + pass except OSError: pass finally: diff --git a/integrations/claude-code-plugin/tests/test_telemetry.py b/integrations/claude-code-plugin/tests/test_telemetry.py index b816b629e..6029fa98a 100644 --- a/integrations/claude-code-plugin/tests/test_telemetry.py +++ b/integrations/claude-code-plugin/tests/test_telemetry.py @@ -352,6 +352,26 @@ def test_a_half_written_salt_is_never_visible_to_another_process(isolated_env, m assert salt_path.read_text(encoding="utf-8").strip() == salt +def test_a_filesystem_without_hardlinks_still_gets_a_salt(isolated_env, monkeypatch): + """Publishing by link must not become a silent loss of the hashes. + + Some network mounts and container volumes reject os.link. Returning "" + there would drop repo_hash and session_hash on every run for that whole + cohort, which is a bigger loss than the narrow race the link closes. + """ + telemetry._salt_cache = "" + monkeypatch.setattr( + telemetry.os, "link", lambda src, dst: (_ for _ in ()).throw(OSError(38, "not implemented")) + ) + + salt = telemetry._install_salt() + + assert len(salt) == 32, "no salt on a filesystem without hardlinks" + assert telemetry._salt_path().read_text(encoding="utf-8").strip() == salt + assert telemetry._scoped_digest("git@github.com:acme/x.git") != "" + assert not list(telemetry._salt_path().parent.glob("telemetry-salt.*.tmp")) + + def test_a_concurrent_writer_does_not_clobber_the_published_salt(isolated_env): """Second process to finish must adopt the first one's salt, not replace it. diff --git a/integrations/codex-plugin/core/telemetry.py b/integrations/codex-plugin/core/telemetry.py index 2cb1056e0..d86873d3d 100644 --- a/integrations/codex-plugin/core/telemetry.py +++ b/integrations/codex-plugin/core/telemetry.py @@ -147,6 +147,19 @@ def _install_salt() -> str: os.link(temporary, path) except FileExistsError: pass + except OSError: + # No hardlinks here (some network mounts, some container volumes). + # Claim the name directly instead. That reopens the empty-file + # window, but the window is now benign: a reader that lands in it + # gets "" and omits the hash for that process rather than caching a + # guessable one. Losing the hashes on every run of an entire + # filesystem is the worse failure. + try: + fallback = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fallback, "w", encoding="utf-8") as stream: + stream.write(temporary.read_text(encoding="utf-8")) + except OSError: + pass except OSError: pass finally: diff --git a/integrations/cursor-plugin/core/telemetry.py b/integrations/cursor-plugin/core/telemetry.py index 2cb1056e0..d86873d3d 100644 --- a/integrations/cursor-plugin/core/telemetry.py +++ b/integrations/cursor-plugin/core/telemetry.py @@ -147,6 +147,19 @@ def _install_salt() -> str: os.link(temporary, path) except FileExistsError: pass + except OSError: + # No hardlinks here (some network mounts, some container volumes). + # Claim the name directly instead. That reopens the empty-file + # window, but the window is now benign: a reader that lands in it + # gets "" and omits the hash for that process rather than caching a + # guessable one. Losing the hashes on every run of an entire + # filesystem is the worse failure. + try: + fallback = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fallback, "w", encoding="utf-8") as stream: + stream.write(temporary.read_text(encoding="utf-8")) + except OSError: + pass except OSError: pass finally: diff --git a/integrations/kimi-plugin/core/telemetry.py b/integrations/kimi-plugin/core/telemetry.py index 2cb1056e0..d86873d3d 100644 --- a/integrations/kimi-plugin/core/telemetry.py +++ b/integrations/kimi-plugin/core/telemetry.py @@ -147,6 +147,19 @@ def _install_salt() -> str: os.link(temporary, path) except FileExistsError: pass + except OSError: + # No hardlinks here (some network mounts, some container volumes). + # Claim the name directly instead. That reopens the empty-file + # window, but the window is now benign: a reader that lands in it + # gets "" and omits the hash for that process rather than caching a + # guessable one. Losing the hashes on every run of an entire + # filesystem is the worse failure. + try: + fallback = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fallback, "w", encoding="utf-8") as stream: + stream.write(temporary.read_text(encoding="utf-8")) + except OSError: + pass except OSError: pass finally: diff --git a/integrations/mem0-agent-plugin/core/telemetry.py b/integrations/mem0-agent-plugin/core/telemetry.py index 2cb1056e0..d86873d3d 100644 --- a/integrations/mem0-agent-plugin/core/telemetry.py +++ b/integrations/mem0-agent-plugin/core/telemetry.py @@ -147,6 +147,19 @@ def _install_salt() -> str: os.link(temporary, path) except FileExistsError: pass + except OSError: + # No hardlinks here (some network mounts, some container volumes). + # Claim the name directly instead. That reopens the empty-file + # window, but the window is now benign: a reader that lands in it + # gets "" and omits the hash for that process rather than caching a + # guessable one. Losing the hashes on every run of an entire + # filesystem is the worse failure. + try: + fallback = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fallback, "w", encoding="utf-8") as stream: + stream.write(temporary.read_text(encoding="utf-8")) + except OSError: + pass except OSError: pass finally: