Skip to content

Compatibility, plugins and release automation: 123 fixes, release 4.13.0 - #11

Merged
MaxFreedomPollard merged 144 commits into
mainfrom
improve/4.13
Oct 20, 2026
Merged

MaxFreedomPollard merged 144 commits into
mainfrom
improve/4.13

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Owner

What this changes

A compatibility release: Compartment installing, starting and staying connected on every system and in every agent. Six areas were reviewed in parallel (platform and lock model, agent integrations, storage engine, MCP server/CLI/embedder, desktop apps, plugin stores), plus CI and release automation. 123 fixes, each with a test that fails on the old code. Full release notes: .github/release-notes/v4.13.0.md, which the Release workflow attaches to the draft. 4.12.0 was never published, so its fixes ship in this release too.

Highlights

  • Sandboxed agent shells: compartment works from an agent's own sandboxed shell (Claude Code and Codex Seatbelt). Boot clock fallback, no writes on credential lookup, in-process embedding when sockets are refused.
  • PATH for GUI clients: integrate writes the full path of the installed command. GUI clients without your PATH failed with "spawn compartment ENOENT".
  • Windows on ARM and Intel Macs: pip install compartment works there again (platform-scoped dependency caps), and the Mac app ships for Intel too.
  • Correct dependency floor: mcp 1.10–1.21.1 break beside current pydantic, so the floor is mcp >= 1.21.2.
  • Plugins:
    • New: a Claude Desktop extension (.mcpb).
    • Version-gated: the Claude Code plugin.
    • Updated: Codex, MCP Registry, Gemini and LM Studio listings.
    • Prepared: Docker MCP Catalog and Continue entries.
    • Documented in docs/PLUGINS.md.
  • Platform bugs:
    • Windows: a relock after 24.9 days of uptime, and console windows behind the tray.
    • Linux: autostart on non-systemd desktops.
    • macOS: power-cut durability.
  • Release automation: a tag builds the wheel, sdist, and Apple silicon plus Intel .pkg/.dmg, and drafts the release. Publishing uploads those same files to PyPI and attaches the .mcpb.
  • CI coverage:
    • Python 3.11–3.14 on all three OSes, plus ARM64 Linux, Windows on ARM and an Intel Mac.
    • Wheel smoke tests over MCP.
    • The suite at the lowest dependency versions.
    • The macOS app build.
    • A weekly wheel-coverage check.

Test plan

  • Full suite locally (macOS, inside a Seatbelt sandbox): 1677 passed. The 3 failures are sandbox-only: process listing, and AF_UNIX bind refused. They pass on CI.
  • Suite at the lowest dependency versions the bounds allow (Python 3.11, mcp 1.21.2, onnxruntime 1.17, numpy 1.26).
  • tools/wheel_coverage.py: no supported platform needs a compiler.
  • actionlint on every workflow.
  • CI green on this PR: every OS and Python, the macOS app builds, the wheel smoke tests.
  • After merge: tag v4.13.0, check the draft release's assets, then publish.

🤖 Generated with Claude Code

MaxFreedomPollard and others added 30 commits October 19, 2026 17:24
…kernel's record is out of reach

Claude Code and Codex run an agent's shell commands under macOS Seatbelt,
which refuses kern.boottime both to the sysctl binary and to sysctlbyname,
and Android's SELinux hides /proc/stat from Termux. Every vault open there
raised "Cannot identify this boot session", so `compartment store` run by
the agent itself could never use the vault the user had unlocked. The
estimate now falls back to CLOCK_MONOTONIC on macOS (mach_continuous_time,
which counts through sleep) and CLOCK_BOOTTIME elsewhere, and the result is
pinned per boot like every other reading, so a sandboxed process and an
unsandboxed one agree on the token. macOS and the BSDs read kern.boottime
in-process first instead of forking sysctl on every vault open, and
FreeBSD, NetBSD and DragonFly get the kernel record at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…actually installed, or the package manager's link to it, instead of a bare name a GUI app cannot find

executable() looked for the script beside the interpreter, then on PATH,
then gave up and wrote the bare name `compartment`. `pip install --user`,
which is what the install scripts for macOS, Windows and Linux all run,
puts the script in the user scheme's directory (~/Library/Python/3.X/bin,
~/.local/bin, %APPDATA%\Python\PythonXY\Scripts): beside no interpreter,
and on no PATH that a Dock- or Start-menu-launched client, or the panel's
own child process, has. So the Connect button and `integrate` from a
fresh terminal wrote `"command": "compartment"`, and Claude Desktop,
Cursor and the rest failed to start the server with "spawn compartment
ENOENT"; the capture hook named the same bare word.

The scripts directory of the install scheme whose library directory holds
the running package is now asked first: it is where pip wrote the script
for this copy, venv, system or --user alike. A stale copy in another
scheme is not taken for it.

A script inside a package manager's versioned tree, such as Homebrew's
Cellar/compartment/<version>/libexec/bin, stops existing after the next
upgrade and cleanup, so configs written before it pointed at nothing.
When a link on PATH, or in a package manager's bin directory (Homebrew,
pipx's and uv's ~/.local/bin, Nix profiles), resolves to the very script
found, the link is written instead, since every upgrade repoints it. A
link to some other install is not used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l, so the vault keeps opening on a full disk

A journal append that hit ENOSPC or EDQUOT mid write, or whose fsync
failed, left its partial entry on the end of the vault. Nothing had been
acknowledged, but a torn tail is the one thing an open cannot get past
without compacting, and on a disk that was still full the compaction failed
and the open with it: every agent's MCP server reopening after the failed
store, `compartment status` and the panel lost the vault, reads included,
until space was freed. A sync that failed after every byte was written left
a complete entry instead, and the memory the caller had been told was not
stored came back on the next open.

The append now writes through one descriptor and, on any failure, truncates
the file back to where it began (which succeeds on the full disk that caused
it) before re-raising. It no longer creates a missing vault either: an
append to a deleted file used to recreate it as journal frames with no
header, which nothing can open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ved with a byte order mark or as UTF-16 is read and updated instead of refused

Windows users are sent to Notepad or PowerShell to edit these files, and
both save encodings the readers did not accept. Notepad before Windows 10
1903 and PowerShell 5's `Out-File -Encoding utf8` put a UTF-8 byte order
mark first, which json.loads and tomllib do not take for whitespace, so
the file was refused as "not valid JSON" (or TOML) and the user got the
block to paste into the very file just refused. PowerShell 5's `>` and
`Out-File` write UTF-16, which did not decode at all; Claude Desktop's
and the hook's readers let that escape as a bare UnicodeDecodeError.
Codex's own parser skips a leading mark, so a config.toml Codex read
happily was refused too.

There is now one reader, clients.read_config_text, used for every client
config, Codex's TOML, Claude Desktop's config and Claude Code's
settings.json. A marked file is decoded by its mark (UTF-8, UTF-16 or
UTF-32, either byte order); an unmarked one must be UTF-8, as before.
Line endings come back as \n, as read_text gave them, so a CRLF file is
not written back with \r\r\n on Windows. What is written is UTF-8
without a mark, which every client reads: Node's JSON.parse rejects the
mark and cannot read UTF-16, so for Electron clients such a file was
already broken. The backup is the original bytes, as always, and a file
that is not text in any of these is still refused untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… included, and CI covers every declared Python and both ARM platforms

Releases were built by hand on one laptop, so each one depended on that
machine and could only produce installers for its architecture. The new
Release workflow checks the tag against every version field, builds the
wheel and sdist, builds the macOS app, .pkg and .dmg on an Apple silicon
runner and on an Intel one (onnxruntime 1.23.2, its last x86_64 release),
and attaches all of them with SHA256SUMS.txt to a draft release. Publishing
the draft uploads those same wheel and sdist files to PyPI instead of a
second build, so the release page and pip hand out identical bytes.

CI now runs the suite on Python 3.11 through 3.14 (3.13 and 3.14 were
classifiers nothing ran), on ARM64 Linux, Windows on ARM and an Intel Mac.
A wheel-smoke job installs the built wheel with pipx on each system and
uses it like a person and like an MCP client would (tools/mcp_smoke.py), so
a file missing from the wheel or a server that only starts from source is
caught. A lowest-dependencies job runs the suite at the lowest versions the
bounds allow: it found that mcp>=1.0 was never true, since nothing before
1.10 can start the server, so the floor is now mcp>=1.10. Every action is
on its current, Node 24 major.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…no longer leaves a vault that refuses to open as tampered

fsync on macOS hands the data to the drive and no further: the drive may
keep it in its volatile cache and write it out in any order, and SSDs in
current Macs are measured to lose fsync'd data on power loss. Apple
documents F_FULLFSYNC as the call that flushes it. Without it the journal's
promise (an acknowledged write survives kill -9 and power loss, as the docs
say) did not hold on a Mac, and worse, an entry whose length reached the
disk before its body read back as a complete entry failing authentication,
which an open must treat as tampering: the vault refused to open after a
power cut. The compaction's copy had the same exposure, with the rename
able to land before the bytes it points at.

Journal appends, the compaction's copy and the truncation of a refused
append now go through one _sync: F_FULLFSYNC where Python defines it
(macOS, iOS), falling back to fsync on a filesystem that refuses it (SMB,
some FUSE and USB media), and plain fsync everywhere else. It costs about
4 ms per store on an Apple SSD, against roughly 40 ms of embedding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ory, so a vault opens where that directory cannot be made or re-moded

Every vault open asks session.get() first, before the Keychain and before
COMPARTMENT_PASSPHRASE. get() began by making the session directory and
chmod-ing it 0700, on every call, whether or not a credential was there.
Two kinds of process could not do that and so could not open a vault at
all:

- A container run as an arbitrary uid (`docker run --user 1000`,
  OpenShift, Kubernetes runAsUser), where HOME is `/` and the store would
  be /.compartment/session, or a read-only root. mkdir raised
  PermissionError out of find_credential, so even with the passphrase
  exported every open was a traceback. `compartment lock` failed the same
  way.
- An agent's shell under macOS Seatbelt (Claude Code, Codex), which may
  read the user's 0700 session directory but answers chmod on it with
  EPERM; checked in this sandbox on an existing 0700 directory outside
  the write allowlist. So `compartment search` run by the agent died
  before it could read the credential the user had unlocked.

get() and clear() now look at the credential's path without creating
anything, and _session_dir() narrows the mode only when it is not already
0700, so a store that is wider is still narrowed on the next write.
_forget_boot_secret() treats a store that cannot be made like one with no
holder.

Before the fix:
  test_finding_no_credential_needs_no_session_directory
    E  NotADirectoryError: [Errno 20] Not a directory: '.../session'
  test_the_passphrase_variable_opens_a_vault_where_home_is_not_writable
    E  NotADirectoryError: [Errno 20] Not a directory: '.../session'
  test_a_store_that_refuses_chmod_still_yields_its_credential
    E  PermissionError: [Errno 1] Operation not permitted: '.../sess'

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the build refuses a binary that needs a newer one

The bundle declares macOS 13.0 (LSMinimumSystemVersion, the launcher's
-mmacosx-version-min, the .pkg's allowed versions), but the build let
pip choose the wheels, and pip chooses for the macOS it is running on.
From onnxruntime 1.24 on, the macOS wheels are arm64 only and tagged
macosx_14_0, and numpy ships a macosx_14_0 build beside its 11_0 one, so
an app built on a current Mac or a macos-latest runner carried
libraries that need macOS 14. Installer accepted it on macOS 13, and
there the app could not load its embedding model, with no window to say
so.

The wheels are now chosen for the declared minimum: Compartment is
built as a wheel, `pip download --platform macosx_<min>_<arch>
--only-binary=:all:` resolves everything into a wheelhouse, and the
install takes only from it. On arm64 with 13.0 that is onnxruntime
1.23.2, the last release with a macosx_13_0 build; numpy and the rest
get their macosx_11_0 or universal2 wheels. A Python that has no such
set (onnxruntime 1.23.2 stops at 3.13) is refused with what to embed
instead. The launcher is compiled for the interpreter's architecture.

A new check, _verify_deployment_target, reads every Mach-O in the bundle
(thin and universal, LC_BUILD_VERSION and LC_VERSION_MIN_MACOSX) and
fails the build if any lacks the app's architecture or needs a newer
macOS than the app declares. --min-macos (default 13.0, not below it,
since SMAppService needs 13) sets the minimum in one place, and the
.pkg's allowed-os-versions is read from the built app instead of a
second hard-coded 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…config.toml alone when a table cannot be added to it without breaking it

Codex refuses to start on a config.toml it cannot parse, and two things
let `integrate codex` produce one.

The values were escaped with json.dumps, whose escaping resembles TOML's
but writes a character outside the Basic Multilingual Plane as a UTF-16
surrogate pair (🔒). TOML allows only Unicode scalar values in
an escape, so a vault in a folder with an emoji in its name, or under a
user name with a CJK Extension B character, appended a table that made
the whole file unreadable. Strings are now escaped as TOML defines them,
with everything else written as itself in the file's UTF-8.

And the append was never read back. A config that declares
`mcp_servers = { ... }` inline, or as an array, cannot be extended by a
[mcp_servers.compartment] header, so a working config with every other
server in it became one Codex could not load. The appended document is
now parsed before it is written: it has to hold exactly our entry and
everything else exactly as before, or the file is left untouched, no
backup is taken, and the block to paste is printed with the reason.
Dotted keys for another server still take the table, as they should.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed shells, and list the stores and clients people now install from

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ores acknowledged after it survive a power cut

The compaction synced its new copy and renamed it over the vault, but a
rename is a change to the folder, and on POSIX only syncing the folder makes
it durable. After a power cut the old file could come back, and with it
every store acknowledged since the compaction was gone, because each had
been appended (and synced) to the new file that no name pointed at any
more. ext4's ordering usually hid this; btrfs, XFS, APFS and network or
FUSE filesystems give no such promise.

write_vault_file now syncs the containing folder after the rename (with
F_FULLFSYNC on macOS, like every other sync). It is best effort: a mount
that refuses to sync a directory does not fail a save that has already
happened. Skipped on Windows, where a directory cannot be opened for this
and NTFS journals the rename itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… installed, so agents connected from the app get a working memory server

pip writes the absolute path of the interpreter that installed a console
script into its first line, and the build installs with the copy of
Python inside build/Compartment.app. So Contents/Resources/runtime/bin/
compartment in the shipped app named an interpreter on the build machine
(checked: pip 26 writes `#!<build dir>/runtime/bin/python3.12`). That
script is what clients.executable puts into every agent's config when
the panel's Connect button or `integrate` runs inside the app, and what
the capture hook runs. On anybody else's Mac the agent's memory server
failed to start with "bad interpreter", while the panel reported the
agent connected.

The build now rewrites every console script that names the runtime
being built, plain shebang or pip's exec form for a path with a space,
into the relocatable form uv gives its own scripts: run the interpreter
beside the script, found through realpath (macOS 13 and up), with -B so
nothing is written into the signed bundle. A new check fails the build
if any script still names the build directory, and runs the bundle's
compartment --version from a clean environment before signing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ault has changed, keeping whatever else the user put in it

Re-registering Codex reported "already registered; left as it is"
whatever the existing [mcp_servers.compartment] table said, while every
JSON client's entry is refreshed in place. So a Codex entry naming a
bare `compartment` that Codex could not start, a console script an
upgrade had moved, or another vault could not be put right by running
`integrate codex` (or `integrate --all`) again; the user had to remove it
first, and nothing said so.

An entry with the same command and arguments is still left untouched. One
that differs is taken out exactly as `unregister` takes it out, header,
keys and sub-tables, and appended again with the new command and
arguments; anything else the user had added to it (startup_timeout_sec,
an env table, enabled = false) is carried over. The result goes through
the same read-back check as a first registration. An entry written in a
form that cannot be taken out cleanly, an inline table or dotted keys, is
left as it is with the block to paste. Taking a table out now also takes
the blank line that set it off when another follows, so refreshing or
removing it does not leave the file one blank line longer each time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aiting ten seconds and blaming another process

FileLock took every OSError from flock (msvcrt.locking on Windows) for "held
by someone else" and retried until its timeout, then raised "Vault is busy:
another process holds the write lock (waited 10s)". A filesystem that does
not lock at all fails flock with something else: ENOLCK on NFS whose lock
service is not running or that is mounted nolock, ENOTSUP/EOPNOTSUPP on a
macOS NFS mount with nolocks and on some FUSE and SMB mounts. Anyone with a
home directory or vault on such a share waited ten seconds on every write
and was then told to look for a process that did not exist.

Only EWOULDBLOCK/EAGAIN/EACCES (EACCES/EDEADLOCK from msvcrt) now mean
busy. Any other error stops at once with "Cannot lock <file>: the
filesystem it is on does not support file locks (...)", naming network
shares as the usual cause and saying to keep the vault on a local disk or
enable locking on the mount. Locking is never skipped: a vault written
without the lock can lose a concurrent write.

Before the fix:
  test_a_filesystem_without_locks_is_named_at_once_not_called_busy
    [ENOLCK, EOPNOTSUPP, EINVAL]
    E  assert (93361.48 - 93358.43) < 1.5    (it waited the full 3 s)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…del at once, instead of starting a daemon that can never listen

Claude Code and Codex run an agent's shell commands on macOS under a
Seatbelt profile that refuses bind() on an AF_UNIX path with EPERM. Every
`compartment search` or `store` an agent ran there started the shared
embedding daemon, waited for it to import numpy and onnxruntime and exit,
and only then built the in-process model it was always going to use: a
wasted process and a quarter of a second per command, again at every
reconnect in a server, and `compartment embed-daemon run` exited 1 without
a word. A sandbox that refused connect() to a daemon started outside it, or
refused starting a process, was asked again on every call.

The client now asks the kernel with one bind() on a probe name before it
starts anything, treats PermissionError from socket(), connect() or the
spawn as a refusal, and remembers it for the life of the process, so later
embedders go straight to their own model with no system call at all.
`embed-daemon status` says why nothing runs, `embed-daemon run` says why it
cannot listen, and status and stop no longer reach for a uid on Windows or
raise when the session directory cannot be made.

The daemon tests that need a real listening socket now skip with the reason
on a machine that refuses bind(), and the refusal paths are tested
everywhere by refusing bind() and connect() in-process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n Intel build gets its own Compartment-<version>-intel name

The .pkg's distribution file said hostArchitectures="arm64,x86_64" for
every build, and every build is single-architecture: the embedded
standalone Python is one or the other (python-build-standalone has
no universal2 build), and so is every wheel chosen for it,
onnxruntime's included (its last universal2 wheel was 1.20.1). So an
Intel Mac installed the Apple silicon app, which could never launch,
and being a menu bar app with no window it said nothing.

The architecture is now read from the built app's executable, and the
installer allows only that one. An x86_64 build, which choosing the
wheels for the declared minimum (two commits back) makes possible on
an Intel runner with Python 3.12 or 3.13 and onnxruntime 1.23.2, is
written as Compartment-<version>-intel.pkg and
.dmg, so a release can carry both without the second upload replacing
the first; the Apple silicon names stay Compartment-<version>.pkg and
.dmg, which the README and every earlier release link. The .dmg's
READ ME names the .pkg of its own architecture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…OpenBSD's own peer-credential layout, so it no longer turns its own user away

The daemon accepts a connection only from its own uid, asked through
SO_PEERCRED where the platform has it. OpenBSD has SO_PEERCRED, but its
struct sockpeercred is { uid; gid; pid }, while the code unpacked Linux's
struct ucred { pid; uid; gid }. On OpenBSD the "uid" it compared was the
peer's gid: every client of a user whose primary group id differs from
their user id was hung up on, so every agent there fell back to loading
its own model, and a peer whose gid happened to equal the daemon's uid
would have been let in on that basis. The uid is now read from the field
OpenBSD puts it in; Linux, musl and Android keep struct ucred.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erpreter is the problem and where a working one comes from

Some Python builds have a SQLite without FTS5: Python compiled against the
SQLite of CentOS/RHEL 7 or Amazon Linux 2, and some minimal or embedded
builds. The store had a friendly refusal for that, but it ran after the
schema was created, and creating the schema's full-text table is exactly
where such a SQLite first fails. A new vault therefore died on a bare
"sqlite3.OperationalError: no such module: fts5", and the message was only
ever reached when an existing vault was opened.

The schema, the column migration and the probe now run under one handler
that turns a missing fts5 module into the StoreError, and the message names
the SQLite version and the interpreter in use, lists where a Python with
FTS5 comes from, and says how to reinstall with it. There is no degraded
mode to fall back to: the full-text table is part of the vault image and
keyword search is half of every recall. Other schema errors are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or the per-boot secret" error names what to use instead

The boot-session credential is safe only because its per-boot secret is in
a holder no disk backs. OpenBSD's libc implements shm_open as an ordinary
file, /tmp/<sha256 of the name>.shm (lib/libc/gen/shm_open.c, read on
GitHub), and /tmp is a disk partition on a default install. There the
secret sat on the same disk as the session file, and after a power cut it
stayed there until the next boot pruned /tmp: a copy of that disk opened
the credential, the exact case session.py says it protects against.
Storing now refuses on OpenBSD the way it does where shm_open is missing,
before anything is written. FreeBSD (kernel object) and NetBSD (libc
requires /var/shm to be tmpfs and fails ENOTSUP otherwise, lib/librt/shm.c)
are unchanged. There is no onnxruntime build for OpenBSD today, so this
closes the hole before anyone can fall into it.

Where shm_open exists but fails - glibc's /dev/shm missing in a chroot or
build container (ENOENT), read-only or full (EROFS, ENOSPC), refused by a
sandbox (EACCES/EPERM) - unlock said only "Cannot create this boot's secret
(/cmpt...): [Errno 2] No such file or directory". It now says that POSIX
shared memory is not usable here and why it usually is not, and names
COMPARTMENT_PASSPHRASE, `unlock --once`, `unlock --keychain` and
`init --no-session`, as the missing-shm_open error already did.

Before the fix:
  test_a_holder_that_cannot_be_made_names_the_way_round_it
    [ENOENT, EACCES, EROFS, ENOSPC]
    E  assert '/dev/shm' in "Cannot create this boot's secret
       (/cmpt.84fc...): [Errno 2] No such file or directory: ..."
  test_openbsd_does_not_keep_the_secret_in_a_file_on_disk
    E  Failed: DID NOT RAISE CryptoError

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… lines become annotations

A job's log is only readable when signed in, and pytest's verdict sits at
the end of thousands of lines. tools/ci_report.py runs pytest unchanged and,
when it fails under GitHub Actions, also emits the short summary and the
tail as error annotations, which show on the run page, inline on a pull
request and through the check-runs API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… user and no longer deletes everyone's unlock credential

On Windows the session module identifies the user by the USERNAME variable,
for both the holder's registry name and the wrap key's context, and fell
back to the literal "user" when it was missing. A process can be started
without it: an MCP client that replaces the environment with the `env`
block of its config (older MCP SDKs did exactly that), a service wrapper,
a scheduler. That process derived a different holder name, found no secret
there, took the credential for one from an earlier boot, and get() deleted
it - the file every other process of the user was opening - so one
misconfigured client relocked the vault for all of them.

USERNAME still comes first, so every existing holder keeps its name; when
it is missing the account name is asked of Windows (GetUserNameW, which
returns the logon name Windows itself puts in USERNAME) before falling back
to "user". POSIX is unchanged.

Before the fix:
  test_a_windows_process_started_without_username_is_still_the_same_user
    E  AssertionError: assert 'user' == 'Zoë'

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a link, so a link another user leaves in the temp directory cannot make it overwrite a file

A home directory too deep for an AF_UNIX path moves the daemon's socket,
and the .lock and .spawn files beside it, into the shared temp directory,
under names anyone can derive from the home path. Both were opened with
O_CREAT and no O_NOFOLLOW, and the daemon truncates its lock file and
writes its pid there: a symlink or hard link another local user left at
that name made the daemon replace the target, the user's own shell profile
for instance, with a number. Linux refuses to follow a stranger's symlink
in /tmp (fs.protected_symlinks) and hard links to files one does not own
(protected_hardlinks), but FreeBSD, OpenBSD, NetBSD and others do not.

The files are now opened with O_NOFOLLOW and accepted only when they are a
regular file owned by this user with a single link; anything else is
refused, the daemon exits 1 and says why, and the client embeds with its
own model as it does whenever no daemon can be had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t has comments in it, keeping every comment, instead of refusing it

Zed writes its settings.json on first run with a block of // comments
above the opening brace, and VS Code reads mcp.json as JSON with comments
and trailing commas. The writer accepted only strict JSON, so `integrate
zed` (and `--all`) refused the file Zed itself creates on every machine
and printed a block to paste, and a VS Code mcp.json with a comment in it
went the same way.

Re-serialising would have parsed, and deleted everything the user wrote
in comments. So for clients that read their file as JSON with comments
(Client.jsonc), a file the strict reader refuses is now edited as text: it
is parsed with the position of every object member, our member is spliced
in after the last one in the file's own indentation (or its value is
replaced, when it is already there), and the result is parsed again and
must equal the original data with exactly that change, or nothing is
written. Comments, trailing commas, ordering and formatting stay byte for
byte. Taking the entry out works the same way and takes only its own
lines and the comma that separated it. A duplicate key, a root that is
not an object, an unterminated comment or anything else that cannot be
proven is refused as before, with the reason and the block to paste, and
a registration that cannot be taken out is reported as such. Clients that
read strict JSON, such as Cursor, still refuse a file with comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th the broken character shown as U+FFFD, instead of failing

Python's json decoder turns an unpaired "\ud83d" escape into a lone
surrogate without complaint, and agents send them: a JavaScript client that
cuts a string to length cuts emoji in half, and Windows hands back unpaired
UTF-16 from file names and the clipboard. Such a string has no UTF-8
encoding, so the tokenizer refused it ("TypeError: TextInputSequence must be
str") and SQLite refused it after that (UnicodeEncodeError): memory_store,
memory_search, memory_link and memory_relations failed outright with an
error that said nothing about the cause, and the memory was lost.

The vault now cleans the free-text fields at its entry points (store: text,
source, tags, discovered, namespace; search: query, tags, namespace and the
discovery bounds; link and relations: subject, predicate, object, entity,
namespace). A lone surrogate becomes U+FFFD, what any UTF-8 decoder shows
for the same damage, and a pair that arrived as two code points is joined
into its character. Text that already encodes is returned untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pt named like a module it imports

The daemon is started as `python -m compartment.embed_daemon` with the
home directory as its working directory, and `-m` puts the working
directory first on sys.path. A tempfile.py, random.py, json.py, socket.py
or compartment.py in the home directory, the kind of scratch file people
learning Python leave there, was imported in place of the real module: the
daemon died at start every time, and every agent on the machine quietly
loaded a model of its own and paid for a failed start at each connect.

The daemon is now started with -P (Python 3.11+, which Compartment
requires), so the working directory is not put on the path; the package is
still found through the PYTHONPATH the client sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o console window sits behind it for the whole session

The Run key and the scheduled task launched the console script when one
was on PATH, and only fell back to pythonw.exe without one. But
compartment.exe, which pip and uv write for the console script, is a
console program just like python.exe. Started at sign-in it opened a
console window, and since that process is the tray app the window
stayed for the whole session; closing it quit the app. Every pip, pipx
and uv install has the console script on PATH, so this was the normal
case. The docstring said the opposite of what the code did, and the
existing test only checked that python.exe was not named.

pythonw.exe beside the running interpreter now comes first (every venv,
uv tool environment, python.org and Store install has one, and it runs
the same environment's compartment); the console script is the fallback
for a build without one. The running-copy detection matches either
form, since it reads the command line, not the image name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ython, exits non-zero naming the failed step, and lists what it built

Three things stood between tools/build_macos_app.py and a CI runner:

- The icon was drawn at build time by tools/make_icon.py, which needs
  Pillow in whatever Python runs the script (and iconutil). A runner
  given only uv's standalone Python has no Pillow, so the build died at
  the icon after every other step had passed. The package already ships
  that same drawing as data/app.icns (the pip install's login bundle uses
  it), so the build copies it, and only a checkout missing it is drawn.
- A failed step surfaced as a CalledProcessError traceback through _run.
  main() now prints one line, "error: `<command>` exited N", and returns
  1; the checks that refuse a broken bundle already exit 1 with their
  reason. On success it prints `artifact: <path>` for the app and each
  installer.
- hdiutil on hosted runners intermittently fails with "Resource busy";
  it is tried three times, five seconds apart.

find_standalone_python also looks where UV_PYTHON_INSTALL_DIR points.
The docstring gives the exact CI invocation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cess on every vault open

Two forks were left on the path c557f1a meant to make in-process:

- The in-process kern.boottime read found libc with
  ctypes.util.find_library("c"), which on FreeBSD, OpenBSD and DragonFly
  runs /sbin/ldconfig -r and on NetBSD runs the C compiler, in a
  subprocess, on every call. It now uses the process's own symbols
  (CDLL(None)), where libc's sysctlbyname already is.
- On macOS, when the in-process read is refused (Seatbelt, under Claude
  Code and Codex), the sysctl binary was forked as a fallback. It makes the
  same call and is refused the same way, so every vault open in an agent's
  shell paid a process and a sandbox-violation report for nothing. macOS
  now goes straight to the clock fallback, which the binary was only ever
  meant to back up on OpenBSD (no sysctlbyname).

The clock fallback's docstring said macOS CLOCK_MONOTONIC is
mach_continuous_time. It is not: libc computes it as the wall clock minus
the kernel's boot time from the commpage, so wall clock minus it is
kern.boottime to the microsecond (checked in this sandbox: constant across
calls, and 8.6 s from CLOCK_MONOTONIC_RAW). The conclusion, that it counts
through sleep and agrees with the kernel record, stands, and the docstring
now says why. A Linux test now checks the CLOCK_BOOTTIME estimate against
/proc/stat's btime; the existing comparison only ever ran where sysctl was
readable, which no CI runner is.

Before the fix:
  test_a_refused_kern_boottime_on_macos_does_not_fork_sysctl
    E  Left contains one more item: ['sysctl', '-n', 'kern.boottime']
  test_the_in_process_read_runs_no_helper_to_find_libc
    [FreeBSD, NetBSD, DragonFly, Darwin]
    E  Left contains one more item: 'c'

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng missed

OpenBSD has no sysctlbyname, so `sysctl -n kern.boottime` is its only way
to the kernel's boot record, and there it prints just the seconds
(sbin/sysctl/sysctl.c, read on GitHub). The parser only knew FreeBSD's
"{ sec = N, usec = M }" form, so on OpenBSD the kernel record was always
missed and the boot token came from the CLOCK_BOOTTIME estimate instead.
A bare integer is now read as well. FreeBSD's form is unchanged.

Before the fix:
  test_every_sysctl_dialect_of_kern_boottime_is_read[1700000000\n]
    E  assert (None is not None)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w, and Dashboard no longer keeps one open while it runs

The tray app runs with no console (pythonw.exe at sign-in, and
DETACHED_PROCESS when `init` starts it), and every CLI it starts is a
console program. A console program started without CREATE_NO_WINDOW
from a process that has no console gets a window of its own. Only the
vault view and the JSON status calls passed the flag; Unlock, Lock,
Create vault, Change password and the Connect buttons each flashed a
console window, and Dashboard kept one open for as long as the
dashboard ran, where closing it stopped the dashboard.

Every child the panel starts now goes through one helper, _no_console,
which gives the flag on Windows and nothing elsewhere (creationflags is
an error on POSIX). Grandchildren, such as the agent's own CLI that
`integrate` runs, inherit the hidden console and open none either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxFreedomPollard and others added 23 commits October 19, 2026 18:22
…nd every tool failure reaches the model with its reason

`compartment serve` with a --vault that points nowhere (a client set up
before `compartment init`, or a path mistyped in its config) died in
main() with FileNotFoundError whenever a passphrase was in the
environment, so the client showed a server that failed to start, the
reason in a log file. Without one it started, and every tool said the
vault was locked and to run `compartment unlock`, which cannot make a
vault that is not there. The server now starts either way; each tool
answers "No vault at PATH. Create one with `compartment init` on this
machine, or point --vault in this client's MCP config at the vault that
exists", and memory_status reports {"exists": false} with that message.

mcp 2.x passes the model the text of a ToolError only. Any other failure
in a tool, a full disk, a vault file this user cannot read, the shared
embedding process refusing a text, arrived as "Error executing tool
memory_store" and nothing more. Every exception a tool raises is now
turned into the same ToolError the vault's own errors already were, with
its type and message, as mcp 1.x always reported it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ugin work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the PyPI release and attaches it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onger depends on the runner having a display

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anaged Pythons, the stores, and the full path GUI clients need

The install table leads with pipx and uv, says why plain pip fails on
Debian 12+, Ubuntu 23.04+, Fedora, Arch and Homebrew, and lists the install
scripts. A "Where it runs" table gives each system, CPU and Python range as
CI tests them, why onnxruntime sets the limits, and how Alpine, FreeBSD
and Termux run it on their own onnxruntime. The Mac downloads name the
Intel installer; the stores point at docs/PLUGINS.md; the client block
explains the full path a Dock or Start-menu app needs; Docker gets its run
line; the client count is 33; and the retrieval notes say when the exact
index stays above 20,000 records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rrent OpenClaw reads it, in the config OpenClaw's own variables name

Current OpenClaw reads its MCP servers from `mcp.servers`, and its root
schema rejects keys it does not know (src/config/zod-schema.ts and
zod-schema.root-support.ts). `integrate openclaw`, the panel's Connect
button (which runs it) and its "already connected" check used a top-level
`mcpServers`, so the server was never loaded, the config could fail to
load as a whole, and the panel reported OpenClaw connected all the same.
The entry now goes under mcp.servers; one an earlier release left at the
top level is moved there (the key removed once empty); uninstall takes it
out of both places; only mcp.servers counts as connected; and the block
printed for pasting has the current shape.

The config was looked for in OPENCLAW_HOME as though that were the
.openclaw folder itself. OpenClaw (src/infra/config-dir.ts) reads
OPENCLAW_STATE_DIR as the folder, then the folder of OPENCLAW_CONFIG_PATH,
then .openclaw under OPENCLAW_HOME or the home directory; the CLI and the
panel now share that rule (agent_skill._openclaw_dir) and honour
OPENCLAW_CONFIG_PATH as the file. The panel also finds Hermes by the rule
the CLI now uses, so on Windows it looks in %LOCALAPPDATA%\hermes. Tests
that set OPENCLAW_HOME to the folder itself now set OPENCLAW_STATE_DIR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…_CONFIG_DIR moves Claude Code's directory

Claude Code keeps its user CLAUDE.md in its own directory, which
CLAUDE_CONFIG_DIR moves (a second account, a dotfiles tree). `integrate
claude`, the refresh after an update and uninstall always used
~/.claude/CLAUDE.md, which such a Claude Code never loads, so the memory
instructions never reached it and uninstall looked in the wrong file. The
path now comes from claude_memory.claude_config_dir(), the rule the hook
and the importer already follow; CLAUDE_MD still overrides it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and reopen it, and how, instead of "Restart Claude Desktop"

"Restart Claude Desktop" was read as closing its window, which leaves it
running (in the notification area on Windows, in the Dock on macOS): it
never rereads its config, and a copy still running can write the config
it holds back over the one just written. The line now says to quit it
completely, how on this platform (the notification-area icon's Quit on
Windows, Cmd-Q on macOS), and that closing the window leaves it running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fixed count of tries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tings.json that is not JSON instead of ending in a traceback

claude_hooks refuses a settings.json it cannot read as settings (hand
edited, cut short) with a ValueError that names the file. `hook install`
turned that into one line; `hook status`, bare `hook` and `hook uninstall`
let it out as a traceback. All of them now print it and exit 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wn message instead of a traceback

acl.ConfigError, raised for a <vault>.config.json that is not JSON or
not a settings object, names the file and what to do. It is a ValueError
by design (an unlock caller must not read it as "locked"), and the CLI
caught CryptoError alone, so every command that read such a config ended
in a traceback. main() now prints it as "error: ..." with exit 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…yte-order mark

The import now finds a file's encoding the way json does
(json.detect_encoding): the BOM where there is one, otherwise the zero
bytes the opening "{" leaves in UTF-16 or UTF-32, otherwise UTF-8. A
UTF-16 export with no BOM, which some editors and converters write, and
any UTF-32 one were still refused as not UTF-8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, or no home directory at all, every path embeds in-process or says why instead of raising

The daemon's socket lives in the session directory. Where that cannot be
made (a read-only home) a client already fell back to its own model, but
with no home directory at all (HOME unset and no password-database entry,
a container's arbitrary uid) finding it raised RuntimeError, which
escaped get_embedder and failed the vault's first search or store. And
`embed-daemon run` raised a traceback in either case. Both errors now
mean "no daemon" for a client, status and stop report it, and
`embed-daemon run` says there is no session directory and to pass
--socket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…indows-only test expectations match what Windows writes

write_config wrote in text mode, so the same `integrate` produced LF on
macOS and Linux but CRLF on Windows, whatever the file had. It now writes
verbatim: CRLF when the file had CRLF (detected through its byte order mark
for UTF-16 files), LF otherwise and for a new file.

Real Windows CI also showed three tests asserting POSIX results: the
capture hook's vault path is written with forward slashes there (so Git
Bash does not eat the backslashes), and Goose's default config lives under
AppData; the tests now expect what each system really does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…directory

user_model_dir() computed its ~/.compartment/models default even when
COMPARTMENT_MODEL_DIR was set, so with HOME unset and no password-database
entry (a container's arbitrary uid) naming the folder did not help: the
lookup raised "Could not determine home directory" before the variable
was read. The home is now looked up only without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ctory is read from the environment before home() is asked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d of loading it or starting the shared embedding process

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cannot hang, and the MCP smoke test fails on an unclean exit

`2fa enable` wrote the keyfile and chmod'ed it after, leaving the second
factor at the umask's mode for that moment and writing through anything
planted at the path; it is now created 0600 with O_EXCL. `setup
download-model` bounds its download at 120 s instead of waiting for ever.
tools/mcp_smoke.py now also requires exit 0 and no traceback once the
client hangs up, which a shutdown regression would otherwise pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every version field moves to 4.13.0, including the Hermes plugin.yaml
prepared for the upstream PR, which sat at 1.7.0 and is now gated like the
rest. The release notes the Release workflow attaches are in
.github/release-notes/v4.13.0.md. 4.12.0 was merged but never published,
so its fixes ship in this release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…op-1, which on some CI CPUs is the runner-up

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…els the ones it supersedes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Oct 20, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 90.38113% with 159 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/compartment/clients.py 88.34% 64 Missing ⚠️
src/compartment/cli.py 88.94% 24 Missing ⚠️
src/compartment/embed_daemon.py 86.53% 14 Missing ⚠️
src/compartment/systray.py 86.81% 12 Missing ⚠️
src/compartment/session.py 65.38% 9 Missing ⚠️
src/compartment/embed.py 93.67% 5 Missing ⚠️
src/compartment/menubar.py 90.00% 5 Missing ⚠️
src/compartment/vaultfile.py 93.58% 5 Missing ⚠️
src/compartment/claude_desktop.py 89.47% 4 Missing ⚠️
src/compartment/platforms.py 94.66% 4 Missing ⚠️
... and 6 more

📢 Thoughts on this report? Let us know!

MaxFreedomPollard and others added 5 commits October 19, 2026 21:01
The tray job counted processes named "compartment", but init now starts
the tray with pythonw.exe so no console window opens; it counts by command
line instead. The tilde test searched JSON for a raw Windows path whose
backslashes JSON escapes, and now compares parsed paths; the Hermes test
built ~/.hermes where native Windows Hermes lives under %LOCALAPPDATA%, and
now asks hermes_home() under a test LOCALAPPDATA; the unreadable-vault test
skips on Windows, where chmod cannot take read access away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… no longer asks an approximate index for an exact answer

setup-uv publishes no floating major tags, so `@v10` failed both macOS app
builds at "Set up job"; it is pinned to v10.3.0. The index test asked a
50-vector HNSW graph built in parallel for each row's own vector; such a
graph leaves a few rows unreachable (measured: 1-4 of 50, and 0.3% of
self-lookups at 25,000, in line with HNSW's documented ~99% recall), so it
now checks what it is about: the index is built and searches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ob can hold a runner past 45

Every macOS job sat in the test step for hours: one test hangs on a real
Mac (not in the Linux or Windows jobs), and each stuck job also held one of
the few macOS runners, so the Intel app build behind them never started.
pytest-timeout (now in the dev extras) fails the test that hangs, with
where it hung, and the job timeout caps whatever is left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aemon and stdio tests run on their own on every system

The macOS suite ran into its job timeout with no verdict: a test hangs on a
real Mac, and the report only came at exit. tools/ci_report.py now watches
for silence and annotates the running test and the last lines (with every
thread's stack, via faulthandler_timeout) while the job is alive. A new
real-sockets job runs the embedding-daemon and stdio-server tests - the
ones agent sandboxes cannot run - verbose, on macOS, Linux and Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cOS script's well-known interpreter paths are replaceable, and the suite empties them

install-macos.sh tries Homebrew's and python.org's absolute paths after
PATH. The tests build a stand-in machine through PATH alone, so on a real
Mac (a GitHub macOS runner) the "only Python 3.14 on an Intel Mac" case
skipped the stand-in, found the runner's real Python 3.13, ran a real
install and then waited for a passphrase until the job timed out - and on a
developer's Mac it would have installed into their Homebrew Python.
CMPT_EXTRA_PYTHONS now replaces the list when set (users get the same
default), and the suite sets it empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxFreedomPollard
MaxFreedomPollard merged commit 62a1c0e into main Oct 20, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants