Repository navigation
Compatibility, plugins and release automation: 123 fixes, release 4.13.0 - #11
Merged
Merged
Conversation
…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>
…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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
compartmentworks 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.integratewrites the full path of the installed command. GUI clients without your PATH failed with "spawn compartment ENOENT".pip install compartmentworks there again (platform-scoped dependency caps), and the Mac app ships for Intel too..mcpb).docs/PLUGINS.md..pkg/.dmg, and drafts the release. Publishing uploads those same files to PyPI and attaches the.mcpb.Test plan
tools/wheel_coverage.py: no supported platform needs a compiler.v4.13.0, check the draft release's assets, then publish.🤖 Generated with Claude Code