claustrum protocol reference¶
claustrum uses newline-delimited JSON-RPC 2.0 over an AF_UNIX
SOCK_STREAM socket. This document is the complete wire contract. The validation
battery checks that same contract byte-for-byte. All reference behaviour was
probed at 5db5e4a unless a line says otherwise. The divergence catalog, its
rules, and the reopen triggers are in DIVERGENCES.md.
Transport¶
- One JSON object per line (NDJSON). No length prefix, no binary framing.
- A single request line has a cap of 1 MiB (
bufiomax token =1024*1024). The daemon serves a line up to 1048575 bytes. A line of 1048576 bytes or more closes the connection with no reply. Chunk largeprocess.stdinpayloads below this cap. AF_UNIXstream socket, created mode0600(owner only).- The connection is persistent. It stays open after a response, and id-less stream notifications arrive on it asynchronously.
- The daemon dispatches a connection's requests concurrently. Responses can
arrive out of request order. Match them by
id. - Two error replies skip the concurrent dispatch: the parse error (
-32700) and the auth error (-32001). The daemon writes them on its read path, before it reads the next line. Every other reply goes through the concurrent dispatch. Measured againstf6010b97and90fca6e6on a Linux VM, the reference did the same: - The parse error covers any line that does not decode into a request. A
partial object, a line of only spaces,
[1],123,"x"and a batch array all get it. The frame is{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}, 76 bytes with its newline. - The auth error covers a request with a missing or wrong
auth. The linesnull,{}and{"jsonrpc":"2.0"}get it too. The frame echoes the requestid, re-encoded as in Message shapes. Anullor missingidgives"id":null. An unauthenticatedserver.shutdownis not an auth error (see Authentication). - The client can half-close its write side right after the line. These two frames still reach the wire before the close, with or without a final newline. A reply from the concurrent dispatch races the close.
- A request whose handler blocks does not delay a later auth error on the same connection.
- The daemon logs
[Server] Parse error: <json error>or[Server] Unauthorized request: method=…, id=…before it writes the error reply. Measured againstf6010b97and90fca6e6on a Linux VM, the reference logged the same parse-error text. When its reply write failed, the failure line came after the parse-error line. In a Go type-mismatch error, the type name differs from the reference. - At a client EOF the daemon logs
[Server] Connection closed: <addr>, then closes the socket. Measured with strace on a Linux VM,f6010b97,90fca6e6and claustrum all log this line before the close, in 20 of 20 runs each. - The daemon does not wait for a request in flight when the client
half-closes. The probe sent a
files.readof a pipe that returned data after 100 ms, 500 ms or 2 s. Measured againstf6010b97and90fca6e6on a Windows VM, the reply was lost in 60 of 60 runs per build. A partial last line did not change that. claustrum lost the reply in 60 of 60 runs too. - Input that ends without a final newline. The daemon reads the last bytes
before EOF as one more line, and the rules above apply. A tail of only
\ris a blank line and gets no reply. - If an earlier valid line came first, the parse error for the tail and the
reply to the earlier line race. The probe sent a
server.pingline, a partial line, then a half-close, in 50 runs per build. Measured on a Linux VM,f6010b97and90fca6e6each sent theserver.pingreply in 1 of 50 runs. claustrum sent it in 4 of 50 runs. All three builds closed about 2 ms after the half-close. On Linux, claustrum matches the reference. - On Windows this case has a measured timing difference, not parity.
Measured on a Windows VM,
f6010b97and90fca6e6each sent theserver.pingreply in 50 of 50 runs, in either order. They closed about 12 ms after the half-close. claustrum sent the reply in 21 of 50 runs and closed after about 2.4 ms. Every run on every build got the parse-error frame. The cause of the later close of the reference is not known. - At the idle close, the daemon also reads the last bytes as a line. It dispatches a valid request. It writes the error for a bad tail on the read path, but the connection is already closed, so that write fails. The wire carried 0 bytes on each measured build.
- Either end of an
AF_UNIXconnection on Windows can miss the other end's close. Measured on a Windows VM, a reader got the bytes before the EOF and then blocked. A new read returned the EOF. A plain blockingWSARecvloop missed it too. One claustrum-bridgerun hung in this way until the probe killed it. One90fca6e6-bridgerun also hung until the kill.
Named-pipe transport (Windows, opt-in)¶
This is a strictly additive claustrum extension (CT-5). The reference daemon has no
such transport. It is off by default. When it is off, claustrum is byte-for-byte
identical to the reference. Set -serve -listen-pipe, or set listen-pipe = true
in claustrum.conf. claustrum then additionally serves the exact same NDJSON
JSON-RPC dispatch over a Windows named pipe, concurrently with the socket. The
wire contract, field ordering, framing, and "auth" handshake are the same. It
exists so that a Windows client that cannot consume AF_UNIX can still connect.
One such client is Python asyncio, whose Unix transports are Unix-loop-only.
- Windows-only. Other platforms ignore it and log a warning.
- Name and discovery. claustrum chooses the name
(
\\.\pipe\claustrum-<random-instance-id>). It publishes that name torpc.pipein the socket's directory, besiderpc.sockanddaemon.token. claustrum writes the file atomically before the pipe accepts and before the ready banner. On graceful shutdown claustrum removes the file only when the file is still the inode this daemon published. It usesos.SameFile, the same handoff guard that the socket anddaemon.tokencarry. See Token persistence. A restart's successor therefore keeps its own pointer. The client reads that file to learn the opaque name. - Stale-file invariant. The name is random for each boot. Therefore
rpc.pipeexists if and only if a pipe is actively served this boot. Startup removes any leftover file from an unclean crash. A client can therefore never dial a stale name. - Owner-only and local. Two independent mechanisms give this. The first is an
owner-only DACL (SDDL
D:P(A;;GA;;;<current-user-SID>)), the named-pipe analogue of the socket's0600. The second is remote-client rejection at creation (FILE_PIPE_REJECT_REMOTE_CLIENTS), set by go-winio'sListenPipe. See SECURITY.md.
See DIVERGENCES.md → CT-5 for the full contract.
Authentication¶
Every request carries a top-level "auth":"<token>". server.shutdown is the one
exception, and it is not authenticated at all. A shutdown frame stops the daemon
whether its auth member is absent, empty, wrong, or valid. -stop sends no
auth member. This matches the reference, and the Desktop client depends on it.
Desktop stops the daemon with server --stop --socket <sock> from a bare SSH
command line, with no CLAUDE_RPC_TOKEN in its environment. The exemption covers
auth only. The daemon still rejects a shutdown frame that carries a bad or absent
jsonrpc version with -32600, and the daemon stays up. Every other method
rejects an unauthenticated request with
-32001 Unauthorized: invalid or missing auth token. It also logs
[Server] Unauthorized request: method=…, id=….
The server's expected token comes from -token-file or from -token-fd. With
-token-file the daemon reads the file once at startup and then unlinks it. With
-token-fd the daemon reads the token from an open descriptor and forwards it to
the detached child over a pipe. That handoff never touches disk.
No claustrum mode reads CLAUDE_RPC_TOKEN. This holds for -serve, for
-bridge, and for -stop. -bridge is a simple relay and does not add auth. The
client that speaks through it must include "auth" itself, from the
daemon.token handshake below or from its launcher. claustrum only removes the
variable. It unsets the variable before it daemonizes, and it strips the variable
from every spawned child. Therefore a token never reaches a child through the
environment.
Token persistence (daemon.token)¶
Once the socket is listenable, the daemon writes the token to daemon.token in
the socket's directory. The file has mode 0600. The daemon writes it atomically
through a daemon.token-* temp file and a rename, and it unlinks the file on
graceful shutdown. A client can therefore reconnect to an already-running daemon
and re-authenticate after the original -token-file was unlinked, or after the
-token-fd pipe closed. The write does not depend on the token source, because it
uses the in-memory token. The write is also best-effort. The daemon logs a failure
([daemon] failed to persist token: …) and continues. Reference build 5db5e4a
added this, and claustrum matches it. It is off the JSON-RPC wire, because the
file sits beside the socket, not on it. An unclean kill (SIGKILL or a crash)
leaves the file behind, because the daemon removes it only on the graceful
server.shutdown / SIGTERM path. -stop also removes it after a failed
connect, unless it prints survivor. See the -stop section below.
A new daemon replaces the token of a dead daemon only after its bind. On a Linux
VM (row P12, two runs), a dead daemon left its daemon.token and one recorded
child that ignored SIGTERM. For the 2.2 s of the reap, the file still held the
token of the dead daemon. 89cb6289 and claustrum renamed the new token into
place after the SIGKILL of the reap, the bind and the listen.
The fixed name and the socket-dir location are the reconnect contract, so they are
not configurable. On Windows 0600 is not an owner-only DACL. This is a Go
os.CreateTemp limitation, and the per-user session dir is the confinement. It is
a parity caveat that claustrum deliberately does not "fix". The former caveat "two
daemons in one directory collide on this file" is now bounded. On Linux and macOS
the run-dir lock (below) evicts a prior live daemon of the same socket before the
new one binds. Two same-socket daemons therefore no longer coexist to collide on
the honest path. A collision survives only where eviction is refused, and on
Windows, which ships no run-dir lock. Eviction is refused for a foreign or
cross-machine lock holder, and for a holder that survives SIGKILL.
The unlink carries an inode-ownership qualifier. On graceful shutdown the
daemon unlinks daemon.token only when the file on disk is still the inode this
daemon wrote. The daemon stats the file and
compares its identity (os.SameFile) to the one it recorded at startup. A
restart's successor can rebind the socket and
republish daemon.token to a new inode before the predecessor leaves. The
departing predecessor then leaves the file alone. It does not delete the
successor's token and break the new daemon's reconnect auth. On Linux and macOS the socket file
carries the same identity guard on the same graceful path. On Windows the
rpc.pipe pointer carries it too (removeSocketIfOwned /
removePersistedToken / removePipeNameFileIfOwned). Each removes its file only
when the recorded identity still matches. They differ only in the no-identity
fallback. A socket the daemon never recorded is removed with a plain unlink. A
token or pipe pointer with no recorded identity is left in place, not unlinked.
This is the departing-daemon half of the daemon-to-daemon handoff. The launcher
half is under Daemon startup. It is off the JSON-RPC
wire.
Run-dir lock (daemon.lock)¶
Before it binds the socket, a -serve daemon takes an exclusive flock on
daemon.lock in the socket's directory (mode 0600). It then writes an owner
record into that file. The record is a JSON object. At first it is
{pid, role, node}, where role is serve. After the reap of the start and the
bind, the daemon writes it again as {pid, role, node, instanceId, startedAt}.
node is omitted when the machine identity is unknown. Reference build 4534d86
added the lock, and claustrum matches it. It is off the JSON-RPC wire, because it is a file
beside the socket. On graceful shutdown the daemon truncates the record and drops
the lock, but it leaves the file in place. daemon.token is unlinked instead. A
Linux VM measured the same end state against 89cb6289 in the stop rows:
daemon.token and rpc.sock removed, daemon.lock left empty. The instanceId
of the record is the instance of the listening line, and the instanceId that
server.capabilities answers. On a Linux VM, the listening line, the lock and
server.capabilities of 89cb6289 held one value in 36 of 36 daemons.
Linux and macOS VMs measured the two forms of the record against 89cb6289. In
row D1 a dead daemon left one recorded child that ignores SIGTERM. The lock of
the new daemon held {"pid":<n>,"role":"serve","node":"<node>"} for about 2 s.
Then it held the full record. With nothing to reap (row D2), the first read on
macOS showed the full record. On Linux one read of row D2b showed the short form.
The startedAt of the record is the startedAt that server.capabilities
answers. Row D3 measured that on 89cb6289 in one Linux read and in 3 of 3 macOS
reads.
A prior live daemon can still hold the lock. The newcomer then evicts it before it
takes over, with SIGTERM and then SIGKILL after a grace period. On Linux the
SIGTERM goes out as pidfd_open and then pidfd_send_signal. A Linux VM
measured those two calls with strace against 89cb6289 (row E3). The
SIGKILL is pidfd_send_signal with the same descriptor number (rows H7 and
N3). macOS uses kill. The call of the macOS reference is not measured. The identity
reads of the holder come between the open and the send. On a Linux VM (row P6, by
strace, 2 of 2 runs), 89cb6289 made these calls in this order: pidfd_open,
the read of /proc/<pid>/ns/pid, the read of /proc/<pid>/cmdline, then
pidfd_send_signal. claustrum is built to that order.
After the SIGTERM the newcomer waits for the lock, not for the pid. On a Linux
VM (row H7, by strace), the holder was stopped with SIGSTOP. 89cb6289 tried
flock(LOCK_EX|LOCK_NB) on daemon.lock every 50 ms, and the call answered
EAGAIN 43 times. It sent the SIGKILL 2.02 s after the SIGTERM, and it got
the lock 50 ms later. It sent no kill(pid, 0) in that row, and none in rows E3,
P5, P6, N3 and N4. claustrum is built to it: it tries the lock every 50 ms for 2 s,
sends the SIGKILL, and tries for 1 s more. That 1 s is claustrum's own value
and is not measured. Before the SIGKILL claustrum tests the identity of the
holder again. A restart
therefore replaces its predecessor deterministically. The eviction only fires
against a holder that is verifiably one of our own -serve daemons for this
socket, on this machine. See the guards in daemon_runlock_unix.go. Claiming is
best-effort. Any failure logs a warning, and the daemon serves without run-dir
ownership rather than aborting.
If the record of the holder has no instance, the eviction lines name its pid
alone. On a Linux VM and on a macOS VM, 89cb6289 logged these two lines for a
holder with the short record (row P5, 5 of 5 runs on each system):
serve: run dir is held by a live daemon, pid <n>; sending SIGTERM
serve: previous daemon pid <n> exited after SIGTERM
For a holder with the full record, both lines read
pid <n> (instance "<hex>") (row E3). claustrum is built to both rows.
A holder that still has the lock after the 2 s gets SIGKILL. On a Linux VM
(row H7), 89cb6289 then logged these two lines:
serve: WARNING previous daemon pid <n> (instance "<hex>") ignored SIGTERM for 2s; sending SIGKILL (its Claude Code children, if any, are ended next, before this daemon serves)
serve: previous owner of <dir>: killed
For a holder with the short record, the first line reads pid <n> with no
instance (row N3). claustrum is built to both rows.
A signal can reach a daemon during its start. On a Linux VM (row P5, 5 of 5
runs), a second daemon started 0.5 s after a first one. The first daemon still
waited in the reap of its start, for a recorded child that ignored SIGTERM. The
second daemon sent SIGTERM to the first one. The first daemon of 89cb6289 did
not end there. Its log held these lines in this order:
[process.Registry] ending orphaned process group <pgid> recorded by daemon instance "<inst>" (pid <dpid>): SIGTERM
[daemon] received terminated; shutting down (children will be killed)
[process.Registry] group <pgid> outlived SIGTERM for 2s: SIGKILL
[daemon] serve: predecessor's children — 1 orphaned group(s): 0 ended on SIGTERM, 1 on SIGKILL, 0 survived; 0 stale record(s) dropped, 0 kept
[Server] shutdown requested
Claude remote server listening on <socket> (pid <n>, instance <32-hex>)
[daemon] host cleaning off: executable "<binary>" is not a deployed daemon under <root>/srv
[Server] cleanup: closed 0 connection(s), killed 0 child process group(s)
The first daemon bound, listened and wrote its token, then removed the socket
and the token and exited 0. The recorded child got one SIGTERM and one
SIGKILL, both from the first daemon. The second daemon signalled no child, and
it served. On a macOS VM (row P5, 5 runs), the first daemon of 89cb6289 went
on in the same way. Its log held the received line, the outlived line, the
summary line, the shutdown requested line, its listening line and the cleanup
line, in that order.
claustrum is built to that row. It installs its handler for SIGTERM and SIGINT
before the claim of the lock, and it handles one signal.
More Linux rows measured a signal or a second start during a start. In each row
the recorded child of a dead daemon ignored SIGTERM. Build ac5cadb of
claustrum sent the same signals and gave the same end state as 89cb6289 in
each row but one, the 0.05 s gap:
- A
SIGTERMor aSIGINT1 s into the reap (rows H8a and H8b). The daemon loggedreceived terminated; shutting down (children will be killed), orreceived interruptwith the same tail. It sent theSIGKILLof the reap at 2.1 s, bound and exited 0. The run folder then heldchildren, an emptydaemon.lockand the logs. - Two
SIGTERM0.3 s apart (row H8c). The daemon logged onereceivedline and did the same. - A
SIGTERMto a daemon that waits in its eviction of an older holder (row N4). The daemon still sent the holderSIGKILLafter the 2 s, took the lock, sent the child of that holderSIGTERM, bound and exited 0. Its launcher exited 1 after 12.08 s withdaemon did not take over <socket> (predecessor still owns it). - A
-stop0.5 s into the reap (row N5). The connect of-stopwas refused. It sent the daemon oneSIGTERMand tried the lock every 50 ms. The daemon finished the reap, bound and exited 0.-stopthen got the lock, printedterminatedand exited 0, 1.7 s after its start. The launcher of the daemon exited 1 after 12.09 s with the timeout line. - A second daemon 0.2 s after the first (row N3, 3 runs). The second daemon sent
SIGTERMonly. The first one finished, bound and exited 0. The second one then bound and held the lock with its full record. - A second daemon 0.05 s after the first (row N3, 3 runs). The first daemon was
still in its start after the 2 s, so the second daemon sent it
SIGKILL.89cb6289then got the lock, bound and wrote its own full record. It sent the recorded child no signal. Buildac5cadbof claustrum read the killed daemon as alive and served without the lock. claustrum now waits on the lock as89cb6289does, and it is built to this row.
A process that is no daemon can hold the lock, with no owner record in the file.
The daemon then signals nothing and logs three lines. On a Linux VM (row ST02),
f6010b97 and 89cb6289 logged the same three texts:
[daemon] serve: WARNING <dir>/daemon.lock is held by a live process we will not signal (its holder is not a daemon (role "")); leaving it alone
[daemon] serve: previous owner of <dir>: survivor
[daemon] serve: not the run dir's lock holder; leaving any predecessor's children alone and recording none
claustrum logs the third line at every start that ends without the lock. The other
cases without the lock are not measured. A record with another role text is not
measured. claustrum then reaps and records nothing.
Cell H5 (5fd08069, Linux VM) measures a held flock.
The lock, the owner record, and the eviction run on Linux and macOS only. The
machine identity (node) is the boot id joined to the pid-namespace inode on
Linux, and sysctl kern.bootsessionuuid on macOS. Windows ships no run-dir
lock. Mutual exclusion on Windows stays the socket remove-then-rebind handoff.
On Windows the "not the run dir's lock holder" line above is the first log line of
every start. A Windows VM (row K11) measured that against f6010b97 and
89cb6289.
On macOS claustrum makes sure of the holder through sysctl KERN_PROCARGS2,
where the reference skips that test. This holds for the
eviction and for -stop. See DIVERGENCES.md D15.
Host cleaner (off-wire, Linux and macOS)¶
A periodic sweep ends orphaned Claude Code process groups, retires abandoned daemons and tidies stale run dirs. It reaches no JSON-RPC frame, so nothing here is a wire contract. It is recorded because one of its decisions is an always-on divergence that a client can feel as a lost session.
The sweep ends no stranded daemon. A stranded daemon is a daemon whose socket path
no longer leads to it. The cleaner sends no signal to such a daemon and none to its
children. On a Linux VM, f6010b97 and 89cb6289 sent none in every staged shape
(rows ST01, ST02, ST04 to ST10, HC16 to HC19, HC23 to HC26). On Linux and macOS
the stranded daemon ends by itself. See Orphan-exit self-probe. On a macOS
VM, f6010b97 and 89cb6289 sent none either (rows ST01, ST09, ST10, HC16,
HC18). In row ST01 on that VM, claustrum sent none either.
The cleaner retires an abandoned daemon with SIGTERM. On Linux the signal goes
through pidfd_send_signal, as on the reference (rows HC10, HC12 to HC15, by
strace). 89cb6289 and claustrum open the descriptor with pidfd_open, then read
/proc/<pid>/stat, then send the signal. A Linux VM saw that order in 26 of 26
retires for each binary (rows HC10, HC12a, HC13, HC14). On a kernel without that
call claustrum uses kill. That fallback is
not measured. macOS uses kill. The call of the macOS reference is not measured.
On a macOS VM, the cleaners of 89cb6289 and claustrum ended the same daemons and
orphan groups and removed the same folders (rows K9, HC01, HC10, HC14c, HC20,
HC22d).
The cleaner is on in one case only. The cleaned socket path is absolute, and its
last three components read run/<id>/rpc.sock. The folder above run is the
root. The resolved daemon binary is <root>/srv/<id>/<name> under that root, or
under the symlink-resolved form of that root. In every other case the cleaner is
off and runs no pass. The daemon then logs one of two lines, right after the
listening line:
[daemon] host cleaning off: socket path "<socket>" is not <root>/run/<id>/rpc.sock
[daemon] host cleaning off: executable "<binary>" is not a deployed daemon under <root>/srv
The socket line names the socket as given. The executable line names the resolved
binary and the srv of the root as given. If the daemon cannot read its own
executable path, the binary test does not run. That case is not measured.
A pass acts on one folder. Its root list holds the root as the socket path gives it. When the resolved form of that root differs, the list also holds that form, which is the same folder under its real path. The root of the binary is never added. A pass lists each run folder once, and its lines name the run folders in the spelling of the socket as given. If the resolution of the root fails, the list holds only the root as given. That case is not measured.
A Linux VM measured the decisions of this gate against 89cb6289, in start rows
and in rows with staged fixtures. claustrum gave the same decision in each row:
| start row | 89cb6289 and claustrum |
|---|---|
<R>/run/c1/rpc.sock, binary <R>/srv/d1/<name> |
on, a pass 15 s after the start |
<R>/run/./k1/rpc.sock, <R>//run/k1/rpc.sock (rows KS-i-j, KS-i-k) |
on |
<R>/b/s.sock, <R>/b/rpc.sock, <R>/run/rpc.sock, <R>/run/c1/x.sock, <R>/run/c1/d/rpc.sock, <R>/RUN/c1/rpc.sock |
off, socket line |
<R>/lnk/c1/rpc.sock, where lnk is a symlink to run |
off, socket line |
relative run/c1/rpc.sock (rows START-i-h, START-p-h, KREL) |
off, socket line |
<R>/run/c1/rpc.sock, binary outside <R>/srv (the plain layout) |
off, executable line |
binary <R>/srv/<name>, or <R>/srv/d1/sub/<name> |
off, executable line |
binary <R>/srv/d1/<name> that is a symlink to a file outside |
off, executable line with the resolved path |
binary under the srv of another root (rows START-x-e, KXB, KS-x-e) |
off, executable line |
another file name in <R>/srv/d1 |
on |
binary started through a symlink to <R>, socket through the real path (row KS-u-e) |
on |
socket through a symlink <R>l to <R>, binary through either path (rows KS-m-e, KS-q-e) |
on, the lines name <R>l/run/<id> |
| binary and socket through a symlink one level above the root | on |
The socket rows gave the socket line in the install layout and in the plain
layout. In rows KREL and KXB 89cb6289 sent no signal and removed no folder. In
rows KS-m-e and KS-q-e it ran one pass with staged fixtures. f6010b97 logged the
same two texts in its rows (EV01, K3, ST08 and every plain-layout row). A macOS VM
measured nine gate shapes against 89cb6289 (rows GSa to GSg3). claustrum made the
same decision in each one, with the same log lines. In row GSc the daemon binary
runs through a symlinked root and the socket has the real path: both run the
cleaner.
claustrum links no cleaner on Windows. The Windows references logged no cleaner line and changed nothing in 120 s (row WN06, with no positive control).
Before it signals a daemon whose run dir went idle past the threshold, or whose run
dir name carries .removing- from an earlier removal, the cleaner
asks whether that daemon still has a live client. On macOS it asks lsof. That
run has three bounds: a command deadline, a wait for the output pipe, and a
last bound that gives up on it. Those bounds are claustrum's own.
The cleaner asks lsof two more things on macOS: whether the stdio of an orphan
is three pipes, and whether a process holds the daemon.lock of an idle run dir.
claustrum tells these outcomes of an lsof run apart. A macOS VM measured the first two against
89cb6289, 3 of 3 runs in each row. The rows
are of the macOS host cleaner round of issue 429. The rows where the command
does not start ran under a sandbox rule that denies the exec of lsof. Rows E3i
and E4 hold an idle daemon, and rows D1k and E3k hold a daemon with one client:
lsof outcome |
busy read of a daemon | lock read of a run dir |
|---|---|---|
| the command does not start | the daemon gets no SIGTERM (rows D1i and D1k, as 89cb6289) |
a dir with a lock file stays (row C3, as 89cb6289) |
| the run writes to stderr and exits 1 | the daemon gets no SIGTERM (rows E3i, E3k and E4, as 89cb6289) |
a dir with a lock file stays (rows D2p, D2f and E4, as 89cb6289) |
| the run is given up on | busy, so no SIGTERM (DIVERGENCES.md D17, not measured) | kept (not measured, claustrum's own) |
| the 15 s deadline ends the run | no SIGTERM (5fd08069 under a hung lsof, cells B1 and B2. claustrum gives the same result, round 8, macOS VM) |
kept (cells L1 and L2) |
| the run completes with nothing on stderr | the output decides | the output decides |
The exit code is no rule. lsof exits 1 with no text when no process holds the
file, and the dir then goes (rows C1 and C6). In rows
D1i, D1k, E3i and E3k the pass of 89cb6289 and of claustrum logs these two lines
for the daemon, the first one before every run dir line. Its summary counts
abandoned daemons retired 0 and candidates left undecided 1:
[hostclean] daemon pid <pid> left alone this pass: its open files could not be inspected
[hostclean] run dir "<dir>" unused for 40 days: its daemon (pid <pid>) was not retired: a connection is attached to it right now, or that could not be read: in use after all
In the two control rows lsof starts. Both sides retire the idle daemon (row D1ci)
and spare the daemon with a client (row D1ck).
On claustrum the first line goes to a daemon that is a candidate for the retire. In row E2 two
daemons are over 5 minutes old on run dirs that are 40 days old. Each gets the
line, and the summary counts undecided 2. In row E1 a second daemon is about 33 s old
on a fresh run dir. It gets no line, and the count stays 1. claustrum asks for
both: an age of 5 minutes and an idle run dir. Both hold on 89cb6289 in two more
rows. In row F1 a daemon about 33 s old is on a run dir that is 40 days old. It
gets no such line on either side, and both sides print
its daemon (pid <pid>) was not retired: it is too young to judge for the run dir.
In row F2 a daemon 5 minutes old is on a run dir with a fresh mtime, and no line
names it on either side. From the code: with more than 4096 processes claustrum
prints no such line. That state is not measured.
Row E5 adds an orphaned Claude Code group to the state of row D1i. Neither side
signals the group. Both log this line after the daemon line and before the run
dir lines, and the summary counts undecided 2:
With a working lsof both sides end the group (row E5c). After an lsof run that
writes to stderr and exits 1, both sides print the same line and signal nothing
(row F3). A run that is given up on spares the orphan with claustrum's own line.
The lock read has these measured states (macOS VM against 89cb6289, 3 of 3 runs
in each row, and claustrum gives the same answer):
| row | state | 89cb6289 and claustrum |
|---|---|---|
| C3 | the lsof command does not start, and the dir has a daemon.lock (empty, or the record of a dead daemon) |
the dir stays, with the line [hostclean] run dir "<dir>" unused for 40 days: kept, its daemon.lock could not be examined |
| D2p, D2f | the lsof run writes to stderr and exits 1. In rows D2p and D2f a live process holds one of the lock files |
the dir stays, with the same line |
| D4 | the daemon.lock has mode 0000 |
the dir stays, with the same line |
| E7a, E7b, F4 | the daemon.lock is a symlink to a regular file, a folder, or a FIFO |
the dir stays, with the same line |
| C3, D2p, D2f, D4 | the dir has no daemon.lock |
the dir goes |
| C2 | another live process holds the lock file open | the dir stays, with the held line |
| C4 | only the cleaner itself holds the lock file open | the dir goes |
| D3 | the cleaner and a child of the cleaner hold the lock file open | the dir stays, with the held line |
| C5 | the lock file holds 7 bytes that are no record, and no process holds it | the dir goes |
| D7a, D7b | the lock file holds {}, or the first half of a record |
the dir goes |
| D5 | the lock holds the record of a live pid that does not hold the file | the dir goes |
| C6 | the lock holds the record of a dead pid, and no process holds it | the dir goes |
Not measured: a second pass, and an lsof run that writes to stderr with another
exit code or with output on stdout too. claustrum reads each such run as no
answer, whatever the exit code and the output are. Linux is not
measured for these states. On Linux, a lock that cannot be opened, a lock that is
no regular file, and lock content that is no record keep the dir with claustrum's
own line. On Linux the pass logs no left alone this pass line.
Daemon startup (-serve)¶
If the socket's parent directory is missing, the -serve launcher creates it
(mode 0700). The launcher then does not return until the socket path exists. It
polls every 20 ms, up to a bound of 12 seconds. To make sure that the daemon is
ready, it dials the socket and closes the connection again. A freshly started
daemon's log therefore holds a New connection from: @ /
Connection closed: @ pair from the launcher's own probe, after the listening
line.
It waits for the path to exist, not for a successful dial. One case differs on
Linux and macOS: the stale socket file of a killed daemon. That case is below. It
also does not give up early when the child dies. Both behaviours are measured
against 5db5e4a:
| start | what the launcher sees | outcome |
|---|---|---|
| normal | path appears, and the dial succeeds | exit 0 |
| socket path occupied by a directory | path exists immediately | exit 0 (~0.01 s, reference 0.08 s) |
| child can never bind (uncreatable parent dir) | path never appears | exit 1 at the launcher bound (5db5e4a 10.06 s) |
The launcher bound of claustrum is 12 s. On a Linux VM (row P7), a daemon child of
89cb6289 ended before its bind, and the launcher exited 1 after 12.06 s. The
row holds three forms: a stale socket file, no socket file, and an empty run
folder. A macOS VM measured 12.08 to 12.16 s in the same three forms (row P7). A
Windows VM measured 12.058 s with a stale socket file and with none. The bound is
shared code, and claustrum took 12.01 to 12.13 s in those rows. The bound of
5db5e4a in the table was about 10 s, and claustrum used 10 s until that row.
A Windows VM measured the occupied path against 89cb6289 (rows OCC and OCCf, two
runs each). An empty folder or a regular file was at the socket path. On both
sides the launcher exited 0 at once, the entry was removed, and the daemon
served on a socket at that path. A folder with content is not measured.
On Linux and macOS the daemon binds the socket only after the reap of its start.
The order is: claim daemon.lock, reap the recorded children of a dead daemon,
remove the old socket file, bind, complete the owner record. On a Linux VM the
calls of 89cb6289 came in this order (row D1, by strace): the open of
daemon.lock, the signals of the reap, the unlink of the old socket file, bind
and listen. The strace rows hold no write to the lock. The place of the last
step comes from the startedAt of the full record. In 8 of 8 Linux runs of rows
D1 and D2 it was within 1.5 ms of the listen call. While the reap waits
for a group to end, nothing listens on the path. In row D1 a recorded child
ignored SIGTERM, so the wait took about 2 s. A client that connected in that
wait got ECONNREFUSED when the socket file of the dead daemon was there (row
D1a). It got ENOENT when no socket file was there (row D1b). Linux and macOS VMs
measured both against 89cb6289. The first connection worked 2.2 to 2.4 s after
the start. With nothing to reap, the first connection
worked 0.1 to 0.24 s after the start (row D2). See
process.spawn for the reap itself.
On a timeout the launcher prints
claustrum: timeout waiting for daemon to accept on <socket> to stderr and
exits 1. On success it prints the ready banner and exits 0. After a successful
-serve, the socket accepts connections before -serve returns. Measurement
detail is condensed out of this reference.
Reference build 7d193f89 added a daemon-to-daemon socket handoff. A unix socket
path cannot be rebound in place, so a restart on a live socket unlinks the old
inode and binds a fresh one. Before it spawns the child, the launcher records
whether a live predecessor is already accepting on the path. If one is, the
launcher also records that socket's inode identity. The probe is a short dial on
unix. On Windows it is a no-op stub, and it has no observable effect. The launcher
then waits not merely for the path to exist but for the inode to become different
from the predecessor's. That is how it tells the successor's fresh socket from the
one the predecessor is still serving. A predecessor can be present and the
deadline can pass with the inode unchanged. The launcher then prints the distinct
message claustrum: daemon did not take over <socket> (predecessor still owns it)
to stderr and exits 1, in place of the plain timeout line above. With no live
predecessor and no socket file on disk, the wait is unchanged. The next paragraph
covers a stale socket file. The
inode-difference wait and that distinct message are unix-only. On Windows the
predecessor probe is a no-op that always reports no predecessor. Startup there
always takes the no-predecessor path, and on failure it emits only the ordinary
timeout line, never the "daemon did not take over" message. This is off the
JSON-RPC wire, because it is launcher lifecycle. The departing daemon's matching
half is the inode-ownership unlink under
Token persistence.
A killed daemon leaves its socket file on disk. The launcher records
the socket file that is on disk before it starts the child. While the path still
holds that file and no daemon answers a dial, the launcher keeps waiting. It then
dials the new daemon once. On a Windows VM (rows WN03 and WN05), the log of a new
f6010b97 or 89cb6289 daemon held the connection pair in that case. On a Linux
VM (row XA), the -serve command of f6010b97 and 89cb6289 returned when the
socket accepted, 2.06 s after its start. A dial
that a live older daemon answers ends the wait at once, as before. On Windows a
client that connects in that window is not measured. On Linux and macOS only a
socket file makes the launcher wait. Another kind of file at the path counts as
present, as in the table above. A stale socket file and a daemon that never binds
give the timeout line at the bound and exit 1. That is form a of row P7 above:
89cb6289 exited 1 after 12.06 s.
On Windows the launcher starts the serving process outside the job of the
launcher (CREATE_BREAKAWAY_FROM_JOB). The SSH server of the Windows VM puts each
session in a job with the limit flags 0x2800. That job ends its processes when
the session ends, and it allows breakaway. On that VM (rows WJ01 to WJ07), the
serving process of f6010b97 and 89cb6289 was outside the session job. Row WJ08
shows the same for 89cb6289. It was alive 120 s after the session closed (row
WJ01). The serving process of claustrum was outside that job in rows WJ01 to WJ08.
How the reference leaves the job is not measured. A job that refuses breakaway
refuses that start. The launcher then starts the serving process inside the job
and prints one line on its stderr. On a Windows VM (row JB, a job with the limit
flags 0x2000), 89cb6289 and claustrum both served from inside that job. Both
printed this text after a time stamp, and claustrum puts its level tag before it:
Idle-connection close¶
A -serve daemon closes any accepted connection that goes 5 minutes with no read
or write activity. This matches f6010b97, as measured. The timeout is fixed and
always-on. There is no flag and no configuration key to change it or to disable
it. The daemon stamps the last activity time on every read and write of the
connection. A per-connection watcher polls for silence, at a quarter of the
timeout, bounded to at most 30 s. Once the idle span reaches the timeout, the
watcher closes the socket and logs
[Server] closing connection idle <d> in both directions: <addr>. On a Linux VM
(rows HC12a and HC12b), 89cb6289 logged that text with 5m0s and @. The watcher stops as
soon as the connection closes for any other reason, and as soon as the daemon
shuts down. It therefore never outlives its connection. This is off the JSON-RPC wire, because it is connection lifecycle and
sends no frame. It closes only an idle connection, never the daemon. On Linux
and macOS a client-less orphan daemon is retired by the separate orphan-exit
self-probe below.
Daemon log (remote-server.log)¶
The launcher creates remote-server.log in the socket's directory with mode
0600. The launcher rotates any existing log to
remote-server.log.old and creates a fresh one. One Windows case gets no fresh
file: a second daemon on a live socket (see below). This matches 4534d86, which
keeps the previous session's log as .old on every restart. The launcher
redirects the daemonized child's stdout and stderr into that file, so the
launcher's own streams stay empty. On Linux and macOS, when the limit raise
works, the first line tells the open-files limit that process.spawn children
get. This line is at INFO level, so CLAUSTRUM_LOG_LEVEL=warn drops it. The
ready banner follows, with no timestamp:
2026/07/31 00:17:30 INFO [daemon] child processes will start with an open-files limit of 65536
Claustrum remote server listening on /run/user/1000/claude/rpc.sock (pid 4711, instance 5f1c0a9e2b7d4c3e8a6f0b1d2c3e4f50)
2026/07/31 00:17:30 INFO [daemon] host cleaning off: socket path "/run/user/1000/claude/rpc.sock" is not <root>/run/<id>/rpc.sock
2026/07/31 00:17:30 INFO [Server] New connection from: @
The value of the first line is the soft limit it set, which is the lower of 65536 and the hard limit. f6010b97
writes the same line, with no level tag, before its banner. Linux and macOS VMs
measured this, with the value 65536. On the Windows VM the reference log has no
such line, and claustrum writes none there. A failed raise was not measured.
Claustrum writes no line then.
A start with CLAUDE_SSH_PEER_CHECK=1 writes one more line before the banner,
at WARN level. CLAUSTRUM_LOG_LEVEL=error drops it. On macOS and Windows the
text is:
5fd08069 writes that text there, with no level tag, as the last line before its
banner (macOS and Windows VMs). On Linux the text is claustrum's own:
On the Linux VM 5fd08069 writes a different line at that place
(D25). The place of the line beside the lines of a reap is
not measured. The peerCheck member of server.capabilities tells the same
state.
The banner ends with the pid of the daemon and its instance. The instance is the
instanceId of server.capabilities. f6010b97 and 89cb6289 print the same
suffix (Linux, macOS and Windows VMs). The name Claustrum is claustrum's own.
The reference prints Claude there. The banner beside the lines of
-listen-pipe and -metrics-addr is not measured.
For a planted symlink in a writable directory, claustrum renames the link, not its
target, to remote-server.log.old. It then creates a fresh regular log with
O_EXCL. On Linux and macOS the link is never followed, and the victim stays untouched. If claustrum
cannot rename the existing entry, the exclusive create fails too. One such case is a
sticky directory that holds another user's file or symlink. On Linux and macOS
claustrum then declines the
log entirely and falls back to inherited stdio. In both cases, on Linux and macOS,
claustrum never follows the link and never writes into a file another user owns.
On Windows the launcher then opens an existing regular file for append (see
below). That open tests the file type first and has no owner test. A swap between
the test and the open is not measured. This follows from the code. This is
intentional divergence D8, and it is always-on. 4534d86 no longer
plain-truncates a foreign regular log, and claustrum matches the .old rotation. But in a root-owned sticky directory the
reference still follows a planted remote-server.log symlink. It then writes its
log into the victim, or it refuses to start. claustrum declines instead. The
trigger is not reachable on the deployed path, because the socket directory
(~/.claude/remote/) is per-user and not world-writable. That is why D8 is
always-on and not opt-in. See DIVERGENCES.md → D8.
On Windows a second daemon can start on a live socket (rows WN04 and WJ04). The
first daemon holds remote-server.log open, so the launcher cannot rotate it. The
launcher then opens the same file for append, and the second daemon logs there.
Each daemon writes to the end of the file. On a Windows VM the file of claustrum
kept the 16 earlier lines of the first daemon. The later lines of both followed in
time order. 89cb6289 logs to the file too, and both launchers return at once. It
truncates the file at that start. The 16 earlier lines of its first daemon were
lost, and the later lines of that daemon sat behind a block of NUL bytes. This is
divergence D21, a maintainer decision of 2026-10-02: claustrum keeps the lines.
See DIVERGENCES.md → D21. A first daemon of an earlier
claustrum build does not write in append mode. It writes at its own offset. That
mix follows from the code and is not measured.
claustrum does not remove the log on graceful shutdown. This differs from the
socket and from daemon.token. The log outlives the daemon, so a post-mortem
stays readable. The fixed name and location are the deployment contract, and they
are not configurable.
Orphan-exit self-probe¶
On Linux and macOS a -serve daemon whose socket path no longer leads back to it
shuts down, as on 4534d86. So a predecessor retires once a newer daemon took over its socket. The
idle timeout closes only a connection. The probe is off the JSON-RPC wire. Its
effects are log lines and, once orphaned, a self-directed authed
server.capabilities request.
Every 60 seconds the daemon compares the file identity of its socket path
(os.SameFile) to the inode it bound. If the path is gone or another inode, a
10-minute grace clock starts. A path that still matches resets the clock. A check
that sees a client starts the clock again and writes no line. So the first line
below does not come after that client leaves, as on 5fd08069 (cell K2 of round
tier1-01, Linux and macOS VMs). After the grace with nobody connected, the daemon
dials its socket and compares the instanceId of the reply to its own. A probe
that reaches itself resets the clock. Two failed probes, 60 seconds apart, start a
graceful shutdown. It closes listeners, drops clients, and stops child process
groups unless -keep-children is set.
A Linux VM measured the self-exit of a stranded reference daemon (rows ST09 and
ST11, several runs). The log lines of 89cb6289 in one run:
[Server] socket path <socket> no longer leads to this daemon; shutting down if that holds for 10m0s with nobody connected
[Server] socket path <socket> did not lead back to this daemon (1/2); re-checking before acting
[Server] orphaned for 11m0s (socket path gone or re-bound, 2 self-probes failed, no connections); shutting down and killing 0 child process(es)
[Server] shutdown requested
[Server] cleanup: closed 0 connection(s), killed 0 child process group(s)
The second line came 9 min 59 s after the first, and the third line one minute
later. f6010b97 logged the second and third line one minute later
(orphaned for 12m0s), in its one run. 89cb6289 logged 11m0s in four rows and
12m0s in three. Each daemon exited with code 0, with no signal from another
process, and removed daemon.token. At its self-exit 89cb6289 ended both
children with SIGKILL (row ST11). On a Linux VM, claustrum logged the same
texts, ended both children and exited with code 0 (rows ST09 and ST11). It logged
orphaned for 12m0s in each of its six rows. The cause of the 60 s difference is
not measured, and the interval of the reference is not measured.
A check with no client also ends a daemon after 30 days with no counted request,
on the monotonic clock. Each request past auth counts, except
server.capabilities. This idle exit is not measured on a reference build.
The checks run on Linux and macOS only. On Windows a daemon whose socket file was
deleted writes no line. 5fd08069 wrote no line and was still alive after 14 min
(cell K3 of round tier1-01, Windows VM).
Message shapes¶
// request
{"jsonrpc":"2.0","id":<n>,"method":"<ns>.<method>","params":{…},"auth":"<token>"}
// success
{"jsonrpc":"2.0","id":<n>,"result":{…}}
// error
{"jsonrpc":"2.0","id":<n>,"error":{"code":<c>,"message":"…"}}
// id-less stream notification (server -> client)
{"type":"stream","processId":"<id>","stream":"stdout|stderr|exit","seq":<n>,"data":"<base64>","exitCode":<n>,"signal":"<SIG>","killedBy":"<who>"}
The reply's id is the request's id decoded and re-encoded. It is not the bytes
the client sent. The daemon accepts any JSON value and returns it canonicalized. A
number round-trips through a float64
(1.0 → 1, 1e2 → 100, 12345678901234567890 → 12345678901234567000). An
object comes back with its keys sorted ({"b":1,"a":2} → {"a":2,"b":1}).
Integers, strings, arrays and null are unchanged. A client that matches replies
by the id text must compare the decoded value instead.
Results are ordered structs, never maps. The field order below is the wire contract.
Error codes¶
| code | meaning |
|---|---|
-32700 |
parse error, a malformed JSON line (response id is null) |
-32600 |
Invalid JSON-RPC version, meaning jsonrpc is absent or not "2.0" |
-32601 |
Invalid method format: <m> (method has no .), Unknown namespace: <ns> (well-formed but unknown namespace), or Unknown method: <ns>.<m> (known namespace, unknown method) |
-32602 |
invalid params (see per-method messages) |
-32603 |
internal error, for example open <path>: no such file or directory. A recovered handler panic also gives this code, with recovered panic: <v> |
-32003 |
stdin offset gap: offset ahead of applied bytes, from process.stdin with an offset past the applied high-water (added in 7c2f88d) |
-32002 |
stdin backpressure: queue full, from process.stdin when the per-process async stdin queue is already full (16 MiB). The write is rejected, not blocked. |
-32004 |
the managed launcher cannot be used: <reason>, from process.spawn when the checks refuse its launcher param, and for any launcher on Windows (added in 89cb6289) |
-32005 |
the managed launcher <path> could not be started: <reason>, from process.spawn when the launcher passes the checks but does not start (added in 89cb6289) |
-32001 |
Unauthorized: invalid or missing auth token |
Error-string catalogue¶
Every method-level error string, verbatim, in one place. The per-method sections
below give the trigger and the result shape. Codes are -32602 unless noted. A
cell C-… is of round tier0-20 against 5fd08069 on a Linux VM.
| namespace / context | error string | code / notes |
|---|---|---|
| protocol | Invalid JSON-RPC version |
-32600 |
| protocol | Invalid method format: <m> / Unknown namespace: <ns> / Unknown method: <ns>.<m> |
-32601 |
| protocol | Invalid params |
-32602 (absent/mistyped params) |
| protocol | Unauthorized: invalid or missing auth token |
-32001 |
| protocol | recovered panic: <v> |
-32603 (claustrum-only, see below) |
| files.stat | stat <path>: <reason> |
-32603 (any stat failure other than ENOENT) |
| files.read | open <path>: <reason> |
-32603 (any open failure other than ENOENT) |
| files.read | files.read: path is a directory |
|
| files.read | files.read: file exceeds maxBytes |
|
| files.read | files.read: not a regular file |
-32602. The path opens and is not a regular file. See files.read |
| files.list | open …: no such file or directory |
-32603 (missing dir) |
| files.list | open <p>: not a directory |
-32603. The path is not a directory. Since 7d193f89 the open itself fails. Before 7d193f89 the message was readdirent … |
| files.list | open <p>: permission denied |
-32603 (an unreadable directory) |
| files.validate | Path does not exist |
in error field, valid:false |
| files.extract_tar | archivePath and destDir are required |
|
| files.extract_tar | destDir must be an absolute, non-root path: … |
in error field |
| files.extract_tar | destDir must not be or contain the home directory: … |
D2, in error field |
| files.extract_tar | open archive: … |
in error field (the archive cannot be opened) |
| files.extract_tar | gzip: … |
in error field (a bad gzip header) |
| files.extract_tar | tar read: … |
in error field (an entry that the tar reader cannot read) |
| files.extract_tar | write <entry>: … |
in error field (the content of a file cannot be copied) |
| files.extract_tar | unsafe path in archive: <entry> |
in error field (zip slip) |
| files.extract_tar | unsupported tar entry type <c>: <entry> |
in error field |
| files.extract_tar | extraction size limit exceeded |
D3 opt-in, in error field |
| files.extract_tar | clean destDir: RemoveAll <name>: … |
in error field. The daemon cannot delete the old destDir or an entry in it. <name> is the last name of destDir |
| files.extract_tar | mkdir parent: mkdir <path>: … |
in error field. The daemon cannot create a folder above destDir. <path> is the whole path of that folder. The text mkdir parent <entry>: … below is for an archive entry |
| files.extract_tar | mkdir destDir: mkdirat <name>: … |
in error field. The daemon cannot create destDir |
| files.extract_tar | clean destDir: "<name>" is not the entry that the path names / mkdir destDir: "<name>" is not … |
in error field. claustrum's own text: the path of destDir and its last name in the parent folder do not name one entry. See D2 |
| files.extract_tar | write .synced: … |
in error field |
| files.extract_tar | open parent: open <parent>: … |
in error field. The daemon cannot open the folder that holds destDir |
| files.extract_tar | open destDir: openat <name>: … |
in error field. The daemon cannot open the new destDir |
| files.extract_tar | open destDir: "<name>" could not be examined (statat .: permission denied) |
in error field. The daemon cannot search the new destDir. See files.extract_tar |
| files.extract_tar | open destDir: "<name>" changed while it was opened |
in error field. claustrum's own text: another process replaced destDir. See D2 |
| files.extract_tar | create <entry>: openat <place>: file exists |
in error field. A directory is at the place of a file entry. On Windows the text ends with is a directory. <place> is relative to destDir |
| files.extract_tar | mkdir parent <entry>: mkdirat <place>: file exists |
in error field. A file is at the place of the directory of a file entry (prefix is contract) |
| files.extract_tar | mkdir <entry>: mkdirat <place>: file exists |
in error field. A file is at the place of a directory entry |
| git.status | baseRepo is required |
-32602. baseRepo is now required, since 7d193f89 |
| git.status / git.list_branches | <go error>, for example exit status 128 |
-32603 (git failed, stdout parse) |
| git.status / git.list_branches | signal: killed |
-32603, D5 opt-in only |
| git.status | <file>: not a regular file |
-32603. claustrum's own text, from the copy into the temporary git folder after the gate passed. <file> is info/sparse-checkout when that entry is a folder or a FIFO. The reference is not measured there |
| git.status | <file> is larger than <n> bytes |
-32603. claustrum's own text, for a copied file over its bound, also one that grew after the gate. <n> is 1073741824 for index, sharedindex.*, info/sparse-checkout and a reftable file, and 1048576 for HEAD and config.worktree. The reference is not measured there |
| git.status | <os error> of the copy or of the temporary folder, for example openat index: permission denied or stat <dir>: no such file or directory |
-32603. The raw Go error when a file of the entry cannot be copied, as an index of mode 000, or when a file of the temporary git folder cannot be made. With "shadowDirOff":true, and on a server with no run folder, a temporary folder that cannot be made answers the raw Go error too. The reference is not measured there. In the states shadow and fallback-git a .gitdir-<n> that cannot be made answers {"isRepo":false,"clean":false}. 5fd08069 answers so in the state shadow for a folder of the daemon of mode 0500 (cell F1 of round val-s3p2b2-7, Linux VM, one run). The state fallback-git and another cause are from the code |
| git.* | config-defined hooks could not be pinned off; git not run: inherited GIT_CONFIG_COUNT "<value>" is not a count |
in the frame of each method, when the daemon's own GIT_CONFIG_COUNT does not parse. See "The daemon's own git environment" |
| git.* | config-defined hooks could not be pinned off; git not run: inherited GIT_CONFIG pair <n> is incomplete |
in the frame of each method, when a pair below the daemon's GIT_CONFIG_COUNT is not set. See "The daemon's own git environment" |
| git.* | config-defined hooks could not be pinned off; git not run: listing the configuration in force: exit status 128: <git text> |
in the frame of each method, for a GIT_CONFIG_KEY_<n> of the daemon that git cannot read. A folder that holds no repository gets it too. 89cb6289 answers so in rows A-N1 to A-N6, A-N5b, A-S1 and A-X2 on Linux and macOS VMs, except row A-N5 on macOS. On a Windows VM the cells are P3-infoN, P3-infoX, P3-lbN, P3-statN, P3-crN, P3-rmN and Wf-P3-crNroot. See "The daemon's own git environment" |
| git.* | config-defined hooks could not be pinned off; git not run: listing the configuration in force: fork/exec <git.exe path>: The directory name is invalid. |
Windows only. In the frame of git.info, git.list_branches, git.status and git.worktree_create. The path of the first two, or the baseRepo of the last two, exists and git cannot start in it. That is a regular file, a file symlink (measured for git.info only) or a folder with a path of 296 characters (measured for git.info only). 89cb6289 answers so on a Windows VM: in cells A-01 to A-04, A-04b2 and A-06 for a regular file, in cell A-10 for the symlink and in cell A-13 for the folder. The path NUL and a file name with one more dot at its end get it too (measured for git.info only, cells Wc). See "Git-directory trust check" |
| git.* | git cannot run on this host; git not run: <exec error>: <git text> |
in the frame of each method (see the table in "Git-directory trust check" for git.worktree_remove), when the configuration listing fails and git version fails too. The exec error and the text are those of git version. Row A-T1 and cells C-d and C-g show that on Linux and macOS VMs, and cell C-h on a Linux VM. Cells P11-infoN, P11-infoT, A-14, Wa-infoF, Wa-lbN, Wa-statN and Wa-crN show it on a Windows VM. See "Git-directory trust check" (89cb6289) |
| git.* | this repository's git config cannot be used: <detail> |
-32603 on git.info, git.status and git.list_branches, and in the frame of git.worktree_create and git.worktree_remove. Since 5fd08069. The details are in Repository config refusals |
| git.* | a file in this repository's .git directory cannot be used: <detail> |
The same frames. See Repository config refusals |
| git.* | Claude could not test git on the remote machine (<cause>). Check free disk space and quota there, then try again. |
-32603 on git.info, git.status and git.list_branches, and in the frame of git.worktree_create and git.worktree_remove. Since 5fd08069. See The settings copy |
| git.* | Claude could not prepare its own files on the remote machine: cannot write its copy of the repository's git config: <cause> |
The same frames. The daemon cannot make the folder of the copy |
| git.* | git could not check this repository on the remote machine: at "<root>": exit status <n> |
The same frames, for a failed call about the repository. After an exit 1 with a text, the text of git stands in the place of exit status <n> (one line in the cell) |
| git.* | git could not check this repository on the remote machine: it did not finish within 30 seconds in "<root>"; try again |
The same frames, for a call about the repository that the daemon ended |
| git.* | git and Claude disagree about which repository holds this folder: from "<root>" git finds "<path>", Claude finds "<git directory>"; try again |
The same frames, for a call about the repository that names another path |
| git.* | config-defined hooks could not be pinned off; git not run: listing the configuration in force: a configuration key is longer than the listing reads |
in the frame of each method (see the table in "Git-directory trust check" for git.worktree_remove), for a configuration key over 1048576 bytes. 89cb6289 measured git.info. See "Hardened git calls" |
| git.* | config-defined hooks could not be pinned off; git not run: too many configured hooks to pin / config-defined hooks could not be pinned off; git not run: configured hook names exceed the aggregate pin byte bound |
in the frame of each method (see the table in "Git-directory trust check" for git.worktree_remove). It answers more than 1024 hook names, or names over 65536 bytes in sum. 89cb6289 measured git.info. See "Hardened git calls" |
| git.* | the repository's git directory could not be trusted; git not run: commondir not as git writes it: <S1, S2 or S3> |
in the frame of each method (see the table in "Git-directory trust check" for git.worktree_remove), for a stray commondir. "Git-directory trust check" gives the three texts (89cb6289) |
| git.worktree_create / git.worktree_remove | baseRepo is inside a managed worktrees directory (beneath .claude/worktrees, or beneath a directory holding a .claude-managed-worktrees marker), or could not be validated as a trust root |
in error. Create adds errorCode:"nested_base_repo", and remove has none. It answers a baseRepo under a managed worktrees tree, or one that fails claustrum's own trust-root test. No git runs. See git.worktree_create and the section of git.worktree_remove |
| git.worktree_create | branchName is required |
|
| git.worktree_create | not a git repository |
in error, errorCode:"not_a_repo" |
| git.worktree_create | refusing to create worktree: <p> {is a relative path / contains a ".." component / has a component Windows reads as a different name (trailing dot or space, or a colon) [Windows] / is not inside the repository <repo>; … / already exists, …} |
in error, errorCode:"unsafe_path" (7d193f89 containment). The spelling refusal is Windows-only and comes before containment. On Linux and macOS, in the <p> of the already exists text the parent folder of the leaf has its symlinks resolved, the last name is kept, and the path is cleaned. 89cb6289 and claustrum name the path so on Linux and macOS VMs: with a symlink at baseRepo (cell T9), with a leaf that is a symlink or a file (cells U1a to U1d), with a path that is not clean (cells U2a to U2c) and with a worktreeRoot behind a symlink (cell U3a2). <repo> is baseRepo as sent, so an absent baseRepo gives the empty string (f6010b97, Linux and macOS VMs). With worktreeRoot, the first two texts also refuse a relative, absent or .. baseRepo. <p> is then baseRepo as sent (f6010b97, Linux and macOS VMs) |
| git.worktree_create | refusing to create worktree: <root> is a filesystem root; choose the worktree location by its absolute path, without "..", beneath the filesystem root |
in error, errorCode:"unsafe_path", Linux and macOS, with worktreeRoot. A worktreeRoot of / is refused. 89cb6289 sends this frame for a worktreePath of /<name>, after the excludes read and the repo test, 3 git calls. The frame is the same with the folder present or gone, and for a daemon that runs as root. Nothing is created (Linux VM, cells R1n, R1u and R1r, and macOS VM, cell R1a). A root of // or /. gets the same text, and <root> is worktreeRoot as sent (cells R2 and R3). A worktreePath of /<directory>/<name> under a root of / gets it too (cell R6). A worktreeRoot that is a mount point other than / passes: the create and the remove succeed (cell R4). With a root of /tmp, a worktreePath of /x1/x2 gets the two-level text (cell R5). 89cb6289 and claustrum sent the same frames in cells R2 to R6 on Linux and macOS VMs. A relative worktreePath gets its own text first (cell C-W1). A root with a .. component that cleans to / gets the .. text (from the code, not measured). Windows refuses every worktreeRoot first, so a drive root such as C:\ is not measured |
| git.worktree_create | refusing to create worktree: <c> is a symbolic link; a symlinked .claude or .claude/worktrees … |
in error, errorCode:"symlinked_component", for a symlinked ancestor component under the repo (7d193f89) |
| git.worktree_create | failed to create parent directory: "" does not name a directory |
in error, errorCode:"mkdir_failed" (empty worktreePath, without worktreeRoot) |
| git.worktree_create | failed to create parent directory: <repo>\<component> is not a directory |
in error, errorCode:"mkdir_failed", Windows, when a directory between baseRepo and the leaf is a junction. <repo>\<component> is the path of the first junction. Nothing is created: no directory, no entry, no branch. Measured for .claude and .claude\worktrees (f6010b97, Windows VM, rows JCR1 and JCR2). Measured for a junction <P>\J above them, with the paths <P>\J\sub\.claude\worktrees\w1 and <P>\J\.claude\worktrees\w1: 89cb6289 and the claustrum build of this change answer equal frames (Windows VM, cells J-b-wt-pj and J-b-wt-pJ). claustrum also refuses a non-symlink reparse point that is not a junction the same way (not measured) |
| git.worktree_create | refusing to create worktree: <root> is the repository <repo> or inside it; a worktree location must be outside the repository |
in error, errorCode:"unsafe_path", with worktreeRoot. A root that is baseRepo or lies beneath it by whole components is refused. <B>/Tx beside <B>/T passes. <root> is worktreeRoot as sent, and <repo> is baseRepo as sent. claustrum cleans both paths first, as on remove. No create row measured that cleaning. It comes after the repo test and before the root-chain tests. claustrum runs it after the two-level test, as on remove. That order is not measured on create. Nothing is created. Measured against f6010b97 and 89cb6289 on Linux and macOS VMs |
| git.worktree_create | refusing to create worktree: <root> passes through <dir>, which is writable by <who> without the sticky bit (mode <mode>), so they could replace what is beneath it; choose a location under directories only you (or the system) control, or remove the extra write permission (chmod go-w) / refusing to create worktree: <root> passes through <dir>, which is owned by uid <uid>, neither you nor the system; choose a location you reach through your own directories |
in error, errorCode:"unsafe_path", Linux and macOS, with worktreeRoot. This is the ancestor test. It judges each directory above the root, top first. The root itself is not judged. The daemon resolves the symlinks of each level, then judges each directory from / down to that resolved level. Last it resolves the root and judges each directory above the resolved root. So when the root is a symlink, the chain above its target is judged. <root> stays the symlink as sent (row G42, Linux and macOS VMs, 89cb6289 only). A missing level or a failed resolve ends the walk with no refusal. The first failing directory reached is named. When the failing directories lie on one chain, that is the highest one. The kind of failure plays no part. <dir> is that directory with its symlinks resolved, and <root> is worktreeRoot as sent. The owner test comes first. uid 0 and the daemon's user pass. Any other owner gives the second text, even with the sticky bit set. <uid> prints unsigned, for example 4294967294 on macOS. Then the sticky bit clears the write tests. The group-write bit refuses unless the group is the private group of the daemon's user. The four tests of the private group are those of the root's own write test (rootGroupIsPrivate). The other-write bit always refuses. An owner of uid 0 gets no exemption from the write tests. <who> is "its group", "every user on this host" or "its group and every user on this host", as for the root. <mode> is the permission bits as four octal digits, so a mode of 2775 prints 0775. Only the chain of the root is walked, not the chain of baseRepo. A create without worktreeRoot and git.worktree_remove run no ancestor test. It comes after the test above and the 9 git calls (see "Hardened git calls"). After its owner and mode, each directory gets the checkout tests and the .git test below. The first failing directory from the top decides (cells C-W15 and C-W21). 5fd08069 refuses a link above the root that another user owns (cell C-W16), and answers "not a directory" for a regular file (cell C-W14). The order of these tests inside one directory is not measured. So a failing directory above the root gets this text even when its owner has no search access to it (row G36a). The lstat text does not apply there. Nothing is created. Measured against f6010b97 and 89cb6289 on Linux and macOS VMs (rows K1, G1 to G41b, Y11d, T7 and T7c). In macOS rows G28 and Y11d the root is a firmlink spelling, and <dir> is /System/Volumes/Data. Only Linux VMs measured rows G1, G32, G33a, G33c, G34b and G36b. A macOS VM measured the other round 2 rows and G42 against 89cb6289 only. Other cases are claustrum's choice and are not measured. The comment of worktreeRootAncestorRefusal lists them. The walk also tests the folders above a link in the root (cells X1, X2 and M3 of round tier0-22, Linux and macOS VMs). |
| git.worktree_create | refusing to create worktree: <root> leads into the repository <repo> (at <at>); a worktree location must be outside the repository |
in error, errorCode:"unsafe_path", with worktreeRoot. The root passes the in-repo test and the ancestor test above but leads into the git top level of baseRepo, the main checkout or the git directory, also a separate one (cell C-W7). The remove row below gives the test. claustrum compares file identity as on remove (not measured on create). In macOS row Y11d and Linux row T7 the root also leads into the repository, and both references send the ancestor text. <repo> is baseRepo as sent. It comes after 9 git calls (see "Hardened git calls"). Nothing is created. Measured against f6010b97 and 89cb6289 on Linux and macOS VMs |
| git.worktree_create | refusing to create worktree: <root> leads into <wt>, a worktree of the repository <repo> (at <wt>); a worktree location must be outside the repository's checkouts |
in error, errorCode:"unsafe_path", with worktreeRoot. The root passes the tests above but lies in a linked worktree <wt> of the repository. The remove row below gives the test. <repo> is baseRepo as sent. A listed worktree whose folder is gone refuses too. The create makes that folder first, with mode 0700 (cell C-W8). Nothing else is created. Measured against f6010b97 and 89cb6289 on Linux and macOS VMs |
| git.worktree_create | refusing to create worktree: <root> is inside a git checkout (<dir> has a .git entry); a worktree location must be outside every checkout, so that no session working in one can reach it — choose a directory that is not part of any repository |
in error, errorCode:"unsafe_path", Linux and macOS, with worktreeRoot, when a directory from / down to the root holds a .git entry of any kind. A missing directory is skipped. The ancestor test answers first at the same directory (rows G18, G40a and G40b). Nothing is created. Measured against f6010b97 on Linux and macOS VMs, with a .git entry up to five levels above the root. <root> is worktreeRoot as sent, with a trailing slash or // kept. <dir> is that directory, resolved and cleaned. Linux and macOS VMs measured these spellings. When several directories hold a .git entry, the highest one is named. Linux and macOS VMs measured that too |
| git.worktree_create | refusing to create worktree: <dir> is itself a git checkout (it has a .git entry); a worktree location must be outside every checkout |
in error, errorCode:"unsafe_path", Linux and macOS, with worktreeRoot, when the <directory> level holds a .git entry. Nothing is created. Measured against f6010b97 on Linux and macOS VMs. Under a symlinked root, <dir> has the root resolved. Linux and macOS VMs measured that spelling |
| git.worktree_create | refusing to create worktree: <root> passes through too many symbolic links (a loop?) |
in error, errorCode:"unsafe_path", Linux and macOS, with worktreeRoot. A symlink loop or too many links lie in the path of the root. claustrum uses the kernel limit and tests this before the ancestor test (neither measured). 5fd08069 sends it for a chain of 45 links, and the remove sends it too (cells C-W13, C-S5 and C-W23). Nothing is created. Measured against f6010b97 on Linux and macOS VMs. <root> is worktreeRoot without a trailing slash. claustrum also drops a // here (not measured) |
| git.worktree_create | refusing to create worktree: <p> carries a .git file that does not name this repository's own worktree admin directory |
in error, errorCode:"unsafe_path", Linux and macOS, after a successful add. In <p> the parent folder of the leaf has its symlinks resolved, the last name is kept, and the path is cleaned (89cb6289 and claustrum, cells T1, T5, U2f and A12c g, Linux and macOS VMs). The gitdir: path in the .git file of the new worktree lies in a folder that is not named worktrees. Or baseRepo has a worktrees folder with no entry of that name (89cb6289 in cell P-p, Linux and macOS VMs). Or that entry is a symbolic link (5fd08069, cell V7 of round tier0-10, Linux VM). Or the commondir file of the registration does not name the git directory. 5fd08069 answers it for an absolute path that reaches that directory through a symbolic link (cell V6 of round tier0-10, Linux VM). claustrum compares by text. Or the commondir file or the gitdir record of the registration cannot be read as a file (89cb6289, cells P-g to P-j, P-n, P-o and P-q, Linux and macOS VMs). No checkout runs and nothing is rolled back. |
| git.worktree_create | refusing to create worktree: <p> was not populated by git worktree add |
in error, errorCode:"unsafe_path", Linux and macOS, after a successful add, when the git directory of baseRepo has no worktrees folder. Or, in the state shadow, .git no longer names the held git directory right after the add, and is no real directory that passes the gate (5fd08069, round val-s3p2b2-10, Linux and macOS VMs). No checkout runs and nothing is rolled back. 89cb6289 answers it with a daemon GIT_COMMON_DIR of another repository (rows B-E1 and B-E3 and cell D8e, Linux and macOS VMs). |
| git.worktree_create | refusing to create worktree: <p> carries a .git file naming an admin directory whose own record is of a different worktree |
in error, errorCode:"unsafe_path", Linux and macOS, after a successful add. The gitdir record of the registration in the git directory of baseRepo was read and does not name <p>/.git. A record with a slash or a blank at its end, a /./ part or a double slash names it (5fd08069, cell V4 of round tier0-10, cells R1 to R3 of round tier0-11, Linux VM). No checkout runs and nothing is rolled back. If timeoutMs expired during the add, the answer is the same (cell P-f). 89cb6289 answers it in cells P-c and P-k to P-m (Linux and macOS VMs), and 5fd08069 in cell V5c of round tier0-10 (Linux VM). |
| git.worktree_create | git worktree add failed (verification): could not verify the worktree's registration: git did not report its spelling of the path (entry <name>); retry |
in error, errorCode:"worktree_add_failed", Linux and macOS. The state of the row above, and git fails the rev-parse --show-toplevel call that follows. <name> is the entry that the .git file names. The leaf, the one new entry of the worktrees folder and the new branch go (5fd08069, cell V5 of round tier0-10, cell P1 of round tier0-11, Linux VM). claustrum's own, not measured: the folder has one new entry, and no deadline ended the call. |
| git.worktree_create | refusing to create worktree: <p> carries a .git file naming an admin entry other than the one just created for it |
in error, errorCode:"unsafe_path", Linux and macOS, after a successful add. The registration is not the one new name of the worktrees folder since the listing before the add. With two new entries 5fd08069 answers it (cell V2 of round tier0-10, Linux VM). With no new entry 89cb6289 answers it (cells A9 g, A10 g and A13 g, Linux and macOS VMs). claustrum's own, not measured: a listing that fails gets it, and a refusal of the four rows above comes first. No checkout runs and nothing is rolled back. |
| git.worktree_create | refusing to create worktree: <p> has no .git file / refusing to create worktree: <p>/.git is not a regular file / refusing to create worktree: <p>/.git does not name a git dir |
in error, errorCode:"unsafe_path", Linux and macOS, after a successful add. The .git entry of the new worktree is gone, is no regular file, or does not start with gitdir:. The registration and the branch stay, and an empty leaf goes (5fd08069, cells V3a, V3b and V3c of round tier0-10, Linux VM). |
| git.worktree_create | refusing to create worktree: <p> is no longer the directory that was created for the worktree |
in error, errorCode:"unsafe_path", Linux and macOS, before the checkout. Another folder is at the path of the leaf. Nothing is checked out or rolled back (5fd08069, cell V8 of round tier0-10, Linux VM). |
| git.worktree_create | git worktree add failed: cannot prepare the repository's worktrees directory: "<git dir>/worktrees" is not a directory / git worktree add failed: cannot prepare the repository's worktrees directory: openat worktrees: permission denied / git worktree add failed: cannot list the repository's worktree entries: "worktrees" is a link or a file where Claude needs a real directory; put the directory itself in its place |
in error, errorCode:"worktree_add_failed", Linux and macOS. The first text: in the state shadow, worktrees of the git directory is a symbolic link or a file. The second: in the state shadow, a folder that cannot be opened, here of mode 0300. The third: a symbolic link with "shadowDirOff":true, or a file (claustrum's own). No worktree add runs, and no leaf or branch stays (5fd08069, cell V1 of round tier0-10, cells C1, C2, C4 and C5 of round tier0-24, Linux VM, cells C2 and C5 of round tier0-25m, macOS VM). |
| git.worktree_create | failed to create parent directory: lstat <path>: <errno text> |
in error, errorCode:"mkdir_failed", Linux and macOS, with worktreeRoot. There are two cases. First, a root symlink names a missing target, and <path> is that target. Second, a directory from / down to the root cannot be searched. Then <path> is <dir>/.git for the highest such directory. When that directory is above the root and fails the ancestor test itself, it gets the ancestor text instead (row G36a). Nothing is created. Measured against f6010b97 on Linux and macOS VMs. Other errors from resolving the root are sent as Go prints them (not measured) |
| git.worktree_create | failed to create parent directory: <path>: not a directory |
in error, errorCode:"mkdir_failed", Linux and macOS, with worktreeRoot, when the deepest existing part of the root is not a directory after its symlinks are resolved. <path> is that resolved path. It can be the root, a path above the root, or the file that a root symlink names. A regular file above the root gets this text, not the ancestor text (cell C-W14). Nothing is created. Measured against f6010b97 on Linux and macOS VMs |
| git.worktree_create | failed to create parent directory: <path> is not a directory |
in error, errorCode:"mkdir_failed", Linux and macOS. Without worktreeRoot, an existing component above the leaf is not a directory. <path> is its full path, with the symlinks of the repo resolved and with // and /./ removed. With worktreeRoot, the <directory> level exists and is not a directory, and <path> is its path with the root resolved. A <directory> that is a symlink gets the symlink refusal first. Nothing is created. Measured against f6010b97 on Linux and macOS VMs. Linux and macOS VMs measured the symlinked baseRepo, the /./ and the symlinked root. A macOS VM measured the /tmp repo spelling. On Windows, without worktreeRoot, a file in the path keeps the os.MkdirAll text, mkdir <path>: <OS error> (not measured) |
| git.worktree_create | failed to create parent directory: statat .: <errno text> |
in error, errorCode:"mkdir_failed", Linux and macOS, without worktreeRoot, when an existing .claude or .claude/worktrees cannot be searched. This holds also when nothing is left to create. Measured against f6010b97 on Linux and macOS VMs |
| git.worktree_create | failed to create parent directory: statat <name>/.git: <errno text> |
in error, errorCode:"mkdir_failed", Linux and macOS, with worktreeRoot, when the <directory> level exists and cannot be searched. <name> is its last component. Measured against f6010b97 on Linux and macOS VMs |
| git.worktree_create | failed to create parent directory: mkdirat <name>: <errno text> |
in error, errorCode:"mkdir_failed", Linux and macOS, when a missing directory above the leaf cannot be created. Without worktreeRoot, <name> is that directory's last component. With worktreeRoot, <name> is its path relative to the deepest directory that existed before the call, for example a/b. A directory that the call made before the failure stays. Measured with permission denied and file name too long against f6010b97 on Linux and macOS VMs. Other errno texts take the same shape (not measured). A root made in the same call, then a failed <directory>, reads mkdirat R/cp by the same rule (not measured) |
| git.worktree_create | failed to create parent directory: cannot mark <dir> as a worktree location: openat .claude-managed-worktrees: <errno text> |
in error, errorCode:"mkdir_failed", Linux and macOS, with worktreeRoot, when the marker cannot be created and no entry of that name exists. <dir> is the <directory> level with the symlinks of the root resolved. The leaf is not made. Measured against f6010b97 on Linux and macOS VMs, also under a symlinked root |
| git.worktree_create / git.worktree_remove | refusing to <verb> worktree: <p> is not <worktree location>/<directory>/<name> beneath <root> |
<p> and <root> as sent (cell C-W2). Create adds errorCode:"unsafe_path" in each row below that names no other code |
| git.worktree_create / git.worktree_remove | refusing to <verb> worktree: <p> uses the reserved name <name> for a directory under the worktree location |
<name> is .git or .claude-managed-worktrees, in any letter case (cells C-W3 and C-S1) |
| git.worktree_create / git.worktree_remove | refusing to <verb> worktree: <root> has a component named <name>, which is reserved; … |
cells C-W4, C-W4e and C-S2. Not measured: .claude-managed-worktrees there, and its order against the worktreePath tests |
| git.worktree_create / git.worktree_remove | refusing to <verb> worktree: <root> would place worktrees in <dir>, which contains the repository <repo>; choose a location the repository is not inside |
cells C-W5, C-W6, C-S3 and C-S8 |
| git.worktree_create / git.worktree_remove | refusing to <verb> worktree: <root> would place worktrees in <dir>, which is (or is inside) <wt>, a worktree of the repository <repo>; … |
cells C-W19 and C-S7 |
| git.worktree_create / git.worktree_remove | refusing to <verb> worktree: <root> resolves to the filesystem root; … |
cells C-W20 and C-S6 |
| git.worktree_create | refusing to create worktree: <root> leads into a session worktree (at <p>; beneath .claude/worktrees, or beneath a directory holding a .claude-managed-worktrees marker); a worktree location must be outside every session's worktree |
cell C-W9. claustrum tests it before the write tests of the root (not measured) |
| git.worktree_create | refusing to create worktree: <root> would be made inside a session worktree or its per-repository directory (at <p>; … |
cell C-W10 |
| git.worktree_create | refusing to create worktree: <root> is itself a per-repository worktree directory (it holds a .claude-managed-worktrees marker); choose the directory above it, or another location |
cell C-W11 |
| git.worktree_create | refusing to create worktree: <dir> is writable by <who> (mode <mode>); choose a directory only you can write to, or remove the extra write permission (chmod go-w) |
for the root, and for the <directory> level (cell C-W18). claustrum tests that level before its .git test (not measured) |
| git.worktree_create | failed to create parent directory: cannot determine the repository's work tree: <exec error> |
errorCode:"mkdir_failed", cell C-W17b |
| git.worktree_create | failed to create parent directory: cannot list the repository's worktrees: <exec error> |
errorCode:"mkdir_failed", cell C-W17 |
| git.worktree_create | git worktree add failed: <text> |
in error, errorCode:"worktree_add_failed". <text> is made from git's stderr by the text rule. The text of claustrum does not start with git's graft-file hint: lines. The rollback runs no git call and removes the leaf only if it is empty. A pre-existing branch is not deleted (4534d86). A failed add answers this frame even when the caller timeoutMs expired during the add (measured against f6010b97 and 90fca6e6 on a macOS VM) |
| git.worktree_create | git worktree add failed: <fallback text> (attaching to the existing branch <b> was refused first: <attach text>) |
in error, errorCode:"worktree_add_failed", when the attach add for existingBranch fails and the fallback -b <branchName> add fails too. The text rule makes each text on its own, each with its own 512-byte cap. Measured against f6010b97 and 90fca6e6 on a macOS VM |
| git.worktree_create | git worktree add failed (checkout): <text> |
in error, errorCode:"worktree_add_failed", when the read-tree checkout fails. Same text rule, and the same rule for the hint: lines. The new directory is removed. The branch the call created goes only when another ref reaches its tip (see The branch step). In attach mode the attached branch is kept (measured against f6010b97). On Linux and macOS a failed placement of the index answers this frame too. The OS error is, for example, openat <registration name>/index: permission denied (89cb6289, rows A14 and A14f on a Linux VM, A14 and A14b on a macOS VM) or open <temporary directory>/index: no such file or directory (cell X1, Linux and macOS VMs) |
| git.worktree_create | Claude doesn't run the "<name>" git filter, because only this repository's own git config defines it. If you trust that filter, copy the [filter "<name>"] section from that config (usually .git/config) into ~/.gitconfig on the remote machine, then try again. |
in error, errorCode:"repository_only_filter", when a path of the checkout uses a git filter that only the git config of the repository defines. The rollback is the one of a failed checkout. Measured against 5fd08069 on Linux and Windows VMs. A quote mark in <name> has a backslash before it (Linux VM). 89cb6289 runs the filter there. |
| git.worktree_create | git worktree add failed (checkout): this repository's git config cannot be used: a filter defined only there has a name longer than 200 bytes (it starts "<first 40 bytes>"); define it in ~/.gitconfig on the remote machine, or rename it |
in error, errorCode:"worktree_add_failed", for such a filter with a name of 201 or 300 bytes. The rollback is the one of a failed checkout. Measured against 5fd08069 on Linux and Windows VMs |
| git.worktree_create | git worktree add failed (checkout): this repository's git config cannot be used: the filter "<name>" is defined only there, and Claude cannot handle "=" in a filter's name; define it in ~/.gitconfig on the remote machine, or rename it |
in error, errorCode:"worktree_add_failed", for such a filter with = in its name. The rollback is the one of a failed checkout. Measured against 5fd08069 on a Linux VM |
| git.worktree_create | git worktree add failed (checkout): this repository's git config cannot be used: the filter "<name>" is defined only there, and its name is not valid UTF-8; define it in ~/.gitconfig on the remote machine, or rename it |
in error, errorCode:"worktree_add_failed", for such a filter with a name that is not valid UTF-8. The text is claustrum's own. 5fd08069 answers another text on a Linux VM and success on a Windows VM. |
| git.worktree_create | git worktree add failed (checkout): this repository's git config cannot be used: it names a program for git-lfs ("<key>"); remove that setting, or put it into ~/.gitconfig on the remote machine |
in error, errorCode:"worktree_add_failed", when a path uses the filter lfs of the user or the system config and the config of the repository has the key <key>, a program key of git-lfs. The rollback is the one of a failed checkout. The text is claustrum's own. A Windows VM measured the rule with the words "a transfer program for git-lfs" of build D. |
| git.worktree_create | git worktree add failed (checkout): the git filter "<name>" has values in this repository's git config that no option of git can replace for the checkout |
in error, errorCode:"worktree_add_failed", when the repository adds a process key to a filter that has none outside it. The text is claustrum's own. 5fd08069 answers success in the measured state (Linux VM). |
| git.worktree_create | git worktree add timed out after <n>ms (deadline expired {before the checkout started / during the checkout): <text> / after the checkout finished}) |
in error, errorCode:"timeout", from the caller-supplied timeoutMs (4534d86). An absent timeoutMs, or 0, arms no deadline. <text> comes from the stderr of the killed git, by the same text rule |
| git.worktree_create | <frame>; and the undo could not finish for <leaf>: {the worktree directory, its registration, and the branch all remain; remove them by hand before retrying (RemoveAll <entry>: <OS error>) / the worktree directory remains (re-populated while undoing?); remove it by hand before retrying (removeat <leaf base name>: <OS error>)} |
appended to the checkout-failure frame and to each timeout frame when a step of the rollback fails. The errorCode stays as it was. Measured against f6010b97 and 90fca6e6 on a Windows VM. In attach mode the first text reads the worktree directory and its registration both remain instead, because the call made no branch (row C08b, f6010b97 and 89cb6289, Linux VM). On Linux and macOS a registration that cannot be deleted gives the worktree registration and the branch remain; remove them by hand before retrying (RemoveAll <registration name>: <OS error>) instead, with the leaf removed (89cb6289, rows A14 and A14f on a Linux VM, A14 and A14b on a macOS VM). The text in the parentheses can be claustrum's own, see step 2 of the rollback. In attach mode it reads the worktree registration remains; remove it by hand before retrying (RemoveAll <registration name>: <OS error>) (cell X5, Linux and macOS VMs). No rmdir text joins either one (cell X11, Linux VM) |
| git.worktree_create | <frame>; and the undo could not finish for <leaf>: <branch part> |
appended when the branch step of the rollback keeps the branch that the call made. See The branch step for each <branch part>. After a failed leaf rmdir, the branch part follows the rmdir text after ; (row C05). Some cases also add "branchKept":true after errorCode. The table there gives each case with its rows and VMs |
| git.worktree_remove | refusing to remove worktree: <p> {is a relative path / contains a ".." component / has a component Windows reads as a different name (trailing dot or space, or a colon) [Windows] / is not inside the repository <repo>; …} |
in error, with no errorCode. This is 7d193f89 containment. The spelling refusal is Windows-only and comes before containment. <repo> is baseRepo as sent. An absent or empty baseRepo gives the empty string (f6010b97, Linux and macOS VMs). A Windows VM measured an absent one. With worktreeRoot, the first two texts also refuse a relative, absent or .. baseRepo, and <p> is then baseRepo as sent. An empty worktreePath gets the first text there (f6010b97, Linux and macOS VMs) |
| git.worktree_remove | refusing to remove worktree: <root> is a filesystem root; choose the worktree location by its absolute path, without "..", beneath the filesystem root |
in error, with no errorCode, Linux and macOS, with worktreeRoot. A worktreeRoot of / is refused. 89cb6289 sends this frame for a worktreePath of /<name>, with no git call. The frame is the same with the folder present or gone, and for a daemon that runs as root. Nothing is deleted (Linux VM, cells R1n, R1u and R1r, and macOS VM, cells R1a and R1b). Cells R2 to R6 of the create row hold here too. claustrum tests a relative worktreePath first, as on create (not measured on remove). A root with a .. component that cleans to / gets the .. text (from the code, not measured). Windows refuses every worktreeRoot first, so a drive root such as C:\ is not measured |
| git.worktree_remove | refusing to remove worktree: <root> is the repository <repo> or inside it; a worktree location must be outside the repository |
in error, with no errorCode, with worktreeRoot. The test cleans the root and baseRepo and compares whole components. A root that is baseRepo or lies beneath it is refused, and <B>/Tx beside <B>/T passes. <root> is worktreeRoot as sent, so a trailing slash stays. <repo> is baseRepo as sent, so <T>/ and <T>/. stay too. It comes after the spelling, baseRepo and two-level tests, and before any git call. The worktree is not looked at. Nothing is deleted. Measured against f6010b97 and 89cb6289 on Linux and macOS VMs |
| git.worktree_remove | refusing to remove worktree: <root> leads into the repository <repo> (at <at>); a worktree location must be outside the repository |
in error, with no errorCode, with worktreeRoot. The root passes the test above. The daemon resolves the symlinks of the longest existing part of the root and appends the missing rest. Then it walks from that path up to /. <at> is the outermost path on that walk that is the same file as one of three folders. These are the git top level of baseRepo, the main checkout and the git directory. The main checkout is the first entry of worktree list. 5fd08069 refuses a separate git directory so (cell C-S4). So <at> is a prefix of the resolved root, for example /private/tmp/… for a root sent as /tmp/… on macOS. A bind mount (Linux), a firmlink spelling and a case variant (macOS) match too, and <at> keeps their spelling. With baseRepo in a linked worktree, a bind mount (Linux row T1) or a case variant (macOS row T1m) of the main checkout matches too. <repo> is baseRepo as sent. It is below <at> in row Q19, and a linked worktree in row W1. The test of the row below runs on each folder too. The folder nearest to / decides (from the code, as in create cell C-W21). If both checkouts hold the root, the main checkout is named (row T2b). There it is also the outer one, and claustrum names the outer match. It comes after the work-tree step and worktree list. Nothing is deleted. Measured against f6010b97 and 89cb6289 on Linux and macOS VMs |
| git.worktree_remove | refusing to remove worktree: <root> leads into <wt>, a worktree of the repository <repo> (at <wt>); a worktree location must be outside the repository's checkouts |
in error, with no errorCode, with worktreeRoot. The root passes both tests above. <wt> is a linked worktree in worktree list that is the resolved root or a parent of it by whole components. The test compares <wt> as listed, as a string. It resolves no symlink in <wt>, folds no case and compares no file identity. It skips a listed worktree whose path does not exist, also a locked one (rows Y9 and T3). Of two nested worktrees, the outer one is named. A root that holds a linked worktree passes. claustrum skips only a path that does not exist. Another error of the lstat keeps the entry (not measured). <repo> is baseRepo as sent. Nothing is deleted. Measured against f6010b97 and 89cb6289 on Linux and macOS VMs |
| git.worktree_remove | failed to remove worktree: the worktree location <root> is not reachable (<reason>); nothing was removed — retry once it is available, or remove the worktree by hand |
in error, with no errorCode, with worktreeRoot. If the root passes the three tests above but does not exist or does not resolve, this refusal comes right after them. <root> is worktreeRoot as sent. For a root that does not exist, <reason> is <path> does not exist. <path> is the root with the links of its existing part followed and a final slash dropped. 5fd08069 names it so in cells C-W22 and X3 (Linux VM) and M1 and M2 (macOS VM) of rounds tier0-20 and tier0-22. A root below a regular file gets <file>: not a directory (cell C-W24). For a symlink that does not resolve, <reason> is the error of resolving it, for example lstat <target>: no such file or directory (row T5). claustrum sends other resolve errors the same way (not measured). Another error of the lstat keeps the old answer. For permission denied both references and claustrum answer failed to remove worktree: lstat <root>: permission denied (row T6). Nothing is deleted, and the branch stays. Rows T5 and T6 ran against f6010b97 and 89cb6289, row T6 on a Linux VM only |
| git.worktree_remove | refusing to remove worktree: <c> is a symbolic link; a symlinked .claude or .claude/worktrees … |
in error, with no errorCode. It keeps the delete off a planted link (7d193f89) |
| git.worktree_remove | refusing to remove worktree: <t> {is a symbolic link, not a worktree directory / is not a directory} |
in error, with no errorCode, for a leaf that is a symbolic link or not a directory. <t> has the symbolic links of baseRepo resolved. With worktreeRoot, the links of the root are resolved instead. Nothing is deleted (f6010b97, macOS VM) |
| git.worktree_remove | refusing to remove worktree: <t> changed while the removal was checking it |
in error, with no errorCode. claustrum's own text for a leaf that another directory replaced between two looks. Only a race reaches it, and no run has measured the reference there. Nothing is deleted. On Linux and macOS, with worktreeRoot, a <directory> that turns into a symlink out of the root between two looks answers failed to remove worktree: openat <directory>: path escapes from parent. That text is claustrum's own and is not a parity claim |
| git.worktree_remove | refusing to remove worktree: <p> is locked (git worktree lock); unlock it to remove it |
in error, with no errorCode. 7d193f89 refuses a LOCKED worktree (success:false) and leaves it in place. The message is fixed whatever the lock reason is. Before 7d193f89 the reference deleted it and answered success:true. In four states 89cb6289 deletes the folder or answers success, and claustrum refuses (D22). With worktreeRoot, the frames differ in rows q2 to q4 (Linux VM) too. |
| git.worktree_remove | refusing to remove worktree: <p> is gone but its registration is locked (git worktree lock); unlock it to remove the registration and branch |
in error, with no errorCode, for a gone worktree with a locked registration. Nothing is deleted (f6010b97, macOS VM). On Linux and macOS a gone worktree whose locked entry sits behind a linked <git dir>/worktrees gets success, and its branch goes. That follows 5fd08069 (cell L1 of round tier0-26, Linux VM), by the maintainer's decision of 2026-10-14. |
| git.worktree_remove | failed to remove worktree: could not check whether <p> is locked (<reason>); retry |
in error, with no errorCode, for a gone worktree. Without worktreeRoot, <reason> is the hooks refusal or the trust refusal text (f6010b97, macOS VM). Since 89cb6289 it is also the git cannot run text (row L11). claustrum puts a limit refusal of the listing there too. 89cb6289 measured the limits on git.info only. <reason> is also the refusal of the daemon's GIT_CONFIG_COUNT (Linux, macOS and Windows VMs). With worktreeRoot and a missing absolute baseRepo without a .. component, <reason> is the refusal of the daemon's GIT_CONFIG_COUNT (Linux and macOS VMs) |
| git.worktree_remove | failed to remove worktree: "" does not name a directory |
in error (empty worktreePath, without worktreeRoot) |
| git.worktree_remove | failed to remove worktree: could not check whether <p> is locked (its registrations could not be examined); retry |
in error, with no errorCode. Without worktreeRoot, for a worktree directory that exists: the configuration of baseRepo cannot be read, baseRepo holds no repository, or the trust check refuses its git directory. When the daemon's GIT_CONFIG_COUNT is refused, the same holds. Linux and macOS VMs measured that also for a baseRepo of <T>/missing/.., which does not resolve. In both modes, for a worktree directory that exists: the worktree registry exists but cannot be read, or, on Linux and macOS, is a symbolic link (5fd08069, cell C3 of rounds tier0-24 and tier0-25m, Linux and macOS VMs, cells L2 and L3 of round tier0-26, Linux VM). Nothing is deleted |
| git.worktree_remove | failed to remove worktree: could not check whether <p> is locked (the repository at <baseRepo> could not be read); retry |
in error, with no errorCode, with worktreeRoot, when the daemon's GIT_CONFIG_COUNT is refused, baseRepo is missing and the worktree directory exists. This row covers an absolute baseRepo without a .. component. Nothing is deleted (f6010b97, Linux and macOS VMs). With no git on PATH, the same text answers a missing baseRepo in three rows (f6010b97 and 89cb6289, Linux VM). The worktree directory holds a file (row L13wa), is empty (row L13we), or holds a .git file that names its entry (row DG1) |
| git.worktree_remove | failed to remove worktree: cannot determine the repository's work tree: <reason> |
in error, with no errorCode, with worktreeRoot only. <reason> is a trust refusal text, exit status 128, the hooks refusal, or the refusal of the daemon's GIT_CONFIG_COUNT (f6010b97, Linux VM). Since 89cb6289 it is also git finds no repository here: <git text> (row N04). With git 2.56 also the text of a failed call about the repository (round tier0-27). With no git on PATH, both references answer the exec error alone in nine rows where baseRepo exists (Linux VM, see "Git-directory trust check"). claustrum also puts the git cannot run text and a limit refusal of the listing there. Neither is measured with worktreeRoot. Nothing is deleted. See the method section |
| git.worktree_remove | failed to remove worktree: cannot list the repository's worktrees: <exec error> |
in error, with no errorCode, with worktreeRoot only. git worktree list --porcelain -z and then git worktree list --porcelain both fail in baseRepo. Nothing is deleted. 89cb6289 answers exit status 128 for a repository with a valid HEAD whose .git/commondir is a dangling relative symlink (row DG2s-g, Linux VM) |
| git.worktree_remove | failed to remove worktree: statat .claude: permission denied / failed to remove worktree: open <baseRepo>: <error> |
in error, with no errorCode, without worktreeRoot only. baseRepo cannot be searched (mode 0600), or cannot be opened (mode 0000, or a regular file). On Linux and macOS the first text names the first component of worktreePath below baseRepo, for example statat wt or statat a (89cb6289, cells U19, N4, N5, N6a, N6b and N7a). Nothing is deleted |
| git.worktree_remove | failed to remove worktree: open <root>: permission denied / failed to remove worktree: openat <name>: permission denied / failed to remove worktree: statat .: permission denied |
in error, with no errorCode, Linux and macOS, for a folder that the daemon cannot read or search. With worktreeRoot, that is a folder from the root down to the worktree folder. Without it, that is a folder between baseRepo and the worktree folder. <root> is worktreeRoot with its symbolic links resolved. <name> is one path component. Nothing is deleted. Measured against 89cb6289 on Linux and macOS VMs (rows B1 to B6, B8 to B11b, B13a to B14c and B17a to B17d, row B18 on the macOS VM only, cells U1 to U11h and U13a to U15b). Windows is not measured. See the method section |
| git.worktree_remove | failed to remove worktree: RemoveAll <entry>: <errno text> |
in error, when the delete of the worktree fails part-way. If an entry other than .git fails, .git stays. The entry of the worktree and the branch stay in every case (f6010b97, macOS VM) |
| git.worktree_remove | removed the worktree but could not drop its registration (RemoveAll <name>: <errno text>) |
in error, success:false, when the verified entry cannot be deleted after the tree. The branch step still runs. On f6010b97 the branch is deleted (macOS VM). Since 89cb6289 a kept branch adds "branchKept":true after error (row R19, Linux VM) |
| git.worktree_remove | failed to remove worktree: openat <path from baseRepo>: path escapes from parent |
D19, Windows, in error, with no errorCode, when a directory between baseRepo and the leaf is a junction. <path from baseRepo> is the folder that holds the leaf, relative to baseRepo. It is .claude\worktrees in the default layout, and J\sub\.claude\worktrees and J\.claude\worktrees in cells J-b-wt-pj and J-b-wt-pJ (Windows VM). claustrum's own text. Nothing is deleted, and the branch stays |
| git.worktree_remove | worktreePath must not be or contain the home directory: … |
D2, in error. It sits behind 7d193f89 containment on the default branch, where it fires only if a repo is an ancestor of home. It is the active home guard on the external_root branch |
| process.spawn | Process ID is required / Command is required |
|
| process.spawn | command must be an absolute path when a launcher is given |
with a launcher param |
| process.spawn | the managed launcher cannot be used: <reason> |
-32004. The reasons are the launcher.resolve texts, and a launcher cannot be applied on a Windows host |
| process.spawn | the managed launcher <path> could not be started: <reason> |
-32005 |
| launcher.resolve | cliPath is required |
{}, null params, a null or empty cliPath, or a wrong key |
| process.stdin | Invalid base64 data / Process not found / Process not running |
order: decode → not-found → offset verdict (-32003/duplicate) → not-running (fresh write only) |
| process.stdin | stdin offset gap: offset ahead of applied bytes |
-32003 |
| process.stdin | stdin backpressure: queue full |
-32002 (queue full, ~16 MiB) |
| process.killAndWait / process.reattach | Process ID is required / Invalid params |
-install reports failures inside the __INSTALL_RESULT__ facts line as
cliError strings, not through an exit code. They are catalogued in the
-install section below.
Handler panic recovery¶
The per-request goroutine wraps dispatch in recover(). It therefore catches a
panic in any handler, and the daemon does not crash. The reply is
{"error":{"code":-32603,"message":"recovered panic: <v>"}}, and the daemon logs
[Server] recovered panic: method=<m> id=<id>: <v>. One method is the exception.
A recovered server.shutdown panic writes no frame at all. Its only reply is
{"ok":true}.
See server.* below.
This frame is claustrum's own. It is not a statement about the wire. No input is
known to reach a handler panic. Extensive fuzzing found none, and each of
claustrum's own panic sites is an unreachable stdlib guard or an
already-bounds-guarded slice. -32603 is the JSON-RPC 2.0 Internal error code.
The message prefix, log line, and id rendering are claustrum's own conventions.
They are documented so that an operator who sees the frame knows what it means.
They are not a compatibility guarantee.
Validation precedence¶
The daemon tests a request in the order parse, then auth, then version, then method, then params:
- The daemon tests auth before the
jsonrpcversion. A request can fail both tests, with noauthand with a missing or wrongjsonrpc. That request reports-32001 Unauthorized, not the version error. server.shutdownis the exception. The daemon skips auth for it entirely. A shutdown frame that is missing bothauthandjsonrpctherefore gets-32600 Invalid JSON-RPC version, because the version gate still applies, and the daemon stays up.
Params presence and typing¶
Every files.* / git.* / process.* method requires a params object.
server.* methods take no params. The daemon ignores a mistyped params on a
server.* method, and the call succeeds.
- Absent
paramsgives-32602 Invalid params. The daemon tests this after method existence, so an unknown method is-32601in every case. - The daemon accepts an empty
{}and then runs the method's own validation. - Mistyped
paramsgives-32602 Invalid params. This covers a wrong field type ("maxBytes":"4","path":123) and a non-object value ("params":"x"or[…]). The daemon does not coerce the value, and it does not ignore the decode error. - The daemon ignores unknown extra fields. It diverges from the reference in how
strictly it does so (D9). claustrum binds
paramsinto one struct per namespace (pathParams,gitParams). A field that is valid for the namespace but unused by this method therefore still takes part in the decode. A type-mismatched value there gives-32602, for examplefiles.stat {"maxBytes":"{"}. The reference answers that request with defaults, as measured onf6010b97. Both daemons ignore a genuinely unknown key, which is a key in neither struct. This is accepted divergence D9. SeeDIVERGENCES.md.
Path handling¶
A path must be valid UTF-8 to be addressable at all¶
Before any expansion or method logic, the JSON decoder replaces bytes that are not
valid UTF-8 with U+FFFD. A file whose name contains such bytes therefore cannot
be named in any request. The daemon answers about a path that does not exist. It
returns exists:false, or a chdir or stat error that quotes the substituted
name. This is parity, not a divergence. Both daemons inherit it from the JSON
decoder. See
ARCHITECTURE.md → Inherited wire bytes.
Tilde expansion in path params¶
claustrum expands a leading tilde in every path-bearing param before the method
runs: files.* path, extract_tar's archivePath / destDir, git.* path /
baseRepo / worktreePath, and process.spawn's cwd. Branch names are refs,
not paths, so claustrum never expands them. claustrum replaces a leading ~ with
the daemon user's home directory, and then cleans the remainder lexically. Bare
~ is the exception. It returns home verbatim, uncleaned.
| sent | reference replies | absolute-form control |
|---|---|---|
~ |
<home>, verbatim, not cleaned |
n/a |
~/ |
<home> (trailing separator stripped) |
unchanged |
~/f.txt |
<home>/f.txt |
unchanged |
~//f.txt |
<home>/f.txt (doubled separator collapsed) |
<home>//f.txt |
~/a/./b |
<home>/a/b (. resolved) |
<home>/a/./b |
~/a/x/../b |
<home>/a/b (.. resolved lexically) |
<home>/a/x/../b |
~user/f, /tmp/~/f, $HOME/f |
unchanged, not expanded | n/a |
Two consequences:
- Bare
~is the exception. AHOMEof/home/me/echoes back with its trailing slash.~/under the sameHOMEdoes not. - The cleaning is lexical, and it applies to the tilde form only. With
~/link -> b/c, the reference reads<home>/x.txtfor~/link/../x.txt. The absolute spelling walks the symlink and reads<home>/b/x.txt. Same request, different file.
Windows behaves the same way in Windows separator terms. Home comes from
USERPROFILE there, not from HOME.
| sent | reference replies |
|---|---|
~\a7 |
~\a7, not expanded, because ~\ is not a tilde form |
~/a1 |
<home>\a1, with / rewritten to \ |
~/a4\x\..\w |
<home>\a4\w, because \ is a separator for .. |
~/a5/ |
<home>\a5 |
~//a6 |
<home>\a6 |
~ |
<home> verbatim. A home of C:\h\ keeps its trailing \ |
The expanded spelling is wire-visible on eight frames, so it is contract.
git.worktree_create reflects worktreePath into result.path and into git's
error text. The expanded string also appears in the error text of files.stat,
files.read, files.list, files.validate, files.extract_tar and
process.spawn. Two places do not carry the spelling. files.list entry paths
are re-joined, and git.info's root comes from git's own output. On a
trailing-separator spelling the difference is a change of verdict. POSIX
stat("f.txt/") gives ENOTDIR. Once the daemon removes the separator, a
-32603 error frame becomes a success frame. Ids 15, 16, 18 and 19 of
testdata/socket_tilde_expansion.golden.json pin this. Id 17 sends ~// to
files.list and is documentary only.
Stat failures other than "does not exist"¶
files.stat, files.read and files.validate distinguish a path that is absent
from a path the daemon cannot examine. files.read opens the path where the
other two stat it:
- A genuine
ENOENTis the "does not exist" answer in each method's own shape. The three shapes areexists:false,content:"" exists:false, andvalid:falsewitherror:"Path does not exist". - The daemon reports any other failure with the underlying message.
files.statreturns-32603 stat <path>: <reason>.files.readreturns-32603 open <path>: <reason>.files.validatekeeps its result shape and puts that text in itserrorfield instead. Reachable reasons includenot a directory(a path component is a regular file),file name too long, andinvalid argument(a NUL byte in the path). Forfiles.read,5fd08069gives these three withopenon Linux and macOS VMs.
Methods (20)¶
server.capabilities self-describes the set. Order as returned:
server.ping server.capabilities server.shutdown
files.list files.validate files.stat files.read files.extract_tar
git.info git.status git.list_branches git.worktree_create git.worktree_remove
launcher.resolve
process.spawn process.stdin process.kill process.killAndWait process.reattach
plugins.prune
Reference 7c2f88d added process.killAndWait between process.kill and
process.reattach, for 19 methods. 7d193f89 then removed server.version and
brought the set to 18. Calling server.version now answers
-32601 "Unknown method: server.version". The -version CLI flag is a separate
surface and still prints the daemon's version. 19f30c46 appended
plugins.prune last and brought the set back to 19. plugins.prune is the only
member of the plugins.* namespace. 89cb6289 added launcher.resolve between
git.worktree_remove and process.spawn, for 20 methods. The Linux, macOS and
Windows VMs saw it at that place.
server.*¶
| method | params | result |
|---|---|---|
server.ping |
none | {"pong":true} |
server.capabilities |
none | {"version":"<id>","methods":[…20…],"instanceId":"<32-hex>","startedAt":<unix-ms>,"features":["process.stdin.offset","files.extract_tar.execBit","git.status.baseRepo","git.info.discovered_root","git.worktree_create.timeoutMs","git.worktree_create.existingBranch","git.worktree_remove.unpushedGuard","process.spawn.shellAgentSocket","launcher.managed","server.peer_check","git.worktree.external_root","server.instance_id"],"peerCheck":"off"}. plugins.prune is the last method on every OS. git.worktree.external_root is omitted on Windows. files.extract_tar.execBit, git.worktree_create.timeoutMs, git.worktree_create.existingBranch, git.worktree_remove.unpushedGuard, process.spawn.shellAgentSocket, launcher.managed, server.peer_check, instanceId, startedAt and peerCheck are present on every OS |
server.shutdown |
none | {"ok":true}, when the reply gets out. The handler waits until the teardown starts to close connections, and then returns the reply. The frame arrives only when its write wins the race with the close. See below |
server.versionwas removed in7d193f89. It now answers-32601 "Unknown method: server.version"like any other unknown method.instanceIdandstartedAtwere added by4534d86. They sit betweenmethodsandfeatures.instanceIdis a 32-hex string, andstartedAtis the daemon's boot time in unix milliseconds. Both are present on every OS. claustrum generatesinstanceIdfrom 16 crypto/rand bytes at startup. It stampsstartedAtafter the reap of the start and the bind. On a Linux VM thestartedAtof89cb6289was within 1.5 ms of itslistencall (rows D1 and D2, 8 of 8 runs). macOS has no call times. The owner record ofdaemon.lockholds the same two values. It echoes both on the capabilities reply for parity.- The
featuresarray was added by7c2f88d. It followsinstanceIdandstartedAt, and it advertises optional extensions.process.stdin.offset, the resumable and idempotent stdin contract, landed first.7d193f89addedgit.status.baseRepoandgit.worktree.external_root.4534d86insertedgit.worktree_create.timeoutMsbeforegit.worktree.external_rootand appendedserver.instance_id, which is always last on every OS.19f30c46insertedgit.worktree_create.existingBranchaftergit.worktree_create.timeoutMs, becauseworktree_createcan attach an already-existing branch. On unixgit.worktree.external_rootis present. On Windows it is omitted. The reference gates the external-worktree capability off on Windows, measured against7d193f89, and drops the feature from its Windows capabilities frame, so claustrum matches.f6010b97insertedprocess.spawn.shellAgentSocketaftergit.worktree_create.existingBranch, becauseprocess.spawncan hand a child the login shell's SSH agent socket.89cb6289insertedgit.worktree_remove.unpushedGuardaftergit.worktree_create.existingBranch, becausegit.worktree_removekeeps a branch that no other ref reaches. The Linux, macOS and Windows VMs saw it at that place.89cb6289also insertedgit.info.discovered_rootaftergit.status.baseRepo. In that buildgit.inforuns norev-parse --show-toplevelon Linux and macOS (row L01). The Linux, macOS and Windows VMs saw the feature at that place.git.info.discovered_root,git.worktree_create.timeoutMs,git.worktree_create.existingBranch,git.worktree_remove.unpushedGuardandprocess.spawn.shellAgentSocketare present on every OS.89cb6289also insertedlauncher.managedafterprocess.spawn.shellAgentSocket, forlauncher.resolveand theprocess.spawnlauncherparam. Windows lists it too, although a Windows spawn refuses every managed launcher. The VMs of all three OSes saw it at that place. TheCLAUDE_SSH_MANAGED_LAUNCHERgate does not change the capabilities frame (measured on macOS).5fd08069insertedserver.peer_checkafterlauncher.managed, for thepeerCheckmember. The Linux, macOS and Windows VMs saw it at that place.5fd08069also insertedfiles.extract_tar.execBitafterprocess.stdin.offset. The Linux, macOS and Windows VMs saw it at that place. On those VMs5fd08069also lists one feature that claustrum does not list. - In claustrum's answer
peerCheckis the last member, directly afterfeatures. It is in every answer. claustrum answers one of two values:"off"or"unavailable". The daemon reads the variableCLAUDE_SSH_PEER_CHECKof its own environment one time, at its start. Then it removes the variable from that environment. Only the exact value1changes the member. claustrum has no peer check. With1it answers"unavailable"on Linux, macOS and Windows, and it serves every caller. With each other value, and with no variable, it answers"off". On5fd08069each of these gave"off":- Linux VM: no variable,
0,off, the empty value,true,yes,on,2,01and1(a space before the digit). - macOS VM: no variable,
0,off, the empty value andtrue. - Windows VM: no variable,
0andoff.
- Linux VM: no variable,
With 1, 5fd08069 answers "unavailable" on the macOS VM and on the Windows
VM. A server.ping after it got its answer (macOS VM). For this member,
5fd08069 and claustrum differ in one of the listed values: 1 on
Linux. On the Linux VM 5fd08069 answers
"peerCheck":"on","peerCheckBy":["network","pidfd"]. claustrum answers
"peerCheck":"unavailable" there, with no peerCheckBy member, and it serves
all callers. That is D25. The log line of that start is
in Daemon log.
- server.shutdown is not authenticated. See Authentication.
- On the reference, server.shutdown usually closes the connection with no
reply. Its {"ok":true} frame arrived in 24 of 800 single-connection runs
with an earlier ping. It arrived in 0 of 200 runs with no earlier request on
the connection. With a second idle connection open it arrived in 67 of 200 runs.
These counts are measured against f6010b97 and 90fca6e6 on a Linux VM. In
claustrum, the handler signals the teardown and waits until the teardown starts
to close connections. Then it returns the reply for writing, so the reply write
races the close. The teardown closes the listener first, then every connection,
before its other cleanup.
When the frame arrives, it is {"jsonrpc":"2.0","id":<id>,"result":{"ok":true}}
followed by EOF. An idle connection gets a clean EOF with no bytes. On a macOS VM
under spawn load 5fd08069 sent it in 4 of 10 runs (round task003-01).
- Log order on server.shutdown. The daemon logs
[ServerHandler] server.shutdown received over RPC, then
[Server] shutdown requested, before the teardown closes any connection.
Each connection's [Server] Connection closed: <addr> line, when it is
logged, comes after its close. Measured with strace against f6010b97 and 90fca6e6 on a Linux
VM, the reference wrote the two lines before its connection close in 36 of
36 traced runs. Its Connection closed line came after the close in 32 of
them. Each of the other 4 runs also wrote the shutdown reply.
The daemon then logs
[Server] cleanup: closed <n> connection(s), killed <n> child process group(s),
as the reference does. The first count includes the connection that sent the
request. On a Windows VM 89cb6289 logged closed 0 in 2 of 9 such stops in one
run. The rate is not measured. On Linux and Windows VMs, f6010b97 and 89cb6289 logged that line at
every clean shutdown (rows RP11, RP12a, RP12b, ST03, ST09, WN03, WJ03). The
counts for a child that ended just before the shutdown are not measured.
- Log lines on a shutdown by SIGTERM. The daemon logs
[daemon] received terminated; shutting down (children will be killed), then
[Server] shutdown requested, then the cleanup: line. On a Linux VM,
f6010b97 and 89cb6289 logged these three lines (rows RP11, RP12b, ST03).
The line for SIGINT names that signal (5fd08069, round task003-01, Linux and macOS). With
-keep-children claustrum writes kept in place of killed in the first line.
The cleanup: line then says killed 0 child process group(s).
- Reply delivery is a measured timing difference, not parity. The claustrum
reply arrives at a different rate. Each count below is from 50 runs with a ping before the shutdown. The
runs were measured in one session against f6010b97, on one Linux VM and one
Windows VM. On one connection, the claustrum reply arrived in 0 of 50 runs on
Linux and in 0 of 50 on Windows. The reference reply arrived in 5 of 50 on Linux
and in 21 of 50 on Windows. With a second idle connection open, the claustrum
reply arrived in 2 of 50 runs on Linux, where the reference reply arrived in 20
of 50. On Windows the counts were 39 of 50 for claustrum and 36 of 50 for the
reference. All of these rates depend on timing.
files.* (param: path)¶
files.stat¶
{path} → {"exists","isDir","size","mode":"-rw-r--r--"}
Says whether a path exists, and gives its kind, size and mode. The page files.stat has the parameters, the response and the errors.
files.list¶
{path} → {"entries":[{"name","path","isDir"},…]} (name-sorted)
Lists the entries of one directory, without the hidden entries. The page files.list has the parameters, the response and the errors.
files.read¶
{path[,maxBytes]} → {"content":"<raw text>","exists":true}
Returns the content of one file as text, up to a size limit. The page files.read has the parameters, the response and the errors.
files.validate¶
{path} → {"valid":bool,"isDir":bool[,"error"]}
Says whether a path exists and whether it is a directory. The page files.validate has the parameters, the response and the errors.
files.extract_tar¶
{archivePath,destDir} → extracts a gzip tar → {"success":true,"fileCount":<n>}
Unpacks a gzip tar archive into a directory. It deletes the old content of the directory and the archive. The page files.extract_tar has the parameters, the response and what stays after a failure.
git.* (param: path = repo dir; worktree ops use baseRepo)¶
Git-directory trust check¶
f6010b97 added this check. The check looks at the directory the method runs
git in. That is path for git.info and git.list_branches. It is baseRepo
for git.status, git.worktree_create and git.worktree_remove. Every git.*
method runs the check before git runs in that directory. Unless a rule names its
OS, it is measured side by side against f6010b97 on a Linux VM, and on macOS and
Windows VMs where the case can exist. The rules for a stray commondir come from
89cb6289, measured on Linux, macOS and Windows VMs. The check has three
outcomes. "Trusted" lets git run. "No repository" answers as if the directory held
no repository. "Refused" answers with one of the texts M2 to M5 and S1 to S3, and
git does not run.
The daemon finds the git directory as follows, when its own environment sets
neither GIT_DIR nor GIT_COMMON_DIR:
- The walk starts at the request directory with symlinks resolved, and goes up. A refusal names the resolved path, never the alias.
- On Windows the walk of claustrum resolves no junction that comes before the last
component of the path. It starts at the path as it is spelled, and it takes a
..by text. The measured entries of89cb6289fit such a walk. The fixture is a repository P, a repository R and a junction<P>\Jto<R>\inner. For<P>\J\subthat walk gives the.gitof P. On89cb6289each git call ofgit.infoafter the excludes read carriesGIT_COMMON_DIR=<P>\.git(Windows VM, cells B-01, B-02, B-07 to B-09 and B-16). The path<P>\J\..\J\subgives the same entry (cell B-17d). With a second junction<KP>to P,<KP>\J\subgivesGIT_COMMON_DIR=<KP>\.git(cell B-17k). A junction outside any repository gives "no repository" andGIT_DIR=NULon each call (cell B-03). A junction that is the last component gives the pin of P too (cell B-04). A folder symlink in the place of the junction resolves, and the pin is<R>\.git(cell B-06). The claustrum build of this change is equal to89cb6289in frame, disk and every git call in these cells, except B-03 (Windows VM). In cell B-0389cb6289runs 3 git calls and claustrum runs 2. The third call is arev-parse --git-dirthat exits 128. Both sides carryGIT_DIR=NULon each call there, and the frames are equal. From the code: claustrum starts at the spelled path when Go'sfilepath.EvalSymlinksfails for a path that is a directory. A missing name before..is another cause of that failure. The paths<P>\missing\..and<P>\missing\..\J\subare equal in frame, disk and every git call (cells J-a1 and J-a2). - From the code: only
git.inforeaches the spelled start.git.list_branchestests apaththat is not empty withfilepath.EvalSymlinksbefore its trust check, andgit.statustestsbaseReposo.git.worktree_createandgit.worktree_removetest abaseRepothat exists so. If the resolve fails, each of the four answers there.process.spawndoes not use the walk. Ongit.infoan error frame depends on the spelled start too, not onlyrepoSluganddefaultBranch. In cell B-15 the build before the spelled start answered the fields of R, and the build of this change answers the listing refusal of89cb6289(Windows VM). Since the read of the repository config, claustrum answers the class 1 text in the state of cell B-15 (from the code). The listing refusal stays for a config that git refuses and the read accepts, for example aninclude.pathwith no value. - A
.gitdirectory that passes the git-directory test is the git directory. - A
.gitdirectory that fails the test ends the walk. If it holds acommondirentry of any type, it is the git directory, and the stray-commondir rules below judge it. Otherwise the answer is "no repository". Git itself walks on to an outer repository here, so this answer differs from git's own. - A
.gitregular file starts with the exact bytesgitdir:and holds at most 1 MiB. The rest is trimmed of white space. A relative target is taken relative to the directory that holds.git. Any other.gitfile means "no repository".GITDIR:in upper case is one example. - A
.gitof another type, such as a FIFO, is skipped, and the walk goes on. - With no
.git, the directory itself is the git directory if it passes the test. That is a bare repository. Nothing found up to the root means "no repository". - A request directory that does not exist is left to git.
The git-directory test has three parts. objects and refs exist, of any type.
HEAD passes. A regular HEAD passes when its first 255 bytes start with ref:, then
any spaces, tabs, CRs or LFs, then refs/. It also passes when it starts with 40
hex digits in either case. A symlink HEAD passes when its target text starts with
refs/. Any other HEAD fails at once. A FIFO HEAD is one example, and git
itself blocks on it.
The daemon then judges the git directory G:
- G is a linked-worktree entry when its parent is named
worktrees, its own name is not.git, and its grandparent passes the git-directory test. The entries of a main repository, a bare repository and a submodule all count. - G is not an entry and holds no
commondir: trusted. This is a normal main, bare or submodule git directory. - G is not an entry and holds a
commondir: the stray-commondir rules below judge it. Git writes that file only in an entry. An entry whose grandparent fails the test is not an entry, so its honestcommondir(../..) gets S1. That answer follows from the rules and is not measured on89cb6289. - G is an entry without
commondir: M3. - G is an entry whose
commondiris not a regular file of at most 1048576 bytes: M4. A symlink is followed only when it is relative and stays inside the entry. Exactly 1 MiB passes. - Otherwise the content is trimmed of spaces, tabs, CRs and LFs. It is taken
relative to G when it is not absolute, and cleaned lexically. It has to equal the
grandparent as a string. No symlink in it is resolved, so naming the repository
through an alias fails. On Linux and macOS a backslash is an ordinary character.
On Windows the comparison ignores letter case and slash direction. A
\\?\prefix or a path without a drive letter still fails there. Any other value gets M2. That covers another repository, a missing path, an empty file and a NUL byte. - A trusted git directory runs git with
GIT_COMMON_DIRpinned to the repository git directory. For an entry that is the grandparent. For any other git directory it is that directory itself. White space around../..therefore passes the check, although git itself rejects that file. The frames that follow come from git, so they depend on the git version. The pin also keeps git from walking past a trusted nested.git. A nested.gitwhoseobjectsis a regular file therefore answers "not a repository" from git on Linux and macOS. Git for Windows accepts that layout and answers for the nested repository. - An entry directory that is gone means "no repository". On Linux and macOS an entry replaced by a regular file means the same. On Windows it gets M3.
- A
.gitfile that names a plain directory, another repository's git directory or the main git directory is not refused. Git answers for itself there. - On Windows the daemon does not expand 8.3 short names in G. A
.gitfile or a daemonGIT_DIRcan name an entry as…\GIT~1\WORKTR~1\<name>. That G is not an entry, because its parent is not namedworktrees. Itscommondirtherefore gets S1 for the content../.., which names the short path.f6010b97refused it on a Windows 11 VM. The S1 text follows from the rules and is not measured on89cb6289. When a component of G is a symlink, the daemon resolves G. Else it keeps G as spelled. A G with a junction before its last component stays as spelled. For a junction<KP>to a repository, the pin of89cb6289and of the claustrum build of this change is<KP>\.git(Windows VM, cell B-17k).
A stray commondir is one in a git directory G that is not an entry. G is a main
.git, a bare repository or a submodule's git directory. 89cb6289 judges it in
these steps (rows T00 to T11):
- A
commondirsymlink that leaves G is not followed (rows T07a, T07d and T07e). A dangling symlink whose target is relative and stays in G counts as none when G passes the git-directory test. G is then trusted (row T07c, targetnothing-here). Git itself then fails to read the file. The method answers what git's failure gives:git.infowith nobranch,git.list_branches-32603 "exit status 128", and the failed add ongit.worktree_create. WithworktreeRoot,git.worktree_removeanswerscannot list the repository's worktrees: exit status 128and deletes nothing (row DG2s-g, Linux VM). WithoutworktreeRootit answers{"success":true,"branchKept":true}. It deletes the worktree directory and its entry, and the branch stays (row DG3-g, Linux VM). When the directory is gone before the request, it deletes the entry (row DG3x-g, Linux VM). When G fails the git-directory test, that symlink gets S3.89cb6289answers so for aHEADthat readsgarbage(rows DG2-g, DG2-info and DG2-status, each a main.git, Linux VM). - A
commondirthat is not a regular file of at most 1048576 bytes gets S2 with its reason (rows T05 to T09). A relative symlink that stays in G is followed (row T07b). A dangling symlink that is absolute, or that leaves G, gets S2 withopenat commondir: path escapes from parent(rows T07d and T07e on a Linux VM). Their targets were/nonexistentand../nothing. - The content with every trailing CR and LF removed is exactly
.or./. A space or a tab is not trimmed in the measured rows. Any other content gets S1, which quotes it (rows T01 to T04 and T10b). So.,.,.<TAB>,./x,.., an empty file,.\and the absolute path of G itself are all refused. - G then passes the git-directory test, or it gets S3 (row T10a). The content test comes first.
- Else G is trusted, and git runs with
GIT_COMMON_DIRpinned to G. That covers a main.git(row T01), a bare repository (row T11a) and a submodule's git directory under.git/modules/(row T11b).
These cases are not measured on 89cb6289. One is a leading CR or LF. Others are a
lone CR and .//. claustrum cuts the content to 60 runes after the trim, and that
order is not measured. Nor is the order of an S2 reason and a bad HEAD. A
dangling symlink in a git directory with missing objects or refs is not
measured. Neither is S2 for a FIFO or a mode-000 file on Windows.
The daemon's own environment changes the check as follows:
- A
GIT_DIRnames the git directory to judge, for every request directory, even a plain one. A relative value is taken relative to the request directory. AGIT_DIRthat names a gone entry gets M5. On Linux and macOS aGIT_DIRthat names a.gitfile means "no repository". On Windows that.gitfile is trusted and pinned to itself, and without"shadowDirOff":trueclass 2 of Repository config refusals answers. With its entry gone that is not measured on5fd08069. Any otherGIT_DIRthat does not exist is left to git. Git then runs withGIT_COMMON_DIRpinned to thatGIT_DIR, unless the daemon's environment setsGIT_COMMON_DIR. A relative value is first joined to the request directory. On Windows/dev/nullnames nothing, so it becomes<request dir>\dev\null. Measured againstf6010b97on Linux and Windows VMs. - A
GIT_COMMON_DIRturns the check off forgit.infoandgit.list_branches. The other three methods still run it.git.worktree_createthen prefixes the refusal withgit worktree add failed: cannot locate the repository's git directory:. GIT_CONFIG,GIT_CONFIG_PARAMETERSandGIT_CEILING_DIRECTORIESdo not change the check.
M2 to M4 and S1 to S3 start with the repository's git directory could not be
trusted; git not run: commondir not as git writes it:. Each %q of a path is Go
quoting of the path cut to its first 300 runes. The content in S1 is cut to its
first 60 runes instead. A non-ASCII path is cut by runes, not bytes. An invalid UTF-8
byte stays a byte and prints as \xff. The rest of the text is never cut.
| id | text after the prefix | operands |
|---|---|---|
| M2 | %q does not name the entry's own repository, %q; restore the file to read ../.. or remove that worktree entry and add the worktree again |
<entry>/commondir, <repo git dir> |
| M3 | worktree entry %q has none (git always writes one there, containing ../..); the entry is damaged or was not made by git — recreate the worktree with git worktree add, or remove the entry |
<entry> |
| M4 | %q is not a small plain file (<reason>); remove it, or remove that worktree entry and add the worktree again so git rewrites it |
<entry>/commondir |
| S1 | %q reads %q; git writes that file only inside .git/worktrees/<name>/, and Claude Code's sandbox may leave one containing just "."; anything else sends git's configuration and hooks elsewhere, so treat it as tampering: delete it, and if you did not create it, find out what did |
<G>/commondir, the content |
| S2 | %q is not a small plain file (<reason>); git writes that file only inside .git/worktrees/<name>/; one here can send git's configuration and hooks elsewhere, so treat it as tampering: delete it, and if you did not create it, find out what did |
<G>/commondir |
| S3 | %q sits in a git directory git would not take as a repository (its HEAD, objects or refs missing or invalid), so git would look for one somewhere else; repair that directory (usually its HEAD file) |
<G>/commondir |
<name> in S1 and S2 is literal text, and the JSON encoder writes it as
\u003cname\u003e. f6010b97 answered every stray commondir with one older text,
M1, and 89cb6289 printed it in no measured row.
The M4 and S2 reasons seen include commondir is not a regular file,
commondir is larger than 1048576 bytes,
openat commondir: path escapes from parent,
openat commondir: no such file or directory (M4 only) or
openat commondir: permission denied. On Windows the OS text differs, for
example openat commondir: The system cannot find the file specified..
M5 has no common prefix. It is config-defined hooks could not be pinned off; git
not run: the worktree entry this folder's .git names no longer exists.
| method | refused | no repository |
|---|---|---|
git.info |
{"error":{"code":-32603,"message":<text>}} |
{"isRepo":false,"repoSlug":"","defaultBranch":""} |
git.list_branches |
{"error":{"code":-32603,"message":<text>}} |
{"isRepo":false,"branches":[]} |
git.status |
{"error":{"code":-32603,"message":<text>}} |
{"isRepo":false,"clean":false} |
git.worktree_create |
{"success":false,"error":<text>,"errorCode":"worktree_add_failed"} |
{"success":false,"error":"not a git repository","errorCode":"not_a_repo"} |
git.worktree_remove |
without worktreeRoot, the lock-check refusal for a worktree directory that exists, and could not check whether <p> is locked (<text>); retry for a gone one. With it, the work-tree refusal and <text> |
without worktreeRoot, the lock-check refusal for a worktree directory that exists, and {"success":true} for a gone one. With it, the work-tree refusal and exit status 128 |
The lock-check refusal is
{"success":false,"error":"failed to remove worktree: could not check whether <worktreePath> is locked (its registrations could not be examined); retry"}.
It has no errorCode, and the path is neither quoted nor cut. The work-tree
refusal is {"success":false,"error":"failed to remove worktree: cannot determine the
repository's work tree: <reason>"}, also with no errorCode. With worktreeRoot,
the listing classes below give their own reason. With worktreeRoot and no git
on PATH, the exec error replaces both texts of the table in the measured rows
(class 1 below).
Where the trust check finds no repository, the daemon still runs the configuration
listing in that folder. In a
plain folder it carries GIT_DIR=<null>. The
call log of 89cb6289 shows that listing with no GIT_CONFIG_* entry too, and the
frames are those of the "no repository" column there. A listing that fails by class
3 below gives the "refused" column.
89cb6289 answers so for a plain folder and for a folder with an empty .git
folder. Rows A-N1 to A-N6, A-N5b, A-S1 and A-X2 show that on Linux and macOS VMs.
Row A-N5 shows it on the Linux VM only. On macOS 89cb6289 and claustrum answer the
worktreePath shape refusal there, with no git call.
Cells P3-infoN, P3-infoX, P3-lbN, P3-statN, P3-crN and P3-rmN show it on a Windows
VM. See "The daemon's own git environment".
A daemon GIT_DIR that names a regular file differs by system. On Linux and macOS
no listing runs, and the "no repository" column answers. 89cb6289 answers
git.info so on a plain folder and on a repository. That holds with and without an
empty GIT_CONFIG_KEY_0 (cells CfP0 and CfP3, Linux and macOS VMs). Other methods
are not measured on Linux and macOS. On Windows a
file with no gitdir: line gets the refusal of a config on the five methods:
this repository's git config cannot be used: open <GIT_DIR>: not a directory
(5fd08069 in V-Wd of round task039-02, V-Wd2 of task039-03, Windows VM).
With an older git, a .git file to a gone git dir keeps the "no repository" frame of
git.info (cells CbP0 and CbP3, Linux and macOS VMs).
Two more states are not measured. The first is a daemon GIT_COMMON_DIR, where a
listing gets no GIT_DIR=<null>. The second is a .git file that names a regular
file, on Linux and macOS. The rest is from the code. In both
states git.worktree_create runs no listing in a folder where the trust check
finds no repository. git.worktree_remove runs the listing there and does not read
its answer. Both keep the "no repository" column. In the second state git.info
and git.list_branches run no listing either, and they keep that column. With a
daemon GIT_COMMON_DIR those two methods skip the trust check, and the listing
classes below answer. In both states the listing of git.status still carries
GIT_DIR=<null>. A listing that fails there is the refusal of git.status, as in
a plain folder.
When the configuration listing fails, 89cb6289 sorts the failure into a class.
The classes come in this order (Linux, macOS and Windows VMs):
- No
gitonPATH: "no repository", and no process starts. Linux, macOS and Windows VMs measuredgit.info(row L13). A Linux VM measured the other methods (rows L13xa to L13xd, L13t and L13u to L13z).git.list_branches,git.statusandgit.worktree_createanswer their "no repository" shapes. The create answers so with and withoutworktreeRoot.git.worktree_removewithoutworktreeRootanswers{"success":true,"branchKept":true}for a gone worktree with abranchName, and the lock-check refusal for a worktree directory that exists. WithworktreeRoot, both references answer the work-tree refusal with the exec error alone in nine rows:exec: "git": executable file not found in $PATH. In each rowbaseRepoexists. Rows L13xc and L13xd are a repository with the worktree directory present and gone. Rows L13u and L13v are a regular file and a repository folder of mode - Rows L13y and L13z are a
.gitfile to nowhere and a plain folder. Row L13t is a git directory that the trust check refuses, so this answer comes before the trust check. Rows LNKb and LNKr are chains of 45 symlinks to a repository. In claustrum a refused daemonGIT_CONFIG_COUNTanswers before the exec error whenstatreadsbaseRepo. For the chains of rows LNKb and LNKr, claustrum runs no count check. Neither order is measured. AbaseRepothat does not exist gets no work-tree refusal (rows L13wa to L13wr, seegit.worktree_remove). Not measured: a daemon-created worktree underworktreeRoot, andcommondircontents other thanx. Rows L13xa and L13xb ran one repository layout only. - The listing exits 128, and its stderr starts with
fatal: not a git repositoryin every measured row. Leading white space is dropped first, and letter case does not count (rows L14a, L14b, L14d, L14e and N01 to N04). The answer is "no repository". With abranchName,git.worktree_removewithoutworktreeRootruns a second listing for the branch step and keeps the branch. A gone worktree then answers{"success":true,"branchKept":true}. For a worktree directory that exists, claustrum answers the lock-check refusal. Not measured. Exit 1 with that text is not this class (row L14c). For a.gitfile to a gone git dir, git 2.56 saysgitfile does not point to a valid repository. A gone entry under.git/worktreesanswers "no repository" on each method butgit.worktree_remove(cells c01 to c04, c26 and c27). Another gone git dir answers the error of the call about the repository in the folder that holds.git(cells c09 to c12 and c18). Roundstask039-01totask039-04ran these on Linux and Windows VMs. With"shadowDirOff":truethat git dir gets the hooks refusal on every method (roundstask039-02totask039-04, Linux VM). - Any other failure, a failed start included. The daemon runs
git versionwith no-coption. It runs in/on Linux and macOS, and in the daemon's working directory on Windows. In claustrum its environment is the one of the failed listing without theGIT_COMMON_DIRpin and withoutLC_ALL=CandLANGUAGE=C. The call also gets noGIT_CONFIG_COUNT,GIT_CONFIG_KEY_<n>orGIT_CONFIG_VALUE_<n>of the daemon. The call logs of the A rows show that on Linux and macOS VMs. Those of the P3 and P11 cells show it on a Windows VM. It gets noGIT_CONFIG_GLOBALof the daemon either (cell C-c on Linux and macOS VMs, cells Wb on a Windows VM). No log shows aGIT_CONFIG_SYSTEMor aGIT_CONFIG_NOSYSTEMof the daemon. claustrum passes both on (not measured). claustrum matches these names by their exact upper-case letters. On Windows a lower-case name, asgit_config_global, stays on the call (from the code, not measured). If that call fails too, the text isgit cannot run on this host; git not run: <exec error>: <git text>(rows L11, L12 and L15). Else it is the hooks refusal (rows L10 and L14c). WithworktreeRoot, when git cannot start inbaseRepo,git versiongets the light environment (rows L16a and L16b, Linux VM). With"shadowDirOff":true, thegit versioncall of5fd08069equals the call of claustrum after a failed first listing. It has the six heavy values forgit.infoand the four light values forgit.worktree_create. Cell A5 of roundval-s3p2b1shows that on Linux, macOS and Windows VMs, with a syntax error in the config of the repository. Cell A7 shows it on a Linux VM, also forgit.list_branches, with a git that exits 128 in the listing. Without the member the call of a create carries the six values of a create call on5fd08069(cell A7, Linux VM). From the code: claustrum sends those six in the stateshadow.
On Linux and macOS, git sometimes cannot start in the directory itself. That is a
file, or a folder that the daemon cannot search (mode 0600 or 0000). The listing
fails at the chdir. That is no hostile configuration, and the method goes on.
89cb6289 still runs git version there, and claustrum does too. The frames do not
change (rows L16a, L16b, L16d and L16f). For a regular file, cells CeP0 and CeP3 show
the "no repository" frames on Linux and macOS VMs, with and without an empty
GIT_CONFIG_KEY_0. The cells are git.info, git.list_branches, git.status and
git.worktree_create. For a folder of mode 0000, cell C-h in phase P3 shows the
same frames for the last three methods on a Linux VM. The folder is a plain folder
in one run and a repository in the other.
If that git version fails too, the method answers git cannot run on this host;
git not run: <exec error>: <git text> with the detail of the version call.
89cb6289 answers so in these rows and cells:
git.infoon a regular file and on a folder of mode 0000 (rows A-M2 and A-X1 in phase P11, Linux and macOS VMs).git.list_branches,git.statusandgit.worktree_createon a regular file. The create has the text inerror, with and withoutworktreeRoot(cell C-g, Linux and macOS VMs).- The same three methods on a folder of mode 0000, a plain folder and a repository (cell C-h in phase P11, Linux VM).
A path that does not exist keeps its frames there (rows A-M1 and A-M3 to A-M5 in
P11, Linux and macOS VMs). In both phases of cell C-h, git.worktree_remove without
worktreeRoot answers failed to remove worktree: open <baseRepo>: permission
denied (Linux VM). Not measured there: git.worktree_remove on a regular file,
and cell C-h on macOS.
On Windows a git version that fails is measured in these cells. On a plain folder
they are git.info, git.list_branches, git.status and git.worktree_create
(cells P11-infoN, Wa-lbN, Wa-statN and Wa-crN). On a repository and on a regular
file the cells are git.info (cells P11-infoT, A-14 and Wa-infoF). All of them
answer the "cannot run" text. git.info on a path that does not exist keeps its
frame (cell Wa-infoM).
On Windows a path that exists and that git cannot start in is a refusal by class 3.
The start error of the listing is fork/exec <git.exe path>: The directory name is
invalid.. <text> is the hooks refusal with that error, and <git.exe path> is
the git that PATH resolves. 89cb6289 answers these cells on a Windows VM:
| cell | request | frame of 89cb6289 and claustrum |
|---|---|---|
| A-01, A-02 | git.info, path is a regular file in a plain folder or in a repository |
{"error":{"code":-32603,"message":<text>}} |
| A-10 | git.info, path is a file symlink |
the same |
| Wc-infoNUL, Wc-infoDot | git.info, path is NUL, or the name of a regular file with one more dot at its end |
the same |
| A-13 | git.info, path is a folder with a full path of 296 characters |
the same |
| A-03 | git.list_branches, path is a regular file |
the same |
| A-04, A-04b2 | git.status, baseRepo is a regular file, and path is that file or a repository |
the same |
| A-06 | git.worktree_create, baseRepo is a regular file |
{"success":false,"error":<text>,"errorCode":"worktree_add_failed"} |
| A-14 | the request of A-01, with a git version that exits 1 and writes boom |
{"error":{"code":-32603,"message":"git cannot run on this host; git not run: exit status 1: boom"}} |
Nothing changes on disk in these cells. Other frames stay as they are on a Windows
VM. git.status with the file as path and a repository as baseRepo answers
{"isRepo":false,"clean":false} (cell A-04b1). git.worktree_remove with a
regular-file baseRepo answers failed to remove worktree: open <baseRepo>: not a
directory (cell A-05). It answers the same with an empty GIT_CONFIG_KEY_0 (cell
We-P3-rmF). git.info answers
{"isRepo":false,"repoSlug":"","defaultBranch":""} in three more cells. The first
is a path that does not exist (cell A-07m). The second is a path under a regular
file (cell A-07s). The third is a junction whose target is gone (cell A-11).
With GIT_CONFIG_COUNT=x the count refusal answers the regular file (cell A-09).
From the code: if the listing does not start in a path, and stat reads the path
as not a folder, claustrum refuses. In cell A-13 claustrum
reads the folder as one that holds no repository, and the listing does not start
there.
One folder has an access entry that denies read data and traverse to the user of
the daemon. It keeps the git.info frame of a plain folder (cells A-12 and A-12r).
With a daemon token that has no privilege, 89cb6289 and claustrum refuse there.
The text is the
hooks refusal with exit status 128: fatal: unable to access 'NUL/config':
Permission denied (cell A-12n). The folder beside it with no deny entry keeps its
frame under that token (cell A-12nc). Two paths are not measured on Windows: a path
on a drive that does not exist, and a UNC path.
Some points are not measured. claustrum tests the class-2 text as a prefix, not as a
substring. Exit codes other than 1 and 128 are not measured. git version does not
get the daemon's own LC_ALL and LANGUAGE back. On Windows the daemon's working
directory and its socket folder were the same folder, so the two are not told apart.
The "cannot run" text carries the exec error and the stderr of git version, not
those of the listing. Row A-T1 shows it on Linux and macOS VMs, and cells P11-infoN
and P11-infoT on a Windows VM. There the listing exits 128 on an empty
GIT_CONFIG_KEY_0, and a stub git version exits 1. 89cb6289 answers git cannot
run on this host; git not run: exit status 1: <stderr of git version>. The
plain-folder rows A-N1 to A-N4 and A-S1 carry the same text on Linux and macOS VMs.
So do the rows of git.worktree_remove with worktreeRoot there: rows A-N5b and
A-N5c, and cell C-d with the worktree folder present and gone. Row A-N5 carries it
on the Linux VM only. Cells Wa-lbN, Wa-statN and Wa-crN show the plain folder on a
Windows VM.
One stderr line was measured. claustrum makes <git text> with the text rule below.
From the code: each listing of a git method that answers this text takes it from
the version call. Three such listings have no row with a git version that fails.
Two are the later listings of git.status: the one in the common directory and
the one in the temporary folder. The third is the listing of git.worktree_remove
with worktreeRoot for a baseRepo that git cannot start in.
The hooks refusal is config-defined hooks could not be pinned off; git not run:
listing the configuration in force: <exec error>: <git text>. claustrum makes <git text>
with the text rule of git.worktree_create. The rule takes git's raw stderr, keeps
its first 512 bytes, drops invalid UTF-8, turns each non-printable rune into one
space, and then trims spaces. If nothing is left, the text is the error of the git
process, for example exit status 128. A stub git on a Windows VM measured this frame against
f6010b97. The payloads were multi-line, CRLF, over 512 bytes, invalid UTF-8 and
control bytes. 40 newlines and 40 spaces before a long text showed that the cap
comes before the trim. The leading white space counts toward the 512 bytes. A tab
becomes one space on 90fca6e6 too (Linux VM). With the GIT_COMMON_DIR pin, git
names a corrupt config by its absolute path. f6010b97 does the same in the
git.info, git.list_branches, git.status, create and remove frames, measured on
Linux and macOS VMs. 90fca6e6 names .git/config. On claustrum a syntax error in
a config file of the repository now gets the class 1 text first: see
Repository config refusals.
A FIFO where the daemon reads a git file¶
This section covers three reads of the daemon itself: a .git file, the commondir
file of a worktree entry and the gitdir record of a worktree entry. On Linux and
macOS it opens each of them without blocking. It reads a regular file only, and
it reads that file as before. Anything else there, a FIFO for example, is no usable
file, and the read does not wait. Windows has no FIFO in the file system, and its reads
are as before.
The rows ran side by side against 89cb6289 on Linux and macOS VMs. No row opened
a FIFO for writing. 89cb6289 answered every row except B-G1 in under 2 s. T is a
repository, and T/a is a folder in it.
| Row | State | Request | Answer |
|---|---|---|---|
| B-F1 | T/a/.git is a FIFO |
create with baseRepo T/a |
success. The entry and the branch are made in T |
| B-S2 | T/a/.git is a symlink to a FIFO |
the same create | as B-F1 |
| B-F4 | T lies inside an outer repository O, and T/.git is a FIFO |
create with baseRepo T |
success. The entry and the branch are made in O |
| B-L1 | as B-F1 | the create of B-F1, then a create in T with another leaf | both succeed |
| B-F5 | T/.git is a FIFO, and no repository lies above T |
create with baseRepo T |
not_a_repo, and nothing is made |
| B-S1 | T/a/.git is a regular file of 0 bytes |
create with baseRepo T/a |
not_a_repo, and nothing is made |
| B-F2 | as B-F1 | git.info and git.list_branches with path T/a |
the answers of T |
| B-F2 | as B-F1 | git.status with path T/a and baseRepo T |
{"isRepo":false,"clean":false} |
| B-F3 | a worktree of T under T/a, then T/a/.git becomes a FIFO |
remove with baseRepo T/a |
{"success":true}. The folder, the entry and the branch go |
| B-G2 | the gitdir record of another entry is a FIFO |
remove of a real worktree of T | {"success":true}. The other entry stays |
| B-G3 | the commondir file of the entry of the worktree is a FIFO |
remove of that worktree, without worktreeRoot |
{"success":true}. The folder, the entry and the branch go |
| B-G4 | the commondir file of the entry of a linked baseRepo is a FIFO |
create | the trust refusal with is not a small plain file (commondir is not a regular file) |
| B-G1 | the gitdir record of another entry is a FIFO |
create with baseRepo T |
no answer in 40 s |
From the code: in row B-G3 claustrum does not read the commondir, so it does not
verify the entry.
Row B-G3 ran without worktreeRoot. Cell N2 is that state with worktreeRoot, on
Linux and macOS VMs. 89cb6289 and claustrum give no answer in 40 s there, and
nothing is deleted. Each waits in its child git worktree list --porcelain -z.
The cells below ran side by side against 89cb6289 on a Linux VM, and in part on a
macOS VM. The Answer column is the frame and the disk state of 89cb6289. In each
cell the frame and the files on disk of claustrum are equal to it.
| Cell | State | Request | Answer |
|---|---|---|---|
| N1 | the gitdir record of the entry of the worktree is a FIFO |
remove of that worktree, without worktreeRoot |
{"success":true}. The folder and the branch go, and the entry stays (Linux and macOS) |
| N9 | as N1, and the worktree is locked | the same remove | {"success":true}. The folder and the branch go, and the entry and its locked file stay (Linux and macOS) |
| N10 | the worktree is locked, and the commondir file of its entry is a FIFO |
the same remove | the locked refusal, and nothing is deleted (Linux and macOS) |
| N12 | a second entry names the same worktree, and its gitdir record is a FIFO |
the same remove | {"success":true}. The folder, the first entry and the branch go, and the second entry stays (Linux and macOS) |
| N3 | the .git file of T names its git directory and is padded with newlines to 2 MiB |
git.info, git.status and create in T |
isRepo false, and not_a_repo for the create (Linux and macOS) |
| N4 | the commondir file of the entry of the worktree is a regular file of 2 MiB |
remove of that worktree | {"success":true}. The folder, the entry and the branch go (Linux and macOS) |
| N5 | T/a/.git is a socket |
git.info and create with T/a |
the answers of T, and the create succeeds (Linux) |
| N5b | T/.git is a socket, and no repository lies above T |
git.info and create with T |
isRepo false, and not_a_repo (Linux) |
| N6 | T/a/.git is a symlink to /dev/null |
git.info and create with T/a |
as N5 (Linux and macOS) |
| N6b | T/.git is a symlink to /dev/null, and no repository lies above T |
git.info and create with T |
as N5b (Linux and macOS) |
In row B-G1 89cb6289 waits in its child git worktree add. A plain git worktree
add on that fixture waits too. From the code and a unit test: claustrum passes over
the FIFO record and starts the same call. claustrum adds no bound of its own there.
With timeoutMs 3000 in the state of row B-G1, 89cb6289 and claustrum give no
answer in 40 s (cell N7, Linux VM). From the code: the opt-in git-timeout (D5)
ends that call.
The two runs sent 52 requests on the Linux VM and 37 on the macOS VM. In every
request the frame and the files on disk of claustrum are equal to 89cb6289. The
git call count differs in 17 requests on Linux and in 11 on macOS. There claustrum
runs fewer calls, and no call that 89cb6289 does not run. No client can observe
the count.
Not measured:
- A git that refuses a FIFO named
.gititself. The rows ran with a git that walks past it. - A FIFO as
HEAD,indexor a ref file in a place that git itself reads. A FIFO as the config is in Repository config refusals. - A device file or a socket as a
commondirfile or agitdirrecord. From the code: claustrum takes each as no usable file. - A FIFO that a writer opens during the request. From the code: claustrum reads nothing from it.
- The
commondirfile of the git directory ofbaseRepois a FIFO, in the read ofgit.worktree_createaftergit worktree add. From the code: claustrum takes that git directory as its own common directory. No request was built for this state. A git 2.47 gives no answer in 5 s torev-parse --git-dirthere (Linux host, not a VM).
Repository config refusals¶
Since 5fd08069 the daemon reads the config of a repository itself. If it does not
accept the config, or a file of the git directory, the request gets a
refusal. claustrum follows that build here. In the state shadow it makes the read
after the start test and after the call about the repository (see
The settings copy). In the state fallback-git it makes no
read and gives no such refusal, as 5fd08069 (cell O4 of round s3p2-oldgit, Linux
VM). The rules below ran on a Linux VM. A rule for
one system names that system.
The five methods are git.info, git.status, git.list_branches,
git.worktree_create and git.worktree_remove. <text> is <class>: <detail>.
| Method | Answer |
|---|---|
git.info, git.status, git.list_branches |
{"jsonrpc":"2.0","id":N,"error":{"code":-32603,"message":"<text>"}} |
git.worktree_create |
{"result":{"success":false,"error":"<text>","errorCode":"worktree_add_failed"}}. Nothing is made |
git.worktree_remove |
{"result":{"success":false,"error":"failed to remove worktree: could not check whether <worktreePath> is locked: <text>"}}. The worktree stays, and no branch is deleted |
git.worktree_remove with worktreeRoot |
{"result":{"success":false,"error":"failed to remove worktree: cannot determine the repository's work tree: <text>"}}. Nothing is removed |
Four states ran on all five methods (cells W7, X1 and V9). They are format version
2, a config of 20 MiB, an info/exclude of 20 MiB and a shallow file with a
text line. Most other cells ran git.info and git.status.
On git.worktree_remove the refusal comes for a worktree folder that does not
exist too, and the branch branchName stays. 5fd08069 answers so on a Windows
VM (cells F1a, F1b, F2a, F2b, G1a to G1c, W6, W6x and X12). It answers so on
Linux and macOS VMs too (cells N1). The N1 states are format version 2, a config
of 20 MiB and an info/exclude of 20 MiB. Linux and Windows VMs ran them in a
bare repository too. With the two files of 20 MiB, 89cb6289 answers success
there and deletes that branch.
With worktreeRoot, 5fd08069 answers the text of the last table row (cells N14,
Linux and macOS VMs). The states were format version 2 and a config of 20 MiB.
Each ran with a worktree that exists and with a folder that is missing. With the
config of 20 MiB, 89cb6289 removes the worktree folder and its registration
there. In cells P4 (macOS VM) the worktree exists. An info/exclude of 20 MiB gets
that frame with its class 2 text. A syntax error in the config gets it with the
class 1 text of the syntax error. Nothing is removed in the two cells.
A path in quotes is the path of the file with the separators of the system, quoted
as Go's %q quotes it. A path of more than 300 characters is cut to its first 300
characters. The text has no mark of the cut (cells X12 and V12, also with
characters of two bytes). On Windows each backslash is doubled inside the quotes,
and the JSON encoding doubles it again. The path of the open <file>: text is not
in quotes, and it is not cut (cell V12, a path of 400 bytes). The <worktreePath>
of the remove text is as sent, and it is not cut.
On Linux and macOS VMs the path of the config is the resolved path. A request through a symbolic link gets the real path in the text (cells V11). On a Windows VM a request through a junction keeps the spelling of the junction. A request in another letter case gets the real spelling (cells V11).
The switch. "shadowDirOff": true in the request turns the read off for that
request, on the five methods (cells X1 and V2). The request then gets the answer
that it had before this rule. A FIFO as the config then gets no answer in 10 s. The
member name matches without regard to letter case: "SHADOWDIROFF": true is the
switch too. The values "true", 1, null and false are as no member. Cells V3
measured both facts on git.info and git.status, with format version 2. Cells
V2 and V3 ran on Linux, macOS and Windows VMs, and the FIFO on Linux and macOS VMs.
With worktreeRoot, the switch and a config of 20 MiB, 5fd08069 removes the
worktree (cell P4, macOS VM).
The read. claustrum gives each config file to git on standard input, with one
call for each file: git config --no-includes --file - --list -z. The call log of
5fd08069 shows that call. git follows no include in it. claustrum follows each
include.path setting itself. If the request names a linked worktree, claustrum
reads the config of the repository of that worktree.
From the code: claustrum runs that call in the root directory on Linux and macOS,
and in the working directory of the daemon on Windows. The environment of the call
is that of the daemon without GIT_CONFIG, GIT_CONFIG_PARAMETERS, LC_ALL and
LANGUAGE. Then come GIT_DIR=<null device>, LC_ALL=C and LANGUAGE=C. On Linux and macOS claustrum opens each file
of the read with the flag that gives the daemon no controlling terminal. In cell
V17 the config is a link to a pty slave. If the pty master closes, 89cb6289 and
5fd08069 stay up (Linux VM).
The kept verdict. On 5fd08069 a second git.info on a repository with an
accepted config makes no such call (cells B4 and V19). A request after a change of
the config makes one (cell B4). With a refused format version, a later request of
5fd08069 makes the read again (cells N10 and P5, Linux, macOS and Windows VMs).
claustrum keeps the verdict of a read that accepted the config, for 256 git
directories at most. A verdict with a refusal is not used again: each later request
reads again, as on 5fd08069. If each file that an accepted read used is
unchanged, a later request starts no such call. A file is unchanged if
its size, its modification time and its mode are the same, and its identity where
the system gives one. A file that was missing must still be missing. This rule and
the number 256 are claustrum's own. Cell B4 ran one repeat and one changed config.
The tests of shallow, info/attributes and info/exclude run in each request
and are no part of the kept read. On 5fd08069 a change of one of those files
gives no config read (cells 19a to 19c of round val-s3p2b2-3, Linux VM).
From the code: the identity test is Go's os.SameFile. On Linux and macOS that is
the device and the inode of the file. This page claims no identity test on Windows.
These tests do not see these changes:
- A change that keeps the size, the time, the mode and the identity. One example is a rewrite in place with the same size inside one step of the file time.
- A change during the read, after the daemon recorded the state of that file.
- A change of a file whose path the daemon cannot examine. Such a path has the state of a missing file.
claustrum does not keep a verdict if an open or a read of the daemon ended with an
error in that read. So a refusal with an open <file>: text is read again at the
next request. An error of the daemon itself, such as too many open files, then
answers one request only. That rule is claustrum's own.
Class 1 is this repository's git config cannot be used: <detail>. The format
version and the extensions of an included file are not tested (cells X7 and X9).
| Detail | When |
|---|---|
the repository uses format version "<v>", which this version of Claude does not support |
The last value of core.repositoryformatversion, as git lists it, is not exactly the text 0 or 1. A config with no such key is not refused. <v> is cut to its first 20 characters, with no mark. A value of 20 characters is whole, and one of 21 shows 20 (cells P1, Linux, macOS and Windows VMs) |
the repository uses git extension "<name>", or a value of it, that this version of Claude does not support |
The format version is 1, and an extensions.<name> setting is refused. <name> is in lower case. It is cut to its first 60 characters, with no mark. A name of 60 characters is whole, and one of 61 shows 60 (cells P1, Linux, macOS and Windows VMs). Under version 0 none of the settings that ran is refused |
"<file>" includes "<value>", and Claude expands neither ~user nor %(prefix), nor ~ where no home directory is set; write that path out in full |
An include value of the form ~user/… or %(prefix)/…, in an include section or in an includeIf section. The condition of the includeIf section does not have to match (cells N12, Linux VM). <file> is the file that holds the setting. <value> is cut to its first 100 characters (cells V12, Linux and macOS VMs) |
its includes go past what Claude follows (16 files, nested 3 deep) |
More than 16 included files, or an include chain of more than 3 levels below the config. A missing file counts, and so does the file of an includeIf section, also a missing one (cells V8 and N7). A file that includes itself gets this text too |
"<file>" is not a regular file |
The config or an included file is a FIFO or a directory. A config that is a symbolic link to the null device gets it. So does an included file that is a symbolic link to a FIFO. include.path = ~ gets it with the path of the home folder (cell N13, Linux VM). The daemon does not wait on a FIFO |
"<file>" is larger than 8 MiB |
The config or an included file has more than 8388608 bytes (cells X6 and V7) |
"<file>" has a setting in section "<section>" whose name is longer than 1024 bytes |
The full name of a setting, with its section and subsection, has more than 1024 bytes. The pair 1024 and 1025 is measured in core, remote, lfs, url, filter, extensions, include and includeif. Cells N6 measured it in index, feature and checkout too (Linux, macOS and Windows VMs). <section> is in lower case, without the subsection. The bound holds in an included file, with the path of that file. Cells V7 measured that one level below the config, and cells N8 two and three levels below it |
"<file>" has more settings than Claude reads (8 MiB); remove repeated ones |
See "The count of settings" below |
open <file>: <reason> |
The daemon cannot open the config or an included file. A file of mode 000 gives permission denied. A socket gives no such device or address on a Linux VM. As the config, a socket gives operation not supported on socket on a macOS VM |
fatal: bad config line <n> in "<file>" |
The config or an included file has a syntax error. So does the config.worktree file of the git directory with extensions.worktreeConfig on (cell C3b of round tier0-14, Linux VM). It comes before an extension refusal (cell E3 of round tier0-16, Linux VM). The text is that of git, with the path in quotes |
"<included file>" is outside "<repo>", and on Windows Claude reads a repository's settings only from inside it; if the line that includes it is yours and you trust the file, put that line into ~/.gitconfig on the remote machine instead |
Windows: a relative include that leaves the repository. For a bare repository <repo> is its git directory (cell W6) |
"<file>" includes "<value>", and on Windows Claude follows an include only if its path is relative and stays inside the repository; if that line is yours and you trust the file, put the line into ~/.gitconfig on the remote machine instead |
Windows: an absolute include value, also to a missing file, ~/x and ~root/x. An absolute value and ~/x get it in an includeIf section whose condition does not match too (cells N12w). <value> is cut to its first 300 characters. A value of 300 characters is whole, and one of 301 shows 300 (cells P7w, Windows VM) |
"<file>": is, or is reached through, a link that Claude does not follow here; replace the link with what it points to |
Windows: the config is a file link. Windows, every git method that reads the config: the git directory is a junction or a directory symbolic link, and <file> is <git dir>\config. |
The path of an included file is the folder of the file that holds the setting, one
separator, and the value as written. On a Windows VM path = <dir>/inc.cfg gives
<git dir>\<dir>/inc.cfg in a text (cells V12c and V12d). The folder of the
holding file stays as written too. With include.path = a/../b/inc.cfg, a
path = x.cfg in that file names <git dir>/a/../b/x.cfg (cell N13-b, Linux and
macOS VMs).
Under format version 1 claustrum refuses an extension other than
worktreeConfig when the copied settings file has
no rule for its key or does not accept its value. 5fd08069 refuses
preciousObjects = 1234567890, objectFormat = sha512, refStorage with no
value and partialClone = a/-b (cells R1 to R4 of round tier0-14, Linux VM).
Cells V6 measured these settings as not refused: worktreeConfig = maybe, objectFormat = SHA256, refStorage = FILES,
preciousObjects = yes and preciousObjects = 1. An extension in a config with no
format version key is not refused either. Cells V6 ran on Linux, macOS and Windows
VMs. This rule does not refuse a sha256 repository or a reftable repository.
No refusal comes from these states of the config: a missing file, an empty file
and a dangling symbolic link. A symbolic link to a regular file and a NUL byte in a
value are no refusal either. The same holds for an empty include.path and one
with no value. On Linux the daemon follows a relative include, also one that
leaves the git directory, an absolute include and ~/x with a home folder.
Two kinds of file get the kind test and the size test, with their own path in the
text. A format version 2 in such a file is not refused. No other content of it
ran, and claustrum does not read it.
- The file of an
includeIfsection, whatever its condition is. The conditions that ran aregitdir:that matches,gitdir:that does not match,gitdir/i:,onbranch:andhasconfig:. They ran on Linux, macOS and Windows VMs (cells X9 and V8). - The
config.worktreefile of a linked worktree. The config setsextensions.worktreeConfigtotrue,yes,1orTRUE, or has that key with no value (cells X2 and V14).onandONstart the test too, andfalse,noand0do not (cells N4, Linux, macOS and Windows VMs).Yes,OnandtRuEstart it too (cells P2, the same three systems). The trust check of the request finds that linked worktree. Ongit.statusthat is the worktree thatbaseReponames. Cells V14 rangit.info,git.status,git.list_branchesandgit.worktree_create(Linux, macOS and Windows VMs). On the macOS VM the values other thantruerangit.info. Cell W14 rangit.worktree_removeon a Linux VM.
The count of settings. Each setting of a counted section costs the bytes of its
full name, the bytes of its value, and 96. The config has its own sum, and the
included files have one sum between them. The file in which a sum passes 8388608
is refused. Cells X5 found five counted sections: core,
remote, extensions, include and branch. Cells V5 found seven more: lfs,
index, url, filter, feature, checkout and includeif. The sections x and user do not count. This
arithmetic is fitted to the measured bounds. 5fd08069 states no
such rule. The exact pairs are in core and remote. For each other counted
section one cell of 200000 lines was refused (cells X5 and V5). An included file
of 200000 core.filemode = true lines is refused with its own path (cells V7).
Cells N5 ran files of 40000 core.filemode = true lines on Linux, macOS and
Windows VMs. One such included file passes. Two are refused, and the text names
the second one. The config with 40000 such lines and one such included file
passes. Cells P3 ran files of 30000 such lines on the same three systems. Two
included files pass, and three are refused. A file that includes a second one
passes. A chain of three, one below the other, is refused. In the two refused
cells the text names the third file.
Class 2 is a file in this repository's .git directory cannot be used: <detail>.
The files are shallow, info/attributes and info/exclude, and the git
directory itself.
| Detail | When |
|---|---|
"<file>" is not a regular file |
info/attributes or info/exclude is a FIFO or a directory. A symbolic link there passes, also one to the null device. shallow is a FIFO, a directory or a symbolic link to the null device. The daemon does not wait on a FIFO |
"<file>": it is larger than 4 MiB |
info/attributes or info/exclude has more than 4194304 bytes |
"<file>" is larger than 4 MiB |
shallow has more than 4194304 bytes |
open <git dir>: not a directory |
On Windows a daemon GIT_DIR=.git names the .git file of a linked worktree, on the five methods (cells c19 to c22 of round task039-01, V-RM of round task039-02, Windows VM) |
open <file>: <reason> |
shallow has mode 000 (permission denied) or is a socket (no such device or address, cell V10, Linux VM). The path is not in quotes. An info/attributes or info/exclude of mode 000 is not refused |
"<file>" holds something other than object ids |
A line of shallow is not 40 or 64 hex characters in lower case. That rule is fitted to the cells. Refused are a text line, an id in upper case, and a blank line before or after an id. So are a line of 39, 41 or 63 hex characters and an id with a space after it. A file that holds one line end is refused too (cells V10). A line end of CRLF and a last line with no newline pass. An empty file passes |
Two refused files. A refused config comes before a refused file of the git
directory. In class 2, shallow comes before info/attributes (cells V16, Linux,
macOS and Windows VMs). info/attributes comes before info/exclude. Cells P6
ran that pair four ways on a Linux VM: a file of 20 MiB with a FIFO, two files of
20 MiB, two FIFOs, and a FIFO with a directory. Two more P6 cells have a refused
shallow with a FIFO as one of the two other files, and the text names shallow.
No refusal. A folder with no repository keeps its answers. 5fd08069 keeps
the "not a repository" answers for a HEAD that is no ref and no object id, also
with format version 2 (cells X3). It keeps them for a repository of another
owner with no safe.directory too. Cells V1 ran that owner on Linux, macOS and
Windows VMs. The refused states were format version 2, a config of 20 MiB and an
info/exclude of 20 MiB. A FIFO as the config ran on Linux and macOS VMs. Which call of that build
decides is not measured.
In the first state claustrum finds no repository, so it reads nothing. The second
state is decided by the call about the repository, on 5fd08069 and on claustrum.
That call comes before the read. If git answers it with exit 128 and a text, the
method gives its "not a repository" answer and reads no config. git's text for
another owner is detected dubious ownership in repository. In cells X9 of round
val-s3p2b2-5 a repository of another owner has format version 3 or a syntax
error. Each request of 5fd08069 there is that one call, and a remove makes it two
times (Linux and macOS VMs). An answer of git that names a problem is not kept. So
a new safe.directory line counts at the next request, as on 5fd08069 (cell
N15-a, Linux and macOS VMs). A good answer is kept 5 seconds.
The settings copy has that call.
claustrum asks git no question about the owner beyond that call. With
"shadowDirOff":true and in the state fallback-git it makes no read, so no
refusal comes there.
Where claustrum differs from 5fd08069. A line with "from the code" ran on no
VM. Rounds val-s3p2b2-3 and val-s3p2b2-5 ran a build with the settings copy.
- On git 2.11.4 and 2.12.5,
5fd08069gives no refusal in the four fixtures of cells V18 (Linux VM). The fixtures are format version2, a config of 20 MiB, a FIFO as the config and aninfo/excludeof 20 MiB. Git 2.13.7 is the oldest git that ran with the refusal. Those two gits give the statefallback-git. From the code: claustrum makes no read in that state. No claustrum build with that state ran on those gits. - On a Windows VM, a config of 20 MiB at a config path of 262 characters and more
gets another answer (cells P6w, P6x, X12 and V12a).
5fd08069answers the size text up to 261 characters. From 262 to 266 characters it answers "not a repository". From 268 characters on it answersgit could not check this repository on the remote machine: at "<path>": <text>.<text>is the error text of git, or of its start:fork/exec <git.exe path>: The directory name is invalid.A claustrum build before the call about the repository answers the size text at each length that ran, also in the remove frame. No build with that call ran these cells (not measured). Plain git answers "not a git repository" from 259 to 266 characters. The lengths 267 and 269 did not run. - In some states git itself refuses a value of the config, and
5fd08069gives the answer of a repository, because its copy leaves the value out. A claustrum build with the copy answers as5fd08069in cells C2c, X5b, X9-03 and X9-04 (roundval-s3p2b2-3, Linux VM). It does so in the 48 X8 cells too (roundval-s3p2b2-5, Linux VM). Cell C2c hasextensions.objectformat = md5under version0. Cells X9-03 and X9-04 have aninclude.pathwith an empty value or with none. Four V6 cells of the older rounds did not run on a build with the copy (not measured). Two areworktreeConfig = maybeandobjectFormat = SHA256under version1. One isrefStorage = FILES, and one has no version key andobjectFormat = md5. ForobjectFormat = SHA256thegit.statusof5fd08069answersexit status 128. - After a change of the owner inside the 5 seconds of a kept good answer,
5fd08069and a claustrum build with the copy answer normally. The first request after that time answers "not a repository" (cell 31 of roundval-s3p2b2-5, Linux VM). - A config that changes many times each second. In cell E4 of round
val-s3p2b2-5the config is replaced 50 times each second.5fd08069makes 4 config reads for eachgit.infoand 10 for eachgit.status. claustrum makes 1 for each (Linux VM, one run). The reply lines are equal. - A config with a very long key, or one key 200000 times. On a Windows VM 15 config
read calls of
5fd08069have no end record in cells E1a, E2a and E2b. The same calls of claustrum end with exit 0 (roundsval-s3p2b2-3andval-s3p2b2-5). The reply lines are equal. - A config whose listing by git has more than 64 MiB. If the first 64 MiB show
neither a name over its bound nor too many settings, claustrum gives no
refusal (from the code, not measured on
5fd08069). - In cell X1c (Linux VM) the config is a FIFO. Requests 3 and 4 have
"shadowDirOff":trueand get no answer. After them,5fd08069gives no answer to request 5, agit.list_branches, and to request 7, agit.worktree_create. claustrum answers both with the "not a regular file" text.
claustrum's own choices (from the code, not measured on 5fd08069):
extensions.worktreeConfigstarts theconfig.worktreetest with values that no cell ran, such asoNor007. The last such setting of the config counts.- Under format version
1the value rule refuses extension values that no cell ran, such aspreciousObjects = -1. - The cuts of the format version value and of the extension name count characters. The cells have ASCII text.
- With
extensions.worktreeConfigon, aconfig.worktreefile of the git directory that is not regular or is too large is not used, and gives no refusal. - The name bound and the count of settings are tested on a whole file before an include of that file is followed. That order is fitted to two cells of 200000 include lines of a missing file, which get the "more settings" text.
- The two include limits come before the open of an included file.
- The file of an
includeIfsection is not read. An open error there, and at aconfig.worktreefile, gives no refusal. - An open error other than the measured ones gets the same
open <file>: <reason>form. - A
~/xinclude is joined to the home folder and cleaned. - On Windows a
%(prefix)/…value gets the "expands neither" text, with the cut at 100 characters. - The read runs after the trust check accepted the git directory, and after the
test of the daemon's own
GIT_CONFIG_COUNT. ~/xwith no home folder gets the "expands neither" refusal. No daemon starts with no home folder.- On
git.worktree_removewithworktreeRoot, the read comes after the trust check and before the configuration listing. Cells N14 and P4 have a repository that passes the checks before it. - The listing that the read takes from git for one config file has a bound of 64 MiB. A file with a long subsection name and many keys lists as far more bytes than it has. The read tests the name bound and the count of settings on the first 64 MiB of such a listing. If neither refuses, the read has no verdict, and the request gets no refusal.
- If the read gives no verdict, the method goes on as before. A read gives none
if its git call fails with anything other than a syntax error text. Two cases
are a git that does not start and a call that the
git-timeoutends. A config file that the daemon opens and cannot read gives none too. - A verdict of a read with an open error or a read error is not kept.
- With a daemon
GIT_COMMON_DIR,git.infoandgit.list_branchesread nothing: the trust check pins no git directory there. - A bare repository whose config the daemon refuses, on a host where git refuses
bare repositories (
safe.bareRepository = explicit), gets the refusal. - An
includeIfline in a file three levels below the config gets the "includes go past" text. - On Windows claustrum refuses a link at a config file and at an included file, and a junction or a directory symbolic link at the git directory itself. It tests no other folder above the file. It also refuses an include value that starts with a slash or a drive.
The settings copy¶
Since 5fd08069 the daemon keeps its own copy of the git settings of a repository.
The copy is in a folder git-shadow beside the socket. The git calls of the five git
methods name that copy, so git reads the copy and not the config of the repository.
claustrum follows that build. The five
methods are git.info, git.status, git.list_branches, git.worktree_create and
git.worktree_remove.
The states and settingsControl¶
git.info reports the state of a request in the member settingsControl. It is the
last member of the result. No other method sends it, and an error reply has none
(cell A1 of round s3, Linux, macOS and Windows VMs).
settingsControl |
When | What it changes |
|---|---|---|
| no member | No start test of the daemon gave a state yet | Nothing. A folder with no repository, or a HEAD that is no ref, starts no test (cell E5 of round s3p2, Linux VM) |
"shadow" |
The start test passed | The git calls name the copy. Before them the daemon asks git about the repository |
"fallback-git" |
The git of the host does not work on such a copy. 5fd08069 reports it on git 2.11.4 and 2.12.5 (round s3p2-oldgit, Linux VM) |
The git calls name the git directory. The daemon reads no config of the repository and gives no config refusal. It makes no copy and does not ask git about the repository |
"off-switch" |
The request has "shadowDirOff":true |
For that request the git calls name the git directory. No test runs, no folder is made and no config is read |
- After the test, the "no repository" reply has the member too:
{"isRepo":false,"repoSlug":"","defaultBranch":"","settingsControl":"shadow"}. - A
git.infoon a path that does not exist starts no test. As the first git request of a daemon it makes one call, the excludes read, and its reply has no member. A later one makes none. - With no git in the daemon's
PATH, agit.infoon a repository answers "no repository" with no test (5fd08069, cell C7 of roundtier0-5, Linux VM). Its member follows the daemon's state (not measured). - A
git.infoon a regular file gets the gate of the repository of its folder. Outside any repository it starts no test. As the first git request of a daemon its reply has no member. That is the reply on Linux and macOS. On Windows the reply is the hooks refusal, withfork/exec <git.exe>: The directory name is invalid. - A request with
"shadowDirOff":truegets"off-switch"in three more cases. It is the first request of a daemon. Or its reply is "no repository". Or a failed start test is kept. The cells are A1 and B1 of rounds3and cell 32 of rounds3p2b-edges(Linux VM). - Only the JSON value
truecounts forshadowDirOff."true",1,nullandfalseare as no member (cell B3 of rounds3, Linux VM, and cell S1-seq of roundval-s3p2b2-1, macOS VM). - In the state
fallback-gitthe test runs again on the first request more than 10 minutes after the last test. On5fd08069599.8 s gives no test and 605.5 s gives one (cell 28 of rounds3p2b-edges, Linux VM). In the stateshadowno second test ran up to 720 s. - The daemon logs one
[GitHandler]line at the first decision of the state and one at a change.
From the code: a claustrum server with no run folder has no state. No test runs
there and git.info sends no member. The -serve daemon has the folder of its
socket.
The start test¶
The first request that reaches a repository without the member runs the test. It is
10 git calls in a folder .check-<n> of the daemon, and that folder is gone at the
reply. If the test fails, the request gets this text:
Claude could not test git on the remote machine (<cause>). Check free disk space and quota there, then try again.
git.info, git.status and git.list_branches answer
it as a -32603 error. git.worktree_create answers it in error, with the
errorCode worktree_add_failed. git.worktree_remove answers it after failed
to remove worktree: could not check whether <worktreePath> is locked:, with no
errorCode. 89cb6289 answers those requests normally.
<cause> |
When |
|---|---|
git <verb> failed under "<folder of the daemon>": <error lines of git, space-joined> |
A test call failed. Go's %q quotes the folder |
git <verb> failed under "<folder>": signal: killed |
The daemon ended a test call other than call 7 after 30 seconds (Linux and macOS VMs). On a Windows VM the end is exit status 1 |
the same with (git version <v>) at its end |
The failed or ended call was one of calls 1 to 5 (measured for init and mktree) |
mkdir <path>: <reason> or open <path>: <reason>, with no quotes |
A folder or the lock file of the daemon cannot be made |
A call 6 with exit 1 goes on (cell C3). real at call 6, or a wrong answer at calls 8
to 10, gives fallback-git, as does git's refusal when that call without the copy
works (cells C1, C2, C3b, C4 and C5). At call 10, No space left on device in the
error or an end by a signal fails the test (cells N1 to N3). Two failed calls
name the first (cell C9). All cells: round tier0-5 on 5fd08069 (Linux
VM).
- The daemon keeps a failure for 10 seconds from the end of the failed test. A request in that time gets the kept text with no git call.
- The kept failure holds after its cause is gone. A request with
"shadowDirOff":trueworks in that time. - A test call that a wrapper holds 29 s passes. One held 32 s gives the error at
about 31 s (cell 31 of round
s3p2b-edges, Linux VM). - From the code: call 7, the listing of the test config, waits for its pipes until
one bound and 1 second after the call ended. A call 7 that the bound ended, or whose pipes a
child still holds then, gives the cause
this repository's git config cannot be used: git did not finish reading "<config of the test repository>" within 30 seconds; try again.
The folders¶
The folder of one daemon is <socket folder>/git-shadow/<host form>-<pid>-<16 hex>.
<host form> is the host name with each - written as _. On Linux and macOS
that folder and each folder of a copy have mode 0700, and the files of a copy
have 0600. The daemon makes the folder at
the first request that runs the test. It holds an exclusive lock on the file lock
in it for its whole life. On Windows the hold is an open handle with no sharing.
- At
-stopand atserver.shutdownthe daemon removes its folder. The emptygit-shadowstays (rounds3p2, Linux, macOS and Windows VMs). - If the folder is removed while the daemon runs, the next request makes it again
with another hex part (cell B6 of round
s3, Linux and macOS VMs). - The folder of one repository is
<folder of the daemon>/<32 hex>. The name is the first 32 hex digits of the sha256 of the path of the git directory. A linked worktree uses the folder of its main repository. - That folder holds
HEAD,config, the empty foldersobjectsandrefs, andinfo/excludewhere the repository has it. On Linux and macOS it holds a linkworktreesto theworktreesfolder of the git directory. On Windows it has no such entry. - More copies follow the state of the repository:
shallow,info/attributes,rebase-merge/head-name,rebase-apply/head-nameandBISECT_START. - The daemon makes the folder at the first request that accepts the config of the repository. After a change of a file, a later request writes its copy again.
- In the state
fallback-gitthe short-lived entries of a request are in the folder of the daemon too. The light calls of a create carry six values there (cell Q-S4c of roundval-s3p2b2-3, Windows VM). - One request also keeps short-lived entries in the folder of the daemon:
.gitdir-<n>for agit.status, and.index-<n>and.worktreeinclude-<n>for a create. Also.config-<n>for a create in a repository with aconfig.worktreefile, and.s<n>on Windows. With"shadowDirOff":truethe first three are in the temporary folder, asclaude-ssh-gitdir-<n>,claude-ssh-index-<n>andclaude-ssh-worktreeinclude-<n>.
If the folder of a repository cannot be made, the request gets this text in the three shapes of the start test:
Claude could not prepare its own files on the remote machine: cannot write its copy of the repository's git config: <cause>
The cause is the mkdir error of the folder.
The same request again gets the same line with no git call.
claustrum does not remove the folder of another daemon, and it has no limit on the count of repositories.
The copied settings file¶
The config of the copy is the daemon's own text. Each carried setting is one
block of two lines, [<section>] and <key> = "<value>", in the order of the file
of the repository. The last core.bare of config.worktree follows, then the
[url] blocks of rewritten URLs. The file ends with
[lfs]\n\trepositoryformatversion = 0\n.
gitshadowconfig.gogives 41 keys one rule of accepted values each. Cells C1 to C5 of roundtier0-14measured what they show (Linux VM). The rest is not measured.remote.<name>.url,.lfsurl,.promisorand.partialclonefilter,lfs.<url>.accessandbranch.<name>.remoteare carried too. The length of a rewritten URL is not measured.- Every other key is dropped, such as
core.hooksPathanduser.*. - Outside the version and extension refusals, a value that its rule does not accept is left
out with no refusal. One example is
core.filemode = maybe.89cb6289answers "no repository" or the hooks refusal there (cell A3 of rounds3p2, Linux, macOS and Windows VMs). - Where the copy carries a value that git refuses,
git.infoanswers "no repository" with the member. One example isextensions.objectformat = sha256in a sha1 repository (cell A3 of rounds3p2, the three systems). - A setting of an
[include]file is carried at the place of the include line. Its format version,extensions.*,core.bare(cell C4 of roundtier0-14) andfilter.*(not measured) are dropped. A setting of anincludeIffile is not carried.
The call about the repository¶
In the state shadow the daemon asks git itself about the repository, before the
config read and the calls of the method. The call is rev-parse --git-dir with two
-c items, in the root of the work tree. It names a folder .probe of the daemon
and not the repository. The daemon writes an emptied
.probe/config again (5fd08069, cell C10 of round tier0-5, Linux VM). A good answer is kept 5 seconds from the end of the call.
A change of the root folder or of its .git entry makes the daemon ask again.
| Answer of git | Reply |
|---|---|
| exit 128 with a text, such as git's "dubious ownership" text | The "no repository" answer of each method. No config is read, and no copy is made |
| exit 1, or exit 128 with no text | git could not check this repository on the remote machine: at "<root>": exit status <n> |
| exit 1 or exit 2 with a text | the same text with the text of git in the place of exit status 1. Two lines stand there with one space between them (cells P3 of round val-s3p2b2-7 and cell N-f of round val-s3p2b2-8, Linux VM). |
| another path than the git directory, in two calls | git and Claude disagree about which repository holds this folder: from "<root>" git finds "<path>", Claude finds "<git directory>"; try again. <path> is the path as git printed it: with forward slashes on Windows (table P2 of round val-s3p2b2-7, Windows VM) |
| no answer in 30 seconds | git could not check this repository on the remote machine: it did not finish within 30 seconds in "<root>"; try again |
The three texts have the shapes of the start test. The cells are 37 to 39 of round
s3p2b-edges (Linux VM), where a wrapper answers the call. Cell 40 of that round
has the 30 seconds on macOS and Windows VMs. A bad answer is not kept, so each
request asks again, and a remove asks two times. For a repository of another owner
with no safe.directory, git.worktree_remove answers the lock-check refusal that
ends (its registrations could not be examined); retry.
After a call that exits 1, or exits 128 with no text, one git version call follows. The same
call follows a config read that failed with a syntax error (cells 39 and
x9-syntax-own of round val-s3p2b2-5, Linux VM).
The git calls¶
In the state shadow each hardened call carries GIT_COMMON_DIR=<copy> and
GIT_OBJECT_DIRECTORY=<git directory>/objects. A call with no --git-dir= in its
arguments also carries GIT_DIR and GIT_WORK_TREE. The four come right after the
profile values, in that order (cell B3a of round s3p2, Linux, macOS and Windows
VMs. The order is from Linux and macOS VMs). Hardened git calls
has the other points of a call.
config.worktree¶
The copy has no extensions.worktreeConfig setting, so git reads no
config.worktree file in the state shadow. The daemon does two things in its
place, in a repository with that extension and a config.worktree file.
worktreeConfig values 2 and 10 turn that extension on (cells C3c and K1 of rounds
tier0-14 and tier0-16, Linux VM).
- Each key of the copied settings file but the format version,
extensions.*andcore.baregoes before the option list of the git calls as one-citem, with the value of the copy, sorted by key. Cells C3a oftier0-14and W1 oftier0-16show six keys (Linux VM). Other keys and a repeated key are not measured. A call on a linked worktree gets the items of its own file. - A new worktree of
git.worktree_creategets its ownconfig.worktreefile. See git.worktree_create.
Where claustrum differs from 5fd08069¶
In the differences that stay the reply lines are equal, except in the git-lfs cells of
git.worktree_create and in two Windows rows of a create whose .git becomes
a file.
Hardened git calls¶
The git methods run most git steps as hardened calls. A hardened call carries a fixed
set of -c options and a fixed set of environment variables. This shape reaches a
reply or the disk in at least four places. One is the git.status answer for a
replaced HEAD commit (see git.status). The three others are in
git.worktree_create. Its failure texts have no graft-file hint. A tree path of
270 characters creates on Windows. The records of a new worktree hold absolute
paths.
A logging git wrapper measured
the points below. The option lists and the variables are those of 5fd08069. In
the state shadow the folder variables name the settings copy (see
The settings copy). With "shadowDirOff":true and in the
state fallback-git a call carries GIT_COMMON_DIR=<git directory> alone. See "Not
equal to 5fd08069" below for what differs. Each point names its build and the VMs
that measured it.
- Working directory. Each hardened call runs with the repository directory as its
working directory, and it passes no
-C. The checkout ofgit.worktree_createruns in the new worktree. Thegit statuscall runs in the worktree. Both name their directories with--git-dirand--work-tree. Linux, macOS and Windows VMs. - Profiles. A call uses the light profile or the heavy profile. Each profile has its
own
-coptions and its own variables. The light variables are, in this order,GIT_ALLOW_PROTOCOL=https:ssh,GIT_TERMINAL_PROMPT=0,GIT_NO_REPLACE_OBJECTS=1andGIT_GRAFT_FILE=<null>. The daemon's ownGIT_ALLOW_PROTOCOLchanges the first value (see the next section). The heavy variables are, in this order,GIT_NO_LAZY_FETCH=1,GIT_NO_REPLACE_OBJECTS=1,GIT_GRAFT_FILE=<null>,GIT_ALLOW_PROTOCOL=denied_by_claude_ssh, an emptyGIT_ASKPASSandGIT_TERMINAL_PROMPT=0.5fd08069sends the four and the six in these orders with"shadowDirOff":true(cell B1 of rounds3-linux, Linux VM). Cells A1 and A3 of roundval-s3p2b1show the same two orders with the member on Linux and macOS VMs. The Windows logs of5fd08069are sorted by name. They show the same values and no order (cell B1 of rounds3-windowsand cells A1 and A3 of roundval-s3p2b1, with the member).89cb6289sends four heavy variables: it has noGIT_NO_REPLACE_OBJECTSand noGIT_GRAFT_FILEthere. Without the member, in the statesshadowandfallback-git, the light calls of a create and their listings carry six values. The three listings of the branch step of a remove carry them too. Cell Q-S4c of roundval-s3p2b2-3shows the values in the statefallback-git(Windows VM). The checkout and its listings keep the four light values. Then comesGIT_COMMON_DIRwhen the trust check pins it. In the stateshadowthat is the copy, withGIT_OBJECT_DIRECTORY,GIT_DIRandGIT_WORK_TREEafter it. Then come the hook pins, fromGIT_CONFIG_COUNT: two base pins, and two for each hook name of the listing before the call (see below). The daemon's ownGIT_CONFIG_COUNTmoves them (see the next section). TheGIT_COMMON_DIRpin and the hook pins were measured againstf6010b97on Linux, macOS and Windows VMs. - Heavy calls. The hardened calls of
git.info,git.list_branchesandgit.statususe the heavy profile. So does eachrev-parse --absolute-git-dir, and the--git-dir=<dir> --work-tree=<dir> rev-parse --show-toplevelcall with its listing. Cells B0 and B1 of rounds3-linuxshow that on5fd08069for the three methods (Linux VM). B1 has"shadowDirOff":trueand B0 has no such member. With the member, cell B1 of roundss3-macosands3-windowsshows it forgit.infoandgit.list_branches(macOS and Windows VMs). Cell G4-off-hook of rounds3p2-macosshows it for the calls ofgit.statusand for the pair (macOS VM). Cell B1 of rounds3-windowsshows it for the pair and for thehash-objectandstatuscalls ofgit.status(Windows VM). Without the member, cells B3d and B3e of rounds3p2-linuxshow the origin verify andrev-parse --short HEAD(Linux VM). With the member, cell A2 of roundval-s3p2b1shows both calls with the heavy profile (Linux, macOS and Windows VMs). Cell A6 of that round shows the pair ingit.worktree_remove, with the member (Linux, macOS and Windows VMs). There the registration record names the worktree through a link, and the pair runs three times.89cb6289runsgit.info,git.list_branchesand thatrev-parse --show-toplevelcall with the light profile. The other hardened calls of claustrum use the light profile, except the calls of the branch step, which have their own variables. Of the claustrum calls, only thestatus,ls-filesanddiff-indexcalls ofgit.statusaddGIT_OPTIONAL_LOCKS=0. The checkout addsGIT_INDEX_FILE. - Option lists. The heavy profile has 28
-citems and the light profile has 20, as on5fd08069. Cells B0 and B1 of rounds3-linuxshow both lists (Linux VM). Cell 50 of rounds3p2b-edgesand cell B1 of roundss3-macosands3-windowsshow them on macOS and Windows VMs.89cb6289sends 23 and 13. The heavy list iscore.fsmonitor=false,core.hooksPath=/dev/null,core.attributesFile=/dev/null,core.excludesFile=<file>,core.longpaths=true,protocol.allow=never, the same forext,fd,file,git,ssh,httpandhttps,credential.helper=,core.askPass=,fetch.bundleURI=,core.alternateRefsCommand=,alias.remote-https=,alias.remote-http=,alias.remote-ssh=,alias.remote-denied_by_claude_ssh.command=,help.autocorrect=0,submodule.recurse=false,fetch.recurseSubmodules=false,push.recurseSubmodules=false,gc.auto=0,maintenance.auto=falseandcore.commitGraph=false. The light list iscore.hooksPath=/dev/null,core.fsmonitor=false,branch.autoSetupMerge=false,fetch.bundleURI=,http.saveCookies=false,core.alternateRefsCommand=, the fouralias.remote-items,help.autocorrect=0,core.excludesFile=<file>, the three submodule items,gc.auto=0,maintenance.auto=false,advice.graftFileDeprecated=false,worktree.useRelativePaths=falseandcore.longpaths=true. The checkout addscore.splitIndex=falseandcore.commitGraph=falseafter the light list. Therev-listcall of the branch step addscore.commitGraph=false. - Not equal to
5fd08069. Two sets of rounds compared the calls of claustrum with those of5fd08069, request by request. Roundval-s3p2b1ran the build of the option lists, in its cells A1 to A7 and B1 to B9. Roundsval-s3p2b2-3andval-s3p2b2-5ran a build with the settings copy, without the member. Not each cell ran on each system. The Windows log is sorted by name, so the order of the variables has rows on Linux and macOS VMs only. The first three points say what is equal. The points after them are the differences that stay. The reply lines are equal in each of those, except in the git-lfs cells.- Equal with the member. A call that both builds make is equal in list,
arguments, working directory and variables (Linux, macOS and Windows VMs).
The order of the variables is equal too (Linux and macOS VMs). That holds
for the calls of
existingBranch, ofsourceBranchand of the.worktreeincludestep (cell A3, Linux, macOS and Windows VMs). It holds for a create and a remove withworktreeRoot(cell A4, Linux and macOS VMs). Windows refusesworktreeRootbefore any such call. Roundval-s3p2b1measured this point. The rounds of the settings copy sent no member. - Equal without the member, in the state
shadow. The folder variables, the six values of the light calls of a create, thelfs.storageitem andLANGUAGE=Cof the checkout, and its second listing. Also the ten test calls, the call about the repository and the names of the short-lived entries. In roundval-s3p2b2-5109 requests of theval-s3p2b1cells and 77 requests of newer cells ran with the order of the variables (Linux VM). The calls differ there in two kinds of place: the points below, and the rows that the last edits of the copy follow. On the macOS VM 124 of 177 cells have no call that differs. - Equal in the state
fallback-git: the names of the short-lived entries and the variables (cell Q-S4c of roundval-s3p2b2-5, Windows VM). - Stays: the top folder pair. After
rev-parse --absolute-git-dir, a create of5fd08069that reaches git runs the heavy--git-dir=<dir> --work-tree=<dir> rev-parse --show-toplevelcall with its listing. claustrum runs no such pair there. With the member a plain create has 12 calls on5fd08069and 10 on claustrum. That is cell A3 of roundval-s3p2b1(Linux, macOS and Windows VMs). Without the member it has 13 and 12 (roundval-s3p2b2-5, the same three systems). - Stays, with
worktreeRoot:5fd08069runs one round of six calls before the add, after the add and after the checkout. claustrum runs it before the add only. The round isrev-parse --show-toplevel,rev-parse --absolute-git-dirandworktree list --porcelain -z, each after its listing. That is cell A4 of roundval-s3p2b1(Linux and macOS VMs). Without the member the create has 31 calls on5fd08069and 18 on claustrum (celln3-rootof roundval-s3p2b2-5, Linux VM). - Stays, with
worktreeRoot: a second remove of a worktree that is gone. It has 12 calls on5fd08069and 8 on claustrum. The four more are two--git-dir=<dir>pairs (celln3-rootof roundval-s3p2b2-5, Linux VM). - Stays, with
existingBranch:5fd08069runs two listings in a row before the attach add, and claustrum runs one (cell A3, Linux, macOS and Windows VMs). After a failed attach add,5fd08069runs the-badd with no listing before it. claustrum runs one listing there (cell B2, Linux, macOS and Windows VMs). Both rounds show it. - Stays, without the member: one listing with
--show-scopein each create of claustrum.5fd08069has no such call. - Stays: the read-tree of claustrum carries filter items before its list in some cells with a git filter or with git-lfs.
- The names of the short-lived entries with the member are from the code and
its test:
claude-ssh-gitdir-<n>,claude-ssh-index-<n>andclaude-ssh-worktreeinclude-<n>/patterns.5fd08069has those names in cells A1 and A3 of roundval-s3p2b1(Linux, macOS and Windows VMs).
- Equal with the member. A call that both builds make is equal in list,
arguments, working directory and variables (Linux, macOS and Windows VMs).
The order of the variables is equal too (Linux and macOS VMs). That holds
for the calls of
- Graft hint. With
GIT_GRAFT_FILEset, git can print 8hint:lines aboutinfo/graftson standard error. The light list turns them off withadvice.graftFileDeprecated=false. The heavy list does not. On5fd08069and on claustrum, three calls ofgit.statusprint the hint in a repository with noinfo/graftsfile. They arestatus,diff-indexandrev-parse --verify -q HEAD^{commit}.89cb6289prints no hint there (cell B8 of roundval-s3p2b1, Linux and Windows VMs). With aninfo/graftsfile of one line, the three calls print the hint on each of the three builds (cell B5, Linux and Windows VMs). Thestatus,ls-files,rev-parseanddiff-indexcalls ofgit.statusgive stdout only to the reply. A failure of one of them answers the exec error. So no reply line of cells B5 and B8 holds ahint:line (Linux, macOS and Windows VMs). These cells ran with and without"shadowDirOff":true. - Reply lines against
b027ede, the build before the option lists. Among the requests of roundval-s3p2b1that both builds ran, the replies differ in cells B1, B2, B4 and B9 only.git.statushas cell B1. Cell B7 ran the cells of groups A and B of rounds3p2on both builds. No reply line differs: 65 requests on a Linux VM, 76 lines on a macOS VM and 41 requests on a Windows VM. - Null device.
<null>is/dev/nullon Linux and macOS, andNULon Windows. The same value goes into-c core.excludesFilewhen the user has no global excludes file. The-c core.hooksPathand-c core.attributesFilevalues stay/dev/nullon Windows. Windows VM. claustrum only: thestatus,ls-filesanddiff-indexcalls ofgit.statuspass-c core.excludesFile=/dev/nullon Windows too. That is divergence D16. - Configuration listing. Before each hardened call except the first (see the next
point), the daemon runs
git config -z --listin the same directory.89cb6289dropped--name-only. The listing carries the profile variables of the call after it, andGIT_COMMON_DIR. Before a call of the branch step it carries the light variables, and in the stateshadowthe six values of a create call. It carries no hook pins. One listing is the exception, in the stateshadow: the second listing before the checkout. It carries the pins,GIT_INDEX_FILEandLANGUAGE=C, and noLC_ALL.5fd08069sends it so in cell A3 of roundval-s3p2b1(Linux, macOS and Windows VMs). The listing before the checkout and the listing before thegit statuscall start with--git-dir=<dir>. Linux, macOS and Windows VMs. claustrum only:git.worktree_createruns one more listing before the listing of the checkout, with--show-scope. It names the git directory and not the copy, so it reads the config of the repository. - Listing locale. The listing drops the daemon's own
LC_ALLandLANGUAGEand ends withLC_ALL=CandLANGUAGE=C, after theGIT_COMMON_DIRpin.LANGand the rest keep their places. git then writes its listing errors in English (row L03, Linux VM). The call after the listing keeps the daemon'sLC_ALL,LANGUAGEandLANG(row L02, Linux, macOS and Windows VMs). OtherLC_*variables stay too. OnlyLC_ALL,LANGUAGEandLANGwere measured. - Hook pins.
git config -z --listends each record with a NUL byte and puts a LF between key and value. A hook key that stands in a value pins nothing (row L04). A keyhook.<name>.<var>names a hook. The name is the text between the first and the last dot. It can be empty or hold dots.hook.<var>names none. Each name counts once, in the order the listing first shows it, and getshook.<name>.enabled=falseand an emptyhook.<name>.event(row L05). Each hardened call takes the names of the listing right before it. That choice is not measured: only configurations that stay the same during a request were run. Three limits refuse the method after the first listing, before any hardened call. A key over 1048576 bytes gets the hooks refusal witha configuration key is longer than the listing reads(row L08c). Row L08c had the key in the sectionx. In some sections a key over 1024 bytes is refused before the listing (see Repository config refusals). More than 1024 names getconfig-defined hooks could not be pinned off; git not run: too many configured hooks to pin(row L06b). Names whose bytes add up to more than 65536 get…git not run: configured hook names exceed the aggregate pin byte bound(row L07b). A value has no cap: 2 MiB passes (row L09). Linux, macOS and Windows VMs. Some points are not measured. Onlycommandandeventwere used as<var>. claustrum pins two names that differ in letter case as two names. It counts a name under two keys once in the limits. It tests the count before the byte sum. A sum over 65536 made of names of at most 1024 bytes was not run. Nor was a value over 2 MiB. Every measured failure hit the first listing. No row has a listing over a limit in a folder that holds no repository. From the code, three rules follow. A later listing that runs right before a hardened call does not refuse that call. If it fails or exceeds a limit, the call gets the two base pins only. A failure of one of the two later listings ofgit.statusrefuses, and a limit does not refuse there (seegit.status). In a folder that holds no repository, the first listing follows "The daemon's own git environment": a limit refuses each method exceptgit.status. - Hooks refusal check. The first listing of a method is the hooks refusal check. It
is not an extra call. If it fails, the method answers by the class of the failure
(see Git-directory trust check). If it exceeds a
limit, the method answers the limit refusal (see Hook pins above).
git.worktree_createruns it with the light profile.5fd08069does so with"shadowDirOff":true(cell B1 of roundss3-linux,s3-macosands3-windows, on Linux, macOS and Windows VMs). Without the member the listing of5fd08069carries six values (cell B0 of rounds3-linux, Linux VM). That of claustrum does too in the stateshadow(roundval-s3p2b2-5, Linux VM).git.info,git.list_branches,git.statusandgit.worktree_removerun it with the heavy profile.5fd08069does so in cells B0 and B1 of rounds3-linux(Linux VM). With the member, cell B1 of rounds3-windowsshows the same for the four methods (Windows VM). WithworktreeRoot,git.worktree_removeruns a light check. Later a heavy listing andrev-parse --absolute-git-dirfollow. Linux VM. - Excludes read. Before its first listing, the daemon reads the user's
core.excludesFileonce, withgit config --includes --path core.excludesFile. This call runs in the temporary directory.GIT_DIR=<null>is the only variable that it adds. Linux, macOS and Windows VMs. - The probe
git --attr-source=<empty tree> versionadds no variable. Linux, macOS and Windows VMs. - The
git statuscall starts with--attr-source=<empty tree>. The heavy-coptions,--git-dirand--work-treefollow it. Linux and macOS VMs. It and its listing pinGIT_COMMON_DIRto the clean path of the common git directory. On Windows the reference pins it with backslashes (Windows VM). claustrum cleans the path to match, and its Windows unit test checks the pin. - Variable order. A
GIT_*variable of the daemon's own environment keeps its place, before the variables that the daemon adds. The next section gives the exceptions. If the daemon adds a variable that its environment already has, the added value and place win. Linux and macOS VMs. On Windows the block of a git child of claustrum is in name order.f6010b97and89cb6289do not sort it (Windows VM, row V17h for89cb6289). That isDIVERGENCES.md→ D23. - Temporary names. The folders of the settings copy names the
short-lived folders of a request. With
"shadowDirOff":truethe git dir ofgit statusstarts withclaude-ssh-gitdir-, and the folder of the include step withclaude-ssh-worktreeinclude-.5fd08069has those two names in cells A1 and A3 of roundval-s3p2b1(Linux, macOS and Windows VMs). The temporary index directory of the checkout starts withclaude-ssh-index-, as on89cb6289. Cell X1 shows that name on Linux and macOS VMs. It is in the frame textopen <tmp>/claude-ssh-index-<n>/index: no such file or directoryand in theGIT_INDEX_FILEof each read-tree. The suffix is decimal digits. The name of89cb6289on Windows is not measured, and claustrum uses the same prefix there. git.worktree_removewithoutworktreeRoot, for a worktree directory that exists. If the hooks refusal check or the heavyrev-parse --absolute-git-dirafter it fails, the check runs once more. That is a second listing, and a secondrev-parsewhen that listing passes. Then the method answers the lock-check refusal. Linux and macOS VMs.git.worktree_removewithworktreeRoot. A lightrev-parse --show-toplevelfollows the light check, with no listing of its own. Before the daemon decides whetherworktreePathis a registered worktree, it runs a lightworktree list --porcelain -zand a heavyrev-parse --absolute-git-dir. Each of them has its listing. claustrum runs therev-parseafter it located the worktree folder. It uses the answer only as the--git-dirof the pair (seegit.worktree_remove). For abaseRepothat exists, the "leads into" and "not reachable" refusals come right afterworktree list. With the excludes read that is 7 calls, as onf6010b97and89cb6289. For a missingbaseRepoand a missing root, both references send "not reachable" with no git call, and claustrum makes none there too (row T9). Linux and macOS VMs.git.worktree_createwithworktreeRoot. After the repo test and the in-repo root test, claustrum runs a lightrev-parse --show-toplevel, a heavyrev-parse --absolute-git-dirand a lightworktree list --porcelain -zinbaseRepo. Each has its listing. With the excludes read and the repo test that is 9 calls.f6010b97makes the same 9 calls before it refuses a root in a checkout.89cb6289also makes 9 calls, in this order. claustrum does not use the answer ofrev-parse --absolute-git-dir. On a create that goes on, the references make more calls than claustrum. Linux and macOS VMs. A failed or killedshow-toplevel, a killedworktree list, or two failed ones, stop the create withmkdir_failed(cells C-W17b and C-W17).- Some calls of
f6010b97have no claustrum counterpart. One example is arev-parse --show-toplevelwith--git-dirand--work-treeingit.worktree_create. claustrum runs thatrev-parsepair in three places only. One isgit.infoon Windows. One isgit.worktree_create, after an admin record that does not nameworktreePathafter symlink resolution. One is the gate ofgit.status, whose calls follow89cb6289(see its section).
The daemon's own git environment¶
A logging git wrapper measured the rules below against f6010b97. Each point names
its VMs.
Four calls get the daemon's environment as it is, with only their own additions.
These are the excludes read, the --attr-source version probe, the git version
call of the include scan and the config --no-includes --file - call of git.status. Linux, macOS and Windows VMs. The other git calls of the
git methods are the repository calls. These are the configuration listings, the
hardened calls, worktree add, the checkout and the git status call.
GIT_CONFIGandGIT_CONFIG_PARAMETERSdo not reach a repository call. The names match exactly, in upper case. On Windows,git_configandGit_Config_Parametersreach git. Linux, macOS and Windows VMs.- A listing gets the daemon's
GIT_CONFIG_COUNT,GIT_CONFIG_KEY_<n>andGIT_CONFIG_VALUE_<n>as they are. Linux, macOS and Windows VMs. - A hardened call gets these names after the profile and the
GIT_COMMON_DIRpin. First comesGIT_CONFIG_COUNT, with the number of all the pairs. Then come the daemon's pairs0tocount-1, in index order. Then come the hook pins, from the paircounton. They arehook.enabled=false, an emptyhook.event, and then the two pins of each hook name (89cb6289, rows L04 to L07). TheGIT_INDEX_FILEof the checkout and theGIT_OPTIONAL_LOCKSof thestatus,ls-filesanddiff-indexcalls follow them. The values were measured on Linux, macOS and Windows VMs, and the order on Linux and macOS VMs. - A hardened call gets no other
GIT_CONFIG_KEY_<digits>orGIT_CONFIG_VALUE_<digits>. A leading-zero index such asGIT_CONFIG_KEY_01is removed too. Linux, macOS and Windows VMs. A listing keepsGIT_CONFIG_KEY_01in its place (Linux and macOS VMs). Other names, such asGIT_CONFIGXorGIT_CONFIG_S4, reach git. Linux, macOS and Windows VMs. - The daemon's
GIT_ALLOW_PROTOCOLsets the light value. If it is not set, the value ishttps:ssh. If it is set, the daemon splits it on:and keeps each entry that is exactlyhttpsorssh, once, in its order. The match is case-sensitive, with no trim. If no entry is left, the value isdenied_by_claude_ssh. Examples:ssh:https:gitgivesssh:https,https::sshgiveshttps:ssh, and an empty value,HTTPS:SSHorhttps,sshgivesdenied_by_claude_ssh. The heavy value staysdenied_by_claude_ssh. Linux, macOS and Windows VMs. On Windows a lower-casegit_allow_protocolsets the value too (Windows VM).
The git methods check the daemon's GIT_CONFIG_COUNT. The check reads the count
and its pairs by their exact upper-case names. On Windows it does not see a
lower-case git_config_count or git_config_key_0 (Windows VM).
- If the count is not set, there is no check.
- An empty value counts as
0. Else the check skips leading space, tab,\n,\v,\fand\rbytes. One optional+and one or more digits follow, and then the end. So\t1,+1and01count as1, and00counts as0. Other values are refused. Examples are-1,1\n,+ 1,++1, a lone tab and a no-break space before the digit. So are0x1,xand99999999999999999999. The text isinherited GIT_CONFIG_COUNT "<value>" is not a count, with the value in Go%qquotes. Linux, macOS and Windows VMs. - For each index from
0tocount-1,GIT_CONFIG_KEY_<n>andGIT_CONFIG_VALUE_<n>must both be set.<n>is the plain decimal index, soGIT_CONFIG_KEY_01is not pair1. An empty value counts as set. The first index where one is not set is refused withinherited GIT_CONFIG pair <n> is incomplete. Linux, macOS and Windows VMs. - An empty key is not refused here. On a repository, git then fails the listing.
Linux, macOS and Windows VMs.
f6010b97also refusesgit.infoandgit.list_brancheson a plain-directorypath, andgit.worktree_createandgit.worktree_removeon a plain-directorybaseRepo. Linux, macOS and Windows VMs. claustrum refuses these too, as89cb6289does on Linux, macOS and Windows VMs.
With a key that git cannot read, git fails each configuration listing with exit
status 128. <text> is then config-defined hooks could not be pinned off; git not
run: listing the configuration in force: exit status 128: <git text>. The git of
the Linux, macOS and Windows VMs gave three texts. An empty key gives error: empty config key
fatal: unable to parse command-line config. The key nodot gives error: key
does not contain a section: nodot fatal: unable to parse command-line config.
The key a.b c gives error: invalid key: a.b c fatal: unable to parse
command-line config. A good pair before the broken pair changes nothing. 89cb6289
answers these rows in a folder that holds no repository. The rows are from Linux
and macOS VMs. The cells are from a Windows VM, with an empty key. The same cells of
P5, P6 and P7 hold the other two keys and the good pair.
| row | Windows cell | request | frame of 89cb6289 and claustrum |
|---|---|---|---|
| A-N1 | P3-infoN | git.info on a plain folder |
{"error":{"code":-32603,"message":<text>}} |
| A-X2 | P3-infoX | git.info on a folder with an empty .git folder |
the same |
| A-N2 | P3-lbN | git.list_branches on a plain folder |
the same |
| A-S1 | P3-statN | git.status with a plain-folder baseRepo |
the same |
| A-N3, A-N6 | P3-crN, without worktreeRoot |
git.worktree_create with a plain-folder baseRepo, without and with worktreeRoot |
{"success":false,"error":<text>,"errorCode":"worktree_add_failed"}, and no folder is made |
| A-N4 | P3-rmN | git.worktree_remove without worktreeRoot, plain-folder baseRepo, worktree directory gone |
{"success":false,"error":"failed to remove worktree: could not check whether <worktreePath> is locked (<text>); retry"} |
| A-N5 (Linux VM only), A-N5b, A-N5c | none | git.worktree_remove with worktreeRoot, plain-folder baseRepo |
{"success":false,"error":"failed to remove worktree: cannot determine the repository's work tree: <text>"} |
A GIT_CONFIG_GLOBAL that names a file git cannot parse fails each listing too.
With the content [broken, <git text> is
fatal: bad config line 1 in file <file>. 89cb6289 answers the hooks refusal
there in three requests (cell C-c, Linux and macOS VMs). They are git.info on a
plain folder and on a repository, and git.worktree_create on a plain folder.
Cells Wb show the same three frames on a Windows VM, with a file that fails at
line 2. The git version of 89cb6289 gets no GIT_CONFIG_GLOBAL
there and exits 0.
Nothing changes on disk in these rows. Some frames stay as they are with nothing
set. A path that does not exist keeps its frames (rows A-M1 and A-M3 to A-M5, and cell
P3-infoM for git.info on a Windows VM). So does a plain folder of mode 0000, where
git cannot start (row A-X1). GIT_CONFIG_COUNT=0, an empty value and
GIT_CONFIG_PARAMETERS='x change no frame (Linux, macOS and Windows VMs). On
Windows, git.worktree_create with worktreeRoot answers as in row A-N6 (cell
Wf-P3-crNroot). With nothing set it answers not_a_repo (cell Wf-P0-crNroot).
git.worktree_remove with worktreeRoot is not measured on Windows for this
check. From the code: claustrum refuses a worktreeRoot there before the check.
Take row A-N4 with a worktree directory that exists and holds a file. 89cb6289
and claustrum answer could not check whether <worktreePath> is locked (its
registrations could not be examined); retry, with nothing set and with an empty
key. The folder and the file stay. Cells CaP0 and CaP3 show that on Linux and macOS
VMs. Cells CabP0 and CabP3 show it with a branchName on the Linux VM. That state
is not measured on Windows. From the code: claustrum answers the lock-check refusal
there and deletes nothing.
No row measures a listing over a limit of "Hook pins" in a folder that holds no repository. That is a listing that passes and holds too many hook names, or a key over the size limit. From the code, the methods differ there:
git.info,git.list_branchesandgit.worktree_createanswer the limit refusal in their "refused" frames.git.worktree_removeanswers the limit refusal as<text>of its two frames in the table above, without and withworktreeRoot.git.statusanswers{"isRepo":false,"clean":false}. It refuses in that folder for a listing that fails, and for no other listing.
A .git file with no gitdir: line is not measured either. From the code: its
listing carries GIT_DIR=<null>, so the folder answers as a plain folder does.
A refusal text starts with config-defined hooks could not be pinned off; git not
run:. Before the refusal, the daemon runs no git call except the excludes read.
The table gives the place of the check in each method, and its frame. A refusal of
the git-directory trust check comes first, with the trust text in the frame. That
order holds in git.info, git.list_branches, git.status and
git.worktree_create. It also holds in git.worktree_remove of a gone worktree
without worktreeRoot, and with worktreeRoot for a baseRepo that exists.
Linux and macOS VMs. A Windows VM measured it in git.info, git.status and the
remove of a gone worktree.
| method | frame |
|---|---|
git.info |
{"error":{"code":-32603,"message":<text>}}, also for a plain dir, a missing dir and a file. Linux, macOS and Windows VMs |
git.list_branches |
the same, also for a plain dir, a file, and an empty or absent path. For an empty or absent path, the daemon's working directory was a repository or a plain dir. Linux, macOS and Windows VMs |
git.list_branches, path not empty and does not resolve, or inside a managed worktrees directory |
{"isRepo":false,"branches":[]}, before the check and with no git call. |
git.status |
the same, also for a path that is a plain dir, the repository, missing or a file, and for a plain-dir baseRepo. Linux, macOS and Windows VMs |
git.status, baseRepo does not resolve |
{"isRepo":false,"clean":false}, before the check and with no git call. See the method section. From the code: a baseRepo in a managed worktrees tree answers the same way. That case is not measured with a refused count |
git.worktree_create |
{"success":false,"error":<text>,"errorCode":"worktree_add_failed"}, also for a plain-dir or missing baseRepo and a relative worktreePath. Also for a worktreePath with a .. component, outside the repository or that exists. A missing branchName gets its -32602 frame first. A baseRepo inside a managed worktrees directory gets its nested_base_repo frame first. Linux, macOS and Windows VMs. On Windows the check comes before the worktreeRoot refusal (Windows VM). A baseRepo that fails claustrum's own trust-root test gets that frame first too. Only the Windows VM measured that order (round 1 row C1 P1) |
git.worktree_remove |
Some refusals come first, with their usual frames. These are for a relative path, a path outside the repository, the repository itself and a .. component (Linux, macOS and Windows VMs). They are also for a path outside worktreeRoot, and with worktreeRoot for a .. baseRepo (Linux and macOS VMs). A baseRepo that fails claustrum's own trust-root test gets the managed-worktrees refusal first. Only the Windows VM measured that order (round 1 row A1 P1). Without worktreeRoot, open <baseRepo>: not a directory comes first for a regular-file baseRepo (Linux, macOS and Windows VMs). So does statat .claude: permission denied for a baseRepo with mode 0600 and a path under .claude (Linux and macOS VMs). With worktreeRoot, so does the refusal of a root in baseRepo (row E7, Linux and macOS VMs) |
git.worktree_remove, no worktreeRoot, worktree directory gone |
{"success":false,"error":"failed to remove worktree: could not check whether <worktreePath> is locked (<text>); retry"}, also for a plain-dir or missing baseRepo. Linux, macOS and Windows VMs |
git.worktree_remove, no worktreeRoot, worktree directory present |
{"success":false,"error":"failed to remove worktree: could not check whether <worktreePath> is locked (its registrations could not be examined); retry"}. Linux, macOS and Windows VMs. Linux and macOS VMs also measured a baseRepo of <T>/missing/.., which does not resolve. On Windows that baseRepo gets the managed-worktrees refusal first |
git.worktree_remove with worktreeRoot, baseRepo a repository or a plain dir |
{"success":false,"error":"failed to remove worktree: cannot determine the repository's work tree: <text>"}. A trust refusal puts its own text there instead. Linux and macOS VMs |
git.worktree_remove with worktreeRoot, baseRepo missing, absolute and without a .. component, worktree directory present |
{"success":false,"error":"failed to remove worktree: could not check whether <worktreePath> is locked (the repository at <baseRepo> could not be read); retry"}. Linux and macOS VMs |
git.worktree_remove with worktreeRoot, baseRepo missing, absolute and without a .. component, worktree directory gone |
{"success":false,"error":"failed to remove worktree: could not check whether <worktreePath> is locked (<text>); retry"}. If the root is missing too, the "not reachable" refusal comes first (row T9). Linux and macOS VMs |
On Windows the worktreeRoot refusal of git.worktree_remove comes before the
check (Windows VM).
git.info¶
{path[,shadowDirOff]} → repo: {"isRepo":true,"repo":"<dir>","branch":"<b>","root":"<abs>","repoSlug":"<owner/repo>","defaultBranch":"<b>"[,"settingsControl":"<state>"]} · non-repo: {"isRepo":false,"repoSlug":"","defaultBranch":""[,"settingsControl":"<state>"]}
-
settingsControlis the last member, since5fd08069. Its values are"shadow","fallback-git"and"off-switch". Before a start test of the daemon gave a state, there is no such member. See The settings copy. -
repois the base name ofroot. A name that starts with-or+leaves the member out, and the other members keep their order:{"isRepo":true,"branch":"main","root":"<F>/-repo","repoSlug":"i/r","defaultBranch":"main"}.f6010b97and89cb6289do so on Linux and macOS VMs (rows I11a and I11b). The general rule behind those two rows is not measured. -
A refused daemon
GIT_CONFIG_COUNTchanges the answers of this method (see "The daemon's own git environment"). - Since
f6010b97the git-directory trust check runs onpath. A refused git directory answers-32603with the refusal text. "No repository" answers the non-repo body. AGIT_COMMON_DIRin the daemon's environment turns the check off for this method. See Git-directory trust check. - A repository config that the daemon does not accept answers
-32603with a refusal text. See Repository config refusals. - A failed start test, a bad answer of the call about the repository, and a copy
folder that cannot be made answer
-32603with their text. See The settings copy. - The daemon reads
branchwithbranch --show-current, so it works on an unborn HEAD. An empty repo gives the init branch name, for examplemaster. A detached HEAD givesbranch:"detached:<short-sha>". - When
branch --show-currentfails, the result has nobranchmember at all. git's error text never appears there. An example is a HEAD that git fails to resolve (ref: refs/heads/mainor 40 hex digits, each followed by junk). Measured side by side against90fca6e6andf6010b97on a Linux VM. If therev-parse --short HEADcall of a detached HEAD fails, the result has nobranchmember either.5fd08069answers so in cell V6-13d of roundval-s3p2b2-3(Windows VM). rootis the absolute repo top-level. A subdirectory aspathgives the sameroot(added by reference7cbfa471). Since89cb6289it is the folder where the walk of the trust check found the repository, the one that holds the.gitdirectory or file. Its spelling ispathafter symlink resolution and the walk up. A component that is not a symlink keeps its letter case and Unicode form as sent (rows I07a, I07b and I08 on a macOS VM). A symlink resolves before a later..pops it (row D01).core.worktree, and the daemon'sGIT_DIRandGIT_WORK_TREE, do not move it. A walk that finds a git directory with no work tree, or nothing, answers the non-repo body afterrev-parse --git-dir(rows I04, I05 and T11a). On Linux and macOS norev-parse --show-toplevelruns. On the first request, a repository with noorigin/HEADtakes 9 calls. They are the excludes read, then a listing before each ofrev-parse --git-dir,remote get-url origin,symbolic-refandbranch --show-current(row L01). Row L01 is of89cb6289. Without the member, the firstgit.infoof a daemon of5fd08069has 21 calls there (cell 50a of rounds3p2b-edges, Linux VM). The 12 more are the ten test calls, the call about the repository and one config read.- On Windows git checks the root. After
rev-parse --git-dirthe daemon runsgit --git-dir=<pin> config -z --listandgit -c … --git-dir=<pin> --work-tree=<walk root> rev-parse --show-toplevel, both in<pin>, theGIT_COMMON_DIRpin (row W01). In the stateshadow<pin>is the git directory, andGIT_COMMON_DIRnames the copy (row D5 of roundval-s3p2b2-3, Windows VM,5fd08069). When git's answer names the same directory as the walk root, the root is git's answer as printed. Else it is the walk root with forward slashes (rows W14 to W16). A directory symlink resolves in the walk and a junction does not (row D03). Some points are not measured. claustrum tests the same directory by file identity (os.SameFile). That is derived from rows W08 and W09. claustrum takes no relative answer. The spelling of the walk root is not measured. With no pin, claustrum runs the pair on the git directory of the walk. One state with no pin is measured. With a daemonGIT_COMMON_DIR, the pair and the root are equal on89cb6289and the claustrum build of this change in every call (Windows VM, cell B-13). The other states with no pin are not measured. -
On Windows a path through a junction can name two repositories. The fixture is that of the trust check: a junction
<P>\Jto<R>\inner, P on branchmainwith the originp/p, and R on branchrmainwith the originr/r. For the path<P>\J\subwith no daemonGIT_DIRorGIT_COMMON_DIR, each call after the excludes read carriesGIT_COMMON_DIR=<P>\.git, the entry of P (cells B-01, B-02, B-07 to B-09 and B-16). The other rows of the table have other entries. The calls of the method run in<P>\J\sub, andrev-parse --git-dirprints<R>/.gitthere.89cb6289answers these fields on a Windows VM:Cell State root,repobranchrepoSlugdefaultBranchB-01, B-02 pathis<P>\J\subP rmainp/p""B-04 pathis<P>\JP rmainp/p""B-07 P has no origin P rmain""""B-08 R has no origin P rmainp/p""B-09 R is on a detached HEAD P detached:<short sha of R>p/p""B-16 the config of R sets core.hooksPathandcore.fsmonitorP rmainp/p""B-17k pathis<KP>\J\sub,<KP>a junction to PP rmainp/p""B-17d pathis<P>\J\..\J\subP rmainp/p""B-05p pathis P, no junctionP mainp/pmainB-05r pathis<R>\inner\sub, no junctionR rmainr/rrmainB-06 a folder symlink in the place of the junction R rmainr/rrmainB-13 daemon GIT_COMMON_DIRof RP rmainr/rrmainB-14 daemon GIT_DIRof RP rmainr/rrmain
In the mixed cells the listing prints remote.origin.url of P, symbolic-ref
refs/remotes/origin/HEAD prints refs/remotes/origin/rmain, and the rev-parse
--verify of that ref exits 1 (cell B-01). In cell B-15 the config file of P has
a broken line. The listing in <P>\J\sub exits 128, and 89cb6289 answers
-32603 with the listing refusal that ends
fatal: bad config line 15 in file <P>\.git/config, after 3 git calls. In cell B-03 the junction is outside any
repository, and the answer is the non-repo body. The claustrum build of this change
answers the same frame in each of these cells (Windows VM). For cell B-15 now,
see "Git-directory trust check". Its calls carry the
same entries in these cells, except B-14. In cell B-03 it runs one call less
(see the trust check). The claustrum test on real junctions runs on Windows
only. It has 11 cells: B-01, B-03, B-04, B-05p, B-05r, B-07, B-08, B-15, B-16,
B-17k and B-17d. A test with simulated links runs on Linux and macOS. In cell
B-14 calls 4 and 5 differ. They are the root pair. 89cb6289 runs them with
--git-dir=<P>\.git, in the folder <P>\.git, with GIT_COMMON_DIR=<P>\.git.
The claustrum build of this change runs them with --git-dir=<R>\.git, in the folder
<R>\.git, with GIT_COMMON_DIR=<R>\.git. The frames are equal. Cell J-f-info
repeats cell B-14 with the same result. Not measured: a junction to another
drive, and a junction to a bare repository. Three more states have no cell of
89cb6289. The answers of claustrum there are from the code:
- The path goes through the junction, and <P>\.git\commondir is a stray
file. claustrum judges <P>\.git by the stray-commondir rules, and a refusal
names P. The build before the spelled start ran no trust check there.
- A junction outside any repository leads into a repository whose config file
has a broken line. claustrum answers the non-repo body. The build before the
spelled start answered the listing refusal for the config file of that
repository.
- The path is <P>\missing\.., and <P>\.git\commondir is a stray file.
The answer is that of the first state.
- The excludes read runs before the trust check, so it also runs before a trust
refusal, as on both references.
- 7c2f88d added repoSlug and defaultBranch. Both are always present,
including on the non-repo body. Each is an empty string when the daemon cannot
determine it.
- repoSlug is owner/repo from remote.origin.url. The daemon populates it only
for a canonical github.com remote. These rules are measured across 42 URL
shapes:
- The scheme must be https, http, ssh, git, or absent. An absent scheme
is the scp-like [user@]host:owner/repo. git+ssh:// and file:// give
"".
- The host must equal github.com case-insensitively. www.github.com, the
trailing-dot github.com., a port (github.com:443), GitLab, Bitbucket and
self-hosted GHE all give "". The daemon strips userinfo.
- The path must be exactly two non-empty segments after one optional trailing
/ and one optional .git.
- The owner is alphanumerics with interior hyphens only. ac-me and ac--me
pass. -acme, acme-, acme_corp and acme.co do not.
- The repo is alphanumerics plus ., _, -. It must not start with -, it
must not be . or .., and it must not end in a lowercase .wiki. That
last test is case-sensitive and suffix-only, so GIZMO.WIKI and a repo named
wiki are accepted.
- defaultBranch is what refs/remotes/origin/HEAD points to. It is empty when
refs/remotes/origin/HEAD is unset. The daemon checks the name. A name that
starts with - gives "" with no further call. Else a rev-parse --verify --quiet
refs/remotes/origin/<name>^{commit} runs, with its listing, and only exit 0 keeps
the name. A dangling origin/HEAD and one that names a blob give "" too.
f6010b97 and 89cb6289 do so on Linux and macOS VMs (rows I10a to I10c). The
call has the heavy profile, as on 5fd08069 (cell B3d of round s3p2-linux,
Linux VM, with no "shadowDirOff" member). With the member, cell A2 of round
val-s3p2b1 shows the same on Linux, macOS and Windows VMs. In a partial clone
no build starts a fetch for git.info. The object store, the refs and
packed-refs are unchanged after 4 requests, and defaultBranch is equal. That
is cell B3 of round val-s3p2b1 on a Linux VM, for 89cb6289, 5fd08069 and
claustrum, with and without the member. Not
measured: other invalid names, and a symbolic-ref answer outside
refs/remotes/origin/ (claustrum verifies it the same way).
git.status¶
{path,baseRepo} → clean: {"isRepo":true,"clean":true} · dirty: {…,"clean":false,"changes":["M a.txt"," M b.txt","?? new"]}
baseRepois required since7d193f89. An absent one answers-32602 baseRepo is required. Status is reported only whenpathpasses the gate below. Every other request answers{"isRepo":false,"clean":false}, which is the full shape, unlikegit.info.- A refused daemon
GIT_CONFIG_COUNTchanges the answers of this method (see "The daemon's own git environment"). - A
baseRepothat does not resolve answers{"isRepo":false,"clean":false}before any git call. claustrum resolves it with Go'sfilepath.EvalSymlinks, and any error counts. A..after a symbolic link goes up from the target of the link. Examples are<dir>/missing/..,<dir>/<file>/../Tand<dir>/<dangling link>/... Another is<dir>/<link>/../Twhen the parent of the link target holds noT. These were measured on Linux, macOS and Windows VMs. A link loop and a directory with mode 000 were measured on Linux and macOS VMs, and a dangling junction on a Windows VM. On Windows abaseRepowith a junction before its last component, such as<dir>\<junction>\T, does not resolve (Windows VM). - Since
f6010b97the git-directory trust check runs onbaseRepo, not onpath. A refused git directory answers-32603with the refusal text, even whenpathis an honest linked worktree. "No repository" answers{"isRepo":false,"clean":false}. AGIT_COMMON_DIRin the daemon's environment does not turn the check off here. See Git-directory trust check. - A config of
baseRepothat the daemon does not accept answers-32603with a refusal text. See Repository config refusals. - The status calls carry
GIT_NO_REPLACE_OBJECTS=1, as on5fd08069(cell B1 of rounds3-linux, Linux VM, with"shadowDirOff":true, and cell B0 without it). So status does not read replace objects (refs/replace). On89cb6289the calls have no such variable. Cell B1 of roundval-s3p2b1has aHEADcommit with a replacement that holds another tree.5fd08069and claustrum answer against the real commit.89cb6289answers against the replacement, withM a.txtinchanges. In a second fixture of cell B1 the replace ref names a missing object.89cb6289answers-32603exit status 128there, and5fd08069and claustrum answer the status. Cell B1 ran on Linux, macOS and Windows VMs, with and without"shadowDirOff":true.
The gate. The git call logs of 89cb6289 show no git call inside path before
the gate decides. Its answers follow the worktree entries under the common directory
of baseRepo: the request passes when exactly one entry names path. Each rule names its rows. The rows are from a side-by-side probe of
89cb6289 and claustrum on Linux, macOS and Windows VMs. Rows C1 to C13, row n07L
and the .t rows are from the macOS VM. Rows D1 to D16, K02 to K15 and W03 to W06
and x1 to x11 are from the Windows VM. Rows y1 to y7 are from Linux and macOS VMs.
Rows y8a to y10, o1 to o25 and v2 to v6b are from the Linux VM. Rows o19a to o19d
and r1 to r4 are from the macOS VM. Rows E07a and E07b ran on Linux and macOS VMs
with a GIT_COMMON_DIR in the daemon's environment. The FIFO rows n05a, n14b,
N05a, N05b, C20a and C20b did not run on Windows. Rule 11 names the systems of the
w rows. Every other row ran on all three systems. On Windows the frames are those of the pass with a user excludes file (see
D16 below). The call numbers are those of row K1, which has 22 git calls. Call 1 is
the read of the user's excludes.
- No git call,
isRepo:false: baseRepohas the components.claude/worktreesin it, or is that folder (rows n10, n10b1 and n10c1).X/.claudealone passes (rows n10b2 and n10c2).baseRepodoes not resolve (rows n11 and n11c1), as above.- A folder one or two levels above
baseRepoholds an entry named.claude-managed-worktreesand no.git(rows o23, o23c and o23d, Linux VM). The entry can be a file or a directory. With a.gitin that folder the request passes (row o23b, Linux VM). A marker insidebaseRepoitself is ignored (row v5). Not measured: more than two levels. claustrum refuses them too. - The read of the user's excludes comes next. It also runs before a trust refusal
(rows o20 and o21, Linux VM). On Windows a
baseRepothat is a dangling junction answersisRepo:falsewith this call only (rows n11-j and x11). - Calls 2 and 3 run in
baseRepoas sent: the configuration listing, then the heavyrev-parse --absolute-git-dir. The answer of call 3 is the common directory. baseRepocan be a symlink to the repository,T/orT/sub/..(rows n11b, n11c2 and n11c3). It can be a bare repository, a submodule checkout or a subfolder of a repository (rows A03, B08, A04, A05b and N05a).- A listing that fails is the hooks refusal (row o22b, Linux VM). A syntax
error in the config file of
baseRepoitself gets the class 1 text first: see Repository config refusals. - A
rev-parsethat fails answersisRepo:false(row n17b). - Where the trust check finds no repository, both calls carry
GIT_DIR=<null>in place of theGIT_COMMON_DIRpin, and the answer isisRepo:false(row n10b2). A listing that fails there is the hooks refusal too, and therev-parsedoes not run. Row A-S1 shows that on Linux and macOS VMs, and cell P3-statN on a Windows VM. - On Windows a
baseRepothat is a regular file is the hooks refusal with the start error of the listing (cells A-04 and A-04b2, Windows VM). See "Git-directory trust check". - From the code: where the trust check finds no repository, a listing that
passes and exceeds a limit of "Hook pins" answers
isRepo:false. Only a listing that fails refuses there. No row measures that limit. - On Linux and macOS a
baseRepothat git cannot start in answersisRepo:false(cells CeP0 and CeP3 for a regular file, Linux and macOS VMs). Ifgit versionfails too, the answer is the "cannot run" text. Cell C-g shows that for a regular file on Linux and macOS VMs. Cell C-h shows it for a folder of mode 0000 on a Linux VM. - A common directory with no
worktreesfolder answersisRepo:falsewith no further call (rows n18 and n18c). - Tests on
path, eachisRepo:falsewith 3 calls in total: pathis a regular file (row n09), or its folder is gone (row n26b).pathlies insidebaseRepoand one of its components belowbaseRepois a symbolic link (rows n07 and n07L) or, on Windows, a junction (row n07-j). The link in those rows is.claude. A link at the last component fails too (row v4, and row x10 for a junction). The same layout with no link passes (rows n07b and n08).pathlies insidebaseRepoand has a..component, with or without the folder before it (rows x4 and x4b, Windows VM, and rows y3, y4 and y5, Linux and macOS VMs). A.component passes (row y10, Linux VM). OutsidebaseRepoa..passes (row y2).- On Windows,
pathlies insidebaseRepoand its last component ends in a dot or a space or holds a colon (rows x1, x2 and x3). OutsidebaseRepoa trailing dot or space passes (rows D14dot and D14sp). Not measured: another component with such a name. claustrum refuses it too. - Calls 4 and 5 run in the common directory:
--git-dir=<common> config -z --list, then--git-dir=<common> --work-tree=<dir> rev-parse --show-toplevel. On89cb6289call 5 has the light profile. claustrum sends the heavy profile (see Hardened git calls). <dir>ispathwith its symlinks resolved (rows n06 and C12). Whenpathlies insidebaseRepoor isbaseRepo,<dir>isbaseRepo(rows A04, A05a, N05a, N05b, n07b, n08, n18b and K02b). An emptypathgives.(rows n25d1 and n25d2). Not measured: an emptypathon a daemon whose working directory is a linked worktree ofbaseRepo. A relativepathgoes to git as sent, also when it lies insidebaseRepo(rows n25c, x5 and y6). In row x5 git exits 128 on it, and the answer isisRepo:falseafter 5 calls. Row y6 gives the same answer on Linux and macOS. On Windows<dir>has the letter case on disk and the long names, and keeps a junction, asubstdrive and a\\?\prefix (rows D3, D6, D8, D13 and D16).- A listing that fails here is the hooks refusal, and
git versionruns after it (row o22, Linux VM). Not measured: a listing here that fails with git's "not a git repository" text. claustrum answers the hooks refusal for it too. - A
rev-parsethat fails answersisRepo:false(row n17). Not measured: what89cb6289does with the output. claustrum does not read it. - When the daemon's own environment sets
GIT_COMMON_DIR, calls 4 and 5 keep that value and get no pin (rows E07a and E07b). - The entry match. An entry is a real folder under
<common>/worktrees. A symlinked entry does not count (row n02). A real folder that a symlink also points at counts once (row n02b). - Its
gitdirfile is a regular file of at most 1 MiB (row n05c passes at 1 MiB, row n05b fails at 1 MiB + 1). A FIFO does not match, with no wait (row n05a, Linux and macOS VMs). - A relative value counts from the entry folder (row n03). Its last component is
.git(row n04). - The folder before
.gitequalspathas text. Two spellings ofpathmatch: as sent, and with its symlinks resolved (rows n06 and C12). A relativepathcounts from the working directory of the daemon (row n25c). On Windows asubstdrive, a junction, a\\?\path, short names, another letter case and a trailing dot or space inpathmatch too (rows D3, D4, D6, D8, D13, D14dot, D14sp and D16, Windows VM). claustrum takes the path that the file system reports for the open folder as a third spelling there. - For a
pathinsidebaseRepo, claustrum compares two spellings: as sent, and the resolvedbaseRepojoined with the part belowbaseRepoas sent. On Windows apathwith short names below abaseRepoin short names finds no entry (row K02b, Windows VM). Not measured: apathinside abaseRepothat lies below a symlink. The second spelling is claustrum's choice for it. - On macOS another letter case, the NFD form and
/System/Volumes/Datainpathdo not match (rows C9, C6, C2 and C3). Another letter case in thegitdirfile does not match on macOS (row C10). On Windows the folder compare ignores letter case: one folder name in another case matches, in thegitdirfile (row x6) and inpath(row x8). The.gitname does not: a value that ends in.GITfails (rows x7 and D9). - Exactly one entry matches. Two matching entries answer
isRepo:false(row n01), and so does none (rows n18b and n25). A second entry whosegitdiris a directory is passed over (row v2). - The
.gitinsidepathplays no part. Missing, damaged or naming another place, the answer isisRepo:true(the G rows, n26c and n26d). A locked worktree answersisRepo:true(row n26). - The
commondirfile of the matched entry is a regular file, or a relative symlink to a file in the entry folder (row n19e). An absolute symlink fails (row n19f). A file of 1 MiB + 1 fails (row n19g). Not measured: a file of exactly 1 MiB, which claustrum takes, and a relative symlink that leaves the entry folder, which claustrum refuses. A missing file fails (row n19a). A FIFO fails with no wait (rows C20a and C20b, Linux and macOS VMs). - The value loses the white space at both ends. A relative value counts from the entry folder, and the result is cleaned (rows n19c and n19d).
- It equals the common directory as text, in the spelling of call 3. On macOS a
value under
/tmpfails, because git answers/private/tmp(row n19b.t). Another letter case fails (row C11, macOS VM, and rows D10, W04 and x9, Windows VM). On Windows an absolute value with forward or back slashes passes (rows D11f and D11b). A short name, a\\?\prefix and a value with no drive letter fail (rows K05a, K07a, K15a, W05 and W06). - The
HEADfile of the entry holds 40 or 64 hex characters, or aref:line underrefs/(rows o14b, o14c, o14d and o14f, Linux VM).garbage, 39 hex characters andref: xanswerisRepo:false(rows n15, o14a and o14e). A branch that does not exist and a detached commit pass (rows n16 and n16b). - The
indexof the entry. A relative symlink to a file in the entry folder passes (row o15a). Not measured: a symlink that leaves the entry folder. claustrum refuses it. A FIFO answersisRepo:falsewith no wait (row o15b). Anindexof 1 GiB + 1 answersisRepo:false, and exactly 1 GiB passes (rows o15c and o15e). Anindexof 1 GiB beside asharedindex.xof 1 GiB + 1 answersisRepo:false(row o16). A missingindexpasses (row o15d). All Linux VM. Not measured: whether the bound is for each file or for their sum, and a bound on the count ofsharedindex.*files. claustrum bounds each file and not the count. - The
config.worktreefile of the entry. A missing file passes. A folder, a FIFO and a syntax error answerisRepo:false, with no wait on the FIFO (rows n14, n14b and n14c).- The daemon runs
git config --no-includes --file - --list -zwith the file on stdin. The call runs in the daemon's working directory with the daemon's environment (rows n12, n12b, n13 and n14c). It is call 6 there. Those rows are of89cb6289. In the stateshadowthe call of5fd08069runs in the root folder. Its variables areGIT_DIR=<null>,LANGUAGE=CandLC_ALL=C(cell 1c of roundval-s3p2b2-5, Linux VM). From the code: claustrum sends that form in the stateshadow, and the first form with the member and in the statefallback-git. - A file with
core.sparseCheckoutorindex.sparsepasses (rows n12 and n12b), and so does one withcore.sparseCheckoutCone(rows o17 and v3a).user.nameandcore.bareanswerisRepo:false(rows n13 and v3b). Not measured: every other key. claustrum answersisRepo:falsefor each of them.
- The daemon runs
- The
reftablefolder of the entry, in a repository with the reftable format (rows o19a to o19d and r1 to r4, macOS VM).- Every regular file of the folder goes into the temporary folder, also a table
that
tables.listdoes not name (row r1). - An entry that is not a regular file is left out. With a FIFO as
tables.listthe daemon does not wait, copies no list, and answers the status. git then shows every tracked file as added (row r2). - A
tables.listthat is copied names at least one table (row r4), and each name is a file that is copied. Else the answer isisRepo:falseafter 5 calls: for a missing table (rows o19c and o19d) and for a table that is a symlink (row r3). - Not measured: a size bound of a table or of the list, a folder inside
reftable, and a symlink that the list does not name. claustrum bounds a table at 1 GiB and the list at 1 MiB, and leaves the other two out. - A
reftablethat is not a real folder counts as none. Nothing of it is copied, and the request goes on. That holds for a symlink to a folder outside the entry (row w1, Linux and macOS VMs, and row w1b, macOS VM) and for a regular file (row w2, Linux, macOS and Windows VMs). On Windows it holds for a junction and for a directory symbolic link (rows w1 and w1s, Windows VM). In a reftable repository git then shows every tracked file as added (rows w1 and w1b, macOS VM).
- Every regular file of the folder goes into the temporary folder, also a table
that
Not measured: the order of rules 7 to 11, and the size bound of HEAD and
config.worktree. claustrum bounds both at 1 MiB. No read of an entry file blocks:
claustrum opens each file without blocking and reads a regular file only.
The answer.
- The temporary git folder holds copies of
HEADandindexof the entry and acommondirfile (row K1). It also holdsconfig.worktreewhen the entry has one (rows n12 and o17),info/sparse-checkout(row o17, Linux VM) and eachsharedindex.*file (rows o16b1 and o16b2). In a reftable repository it holds the regular files of the entry'sreftablefolder (rows o19a and r1, macOS VM). Not measured: aninfo/sparse-checkoutthat is not a regular file, such as a FIFO. From the code: claustrum does not wait and answers-32603(see the failures below). No entry is left after the reply. The status therefore does not refresh or rewrite the caller's index, and it does not takeindex.lock. In the statesshadowandfallback-gitthe folder is.gitdir-<n>in the folder of the daemon. With"shadowDirOff":trueits name starts withclaude-ssh-gitdir-, in the temporary directory. - On Windows in the state
shadow,rev-parse --absolute-git-dircan print another spelling of the git directory than the trust check pinned. One case is abaseRepothrough a junction. If both spellings name one folder, the request goes on with the printed path. The daemon reads the config of that path and makes a second copy folder with the name of that path.5fd08069answers the status there (cell D6j-junction of roundval-s3p2b2-3, Windows VM, 22 calls). How5fd08069matches thegitdirrecord of the entry there is not measured. claustrum takes one more spelling ofpathby file identity. That part is fitted to the reply of the cell. - After the gate comes the
git --attr-source=<empty tree> versionprobe (call 6). From the code: claustrum runs it once for each daemon. Each measured request was the first of its daemon. Then four commands run, each withpathas its working directory (calls 10, 14, 18 and 22):status --porcelain --untracked-files=all --ignore-submodules=all,ls-files -s -z,rev-parse --verify -q HEAD^{commit}anddiff-index --cached --raw -z --ignore-submodules=none HEAD --. - Three calls come before each command (calls 7 to 9). Two run in the working
directory of the daemon:
--git-dir=<common> config -z --listand the heavy--git-dir=<common> hash-object -t tree <null>. The third runs inpath:--git-dir=<temp> config -z --list. When the daemon's own environment setsGIT_COMMON_DIR, the two<common>calls keep that value. The third call and the command getGIT_COMMON_DIR=<common>(rows E07a and E07b). - Each command carries
--attr-source=<id>, the heavy profile,--git-dir=<temp>and--work-tree=<path as sent>.status,ls-filesanddiff-indexaddGIT_OPTIONAL_LOCKS=0. The--attr-sourceoption makes git ignore the.gitattributesof the repository, so no clean filter runs. It is left out when the runtime git predates it (git 2.40).<id>is the answer of thehash-objectcall right before the command. In a SHA-256 repository it has 64 hex characters (rows v6a and v6b). The probe keeps the SHA-1 id there. Whenhash-objectfails,<id>is the SHA-1 id of the empty tree (row n17f). - When
rev-parse --verifyfails,HEADnames no commit. The two<common>calls run once more, anddiff-indexgets the id of the empty tree in place ofHEAD(rows n16, o13 and o14d, 24 calls). - Failures. A
status,ls-filesordiff-indexthat fails answers-32603with the Go error string, for exampleexit status 1(rows n17c, n17d and n17e). The text of git does not show for these three commands. Ahash-objectthat fails changes no answer (row n17f). A relativepathpasses the gate. git gets it as sent,statusexits 128, and the answer is-32603 exit status 128(row n25c). ApathoutsidebaseReposuch asW/missing/..passes the gate cleaned. git then does not start in the path as sent. The answer is-32603with the hooks refusal text andchdir <path>: no such file or directory. Nogit versionfollows the refusal (row y1, Linux and macOS VMs). With opt-in D5 the same-32603can carrysignal: killed. - claustrum's own
-32603texts, from the copy into the temporary folder after the gate passed. The reference is not measured in any of them.<file>: not a regular fileanswers aninfo/sparse-checkoutthat is a folder or a FIFO.<file> is larger than <n> bytesanswers a copied file over its bound, also one that grew after the gate. A file that cannot be copied answers the raw Go error, for exampleopenat index: permission denied. With"shadowDirOff":true, and on a server with no run folder, a temporary folder that cannot be made answers the raw Go error. - In the states
shadowandfallback-gita.gitdir-<n>that cannot be made answers{"isRepo":false,"clean":false}.5fd08069answers so in the stateshadowfor a folder of the daemon of mode 0500, for a deny entry on Windows and for a file system with no free inode. The statefallback-gitand another cause are from the code. - Not measured: a failure of the listing in the temporary folder. claustrum answers the hooks refusal, as for the listing of rule 5.
changesstarts with the stdout lines ofstatus. Stderr warnings never appear. Each line is verbatim minus the line ending, the first one included. The XY column is positional, so the leading space of an unstaged change is data:"M a.txt"is staged and" M b.txt"is unstaged.--untracked-files=alllists an untracked file inside an untracked directory by itself ("?? sub/u.txt").- An entry longer than 512 bytes is cut to 512 bytes of the whole line, and
…follows (row o1, Linux VM). At most 10000statusentries pass, and no entry marks the cut (row o2, Linux VM). Submodule entries still follow (row o3). Not measured: a cut in the middle of a multi-byte character. Row o1 used an ASCII path. claustrum's own behavior there: the cut can split a character, which is possible withcore.quotePath=false, and no profile pins that key. The JSON encoding then sends U+FFFD for each leftover byte. The reference is not measured there. - Submodule entries follow the
statuslines. Each isS, the quoted name and one of three texts. All rows below are from a Linux VM. Rows n20 to n20e ran on macOS and Windows VMs too. ls-filesshows each index entry of mode 160000. For each one the daemon looks at<name>/.gitinside the work tree. It follows no link out of the work tree. Not there: no entry (rows n20e and o6d). There, as a file, a folder or a dangling symlink:(submodule present; contents not inspected)(rows n20, o6a, o6b and o6c). A gitlink path that is a symlink to a folder inside the work tree counts as present (row o5b).- If it cannot be looked at, the text is
(submodule; could not be inspected). Three gitlink paths are measured: a regular file (row o4), a symlink to a folder outside the work tree (row o5), and a folder of mode 000 (row o5c). - There is one entry for each index stage, so a conflicted gitlink gives three (row o12).
- For each
diff-indexrecord whose old or new mode is 160000, the text is(submodule change staged; contents not inspected)(rows o7, o8, o9, o9b, o9c and o13). - The order is the
statuslines, thels-filesentries, then thediff-indexentries (rows o7, o8, o9, o10 and o12). One gitlink can have all three. - A name longer than 200 bytes is cut to 200 bytes, and
…follows. The name is then quoted with double quotes,\",\n,\t, and\xfffor a byte that is not valid UTF-8. A printable character outside ASCII stays (row o11). Not measured: a cut in the middle of a character, and a name with<,&,>or U+2028. claustrum's own behavior for the cut: it can split a character, and the quoting then writes each leftover byte as\xNNtext. The reference is not measured there. - At most 100 submodule entries pass. Then one entry gives the count of the rest:
S … (+20 more submodule entries)(row o10, and row n20d with 1). cleanis true only for an emptychanges.- Windows divergence D16. On Windows, when the user has no global excludes file,
89cb6289passes-c core.excludesFile=NULto itsstatuscall. git 2.55 exits 128 on that value, and the reference answers-32603 "exit status 128"(pass A of the Windows rows). claustrum passes/dev/nullto itsstatus,ls-filesanddiff-indexcalls there and returns the status. With a user excludes file the Windows rows equal the Linux rows (pass X). See DIVERGENCES.md D16. - On Windows a gitlink path that is a regular file gives no entry (row o4, Windows VM). A gitlink path that is a junction gives the "could not be inspected" text (row o5b, Windows VM). The o rows ran on the Windows VM too, except o5c, o15b, o19 and o20.
git.list_branches¶
{path} → {"isRepo":true,"branches":[…sorted…]}
Lists the local branches of a git repository. The page git.list_branches has the parameters, the response and the errors.
git.worktree_create¶
{baseRepo,branchName,worktreePath[,sourceBranch][,existingBranch][,worktreeRoot][,timeoutMs][,shadowDirOff]} → {"success":true,"path":"<worktreePath>","sourceBranch":"<b>","branch":"<b>"}
Makes a linked git worktree for a session, on a new branch or on a branch that exists. The page git.worktree_create has the parameters, the response and the error codes.
git.worktree_remove¶
{baseRepo,worktreePath[,branchName][,worktreeRoot]} → {"success":true} (lenient), or {"success":true,"branchKept":true} when the branch step keeps the branch
- Since
f6010b97the git-directory trust check runs onbaseRepoonly, before git runs. WithoutworktreeRootit runs after the containment tests, the look intobaseRepoand the leaf check below. When the worktree directory exists, a refused git directory and "no repository" both answer the lock-check refusal of the error-string catalog. After a refusal the daemon deletes nothing. The worktree directory, its entry and its branch all stay. Damage to the entry of the worktree being removed does not stop the removal. See Git-directory trust check. - A config of
baseRepothat the daemon does not accept refuses the removal, and nothing is deleted. WithoutworktreeRootthe text ends withis locked: <text>, with no; retry. WithworktreeRootthe text is the work-tree refusal below with<text>as the reason. See Repository config refusals. - A failed start test, a bad answer of the call about the repository, and a copy
folder that cannot be made refuse the removal too. Nothing is deleted, and the
text ends with
is locked: <text>. If git names no repository in that call, the answer is the lock-check refusal. A remove makes that call two times after a bad answer. See The settings copy. WithworktreeRootclaustrum gives each of those texts as the reason of the work-tree refusal below. With git 2.565fd08069does so for a bad answer (roundtier0-27, cells GF and N04, Linux VM). Other shapes are not measured. - With
worktreeRoot, the daemon determines the repository's work tree after the containment checks and before the dir-symlink check. A failure answers{"success":false,"error":"failed to remove worktree: cannot determine the repository's work tree: <reason>"}, with noerrorCode, and nothing is deleted. It comes before the "already gone" success, so a missing worktree gets it too. The reasons, in order: - Git cannot start in
baseRepo. It is a regular file, a directory without search permission (mode 0600), or a path that the kernel does not follow.<reason>is the hooks refusal with the start error, for example…listing the configuration in force: fork/exec /usr/bin/git: permission deniedor…: not a directory. The path is the git thatPATHresolves.git versionruns after that listing (89cb6289, row L16b). If it fails too, the text starts withgit cannot run on this hostinstead, which is not measured here. AbaseRepobehind a chain of 45 symlinks, which the kernel does not follow, gets this reason too. Its text ends withchdir <baseRepo>: too many levels of symbolic links, and nothing is deleted (89cb6289, rows LNKb-g and LNKr-g on a Linux VM). The one git call there is the read of the user excludes. Not measured: chain lengths other than 5 and 45, absolute link targets, a chain to a plain folder and a chain to nothing. Nor are a chain with a locked or marked worktree, a chain withoutworktreeRoot, and a path that is too long for the kernel. - The trust check refuses the git directory of
baseRepo.<reason>is the refusal text M2 to M5 or S1 to S3. A plain folder as the target gets this text too. -
The trust check finds no repository.
<reason>isexit status 128. Examples are a plain directory, an empty nested.gitdirectory, a.gitfile with nogitdir:line, and a linked worktree whose entry is gone. A.gitfile whose target is gone and is not a worktree entry goes on to the next step instead. On Linux and macOS, a configuration listing that fails there gives the refusal text of its failure class as<reason>.89cb6289answers so with an emptyGIT_CONFIG_KEY_0of the daemon (rows A-N5b and A-N5c on Linux and macOS VMs, row A-N5 on a Linux VM).On claustrum the read of the repository config comes here. Its refusal text is
<reason>(5fd08069, cells N14, Linux and macOS VMs). 4. Git cannot list the configuration.<reason>is the refusal text of the failure class. When the listing says git finds no repository,<reason>isgit finds no repository here: <git text>. With git older than 2.56 a.gitfile with a gone target gets it withfatal: not a git repository: <target>(89cb6289, row N04 on Linux and macOS VMs).f6010b97gave the hooks refusal text there. With git 2.56 it gets the text of a failed call about the repository (roundtier0-27, cells GF and N04, Linux VM). 5.baseRepois itself a git directory, such as a bare repository or a.gitdirectory.<reason>isexit status 128. A repository whose config setscore.barestill passes. 6. The hardenedgit rev-parse --absolute-git-dirfails inbaseRepo.<reason>is its exec error, for exampleexit status 128. A daemonGIT_DIRthat names nothing is one example.
With no git on PATH, claustrum gives a baseRepo that exists one reason
before these six. It is the exec error alone, exec: "git": executable file not
found in $PATH. Both references answer so in nine rows. One is a git directory
that the trust check refuses (89cb6289, row L13t on a Linux VM). Class 1 of the
failed listing gives the other rows and the place of the GIT_CONFIG_COUNT check
(see Git-directory trust check). The other examples of
reason 3 are not measured with no git.
A baseRepo that does not exist gets no work-tree refusal, and no git call runs
for this step. That holds with git on PATH and without it (89cb6289, Linux
VM). The removal then goes on. Each request sent a branchName. The answer
depends on the worktree directory:
- It is gone, and the <directory> level exists (row L13wb), or the root exists
and is empty (row L13wc): {"success":true,"branchKept":true}. The twins with
git answer the same.
- It is gone, and the root does not exist (row L13wr): the "is not reachable"
refusal. The twin with git answers the same.
- It holds a file and no .git file. With git it gets the "is not a worktree of"
refusal (row L13wa-g). With no git it gets could not check whether
<worktreePath> is locked (the repository at <baseRepo> could not be read); retry
(row L13wa).
- It is empty. With git the directory is removed, and the answer is success with
branchKept (row L13we-g). With no git the lock-check text above answers, and
the directory stays (row L13we).
- Its .git file names an entry of the missing repository, and it holds other
files. With git the directory is deleted, and the answer is success with
branchKept (row DG1-g). With no git the lock-check text above answers, and
the directory stays (row DG1). f6010b97 does the same, with no branchKept.
Any other stat error of baseRepo keeps the six reasons and the no-git answer.
claustrum runs no GIT_CONFIG_COUNT check then (not measured).
A baseRepo that is not a directory gets reason 1. Each of the six reasons and
the order around this step were measured side by side against f6010b97 on a
Linux VM.
- By default, with no worktreeRoot, 7d193f89 confines the removal to inside the
repository. worktreePath must be absolute, carry no .. component, and sit
strictly under baseRepo. Otherwise the reply is {"success":false,"error":"refusing
to remove worktree: <p> …"}, with no errorCode, and with the same three reasons
as worktree_create. The "not inside the repository" text names baseRepo as
sent. An absent or empty baseRepo gives the empty string, so a space comes before
the semicolon (f6010b97, Linux and macOS VMs). A Windows VM measured an absent
one. An empty worktreePath is
{"success":false,"error":"failed to remove worktree: \"\" does not name a
directory"}. Only a path that passes these tests reaches the delete below.
- With worktreeRoot, the path must sit two levels under that root, under the same
external containment as worktree_create. An empty worktreePath is a relative
path there. After the worktreePath checks and before the two-level check,
baseRepo must be absolute and carry no .. component. The refusal uses the
worktreePath texts and names baseRepo as sent, for example
{"success":false,"error":"refusing to remove worktree: <baseRepo> contains a \"..\" component; choose the session folder by its absolute path, without \"..\""}.
An absent baseRepo is a relative path with the empty string as its name. No git
runs before these refusals. Under a refused daemon GIT_CONFIG_COUNT, a ..
baseRepo still gets the .. text. Measured against f6010b97 on Linux and
macOS VMs. The .. test is by whole component: <T>/sub/.. is refused, and
<B>/d..d/T and <T>/. pass (Linux and macOS VMs).
- With worktreeRoot, a root that is the file system root / is refused with no
git call, and nothing is deleted. The error table gives the frame and the cells.
- With worktreeRoot, more tests refuse the root, and nothing is deleted. The error
table gives each text and its cells. Cells C-S1 to C-S3 of round tier0-20
refuse before any git call and cells C-S4 to C-S8 after worktree list
(5fd08069, Linux VM). The order inside each group is claustrum's (not
measured). claustrum runs the <directory> symlink check before these tests (not
measured). A root that holds the repository or a linked worktree passes (rows
Q15s and Y7). So does a root in another repository beside it (rows Q20 and Q20b).
If worktree list --porcelain -z fails without a D5 kill, the daemon runs
worktree list --porcelain. If that fails too, nothing is deleted (89cb6289,
row DG2s-g, Linux VM). A baseRepo
that does not exist runs neither call.
- The managed-worktrees refusal skips a baseRepo that does not exist. Measured
against f6010b97 on a macOS VM.
- claustrum's own trust-root test also gives the managed-worktrees refusal. It fails a
baseRepo that os.Stat does not report as missing and that Go's
filepath.EvalSymlinks cannot resolve. The test is fitted to the measured rows of
f6010b97, and every measured row fits it. On Linux and macOS, rows G1, G2 and G4
got the refusal. They send <T>/a.txt/.., <T>/loop/.. (a link to itself) and a
path through a directory with mode 000. There os.Stat fails with ENOTDIR, ELOOP or
EACCES. On Linux and macOS, <T>/missing/.. and <T>/dl/.. (a dangling link) are
missing, and skip the test (rows A1 and G3). On Windows <T>\missing\.. stats,
because Windows removes missing\.. by name, but it does not resolve. The Windows
VM rows K1, D1, D2, D4, J1 and J3 fit this rule too. They put a missing name, a
regular file or a dangling directory symlink before .., or a junction mid-path. No
git runs, and nothing is deleted. The refusal comes before the worktreePath
checks, the worktreeRoot refusal of Windows and the check of the daemon's
GIT_CONFIG_COUNT. Only the Windows VM measured that order (round 1 rows O1 to O4,
WR1 and A1 P1).
- The daemon runs no git worktree remove and no git worktree prune. It deletes the
worktree directory itself, then the entry of the worktree under <git dir>/worktrees.
Without worktreeRoot, the entry stays for a baseRepo that does not exist.
Without worktreeRoot and with a daemon GIT_DIR and GIT_COMMON_DIR, the entry
that goes is in the repository that GIT_DIR names. The rows are below.
<git dir> is the repository git directory that the trust check pinned for
baseRepo. For a main repository that is <baseRepo>/.git. For a linked worktree
it is the git directory of the main repository. For a subdirectory of a repository
and for a submodule it is the git directory of that repository. With a daemon
GIT_DIR alone, it is the directory that GIT_DIR names. The worktrees
directory itself stays. After its last entry goes, it stays as an empty
directory. The rules below were measured side by side against f6010b97 on
VMs. On macOS all 107 cases are equal. On Linux every remove case is equal. On
Windows every case is equal except the junction rows of D19. The full Windows
set was measured on an earlier build, and the fixed rows again on the shipped
one. A sentence marked "not measured" has no row behind it.
- Without worktreeRoot, these rows of 89cb6289 set which entry goes. T is the
repository of baseRepo, S is its worktree s0, and X is another repository.
- The daemon has GIT_DIR and GIT_COMMON_DIR of X in its environment. The
folder of S goes. The entry s0 of T and the branch s0 of T stay. Probe row
1 shows that on Linux, macOS and Windows VMs, and battery row E13 on Linux and
Windows VMs.
- The same environment, and the daemon created the worktree w1 itself. git
registered w1 in X. The removal deletes the entry w1 of X and the branch
w1 of X (row 2, the same three systems).
- The same environment, and X holds an entry zz whose gitdir record names
<S>/.git. The entry zz of X goes, and the entry s0 of T stays (row 3, the
same three systems). The entry zz was made by hand.
- The same environment, and X holds a merged branch s0. The branch s0 of X
goes. The entry and the branch s0 of T stay (row 4, the same three systems).
- The daemon has GIT_COMMON_DIR of X alone. The entry s0 of T goes (row 5,
the same three systems). The same holds on Linux and macOS VMs in three more
rows. In row p2a T is a submodule. In row p2b baseRepo is a subfolder of T.
In row p2c baseRepo is bare. The branch result of these rows and of row 5
depends on the commits of the fixture. In rows p2a to p2c 89cb6289 keeps the
branch on the Linux VM (7 calls) and deletes it on the macOS VM (9 calls).
- The daemon has GIT_DIR of X alone. The entry s0 of T stays (row 6, the same
three systems).
- The daemon has GIT_DIR of X and GIT_COMMON_DIR of a third repository Y. The
entry s0 of T stays. An entry of X that names S goes, and such an entry of Y
stays (rows p3, p3b and p3c on Linux and macOS VMs, row p3b on Windows too).
- Two removals at once with the environment of row 1, each for an entry in X.
Both folders and both entries go (row p5 on a Linux VM, 10 runs). 89cb6289
makes 22 git calls there and claustrum 21: 89cb6289 reads the user excludes
in each request, and claustrum reads them once.
- baseRepo does not exist as sent. The folder of S goes and its entry stays.
The one git call is the read of the user excludes. Row 7c sends
<T>/missing/.. with a branchName on Linux and macOS VMs, and the reply
carries branchKept. Battery rows A1, A3 and G3 send <T>/missing/..,
<F>/missing/../T and <T>/dl/.. (a dangling link) with no branchName on a
Linux VM. A folder that is already gone is not measured. With no git on
PATH, the answer is the lock-check refusal, and nothing is deleted. Rows p1
and p1b show that for <T>/missing/.. and <T>/dl/.. on Linux and macOS VMs.
- baseRepo holds an empty .git folder and lies in an outer repository, and
the folder is gone. The reply carries branchKept, and the branch of the
outer repository stays (row p4 on Linux and macOS VMs).
- A worktree locked in the .git folder of baseRepo is refused. That is
divergence D22: see the lock rule below.
- On Windows, both paths go through a subst drive, or through a junction to
the repository. The folder and its entry go (probe row D-subst, battery rows
J2, Q1 and Q2). The claustrum test of the junction row runs on Windows only.
- The request is in 8.3 short names on Windows, or in another letter case on
macOS. The folder and the branch go, and the entry stays (rows D-83 and D-case).
- The git calls of a removal without worktreeRoot, as the call logs of 89cb6289
show them. Each row below ran on Linux, macOS and Windows VMs unless its system
is named. "The check" is a heavy config -z --list and
rev-parse --absolute-git-dir in baseRepo. <G> is the answer of the check.
"The pair" is two calls in <G>. The first is --git-dir=<G> config -z --list.
The second is --git-dir=<G> --work-tree=<baseRepo> rev-parse --show-toplevel.
- A removal in a baseRepo that exists starts with the check, after the read
of the user excludes. A worktree whose .git file names its own
entry under <G>/worktrees gets no other call before the branch step (rows K0,
2 and 5, and row 9 on Linux and macOS).
- A .git file that names a git dir which is not such an entry gets the check
once more (rows 1, 3, 4, 6, 12 and 14).
- An entry under <G>/worktrees whose record names another path gets the pair
first, then the check (row 13, and row D-83 on Windows). On Windows the pair
alone follows through a subst drive, and the entry goes (row D-subst).
- If <G>/worktrees exists, the request makes the pair (rows 3, 11, 12, 13, 14,
15 and 15b, row K1 on Linux, row R17a on Linux and macOS). An empty directory
gets it too (battery row W01 on Windows). If the directory is missing, the
request does not make the pair (rows 1, 4 and 6). claustrum runs the pair
before it looks at the entries by path.
- If no entry names the folder, the request makes the pair once more (rows K1
and K1b on Linux, battery row E13 on Linux and Windows, row D-83 on Windows).
claustrum runs it after the delete of the folder. With the folder already
gone, the request makes the pair twice too (battery rows W01 to W07-b, W14,
W15 and W16 on Windows).
- A baseRepo that holds no repository gets the check twice before the
lock-check refusal (row A5). With the folder gone, it gets the check once (row
16). Each call there carries GIT_DIR=<null device>, the calls of the branch
step too. Rows 16, A5, R18, R18b and N00 show that on Linux and macOS VMs, and
rows 16, A5 and N00 on a Windows VM. With the folder gone, a listing that fails
refuses: could not check whether <worktreePath> is locked (<text>); retry.
89cb6289 then makes the listing and git version (row A-N4 on Linux and
macOS VMs). In cell P3-rmN on a Windows VM the excludes read comes before the
two. That request is the first of its daemon.
- A baseRepo whose .git file names a git dir that is gone, with the folder
gone: the request makes two listings, and each fails. Both carry
GIT_COMMON_DIR=<that git dir> (row N04 on Linux and macOS VMs).
- A daemon GIT_DIR that names a file, with the folder gone: the reply carries
branchKept, and the request makes no git call (row N05a on Linux and macOS
VMs).
- A git directory that the trust check refuses, with the folder present: the
one git call is the read of the user excludes (row DG2i-g on a Linux VM).
- claustrum's tests compare the calls before the branch step with these rows:
the working directory, the profile, the arguments and the GIT_DIR and
GIT_COMMON_DIR values. For the branch step they compare the count of calls.
- With worktreeRoot, the call log of 89cb6289 shows rev-parse
--absolute-git-dir before worktree list, and the check once more after it.
That second check does not run for a worktreeRoot below a folder of mode 000. That
request answers after 7 calls (row T6 on a Linux VM). For a baseRepo that
holds no repository, the request makes a light listing and rev-parse
--show-toplevel. Both carry GIT_DIR=<null device> (rows L13z-g and DG2h-g on
a Linux VM). For a .git
file that names no verified entry, the calls follow the rules above (rows Q20,
Q20b and Y8w on a Linux VM). The pair carries --work-tree=<worktreePath> there
(rows Q20b and Y8w). Nothing is deleted in those rows.
- On Windows, a junction at a directory between baseRepo and the leaf is refused.
Nothing is deleted, and the branch stays. The reply is
{"success":false,"error":"failed to remove worktree: openat <path from
baseRepo>: path escapes from parent"}. <path from baseRepo> is the folder that
holds the leaf, relative to baseRepo. That text is claustrum's own. From the
code: claustrum opens that folder in one os.Root step, and the text is the
error of that open. For a junction at .claude or at .claude\worktrees the
path is .claude\\worktrees in the frame. f6010b97 answers {"success":true}
there, deletes nothing, and deletes the branch. The whole-build check of issue
442 measured 89cb6289 the same (Windows rows JC, J03 to J05, JCR1 and JCR2),
for a branch that another ref reaches. A branch tip that no other ref reaches is
not measured there. Two cells have a junction <P>\J above .claude, with the
worktreePath <P>\J\sub\.claude\worktrees\w1 and <P>\J\.claude\worktrees\w1.
89cb6289 answers {"success":true} there after 2 git calls, a listing and
rev-parse --absolute-git-dir. The claustrum build of this change answers the text with
J\\sub\\.claude\\worktrees and with J\\.claude\\worktrees, and runs no git
call. In both cells the leaf did not exist and no branch existed. Nothing
changed on disk on either side (Windows VM, cells J-b-wt-pj and J-b-wt-pJ). Not
measured: a live worktree behind such a junction, and the branch step there. See
DIVERGENCES.md → D19.
- The last component of <p> is checked first. In the repository no git runs before
this check. A symbolic link answers {"success":false,"error":"refusing to remove
worktree: <t> is a symbolic link, not a worktree directory"}. A file that is not a
directory answers refusing to remove worktree: <t> is not a directory. Nothing is
deleted. <t> is <p> with the symbolic links of baseRepo resolved. With
worktreeRoot, the links of the root are resolved instead. Under
macOS /tmp, <t> therefore reads /private/tmp/…. Before, a link to a sibling
worktree made claustrum delete that sibling, its entry and its branch.
- The entry comes from the .git file of <p>. The file must hold gitdir: and a
path of the form <something>/worktrees/<name>. The entry <git dir>/worktrees/<name>
must be a directory. Its commondir must lead back to <git dir>, or without
worktreeRoot to the git directory that rev-parse --absolute-git-dir answered in
baseRepo. Its gitdir record must name <p>. Without worktreeRoot, a record in another spelling of
<p> passes too: <p> with its baseRepo part as git names that folder. So a
request through a subst drive or a junction drops its entry (Windows VM, rows
D-subst, J2, Q1 and Q2). No row tests each of these steps on its own. The path match is exact, so on macOS a spelling of <p> that
differs only in letter case does not match. On Windows the match ignores letter
case and slash direction, because git records its paths with forward slashes there.
An 8.3 short name does not match on Windows, so such a remove keeps the entry. Rows
C01 and C02 measure the case rule. Rows N83_leaf and N83_base measure the 8.3 rule.
- If a verified entry holds a locked entry of any kind, it is locked. A dangling
symbolic link counts. Without a verified entry, the daemon checks every entry whose gitdir
record names <p>. If the worktrees directory exists but cannot be read, the reply
is the lock-check refusal above. A locked worktree answers the
is locked (git worktree lock) refusal, and nothing is deleted.
- claustrum refuses a worktree that is locked in the .git folder of baseRepo.
That is divergence D22. The one exception is cell L1, in the paragraph of a gone
worktree below. In four states 89cb6289 answers success for such a
worktree (rows p6, p6d, p6e and p6f, see the entry). claustrum answers the
locked refusal above, and in row p6f the gone-and-locked refusal below. If the
removal reads its entries from another git directory, claustrum also reads the
entries under <baseRepo>/.git/worktrees. It makes no git call for that, with
or without worktreeRoot. If that folder exists and cannot be read, the reply is
the lock-check refusal. With worktreeRoot and a lock in T, 89cb6289 deletes
nothing (rows q2 to q4 on a Linux VM). In rows q2 and q3 it answers the
transient could not verify reply below, with the detail open
<X>/.git/worktrees: no such file or directory. In row q4 it answers
{"success":true}. claustrum answers the
locked refusals there. A baseRepo with no .git folder of its own, such as a
subfolder of T, a linked worktree or a submodule, is not covered. With a daemon
GIT_DIR of another repository, claustrum deletes a worktree that is locked
there, as the reference does in row p6. A lock on an entry of X that names the
worktree is refused on both sides (row p6b). So is a lock with no daemon
environment (row p6c on Linux, macOS and Windows VMs, row p6g on a Linux VM).
See DIVERGENCES.md → D22.
- The delete first removes each entry of <p> except .git, in the order of the
directory read. It stops at the first failure. Then it removes the rest, .git
included, and <p> itself. Every delete goes through an os.Root, so no delete
follows a symbolic link out of <p> or out of its parent. That is a property of
os.Root, not measured. A failure answers {"success":false,"error":"failed to
remove worktree: RemoveAll <entry>: <errno text>"}, for example RemoveAll zz:
permission denied. If an entry other than .git fails, that entry, every entry
after it, and .git stay. If the removal of <p> itself fails at the end, <p>
stays empty and .git is gone. The entry of the worktree and the branch stay in
both cases. Both cases are measured on macOS, Linux and Windows.
- After the tree, the daemon deletes a verified entry. If that delete fails, the reply
is the could not drop its registration text of the catalog, and the branch
step still runs.
A kept branch adds "branchKept":true after error (rows R19 and B2-06, Linux VM).
Without a verified entry, the daemon deletes the one entry whose record names <p>.
If two entries match, or a match is locked, it deletes nothing. It does not
report a failure of that delete (not measured). A plain directory inside the repository is
therefore deleted, and the reply is {"success":true}. Treat worktreePath as a
path you ask the daemon to remove, not as a filter.
- With worktreeRoot, a <p> without a verified entry is removed in two cases only.
An empty directory is removed, and the request then goes on as for a gone worktree.
A stale worktree of baseRepo is removed too. Its .git names an entry of
<git dir> that is gone, or the whole <git dir>/worktrees is gone. A lock by path
is checked before both cases. Only the lock before a stale or damaged worktree is
measured. Any other path is refused and left in place, with
{"success":false,"error":"refusing to remove worktree: <p> is not a worktree of
<repo> (<reason>), so it is left in place; remove it by hand if it is a
leftover"}. Here <p> is the path as sent, a trailing slash included. <reason>
is one of <t> has no .git file, <t>/.git is not a regular file, <t>/.git does
not name a git dir, <t> carries a .git file that does not name this repository's
own worktree admin directory, or <t> carries a .git file naming an admin directory
whose own record is of a different worktree. Another read error gives the
transient reply {"success":false,"error":"failed to remove worktree: could not
verify that <p> is a worktree of <repo> (<detail>); retry"}. There <p> is the
cleaned path. Two inputs are measured, with git on PATH, on a Linux VM. Row
DG1c-g is a missing baseRepo and a .git file that names an entry of another
missing repository. 89cb6289 sends this frame there, and nothing is deleted.
The <detail> is the hooks refusal with chdir <baseRepo>: no such file or
directory. The one git call is the read of the user excludes. claustrum sends
that <detail> for every such remove with a baseRepo that does not exist. Only
row DG1c-g measures it. Row Q20 is a baseRepo that exists with no worktrees
directory. The <detail> is the error of the open there: open
<baseRepo>/.git/worktrees: no such file or directory. A refused daemon GIT_CONFIG_COUNT changes these answers (see
"The daemon's own git environment").
- For a worktree whose directory is gone, the daemon checks the registration by path.
A locked one answers the is gone but its registration is locked refusal. Otherwise the one entry whose record names <p> is
deleted. Here the baseRepo part of <p> also counts in its resolved form, with
junctions and 8.3 names resolved on Windows. The rest of <p> counts as sent. So a
baseRepo sent in 8.3 form or through a junction still finds a locked registration
when the worktree and its parent are gone (rows GL1 and GL2, with GL0 as the
control). With worktreeRoot, a root that does not exist is refused before
this path. A worktrees directory that cannot be read
does not stop this path: with worktreeRoot and no daemon environment the reply
is {"success":true} (row K13, Linux VM). On Linux and macOS, nor does a symbolic link, with a
locked entry behind it (cell L1 of round tier0-26, see the catalog row). If the removal reads its entries from
another git directory and <baseRepo>/.git/worktrees cannot be read, the reply
is the lock-check refusal (D22). Without worktreeRoot the case
is not measured. Without worktreeRoot, two more answers
come first. A configuration listing that fails, or a refused git directory, answers
the is locked (<reason>); retry refusal.
<reason> is the hooks refusal, the git cannot run text, a limit refusal or the
trust refusal text. A listing that says git finds no repository skips the
registration instead, and so does a PATH with no git. The reply is then success,
with branchKept when the branch step keeps a branch (see
Git-directory trust check). A baseRepo that holds
no repository answers {"success":true}, and so does one that does not exist.
The branch step runs on this path too (rows R17a and R17b). Without a repository its
for-each-ref fails, so a request that names a branch gets "branchKept":true
(rows R18 and R18b, Linux VM). A baseRepo that does not exist gets the member
too, and its only git call is the excludes read (row X3, Linux VM). There
f6010b97 answers a bare {"success":true}.
A refused daemon GIT_CONFIG_COUNT changes these answers (see "The daemon's own
git environment").
- The branch goes last, through the branch step below. A kept branch adds
"branchKept":true after success, or after error when there is one (rows R02
and R19). The member is only ever true. The step adds no text to the reply. A
branchName that is empty or starts with - or + is skipped. A request whose
branch the listing and rev-parse show absent answers a bare {"success":true}.
That is what "lenient" means here.
- Without worktreeRoot, a baseRepo that the daemon can open but not search
(mode 0600) answers {"success":false,"error":"failed to remove worktree: statat
.claude: permission denied"}. One that it cannot open at all (mode 0000) answers
failed to remove worktree: open <baseRepo>: permission denied. Nothing is
deleted in either case. Measured against f6010b97 on Linux and macOS VMs.
On Linux and macOS the statat text names the first component of <p> below
baseRepo. For a baseRepo of mode 0600, 89cb6289 answers statat wt for
<baseRepo>/wt (cell U19 on Linux and macOS VMs), also with the folder gone
(cell N4, Linux VM) and with mode 0400 (cell N5, Linux VM). It answers statat
a for <baseRepo>/a/wt and <baseRepo>/a/b/c/wt (cells N6a and N6b, Linux
VM), and statat .claude for a path under .claude (cell N7a, Linux VM). On
Windows claustrum names .claude. Windows is not measured.
- On Linux and macOS, a folder that the daemon cannot read or search is refused.
With worktreeRoot, that holds from the root down to <p>. Without it, that
holds between baseRepo and <p>. Nothing is deleted: the worktree, its entry and the
branch stay. Each text below follows failed to remove worktree:. 89cb6289
sends the same frames on Linux and macOS VMs. The daemon was not root.
| level and mode | text | rows |
|---|---|---|
worktreeRoot 0300, 0100, 0000 or 0200 |
open <root>: permission denied |
B3, B4, B5, B6 |
worktreeRoot 0600 or 0400 |
statat .: permission denied |
B1, B2, B18x (macOS VM only) |
<directory> 0300, 0100 or 0000 |
openat <directory>: permission denied |
B10, B11a, B11b |
<directory> 0600 or 0400 |
statat .: permission denied |
B8, B9 |
the worktree folder 0300, 0100 or 0000, with worktreeRoot |
openat <name>: permission denied |
B14a to B14c |
the worktree folder 0600 or 0400, with worktreeRoot |
statat .: permission denied |
B13a, B13b |
.claude 0600, without worktreeRoot |
statat .: permission denied |
B17a |
.claude 0300, without worktreeRoot |
openat .claude: permission denied |
B17b |
.claude/worktrees 0600, without worktreeRoot |
statat .: permission denied |
B17c |
.claude/worktrees 0300, without worktreeRoot |
openat worktrees: permission denied |
B17d |
<root> is worktreeRoot with its symbolic links resolved. A request that
spells /tmp/… on macOS gets /private/tmp/… in the text (row B18, macOS VM only). <directory>
and <name> are one path component each. A worktreeRoot of mode 0500 does not
stop the removal (row B7), and a .claude of mode 0500 does not either (cell
U12). If the folder above worktreeRoot has mode 0600, the text is lstat
<root>: permission denied (row B16). With mode 0300 or 0100 on that folder,
the removal succeeds (cells U16a and U16b).
A second round measured more states against 89cb6289 on Linux and macOS VMs.
Nothing is deleted in the states of this list.
- With two restricted levels, the first one from the top gives the text (cells
U7 to U10b).
- A worktree folder, a <directory> or a .claude/worktrees that is gone
under a restricted level gets the text of that level. The entry and the
branch stay (cells U1 to U6b).
- The other modes of .claude and .claude/worktrees follow the table: 0000,
0100 and 0200 give openat, and 0400 gives statat . (cells U11a to U11h).
- A worktreeRoot of mode 0300 answers open <root> before these refusals: a
<directory> that is a symlink (cell U13b, and cells N1a to N1c on a Linux
VM for modes 0100, 0000 and 0200), a locked worktree (cell U14),
and a worktree path that is a symlink or a file (cells U15a and U15b). With
mode 0600 and a symlinked <directory>, the text is statat . (cell U13a).
- A worktree at <baseRepo>/a/wt or <baseRepo>/a/b/c/wt gets the same texts
for a restricted a or b: openat a, openat b or statat . (cells U17a
to U18b).
- A worktreeRoot that another user owns, with mode 0711 or 0751, answers
open <root> (cells U20a and U20b). On a macOS VM, an ACL on the root that
denies list to the daemon user does the same (cell U21).
A daemon that runs as root removes the worktree under a worktreeRoot of mode
0300, as 89cb6289 does (cell U22, Linux VM only). Windows is not measured. On
Windows claustrum opens .claude\worktrees in one step, as before.
- A home-directory worktreePath is refused. The 7d193f89 containment now does
it, as parity. A ~-expanded home path is not strictly under baseRepo, so it is
refused with the reference's "…is not inside the repository…" wording before any
delete. The claustrum-only D2 frame ("worktreePath must not be or contain
the home directory: …") is now behind that containment on this method's default
branch. It fires only in the exotic case of a repository that is itself an
ancestor of home. On the worktreeRoot / external_root branch the in-repo
containment does not apply, so there D2 is the active home guard. D2
remains the primary guard for files.extract_tar, which gained no containment. See
DIVERGENCES.md → D2.
- A relative worktreePath is refused upfront ("…is a relative path…"). Send an
absolute path under the repository.
- gitTimeout (D5) is off by default. On this method the deadline starts no delete.
A kill refuses or skips a step. One skipped step is the daemon's own read of the
repository config. After a kill of that call the remove goes on as it did before
that read. When a hit stops the config or repository
check, the request refuses, or it skips the registration of a gone worktree. With
worktreeRoot that hit answers the work-tree refusal, and so does a hit on
rev-parse --show-toplevel or the first worktree list call. A hit on the second
worktree list call answers the cannot list the repository's worktrees text. A
D5 stop of a call of the branch
step keeps the branch, and the reply adds "branchKept":true. Not measured. See
DIVERGENCES.md → D5.
The branch step¶
Since 89cb6289 the daemon checks a branch before it deletes it. It deletes the
branch only when another local branch or a remote-tracking ref reaches its tip.
Else it keeps the branch. git.worktree_remove runs this step after the worktree
directory and its entry go. The git.worktree_create rollbacks run it on the
branch that the call made. f6010b97 deleted the branch with no check. The rules
below were measured side by side against f6010b97 and 89cb6289. L, M and W name
the Linux, macOS and Windows VMs of each row. <b> is branchName as sent.
- No call runs for an empty
branchName, or for one that starts with-or+(rows R12e, R12d and R12p, L). - Each call runs in
baseRepo, right after its own light config listing (see Hardened git calls). The calls, in order: for-each-ref --count=10001 --format=%(objectname)%00%(refname)%00%(symref) refs/heads/. The format is one argument. Each record is the object name, a NUL byte, the refname, a NUL byte, the symref target and a newline.rev-parse --verify --quiet refs/heads/<b>^{commit}. It runs only when the listing does not holdrefs/heads/<b>.rev-list -n 1 <tip> --not --remotes --not --stdin --.<tip>is the full object name in the record ofrefs/heads/<b>.update-ref --no-deref -d refs/heads/<b> <tip>.f6010b97sent no old value. With it, git refuses the delete when the branch moved after the check.- Every call carries the light
-cset. rev-list adds-c core.commitGraph=falseafter it. The daemon's own environment comes first, as for every hardened call (see "The daemon's own git environment"). Then comeGIT_TERMINAL_PROMPT=0,GIT_NO_REPLACE_OBJECTS=1,GIT_GRAFT_FILE=<null device>, theGIT_COMMON_DIRpin, the count, the inherited pairs, the hook pins,GIT_NO_LAZY_FETCH=1,GIT_ALLOW_PROTOCOL=denied_by_claude_sshandGIT_ASKPASS=, in that order (row R01, L M W). Only rev-list gets a pipe on stdin. The other calls get a character device (macOS VM). - With no repository at
baseRepo, both references putGIT_DIR=<null device>in the slot of the pin, and claustrum does not. Both references also runrev-parse --absolute-git-dirthere, which exits 128, and claustrum does not. The frames are equal in rows R18 and R18b (L). This is a gap in the calls only. - Both references run
--git-dir=<T>/.git --work-tree=<T> rev-parse --show-toplevelin<T>/.git, after its listing. They run it on the gone path of a remove, before a second create at the same path, and before an attach add. claustrum does not. The frames are equal in rows R17a, R17b, C06, C06b, C06c and C09 (L). This is a gap in the calls only. - The steps, in order. "Keep" means that the branch stays.
- for-each-ref fails or is stopped: keep (rows R18 and R22a, L, B2-02, M W). A
listing that does not parse keeps the branch too. That is claustrum's choice
(not measured). If the configuration listing before for-each-ref exits 128
with
fatal: not a git repository, for-each-ref does not run. The branch is then kept (89cb6289, rows L14a, L14b, L14d, L14e and N01 to N04, L M). - The listing holds 10001 records: keep. This comes before step 4, so a branch past the 10001st record is kept with no rev-parse (rows R15a and R15c, L M W). With 10000 heads the whole check runs (row R15b, L M W).
- A listed refname differs from
refs/heads/<b>only in letter case: keep (rows R16 and R16b, L M W). claustrum folds case with Go'sstrings.EqualFold, which also folds letters outside ASCII. That is claustrum's choice (not measured). - The listing does not hold
refs/heads/<b>: rev-parse runs. Exit 1 means the branch is absent, and nothing else runs (rows R11, L M W, and B2-09d, L). If rev-parse exits 0 or fails another way, claustrum keeps the branch. A rev-parse stopped at a bound is such a failure, also on W, where the kill gives exit code- That is claustrum's choice (not measured).
- rev-list gets one
^<refname>line for each other listed record, in listing order. A record with a symref target is left out, and so isrefs/heads/<b>(rows R04, R08 and B2-05). The stdin is empty when<b>is the only branch (rows B2-04a and B2-04b, L). A printed commit keeps the branch (row R02, L M W). A failed or stopped rev-list keeps it too (rows B2-01 and B2-03, L). - update-ref runs. A failure or a stop keeps the branch (rows R14, L M W, and R23a).
- Another local branch at or past the tip reaches it, and so does a remote-tracking
ref (rows R03, R04, R04b and B2-04b). A tag, a detached HEAD, a symref and a branch
at a parent commit do not (rows R05 to R08 and B2-04a). A branch checked out in
another worktree gets no guard of its own (rows R10a and R10b, L). With a symref as
branchName, update-ref deletes the symref only (row B2-05, L). - The measured bounds are parity. They are always on, and they are not a D-series divergence.
- One bound of 60 s covers for-each-ref and rev-list together. At the bound the running call is killed, with no SIGTERM first. In row R22a (L) for-each-ref ended 60.002 s after its start. In row B2-01 (L) for-each-ref used 40 s, and rev-list got the other 20 s. On W the call ended at about 59.9 s with exit code 1 (row B2-02). On M it ended at about 61.2 s, in a run with one VM stall.
- claustrum starts the bound right before the config listing of for-each-ref. The bound also covers rev-parse. Both are claustrum's choice (not measured).
- update-ref gets SIGTERM 5 s after it starts, on L and M. A call that ignores it is killed 5 s later (rows R23a, R23b, R23a1 and R23b1). On W it is killed at about 5 s, with exit code 1 (row R23a). The 5 s count from the start of update-ref, after its config listing.
- The same stop holds in the create rollback.
f6010b97already stopped update-ref there, and the undo text ends: signal: terminated(row B2-09e, L M). A rollback update-ref that ignores the SIGTERM is killed 5 s later, as on remove. That is claustrum's choice (not measured). - Only the git process gets the SIGTERM or the kill, not its children. That is claustrum's choice (not measured).
- The caller's
timeoutMsdoes not stop the step (rows C04 and B2-10, L). - With
-git-timeout(D5) opted in, each call and its config listing also get their own D5 deadline, as every hardened call does. On update-ref that stop is the SIGTERM above. - Within one daemon, the check and the delete of a branch run one at a time for one
repository, also through a linked worktree. Two removes of worktrees whose
branches share a unique commit then leave one of the two branches. The reply that
comes second carries the member. This holds for one
baseRepo(rows R21, L, and B2-12, M) and through a linked worktree (row R21b, L). The branch steps of different repositories run at the same time (row B2-08, L). The rows do not tell a lock on the common dir from one on the main worktree. claustrum keys the lock by the git dir of the entries, the common dir. That is claustrum's choice (not measured). A create rollback takes the same lock (not measured). - claustrum still runs two requests with the same
baseRepoone after the other, as before.89cb6289deletes both worktree directories first (row R21, L). No frame shows that difference.
The remove frame never gains text from this step. A kept branch adds only
"branchKept":true. Every kept case of steps 1 to 6 sets it on remove (rows R02,
R14, R15a, R16, R18 and R22a).
The create rollback adds a text part after ; and the undo could not finish for
<leaf>:, and sets the member in one case only:
| outcome | part | member | rows |
|---|---|---|---|
| rev-list printed a commit | the worktree itself was removed, but branch <b> was left in place: it now has commits no other branch or remote-tracking ref reaches. To keep that work, push the branch or rename it. To discard it, run git branch -D <b>. This branch name cannot be reused until the branch is renamed or deleted |
"branchKept":true |
C02 (L M W), C04, C09, C10, B2-10 (L) |
| the same, after a failed leaf rmdir | <rmdir text>; branch <b> was left in place: it now has commits … with no the worktree itself was removed, but |
"branchKept":true |
C05 (L W) |
| 10001 records, a case twin, a listing that does not parse, or rev-parse exit 0 | branch <b> still exists; delete it by hand (for example: git branch -d <b>, which git refuses if that would lose commits) before retrying this branch name: could not check whether another branch or remote-tracking ref reaches its commits |
none | C06 (L M W), C06b, B2-09c (L) |
| for-each-ref, rev-parse or rev-list failed or stopped | the same, then a space and the process error in parentheses, for example (exit status 128) or (signal: killed) |
none | B2-09a, B2-09b, B2-09f (L) |
| update-ref failed or stopped | branch <b> still exists; delete it by hand (for example: git branch -D <b>) before retrying this branch name: <process error>, for example exit status 128 or signal: terminated |
none | C07a, B2-09e (L), B2-09e (M) |
the same, with refs/heads/<b>.lock present |
… before retrying this branch name. A lock refs/heads/<b>.lock is also present: if another git process is running against this repository, the lock may be live and clears on its own; if none is, it is stale debris of the interrupted delete — remove the lock file by hand too: <process error> |
none | C07b (L) |
the name starts with +, so no call runs |
branch <b> left in place (its name is unsafe to pass to update-ref); delete it by hand |
none | X1, X2 (L) |
| the branch is absent at the check, or deleted | none | none | B2-09d, C01, C03, C06c (L), C01 (M W) |
- The other rows used
sonly. claustrum puts<b>in each place wheresstands. The dash in the lock text is the raw UTF-8 em dash U+2014. - When claustrum's home or identity guard skips the leaf, the leaf stays. A kept
branch then gets its text without
the worktree itself was removed, but. No honest input reaches that path. It is claustrum's own (not measured). - The rows with a parse failure and with rev-parse exit 0 or another rev-parse failure are claustrum's choice (not measured).
- The text of a skipped name is older than
89cb6289.f6010b97gives the same bytes in rows X1 and X2 (L), whatever reaches the branch. A name that starts with-gets the same text. That is claustrum's choice (not measured). The create does not reach it, because git refuses-b -<name>(host git, not measured on a VM). - The lock part appears when the file
<common git dir>/refs/heads/<b>.lockexists after the failure. The rows do not tell whether the references test the file or read git's stderr. claustrum tests the file. It does so after a stop too. That is claustrum's choice (not measured). - On W a stopped call ends with exit code 1. A W rollback text then ends
exit status 1or(exit status 1)where L and M show a signal. W did not measure these texts. - A rollback that fails in two places, other than C05, joins the two parts with
;in the same order. That is claustrum's choice (not measured). - The texts of rows C07a, C07b and B2-09e are older than
89cb6289, and so is the 5 s stop of the rollback's update-ref.f6010b97gives the same bytes (L, and M for B2-09e).
launcher.* (added 89cb6289)¶
A host administrator names a managed launcher in the managed settings. The
managed launcher is a program that runs the Claude Code CLI on this host. The
daemon puts its argv in front of the CLI command. Three surfaces use it:
launcher.resolve below, the launcher param of
process.spawn, and the launcher runs of -install and
-probe-cli. Every rule below is measured against 89cb6289 on a Linux VM
unless it says otherwise. A rule that names macOS or Windows was measured there too.
launcher.resolve¶
{cliPath} → one of four shapes. Each shape keeps this field order:
{"status":"none"}
{"status":"usable","argv":["<launcher>","<arg>",…],"source":"<file>"}
{"status":"unusable","source":"<file>","reason":"<text>"}
{"status":"unreadable","reason":"<text>","path":"<file or folder>"}
- No
paramsmember, a non-objectparamsor a non-stringcliPath→-32602 Invalid params.{},"params":null, anullor emptycliPath, or a wrong key (wrong key: Windows only) →-32602 cliPath is required. Any other string is acliPath, a relative one too. Anotherlauncher.<x>method →-32601 Unknown method: launcher.<x>. The params checks are the same on Windows. - The method reads the settings files on every call. It caches nothing, so an
edit shows on the next call. It never runs the launcher. The
CLAUDE_SSH_MANAGED_LAUNCHERgate does not change a frame (measured on macOS). - claustrum does not
~-expandcliPath. That is claustrum's choice (not measured).
The settings folder and its files:
- The folder is
/etc/claude-codeon Linux and/Library/Application Support/ClaudeCodeon macOS. The base file ismanaged-settings.jsonin it. The drop-in folder ismanaged-settings.din it. CLAUDE_SSH_E2E_MANAGED_SETTINGS_DIRmoves the folder only when the system folder has neither the base file nor a drop-in. With the system folder absent,89cb6289reads the variable's folder and logs nothing (row RX, measured on Linux and macOS). A base file in the system folder makes it ignore the variable and read the system folder (row RXs, Linux and macOS). A drop-in there does too (row RXd, Linux). A hidden.jsonentry alone inmanaged-settings.dcounts as well (row RXd2, Linux). The resolve then answers from the system folder and skips that entry, so it answers{"status":"none"}. It then logs[launcher] CLAUDE_SSH_E2E_MANAGED_SETTINGS_DIR ignored: <system folder> holds this host's policyonce per daemon (row RXs: two resolves, the line only on the first).<system folder>is the folder of the OS. An existing but empty system folder does not count (row RXe, Linux). An empty value counts as unset, and the system folder is read (row RY, measured on Linux and macOS). claustrum's choices (not measured) follow. A base file counts when its path exists, a dangling symlink too. A base file counts whatever it holds,{}or an unreadable file too. Amanaged-settings.dcounts when it holds an entry whose name ends in.jsonand that is a file or a symlink, a dangling symlink too. A non-.jsonentry and a folder entry do not count, as for the drop-in rule. More than two resolves still log one line.-installand-probe-clihonor the variable the same way and log nothing.- On Windows
89cb6289answered{"status":"none"}for every fixture tried. The fixtures were a file inC:\Program Files\ClaudeCode(both keys, also with the gate), the E2E folder andC:\ProgramData\ClaudeCode. The launcher never ran. claustrum answers none on Windows and reads no file. - A missing or empty folder answers none, with no log line. So does a base file
of
{}, of zero bytes or of white space only. - The base file is read first, then the drop-ins in name order. A later file
with a value wins, and
sourcenames that file. A missing base file is fine. - A drop-in name ends in
.jsonin lower case and does not start with a dot. So.hidden.json,x.JSONanda.jsare skipped. A folder entry and a FIFO entry are skipped. The FIFO is not opened. - A symlink entry is followed.
sourcethen keeps the link path. A dangling symlink is skipped. A symlink to a folder makes the answer unreadable, withis a directory. - A regular file named
managed-settings.dis ignored. - A value of the wrong type in a later file is skipped, and the earlier value
stays. Rows:
{"processWrapper":5},{"env":"x"}and{"env":{"CLAUDE_CODE_PROCESS_WRAPPER":true}}. - One unreadable file makes the whole answer unreadable, and
pathnames it. A valid base file does not help. - claustrum's choices (not measured) follow. The drop-ins sort in byte order. The
first unreadable file in read order is the one reported. Each file gives one
value, so a later file's
processWrapperwins over an earlier file's env key.
| unreadable file | reason |
|---|---|
| mode 000, daemon not root (Linux and macOS) | permission denied |
| a folder | is a directory |
| a FIFO (answered at once, not opened) | not a regular file |
| more than 2097152 bytes (2097152 is read) | larger than 2 MiB |
{ |
not valid JSON |
[] or null |
not a JSON object |
| a UTF-16BE BOM, or a raw U+00A0 before the JSON | not valid JSON |
| a drop-in folder whose listing fails | the drop-in directory could not be listed: permission denied, with the folder as path |
claustrum gives a regular-file drop-in, and the target of a drop-in symlink, the
same reasons (not measured, except is a directory and not valid JSON).
A UTF-8 BOM is dropped. A UTF-16LE BOM with UTF-16LE text is read. Both answer usable. claustrum drops a trailing odd byte of UTF-16LE text. That is claustrum's choice (not measured).
The value:
- Two keys carry it:
env.CLAUDE_CODE_PROCESS_WRAPPERandprocessWrapper, each a string. Both keys gave the same frames on every R10 and R11 row. In one file the env key wins, in both key orders. - An env value of
""counts as unset, soprocessWrapperanswers. An env value of three spaces counts as set with no launcher. claustrum also counts aprocessWrapperof""as unset. That is claustrum's choice (not measured). - A value that starts with
[, after leading white space, is a JSON array of strings. JSON escapes decode, and the metacharacter check does not apply. - Any other value is an argv list, not a shell command. A space, U+00A0 and U+FEFF
split words. U+0085 does not. Double quotes group, so a quoted metacharacter is
fine. Inside double quotes
\"gives"and\\gives\. Any other backslash stays, so"e\nf"keeps its two characters. Outside quotes a backslash is a plain character, and a"after it opens a quote. Single quotes are plain characters. - An invalid byte in the file comes out as U+FFFD.
- claustrum's choice (not measured): the other white space of Go's
unicode.IsSpace, except U+0085, splits words too. The scan stops at the first unquoted metacharacter.
The reason texts. The frame escapes &, <, > and " as JSON does. The log
line writes them raw. — is the raw bytes e2 80 94 in both.
| case | reason |
|---|---|
no launcher: three spaces, "", [] or [""] |
the value is set but contains no launcher — unset it to run without one, or set it to the absolute path of your launcher |
a later empty JSON element, ["<w>",""] |
the JSON array contains an empty element — remove it, or fill in the value it was a placeholder for |
| a JSON element that is not a string | JSON form must be an array of strings |
starts with [ and is not valid JSON |
value starts with `[` but is not valid JSON |
an unquoted ; \| & $ ( ) backtick < > |
the value contains an unquoted shell metacharacter (one of ; \| & $ ( ) ` < >) — it is an argv list, not a shell command |
| an open double quote | unterminated double quote |
a later empty "" word |
the value contains an empty `""` token — remove it, or fill in the value it was a placeholder for |
| argv[0] is not absolute | the launcher must be an absolute path, not a bare name resolved via PATH |
argv[0] equals cliPath |
launcher `<argv0>` is Claude Code's own path |
argv[0] ends in .js, .mjs, .ts, .tsx or .jsx (case-sensitive) |
launcher `<argv0>` is a script the SDK would run in place of Claude Code |
| argv[0] is missing, a folder or not executable | launcher `<argv0>` does not exist or is not an executable regular file |
The launcher checks:
- They check argv[0] only. The launcher args are not checked against files.
- The own-path check is a string compare. It comes before the absolute check and
before the existence check. So
cwithcliPathcis the own-path text, and a missing/opt/claude/cliwith thatcliPathis too. - A symlink to an executable file is usable, and
argvkeeps the link path. argv[0] is quoted as parsed, not cleaned or resolved. - A long argv[0] is cut in the text. One row is measured: a 300-byte path with a
3-byte character at bytes 254 to 256 kept 254 bytes, then
…(e2 80 a6), inside the backticks. The log line shows the same cut, andargvkeeps the whole path. claustrum's choice (not measured): the limit is 256 bytes, cut back to the start of a character, for the own-path, script and existence texts alike. - claustrum's choice (not measured): the script check comes before the existence
check, so a missing
x.jsgets the script text.
Output bytes follow Go's default JSON encoder. In a usable frame <, &, >
and U+2028 come out as JSON escapes. U+0085, U+FFFD and — stay raw.
Log lines. An unusable or unreadable answer writes one line. A usable or none answer writes none:
[LauncherHandler] managed launcher from <source> refused: <reason>
[LauncherHandler] managed settings unreadable: <path>: <reason>
A repeat of the same answer writes no line. A usable answer between two equal
refusals makes the second one log again. A process.spawn refusal writes no
[LauncherHandler] line. claustrum's choices (not measured) follow. The repeat
test compares the whole line. A none answer resets it like a usable one. The
lines log at WARN.
process.* (the agent/MCP-hosting core)¶
The client supplies its own id, which is any string. The daemon delivers output
as id-less stream notifications, and it buffers them for a later replay.
process.spawn¶
{id,command[,args][,cwd][,env][,disableShellAgentSocket][,launcher][,wantPid]} → {"success":true}, then stream frames
- args: string[]. env: {KEY:VAL}, merged over the daemon environment.
- Missing id → -32602 Process ID is required. Missing command →
-32602 Command is required.
- A request with the id of a process that still runs succeeds and replaces the
table entry. The first process gets no signal and keeps running. A
process.kill of that id then reaches the second process only (row A2). In
claustrum process.stdin and process.reattach name the second process too.
-stop and server.shutdown end only the processes of the table, so the first
process outlives the daemon (row A3). Linux and macOS VMs measured this against 89cb6289 (rows
A1 to A4), and a Windows VM too (rows EV and EVInh). On Linux and macOS the
record of the first process stays, and the next daemon start ends that process
from it (row A4). The first process keeps its own connection. On a Linux VM
(row P2, two runs), the first process of 89cb6289 wrote and ended after the
replace. Its stream frames still came on its own connection, under the same
processId, with seq 2 to 9. Its exit frame came there with seq 10. The
connection of the second process got the frames of the second process only.
macOS row P2 and Windows row EVc gave the same split of the frames on
89cb6289 and on claustrum. Three more Linux rows measured two processes under
one id. 89cb6289 and claustrum gave the same
frames in each:
- Both spawns come on one connection (row N2a). That connection gets both
streams under the one processId. Each process counts its own seq, so
seq 1 to 9 and an exit frame with seq 10 come twice.
- The connection of the first process sends process.reattach for the id
with fromSeq 0 (row N2b). The reply names the second process
("firstSeq":1,"lastSeq":2), and its two frames come before the reply. The
connection of the second process gets EOF. The first connection then
carries both processes up to both exit frames.
- The first connection closes, and a third connection sends
process.reattach with fromSeq 5 (row N2c). The reply is
{"found":true,"running":true,"firstSeq":1,"lastSeq":3,"stdinApplied":0}
with no replay. The second connection gets EOF. The third one gets seq 4 to
9 and the exit frame of the second process, and no frame of the first.
- Session superseding is 4534d86 parity. A process.spawn whose args name a
stream-json CLI session terminates any OTHER running process of the SAME session
id. Such args carry an --input-format=stream-json or
--output-format=stream-json arg, or a bare stream-json arg. They also carry a
valid --session-id or --resume token, with the resume fallback suppressed by
--fork-session. The superseded process is killed through the
SIGTERM-then-SIGKILL path, and its exit frame reaches its client. That
frame carries killedBy:"client" (rows B1 to B3 below). A spawn with
no session key, or with a different session id, supersedes nothing. claustrum reads
the argument after a bare flag as its value, as 5fd08069 does for --resume
(K1, K2, Linux VM). claustrum also serializes concurrent spawns of one session.
The daemon ends the other process and waits for its end before it starts the new
one. The reply of the new spawn therefore comes after the exit frame of the old
process. Linux and macOS VMs measured that against 89cb6289 (rows B1 to B4, two
runs each):
- The old process exits on SIGTERM: the reply follows at once (row B1). Its
group gets no SIGKILL (Linux row X2a).
- The old process ignores SIGTERM: the daemon sends SIGKILL to its group
after 3 s, and the reply follows (row B2).
- The old process exits, and a process in another group holds its pipes: the
exit frame and the reply come 5 s after the request (row B3).
- A spawn with the id and the session of a process that still runs
supersedes nothing and sends no signal (row B4).
The daemon log holds the exit line of the old process, then the supersedes
line, then the started line of the new process. A Windows VM measured the same
order of exit frame and reply on 89cb6289 and on claustrum (rows SS and SSInh,
3 runs each).
There the first child ended and its descendants lived on. Not measured: two or
more other processes of one session. claustrum ends them one after the other.
- A session spawn whose command does not exist supersedes nothing. On a Linux VM
(rows P3 and H1) and on a macOS VM (row P3), 89cb6289 answered
-32603 fork/exec <path>: no such file or directory. The old
process got no signal and kept its record. claustrum is built to those rows and
tests the command after it builds the child environment, before the
supersede.
- The test is one stat of the command path. Linux rows H1 to H6 and H9
measured it with strace. 89cb6289 and claustrum made the same stat
calls and gave the same replies in each row:
- The path is read as the request gives it, and it is not joined with cwd.
A session spawn of ./tool with a cwd that holds tool answered
fork/exec ./tool: no such file or directory (row H3). The same request
with no session arguments started.
- A spawn with no session key runs no stat of the command (row H2).
- A command that exists and does not start passes the test. A file of mode
0644, a directory and a script with a missing interpreter each got the
supersede first. The old process got SIGTERM and ended, and the start
error came after it (rows H4a to H4c).
- A bare command name that the lookup does not find answers
exec: "<name>": executable file not found in $PATH. The old process
gets no signal (row H9).
- A cwd that is missing or is a file answers its own error, and the old
process gets no signal (rows H5a and H5b). That test comes before the
PATH read of the first spawn (row H6) and before the stat of the
command (row H3).
With a launcher, the fifth check below is that test.
- On Windows the lookup answers first. Then claustrum runs one stat of an
absolute command path. On a Windows VM (row SSm-path, 3 of 3 runs),
89cb6289 answered
-32603 fork/exec <path>: The system cannot find the file specified. for a
missing path that ends in .exe, and the old process lived on. claustrum is
built to that row. A bare name (row SSm-bare) and a path with no extension (row
SSm-noext) answered exec: "<name>": executable file not found in %PATH% on
both, with no supersede. Not measured: a relative path. claustrum runs no
stat of it.
- A -stop can come while a spawn waits for the old process. Linux and macOS
VMs measured that against 89cb6289 (row P1, 7 Linux runs and 2 macOS runs). The old process
did not end on SIGTERM, and -stop came 1 s after the spawn request. The spawn got
no reply, and no connection got an exit frame. The new process never started.
The daemon sent SIGKILL to the group of the old process and removed its
record. -stop printed stopped and exited 0. The log held no supersedes
line and no exit line. claustrum gave the same events. Its log differs: build
ac5cadb wrote the exit line of the old process and the supersedes line in
1 of 6 Linux runs and in 2 of 2 macOS runs. That difference is open. See
UPSTREAM-TRACKING.md.
- A spawn whose child start returns after the shutdown began gets no reply, and
claustrum ends its child, also with -keep-children
(CT-2). 5fd08069 left late children running (round
task003-01: Linux 64, macOS 1081, Windows 2). claustrum left none (round
task003-04, no -keep-children).
- The SSH agent hand-off is f6010b97 parity on linux and darwin, advertised as
process.spawn.shellAgentSocket, and measured on both. The daemon builds the
child env from its own env, the login-shell PATH and the caller's env. If that
env has no SSH_AUTH_SOCK entry, the child gets SSH_AUTH_SOCK=<socket> after
the caller's entries. The socket is what the user's login shell exports.
- An entry that is already present wins, even with an empty value, such as
"SSH_AUTH_SOCK":"" in the spawn env.
- "disableShellAgentSocket":true also skips it. A non-bool value answers
-32602 Invalid params, and null counts as false.
- The daemon runs <shell> -l -i -c … on the first spawn that needs a socket,
not at startup. The shell choice is the one the PATH extraction uses: $SHELL
when it is executable, then /bin/zsh, /bin/bash and /bin/sh. The run has
a 4 s deadline, and a shell that the kill cannot end is given up on at 5 s.
The first spawn of a daemon also runs the PATH read, which has its own 4 s
deadline. On a Linux VM with a shell that sleeps 10 s, the first spawn of
89cb6289 answered after 8 s (row G3).
- A socket that does not accept a connection within 250 ms is not handed on. The
daemon caches the answer and runs the login shell again only 10 minutes after
the last run. After 3 failed runs in a row it stops asking. A connection
attempt that has not returned after 500 ms ends the hand-off for the life of
the daemon.
- A spawn never fails because of this step. At the default log level, each
login-shell run and each give-up writes a [shellenv] line to the daemon log.
A spawn served from the cache, or skipped, writes none.
- On Windows the capability and the param exist, but spawn runs no login shell
and adds no SSH_AUTH_SOCK. Measured with a Git bash $SHELL whose profile
exports a live socket: neither daemon starts it.
- On Windows the child gets the daemon's own PATH, when it is not empty, as its PATH entry. A Windows VM
measured the rows below against 89cb6289, 3 runs each.
- claustrum reads the daemon's PATH once, at the first spawn (from the code). A
second spawn got the same entry as the first on 89cb6289 (row R8). No shell
runs.
- When that value is not empty, the child env gets an entry named exactly PATH
with that value. An entry named PATH is replaced in place, and otherwise
PATH= is added. Then the caller's env comes.
- Go's os/exec keeps the last of the entries whose names differ only in case
(from the code). So a daemon entry named Path with a value does not reach the
child.
- A daemon with Path=<value> gave the child PATH=<value> and no Path entry
(row R1). A daemon with PATH=<value> gave PATH=<value> (row R6b).
- With a daemon Path entry, a caller Path value was lost, and the child got
the daemon value as PATH (row R4). A caller PATH or path value reached the child with its own name
(rows R3 and R5).
- A daemon with no path entry gave the child none (row R6). A daemon Path with
an empty value stayed empty, and no PATH entry came (row R6c).
- The CLI child of -install keeps Path (row R2). It is no process.spawn child.
- claustrum is built to these rows. Its Windows unit test checks rows R1 and R4,
with rows R3 and R6 as controls.
- These rows give the set of entries, not their order. On Windows the block of
a claustrum child is in name order. 89cb6289 keeps the order of its
launching block, then CLAUDE_SSH_DAEMON_CHILD=1, then the added entries
(Windows VM rows V1 to V10). That is DIVERGENCES.md → D23.
- The launcher param is 89cb6289 parity: a string array that names a
managed launcher. The child runs as <launcher...> <command> <args...>. The
launcher is the process, and its stream frames are its own.
- launcher absent or null means no launcher, and the command runs directly. A
non-array launcher or a non-string element → -32602 Invalid params, with no
log line. A null element becomes an empty arg.
- The checks run in this order. First, on Windows any non-null launcher, []
too, → -32004 the managed launcher cannot be used: a launcher cannot be
applied on a Windows host. The command does not run.
- Second, a relative command with any non-null launcher →
-32602 command must be an absolute path when a launcher is given.
- Third, the launcher checks of launcher.resolve run, with
the command in place of cliPath. A refusal →
-32004 the managed launcher cannot be used: <reason>. [] gives the
no-launcher text.
- Fourth, the cwd check gives its usual frame (its place before the command
check is inferred from log order). Fifth, a command that is missing, a folder
or not executable gives the same -32603 fork/exec <command>: <reason> frame
as a spawn without a launcher. The launcher does not run. Measured for a
missing file, a folder and a 0644 file, on Linux and macOS.
- Sixth, a launcher that passes the checks but does not start →
-32005 the managed launcher <path> could not be started: <reason>. A missing
#! interpreter, or a script with Windows CRLF line endings, gives
its interpreter was not found (the program on its #! line, or the loader of an ELF binary; a script saved with Windows CRLF line endings fails this way).
A 0755 text file with no #! gives exec format error. macOS gives the same
interpreter text (S6a only). claustrum gives the same frames through its
exec-child trampoline (tested, not VM-measured).
- claustrum's choices (not measured) follow. The Windows refusal comes before
the relative-command check. The bad-command check comes before the launcher
starts. Any other start failure gives its errno text.
- The log lines are [process.Manager] Failed to start process <id>: <message>
for each refusal, and
[process.Manager] Process <id> started, PID=<n>, command=<command> via launcher <argv0>
for a start. The started line names argv[0] only. A refusal writes no
[LauncherHandler] line.
- A spawn does not read the managed settings. Rows SP-a and SP-b measured this
on Linux: with a usable launcher in the settings, a spawn without the param
runs the command directly, and a spawn with the param runs the launcher it
names. The caller resolves the launcher with launcher.resolve and passes it
here. The
CLAUDE_SSH_MANAGED_LAUNCHER gate does not change a spawn.
- Every spawned child loses CLAUDE_SSH_MANAGED_LAUNCHER and
CLAUDE_SSH_E2E_MANAGED_SETTINGS_DIR (89cb6289 parity, measured on Linux and
macOS). A child with a launcher also loses CLAUDE_CODE_PROCESS_WRAPPER. A
child without one keeps it. The strip covers the
daemon env and the spawn env param. claustrum strips on Windows too. That is
claustrum's choice (not measured).
- No child of the daemon inherits CLAUDE_SSH_PEER_CHECK. The daemon removes
the variable from its own environment after the read at its start, with any
value. That covers a process.spawn child, a git call, a launcher run and the
login-shell read. Measured on 5fd08069:
- A process.spawn child had no such entry for the daemon values 1 and
0 (Linux, macOS and Windows VMs).
- The git calls of one git.info had none for the value 0 (Linux VM).
- A marker variable of the daemon arrived in each of those children.
- With the lower-case name claude_ssh_peer_check=1 in the daemon
environment, the child had no entry of that name in any letter case
(Windows VM).
- The start environment of the daemon process still showed the variable
(/proc/<pid>/environ on a Linux VM, ps eww on a macOS VM).
The launcher runs and the login-shell read of 5fd08069 are not measured.
A build of claustrum with this removal gave the same child rows on those VMs,
the lower-case row on the Windows VM included. A removal that fails gets one
WARN line of claustrum's own, and the children then inherit the variable. A spawn
env param that names the variable reaches the child, as on 5fd08069
(Linux, macOS and Windows VMs).
- wantPid is a claustrum-only opt-in, CT-1. With "wantPid":true the reply gains
two fields after success: {"success":true,"pid":<int>,"startTime":<number>}.
pid is the child's OS pid. startTime is the daemon's wall clock in epoch
seconds, captured at spawn. Spawn and reattach return the identical value for the
same process. It is an opaque token for PID-reuse and orphan detection. Compare a
persisted daemon value against a later daemon value for the same id. Do not
equality-compare it against an OS-read process start time
(psutil create_time), because the two derivations differ. When wantPid is
absent or false, the daemon omits both fields through omitempty, and the
frame is byte-identical to {"success":true}. An older daemon ignores the
unknown param through its tolerant decode. See
DIVERGENCES.md → CT-1.
- The exec-child trampoline is 19f30c46 parity on linux and darwin. On every
socket shape, a spawned child is launched
through a self-re-exec trampoline (<self> --exec-child <path> <argv…>). That
trampoline adds two environment markers that give the child a stable,
pid-reuse-safe identity. The first is CLAUDE_SSH_RUN_DIR=<run dir>, set by the
daemon. The run dir is the folder of the socket path, cleaned and not made
absolute. The second is CLAUDE_SSH_CHILD=<pid>:<startTicks>, set by the
trampoline from the child's own pid and its start-time. On linux the start-time
is the /proc clock ticks. On darwin it is a date text: the ps start-time
with one blank between its words. The five
Go-runtime vars (GODEBUG, GOGC, GOMAXPROCS, GOMEMLIMIT, GOTRACEBACK)
are stashed under CLAUDE_SSH_HELD_<name> across the re-exec. They are restored
before the target runs, so they do not perturb the transient trampoline. A
command that
does not resolve to a runnable file is not trampolined, so its spawn error frame
is unchanged (fork/exec … or exec: … not found in $PATH). When the
trampoline itself does not start, the daemon starts the command directly and
answers with the error of that start. Measured on Linux against 89cb6289 with
a cwd of mode 000 (row CW03): the frame is
-32603 fork/exec <command>: permission denied. The daemon first logs
[process.Manager] exec trampoline failed for <id> (fork/exec /proc/self/exe: permission denied); starting it directly — a successor daemon will not be able to reap it.
That log text is the Linux text. This changes one process.spawn frame of
claustrum. For a cwd that the daemon cannot enter, the error text named the
trampoline before (fork/exec /proc/self/exe: permission denied). It now names
the command (row CW03). Not measured: the trampoline start fails and the direct
start works. claustrum then answers success, where it answered an error before.
It gives that child CLAUDE_SSH_RUN_DIR and no CLAUDE_SSH_CHILD.
f6010b97 and 89cb6289 set both markers on every socket
shape (Linux and macOS rows EV01a to EV01i, and Linux row K3). Linux and macOS VMs measured the
values and the order against 89cb6289 (Linux rows F1 to F5, macOS rows F1 to
F4). The socket
<R>/b/s.sock gives CLAUDE_SSH_RUN_DIR=<R>/b. A relative socket s.sock gives
CLAUDE_SSH_RUN_DIR=.. The socket <R>//run/./c1/rpc.sock gives <R>/run/c1.
The child environment is the daemon environment, where
CLAUDE_SSH_DAEMON_CHILD is the last entry. A caller key that the daemon
environment holds replaces that entry in place. A new caller key comes after the
daemon entries. CLAUDE_SSH_RUN_DIR and then CLAUDE_SSH_CHILD are the last two
entries. A caller value of those two does not reach the child (Linux rows EV02a
and EV02e). An empty caller value of CLAUDE_SSH_DAEMON_CHILD stays empty.
claustrum builds the environment by these rules. A Go-runtime variable keeps its
place. On a Linux VM (rows P4a to P4c), a GOGC of the daemon environment of
89cb6289 stayed at its place there, before CLAUDE_SSH_DAEMON_CHILD. A
GOMAXPROCS of the spawn env stood with the new caller keys, before
CLAUDE_SSH_RUN_DIR. The two markers were the last two entries. claustrum is
built to those rows. GODEBUG, GOMEMLIMIT and GOTRACEBACK are not measured,
and claustrum treats them the same way. Two or more new caller keys have no
fixed order on 89cb6289. A Linux VM ran 20 spawns per row. For the request
order ZZ, AA, MM it gave ZZ, AA, MM 16 times, AA, MM, ZZ 3 times and
MM, ZZ, AA once (row P9). For the request order ZZ, MM, AA it gave
ZZ, MM, AA 16 times, MM, AA, ZZ 3 times and AA, ZZ, MM once (row N7). Each
order is a rotation of the request order. Build ac5cadb of claustrum gave name
order in 20 of 20 spawns of both rows, and name order is not one of the three
orders of row N7. claustrum now iterates the decoded map again and builds no
order of its own. That gave a rotation of the request order in each of the 20
spawns of a row (Linux rows P9 and N7, macOS row EV20). A request with more keys
is not measured on Linux and macOS. On Windows the block of a claustrum child
is in name order (divergence D23). The
trampoline is off-wire and adds no JSON-RPC frame. The start failure above is
the one frame that it changes. It was verified against
19f30c46 on a VM. The marker set and format match, and the values are
per-process. On macOS the start text in CLAUDE_SSH_CHILD has one blank before
a one-digit day (Fri Oct 2 17:35:19 2026), where ps -o lstart= prints two. A
macOS VM measured that on 89cb6289 (rows F1 to F4). In those rows the text of
the record and the text of the marker were byte-equal, on 89cb6289 and on
claustrum. Only days 1 and 2 of a
month are measured. The held Go vars round-trip. A missing target, a
non-executable-format target, and a relative-under-cwd target each return the
identical -32603 fork/exec … frame, or are trampolined exactly as the
reference does. Darwin links the same subsystem, with the start-time from ps
instead of /proc. This was validated against 19f30c46 on a macOS VM. On
windows the reference daemon reports it is not the run-dir lock holder, so it
stamps neither marker. A windows VM showed that a run-shaped-socket child has the
same environment as a bare-socket child.
- The orphan-child registry record is 19f30c46 parity on linux and darwin. On
every socket shape, a lock holder records each spawned child with a pid
of 2 or more and a readable start-time to <runDir>/children/<pid>.json. The run
dir is the folder of the socket path. The references write the record on every
socket shape (Linux and macOS rows EV01a to EV01i, and rows F1 to F4 of both
systems on 89cb6289). On macOS the start text of the record has one blank between its
words, as in CLAUDE_SSH_CHILD. A macOS VM measured mixed pairs of 89cb6289
and claustrum (rows RP15c, RP15d, RP15f and RP15g, 3 runs each). A daemon of
one binary ended the recorded child of a dead daemon of the other, in both
directions, with a launcher and without one. claustrum compares the start
texts exactly at the reap, as 89cb6289 does. A record whose start holds two
blanks before a one-digit day does not match. 89cb6289 and claustrum dropped
such a record, sent no signal and logged the same line (rows TBr and TBe):
[process.Registry] record <pid>.json: pid <pid> is not a child to end (it did not start at the recorded "<text>": the pid has been reused); dropping the record, signalling nothing.
A daemonStart with two blanks does not match either. Both binaries then read
that daemon as gone and ended its recorded child, while the daemon was alive
(row N6, 2 runs each). With the one-blank text they kept the record and sent no
signal (row N6k). From the code: an older claustrum build wrote two
blanks on day 1 to 9 of a month. The first start of a newer daemon on macOS
does not end a child of that build from those days.
The
daemon writes it atomically, through a temp file renamed into place. The temp
file is children/.rec-<pid>.json.<12 chars> (89cb6289, measured on Linux, row
SP06). The source of the 12 chars is not measured. claustrum uses the nanosecond
clock in base 36. A later
daemon reads these to reap children a since-exited daemon left behind. The record
is an ordered JSON object:
{"pid":<int>,"node":"<boot-id>/pid:[<inode>]","host":"machine-id:<hex>","instance":"<daemon instance id>","daemonPid":<int>,"daemonStart":"<ticks>","argv0":"<child argv0>","start":"<ticks>","at":<epoch-ms>}.
The field ORDER and the string-vs-number typing are the on-disk contract,
measured byte-for-byte against 19f30c46. With a launcher, argv0 is the
launcher, and a program field holds the command, after argv0 and before
start. That is 89cb6289 parity, measured on Linux. claustrum omits program
without a launcher, so that record keeps its 19f30c46 bytes. That is
claustrum's choice (not measured). daemonStart and start are STRINGS,
holding clock ticks on linux and a date text on darwin. pid, daemonPid
and at are numbers. This record is off-wire, because it adds no JSON-RPC
frame. On linux and darwin the daemon reaps these records at -serve startup,
before it binds the socket.
See ARCHITECTURE.md → orphan reap. On darwin the node is the
boot-session UUID and the host is the hostname, and the start-times come from
ps rather than /proc. On windows the reference daemon reports it is not the
run-dir lock holder, so it records no children and reaps none. A windows VM
showed that a spawned child leaves the children dir empty, and that planted
records survive startup.
- The life of a record on linux and darwin. Each row is measured on Linux against
89cb6289. The macOS rows SP01a to SP09b are equal on 89cb6289 and claustrum.
- The end of the child removes its record (rows SP01, SP03, SP04, SP06 and
CW02b). The removal comes before the pipe drain, so it does not wait for the
exit frame (row SP05).
- A graceful shutdown leaves no record at the default, without -keep-children
(rows RP12a, STa and STb, equal on 89cb6289 and claustrum). claustrum waits
for the children that it killed, for 1 s at most. That bound is claustrum's own
value. Not measured: a child that outlives the bound.
- With -keep-children the shutdown removes the records of the children that it
leaves alive. The next daemon then finds no record of them and sends them no
signal. That rule is claustrum's own, as the flag is. A Linux VM ran it on
claustrum only (row P14). -stop of a daemon with the flag sent no kill and
removed the records of its two children. A new daemon then sent no signal and
logged no reap line, with the flag and without it. Both children stayed
alive. The rule has two limits. A process whose id a later spawn took keeps
its record. The host cleaner does not read the records. See
DIVERGENCES.md CT-2.
- A spawn that takes the id of a running child leaves the record of that child
in place. Linux and macOS rows A1 to A3 measure that.
- A daemon that is killed leaves its records. The next daemon on that socket
reads them at its start.
- If children is a regular file or a symlink, the daemon writes no record
through it. It logs
[process.Registry] <runDir>/children is not a directory; recording nothing.
Row SP07a measures a regular file. Row SP07b measures a symlink that leads out
of the run dir. Linux rows SLa and SLb and macOS rows KDa and KDb measure a
relative symlink to a folder inside the run dir. Neither side writes a record
through it.
- If the record cannot be written, the daemon logs
[process.Registry] cannot record child <pid>: <error>. Row SP07c measures a
children folder of mode 000, where the error is
openat children/.rec-<pid>.json.<12 chars>: permission denied. Row P11a
measures a children folder of mode 755 that another user owns. On 89cb6289
and claustrum the spawn succeeded, the child got both markers, no record was
written, and the log held that line.
- In rows SP07a to SP07c the daemon still tries the removal at the end of
the child. It logs
[process.Registry] cannot remove record of child <pid>: removeat children/<pid>.json: <reason>
before the exited line. The reason is not a directory for a regular file,
path escapes from parent for a symlink that leads out of the run dir, and
permission denied for mode 000.
- The removal goes through a relative symlink to a folder inside the run dir.
Both sides remove a file <pid>.json in that folder at the end of the child
(Linux row SLb, macOS row KDb). With no such file both sides log nothing
(Linux row SLa, macOS row KDa).
- A relative symlink that leads out of the run dir is refused like an absolute
one. Both sides keep a file <pid>.json in the outside folder and log the
path escapes from parent line (Linux row SLc, macOS row KDc).
- A removal that finds no record logs nothing. That is claustrum's choice (not
measured).
- The daemon holds its run dir open. After a rename of the run dir, a record goes
under the new name, and the old path is not made again (row SP08).
- claustrum prints each of these lines after its level tag.
- The reap of the records at a -serve start on linux and darwin. Each row is
measured on Linux against 89cb6289, unless the bullet names macOS.
- The reap runs on every socket shape, in the folder of the socket (Linux and
macOS rows EV01a to EV01i).
- The daemon walks the children folder in directory order, the order of ls -U.
One start handles the first 128 names that end in .json. One more such name
ends the walk, and the daemon logs
[process.Registry] more than 128 records in <runDir>/children; leaving the rest for the next start.
Row C1: of 131 names, the first 128 of the directory order were gone after the
first start. The second start removed the last 3 and logged no line. Not
measured: whether a name that does not end in .json counts. claustrum does
not count it.
- At most 64 groups get SIGTERM at one start, in the order of the walk (rows C2
and C4). For more verified groups the daemon logs
[process.Registry] <n> more orphaned group(s) recorded in <runDir>/children than the 64 one start ends; leaving those for the next start.
Row C4: with 70 recorded children, 64 got SIGTERM and 6 records stayed.
- claustrum reads at most 4096 bytes of a record. A record whose first 4096
bytes are not one JSON value is removed, with no signal and no line. On
89cb6289, records of 5259 and 5290 bytes were removed with no signal and no
line while their children ran (rows C3a and C3b). Both held an argv0 of 5000
characters, so the rows do not tell a read limit from a field limit. A
valid record with blanks after it, 2 MiB in all, was read (row ED14g). Row P8
measured the bound with a pad in an added last field. Records of 4095 and
4096 bytes got SIGTERM. Records of 4097 and 5000 bytes got no signal and no
line, and they were removed. 89cb6289 and claustrum did the same in each
of the four sizes, so the bound is 4096 bytes on both.
- Records that the daemon keeps count toward the 128 too. Row P10 staged 128
records of another machine and then one record of a live orphan, the last in
directory order. In two starts, 89cb6289 and claustrum sent no signal and
removed no record. Each start logged the 128 line and a summary with
0 orphaned group(s) and 128 kept. The orphan stayed alive.
- The owner of a record file is not tested. Row P11b replaced the record of a
live child with a copy that root owns (mode 644, same bytes). The next start
of 89cb6289 and of claustrum sent SIGTERM to that child and removed the
record.
- A record that is not a child to end is dropped at once, with the line
[process.Registry] record <pid>.json: pid <pid> is not a child to end (<reason>); dropping the record, signalling nothing.
The reason is one of the texts that the bullets below list. Not measured: the
place of this line when the 128 line and the 64 line print too. claustrum
prints the 128 line, then the record lines, then the 64 line.
- Each group that gets SIGTERM logs
[process.Registry] ending orphaned process group <pgid> recorded by daemon instance "<instance>" (pid <daemon pid>): SIGTERM.
A group that gets the SIGKILL after the 2 s grace logs
[process.Registry] group <pgid> outlived SIGTERM for 2s: SIGKILL.
Linux and macOS rows A4 and D1 measure both lines.
- The records of the groups that got SIGTERM are removed together, when the
last group is done. Row RP04: of five groups, all five records were gone at
the 2.1 s poll and none earlier.
- The sweep ends with one summary line when it sent a SIGTERM or kept a record:
[daemon] serve: predecessor's children — <n> orphaned group(s): <a> ended on SIGTERM, <b> on SIGKILL, <c> survived; <d> stale record(s) dropped, <e> kept.
A sweep that only removed records logs no summary (row C1, second start).
<n> counts the groups that got SIGTERM. kept counts the records of a live
owner or of another machine, the verified groups over 64 (row C4:
6 kept), and a process it cannot inspect (5fd08069, cells H2d, H4). dropped counts a record with the record line (row RP07), a record of a
dead pid (row C1: 127 stale record(s) dropped), and a group that no longer
verifies after its SIGTERM (row PG05a). Not measured: the number
that the other removed records take. claustrum counts each of them as dropped.
- Only a regular file is read as a record. A symlink, a FIFO or an empty folder
with a .json name is removed, with no signal and no log line (rows ED16a to
ED16c). The target of the symlink stays.
- A record with a program key is accepted in three cases. The process runs
argv0. The process runs program, by its full path or by its base name
(rows PG02a, ED03a and ED03b). One whole argument of the process equals
program byte for byte (rows PG02b, ED04a and ED04e).
- An argument does not match by its base name, by a cleaned path, or as a part
of a longer argument (rows ED04b, ED04c and ED04d). An empty argv0 is refused
before program is read (row ED02c). f6010b97 sends no signal in rows ED03a
to ED04e.
- On Linux the daemon reads the process from /proc. The program is the first
argument of /proc/<pid>/cmdline, and the arguments are whole, blanks included.
A recorded name matches the program when the two are equal or have the same
last path component. A program with a blank matches the argument that equals
it (Linux row ED04e: SIGTERM).
- On macOS the daemon judges the process by the command text that ps prints,
as the macOS rows of 89cb6289 do. The words of that text are cut at blanks. The first
word is the program. A recorded name matches in three cases. It equals the
whole text. The text starts with it and one blank. It has the last path
component of the first word.
- So on macOS a program at a path with a blank is still ended (rows PG04s and
PG02s, /tmp/e/bin/my stub). A program with a blank is never taken as an
argument, because no single word equals it (macOS row ED04e: no signal).
- These macOS rows are equal on 89cb6289 and claustrum in 3 of 3 runs: PG02a,
PG02b, ED03a, ED04a and B1 to B8 send SIGTERM or none as the rule gives, and
ED01a sends none. Rows ED03b and ED04b to ED04d are measured on 89cb6289
only. For claustrum they follow from the rule.
- Rows B4a to B4c measure two blanks and a tab on macOS. A path with two blanks
is ended when the record holds both blanks (B4a), and is not when the record
holds one (B4b). ps prints a tab as the four characters \011, so a record
with a tab matches no text and the child gets no signal (B4c). Not measured: a
program key with two blanks or a tab.
- The children folder must be a real folder inside the run dir. The daemon lists
it through the run dir that it holds open, so the reap never leaves the run
dir. If children is a symlink to a folder, the daemon reads nothing, sends
nothing and removes nothing. It logs
[process.Registry] <runDir>/children is not a directory; reaping nothing
before the listening line. Rows SYa and SYb measure that on f6010b97 and
89cb6289, for a folder outside the run dir and for one inside it. claustrum
does the same for every children entry that is not a real folder. The other
shapes are not measured.
- During the 2 s grace, a group that got SIGTERM takes the whole record test
again at each poll, and once more before the SIGKILL. If the test fails, the
daemon sends nothing more. claustrum removes that record at the end of the
sweep, with the records of the other groups. When 89cb6289 removes it is not
measured. Row PG05a
measures a child that replaced itself with another program: 89cb6289 logs
the line below 0.1 to 0.3 s after its start.
- The line is
[process.Registry] group <pgid> no longer verifies (<reason>); nothing more is sent to it.
The reason is the text of 89cb6289 for the test that failed, where a row
measures one. Otherwise the text is claustrum's own. For row PG05a it
is it does not run the recorded program "<argv0>". With a program key the
text goes on with and neither runs nor was handed "<program>" (rows ED04b to
ED04d).
- The other reason texts are these. it does not lead its own process group
(row RP07). it did not start at the recorded "<start>": the pid has been reused
(row ED07).
it was not started by a daemon of this run dir: no CLAUDE_SSH_RUN_DIR names it
(row ED10a).
it belongs to a daemon of another run dir: its CLAUDE_SSH_RUN_DIR names a different one
(row ED10b).
it was not started by a daemon itself: its CLAUDE_SSH_CHILD does not name its own pid and start (something a child started)
(rows ED10c and ED10d).
environment unreadable: open /proc/<pid>/environ: permission denied (one run
of row PG05a). pid namespace unreadable: <error>, stat unreadable: <error> (5fd08069, cells H3v, P7).
- Those rows measure each text in another line of the reference, at the first
test of a record. In the no longer verifies line, only the program text and
the environment text are measured.
- A group that is gone at a poll counts as ended on the SIGTERM. A read that
fails because the process has just ended is not a failed test. Rows K5 and
ED04a measure a child that exits on the SIGTERM: 89cb6289 logs
1 ended on SIGTERM.
- A read that takes the leader for gone is not final. claustrum waits 50 ms and
reads the leader again. If the leader is still gone and the group still
answers, the daemon sends SIGKILL to the group. Row RP05a measures that
SIGKILL for a leader that exited and left members that ignore SIGTERM.
There 89cb6289 sends it right after the SIGTERM, with no wait.
- The 50 ms wait is claustrum's own value. It is there for a child that replaces
its program on the SIGTERM. In the first 30 runs of row PG05a (three sets of
ten), 89cb6289 sent a SIGKILL within 4 ms of the SIGTERM in 9 runs, and
the child ended. In its other 21 runs the child stayed alive. With the wait, a
leader that reads alive again takes the record test and gets the
no longer verifies line. This is DIVERGENCES.md → D20.
- Measured on Linux with the wait, in 30 later runs of row PG05a for each
binary: claustrum ended the child in no run, and 89cb6289 in 5 runs. In 10
runs of row RP05a, claustrum sent the group SIGKILL 51 to 55 ms after the
SIGTERM in 9 runs and 152 ms after it in one. 89cb6289 sent it 0.4 to
4.9 ms after the SIGTERM.
- claustrum prints each line of the reap after its level tag. 89cb6289 prints
no level. The level of each line is claustrum's own choice. The 128 line, the
64 line and the outlived line are WARN. The is not a directory line is
ERROR. The others are INFO.
- The log lines of a spawn and of an exit. They are off-wire. Each one is measured
against 89cb6289. The macOS rows CW01 to CW05 are equal on 89cb6289 and
claustrum.
- A cwd that is missing or is not a folder logs
[process.Manager] Failed to start process <id>: <message>. The message is the
message of the error frame (Linux rows CW01, CW02a, CW02c, CW04c and CW05,
Windows rows CW01, CW02a, CW02c and CW04c). On Windows, rows K4, CW01,
CW02a, CW02c, CW04a, CW04b and CW04c are equal on 89cb6289 and claustrum:
the same frame, and the same line apart from the level tag. Neither side
makes a children folder or a record file there.
- The exited line is [process.Manager] Process <id> exited with code <n>. A
child that a client request ended adds , signalled at client request (Linux
rows SP03a and SP03b). A child that a signal ended adds
, terminated by <signal> (Linux row SP04, with SIGKILL). A plain exit adds
nothing (Linux row SP01).
- A line with both parts has the signal part first. 89cb6289 printed
Process <id> exited with code -1, terminated by SIGKILL, signalled at shutdown request
in Linux row STb, a server.shutdown request with two children.
- That line is a race on the reference. 89cb6289 printed it in row STb, and
printed no exited line in two runs of rows RP11 and RP12a. claustrum prints it
when the exit goroutine reaches the line before the daemon exits. Measured on
Linux: one line for two ended children in rows STa and STb (three or more runs each).
In a later Linux stop row with two children, each side printed one such line,
for one child. 89cb6289 printed it after the cleanup: line, and claustrum
before it.
In rows RP11, RP12a and RP12b the count was none, one or two, and it changed
between runs.
- When the drain grace expires, the daemon logs
[process.Manager] Process <id>: pipe drain grace expired (grandchild holding stdio?); force-closing
before the two read-error lines (Linux row SP05).
process.stdin¶
{id,data[,offset]} → {"success":true,"applied":<int>[,"duplicate":true]}
Sends bytes to the standard input of a child process, with an optional offset that makes a repeated send safe. The page process.stdin has the parameters, the response and the errors.
process.kill¶
{id[,signal]} → {"success":true}
- Best-effort and fire-and-forget. It does not wait for the child to exit.
process.killAndWait does wait.
- On Unix, how wide the signal reaches depends on the signal:
- KILL goes to the whole process group, as a negative pid, so the entire
child tree dies.
- Every other signal (TERM, INT, HUP, and the default) goes to the direct
child only, and a backgrounded grandchild keeps running. A graceful
process.kill does not kill the tree. Use signal:"KILL" for that.
killAndWait with escalate:true sends the group SIGKILL only when its
grace runs out.
The split does not apply on Windows. There every signal form ends the direct
child only, with exit code 1. See Windows child trees.
- On Linux the TERM to the direct child is pidfd_send_signal on the pid file
descriptor that the start of the child gave. A KILL is kill(-<pid>, SIGKILL).
A Linux VM measured both calls with strace against 89cb6289 (row E), for
process.kill and for process.killAndWait. The SIGTERM of a session
supersede is the same call (row B1). In claustrum INT and HUP go the same
way, and they are not measured. On a kernel without pid file descriptors, and on
macOS, the daemon sends kill(<pid>, <signal>). The reference on such a kernel
is not measured.
- claustrum diverges here. It skips the signal when the child has already exited,
because the OS can recycle a reaped pgid. This is OS-level only, and the reply is
identical.
Windows child trees¶
On Windows a spawned child is in no Job Object. A kill ends the direct child only,
with exit code 1. The descendants of the child live on. A Windows VM measured this
against 89cb6289 with a tree of a child, a grandchild, a great-grandchild and an
orphaned grandchild:
| action | direct child | descendants at 1 s, 5 s, 15 s |
|---|---|---|
process.kill with no signal, TERM, INT, HUP, KILL, SIGTERM, SIGKILL |
ended, code 1 | alive |
process.killAndWait default, escalate:false, signal:"KILL" |
ended, code 1 | alive |
-stop and server.shutdown |
ended, code 1 | alive |
taskkill /F of the daemon |
alive | alive |
A later -stop of the daemon does not end the descendants either. A child reported
job=false jobflags=none. On a Windows VM, the children of f6010b97 and
89cb6289 were alive 60 s after taskkill /F of the daemon (rows WJ02, WJ05,
WJ06). A new daemon on the socket did not end them in 120 s (row WJ07). A second
daemon beside a live first one left them alive for 60 s (row WJ04). A clean stop
ended them with exit code 1 (row WJ03, and row WJ08 on 89cb6289).
A later run on that VM put claustrum beside 89cb6289. In each row of the table
and in rows WJ01 to WJ08, the same processes were alive on both. The children of
claustrum reported job=false jobflags=none too.
With descendants that have their own stdio, both sent these frames with equal bytes.
process.kill answers {"jsonrpc":"2.0","id":1,"result":{"success":true}}.
process.killAndWait answers
{"jsonrpc":"2.0","id":1,"result":{"found":true,"died":true}}. The exit frame is
{"type":"stream","processId":"c1","stream":"exit","seq":1,"exitCode":1,"killedBy":"client"}.
The log line of each killed child is
[process.Manager] Process c1 exited with code 1, signalled at client request.
Descendants that inherit the stdout and stderr of the child hold the pipes of the
daemon open after the child ends. The exit drain then runs to its 5 s bound, as on
Linux. Measured against 89cb6289, with the same frames on claustrum:
process.kill, also withsignal:"KILL": the reply comes at once, and the exit frame comes 5.002 s to 5.004 s later.process.killAndWaitwith defaults, also withsignal:"KILL": the reply comes after 5.004 s to 5.006 s, and it is{"jsonrpc":"2.0","id":1,"result":{"found":true,"died":true,"escalated":true}}. In claustrum the 3000 ms grace ends before the drain does, so the daemon escalates. The kill of the escalation meets a child that has ended. Both log[process.Manager] KillAndWait c1: group kill failed: TerminateProcess: Access is denied.process.killAndWaitwithescalate:false: the reply comes after 3.002 s, and it is{"jsonrpc":"2.0","id":1,"result":{"found":true,"died":false}}. The exit frame comes at 5.004 s.- In each of these forms both log
[process.Manager] Process c1: pipe drain grace expired (grandchild holding stdio?); force-closing, then[process.Manager] stdout read error for process c1: read |0: file already closedand the same line forstderr.
This is a wire change on Windows against earlier claustrum builds. Those builds
ended the whole tree through a Job Object, so the pipes closed at once. For the
same requests one such build sent the exit frame within milliseconds. Its
killAndWait answered {"found":true,"died":true} with no escalated member.
That is one build on a Windows VM. Not measured: a child that ignores a console
signal, and more than three generations.
process.killAndWait¶
{id[,signal][,timeoutMs][,escalate]} → {"found":<bool>,"died":<bool>[,"alreadyExited":true][,"escalated":true]}
Sends a signal to a child process, waits for it to end, and can end it by force. The page process.killAndWait has the parameters, the results and the errors.
process.reattach¶
{id,fromSeq[,wantPid]} → {"found","running","firstSeq","lastSeq","stdinApplied"}
Moves the output stream of a child process to the connection that sends the request, and sends the stored frames that the client missed. The page process.reattach has the parameters, the response and the errors.
plugins.* (added 19f30c46)¶
plugins.prune¶
{[keep],[minAgeDays]} → {"root":"<per-install root>","pruned":[…],"prunedLegacy":[…],"staleArchives":0,"kept":0,"live":<n>,"young":<n>,"legacy":"absent|swept|skipped: …","minAgeDays":<clamped>}
Deletes the cached plugin directories that no session used for some days. The page plugins.prune has the parameters, the response and the errors.
Stream notifications¶
{"type":"stream","processId":"<id>","stream":"stdout","seq":1,"data":"<base64>"}
{"type":"stream","processId":"<id>","stream":"stderr","seq":2,"data":"<base64>"}
{"type":"stream","processId":"<id>","stream":"exit","seq":3,"exitCode":0}
{"type":"stream","processId":"<id>","stream":"exit","seq":3,"exitCode":-1,"signal":"SIGTERM","killedBy":"client"}
seqis per-process. It starts at 1 and is monotonic across stdout/stderr/exit.datais base64 for stdout/stderr. Theexitframe carriesexitCodeand nodata. A signal-terminated child reportsexitCode: -1, not128+signo.signalandkilledBywere added by4534d86. They appear on theexitframe only, both afterexitCode, and both are omitempty, so a normal exit stays byte-identical.signalis the SIG-prefixed name of the terminating signal, such asSIGTERMorSIGKILL, read from the wait status. It is omitted on a normal exit, and it is omitted always on Windows, which has no signal on the wait path. That was measured on a Windows VM, where the reference omitssignaland still emitskilledBy.killedBynames who asked the daemon to kill the process. It isclientforprocess.killandprocess.killAndWait, andshutdownfor the shutdown andkillAllsweep.killedByis emitted on every OS. Theclientvalues and theSIGTERMandSIGKILLnames are live-measured. The other mapped signal names derive from the same wait-status path and are not individually measured against the reference. Theshutdownvalue is not probe-measured, because the shutdown exit frame races connection teardown and is not client-observable.- The
exitframe waits at most 5 seconds after the process exits for stdout/stderr to reach EOF. The daemon then closes the read ends and emits the frame anyway. This matters when the command leaves a grandchild that holds the same pipe, asnpm run dev &does. The daemon does not forward output that the grandchild writes after the cap, because that write failsEPIPE. Theprocess.reattachrunningflag does not wait for that frame. Since90fca6e6, a reattach inside the drain window reportsrunning: false. In claustrum it flips at the reap itself. On the reference the flip is measured one second into the drain. The probe therefore bounds the flip at one second rather than at the reap.process.stdinrefuses inside the same window with-32602 Process not running, for a write that carries fresh bytes. An offset gap still answers-32003and a wholly duplicate write still answers{"success":true,…,"duplicate":true}. Before90fca6e6, both paths reported the process as still running inside the drain, measured on19f30c46. The refusal also stops the drain window from inflatingappliedandstdinApplied, which used to count bytes the closed pipe discarded. The acknowledgement caveat ofstdinApplied(see the page process.reattach) still applies for a write accepted while the process was genuinely live. - Each stdout/stderr frame carries at most one 32 KiB read. Larger output splits
across frames. Concatenate
datainseqorder to reassemble it. The exact frame boundaries depend on pipe scheduling and are not stable. Only the reassembled bytes are stable. - The replay buffer has a bound of 16 MiB per process. The daemon counts the
serialized frame including its trailing newline, which is the bytes a subscriber
receives, not the base64
dataalone. An exit frame therefore costs its envelope although it carries nodata. The daemon drops frames oldest-first, whole frames at a time, once adding a new frame pushes the buffer total over the cap. It always retains at least one frame, even a frame larger than the cap.reattach{fromSeq:0}therefore replays everything still retained, and not necessarily everything ever emitted.firstSeqis the floor. Compare it against the lastseqyou saw to detect a gap. - A process survives the disconnect of the connection that spawned it. Another
connection picks it up with
reattach. This is the multi-attach and reconnect mechanism.
Daemon lifecycle (flags)¶
One binary, six modes: -serve, -bridge, -stop, -version, -install and
-probe-cli.
Flags and config keys¶
Every opt-in divergence flag defaults to its zero value, which is OFF, and which
is byte-identical to the reference. Each flag has a matching claustrum.conf key.
Claude Desktop owns the -serve and -install argv. That is a driver claim. See
ARCHITECTURE.md → Driver claims and their
provenance. The configuration
key is therefore the reachable knob. The precedence is: explicit CLI flag, then
configuration, then default. A disabled bound bypasses the guard entirely. It is
never "a huge limit".
| flag | config key | default | effect when set | mode |
|---|---|---|---|---|
-token-file <p> |
none | none | token source (read once, unlinked) | -serve |
-token-fd <n> |
none | -1 |
token from an open fd (claustrum-only) | -serve |
-metrics-addr <a> |
metrics-addr |
"" |
Prometheus /metrics (claustrum-only) |
-serve |
-wire-log <p> |
wire-log |
"" |
append every JSON-RPC frame to <p> as JSONL (claustrum-only) |
-serve |
-wire-log-max-string <n> |
wire-log-max-string |
512 |
bytes kept per string value. 0 keeps whole payloads |
-serve |
-keep-children |
keep-children |
off | survive restart (CT-2) | -serve |
-listen-pipe |
listen-pipe |
off | named-pipe transport, Windows-only (CT-5) | -serve |
-max-extract-bytes <n> |
max-extract-bytes |
0 |
cap files.extract_tar bytes (D3) |
-serve |
-git-timeout <dur> |
git-timeout |
0 |
deadline on git invocations (D5) | -serve |
-files-read-regular-only |
files-read-regular-only |
none | deprecated. It sets nothing and logs one warning. files.read refuses a file that is not regular with or without it (retired D4) |
-serve |
-max-cli-bytes <n> |
max-cli-bytes |
0 |
cap CLI decompress + download (D10) | -install |
-cli-probe-timeout <dur> |
cli-probe-timeout |
none | deprecated. It sets nothing and logs one warning. The direct <cli> --version run always has its 30 s and 120 s bounds (retired D11) |
-install |
-cli-download-timeout <dur> |
cli-download-timeout |
0 |
download deadline (D12) | -install |
-libc-probe-timeout <dur> |
libc-probe-timeout |
none | deprecated. It sets nothing and logs one warning. The ldd probe always has its 5 s bound (retired D14) |
-install |
-cli-keep <n> |
none | 3 |
entries of the cli-dir to retain on prune. 0 removes every counted entry. A negative value ends a good install with exit code 2 and no facts line |
-install |
| none (config only) | version-override |
none | -version stdout rebrand (CT-3) |
-version |
Config-value parsing: bool keys accept true/1/yes/on and false/0/no/off. The
parser ignores a negative or unparseable numeric value, so a typo can never
silently enable a cap. An unrecognised bool value leaves the key unset, and the
flag value, or the default, stands.
-serve — run the daemon¶
claustrum -serve -socket <p> {-token-file <p> | -token-fd <n>} [-metrics-addr <a>] \
[-keep-children] [-listen-pipe] [-wire-log <p> [-wire-log-max-string <n>]] \
[-max-extract-bytes <n>] [-git-timeout <dur>]
The binary self-daemonizes, which means it reparents to init and detaches. It then
runs the RPC server. On success it
prints Claustrum remote server listening on <socket> (pid <pid>, instance <32-hex>)
to stdout.
On Unix the daemon runs no login shell at its start. The first process.spawn
reads the login-shell PATH, and that spawn waits for the read. The daemon keeps
the result for its life, and it keeps a failed read too. Linux and macOS VMs
measured that against 89cb6289 (rows G1 and G2). No shell ran in 20 s without a
spawn. The first spawn ran the shell, and the second spawn ran none. A Linux VM
measured a login shell that sleeps 10 s (row G3). The shell was killed after 4 s.
The first spawn answered after 8 s, because the SSH agent read ran that shell
again for 4 s. The second spawn answered at once. On Windows the first spawn
reads the daemon's own PATH and runs no shell (claustrum, from the code). See
process.spawn.
When $SHELL is an executable file, login-shell PATH extraction on Unix runs
$SHELL -l -i -c …. Otherwise it runs the first usable of /bin/zsh,
/bin/bash and /bin/sh. zsh comes first, which matches the reference. The value
reaches
process.spawn children as their PATH only. It never reaches the daemon's own
environment, so it never changes how the daemon resolves a command. The
extraction has a cap of 4 s. On a timeout the daemon discards whatever the shell
printed, even a valid PATH, and children fall back to the inherited PATH.
The cap also holds after the shell is gone, while another process holds the
output pipe of the shell. On a Linux VM (rows P13 and P13k), the login shell left
setsid sleep 30 on the pipe and ended after 0.06 s. The first spawn of
89cb6289 answered after 4.6 s, in 3 of 3 runs. Its log held
[shellenv] Failed to extract PATH from login shell: shell PATH extraction timed out (<shell>).
The daemon sent no signal, and the second spawn answered at once. claustrum is
built to those rows. claustrum answered after 4.6 to 4.7 s with the same line
(Linux row P13, 3 runs). A macOS VM measured the same (row P13): the first spawn
answered after 4.7 s on 89cb6289 and after 4.7 to 4.9 s on claustrum, with the
same line. One part is claustrum's own and is not measured: it closes its end of
that pipe two caps after the shell ended.
The shell of the PATH read gets a small environment. On a Linux VM (row P13v),
the PATH shell of 89cb6289 got seven entries from the daemon. They were
DISABLE_AUTO_UPDATE=true, HOME, PATH, SHELL, an empty TERM, USER and
ZSH_DISABLE_COMPFIX=true. The daemon had no TERM. Its LANG and its
CLAUDE_SSH_DAEMON_CHILD did not reach the shell. claustrum is built to that
row. macOS rows P13v and P13z gave the same seven entries on 89cb6289 and on
claustrum. In row P13z the daemon had no SHELL, and the entry was empty on
both. Not measured: the order of the entries, and a daemon that has a TERM.
Also not measured: a daemon with no HOME, PATH or USER.
claustrum copies the five values from its environment and writes an empty value
for a name that is not set. The shell of the SSH agent read gets another
environment. In Linux row P13v and macOS row P13z that one was equal on
89cb6289 and claustrum.
A token source is required. The detached child tests for it, not the launcher:
- Both flags missing → the launcher daemonizes anyway, the child refuses to start,
and the launcher reports its accept timeout after 12 s:
claustrum: timeout waiting for daemon to accept on <socket>, exit 1. The
specific reason (claustrum: daemonized child requires --token-file or
--token-fd) reaches only the child's detached stderr. This is deliberate
parity. 5db5e4a exited 1 after 10.07 s in that case. A zero-byte
-token-file behaves identically. On a Linux VM (row P7), the launcher of
89cb6289 exited 1 after 12.06 s with a zero-byte -token-file, in three
forms: a stale socket file, no socket file, and an empty run folder. The
launcher bound of claustrum is 12 s, to that row. Both flags missing is not
measured on 89cb6289.
- The daemon reads the token as a line. It strips one trailing \n or \r\n, and
preserves other surrounding whitespace verbatim.
- A bad -token-file → claustrum: read --token-file: <err>, exit 1.
- -token-fd <n> is claustrum-only. It reads from an already-open fd, where 0 is
stdin, so this handoff never touches disk. The launcher forwards it to the
detached child over an inherited pipe.
The daemonize sentinel is internal and claustrum-namespaced. The re-exec marker is
CLAUSTRUM_DAEMON_CHILD, not the reference's CLAUDE_SSH_DAEMON_CHILD. The
reference name cannot serve here. A host that runs inside a real claude-ssh
session exports CLAUDE_SSH_DAEMON_CHILD=1 ambiently. If claustrum used that name,
the launcher mistakes itself for the already-daemonized child. claustrum keeps the observable
parity separately. daemonizeWithToken still sets CLAUDE_SSH_DAEMON_CHILD=1 in
the daemon's environ, so that variable propagates into process.spawn children.
TestSpawnInheritsDaemonChildMarker pins that. claustrum unsets the internal
marker before it spawns.
Claustrum-only extras follow. They are off the wire. -keep-children and
-listen-pipe have an entry in DIVERGENCES.md. -metrics-addr
and -wire-log have none, and this section holds their detail:
- -metrics-addr <a> serves Prometheus counters at
http://<a>/metrics, covering connections, spawns and exits, reattaches, and
stream and stdin bytes. It is off by default, with no listener. It counts only,
and it has no auth, so bind it to loopback. The daemon logs a bind failure
([Server] metrics: …), which is non-fatal.
- -wire-log <p> appends every JSON-RPC frame, in both directions, to
<p> as JSONL. It is a diagnostic side channel that observes already-marshaled
bytes, so a daemon that logs emits frames byte-identical to one that does not. It
is off by default, with no file and no work. -wire-log-max-string <n> bounds
each string value. The default is 512, and 0 keeps whole payloads, which is
needed to reconstruct a session from stream frames. Credentials are redacted by
key, which covers the auth member and token-like env keys. A secret a client
embeds inside a payload string is not caught, so redaction is best-effort, not a
guarantee. A capture holds the frames of both directions, such as the content
of a files.read reply, process.stdin and the spawn env. It is therefore
forced to 0600 on every open, appends included, and it belongs somewhere
private. Each record carries the
frame as a decoded body, which is structured and truncated per value. That is a
normalized view, so field order is not significant there. At
-wire-log-max-string=0 the record carries the frame as raw instead, verbatim,
preserving the field order and number formatting that is the wire contract. An
unopenable path is fatal, not silent.
- -keep-children is CT-2. It is off by default, so a graceful
shutdown kills the children: the whole tree on Linux and macOS, the direct child
on Windows. When set, it leaves spawned children running
across a restart, and logs [Server] -keep-children: leaving <n> running child
process(es) alive across shutdown. On Linux and macOS that shutdown also
removes the records of the kept children. The reap of the next daemon start
then sends them no signal. That rule is claustrum's own, and CT-2 names its two
limits: a process whose id a later spawn took, and the host cleaner. The new daemon does not re-adopt them, and
the survivors lose their stdio. Their stdin reaches EOF, and a stdout or stderr
write gets SIGPIPE, or EPIPE for a child that ignores SIGPIPE, such as Node. It
therefore suits only children that tolerate dead stdio. The stdio of a survivor
is not measured on Windows. The flag works on
Windows too, as claustrum's own flag. There it changes each graceful shutdown:
the direct child stays alive as well. On a Windows VM the whole tree of claustrum
was alive after -stop and after server.shutdown with the flag. The reference
has no such flag. On that VM 89cb6289 refused it with exit code 2.
- -listen-pipe is CT-5 and Windows-only. See Named-pipe
transport. The daemon logs a setup failure
([Server] named-pipe transport: …), which is non-fatal. The socket still serves.
The opt-in divergences on this mode are -max-extract-bytes (D3) and
-git-timeout (D5). Off is parity. Their wire
frames appear in the method sections above, under git.status and
git.worktree_remove, and in two pages. The frame of D3 is in
files.extract_tar. The frame of D5 on git.list_branches is in
git.list_branches. See the flags table and
DIVERGENCES.md.
-bridge — stdio↔socket relay¶
A simple relay. It is the thing an SSH session attaches to. It adds no auth. The
client that speaks through it supplies "auth" itself. It is strict, so a dial
failure is a hard error: claustrum: dial server: <err> on stderr, exit 1.
At stdin EOF the bridge half-closes its socket write side, so the daemon reads
EOF. The bridge then keeps copying the socket to stdout. It exits 0 when the
daemon closes the connection, even with stdin still open. It prints every frame
that arrived before the close. Measured against f6010b97 and 90fca6e6 on
Linux and Windows VMs, the reference bridge relayed the parse-error frame for a
final partial line. It then exited 0. Measured on a Linux VM, the
reference bridge also exited 0 with stdin still open once the daemon closed.
It printed the frames that arrived before the close. If the half-close fails,
the claustrum bridge closes the socket fully.
On Windows the daemon can miss the half-close of the bridge (see Transport). The bridge then does not exit.
-stop — ask a running daemon to shut down¶
-stop sends server.shutdown with no auth member, because that method is
not authenticated. See Authentication. The request is
{"jsonrpc":"2.0","id":1,"method":"server.shutdown"} and a newline. -stop then
reads once, with a 2 s deadline, and discards what it reads. It never relays a
frame to stdout. A live daemon's reply does not always arrive. On one connection
the claustrum reply arrived in 0 of 50 runs on Linux and on Windows, where the
f6010b97 reply arrived in 5 of 50 on Linux and in 21 of 50 on Windows. This is
a measured timing difference, not parity. See server.* above for the
counts with a second idle connection. -stop prints stopped whether the
reply arrives or not.
-stop prints one word on stdout, then a newline, and exits 0 in every state
in the table below. The words and the rules below are measured against
f6010b97 and 90fca6e6 on a Linux VM, except the rules marked as claustrum's
own. After SIGKILL, -stop polls the lock for 1.5 s. The word killed needs
the lock to become free in that wait. The macOS results are below.
| word | state |
|---|---|
stopped |
The connect succeeded. A reply, garbage, an EOF, a reset and the read deadline all give this word. |
none |
The connect failed, and daemon.lock is free or missing. claustrum also prints none when the lock cannot be opened or locked. |
terminated |
The connect failed, and a live serve daemon of this binary for this socket holds daemon.lock. -stop sent SIGTERM, and the lock became free. |
killed |
As terminated, but the lock was still held 4 s after SIGTERM. -stop sent SIGKILL. |
survivor |
The connect failed, and a live process that is not that daemon holds daemon.lock. -stop does not signal it. The word is also survivor if the lock is still held 1.5 s after SIGKILL. -stop does not wait longer. claustrum also prints survivor if the holder check fails again before SIGKILL. It prints it too when a signal cannot reach a holder that is still there. |
The lock check uses the holder test of the serve eviction. See Run-dir
lock. The exact rule of the Linux reference was not
isolated. Windows has no run-dir lock, so a failed connect there always prints
none.
On macOS the words and effects match the references in 22 of 24 measured states.
The two other states have a live lock holder that is not a serve process. The
macOS reference signals any live holder whose lock record says role serve on
this host's node. It does not check the holder's command line. claustrum keeps
its holder check on macOS, so it does not signal such a holder and prints
survivor. This is part of D15. It was measured on a macOS
VM against f6010b97 and 90fca6e6.
The lock path writes these lines to stderr. claustrum adds its own level tag, as it does for the serve eviction lines. The message text of these five lines is the reference text:
<ts> INFO [daemon] stop: run dir is held by a live daemon, pid <N> (instance "<hex32>"); sending SIGTERM
<ts> INFO [daemon] stop: previous daemon pid <N> (instance "<hex32>") exited after SIGTERM
<ts> WARN [daemon] stop: WARNING previous daemon pid <N> (instance "<hex32>") ignored SIGTERM for 4s; sending SIGKILL (its Claude Code children, if any, are ended next, before this daemon serves)
<ts> WARN [daemon] stop: WARNING <dir>/daemon.lock is held by a live process we will not signal (pid <N> is not a <basename> --serve process for <sock>); leaving it alone
<ts> WARN [daemon] stop: WARNING previous daemon pid <N> (instance "<hex32>") survived SIGKILL (uninterruptible?); proceeding without run-dir ownership
A holder with the short record, a daemon that has not bound yet, gives the first
two lines with pid <N> and no instance. On a Linux VM (row N5), 89cb6289
logged [daemon] stop: run dir is held by a live daemon, pid <N>; sending SIGTERM
and [daemon] stop: previous daemon pid <N> exited after SIGTERM. claustrum is
built to that row. The other lines are not measured for a short record, and
claustrum writes them with (instance "").
This line is claustrum's own. It comes from the second holder check:
<ts> WARN [daemon] stop: pid <N> is no longer our serve holder before SIGKILL (<reason>); leaving it alone
After a successful connect, -stop removes nothing. A live daemon removes its own
socket and daemon.token when it stops. After a failed connect, -stop removes
the socket path and the daemon.token beside it, stale or not. It does this after
the lock check. If the word is survivor, -stop removes neither file. This is
measured for a holder that -stop does not signal and for a lock that outlives
SIGKILL. The other survivor cases follow the same rule. -stop never creates
or removes daemon.lock, and it writes no record into it. The references also
never create or remove it, and write no record into it. The socket unlink removes
a stale socket, a regular file and an empty directory. It keeps a non-empty
directory, and a path in a directory that the user cannot write. The references do
the same.
If the word is not survivor, a live foreign listener that refuses the connect
with EACCES loses its socket path. The listener stays alive, but a client that
dials by path cannot reach it afterwards. The unlink does not check what the path is. A variant that removes
only a socket keeps a regular file and an empty directory at the path. It does
not keep the socket of such a listener. That variant is a candidate not taken.
It is recorded under Candidates considered but not
taken.
-version, -install and -probe-cli win over -stop. -bridge and -serve
win too. With -stop -bridge claustrum runs the bridge, and with -stop -serve
it runs serve.
Upgrading a live daemon needs care. A daemon still running from a build that predates the shutdown-auth-exemption change does require auth on
server.shutdown. It answers-32001and keeps running.-stopprintsstoppedand exits0either way, so the caller sees success while the old daemon survives. Stop the old daemon before you upgrade, or kill it by PID once.
-version¶
version-override through claustrum.conf is an intentional divergence. It is
claustrum-only, CT-3. claustrum.conf is an optional key = value file. claustrum
reads it from the directory that holds the binary, and it gates the opt-in
divergences above. An absent or malformed file gives stock behaviour. The file can
set version-override to a bare commit SHA. That SHA is a 40-hex git SHA-1, the
string the desktop client pins. claustrum also accepts 64-hex, and anything else is
a no-op. The output then becomes:
This exists so that the desktop client treats an already-deployed claustrum as
up-to-date. That client decides whether to re-upload from a <bin> --version
output that matches /claude-ssh\s+(\S+)/. The override is CLI stdout only, not a
JSON-RPC frame, so it does not touch the wire contract. server.capabilities
still reports claustrum's own <id>. server.version was removed in 7d193f89.
See
DIVERGENCES.md → CT-3.
-install — ensure the agent CLI¶
claustrum -install -cli-version <v> [-cli-dir <d>] \
[-cli-url <u> -cli-checksum <sha256>] [-cli-zst <p>] [-cli-keep <n>] \
[-max-cli-bytes <n>] [-cli-download-timeout <dur>]
-install downloads, verifies, extracts and prunes, and then prints one
__INSTALL_RESULT__<json> facts line (schema in
ARCHITECTURE.md). -install exits 0 whenever it prints
the facts line. It reports a failure inside the facts as cliError, not through
the exit code. Four cases print no facts line. A flag that does not parse exits
2. -cli-version with no -cli-dir and no home folder exits 2. Any other
run with no home folder exits 1 (see the default CLI folder below). A good
install with a negative -cli-keep exits 2 (see the prune below). -install
reaches the network only with -cli-url.
The cliError catalogue follows:
cliError |
trigger |
|---|---|
installed cli at <path> is not runnable |
the --version run of a new CLI did not start or exited non-zero. <path> is the final path |
cli <v> missing and no --cli-url or --cli-zst provided |
cache miss with no source flag |
cli unresponsive: the installed Claude Code binary started but did not answer --version within 30s (120s for a first run) and was stopped; the host is not letting it run (endpoint security software or a stalled network home are the usual causes) |
a direct --version run stopped at 30 s (120 s after an install in the same run). Always-on, parity (see below) |
checksum mismatch: expected=<x>, actual=<y> |
-cli-checksum verify failed. This applies to -cli-url always, and to -cli-zst only when a checksum is supplied. The compare is case-sensitive |
opening input: <err> |
-cli-zst read error |
decompressing: <err> |
bad zstd blob, for example invalid input: magic number mismatch |
decompressing: decompressed CLI exceeds <n> bytes |
D10 cap, opt-in |
download failed: response exceeds <n> bytes |
D10 cap on the download body, opt-in |
download failed: <transport err> |
a transport error before the body, for example a refused connection |
download failed: Get "<url>": net/http: timeout awaiting response headers |
no response headers for 60 s (always-on, parity, see below) |
download interrupted after <got>/<total> bytes: <transport err> |
a body read that ended with a transport error, for example read tcp …: read: connection reset by peer (parity, see below) |
download failed: context deadline exceeded (Client.Timeout or context cancellation while reading body) |
D12 download deadline, opt-in |
download stalled: no data for 60s after <got>/<total> bytes |
read-idle abort, meaning no bytes for 60 s on the -cli-url body (always-on, 4534d86 parity, VM-measured) |
mkdir cli dir: <err> |
cli-dir uncreatable. cliPath is then empty. A file or a FIFO at the cli-dir path is replaced first (see Staging and cleanup) |
cli version "…" must be a single path component |
D6 hardening |
cli version "…" collides with the install download blob |
version starting .blob- (D18) |
cli path must not be or contain the home directory: "<path>" |
a folder at the final path that is the home folder or one of its parent folders (D2 hardening) |
clearing stale dir at <path>: <err> |
an occupied cliPath directory that claustrum cannot remove |
staging file vanished before install: <err> |
a concurrent sweep took the staging file |
cli unresponsive: the installed Claude Code binary was started through the host's managed launcher <argv0> and the run did not answer --version within 33s (123s for a first run), so it was stopped; the launcher or the host is not letting it finish |
a managed launcher run stopped at 33 s (123 s after a fresh install), with CLAUDE_SSH_MANAGED_LAUNCHER=1 (see below) |
The managed launcher (89cb6289 parity):
- -install uses the managed launcher only when
CLAUDE_SSH_MANAGED_LAUNCHER is exactly 1. The values true and 1 (a
trailing space) and an unset variable add no field and run no launcher, even with
a usable launcher. claustrum treats every value other than 1 as off. That is
claustrum's choice (not measured for other values).
- With the gate and a CLI path, -install resolves the launcher for that path, as
launcher.resolve does. The facts line then gains launcher fields after
cliWasPresent, cliError and cliUnresponsive, in this order:
launcherStatus, launcher, launcherSource, launcherPath,
launcherReason, launcherStderr. A field that does not apply is omitted.
With -cli-url, fetch comes after cliUnresponsive and before
launcherStatus. That is measured on a Linux VM for the statuses usable,
none, unusable, probe_failed and unresponsive.
- Without -cli-version the cliPath is empty and the facts line gains no
launcher field. A cliPath that is empty because the cli-dir cannot be made
gains no launcher field either (measured on a Linux VM with the gate, against
f6010b97 and 89cb6289). Without
-cli-version, with or without -cli-dir, the line ends
"cliPath":"","cliWasPresent":false} and no launcher runs. That is measured on
a Linux VM with the gate, for a usable launcher, an unusable one and none.
- With -cli-version and no -cli-dir, the CLI path is in the default CLI
folder (see below), with the gate and without it. The launcher gets that path
(measured on macOS).
- none: the CLI runs directly, as without the gate, under the direct bounds
below. That run keeps the gate in its env. The facts end
"cliWasPresent":true,"launcherStatus":"none"}. A stopped direct run ends
"cliUnresponsive":true,"launcherStatus":"none"} (measured on Linux, macOS and
Windows).
- usable: the CLI runs once, as <launcher argv...> <cli> --version. No direct
run happens. The launcher's stdout is not reported, and its stderr does not
reach -install stderr. The facts add launcher and launcherSource.
- unusable or unreadable: the CLI does not run at all. cliWasPresent stays
true, and there is no cliError. The facts add launcherSource or
launcherPath, and launcherReason.
- A launcher run that exits non-zero, or ends on a signal, is probe_failed.
launcherReason is exit status <n> or terminated by SIGTERM. The CLI file
stays, cliWasPresent stays true, and there is no cliError.
launcherStderr holds the launcher's stderr and is omitted when it is empty.
- launcherStderr keeps 8192 bytes. More stderr adds \n[… <n> more
bytes], with the JSON escape \n on the line and … as raw e2 80 a6.
<n> is the count of dropped bytes. Measured with 10000 bytes: 8192 kept and
1808 named. claustrum's choices (not measured): it keeps the first bytes, and it
adds the \n even when the kept part ends in a newline.
- On a cache hit, a launcher run that has not ended at 33 s is stopped. The facts
then read "cliWasPresent":false, the cli unresponsive: … cliError above,
"cliUnresponsive":true, "launcherStatus":"unresponsive" and
"launcherReason":"did not exit within 33s and was stopped". The CLI file
stays. The reference stopped a 60 s launcher at 33.057 s and left no process.
This bound is parity. With a usable launcher the direct bounds below do not
apply. A CLI that answers at 31 s through a launcher is present (measured on
Linux and macOS).
- The run right after a fresh install has a bound of 123 s. The reference stopped
a 300 s launcher at 123.048 s after a -cli-url install and at 123.066 s after
a -cli-zst install (Linux VM). It let a 100 s launcher finish as usable
after a -cli-url install.
The stopped facts are the ones above, with
"launcherReason":"did not exit within 123s and was stopped". The cliError
text is the same. The CLI is installed all the same, and the blob is consumed.
- No install follows a stopped launcher run on a cache hit, and nothing is
pruned. The sweep runs: a swept file more than 10 minutes old is removed
(measured on Linux and macOS).
- claustrum's choices (not measured): the stop kills the run's whole process
group, then waits up to 2 s for its output. The texts name the launcher's
argv[0] only. No install follows a stopped launcher run on a cache hit when a
source flag is given either.
- A fresh install puts the CLI at its final path, then runs it once through a
usable launcher. The facts read "cliWasPresent":false with the usable fields.
The launcher gets the final path (measured on Linux and macOS).
- Right after a fresh -cli-url install, a failed run still installs the CLI.
An unusable launcher installs the CLI without a run. Both are measured on a
Linux VM. claustrum does the same for an unreadable answer and for -cli-zst.
That is claustrum's choice (not measured).
- After a stopped first run no prune follows (measured on Linux and macOS).
- The launcher run gets the daemon env without CLAUDE_SSH_MANAGED_LAUNCHER
(measured on Linux and macOS). claustrum also drops CLAUDE_CODE_PROCESS_WRAPPER
and CLAUDE_SSH_E2E_MANAGED_SETTINGS_DIR there, as for a spawn with a launcher.
That is claustrum's choice (not measured).
- -install still exits 0 with an empty stderr, and it writes no
[LauncherHandler] line.
- On Windows the resolve answers none. With the gate and a CLI path -install appends
"launcherStatus":"none" last and runs the CLI directly (measured on a Windows
VM).
The direct --version run and its bounds are parity. They are measured on
f6010b97 and 89cb6289. A direct run is a run with no usable launcher: the gate
is unset, or the launcher status is none.
- On a cache hit, a run that has not ended after 30 s is stopped. The facts
then read "cliWasPresent":false, the cli unresponsive: … cliError of a
direct run and "cliUnresponsive":true. -install exits 0 with an empty
stderr. A CLI that answers at 28 s is present. Measured on Linux, macOS and
Windows. A CLI that answers at 31 s is stopped (Linux and Windows).
- The run of a CLI that this run installed is stopped at 120 s, with the same
text. A CLI that answers at 118 s installs with no error. Measured on Linux,
macOS and Windows. A CLI that answers at 121 s is stopped (Linux and Windows).
- The class of the bound follows the install in this run. With a file that does
not run at the final path and a blob, the new CLI gets the 120 s bound
(measured on Linux).
- The download time is outside the bound. A 15 s download and a CLI that answers
at 110 s install with no error (measured on Linux and Windows).
- claustrum starts the clock right before the process start call. Measured on
Windows against 89cb6289: a stopped run has a wall time of 30.01 to 30.06 s,
or 120.02 to 120.06 s. The start delay of the CLI does not change it. That the clock
of the reference starts before the start call is inferred from those wall
times. On Linux and macOS both readings fit the rows.
- On Linux and macOS the CLI runs in a process group of its own, in the session
of -install. The stop ends a child in the group of the CLI. A child in a new
session survives. A CLI that ignores SIGTERM is stopped at the same time.
claustrum kills the group with SIGKILL. The name of the signal is not measured.
- On Windows the stop ends the CLI process. Its children stay alive (measured).
- A process that holds the stdout and stderr of the CLI after the CLI exited
delays nothing (measured on Linux, macOS and Windows). claustrum gives the CLI
the null device as stdin, stdout and stderr. What the reference gives it is
not measured.
- After a stopped cache hit the CLI file stays, and nothing is installed. A
-cli-zst blob stays and nothing is pruned. The sweep runs. Measured on
Linux, macOS and Windows. A -cli-url server gets no request (Linux and
Windows).
- After a stopped first run the new CLI stays at its final path, the -cli-zst
blob is consumed and nothing is pruned. Measured on Linux, macOS and Windows.
With -cli-url the facts carry fetch after cliUnresponsive.
- Not measured: a CLI that exits non-zero after a long time, and a second
-install on the CLI that a stopped first run left.
The default CLI folder is parity, measured on 89cb6289:
- With -cli-version and no -cli-dir, or an empty -cli-dir, the CLI folder
is <home>/.claude/remote/ccd-cli. Measured on Linux, macOS and Windows. On
Windows <home> is USERPROFILE, and HOME is not read.
- The other steps work in that folder as in an explicit one. The install from
-cli-zst or -cli-url is measured on Linux, macOS and Windows. The cache hit
is measured on macOS and Windows. The launcher run is measured on macOS.
- A cache miss makes the folder chain, with or without a source flag. Each new
level gets mode 0700. A level that exists keeps its mode. Measured on Linux
and macOS. With umask 000 the new levels still get 0700 (Linux). The
Windows listing shows no modes.
- Without -cli-version the cliPath is empty, nothing runs and no folder is
made. The default folder does not apply (measured on macOS and Windows).
- With no home folder, -install -cli-version <v> with no -cli-dir prints
nothing on stdout and exits 2. The reference prints
claude-ssh: cannot resolve home directory: $HOME is not defined on stderr.
claustrum prints the same line with its own prefix, claustrum:. Measured on
Linux against f6010b97 and 89cb6289, with HOME not in the environment.
- Not measured: no home folder together with -cli-dir, without -cli-version,
or in another mode. claustrum exits 1 in each of those cases, with the same
stderr line.
- Not measured either: macOS, and a missing USERPROFILE on Windows. claustrum
exits 2 there for the same arguments. On Windows its stderr line names
%userprofile%.
The CLI file name on Windows is parity, measured on a Windows VM against
89cb6289:
- The CLI file is <cli-dir>\<version>.exe. The cliPath, the file on disk,
the path of the --version run and the not runnable text all carry that
name. The missing text names the version with no suffix.
- Only <version>.exe counts as present. A file with the bare name counts for
nothing, and it does not run.
- The suffix is added always. -cli-version 9.9.9.exe installs 9.9.9.exe.exe.
- The prune counts every file, with or without the suffix.
- A folder at the bare name stays. A folder at the .exe name is replaced by
the CLI file.
- A CLI that an older claustrum installed at the bare name counts for nothing.
-install answers missing for it until a source flag installs the .exe
file beside it.
- The mtime decides between <v> and <v>.exe in the prune. With a bare
1.0.0 that is older than 1.0.0.exe, -cli-keep 2 removes the bare file
and -cli-keep 1 removes both (cells C-14k2 and C-14k1).
- Not measured: a version that ends in .EXE. A version that
the sweep claims by its end, such as 1.0.zst, installs as 1.0.zst.exe. The
sweep does not claim that name. No row measures that case.
Download progress and fetch stats came with 4534d86, on the -cli-url path:
- While downloading, -install prints __INSTALL_PROGRESS__<json> lines to
stdout: {"phase":"download","bytes":<n>[,"total":<m>]}.
total carries the Content-Length and is dropped when the server sends none,
as with a chunked body.
- A leading bytes:0 line comes first, after the response headers and before
any body byte. A server that sends no headers gets no line (measured on
89cb6289 on Linux and Windows). A final
bytes:<n> line comes only after the checksum passes. A failed check prints no
final line. Measured on a Linux VM against f6010b97 with a 57-byte blob, 5 of
5 runs per row: bytes:0 then bytes:57 on success, and bytes:0 alone on a
mismatch.
- Between those two lines a tick comes each 1 s. A tick prints a line only when
the byte count differs from the last printed line. Measured on 89cb6289: a
body of 6 parts that are 20 s apart prints 7 lines, not one line per second
(Linux, macOS and Windows). A body with no byte prints the leading line only
(Linux and Windows). A body with no Content-Length follows the same rule (Linux and
Windows). claustrum printed a line on every tick before.
- The tick lines show the count at the tick, so their byte counts jump
irregularly. A consumer treats them as progress, not as a byte-exact sequence.
- Not measured: whether the final line repeats a count that a tick printed
already, and what starts the tick clock. claustrum always prints the final
line, and it starts the tick clock when the body becomes readable.
- The __INSTALL_RESULT__ facts line gains a fetch object after cliError. It is
the last field, except with the managed launcher gate (see above):
{"bytes":<n>,"ms":<n>,"longestPauseMs":<n>}. Those are bytes read, download
duration, and the largest gap between reads. It appears whenever a -cli-url
download was attempted, even a 0-byte 404. It is dropped on the -cli-zst path
and on the cache-hit path.
- claustrum does not close its idle connections after the download. Its
transport is a copy of the Go default transport, whose idle limit is 90 s.
When the client closes the download connection is measured with three kinds
of server:
- macOS, a server that never closes first (18 runs). 89cb6289 and
claustrum both close the connection 90.0 to 90.1 s after the last body
byte, while the CLI runs.
- macOS, a server that closes at its own 5 s limit (18 runs): neither
binary closes first.
- Linux, a server that sends Connection: close: 89cb6289 closes 2 to
51 ms after the last body byte, and claustrum 0 to 5 ms after it.
- Not measured: Windows, and a CLI run shorter than 90 s against a server
that never closes first.
- The wait for the response headers has an always-on 60 s limit. A server that
reads the request and sends nothing fails the install with
download failed: Get "<url>": net/http: timeout awaiting response headers.
No progress line is printed, and fetch reads "bytes":0 and
"longestPauseMs":0. This is parity: f6010b97 and 89cb6289 give up after
60.0 s (measured on Linux and Windows). claustrum had no such limit before.
Not measured: macOS, and the exact start point of the limit.
- A body read that ends with a transport error fails the install with
download interrupted after <got>/<total> bytes: <transport err>, with no
download failed: prefix. The wait from the last byte to the error counts for
longestPauseMs. One progress line is printed, and the cli-dir stays empty.
This is parity: measured on a Linux VM against f6010b97 and 89cb6289 with a
connection reset after half the body. claustrum answered
download failed: <transport err> with "longestPauseMs":0 before. Not
measured: any other read error, a body with no Content-Length, and an error
before the first body byte. claustrum words them the same way, with a <total>
of 0 for a body with no length. The opted-in D12 deadline keeps its own
text.
- The -cli-url body has an always-on 60 s read-idle abort, reset on every byte,
that fails the install with the download stalled: … cliError above. This is
parity, not a divergence, because the reference does it. It is a read-idle bound,
not a total deadline, so a slow-but-progressing download completes. That total
cap is the opt-in D12, which is off by default.
Checksum and verify ordering:
- claustrum verifies -cli-checksum on the -cli-url path unconditionally. An
empty checksum still fails.
- Verify happens BEFORE decompress. This is intentional divergence D13. It is
always-on and unresolved. The reference decompresses first, and claustrum
checksums first. A blob that is both undecompressable and wrong-checksummed
diverges on the string. A short artifact yields checksum mismatch where the
reference says decompressing: unexpected EOF. A connection reset in the
body is not a D13 case: both binaries answer the download interrupted after …
text above (Linux VM). Any other interruption is not measured. Both binaries
fail the install either way. A download that is not
a zstd archive, with the right checksum for its bytes, gives the same
decompressing: invalid input: magic number mismatch text on both. The
reference reports 4 bytes there: "fetch":{"bytes":4,…} and one progress
line. claustrum reads the whole body first: "bytes":4096 for a 4096-byte
body, and two progress lines. Measured on a Linux VM against f6010b97 and
89cb6289. See
DIVERGENCES.md → D13.
- On the -cli-zst path claustrum verifies the blob only when a -cli-checksum
is supplied, as the reference does since 7d193f89. A mismatch answers
checksum mismatch and keeps the blob. An absent or empty checksum verifies
nothing.
- The compare is case-sensitive on both paths. The right digest in upper case
answers checksum mismatch: expected=<UPPER>, actual=<lower>, as on the
reference.
- On every cache miss claustrum creates the cli-dir (mode 0700, with its
parents) first. That is before the source check, before it opens a -cli-zst
blob and before any -cli-url network access. So every failed cache miss past
the version check leaves the cli-dir, empty if it was new. A mismatch, a 404, a
refused connection and a missing source all leave it. That matches the
reference: on every measured build from 5db5e4a for -cli-zst, and on
f6010b97 for -cli-url and a missing source. Measured on a Linux VM.
- The folder that claustrum creates is the cleaned cli-dir path. For
-cli-dir <dir>/sub/.. that is <dir>, so it makes no folder sub. That is
a code fact. 89cb6289 creates <dir>/sub (mode 0700), when no folder
sub exists before the run (Linux cell HS10b, three runs). In that cell
claustrum refuses at the home guard, and no folder sub exists after the run.
One opt-in wall-clock bound exists, and it is off by default, so a stock
claustrum does not apply it. The other -install clocks in this paragraph are
always on, and each one is a clock of the reference. The direct --version run has its
30 s and 120 s bounds, and a launcher run has its 33 s and 123 s bounds. The
download has the 60 s response-header limit and the 60 s read-idle abort. The ldd probe has the
reference's 5 s bound on ldd itself and a 2 s drain after ldd exits. The stdlib
transport clocks (net.Dialer{Timeout:30s}, TLSHandshakeTimeout:10s) apply on
-cli-url only. They are always on, and they are not probed on the reference.
One clock is claustrum's own choice and is not measured: the wait of up to 2 s
for the output of a stopped launcher run.
See DIVERGENCES.md:
- -cli-download-timeout <dur> is D12. 0 gives http.Client{Timeout:0}, which
is no bound. When armed, it bounds the whole exchange. An honest download that is
merely too slow therefore trips download failed: context deadline exceeded (…)
as surely as a black hole does.
- -cli-probe-timeout <dur> is a deprecated no-op. It was the opt-in D11, which
is retired: the reference bounds the direct --version run itself.
- The ldd --version libc probe bounds ldd itself at 5 s, as on the reference
since 19f30c46. It is not a knob. -libc-probe-timeout is a deprecated no-op. See
Staging and cleanup below.
D10 is the opt-in size cap. -max-cli-bytes <n>, or the max-cli-bytes
configuration key, governs both the decompressed CLI and the download body. 0 is
off, which is parity, because the reference took a 600 MiB payload to the
runnability test. claustrum streams the blob and never buffers it. It keeps a path,
not a []byte, so the staging retry can re-read it. "Cap off" therefore does not
mean unbounded memory. See DIVERGENCES.md → D10.
-cli-version hardening is claustrum-only:
- D6 requires a single path component. The clearing step is an os.RemoveAll
on the CLI path, <cli-dir>/<version> (on Windows <version>.exe). A version
that escapes the cli-dir
therefore deletes unrelated data. Measured, the reference destroys the target on
../victim. link/1.0.0 through an intermediate symlink also escapes. claustrum answers
cli version "…" must be a single path component. It answers before
the install writes or removes anything at a path built from the version. The
two sweeps of the cli-dir name only the cli-dir, and they are not behind this
rule. From the code: the *.zst.part sweep runs before the rule answers, and
the 10 minute sweep runs after the refusal. Cell P5 (Linux VM) has -cli-version ../x, a
p.zst.part file that is 8 days old and a .fetch-d file that is 20 minutes
old. claustrum and 89cb6289 both remove the two files and exit 0.
claustrum answers the refusal, writes nothing and keeps the blob. 89cb6289
installs a file at <parent>/x. That rule runs on the install path. A regular file that
is already at the joined path is first run with --version, as the cache-hit
check. A run that passes answers "cliWasPresent":true with that path, as
on the reference. That is measured with -cli-version ../x on Linux against
f6010b97 and 89cb6289, and on macOS against 89cb6289. A run that is
stopped answers the unresponsive text and sweeps the cli-dir. D6 answers only
when that run fails, or when no file is there. The reference has no such rule.
With ../x and a blob it installs to <parent>/x, outside the cli-dir, and
runs that file. That is cell V1c on Linux, macOS and Windows. It
refuses ., .., / and \ on every OS. claustrum uses a single-component
test and not lexical containment, because containment accepts link/1.0.0, and
EvalSymlinks adds a TOCTOU window. A final component that is itself a
symlink stays legal, because os.RemoveAll unlinks it and does not follow it. The
real client passes bare versions. 1.0.86, 2.0.0-beta.1, a commit sha,
latest and 1.0.86+build.5 are all measured as accepted.
- A version that the sweep claims, such as .fetch-x or 1.0.zst, installs.
The same run keeps it, because the sweep removes only old entries. A later
install removes it once it is more than 10 minutes old. This matches the
reference since 4534d86. The retired D7 refused such a version.
Staging and cleanup:
- An install follows the order of the reference. The new CLI is put at its
final path, the sweep runs and the download blob is removed. Then
<final path> --version runs. At that run the
cli-dir holds no .fetch- temp of this install, no .blob- file and no swept
file more than 10 minutes old. The prune follows a good run only. A run that
exits non-zero removes the new CLI again, so nothing is left at the final
path. A stopped run keeps the new CLI. Measured on 89cb6289 on Linux, macOS
and Windows. claustrum ran its staged .fetch-<random> file before, and it
swept after the run.
- What is at the final path goes before the new CLI runs. A folder there is
removed with the file in it, and at the run the cli-dir holds the CLI file
only. Measured on Linux, macOS and Windows. A file there that does not run is
replaced by the new CLI before the run (measured on Linux). claustrum replaced
both only after the run before.
- claustrum does not remove a folder at the final path that is the home folder
or contains it. It answers
cli path must not be or contain the home directory: "<path>". The folder is
not removed, the blob stays and no CLI runs. This is a divergence
(D2). The reference removes that folder as a tree and puts
the CLI file there. When the new CLI then fails, the folder is gone and nothing
is left at the path (Linux cell H2, macOS cell HX2L). Measured with
-cli-zst on Linux, macOS and Windows. D2 lists the
cells and what no cell covers. Not measured: -cli-url.
- That guard compares the folder at the final path with the home folder and
with each parent folder of it, by file identity. It does that for the home
path as given and for its resolved path. The home guard of the RPC paths
stays lexical.
- The sweep and the -cli-keep prune never remove the home folder. Each skips
an entry of the cli-dir that is the home folder or holds it
(D2). With a cli-dir that is the parent of the home
folder, the home folder is such an entry. Each of the two removes with one
plain remove, so a home folder with content cannot go there. A folder gets
the two tests of the guard above. Every other kind of entry gets the lexical
test alone. In the prune the skipped entry still takes its place in the
order, so the other entries go as without the guard. That is claustrum's own
choice. 89cb6289 removes an empty home folder there. In the prune it is the
oldest entry, past the keep value (cells H1, Linux, macOS and Windows VMs). In
the sweep it has the name h.zst and is 20 minutes old (cells H3, the same
three systems). claustrum keeps it in each of those cells, and every other
entry goes as on 89cb6289. A home folder with content stays on both (cells
H2, the same three systems). The difference stays by the maintainer's
decision of 2026-10-07.
- The volume of the macOS VM ignores letter case. There a -cli-version in
another letter case names the folder on disk. With -cli-version ALICE and a
folder alice that is not the home folder, both binaries replace the folder.
The new file has the name ALICE, as the command gave it (cells HXKC and
HXKC0). With -cli-version USERS and a folder users that contains the home
folder, 89cb6289 removes the folder and names the new file USERS.
claustrum refuses, and the folder keeps its name (cell HXK5).
- Not measured: a new CLI that exits non-zero over a file or a folder at the
final path. claustrum then leaves nothing at the final path, as for an empty
one.
- Not measured: how the reference writes the final file, and the order of its
sweep against that write.
claustrum decompresses to <cli-dir>/.fetch-<random>, renames that file to the
final path and then sweeps. It consumes the -cli-zst blob after the run.
- A cli-dir path that names something that is not a folder is replaced, as on
the reference. The trigger is a regular file, a symlink to a regular file or a
FIFO at the cli-dir path. -install removes that entry. For a symlink it
removes the link and keeps the target. It also removes the regular file named
exactly ccd-cli-version in the parent folder of the cli-dir. Then it makes
the cli folder with mode 0700 and goes on. That holds for the default CLI
folder too, and with no source flag, before the missing answer.
- The name ccd-cli-version is literal. It does not follow the name of the
cli-dir, and a file named other-version, ccd-cli-version.bak or
zzz-version stays.
- A folder, a symlink to a folder and an absent cli-dir trigger nothing, and
ccd-cli-version stays. A dangling symlink triggers nothing either: the
answer is mkdir cli dir: mkdir <path>: file exists with an empty cliPath.
- With a parent folder that is not writable nothing is removed. The answer is
mkdir cli dir: mkdir <path>: not a directory with an empty cliPath.
- The four bullets above are measured against f6010b97 and 89cb6289, one
run for each shape: 14 shapes on Linux and on macOS, 13 on Windows. Windows has
no FIFO row and no row with a parent that is not writable. The mkdir texts
above are the Linux and macOS texts. No mode is claimed on Windows.
- Both removes are plain removes of one path. Nothing is removed as a tree.
claustrum's remove does not open the FIFO. When a FIFO stays behind the
cli-dir path, claustrum's sweep after the mkdir error opens that path once as
a directory. That open fails at once (measured on Linux).
- Not measured: a socket, a device or a symlink to a FIFO at the cli-dir path.
A ccd-cli-version that is a folder or a symlink is not measured either.
claustrum leaves each of them alone. Not measured: the -cli-url source and the launcher gate with
these shapes, and the order of the two removes. claustrum removes the version
file first.
- claustrum stages the CLI at <cli-dir>/.fetch-<random> and renames it into
place. It never stages at <cliPath>.tmp. This is one code path
for -cli-url and -cli-zst alike. The orphan sweep matches .fetch-*, so it
reclaims the litter of an interrupted install.
- A -cli-url download lands at <cli-dir>/.blob-<random>, because ensureCLI
creates the cli-dir first. If the cli-dir is unwritable, it lands at
$TMPDIR/claustrum-fetch-<random>. The .blob- prefix is deliberately
different, so that the -cli-keep prune does not count it and the sweep does
not claim an in-flight blob. That is also why claustrum refuses a
-cli-version that starts with .blob- (D18). The install removes the blob
before the --version run of a new CLI. An attempt that ends earlier removes
the blob at its end. Only a SIGKILLed download
leaves it behind, and nothing reclaims
it. The sweep must not take it, because a retry re-reads the blob after the
staging file, which is never older. No frame changes either way.
- One difference on a completed install, which claustrum keeps for now.
Measured on macOS against 89cb6289, 4 runs per binary (cell HX5C). With
-cli-zst, the blob is already gone when the CLI runs --version on
89cb6289. On claustrum it is still there during that run, and gone after
it. No rule is claimed beyond these rows. The same cell showed 89cb6289
removing empty folders in the cli-dir. The C rows of the prune below measure
that.
- The -cli-keep prune follows 89cb6289, with three differences. The first
is the .blob- name below (D18). The second is the home
folder above (D2). The third is the stderr text of a
negative keep value below. It is measured in the C
rows: rows C-0 to C-15 on a Linux VM and a macOS VM, and cells C-00 to C-18
on a Windows VM. Each run is one -install with -cli-zst. A second round
ran on the same three systems. Its cells have a letter and a number, such as
K0a and Z2.
- Every entry of the cli-dir counts: a file, a folder and a link. With four
empty folders and -cli-keep 3, the two oldest go (row C-1, cell C-01).
With two files and two newer empty folders, both files go (row C-5, cell
C-04). A link takes a keep place at its own mtime (cell P2, Linux VM).
The cell has a new link to a file that is 6 h old, two files that are 2 h
and 3 h old and -cli-keep 2. Both files go, and the link and its target
stay. The control cell P2c has a new file in place of the link and gives
the same.
- The order is the mtime, newest first. The keep value is how many entries
stay. With five entries, -cli-keep 5 removes nothing and -cli-keep 4
removes the oldest (rows C-3 and C-4, cells C-03k5 and C-03k4).
- Entries with the same mtime stay in name order, so the later name goes
(rows C-12 and C-12b, cell C-15). The rows have two and three such names,
all in lower case. The name order is the order of the bytes, so an
upper-case name comes first. With B, a and c of one mtime and
-cli-keep 2, B stays beside the new CLI, and a and c go. Cells
T1 have files (Linux, macOS and Windows VMs). Cells T1d have empty
folders (macOS and Windows VMs). With -cli-keep 3, c alone goes
(cell T1b, Linux VM). Files that are made in the reverse order give the
result of cell T1 (cell T1c, Linux VM).
- The new CLI counts as every other entry. With two folders whose mtime is
1 h in the future and -cli-keep 2, the new CLI is removed. The result
line still carries its cliPath and no cliError, and the exit code is
0 (row C-11 on Linux and macOS). On Windows three folders (cell
C-11dir) or three files (cell C-11file) with -cli-keep 3 give the same.
On Windows a new CLI that a process still runs stays (cell C-11b).
- -cli-keep 0 removes every entry that counts, the new CLI too. A folder
with content stays. The result line still names the CLI path, with no
cliError, and the exit code is 0 (cells K0a and K0b on Linux, macOS
and Windows VMs, and cell C-13).
- A negative keep value ends a good install after the new CLI is in place
and before any entry goes. Cells Kneg
(-cli-keep -1, Linux, macOS and Windows VMs): 89cb6289 installs the
new CLI and removes the -cli-zst blob. Then it exits 2 with an empty
stdout, so with no result line, and with a Go runtime error on stderr.
Nothing is pruned. claustrum matches the listing, the exit code and the
empty stdout. Its stderr is one line of its own:
claustrum: -cli-keep -1 is not a valid keep count. That is the
maintainer's decision of 2026-10-07. -cli-keep -2 gives the same exit
code and no result line on both (cells Kn-2, the same three systems).
On a cache hit and after a failed install no prune runs. There a
negative value changes nothing: both print the result line and exit 0
with an empty stderr (cells Kn-hit and Kn-fail, -cli-keep -1, Linux,
macOS and Windows VMs). Not measured: a negative value after a stopped
run. claustrum prints the result line and exits 0 there.
- Each remove is a plain remove of one entry. A folder with content stays
(rows C-2 and C-6, cell C-02). A symlink goes as a link, and its target
stays (row C-7). A link to a folder with content outside the cli-dir
goes too, and that folder keeps its content (cells S1, Linux and macOS
VMs). On Windows a junction, a folder symlink and a file
symlink go the same way (cell C-06). A junction and a directory symlink
to a folder with content go too, and that folder keeps its content
(cells S1j and S1s, Windows VM). On Windows an empty folder with the
read-only attribute stays, and a read-only file goes (cells C-05 and
C-07). A file that another process holds open with no delete sharing
stays, and so does a program that runs (cells C-08, C-09rw and C-10).
- A name that the sweep claims is not counted and not removed by the
prune. A fresh one stays and takes no place: .fetch-d and x.zst in
row C-9, .fetch-e and y.zst in cell C-18. At 20 minutes the sweep
removes both before the new CLI runs (row C-8). A row on f6010b97
showed the same for two files on a Linux VM. Cell P3 (Linux VM) shows it
for files on 89cb6289: .fetch-x and y.zst are 1 minute old, two
other files are 4 h and 3 h old and -cli-keep is 2. The older of the
two other files goes, and .fetch-x and y.zst stay.
- A *.zst.part name is not counted either: a file q.zst.part that is
6 days old and older than every other entry stays (cell C-18). Cell P1
(Linux VM) has two files a.zst.part and b.zst.part that are 1 minute
old, a file v1 that is 4 h old and -cli-keep 2. All three stay beside
the new CLI. The control cell P1c has the names a.part and b.part,
and there b.part and v1 go. A sweep
of its own removes such a name when it is more than 7 days old. A file
that is 6 days and 23 h old stays, and one that is 7 days and 1 h old
goes (cells Z1, Linux, macOS and Windows VMs). Cells Z6 narrow that
bracket. A file that is 7 days and 1 s old goes (Windows VM). A file
that is 7 days and a fraction of a second old goes (Linux and macOS
VMs). On the macOS VM the two runs of 89cb6289 are 0.13 to 0.67 s at
the start of the process. A file that is 7 days minus 5 s old stays
(cell Z6m, macOS VM). A file that is exactly 7 days old at the instant
of the read is not measured, and claustrum keeps it. Row C-10 shows the
rule for empty folders at 6 and 8 days.
- The *.zst.part rule wins over the .fetch- prefix. A file
.fetch-a.zst.part that is 20 minutes old stays, as .fetch-b.zst.part
at 1 minute does (cells Z2, Linux, macOS and Windows VMs). Before,
claustrum removed the first. A file .fetch-old.zst.part that is 8 days
old goes (cells Z5, the same three systems).
- Both name rules match the letter case. The files X.ZST.PART and
x.ZST stay at 9 days (cells Z3, Linux, macOS and Windows VMs).
- claustrum runs the *.zst.part sweep once, before any CLI runs, also on
a cache hit. Cells Z4b (Linux, macOS and Windows VMs): the CLI of the
version is present and runs, and a p.zst.part file that is 8 days old
is gone after the run. Cells Z4a (the same three systems): the new CLI
exits 1, and the file is gone. Cell Z7 (Linux VM) has no CLI of the
version, no -cli-zst and no -cli-url. The result line carries the
cliError cli <version> missing and no --cli-url or --cli-zst provided
with exit code 0, and the file is gone. Not measured: the run of cell
Z7 on macOS and Windows. claustrum sweeps there too. Before, claustrum
kept the file on a cache hit.
- That sweep takes a CLI file too. Cell P4 (Linux VM) has
-cli-version 1.0.zst.part, a blob and a CLI file of that name that is
8 days old. It is not a cache hit. claustrum and 89cb6289 both install
the version again from the blob, answer "cliWasPresent":false and
consume the blob. In the control cell P4c the file is 6 days old, and
both answer a cache hit.
- The sweep names only the cli-dir, so a -cli-version that claustrum
refuses does not stop it. In cell P5 (Linux VM, -cli-version ../x) a
p.zst.part file that is 8 days old is gone on claustrum and on
89cb6289. The D6 text above has the rest of that cell.
- A cache hit with a good run does not run the 10 minute sweep. A file
x.zst and a file .fetch-d that are 20 minutes old both stay (cells
Z8, Linux, macOS and Windows VMs). A cache hit whose --version run is
stopped at the 30 s bound runs it. Cell P6 (Linux VM): claustrum and
89cb6289 both exit 0 with the cli unresponsive result line. The
files x.zst and .fetch-d, 20 minutes old, are gone after the run. A
p.zst.part file that is 8 days old was gone before the CLI started.
- A name with the .blob- prefix is not counted and not removed by the
prune. 89cb6289 counts it and removes it
(D18). Cell C-12 (Windows VM only) has -cli-keep 2
and a planted .blob-planted file that is 1 h old. Two files beside it
are 4 h and 3 h old. 89cb6289 counts the planted file and removes
both older files. claustrum removes the 4 h file only. Cells B1 (Linux,
macOS and Windows VMs) have -cli-keep 1 and a planted file that is
older than two other files. 89cb6289 removes all three. claustrum
removes the two other files and keeps the planted file.
- A failed install and a cache hit do not prune (rows C-14 and C-15, cells
C-16 and C-17). A TMPDIR that names a folder of the cli-dir changes
nothing (row C-13).
- A folder with content takes a place in the order. Cells F2 (Linux, macOS
and Windows VMs) have -cli-keep 2 and a folder with content that is
newer than three files. The new CLI and the folder stay, and all three
files go. Cells F1 (the same three systems) do not show it. There the
folder is older than four files and the keep value is 3. The two oldest
files go and the folder stays, with and without a place for it.
- Not measured: a swept name that the sweep did not remove. claustrum does
not count it.
- Before, claustrum counted and removed files and links only. It did not
prune for -cli-keep 0, and it counted a *.zst.part file as a version.
A negative keep value gave the result line and exit code 0.
- claustrum consumes the -cli-zst blob once decompression succeeds, and not
only on a fully successful install. An extracted CLI that fails the runnability
test still costs the blob. claustrum leaves a blob that is not valid zstd alone.
One exception: the home guard refusal (D2) keeps the blob.
- claustrum clears an occupied cliPath, and that is not fatal. rename(2)
refuses to replace a non-empty directory, so claustrum removes it first, as a
tree. It removes it only when cliPath is a directory. A regular file, which an
installed CLI always is, is replaced atomically by the rename. If claustrum cannot remove it →
clearing stale dir at <path>: <err>. If the staging file vanished →
staging file vanished before install: <err>, and a file at cliPath stays
untouched. On Windows the cleared name is <version>.exe. The D6 rule runs
before this step, so cliPath is a direct child of the cli-dir.
- The orphan sweep removes a .fetch-* or *.zst entry only when its mtime is
about 10 minutes old or more. A name that ends in .zst.part is not part of
this sweep (see the prune above). The gate exists from 4534d86 on. 5db5e4a and
7d193f89 swept at every age. On 19f30c46 through f6010b97, 599 s stays,
601 s goes and a future mtime stays. claustrum removes an entry only when it is
more than 600 s old. The names are case-sensitive, and the bare .fetch- and
.zst count. The age is the entry's own mtime, for a symlink the link's own, so a symlink is
judged by the link, and only the link goes. The sweep uses one os.Remove per
entry. It therefore clears files and empty directories, and leaves a
non-empty .fetch-dir/ at every age. Unrelated files survive. Measured on a
Linux VM. The sweep runs once whenever an
install was attempted, and on a cache hit whose --version run was stopped
at its bound (cell P6 of 89cb6289, Linux VM). A cache hit with a good run does not sweep (cells Z8 of
89cb6289, Linux, macOS and Windows VMs). The -cli-keep
prune runs only on success. claustrum
stages its extract in this same .fetch-* namespace, until the rename. A concurrent
install can reclaim that staging file only once it is more
than 10 minutes old. claustrum handles that case with a single retry of the
staging step.
- claustrum runs ldd on every libc probe since build 3ef9370, and its output
decides the answer. A "musl" banner reports musl, and any other output reports
glibc. The /lib/ld-musl-*.so.* marker is consulted only when ldd produced
no output.
- The probe bounds ldd itself at 5 s, as on the reference since 19f30c46. ldd
runs in its own process group. If ldd still runs at 5 s, the whole group is
killed. Output written before that kill is dropped, so the marker decides. No log line is
written, and the facts line keeps its shape. The bound applies on a fresh
install and on a cache hit. Measured on a Linux VM.
- If ldd exits before 5 s while a child still holds its output pipe, the probe
waits up to 2 s more. Then it uses the output it has, even past 5 s. The child
is not killed. Measured on a Linux VM against f6010b97 for exits from 0.5 s to
4.5 s: the reference answered musl about 2 s after ldd exited.
-probe-cli — classify a CLI binary¶
-probe-cli <path> runs the bounded <path> --version runnability probe and exits
0. It is how Claude Desktop classifies a CLI binary out of band, without a full
-install (reference build 19f30c46). Its stdout is:
- If the CLI runs and exits 0 within the bound, stdout stays empty.
- If the deadline had to kill it, stdout is
__CLI_HUNG__\n. - If the binary is missing or does not run, stdout is
__CLI_BAD__\n. Not running means it either fails to start or exits non-zero. - On Windows, a path with no file at exactly that name gives
__CLI_BAD__\n, and nothing starts. The probe never adds.exe. Measured on a Windows VM against89cb6289: with only9.9.9.exeon disk,-probe-cli <dir>\9.9.9answers__CLI_BAD__and the CLI does not run. claustrum ran9.9.9.exethere before. Not measured: the exact test of the reference, a folder, a relative path and a path with another extension. claustrum tests only that the path exists.
With CLAUDE_SSH_MANAGED_LAUNCHER=1 the probe uses the
managed launcher (89cb6289 parity). Every
-probe-cli row exits 0, and each stderr text below ends in one newline:
- A usable launcher runs
<launcher argv...> <path> --version. If the run exits 0, stdout and stderr stay empty. - An unusable launcher prints
__CLI_LAUNCHER__\n, and the CLI does not run. stderr isclaude-ssh: <path> was not run: managed launcher unusable: <reason>. - Unreadable settings print
__CLI_LAUNCHER__\n, and the CLI does not run. stderr isclaude-ssh: <path> was not run: managed settings unreadable: <file>: <reason>. - A failed launcher run prints
__CLI_LAUNCHER__\n, not__CLI_BAD__. stderr holds four lines. The launcher outputL5: to stderr\nis followed by one more newline:
claude-ssh: the managed launcher's run printed:
L5: to stderr
claude-ssh: <path> --version through the managed launcher <argv0> did not succeed: exit status 3
- A launcher run that has not ended at 33 s is stopped. stdout is
__CLI_HUNG__\n, and stderr isclaude-ssh:and thecli unresponsive: …text of-install. The reference stopped a 150 s launcher at 33.069 s. - Without the gate the probe runs directly, as before. On Windows the gate shows no difference (measured on a Windows VM).
- claustrum's choices (not measured) follow. A none answer runs the probe
directly. An empty launcher stderr leaves out the
printed:block. The stderr cap of-installapplies. A signal readsterminated by SIG<name>.
The -help text of -probe-cli names this mode. It is the one -help line that
differs from f6010b97 (measured on Linux, macOS and Windows).
The direct bound is a fixed 30 s, always applied. Measured on 89cb6289 on
Linux, macOS and Windows: a CLI that answers at 28 s passes, and one that answers
at 33 s gives __CLI_HUNG__ at 30.0 s. The mode unsets CLAUDE_RPC_TOKEN so the
probed child never inherits it. The probe runs in its own process group. On Unix the
fixed deadline group-kills the whole subtree, so a --version that forks a descendant
cannot outlive the probe. On Windows the direct child is killed and WaitDelay bounds
the mode, so a stray descendant is left to exit on its own. The mode installs no SIGINT
handler. A Ctrl-C therefore terminates the claustrum process itself, with exit 130 and
empty stdout. The probed CLI runs in its own process group, so a terminal Ctrl-C is not
delivered to it. The mode ignores
SIGPIPE, as -install does, so a stdout pipe the caller closes mid-write fails the
write with EPIPE instead of terminating the process.
Behavior shared by every mode¶
- The default socket is
~/.claude/remote/rpc.sock. When-socketis omitted, all modes fall back to it. If the parent directory is missing,-servecreates it with mode0700, so a bare-serveon a fresh machine works.-bridgeand-stopdo not create it. When no daemon has run,-bridgefails withconnect: no such file or directory, and-stopprintsnone. - With no mode given, claustrum prints
claustrum: one of --version/--install/--probe-cli/--serve/--bridge/--stop is requiredon stderr and exits2, with no usage dump. An unknown flag gets the stdlibflagerror plus the usage, and exit2.
See ARCHITECTURE.md for the -install facts schema and the
deployment lifecycle. See DIVERGENCES.md for the full
divergence catalog and rules. See EXAMPLES.md for runnable
snippets.