<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Macos on Lessons learned</title><link>https://eghuro.cz/tags/macos/</link><description>Recent content in Macos on Lessons learned</description><generator>Hugo</generator><language>en-us</language><copyright>© Alexandr Mansurov · Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)</copyright><lastBuildDate>Sat, 31 Oct 2026 22:29:00 +0100</lastBuildDate><atom:link href="https://eghuro.cz/tags/macos/index.xml" rel="self" type="application/rss+xml"/><item><title>Backing up ~/.claude and ~/.codex without archiving your AI logins</title><link>https://eghuro.cz/posts/ai-client-local-state-backup/</link><pubDate>Sat, 31 Oct 2026 22:29:00 +0100</pubDate><guid>https://eghuro.cz/posts/ai-client-local-state-backup/</guid><description>&lt;p&gt;If you back up the whole &lt;code&gt;~/.claude&lt;/code&gt; or &lt;code&gt;~/.codex&lt;/code&gt; folder, the restore works, and the backup now holds everything an infostealer wants. The same folder holds your settings, every session transcript in plaintext, and, on Linux, the login itself:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;~/.claude/.credentials.json # Claude Code login, mode 0600&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;~/.codex/auth.json # Codex login when file storage is in use&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;~/.claude/projects/*/*.jsonl # transcripts: prompts, tool output, file contents&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Split the folder into two lists. Back up what you wrote: settings, instructions, skills, hooks, and memory. Keep transcripts as optional encrypted data. Leave login files out of routine backups and log in again after a restore.&lt;/p&gt;&#10;&#10;&lt;h2 id="where-ai-client-state-lives-on-macos-and-linux"&gt;Where AI client state lives on macOS and Linux &lt;/h2&gt;&#10;&lt;p&gt;I checked these paths against Claude Code 2.1.316, Claude Desktop for Linux 4.4212.0, ChatGPT for macOS 1.2026.183, the Codex desktop app for macOS 26.1027.30824, and Codex source at commit &lt;code&gt;67f0994&lt;/code&gt;. &amp;ldquo;Documented&amp;rdquo; means the vendor docs name the path. &amp;ldquo;Observed&amp;rdquo; means I found it by inspecting the official build so that it can change in any release.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Client&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;State root&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Login material&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Evidence&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Claude Code&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;~/.claude/&lt;/code&gt; and &lt;code&gt;~/.claude.json&lt;/code&gt;, both under &lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt; if set&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Keychain on macOS, or &lt;code&gt;~/.claude/.credentials.json&lt;/code&gt; when the Keychain rejects the write (locked in an SSH session, for example). On Linux, always that file&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Documented&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Claude Desktop&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;macOS: &lt;code&gt;~/Library/Application Support/Claude/&lt;/code&gt;. Linux (beta): &lt;code&gt;${XDG_CONFIG_HOME:-~/.config}/Claude/&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Electron cookies and Local Storage, extension secrets encrypted with &lt;code&gt;safeStorage&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;MCP config documented, rest observed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;ChatGPT desktop, macOS only&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;~/Library/Application Support/com.openai.chat/&lt;/code&gt;, &lt;code&gt;~/Library/Group Containers/2DC432GLL2.group.com.openai.chat/&lt;/code&gt;, &lt;code&gt;~/Library/Preferences/com.openai.chat.plist&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Keychain items in OpenAI&amp;rsquo;s access group&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Observed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Codex desktop, macOS only&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;~/Library/Application Support/Codex/&lt;/code&gt;, plus &lt;code&gt;~/.codex&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Shared with Codex CLI&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Observed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Codex CLI&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;${CODEX_HOME:-~/.codex}&lt;/code&gt;. &lt;code&gt;CODEX_SQLITE_HOME&lt;/code&gt;, &lt;code&gt;sqlite_home&lt;/code&gt; and &lt;code&gt;log_dir&lt;/code&gt; can move parts of it&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;auth.json&lt;/code&gt; in &lt;code&gt;file&lt;/code&gt; mode or as the &lt;code&gt;auto&lt;/code&gt; fallback; &lt;code&gt;.credentials.json&lt;/code&gt; for MCP OAuth without a keyring&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Documented and source&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Desktop sessions that run Claude Code write to &lt;code&gt;~/.claude/&lt;/code&gt;, not to the Electron folder, so include both.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-to-back-up-and-what-to-leave-out"&gt;What to back up and what to leave out &lt;/h2&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Treatment&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Claude Code&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Codex&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Back up, versioned&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;settings.json&lt;/code&gt;, &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;rules/&lt;/code&gt;, &lt;code&gt;skills/&lt;/code&gt;, &lt;code&gt;commands/&lt;/code&gt;, &lt;code&gt;agents/&lt;/code&gt;, &lt;code&gt;workflows/&lt;/code&gt;, &lt;code&gt;output-styles/&lt;/code&gt;, &lt;code&gt;keybindings.json&lt;/code&gt;, &lt;code&gt;projects/*/memory/&lt;/code&gt;, &lt;code&gt;agent-memory/&lt;/code&gt;, &lt;code&gt;plugins/installed_plugins.json&lt;/code&gt; as a manifest&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;config.toml&lt;/code&gt;, &lt;code&gt;*.config.toml&lt;/code&gt;, &lt;code&gt;hooks.json&lt;/code&gt;, &lt;code&gt;rules/&lt;/code&gt;, &lt;code&gt;skills/&lt;/code&gt;, agent-role files&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Back up after a secret check&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;~/.claude.json&lt;/code&gt; (personal MCP servers, trust decisions)&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;MCP &lt;code&gt;env&lt;/code&gt; blocks in &lt;code&gt;config.toml&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Optional, encrypted, short retention&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;projects/*/*.jsonl&lt;/code&gt;, &lt;code&gt;subagents/&lt;/code&gt;, &lt;code&gt;tool-results/&lt;/code&gt;, &lt;code&gt;history.jsonl&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;sessions/YYYY/MM/DD/rollout-*.jsonl&lt;/code&gt;, &lt;code&gt;archived_sessions/&lt;/code&gt;, &lt;code&gt;history.jsonl&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Exclude&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;.credentials.json&lt;/code&gt;, &lt;code&gt;file-history/&lt;/code&gt;, &lt;code&gt;paste-cache/&lt;/code&gt;, &lt;code&gt;debug/&lt;/code&gt;, &lt;code&gt;shell-snapshots/&lt;/code&gt;, &lt;code&gt;session-env/&lt;/code&gt;, &lt;code&gt;backups/&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;auth.json&lt;/code&gt;, &lt;code&gt;.credentials.json&lt;/code&gt;, &lt;code&gt;log/&lt;/code&gt;, &lt;code&gt;logs_2.sqlite&lt;/code&gt;, &lt;code&gt;shell_snapshots/&lt;/code&gt;, &lt;code&gt;packages/&lt;/code&gt;, caches&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;For Claude Desktop, keep &lt;code&gt;claude_desktop_config.json&lt;/code&gt; after checking its MCP &lt;code&gt;env&lt;/code&gt; blocks, plus a list of installed extensions and where each came from. Reinstall extensions instead of restoring their executable caches. The ChatGPT app&amp;rsquo;s local databases are caches of a server-side account; use the account&amp;rsquo;s data export for conversations you want to keep.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-copying-the-whole-folder-is-the-wrong-default"&gt;Why copying the whole folder is the wrong default &lt;/h2&gt;&#10;&lt;p&gt;Transcripts are plaintext. &lt;a&#10; href="https://code.claude.com/docs/en/claude-directory.md"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Anthropic&amp;rsquo;s docs&lt;/a&gt;&#10; say that anything passing through a tool is written to the session &lt;code&gt;.jsonl&lt;/code&gt;, so a &lt;code&gt;.env&lt;/code&gt; file a tool reads or a token a command prints is in there. Codex rollouts record tool activity the same way.&lt;/p&gt;&#10;&lt;p&gt;Your backup also keeps transcripts after the client has deleted them. Claude Code deletes transcripts after &lt;code&gt;cleanupPeriodDays&lt;/code&gt; (default 30), but keeps sessions started, or last continued in Claude Desktop or Cowork at any age unless you set &lt;code&gt;desktopSessionCleanupPeriodDays&lt;/code&gt;. A backup with a year of history still holds a transcript eleven months after the client deleted it.&lt;/p&gt;&#10;&lt;p&gt;A restore can also run code. Hooks, MCP server commands, skills, plugins, desktop extensions, and shell snapshots all execute something. If malware edited them, a full restore puts it back on the clean machine.&lt;/p&gt;&#10;&lt;p&gt;The Keychain protects less than it seems to. It stops someone who copies the disk offline, but malware running as your user can still request the item. The Linux desktop build can also fall back to Chromium&amp;rsquo;s basic password store when no wallet is running.&lt;/p&gt;&#10;&#10;&lt;h2 id="dead-ends-worth-skipping"&gt;Dead ends worth skipping &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Guessing Linux paths from macOS ones.&lt;/strong&gt; Replacing &lt;code&gt;Library/Application Support&lt;/code&gt; with &lt;code&gt;.config&lt;/code&gt; works for Claude Desktop, which has a Linux beta. OpenAI ships no Linux desktop app for ChatGPT or Codex. The Codex source has Linux branches, but there is no supported Linux build. On Linux, your ChatGPT state lives in the browser profile.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Copying only the &lt;code&gt;.sqlite&lt;/code&gt; files.&lt;/strong&gt; Codex keeps state in &lt;code&gt;state_5.sqlite&lt;/code&gt;, &lt;code&gt;thread_history_1.sqlite&lt;/code&gt;, &lt;code&gt;memories_1.sqlite&lt;/code&gt; and &lt;a&#10; href="https://github.com/openai/codex/blob/67f0994b532b8740f14b5b738af7b7b2a915b1d6/codex-rs/state/src/sqlite.rs#L48-L53"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;others&lt;/a&gt;&#10;. A live copy without the &lt;code&gt;-wal&lt;/code&gt; and &lt;code&gt;-shm&lt;/code&gt; sidecars can be incomplete or corrupt. Stop the client and copy all three.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Treating the desktop database as the archive.&lt;/strong&gt; The Codex desktop app can rebuild its index from the rollout JSONL files, but metadata that lives only in the database is lost, back up the JSONL files.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Assuming &lt;code&gt;claude purge&lt;/code&gt; cleaned everything.&lt;/strong&gt; It doesn&amp;rsquo;t touch &lt;code&gt;shell-snapshots/&lt;/code&gt;, &lt;code&gt;backups/&lt;/code&gt; (older copies of &lt;code&gt;~/.claude.json&lt;/code&gt;) or files written by &lt;code&gt;/heapdump&lt;/code&gt;. According to Anthropic, a heap snapshot contains the full conversation and any credentials the process held.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="restore-into-a-staging-profile"&gt;Restore into a staging profile &lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Reinstall the signed apps, plugins, and extensions from their sources.&lt;/li&gt;&#10;&lt;li&gt;Restore your config, then read every hook, MCP command, and executable path before the first launch.&lt;/li&gt;&#10;&lt;li&gt;Restore transcripts or memory only if you need them.&lt;/li&gt;&#10;&lt;li&gt;Log in again. Don&amp;rsquo;t restore &lt;code&gt;auth.json&lt;/code&gt;, &lt;code&gt;.credentials.json&lt;/code&gt;, cookies, or Keychain exports.&lt;/li&gt;&#10;&lt;li&gt;Resume one session and confirm that it uses the right account and repository.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&#10;&lt;h2 id="verify-the-backup-set"&gt;Verify the backup set &lt;/h2&gt;&#10;&lt;p&gt;To list what exists and how large it is, without printing any contents, run this on the machine you back up:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; p in &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CONFIG_DIR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="p"&gt;/.claude&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude.json&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CODEX_HOME&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="p"&gt;/.codex&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;XDG_CONFIG_HOME&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="p"&gt;/.config&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/Claude&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/Application Support/Claude&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/Application Support/Codex&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/Application Support/com.openai.chat&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/Group Containers/2DC432GLL2.group.com.openai.chat&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;[&lt;/span&gt; -e &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; du -sh &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then check that no login file ended up in the backup:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;find /path/to/mounted/backup &lt;span class="se"&gt;\(&lt;/span&gt; -name .credentials.json -o -name auth.json &lt;span class="se"&gt;\)&lt;/span&gt; -print&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It should print nothing. If it prints anything, that backup is a credential store: encrypt it, shorten its retention, or rotate the logins.&lt;/p&gt;&#10;&#10;&lt;h2 id="if-malware-may-already-have-run"&gt;If malware may already have run &lt;/h2&gt;&#10;&lt;p&gt;Don&amp;rsquo;t treat a restore as the cleanup. From a clean device, revoke your Claude and ChatGPT sessions, then rotate API keys, MCP and connector credentials, and any secret that appeared in a transcript or in tool output. Any backup the malware could reach counts as exposed. Rebuild the user profile, restore only config you have reviewed, and log in again.&lt;/p&gt;&#10;&#10;&lt;h2 id="caveats"&gt;Caveats &lt;/h2&gt;&#10;&lt;p&gt;Paths and retention rules change between releases. The ChatGPT and desktop paths come from build inspection, not from a published schema, so check them against your installed versions before you write a backup job. Sources: the &lt;a&#10; href="https://code.claude.com/docs/en/claude-directory.md"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;&lt;code&gt;.claude&lt;/code&gt; directory reference&lt;/a&gt;&#10;, &lt;a&#10; href="https://code.claude.com/docs/en/authentication.md#credential-management"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Claude Code credential management&lt;/a&gt;&#10;, &lt;a&#10; href="https://learn.chatgpt.com/docs/config-file/config-advanced.md#config-and-state-locations"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Codex config and state locations&lt;/a&gt;&#10;, &lt;a&#10; href="https://learn.chatgpt.com/docs/auth.md#credential-storage"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Codex credential storage&lt;/a&gt;&#10;, &lt;a&#10; href="https://modelcontextprotocol.io/quickstart/user"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Claude Desktop MCP config paths&lt;/a&gt;&#10;, and &lt;a&#10; href="https://www.electronjs.org/docs/latest/api/app#appgetpathname"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Electron&amp;rsquo;s &lt;code&gt;app.getPath()&lt;/code&gt;&lt;/a&gt;&#10;.&lt;/p&gt;&#10;&lt;p&gt;Back up the config you wrote, and leave out the files the app uses for login. Run the &lt;code&gt;find&lt;/code&gt; check above against your latest backup tonight.&lt;/p&gt;&#10;</description></item><item><title>"No route to host" only for the AI agent: macOS Local Network privacy</title><link>https://eghuro.cz/posts/macos-local-network-privacy-agent-no-route-to-host/</link><pubDate>Sat, 17 Oct 2026 22:30:00 +0200</pubDate><guid>https://eghuro.cz/posts/macos-local-network-privacy-agent-no-route-to-host/</guid><description>&lt;p&gt;My coding agent couldn&amp;rsquo;t reach anything on my home network:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;nc: connectx to 192.168.0.2 port 22 (tcp) failed: No route to host&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ping: sendto: No route to host&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The router answered, and &lt;code&gt;arp -an&lt;/code&gt; listed the server&amp;rsquo;s MAC address. In my own Terminal, the same &lt;code&gt;ssh&lt;/code&gt; command connected. If you see this on a Mac and only your agent sees it, your network is fine. macOS is blocking the app the agent runs in because that app doesn&amp;rsquo;t have the Local Network permission.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;macOS 27 on Wi-Fi&lt;/li&gt;&#10;&lt;li&gt;Claude Code in its VS Code extension, so every command the agent runs is a child process of VS Code&lt;/li&gt;&#10;&lt;li&gt;Targets on the Mac&amp;rsquo;s own subnet: a Linux server, a NAS, a second router&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;The agent was helping me with a server that really was down (&lt;a&#10; href="https://eghuro.cz/posts/systemd-run-rollback-killed-dhclient"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;that story is here&lt;/a&gt;&#10;). It probed the subnet from the Mac, found that only the router answered, and reported with confidence that &amp;ldquo;the Mac reaching only [the router] looks like client isolation on the Wi-Fi. That&amp;rsquo;s usually a guest network.&amp;rdquo; It told me to join a different network.&lt;/p&gt;&#10;&lt;p&gt;I wasn&amp;rsquo;t on a guest network, and I said so. The agent looked again and noticed what rules the network out: &lt;code&gt;ping&lt;/code&gt; itself failed with &lt;code&gt;sendto: No route to host&lt;/code&gt;. That message comes from the local &lt;code&gt;sendto()&lt;/code&gt; call, so the Mac refused to send the packet at all. Client isolation drops packets in the access point, and then &lt;code&gt;ping&lt;/code&gt; reports &lt;code&gt;Request timeout&lt;/code&gt;, not a send error. The target&amp;rsquo;s MAC was already in the ARP table, so the kernel knew exactly where to send it.&lt;/p&gt;&#10;&lt;p&gt;So the block was at the MAC, and every TCP test the agent ran from there was worthless.&lt;/p&gt;&#10;&lt;p&gt;Later that evening, in a new session, it happened again with a different host on the same subnet. This time the agent suspected its own sandbox and retried with the sandbox off. Same error. What settled it was my reply: the same &lt;code&gt;ssh&lt;/code&gt; command works in my Terminal.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Open System Settings → Privacy &amp;amp; Security → Local Network and turn on Visual Studio Code (or whichever app hosts your agent). The agent&amp;rsquo;s next &lt;code&gt;ssh&lt;/code&gt; went through. If yours still fails, restart the app.&lt;/p&gt;&#10;&lt;p&gt;The app can be missing from the list if macOS never showed it the permission prompt. Apple&amp;rsquo;s &lt;a&#10; href="https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;TN3179&lt;/a&gt;&#10; notes that the prompt doesn&amp;rsquo;t appear when a very short-lived process makes the request, and most agent commands exit right after they fail. Since macOS 27.2, the list includes a + button to add an app yourself.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-only-the-agent"&gt;Why only the agent &lt;/h2&gt;&#10;&lt;p&gt;Local Network privacy covers traffic to addresses on a Wi-Fi or Ethernet network that goes directly to the host, not through a router. TN3179 lists who gets access without asking:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;daemons started by &lt;code&gt;launchd&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;programs running as root&lt;/li&gt;&#10;&lt;li&gt;&amp;ldquo;command-line tools run from Terminal or over SSH, including any child processes they spawn&amp;rdquo;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Everything else is charged to its &amp;ldquo;responsible code&amp;rdquo;. If an app spawns a helper and the helper connects to a local address, macOS checks the app&amp;rsquo;s permission. The agent&amp;rsquo;s shell is a child of VS Code, so &lt;code&gt;ssh&lt;/code&gt;, &lt;code&gt;nc&lt;/code&gt;, and &lt;code&gt;ping&lt;/code&gt; run there under VS Code&amp;rsquo;s permission. The same binary, run by the same user from Terminal, is allowed.&lt;/p&gt;&#10;&lt;p&gt;That explains the other half of the symptom. Internet traffic goes through the router, so it isn&amp;rsquo;t local and works fine. TN3179 also exempts the device&amp;rsquo;s DNS server when it sits on the local network, and a home router is usually the DNS server it hands out by DHCP. That is probably why the router was the one host still reachable, and it is exactly how client isolation looks from the inside.&lt;/p&gt;&#10;&lt;p&gt;The same trap waits for any agent or tool that an app such as an IDE starts on macOS.&lt;/p&gt;&#10;&#10;&lt;h2 id="check-before-you-blame-the-network"&gt;Check before you blame the network &lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Run &lt;code&gt;ping&lt;/code&gt; from the agent&amp;rsquo;s shell. &lt;code&gt;sendto: No route to host&lt;/code&gt; for a host on your subnet whose MAC is in &lt;code&gt;arp -an&lt;/code&gt; means the Mac refused to send it.&lt;/li&gt;&#10;&lt;li&gt;Run the same command in Terminal. If it works there, check the app&amp;rsquo;s Local Network permission.&lt;/li&gt;&#10;&lt;li&gt;If it fails in Terminal too, the cause is the network or the routing table. Later that same evening, another VPN came up as &lt;code&gt;utun6&lt;/code&gt; and took the default route, which cut the whole Mac off from the subnet. This time the agent caught it in &lt;code&gt;netstat -rn -f inet&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;On macOS, an app without the Local Network permission gets &lt;code&gt;No route to host&lt;/code&gt; for hosts on your subnet, and so does every tool it spawns. An agent running inside that app will diagnose a working network as broken.&lt;/p&gt;&#10;&lt;p&gt;If your agent runs inside an app on macOS, open Privacy &amp;amp; Security → Local Network now and check that the app is on the list and switched on.&lt;/p&gt;&#10;</description></item><item><title>"Ansible requires blocking IO on stdin/stdout/stderr" when an AI agent runs Ansible</title><link>https://eghuro.cz/posts/ansible-requires-blocking-io-agent-shell/</link><pubDate>Tue, 13 Oct 2026 22:34:00 +0200</pubDate><guid>https://eghuro.cz/posts/ansible-requires-blocking-io-agent-shell/</guid><description>&lt;p&gt;The first time my coding agent ran Ansible, every command failed before Ansible had read a single file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR: Ansible requires blocking IO on stdin/stdout/stderr. Non-blocking file handles detected: &amp;lt;stdout&amp;gt;, &amp;lt;stderr&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;--syntax-check&lt;/code&gt; too, and even &lt;code&gt;ansible --version&lt;/code&gt;. If you see this from an agent, a CI job, or a wrapper script, Ansible isn&amp;rsquo;t broken. The process that started it handed it non-blocking file handles, and a few shell redirections replace them.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;ansible-core 2.20 (Ansible 13.3.0 from Homebrew) on macOS&lt;/li&gt;&#10;&lt;li&gt;A coding agent (Claude Code in VS Code) that runs commands in its own shell and captures their output&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The check has been in every Ansible CLI entry point since ansible-core 2.14 (2022).&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;O_NONBLOCK is a flag on the open file, not on the process, so every child that inherits the file descriptor inherits the flag too. The agent&amp;rsquo;s harness connects the shell&amp;rsquo;s stdout and stderr to non-blocking pipes, and the shell passes them on unchanged to &lt;code&gt;ansible-playbook&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;At startup, Ansible calls &lt;code&gt;os.get_blocking()&lt;/code&gt; on each of the three handles and exits if any of them is non-blocking. The list at the end of the message names the handles that failed. That makes it a good diagnostic: the first failure in my session listed only &lt;code&gt;&amp;lt;stderr&amp;gt;&lt;/code&gt;, because the command was &lt;code&gt;ansible --version | head -3&lt;/code&gt;. The pipe to &lt;code&gt;head&lt;/code&gt; had already given stdout a fresh, blocking handle, while stderr still pointed at the agent&amp;rsquo;s pipe.&lt;/p&gt;&#10;&lt;p&gt;Run this in the shell you suspect to see which handles are affected:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 -c &lt;span class="s1"&gt;&amp;#39;import os; print([os.get_blocking(fd) for fd in (0, 1, 2)])&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;False&lt;/code&gt; in any position means Ansible will refuse to start there.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Give Ansible three handles the shell creates itself:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ansible-playbook site.yml &amp;lt;/dev/null 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;| cat&lt;/code&gt; gives stdout a new pipe, which is blocking.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; sends stderr into that same pipe. &lt;code&gt;| cat&lt;/code&gt; alone fixes only stdout.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;&amp;lt;/dev/null&lt;/code&gt; replaces stdin. My agent&amp;rsquo;s stdin was never flagged, but Ansible checks it too, and an agent can&amp;rsquo;t answer a prompt anyway.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The pipe has a side effect: the command&amp;rsquo;s exit status is now &lt;code&gt;cat&lt;/code&gt;&amp;rsquo;s, which is 0. A failed playbook looks like a success unless you turn on &lt;code&gt;pipefail&lt;/code&gt; (bash and zsh both have it):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -o pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ansible-playbook site.yml &amp;lt;/dev/null 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To check the fix without touching your real shell, you can make all three handles non-blocking for a single command and try it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;NB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;import os,sys; [os.set_blocking(fd, False) for fd in (0,1,2)]; os.execvp(sys.argv[1], sys.argv[1:])&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;python3 -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$NB&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; ansible --version&lt;span class="o"&gt;)&lt;/span&gt; 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &amp;lt;/dev/zero &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ERROR: ... Non-blocking file handles detected: &amp;lt;stdin&amp;gt;, &amp;lt;stdout&amp;gt;, &amp;lt;stderr&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;python3 -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$NB&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; sh -c &lt;span class="s1"&gt;&amp;#39;ansible --version &amp;lt;/dev/null 2&amp;gt;&amp;amp;1 | cat&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &amp;lt;/dev/zero &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ansible [core 2.20.1] ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The outer &lt;code&gt;| cat&lt;/code&gt; and &lt;code&gt;&amp;lt;/dev/zero&lt;/code&gt; keep the flag on throwaway handles, so your terminal stays blocking.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-ansible-insists"&gt;Why Ansible insists &lt;/h2&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://github.com/ansible/ansible/pull/77639"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;pull request that added the check&lt;/a&gt;&#10; explains it. On a non-blocking stdout, a large write can fail partway with &lt;code&gt;[Errno 11] write could not complete without blocking&lt;/code&gt;, and Ansible&amp;rsquo;s output gets cut off, or the run dies with an &amp;ldquo;Unexpected Exception&amp;rdquo;. The check turns those intermittent failures into one clear error at startup. The PR names another way to end up there: &lt;code&gt;ssh&lt;/code&gt; switches its handles to non-blocking while it runs and doesn&amp;rsquo;t always switch them back when it exits.&lt;/p&gt;&#10;&lt;p&gt;So the same error can turn up wherever a parent process hands down non-blocking pipes, CI runners included, or in a terminal after an &lt;code&gt;ssh&lt;/code&gt; that didn&amp;rsquo;t clean up.&lt;/p&gt;&#10;&#10;&lt;h2 id="make-the-agent-remember-it"&gt;Make the agent remember it &lt;/h2&gt;&#10;&lt;p&gt;The agent worked this out within two minutes, and later that night it wrote the fix into the project&amp;rsquo;s skill for provisioning VMs:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Run Ansible from Claude Code with &lt;span class="sb"&gt;`&amp;lt;/dev/null`&lt;/span&gt; and pipe its output through &lt;span class="sb"&gt;`cat`&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The next morning, a session about a different service ran &lt;code&gt;ansible-playbook --syntax-check&lt;/code&gt; without the redirections and hit the same error. It never loaded the VM skill, so it never saw the line. A rule about how to run a tool belongs in the file the agent reads in every session, not in the instructions for one task.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;&amp;ldquo;Ansible requires blocking IO&amp;rdquo; means the process that started Ansible handed it non-blocking file handles. &lt;code&gt;&amp;lt;/dev/null 2&amp;gt;&amp;amp;1 | cat&lt;/code&gt; gives it blocking ones, and &lt;code&gt;set -o pipefail&lt;/code&gt; keeps a failed run from looking like a success.&lt;/p&gt;&#10;&lt;p&gt;If your agent runs Ansible, put that line in the instructions file it reads every session (&lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt;).&lt;/p&gt;&#10;</description></item><item><title>APFS sparse bundle on SMB: rsync "Operation not supported (45)" and F_FULLFSYNC</title><link>https://eghuro.cz/posts/apfs-sparse-bundle-smb-fullfsync/</link><pubDate>Tue, 29 Sep 2026 12:55:00 +0200</pubDate><guid>https://eghuro.cz/posts/apfs-sparse-bundle-smb-fullfsync/</guid><description>&lt;p&gt;A backup job that rsyncs into encrypted disk images on a NAS started printing this for every file (paths elided):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rsync: [generator] failed to set times on …&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rsync: [receiver] mkstemp …&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rsync: [generator] delete_file: unlink … failed: Operation not supported (45)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then &lt;code&gt;hdiutil attach&lt;/code&gt; for the next image hung for over ten minutes, and the two images whose rsyncs had finished stayed mounted.&lt;/p&gt;&#10;&lt;p&gt;If you keep APFS sparse bundles on an SMB share and see error 45 inside them, the files aren&amp;rsquo;t the problem. The share can&amp;rsquo;t do &lt;code&gt;F_FULLFSYNC&lt;/code&gt;, and APFS can&amp;rsquo;t commit a single transaction without it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;p&gt;macOS 27.0.1 dropped AFP, so an old NAS that speaks only SMB 2.1 got mounted over SMB instead. Each backup source has its own AES-256 sparse bundle on the share, created like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hdiutil create -encryption AES-256 -type SPARSEBUNDLE -size 50g &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -fs APFS -volname app -stdinpass /Volumes/backup/staging/app.sparsebundle&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The job attaches a bundle, rsyncs a server&amp;rsquo;s files into it, and detaches it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-kernel-log"&gt;The kernel log &lt;/h2&gt;&#10;&lt;p&gt;rsync names the files, so the obvious suspects are permissions or the files themselves. The kernel log points at the image instead (timestamps shortened, process column removed):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/usr/bin/log show --last 90m --style compact &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --predicate &lt;span class="s1"&gt;&amp;#39;process == &amp;#34;kernel&amp;#34; AND eventMessage CONTAINS &amp;#34;apfs&amp;#34;&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;12:43:53.531 disk7s1 mount-complete volume app, requested by: mount_apfs (pid 82535); parent: mount (pid 82534)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;12:43:53.532 disk7s1 Host is remote&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;12:47:45.393 (apfs) tx_flush_thread:1678: disk7 tx_flush(8311) failed, 45, # 297&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;12:47:45.931 (apfs) tx_flush_thread:1678: disk7 tx_flush(8311) failed, 45, # 298&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;APFS noticed the image lives on a remote host. From 24 seconds after mounting, it failed to flush a transaction about twice a second, with error 45 (&lt;code&gt;ENOTSUP&lt;/code&gt;). The second mounted image did the same. Nothing written inside them was being committed.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-probe"&gt;The probe &lt;/h2&gt;&#10;&lt;p&gt;Error 45 from a flush suggested the sync call, not the write. This script tries plain &lt;code&gt;fsync&lt;/code&gt; and the two stronger syncs macOS offers. Run it from a directory on the share:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;fcntl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;os&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;_synctest&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_RDWR&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_CREAT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mo"&gt;0o600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;x&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;fsync&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;F_FULLFSYNC&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;51&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;F_BARRIERFSYNC&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;85&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;OK&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="ne"&gt;OSError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;-&amp;gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;fsync OK&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;F_FULLFSYNC -&amp;gt; [Errno 45] Operation not supported&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;F_BARRIERFSYNC -&amp;gt; [Errno 45] Operation not supported&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;APFS asks the disk image to flush; the disk image passes that on as &lt;code&gt;F_FULLFSYNC&lt;/code&gt; on its band files on the share, and the SMB client says no.&lt;/p&gt;&#10;&lt;p&gt;The NAS also had a share called &lt;code&gt;TimeMachineBackup&lt;/code&gt;, and Time Machine over SMB needs full sync, so it seemed worth a try. Same result. Its Time Machine backups dated from the AFP days.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-works"&gt;What works &lt;/h2&gt;&#10;&lt;p&gt;Keep the image off the share. The bundles moved to a USB drive, and every healthy one attaches, writes, deletes, and detaches fine there. That&amp;rsquo;s where the job runs now.&lt;/p&gt;&#10;&lt;p&gt;If the image has to stay on the share, use HFS+ inside it. A test bundle on the same share, created with &lt;code&gt;-fs HFS+J&lt;/code&gt; instead of &lt;code&gt;-fs APFS&lt;/code&gt;, passed everything the job does: rsync with &lt;code&gt;--delete-during&lt;/code&gt;, rename, setting times, deletes, detach and reattach.&lt;/p&gt;&#10;&lt;p&gt;If the server runs Samba and you control it, &lt;code&gt;fruit:time machine = yes&lt;/code&gt; on the share makes vfs_fruit advertise the FULLSYNC volume capability to macOS clients. That should let APFS images commit, but it wasn&amp;rsquo;t tested here.&lt;/p&gt;&#10;&#10;&lt;h2 id="check-the-images-you-already-have"&gt;Check the images you already have &lt;/h2&gt;&#10;&lt;p&gt;The failed commits also left damage. When the bundles were copied to the USB drive, one of them wouldn&amp;rsquo;t mount:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hdiutil: attach failed - no mountable file systems&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A read-only check of its volume (&lt;code&gt;fsck_apfs -n&lt;/code&gt;) said why:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;warning: (oid 0x74e6) om: btn: invalid o_cksum (0xdfbd1ccee26c9006)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;** The volume /dev/rdisk8s1 with UUID … was found to be corrupt and cannot be repaired.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Its band files had last been modified during the failed SMB run, and the original on the NAS was identical, so the copy wasn&amp;rsquo;t to blame. Since it was only a staging mirror, it got recreated empty, and the next run filled it again. After any such episode, attach each image and run &lt;code&gt;diskutil verifyVolume&lt;/code&gt; on it.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-restic-didnt-mind"&gt;Why restic didn&amp;rsquo;t mind &lt;/h2&gt;&#10;&lt;p&gt;Restic repositories on the same share were unaffected. Restic is written in Go, and Go&amp;rsquo;s &lt;code&gt;Fsync&lt;/code&gt; on macOS tries &lt;code&gt;F_FULLFSYNC&lt;/code&gt; and falls back to plain &lt;code&gt;fsync&lt;/code&gt; on &lt;code&gt;ENOTSUP&lt;/code&gt;. The &lt;a&#10; href="https://github.com/golang/go/blob/master/src/internal/poll/fd_fsync_darwin.go"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;comment in the Go source&lt;/a&gt;&#10; names this exact case: &amp;ldquo;There are scenarios such as SMB mounts where fcntl will fail with ENOTSUP.&amp;rdquo; APFS inside a disk image has no such fallback: when the full flush fails, it stops committing.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;An APFS disk image on an SMB share works only if the share supports &lt;code&gt;F_FULLFSYNC&lt;/code&gt;. Without it, every write inside the image fails with error 45, and the failed commits can corrupt the image.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;re moving sparse bundles from AFP to SMB, run the probe above on the new share before you mount anything on it.&lt;/p&gt;&#10;</description></item><item><title>macOS SMB: OSError: [Errno 9] Bad file descriptor after a silent reconnect</title><link>https://eghuro.cz/posts/macos-smb-ebadf-after-reconnect/</link><pubDate>Tue, 29 Sep 2026 12:15:00 +0200</pubDate><guid>https://eghuro.cz/posts/macos-smb-ebadf-after-reconnect/</guid><description>&lt;p&gt;A Python script that had encrypted backup archives on a NAS for months started failing on every run:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Traceback (most recent call last):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; File &amp;#34;/Volumes/backup/./encrypt-one.py&amp;#34;, line 74, in &amp;lt;module&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; main()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; File &amp;#34;/Volumes/backup/./encrypt-one.py&amp;#34;, line 38, in main&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; with open(f, &amp;#34;rb&amp;#34;) as src:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;OSError: [Errno 9] Bad file descriptor&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then the bash script that called it stopped too. Not intermittently: start the job and something fails sooner or later.&lt;/p&gt;&#10;&lt;p&gt;If you run long jobs from an SMB share on macOS and get &lt;code&gt;EBADF&lt;/code&gt; on files that are plainly there, the cause is probably a session reconnect you never saw, and the kernel log is the only place that records it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;p&gt;macOS 27 dropped AFP, so an old NAS that speaks only SMB 2.1 got mounted over SMB instead. A bash script on the share runs &lt;code&gt;encrypt-one.py&lt;/code&gt; on a few archives in parallel with &lt;code&gt;xargs -P&lt;/code&gt;. Each run streams a 600 MB archive from the share through &lt;code&gt;openssl enc&lt;/code&gt; and writes the result back to the same share.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-dead-end"&gt;The dead end &lt;/h2&gt;&#10;&lt;p&gt;The traceback points at &lt;code&gt;open()&lt;/code&gt;, so the first move was to open and read that archive by hand. It worked, and so did a read under &lt;code&gt;xargs -P 3&lt;/code&gt;. A file that opens fine and a job that fails anyway tells you the file isn&amp;rsquo;t the problem. Something happens to the handle while it&amp;rsquo;s open.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-kernel-log"&gt;The kernel log &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;smbutil&lt;/code&gt; gave the first hint:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ smbutil statshares -m /Volumes/backup&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;SMB_VERSION SMB_2.1&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;SESSION_RECONNECT_TIME 2026-09-29 12:01:17&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;SESSION_RECONNECT_COUNT 4&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Four reconnects; the last one was inside the failed run. The kernel log explains what each one does (timestamps shortened, process column removed):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/usr/bin/log show --last 1h --style compact &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --predicate &lt;span class="s1"&gt;&amp;#39;process == &amp;#34;kernel&amp;#34; AND eventMessage CONTAINS[c] &amp;#34;smb&amp;#34;&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:15.502 (smbfs) smb_iod_sendall: id 0 ts &amp;gt; iod_lastrecv &amp;amp;&amp;amp; ts &amp;gt; sr_timesent.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:15.502 (smbfs) smb_iod_reconnect: id 0 Starting reconnect with nas.local&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:15.502 (smbfs) smb2_smb_parse_change_notify: smb_rq_reply failed 60&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:16.523 (smbfs) smb_iod_reconnect: id 0 The negotiate succeeded to nas.local&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.065 (smbfs) smb2fs_reconnect_dur_handle: Missing durable handle on &amp;lt;archive.tar.bz2.enc.tmp&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.173 (smbfs) smb2fs_reconnect_dur_handle: Missing durable handle on &amp;lt;backup.sh&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.186 (smbfs) smb2fs_reconnect_dur_handle: Missing durable handle on &amp;lt;archive.tar.bz2&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.205 (smbfs) smb_iod_reconnect: id 0: Reconnect completed successfully.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.212 (smbfs) smb2_smb_read_write_async: smb_rq_reply failed 9&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.212 (smbfs) smbfs_do_strategy: archive.tar.bz2: READ failed with an error of 9&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.283 (smbfs) smbfs_do_strategy: archive.tar.bz2.enc.tmp: WRITE failed with an error of 9&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The NAS stopped answering under sustained I/O (error 60 is &lt;code&gt;ETIMEDOUT&lt;/code&gt;). macOS dropped the session and reconnected, and that part worked. Then it tried to reattach each open file; the server had nothing to reattach them to, and every read and write on those files failed with error 9, &lt;code&gt;EBADF&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Look at the middle line: &lt;code&gt;backup.sh&lt;/code&gt; is on the list. Bash reads a script from disk as it runs, so the script file is open the whole time and goes bad with everything else. That&amp;rsquo;s why the shell stopped as well.&lt;/p&gt;&#10;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;A durable handle is how an SMB 2.x client keeps an open file across a dropped connection: the server holds the open for a while, and the client reclaims it after reconnecting. This server granted none. So &amp;ldquo;Reconnect completed successfully&amp;rdquo; is true for the session and useless for every file that was open at the time. Nothing reaches the application except &lt;code&gt;EBADF&lt;/code&gt; on the next read or write.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-fixed-it"&gt;What fixed it &lt;/h2&gt;&#10;&lt;p&gt;Stop running long I/O jobs from the share. The pipeline moved to a USB drive, and only finished files go to the NAS. That ended the failures.&lt;/p&gt;&#10;&lt;p&gt;If you can&amp;rsquo;t move off the share, two workarounds got the job past the failing step:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;Make bash read the whole script before it starts, so it never goes back to the share for the next line:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/usr/bin/env bash&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ... the whole script ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;exit&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Bash parses the entire &lt;code&gt;{ }&lt;/code&gt; block before running any of it, and the &lt;code&gt;exit&lt;/code&gt; stops it from reading past the closing brace.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;Make the I/O survive a reconnect: process the file in segments (64 MiB worked), open, write and &lt;code&gt;fsync&lt;/code&gt; each segment separately, and on &lt;code&gt;EBADF&lt;/code&gt;, &lt;code&gt;EIO&lt;/code&gt; or &lt;code&gt;EAGAIN&lt;/code&gt; reopen and retry only that segment. With AES-CBC you can chain the segments by using the last ciphertext block as the next IV, and the output is byte-identical to one &lt;code&gt;openssl enc&lt;/code&gt; pass. In the test run on the share, no reconnect happened at all, probably because the per-segment &lt;code&gt;fsync&lt;/code&gt; slowed the writes enough for the NAS to keep up.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;If the server is a Samba box you control, check whether it grants durable handles: the &lt;code&gt;durable handles&lt;/code&gt; option only takes effect with &lt;code&gt;kernel oplocks = no&lt;/code&gt;, &lt;code&gt;kernel share modes = no&lt;/code&gt; and &lt;code&gt;posix locking = no&lt;/code&gt;. Turning off SMB signing on the client (&lt;code&gt;signing_required=no&lt;/code&gt; in &lt;code&gt;/etc/nsmb.conf&lt;/code&gt;) might also take enough load off a weak NAS CPU to prevent the stalls. Neither was tried here.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;On macOS, an SMB reconnect to a server without durable handles invalidates every open file on the share, including a running shell script, and the only trace is &lt;code&gt;Missing durable handle&lt;/code&gt; in the kernel log.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;ve seen &lt;code&gt;Errno 9&lt;/code&gt; on an SMB mount, run &lt;code&gt;smbutil statshares -a&lt;/code&gt; and look at &lt;code&gt;SESSION_RECONNECT_COUNT&lt;/code&gt; before you debug your code.&lt;/p&gt;&#10;</description></item><item><title>Zero failures is suspicious: agent checks that silently ran nothing</title><link>https://eghuro.cz/posts/zero-failures-is-suspicious/</link><pubDate>Tue, 29 Sep 2026 12:05:00 +0200</pubDate><guid>https://eghuro.cz/posts/zero-failures-is-suspicious/</guid><description>&lt;p&gt;My coding agent ran each of the two flaky Rust tests 150 times and got this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;streamed_read_right_after_a_rename: 0/150 failed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;renames_are_combined_across_changes_they_do_not_depend_on: 0/150 failed&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Its next message was &amp;ldquo;Zero failures is suspicious.&amp;rdquo; It was right: neither test had run once. In the same week, three of its checks gave a tidy answer (nothing found, all passed, all failed) that came from a broken command, and each broken command hid its own error.&lt;/p&gt;&#10;&lt;p&gt;If your agent&amp;rsquo;s output has &lt;code&gt;(eval):log:1: too many arguments&lt;/code&gt;, &lt;code&gt;0 passed; 0 failed; … filtered out&lt;/code&gt; or &lt;code&gt;argument list too long&lt;/code&gt; somewhere above the summary, the summary is about a check that never ran.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Claude Code 2.1.283 on macOS, where the agent&amp;rsquo;s shell is zsh 5.9&lt;/li&gt;&#10;&lt;li&gt;A Rust workspace whose test binary is run directly from &lt;code&gt;target/debug/deps&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="trap-1-zsh-has-a-log-builtin"&gt;Trap 1: zsh has a &lt;code&gt;log&lt;/code&gt; builtin &lt;/h2&gt;&#10;&lt;p&gt;The agent was debugging &lt;a&#10; href="https://eghuro.cz/posts/fskit-mount-fts-read-operation-not-permitted"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;an FSKit file system extension&lt;/a&gt;&#10; and searched the unified log for sandbox denials:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;log show --last 30m --style compact --predicate &lt;span class="s1"&gt;&amp;#39;process == &amp;#34;MyFS&amp;#34;&amp;#39;&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="p"&gt;|&lt;/span&gt; tail -40&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Nothing came back, and the agent told me: &amp;ldquo;No log entries so far.&amp;rdquo; Half a minute later it ran &lt;code&gt;log stream … &amp;gt; /tmp/fs.log 2&amp;gt;&amp;amp;1 &amp;amp;&lt;/code&gt; and read the file. Its first line was:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(eval):log:1: too many arguments&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;zsh has a builtin called &lt;code&gt;log&lt;/code&gt; that lists the logins &lt;code&gt;watch&lt;/code&gt; reports. Builtins win over &lt;code&gt;$PATH&lt;/code&gt;, so &lt;code&gt;log show&lt;/code&gt; never reached &lt;code&gt;/usr/bin/log&lt;/code&gt;, and &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; threw away the only sign of it. You can check your own shell:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-console" data-lang="console"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gp"&gt;$&lt;/span&gt; zsh -c &lt;span class="s1"&gt;&amp;#39;whence -wa log&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;log: builtin&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;log: command&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With &lt;code&gt;/usr/bin/log&lt;/code&gt;, the denial showed up at once, and the agent corrected its earlier claim. The same error turned up in a different project&amp;rsquo;s session days later.&lt;/p&gt;&#10;&#10;&lt;h2 id="trap-2-cargo-test---exact-with-the-wrong-path-passes"&gt;Trap 2: &lt;code&gt;cargo test --exact&lt;/code&gt; with the wrong path passes &lt;/h2&gt;&#10;&lt;p&gt;The 150-run loop called the test binary like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$B&lt;/span&gt; --exact tests::&lt;span class="nv"&gt;$t&lt;/span&gt; -q &amp;gt; /tmp/out_&lt;span class="nv"&gt;$t&lt;/span&gt;.txt 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;f+1&lt;span class="k"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With &lt;code&gt;--exact&lt;/code&gt;, the filter has to equal the full test path. The tests live in &lt;code&gt;vfs::tests&lt;/code&gt;, so &lt;code&gt;tests::streamed_read_right_after_a_rename&lt;/code&gt; matched nothing, and libtest treats that as success:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 93 filtered out; finished in 0.00s&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Exit code 0, every time. Without &lt;code&gt;--exact&lt;/code&gt;, the same filter would have matched as a substring. The agent listed the real names with &lt;code&gt;$B --list&lt;/code&gt;, fixed the prefix, and the races then showed up under load.&lt;/p&gt;&#10;&#10;&lt;h2 id="trap-3-an-empty-variable-makes-everything-fail"&gt;Trap 3: an empty variable makes everything fail &lt;/h2&gt;&#10;&lt;p&gt;After the fixes, the agent stress-ran the suite 400 times and looked up the binary first:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;B&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;ls -t target/debug/deps/storage_core-* &lt;span class="p"&gt;|&lt;/span&gt; grep -v &lt;span class="s1"&gt;&amp;#39;\.d$&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; head -1&lt;span class="k"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The glob had grown past what &lt;code&gt;ls&lt;/code&gt; accepts:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(eval):1: argument list too long: ls&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;failed runs: 400 / 400&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;$B&lt;/code&gt; was empty. zsh drops an empty unquoted word, so each run executed &lt;code&gt;vfs::tests -q&lt;/code&gt;, got &lt;code&gt;command not found&lt;/code&gt; into a log file nobody read, and counted as a failure. This time the agent saw the error right above the count and called the result bogus.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Make every check prove that it ran before you trust what it says.&lt;/p&gt;&#10;&lt;p&gt;Call &lt;code&gt;/usr/bin/log&lt;/code&gt; by its full path, or put &lt;code&gt;command&lt;/code&gt; in front of it, and keep stderr visible while you are still debugging:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/usr/bin/log show --last 30m --style compact --predicate &lt;span class="s1"&gt;&amp;#39;process == &amp;#34;MyFS&amp;#34;&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; tail -40&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Count the tests that ran, not only the failures. One test per run should say &lt;code&gt;1 passed&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$B&lt;/span&gt; --exact vfs::tests::&lt;span class="nv"&gt;$t&lt;/span&gt; -q &amp;gt; /tmp/out_&lt;span class="nv"&gt;$t&lt;/span&gt;.txt 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;f+1&lt;span class="k"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -q &lt;span class="s1"&gt;&amp;#39;1 passed&amp;#39;&lt;/span&gt; /tmp/out_&lt;span class="nv"&gt;$t&lt;/span&gt;.txt &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="s2"&gt; did not run&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; 1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Get the binary path from cargo instead of a glob, and stop if it is empty:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;B&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;cargo &lt;span class="nb"&gt;test&lt;/span&gt; -p storage-core --lib --no-run 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; grep -o &lt;span class="s1"&gt;&amp;#39;target/debug/deps/storage_core-[0-9a-f]*&amp;#39;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;: &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;B&lt;/span&gt;&lt;span class="p"&gt;:?test binary not found&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For the agent, add one line to its instructions file (&lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt;):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;When a check finds nothing, passes everything, or fails everything, first show that it ran:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;how many items it checked, and that a known-bad input fails it.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="why-it-happens"&gt;Why it happens &lt;/h2&gt;&#10;&lt;p&gt;All three are the same bug: the harness failed, and the failure looked like a result. A loop that counts exit codes can&amp;rsquo;t tell &amp;ldquo;no failures&amp;rdquo; from &amp;ldquo;no tests&amp;rdquo;, and a pipeline that ends in &lt;code&gt;2&amp;gt;/dev/null | tail&lt;/code&gt; can&amp;rsquo;t tell &amp;ldquo;no matches&amp;rdquo; from &amp;ldquo;no command&amp;rdquo;. Agents write exactly these one-liners, in zsh, many times per session, and they report what the last line printed.&lt;/p&gt;&#10;&lt;p&gt;The agent caught two of the three because the number looked wrong: zero out of 150 for known flaky tests, 400 out of 400 for a suite that had just passed. An empty log search looked plausible, so it went to the user as a finding.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A clean result from a check you haven&amp;rsquo;t seen fail is not evidence. Before you believe &amp;ldquo;nothing found&amp;rdquo; or &amp;ldquo;0 failed&amp;rdquo;, look for the count of what was checked.&lt;/p&gt;&#10;&lt;p&gt;Next time your agent reports zero failures, ask it how many tests actually ran.&lt;/p&gt;&#10;</description></item><item><title>FSKit after mount -F: "ls: fts_read: Operation not permitted"</title><link>https://eghuro.cz/posts/fskit-mount-fts-read-operation-not-permitted/</link><pubDate>Sat, 26 Sep 2026 16:30:00 +0200</pubDate><guid>https://eghuro.cz/posts/fskit-mount-fts-read-operation-not-permitted/</guid><description>&lt;p&gt;My new FSKit filesystem mounted without complaint, but nothing inside it could be read:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ ls -la ~/mnt/storage-test&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ls: fts_read: Operation not permitted&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;total 0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ cat ~/mnt/storage-test/sub/a.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cat: /Users/me/mnt/storage-test/sub/a.txt: Operation not permitted&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;stat&lt;/code&gt; on the mount point worked. If your FSKit extension does the same after a &lt;code&gt;mount -F&lt;/code&gt; from the command line, check its sandbox before your code: the extension can see the source folder&amp;rsquo;s metadata but may not read its contents.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;macOS 26, a file system extension built on FSKit&amp;rsquo;s &lt;code&gt;FSUnaryFileSystem&lt;/code&gt; that exposes a local folder through a mount point&lt;/li&gt;&#10;&lt;li&gt;A small host app, &lt;code&gt;StorageHost.app&lt;/code&gt;, in &lt;code&gt;/Applications&lt;/code&gt;; its executable runs &lt;code&gt;mount -F -t storagefs &amp;lt;source&amp;gt; &amp;lt;mount point&amp;gt;&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;The extension &lt;code&gt;StorageFS.appex&lt;/code&gt; with the FSKit entitlement and the app sandbox&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;Info.plist&lt;/code&gt; with &lt;code&gt;FSSupportsPathURLs&lt;/code&gt; set to true and &lt;code&gt;FSRequiresSecurityScopedPathURLResources&lt;/code&gt; set to false&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;The first mount was over &lt;code&gt;~/Downloads&lt;/code&gt;, so the obvious suspect was privacy protection (TCC) for that folder. A fresh &lt;code&gt;~/storage-test&lt;/code&gt; with one file in it failed the same way, which ruled TCC out and pushed the search elsewhere.&lt;/p&gt;&#10;&lt;p&gt;My coding agent&amp;rsquo;s first log queries came back empty, and it told me there were no log entries. That was wrong. In zsh, &lt;code&gt;log&lt;/code&gt; is a builtin, so &lt;code&gt;log show ... 2&amp;gt;/dev/null&lt;/code&gt; runs the builtin, sends its &lt;code&gt;too many arguments&lt;/code&gt; error to &lt;code&gt;/dev/null&lt;/code&gt;, and prints nothing. Call &lt;code&gt;/usr/bin/log&lt;/code&gt; by its full path. With that, the kernel had the answer:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;kernel (Sandbox) 3 duplicate reports for Sandbox: StorageFS(83505) deny(1) file-read-data /Users/me/storage-test&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The extension&amp;rsquo;s own log showed the failures from FSKit&amp;rsquo;s side: &lt;code&gt;readDirectory:...reply:error:1&lt;/code&gt; and &lt;code&gt;readFrom:...reply:error:1&lt;/code&gt;. Error 1 is &lt;code&gt;EPERM&lt;/code&gt;, which &lt;code&gt;ls&lt;/code&gt; turns into &lt;code&gt;fts_read: Operation not permitted&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;mount -F&lt;/code&gt; gives the extension a plain path, not a security-scoped URL, so a sandboxed process can&amp;rsquo;t read an arbitrary path. For development, grant temporary sandbox exceptions in the extension&amp;rsquo;s &lt;code&gt;.entitlements&lt;/code&gt; file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-xml" data-lang="xml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;com.apple.security.temporary-exception.files.home-relative-path.read-write&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;com.apple.security.temporary-exception.files.absolute-path.read-write&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/Volumes/&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This covers your home folder and external drives. It doesn&amp;rsquo;t override privacy protection, so &lt;code&gt;~/Downloads&lt;/code&gt;, &lt;code&gt;~/Documents&lt;/code&gt;, and &lt;code&gt;~/Desktop&lt;/code&gt; may still be refused; test with a folder elsewhere. Temporary exceptions are for development on your own machine. A shipping extension should use security-scoped resources instead. &lt;code&gt;FSRequiresSecurityScopedPathURLResources&lt;/code&gt; in &lt;code&gt;Info.plist&lt;/code&gt; requests that; I haven&amp;rsquo;t managed a clean test yet (see below).&lt;/p&gt;&#10;&lt;p&gt;The same logs flagged two more problems that I fixed along the way:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-[FSModuleVolume(Project) getMaxFileSizeInBits]: Volume implements neither maxFileSizeInBits nor maxFileSize. One of them must be implemented.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-[FSVolumeConnector getStandardItemAttributesForItem:replyHandler:]_block_invoke: Reported attributes are incomplete&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The first needs &lt;code&gt;maximumFileSizeInBits&lt;/code&gt; on the volume. The second went away once the item attributes set &lt;code&gt;flags&lt;/code&gt;, which cleared the log entry.&lt;/p&gt;&#10;&#10;&lt;h2 id="reinstalling-switches-the-module-off"&gt;Reinstalling switches the module off &lt;/h2&gt;&#10;&lt;p&gt;To test each change, I replaced the app: &lt;code&gt;rm -rf /Applications/StorageHost.app&lt;/code&gt;, copied the new build, and launched it once. After that, &lt;code&gt;mount&lt;/code&gt; failed outright:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Module com.example.storage.fs is disabled!&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mount: Unable to invoke task&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;pluginkit&lt;/code&gt; disagreed. &lt;code&gt;pluginkit -e use -i com.example.storage.fs&lt;/code&gt; succeeded, and &lt;code&gt;pluginkit -m&lt;/code&gt; listed the extension with a &lt;code&gt;+&lt;/code&gt;, which means enabled. FSKit still refused. It keeps its own switch in System Settings → General → Login Items &amp;amp; Extensions → File System Extensions, and I didn&amp;rsquo;t find a command-line way to flip it. After I turned StorageFS back on there, the next mount listed and read files, and appends, renames, truncation, and an 8 MB write-and-readback all worked.&lt;/p&gt;&#10;&lt;p&gt;The disabled module also spoiled my one test of security-scoped resources. With &lt;code&gt;FSRequiresSecurityScopedPathURLResources&lt;/code&gt; set to true, &lt;code&gt;mount&lt;/code&gt; failed with &lt;code&gt;Unable to invoke task&lt;/code&gt;, but the reinstall had just switched the module off, so the failure says nothing about the setting.&lt;/p&gt;&#10;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;An FSKit module is an app extension, and FSKit requires it to run in the app sandbox. When an app hands a folder to the extension, sandbox access can travel with it as a security-scoped URL. &lt;code&gt;mount -F&lt;/code&gt; on the command line passes only a string. The sandbox lets the extension look up metadata for that path, which is why the mount and &lt;code&gt;stat&lt;/code&gt; worked, and denies &lt;code&gt;file-read-data&lt;/code&gt;, which every directory listing and file read needs.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;If an FSKit mount works and every read returns &lt;code&gt;Operation not permitted&lt;/code&gt;, search the kernel log for &lt;code&gt;deny(1) file-read-data&lt;/code&gt; with &lt;code&gt;/usr/bin/log&lt;/code&gt;, then give the extension sandbox access to the source path. After every reinstall, check the File System Extensions switch before you trust a test result.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;ve got &lt;code&gt;FSRequiresSecurityScopedPathURLResources&lt;/code&gt; working with a command-line &lt;code&gt;mount -F&lt;/code&gt;, I&amp;rsquo;d like to hear how.&lt;/p&gt;&#10;</description></item><item><title>ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY: your coding agent's pnpm 11 is not your pnpm 10</title><link>https://eghuro.cz/posts/pnpm-11-agent-runtime-no-tty/</link><pubDate>Mon, 06 Jul 2026 15:04:00 +0200</pubDate><guid>https://eghuro.cz/posts/pnpm-11-agent-runtime-no-tty/</guid><description>&lt;p&gt;A coding agent ran &lt;code&gt;pnpm test&lt;/code&gt; in my repo and got this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Scope: all 6 workspace projects&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY] Aborted removal of modules directory due to no TTY&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;If you are running pnpm in CI, set the CI environment variable to &amp;#34;true&amp;#34;, or set &amp;#34;confirmModulesPurge&amp;#34; to &amp;#34;false&amp;#34;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The stack trace ended in &lt;code&gt;runDepsStatusCheck (.../node_modules/.pnpm/pnpm@11.7.0/...)&lt;/code&gt;. My &lt;code&gt;package.json&lt;/code&gt; says &lt;code&gt;&amp;quot;packageManager&amp;quot;: &amp;quot;pnpm@10.33.0&amp;quot;&lt;/code&gt;, and &lt;code&gt;pnpm -v&lt;/code&gt; in my terminal prints &lt;code&gt;10.33.0&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;If you see this error only inside an agent, the agent is running its own pnpm, and following the hint makes things worse.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;macOS, pnpm 10.33.0 through Corepack, a pnpm monorepo with a Next.js 15 app&lt;/li&gt;&#10;&lt;li&gt;The Codex desktop app, which ships its own Node 24 and pnpm under &lt;code&gt;~/.cache/codex-runtimes/codex-primary-runtime/dependencies/&lt;/code&gt;: pnpm 11.7.0 in July, 11.25.0 in October&lt;/li&gt;&#10;&lt;li&gt;The agent runs &lt;code&gt;pnpm test&lt;/code&gt; without a TTY&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;The agent followed the hint and reran with &lt;code&gt;CI=true pnpm test&lt;/code&gt;. pnpm stopped asking and did what it had wanted to do from the start:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Recreating /Users/me/project/node_modules&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Progress: resolved 378, reused 0, downloaded 137, added 12&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[WARN] GET https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz error (ERR_PNPM_ENOSPC). Will retry in 10 seconds. 2 retries left.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[WARN] GET https://registry.npmjs.org/next/-/next-15.5.19.tgz error (ERR_PNPM_ENOSPC). Will retry in 10 seconds. 2 retries left.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Progress: resolved 378, reused 0, downloaded 377, added 377, done&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: cpu-features@0.0.10, esbuild@0.21.5, esbuild@0.28.1, protobufjs@7.6.4, sharp@0.34.5, ssh2@1.17.0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It deleted &lt;code&gt;node_modules&lt;/code&gt; and downloaded every package again (&lt;code&gt;reused 0&lt;/code&gt;) into a second store, and the disk ran out on the way. It left an untracked &lt;code&gt;.pnpm-store/&lt;/code&gt; in the repo and wrote placeholders into the tracked &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;allowBuilds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;cpu-features&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;esbuild&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;protobufjs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ssh2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No tests ran. After that, even &lt;code&gt;pnpm exec tsc --noEmit&lt;/code&gt; failed with the same NO_TTY error, so the agent switched to &lt;code&gt;./node_modules/.bin/tsc&lt;/code&gt; and &lt;code&gt;./node_modules/.bin/vitest&lt;/code&gt;, reverted the YAML, deleted &lt;code&gt;.pnpm-store/&lt;/code&gt; and committed with only the focused tests green.&lt;/p&gt;&#10;&lt;p&gt;Ten weeks later, &lt;code&gt;next build&lt;/code&gt; failed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ⚠ Attempted to load @next/swc-darwin-arm64, but it was not installed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ⨯ Failed to load SWC binary for darwin/arm64, see more info here: https://nextjs.org/docs/messages/failed-loading-swc&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The July install had ended one package short (378 resolved, 377 added), and the SWC binary was one of the two downloads that hit &lt;code&gt;ENOSPC&lt;/code&gt;. A later agent session &amp;ldquo;fixed&amp;rdquo; the build by installing the binary into &lt;code&gt;/tmp&lt;/code&gt; and symlinking it into &lt;code&gt;node_modules&lt;/code&gt;, a fix that lasts until macOS cleans &lt;code&gt;/tmp&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;First, ask the agent&amp;rsquo;s shell which pnpm it gets:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;command&lt;/span&gt; -v pnpm&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pnpm -v&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then make the agent use your version. Corepack reads &lt;code&gt;packageManager&lt;/code&gt;, whatever is first on &lt;code&gt;PATH&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;corepack pnpm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="c1"&gt;# runs pnpm 10.33.0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Write that into &lt;code&gt;AGENTS.md&lt;/code&gt; (or your agent&amp;rsquo;s equivalent), along with one rule: if pnpm wants to remove &lt;code&gt;node_modules&lt;/code&gt;, stop and report.&lt;/p&gt;&#10;&lt;p&gt;To make the bundled pnpm harmless as well, turn off the automatic install in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;verifyDepsBeforeRun&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That is the pnpm 10 default, so nothing changes for you. I checked it with the bundled pnpm 11.25.0: with the setting, it runs the script; without it, it installs first.&lt;/p&gt;&#10;&lt;p&gt;If you already followed the hint, clean up with your own pnpm:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git checkout -- pnpm-workspace.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm -rf .pnpm-store&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;corepack pnpm install --frozen-lockfile&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Run that in a terminal. If pnpm asks to remove &lt;code&gt;node_modules&lt;/code&gt;, say yes; this time it rebuilds against your own store.&lt;/p&gt;&#10;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;Three pnpm 11 changes line up:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;code&gt;verify-deps-before-run&lt;/code&gt; defaults to &lt;code&gt;install&lt;/code&gt; (pnpm 10: &lt;code&gt;false&lt;/code&gt;). &lt;code&gt;pnpm test&lt;/code&gt; and &lt;code&gt;pnpm exec&lt;/code&gt; check &lt;code&gt;node_modules&lt;/code&gt; first and install if anything is off.&lt;/li&gt;&#10;&lt;li&gt;pnpm 11 uses store &lt;code&gt;v11&lt;/code&gt;; pnpm 10 uses &lt;code&gt;v10&lt;/code&gt;. &lt;code&gt;node_modules/.modules.yaml&lt;/code&gt; records the store it was linked from, so to pnpm 11 a working pnpm 10 install looks incompatible and has to be purged. &lt;code&gt;confirmModulesPurge&lt;/code&gt; defaults to &lt;code&gt;true&lt;/code&gt;; a purge without a TTY aborts, and &lt;code&gt;CI=true&lt;/code&gt; drops the confirmation but not the purge.&lt;/li&gt;&#10;&lt;li&gt;Ignored build scripts are an error, and pnpm writes the &lt;code&gt;allowBuilds&lt;/code&gt; placeholders for you to fill in.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;On my Mac, the two stores now sit side by side in &lt;code&gt;~/Library/pnpm/store/&lt;/code&gt;: 851 MB in &lt;code&gt;v10&lt;/code&gt;, 588 MB in &lt;code&gt;v11&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Why didn&amp;rsquo;t &lt;code&gt;packageManager&lt;/code&gt; catch it? pnpm 11 does honor it: run the bundled binary directly and &lt;code&gt;pnpm -v&lt;/code&gt; prints &lt;code&gt;10.33.0&lt;/code&gt;, because it switches to the pinned version. The wrapper in the October runtime turns that off:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/usr/bin/env bash&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;dirname &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASH_SOURCE&lt;/span&gt;&lt;span class="p"&gt;[0]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;pnpm_config_pm_on_fail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ignore&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/../../node/bin/node&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/../../node/node_modules/pnpm/bin/pnpm.mjs&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The environment variable beats anything in the repo. With &lt;code&gt;pmOnFail: error&lt;/code&gt; in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;, or &lt;code&gt;devEngines.packageManager&lt;/code&gt; with &lt;code&gt;&amp;quot;onFail&amp;quot;: &amp;quot;error&amp;quot;&lt;/code&gt;, the wrapper still runs 11.25.0.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;An agent runtime can bring its own toolchain and switch off the check that would have caught it. When an agent reports &amp;ldquo;tests blocked by the environment&amp;rdquo;, find out which binary it ran before you believe the report.&lt;/p&gt;&#10;&lt;p&gt;Run &lt;code&gt;pnpm -v&lt;/code&gt; in your agent&amp;rsquo;s shell today and compare it with &lt;code&gt;packageManager&lt;/code&gt;.&lt;/p&gt;&#10;</description></item></channel></rss>