Staying in lock-step with the reference daemon¶
claustrum is behaviorally compatible with a reference daemon. That daemon ships inside Claude Desktop's SSH-remote feature. A git SHA gives its version, and a public CDN holds it as per-platform zstd blobs. This document tells you how to detect a new build. It also tells you how to find out whether that build changed anything claustrum must match. For the running history of which builds changed what, see the reference build ledger.
How the reference is distributed¶
- Per-version manifest:
https://downloads.claude.ai/claude-ssh-releases/<sha>/manifest.json→{"version":"<sha>","platforms":{"<goos>-<goarch>":{"checksum":"<sha256 of .zst>","size":N}}} - Per-platform artifact:
https://downloads.claude.ai/claude-ssh-releases/<sha>/<goos>-<goarch>/claude-ssh.zst - Six targets:
linux-amd64,linux-arm64,darwin-amd64,darwin-arm64,windows-amd64,windows-arm64. (GOARCH naming:amd64, notx64.)
There is no "latest" index. The SHA is the key to each release, so step 1 is always "find the new SHA".
Step 1 — find a candidate SHA¶
The SHA of the reference build is the SHA Claude Desktop deploys now. These are the sources, easiest first:
- A host that Desktop connected to. The cache holds one daemon per SHA:
- The Desktop app bundle. The app contains the pinned SHA, and all six
per-platform checksums and sizes, as a build-time constant. You can read
that constant offline, and it needs no network. In the Linux
.debit is aJSON.parse('{"version":"<sha>","manifest":{…},"baseUrl":".../claude-ssh-releases"}')literal insideresources/app.asar. That file is a minified.vite/build/index.chunk-*.js, so the chunk name and the wrapper function are random for each build. A parallel literal pins the CLI (claude-code-releases). From version 1.24012.9 onward, itsbaseUrlcarries a channel suffix,claude-code-releases/rc/<sha>. The extractor therefore matches the bucket by path segment, not byendswith. A new Desktop build is itself the "new SHA" signal. Two scripts read the constant: scripts/extract-desktop-pin.pyreads it directly from a Linux.deb, a Windows.nupkgor a macOS.zip. It uses the standard library only:ar→data.tar.xz→app.asar, or zip →app.asar, then an enclosure brace-match. You need nodpkg, nounzipand noasar. The Windows and macOS bundles carry the same literal.scripts/latest-desktop-sha.pyruns the full loop for one platform (--platform linux|windows|macos, defaultlinux): find the newest Desktop → download → extract → compare toUPSTREAM_SHA.
Observed pins: Linux 1.18286.0 (2026-07-02) pinned 7c2f88d…. Versions
1.20186.1 → 1.24012.9 pin 5db5e4a….
3. The Desktop machine's cache. The per-platform binaries are under
<app-data>/claude-ssh-remote/<sha>/ (%APPDATA%/Claude/… on Windows),
beside a .verified-<goos>-<goarch> marker.
4. Probe a guess. manifest.json returns 200 for a real SHA. It returns 404
for all other values.
Note: the CLI release manifest (
claude-code-releases/<ver>/manifest.json) has acommitfield. That field gives the CLI's commit, not the daemon's. Do not confuse the two.
claustrum re-uploads every session (harmless)¶
Before a session the client runs server --version on the cached
~/.claude/remote/srv/<pinned-sha>/server. It then matches /claude-ssh\s+(\S+)/.
It skips the re-upload only for a first token that equals the pinned SHA.
claustrum prints claustrum <ver> (built …), which is its own identity. So the
token never matches, and the client sends the daemon again by SFTP every
session. That transfer is idempotent and is approximately 2.3 MB. The
claude-ssh:→claustrum: rebrand causes this. The version line is CLI stdout,
not a JSON-RPC frame. The wire contract therefore does not change, and the redeploy
is harmless. The daemon that runs at the end is still claustrum.
A drop-in build stamp makes the client see claustrum as up-to-date and stops
the overwrite. Such a stamp emits claude-ssh <pinned-sha> as the first token,
and the binary goes at srv/<pinned-sha>/server. It is off by default. It is an
opt-in build stamp, not a wire change. Mechanism and the -version format:
docs/PROTOCOL.md → -version.
Step 2 — run the drift check¶
scripts/check-upstream.sh <sha>
# or, with the pinned baseline SHA in scripts/UPSTREAM_SHA:
scripts/check-upstream.sh
The script needs network access. It does these steps:
- Compare
<sha>to the pinned baseline inscripts/UPSTREAM_SHA. A difference is itself the "new release" signal. - Fetch the manifest. Download the
linux-amd64claude-ssh.zst. Check it against the manifest checksum. Decompress it. - Build claustrum. Then diff the two binaries on the items claustrum must match:
- method names (
server.*/files.*/git.*/process.*literals), - CLI flags (
-helpoutput), - the
-versionformat, - the app-facing string set (errors,
[Server]/[process.Manager]/[frameSink]/[shellenv]log lines, flag help). - With no drift in the checked surface, print
PASS. With drift, printDRIFTwith the specifics, and exit with a non-zero status.
This static check needs no running daemon. It is safe to run on any machine that has network access.
Step 3 — authoritative byte-for-byte recheck (local only)¶
The static check catches methods, flags, and strings that were added or removed.
Frame-level byte-identity covers result field order, stream framing, error bodies,
and reattach semantics. To make sure that it holds, run the private validation
battery against both the new reference and claustrum. That battery stays in scratch/ and is not published:
# starts each binary as a PRIVATE -serve on a throwaway /tmp socket, runs the
# full method battery, and diffs normalized frames. Never touches a live daemon.
scratch/probe/validate.sh <path-to-reference> > /tmp/ref.json
scratch/probe/validate.sh ./claustrum > /tmp/mine.json
diff /tmp/ref.json /tmp/mine.json
Safety: probe only a private instance on a
/tmpsocket with its own-token-file. Never point the probe at a live daemon's socket. Clean up after every probe.
Step 3b — real-session capture (highest fidelity, optional)¶
Steps 2–3 drive the daemon with synthetic requests we write. The best check is the traffic of the real desktop client. This is the method:
- The bridge (
server --bridge) is a simple stdio relay. So a tee on its stdin and stdout records the exact client↔daemon NDJSON of a live session. You can then replay that record against claustrum and diff it. - The tools are in
scratch/capture/(gitignored): acapture-bridge,replay.js, and aREPLAY.mdrunbook.replay.jsdiffs without regard to order. It keys responses byidand stream frames byprocessId+seq. It masks the version SHA, the token, and (with--mask-data) nondeterministic agent payloads. - A throwaway SSH user records the session, through a
ForceCommandwrapper scoped to that user, so the capture never touches a live daemon. Raw dumps carry the session token and host paths. Keep them inscratch/(gitignored). Never commit them.
We last ran this capture against a then-pinned reference, 8de85faa. That build is
now well behind the current baseline, 89cb6289. It used a real Desktop session and
covered the full process.* lifecycle. That lifecycle included a >32 KiB output
stream and a mid-stream disconnect and reconnect that drove process.reattach. The
result was byte-identical for the methods that build exposed. The server.capabilities
set, the stream-envelope field order, and per-process reattach replay all
matched. docs/PROTOCOL.md holds the canonical frame shapes.
Use this capture as a periodic spot-check. Use it again for a new build that changes
process.* behavior the synthetic battery cannot fully model (real reconnect
timing, multi-process reattach).
Step 4 — reconcile¶
If the check reports drift:
- Identify exactly what changed: a new method, a changed error string, a new flag, a new install fact, or a changed frame shape.
- Implement the change in claustrum behind the existing structure. Keep every unchanged behavior unchanged.
- Run the static check and the byte-for-byte battery again, until both are clean.
- Update
scripts/UPSTREAM_SHAto the new SHA. Note the change inCHANGELOG.md. If the wire surface changed, updatedocs/PROTOCOL.md.
Don't reconcile the deliberate divergences away¶
Not every claustrum behavior is meant to match the reference. docs/DIVERGENCES.md catalogs the deliberate divergences (the D series and CT-1..CT-5) and the four-rule standard that governs them. That file is the canonical home for what each one is, why it exists, how to activate it, and its reopen trigger. When the drift check flags one of them, it is expected, not drift. Check it against the catalog before you "fix" it.
This triage index adds the one question the catalog does not answer directly. Can a probe even see this divergence? If it can, is the symptom drift or an activated opt-in?
Which divergences a probe can see¶
battery-visible? asks whether the standard frame battery (validate.sh /
battery.js) shows a diff. The install-path entries (D10, D12 and D13) run under
-install, which the frame battery never drives at all.
| ID | Default | Battery-visible? | What it is |
|---|---|---|---|
| D3 | Off (0 = unlimited) |
No. Off the default path | files.extract_tar size cap |
| D5 | Off (0 = no deadline) |
No. Off the default path | git-invocation deadline |
| D10 | Off (0 = unlimited) |
No. Install path | -install CLI size cap |
| D12 | Off (0 = no bound) |
No. Install path | -install download bound |
| D2 | Always-on | Maybe. A probe that reaches the path shows it (expected) | destructive-path home-dir refusal. On -install it is the cli path must not be or contain the home directory text. That refusal is this guard, not drift. A home folder that stays in the cli-dir after the sweep or the prune of -install is this guard too |
| D6 | Always-on | Maybe. A probe that reaches the path shows it (expected) | -cli-version single path component |
| D18 | Always-on | Maybe. A probe that reaches the path shows it (expected) | -cli-version must not start with .blob-. The -cli-keep prune does not count a .blob- name, so one more old entry stays than on 89cb6289 (cell C-12, Windows VM). It does not remove such a name either, where 89cb6289 removes a planted one (cells B1, Linux, macOS and Windows VMs). That is this entry, not drift. A good install with a negative -cli-keep exits 2 with no result line on both (cells Kneg and Kn-2, the same three systems). That is parity. The stderr text there is claustrum's own line, by the maintainer's decision of 2026-10-07. It is not drift and has no entry of its own |
| D19 | Always-on, Windows only | Maybe. A Windows probe with a junction at any directory between baseRepo and the leaf shows it (expected) |
git.worktree_remove refuses that junction with failed to remove worktree: openat <path from baseRepo>: path escapes from parent. At .claude or .claude\worktrees, f6010b97 answers success and deletes the branch, and 89cb6289 does the same for a branch that another ref reaches. At a junction <P>\J above .claude, 89cb6289 answers success after 2 git calls. No leaf and no branch existed there (Windows VM, cells J-b-wt-pj and J-b-wt-pJ). The maintainer widened the entry to those levels on 2026-10-07. That is this entry, not drift |
| D20 | Always-on, Linux and macOS | No. Off the wire. It is a signal time at a daemon start, not a frame | 50 ms settle before the group SIGKILL of a leader that reads as gone. In row RP05a that SIGKILL comes about 50 ms later than on 89cb6289. In row PG05a claustrum ends the child in no run, where 89cb6289 ends it in some Linux runs. In rows where the child ends on SIGTERM, 89cb6289 also sends a group SIGKILL right after it in some Linux runs, and claustrum sends none. None of these is drift |
| D21 | Always-on, Windows only | No. Off the wire. It is the content of remote-server.log, not a frame |
a second daemon on a live socket appends to remote-server.log. 89cb6289 truncates the file at that start. The earlier lines of its first daemon are lost. The later lines of that daemon sit behind a block of NUL bytes (rows WN04, WJ04). Keeping the lines is a maintainer decision of 2026-10-02. The longer log of claustrum is not drift |
| D22 | Always-on | Maybe. A probe that reaches the path shows it (expected) | git.worktree_remove refuses a worktree that is locked in the .git folder of baseRepo. 89cb6289 answers success in four states (rows p6, p6d, p6e and p6f), and with worktreeRoot rows q2 to q4 differ in the frame too (see the entry). A maintainer decision of 2026-10-03. The refusal is not drift. 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. |
| D23 | Always-on, Windows only | Maybe. A Windows probe whose child prints its environment block shows it in the stream frames (expected) | the environment block of a 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). The set of entries and the response frames are equal in those rows. Cause: the Go version. The other order is not drift |
| D24 | Always-on, Linux and macOS | No in the measured cells: the frames are equal and the disk differs (Linux and macOS VMs). A probe that lists the registrations folder shows it. At the placement the frame holds claustrum's own text is not the folder that was tested after the add (not measured) |
If git answered rev-parse --absolute-git-dir, the rollback of git.worktree_create deletes the registration only if the folder at its path is still the folder that the create accepted after the add. 89cb6289 removes the folder at that path in cells B6 and B6b (Linux and macOS VMs, 2 runs each). In B6b that folder holds the registration of a live sibling worktree. The placement of the index has the same test (not measured). A maintainer decision of 2026-10-07. The kept folder is not drift |
| D25 | Always-on, Linux only | Against 5fd08069: yes on a Linux run with CLAUDE_SSH_PEER_CHECK=1 in the daemon environment, and that member is equal with no variable. Against 89cb6289: that build has no peerCheck member, see the peerCheck bullet below |
server.capabilities answers "peerCheck":"unavailable", where 5fd08069 answers "peerCheck":"on","peerCheckBy":["network","pidfd"] (Linux VM). claustrum has no peer check and serves every caller. Its log line there is its own text. A maintainer decision of 2026-10-10. The other answer is not drift |
| D8 | Always-on | No. It falls back to inherited stdio, not a frame | foreign/symlinked remote-server.log not followed (.old rotation matched, refuse-to-follow kept). Linux and macOS. On Windows see D21 |
| D9 | Always-on | Maybe. A type-mismatched namespace field is rejected | namespace-param binding vs. the reference's ignore |
| D13 | Always-on (unresolved in DIVERGENCES.md) | No. Install path | verify-before-decompress ordering, on -cli-url and on -cli-zst with a checksum |
| D16 | Always-on, Windows only | Yes on a Windows run. git.status of a linked worktree: the reference answers exit status 128 when the user has no global excludes file, claustrum answers the status |
the core.excludesFile of the status, ls-files and diff-index calls of git.status is /dev/null, not NUL. The reference stops at its status call there, so its value for the other two is not measured |
| CT-1 | Opt-in (wantPid) |
Yes, on request. It adds pid/startTime |
spawn/reattach reply extension |
| CT-2 | Opt-in (-keep-children) |
No | children survive shutdown |
| CT-3 | Opt-in (claustrum.conf) |
Only version-override, via the static check's -version diff |
the configuration file itself |
| CT-4 | Not built. A deferred idea, recorded in DIVERGENCES.md only | No. There is no code | opt-in hardened token persistence (a persist-token key, or a Windows owner-only DACL) |
| CT-5 | Opt-in (-listen-pipe, Windows) |
No | additional named-pipe transport |
D1, D7, D11 and D14 are retired. The reference changed on the path, or a later
measurement corrected the premise, and claustrum now matches it. A difference on those paths is drift, not a divergence, unless
an entry in the table above covers it (for example D6, D10, D13, D18).
D4 is retired too. Its paths follow 5fd08069, so a difference against
89cb6289 there is expected. The paragraph on files-read-regular-only below
names the differences. See
DIVERGENCES.md → Retired entries.
Check both indexes. The shipped ledger (docs/IMPROVEMENTS.md)
numbers several more claustrum-only behaviors, and they are just as real. They are
item 16 (-metrics-addr) and item 18 (-token-fd). Item 21 is another: claustrum skips the
kill signal for a child that already exited. Check both the divergence catalog and
the shipped ledger before you conclude that something is drift. -wire-log is
claustrum-only too, and it is in neither index: its detail is in
PROTOCOL.md → Flags and config keys.
Drift, or an activated opt-in? — the parse-behaviour table¶
An opt-in key that is present is not a deadline that is in force. A mistyped or
inert value leaves the divergence off, and the parity behavior that results reads
exactly like drift. When a symptom matches a D-shaped divergence on a stock
claustrum, check for the key or flag first. Then make sure that the value parses to
a positive duration. Two knobs take a duration: D5 (git-timeout) and D12
(cli-download-timeout). D5 logs under [Server], and D12 logs under
[Install]. Both parse the same way. The deprecated cli-probe-timeout and
libc-probe-timeout set nothing, whatever their value. A flag value that does
not parse still exits 2, as in the table:
| Value shape | Configuration key in claustrum.conf |
-… flag on the argv |
|---|---|---|
Unparseable (= 15) |
Dropped silently, with no log. The run proceeds with no deadline | flag.Duration rejects it before any mode runs: invalid value "15" for flag …: parse error + usage, exit 2, no facts line |
Negative (= -1s) |
Dropped silently, with no log and no deadline | Normalized to 0 with a [Server]/[Install] stderr warning, then runs normally (exit 0, facts line printed) |
Any zero (0, +0, -0, 0s, -0m, -0.4ns) |
Accepted. It reads as opted-in but the deadline stays off | Accepted, with the deadline off |
The configuration path is silent, and that silence is the shape that looks like
drift. A missing __INSTALL_RESULT__ does not by itself mean a malformed flag.
-install prints no facts line while it waits for a CLI that does not answer.
That wait ends at 30 s on a cache hit and at 120 s after an install. Through a
usable managed launcher it ends at 33 s and at 123 s. The
facts line then carries "cliUnresponsive":true. That is parity. Discriminate on
exit. Exit 2 with parse error on stderr is the bad flag. Exit 2 with
cannot resolve home directory on stderr is a missing home folder.
The deprecated files-read-regular-only (retired D4) sets nothing, whatever
its value. It is a bool, not a duration. Its flag forms parse like this:
- Flag
=valueform unrecognized (-files-read-regular-only=maybe):flag.Boolrejects it with usage + exit 2. - Flag space form (
-files-read-regular-only maybe): a bool does not consume the next arg.maybebecomes a positional and parsing stops. That silently drops every later flag.
A -32602 files.read: not a regular file on a stock claustrum is not drift. It
is the rule of 5fd08069. Against 89cb6289 the same request differs: that
build reads the path, waits, or answers another error. An open <path>: … text
of files.read is not drift in these measured cases, where 89cb6289 has
another text:
- Linux and macOS,
stat <path>: …on89cb6289: a symlink loop, a/or a further component after a regular file, a name that is too long, a NUL byte, and a folder that the daemon cannot search. The battery row forfiles.readof~/a.txt/shows it. - Windows,
CreateFile <path>: …on89cb6289:CON,CONIN$,CONOUT$, a\after a regular file, and a name that is too long. A NUL byte getsStat <path>: …there. - Each system, where the open fails:
89cb6289answersfiles.read: path is a directoryfor a directory andfiles.read: file exceeds maxBytesfor a file over the limit.
An open <path>: resource temporarily unavailable of files.read on a regular
file that another process holds under a lease is not drift either. From the
code, it comes from the O_NONBLOCK open of claustrum. No VM row has that
case.
Triage gotchas — when a probe result is misleading¶
docs/DIVERGENCES.md holds the full measurements. These are the traps that matter for telling drift from expected:
- D5 (surviving-child git): a probe run that records "no
reply from both binaries" does not discriminate. The off state blocks too.
A surviving child makes the general D5
git sites soft (
CombinedOutputwaits on the output pipe, not on git's exit). The one exception isgit.worktree_create's read-tree checkout. It caps the post-exit drain at ~5 s and SIGKILLs the process group, which is parity with4534d86(scratch/probe/wt-success-lingering-4534d86.md). It gates success againsterrorCode:"timeout"+rollback on the callertimeoutMs. The off default (notimeoutMs, D5 off) is unbounded on every path, matching the reference. The one exception is the branch step, which both binaries bound since89cb6289(PROTOCOL.md → The branch step). - D5 has a wire-invisible arm. When the deadline kills
git ls-filesorgit check-ignore,git.worktree_createstill answers{"success":true}, and the seeded files are absent. That covers both passes: the.worktreeincludemanifest copy and the.claude/copy. Nothing on the wire says so, so a clean frame diff does not cover it. A D5 kill of anysourceBranchstep can change the start commit. The frame changes only when both lookups are killed. - The failure frames of
git.worktree_creatediffer between pins. Inf6010b97the git text can start with git's graft-file deprecationhint:lines. In the add-failure frame the 512-byte cap can then cut the rest of the text.90fca6e6prints no hint. claustrum setsGIT_GRAFT_FILEtoo, and its light calls turn the hint off with-c advice.graftFileDeprecated=false. Its heavy calls print the hint on stderr, and no reply holds it. A hint in a frame off6010b97and none in the frame of claustrum is therefore not drift. The texts of5fd08069have no hint either, and those of89cb6289have it (cell B2 of roundval-s3p2b1, Linux, macOS and Windows VMs). - In a repo with no
.claude/, atimeoutMsthat expires during the copy step splits the pins.f6010b97runs no copyls-filesand answers success.90fca6e6and claustrum answertimeout"after the checkout finished" and roll back.89cb6289answers like claustrum (the Linux timeout set of the whole-build check of issue 442). That difference is not drift. - In two cells of
git.worktree_createa folder stays on claustrum that89cb6289removes. In cell B6 it is an empty folder at the path of the new registration. In cell B6b it is the registration of a live sibling worktree that a wrapper moved to that path (Linux and macOS VMs). The frames are equal. A kept folder there is D24, not drift. - The branch step of
git.worktree_removeand of the create rollbacks splits the pins. claustrum follows89cb6289, the build thatscripts/UPSTREAM_SHAnames. Againstf6010b97,server.capabilitiesdiffers by thegit.worktree_remove.unpushedGuardandlauncher.managedfeatures and by thelauncher.resolvemethod. On remove, every kept case of the branch step differs. claustrum keeps the branch and adds"branchKept":true, wheref6010b97adds no member. In most of these casesf6010b97also deletes the branch. The kept classes, with their rows: - A commit that no other ref reaches: R02, R05 to R08, R10b, R17a, R19, R20, R24b
and B2-04a. Also R21, R21b, B2-08 and B2-12, where
89cb6289and claustrum keep a branch with the member, andf6010b97deletes both branches. - A lock file on the branch: R14. There
f6010b97keeps the branch too. - 10001 or more heads: R15a and R15c.
- A letter-case twin: R16 and R16b.
- No repository at
baseRepo: R18 and R18b. Neither side deletes anything there. - A
baseRepothat does not exist: X3. Neither side deletes anything there. - for-each-ref stopped at the 60 s bound: R22a and B2-02.
- rev-list stopped at that bound, or failed: B2-01 and B2-03.
- update-ref stopped: R23a and R23b.
In the create rollbacks, these rows differ from f6010b97: C02, C04, C05, C06, C06b,
C09, C10, B2-09a, B2-09b, B2-09c, B2-09f and B2-10. There f6010b97 deletes the
branch and adds no branch text. Those differences are not drift. Rows C07a, C07b,
B2-09e, X1 and X2 equal f6010b97. See PROTOCOL.md → The branch
step.
- The managed launcher splits the pins the same way, not drift. claustrum follows
89cb6289: launcher.resolve, the process.spawn launcher param, the child
env strip, and the gated launcher runs of -install and -probe-cli.
f6010b97 has none of these. See PROTOCOL.md → launcher.*.
- The peerCheck member splits the builds, not drift. scripts/UPSTREAM_SHA names
89cb6289, and for this member claustrum follows 5fd08069 in two places of the frame.
server.capabilities carries the peerCheck member and the
server.peer_check feature. The frames of 89cb6289 have no
such member and no such feature (Linux, macOS and Windows VMs). The daemon
removes CLAUDE_SSH_PEER_CHECK from its own environment after the read, so
its children do not inherit it. A process.spawn child of 5fd08069 has no
such entry for the values 1 and 0 (Linux, macOS and Windows VMs). Its git
calls have none for 0 (Linux VM). A child of 89cb6289 is not measured.
Against 5fd08069 these differences are not drift:
- With CLAUDE_SSH_PEER_CHECK=1 on Linux, 5fd08069 answers
"peerCheck":"on" with a peerCheckBy member (Linux VM). claustrum answers
"peerCheck":"unavailable", has no peer check and serves every caller. That
is D25.
- The log line of that start has claustrum's own text on Linux, and a level
tag on every system. See PROTOCOL.md → Daemon log.
- 5fd08069 lists one feature that claustrum does not list (Linux, macOS and
Windows VMs).
See PROTOCOL.md → server.*.
- files.extract_tar splits the builds too, not drift. scripts/UPSTREAM_SHA
names 89cb6289, and claustrum follows 5fd08069 in these places. Against
89cb6289 each of them differs in the measured rows:
- server.capabilities lists files.extract_tar.execBit directly after
process.stdin.offset (Linux, macOS and Windows VMs).
- On Linux and macOS a file whose archive mode has the execute bit of the
owner arrives with mode 0700, not 0600. The frame is equal.
- A name that leaves destDir and comes back answers unsafe path in
archive: <name>. 89cb6289 extracts ../dest/x.txt, and it answers a
create text for sub/../../dest.
- A file that the archive names two times has the mode of the last entry
(Linux and macOS VMs). On a volume that ignores letter case it has the name
of the last entry (Windows and macOS VMs).
- The texts after mkdir parent <entry>:, mkdir <entry>: and
create <entry>: name the place below destDir, not the whole path.
- A destDir that the daemon cannot search or open fails with
open destDir:. 89cb6289 fails later, or it extracts.
- The texts of the destDir steps name the last name of destDir, or the
parent folder: clean destDir: RemoveAll <name>: … (Linux, macOS and
Windows VMs), open parent: open <path>: … and mkdir destDir: mkdirat
<name>: … (Linux and macOS VMs). 89cb6289 has a clean destDir: or
mkdir destDir: text with the whole path in the rows where it fails.
- With a parent folder of mode 0300 and no destDir, the answer is the
open parent: text and no destDir is made. 89cb6289 extracts (Linux
and macOS VMs).
- A parent folder of mode 0400 or 0600 answers the open parent: text
too, where 89cb6289 answers clean destDir: openfdat <path>: … (Linux
and macOS VMs. The answer of claustrum is from a Linux VM).
- On Windows, with no \\?\ prefix, a destDir whose last name ends in a
dot or a space is wiped and filled. 89cb6289 keeps the old file, or it
fails (Windows VM).
- On Windows the last names NUL, a:b, CON, COM1, dest? and dest*
answer clean destDir: RemoveAll <name>: …. 89cb6289 answers another
text, or it makes a folder (Windows VM).
A folder above destDir that cannot be made answers mkdir parent: mkdir
<path>: … on 89cb6289 and on 5fd08069 (Linux, macOS and Windows VMs). That is
no difference against the pin.
Against both builds these answers are not drift. On Windows a destDir with the
\\?\ prefix and a dot or a space after its last name answers
clean destDir: "<name>" is not the entry that the path names, or the same
text after mkdir destDir:, and nothing is deleted. 89cb6289 extracts into
a folder with the exact name, and 5fd08069 into the folder with no dot or
space (Windows VM). A destDir whose last name is the home folder by
identity gets the home refusal. See D2.
- The git filters of the git.worktree_create checkout split the builds too,
not drift. scripts/UPSTREAM_SHA names 89cb6289, and claustrum follows
5fd08069. Against 89cb6289 these answers differ:
- A checkout that needs a filter which only the git config of the repository
defines answers errorCode:"repository_only_filter", and nothing is left.
89cb6289 runs the filter (Linux and Windows VMs).
- With a filter in the config of the repository and in a config outside it,
the outside filter runs. 89cb6289 runs the one of the repository
(Linux VM).
- A filter that only the repository defines, with a name of 300 bytes,
answers worktree_add_failed with the text about a name longer than 200
bytes (Linux and Windows VMs).
Against 5fd08069 these differences are not drift:
- With smudge from outside and a process key in the repository,
5fd08069 runs the outside filter and succeeds. claustrum answers its own
worktree_add_failed text about values that no option of git can replace
(Linux VM).
- With a smudge key that has no value, 5fd08069 answers the filter
refusal. claustrum answers worktree_add_failed with git's own text
(Linux VM).
- With a GIT_DIR and a GIT_WORK_TREE of another repository in the daemon
environment, 5fd08069 answers the filter refusal. claustrum answers
not_a_repo, as 89cb6289 does (Linux VM).
- A ~/.gitconfig that is a directory has another text on 5fd08069.
claustrum has the text of 89cb6289 (Linux VM).
- With a temporary directory that cannot be used, 5fd08069 answers the
filter refusal. claustrum fails as 89cb6289 does (Linux and Windows
VMs).
- A filter name that is not valid UTF-8 gets claustrum's own refusal.
5fd08069 answers a checkout failure on a Linux VM and success on a
Windows VM.
- With a git that refuses --show-scope, claustrum refuses the filter of a
conditional include of the user config. 5fd08069 runs it (Linux VM).
- With a program key of git-lfs in the config of the repository, the filter
lfs does not run on claustrum. A create with a file under it answers the
filter refusal for lfs, or claustrum's own worktree_add_failed text
that names the key. On 5fd08069 such a create fails inside git-lfs with
the text of git-lfs, and the agent does not run (cells L1 to L4, Windows
VM). The lfs.extension. keys of that rule ran on no VM.
- A wait on a listing before the checkout, with a timeoutMs that expires,
gives the timeout frame only on the build that makes that listing. The
two make other listings there (cells S2, Linux VM).
- In the call log, the -c pairs with a filter. key differ in some cells,
and the listings before the checkout differ.
macOS is not measured on any build.
- The refusals of a repository config split the builds too, not drift.
Against 89cb6289 a config that the daemon does not accept gets a text that
starts with this repository's git config cannot be used:. A refused file of
the git directory gets a text that starts with a file in this repository's
.git directory cannot be used:. 89cb6289 gives a normal answer, isRepo
false, the hooks refusal, another refusal of that build such as exit status
128, or no answer there. The call log has one more call
for each config file of a first read. Each state but the three Windows texts
ran on a Linux VM, and part of them on macOS and Windows VMs. Most cells ran
git.info and git.status.
With "shadowDirOff":true in the request, the read does not run. A
git.worktree_remove of a worktree folder that does not exist is refused
too. With a config of 20 MiB, 89cb6289 deletes the branch there (Linux,
macOS and Windows VMs). With worktreeRoot the refused remove has the text
cannot determine the repository's work tree: before the refusal (Linux
and macOS VMs).
Against 5fd08069 these differences are not drift. On Windows a long config
path with a config of 20 MiB got another answer on a build before the settings
copy (Windows VM). On a config that changes many
times each second, 5fd08069 makes more config reads than claustrum (Linux
VM). For a config with a very long key, 5fd08069 ends config read calls
early, and claustrum lets them end (Windows VM). A config whose listing by git
passes 64 MiB can get no refusal on claustrum (from the code, not measured on
5fd08069). On a FIFO config, two
shadowDirOff requests get no answer. 5fd08069 then gives no answer to two
later requests, and claustrum answers them (Linux VM).
The states, the bounds and the cells are in
PROTOCOL.md.
- The option lists of the git calls split the builds too, not drift.
claustrum differs from 89cb6289 in four states: git.status with replace
objects, the hint: lines in a failure text of git.worktree_create, a long
tree path on Windows, and the paths of a new worktree with
worktree.useRelativePaths. The call logs differ in the -c lists and in two
variables. The cells and the systems are in
Hardened git calls.
Against 5fd08069 the call logs differ in git.worktree_create. claustrum
makes one listing with --show-scope. 5fd08069 makes a pair after
rev-parse --absolute-git-dir, more rounds with worktreeRoot, and other
listings with existingBranch. Those differences are not drift. See "Not equal
to 5fd08069" in PROTOCOL.md.
- The worktreeRoot location rules follow 5fd08069 (rounds tier0-20 to
tier0-22), not drift. 89cb6289 is not measured in these cells.
- A remove behind a linked <git dir>/worktrees follows 5fd08069, not drift:
cell C3 of rounds tier0-24 and tier0-25m (Linux and macOS VMs) and L1 of
round tier0-26 (Linux VM). 89cb6289 is not measured.
- The settings copy of the git methods splits the builds too, not drift.
Against 89cb6289 a triager can meet these points. Each is in
The settings copy:
- git.info has a last member settingsControl.
- The run folder has a folder git-shadow.
- A first git request makes 12 more git calls.
- These texts are new. They start with
Claude could not test git on the remote machine,
git could not check this repository on the remote machine,
git and Claude disagree about which repository holds this folder and
Claude could not prepare its own files on the remote machine. A create
also answers cannot list the repository's worktree entries,
cannot prepare the repository's worktrees directory,
git worktree add failed (completing the new worktree's git files) and
".git" is a link or a file where Claude needs a real directory.
- On Windows each git method refuses a junction or a directory symbolic link
at .git with the link text, without "shadowDirOff":true.
- A config value that git refuses gets a normal answer where the copy leaves
it out.
- On Windows a create removes old .claude-new-worktree-* entries of the git
directory. See git.worktree_create.
Against 5fd08069 these differences are not drift:
- The git-lfs cells of a create: other reply texts, other read-tree items and
no git-lfs log file.
- The config reads on a config that changes many times each second, and the
config reads that 5fd08069 ends early.
- claustrum leaves the git-shadow folder of a daemon that ended with
SIGKILL. 5fd08069 removes it at the first request of the next daemon.
- On Windows, two reply texts of a create whose .git becomes a file
gitdir: <X> while the create runs (Windows VM).
- The reap of a child record with a program key splits the pins too, off the
wire. claustrum follows 89cb6289. On Linux, if the process runs program or
holds it as one whole argument, 89cb6289 accepts the record. Linux rows PG02a, PG02b,
ED03a, ED03b, ED04a and ED04e show that. f6010b97 sends no signal in rows ED03a
to ED04e. That difference is not drift. See
PROTOCOL.md → process.spawn.
- The configuration listing, the stray commondir rules and the git.info root
split the pins too. claustrum follows 89cb6289 there. Against f6010b97 these
differences are not drift:
- server.capabilities carries git.info.discovered_root.
- A stray commondir that reads . or ./ is served when its git directory is
valid (rows T01, T02 and T11). With a bad HEAD it gets S3 (row T10a). A
dangling relative symlink that stays in a valid git directory counts as none
(row T07c). With worktreeRoot, git.worktree_remove then answers cannot list
the repository's worktrees: exit status 128 (row DG2s-g). Without
worktreeRoot it answers {"success":true,"branchKept":true} and keeps the
branch. It deletes the entry. If the worktree exists, it deletes that too (rows
DG3-g and DG3x-g). f6010b97 refuses there and keeps everything. With a bad HEAD the
symlink gets S3 too (row DG2-g). Any other content or file gets S1 or S2. In all
these rows but DG3-g, f6010b97 gives one older text: %q exists where git
itself never writes one (git keeps that file only in a linked worktree's entry
under .git/worktrees/); remove it if you did not create it, and treat its
appearance as tampering. In row DG3-g it answers the lock-check text with its
registrations could not be examined.
- A listing error is in English under a German daemon locale (row L03).
- A configuration key of 1048576 bytes passes, and one byte more gets the key
text. f6010b97 refuses both with bufio.Scanner: token too long (rows L08b
and L08c).
- A listing that exits 128 with fatal: not a git repository answers the "no
repository" shapes, and git.worktree_remove with worktreeRoot gives git
finds no repository here: …. f6010b97 gives the hooks refusal (rows L14a,
L14b, L14d, L14e and N01 to N04).
- When git version also fails, the text says git cannot run. f6010b97 gives
the hooks refusal, or the "no repository" shape for a git that cannot start
(rows L11, L12 and L15).
- The git.info root comes from the walk, so a daemon GIT_DIR with a plain
folder answers the non-repo body (rows I04a and I04b). The slug after lnk/..
is the one of the resolved repository (row D01). On Windows the daemon does not
take a toplevel answer that names another folder or is relative (rows W14 and
W16). A symlink there resolves before .. (row D03).
- claustrum keeps D16 as it is. git.status now answers by the worktree
entries of baseRepo, as 89cb6289 answers, so the .git inside path plays no
part (PROTOCOL.md → git.status, rule 6).
See PROTOCOL.md → Git-directory trust check, Hardened git calls
and git.info.
- D12 needs a VALID zstd body. D13's ordering answers an invalid one at 0 s,
which reads like "no divergence". Also, a zero download timeout frees the body
read only: the transport still applies net.Dialer{Timeout: 30s},
TLSHandshakeTimeout: 10s and the 60 s response-header limit. That last limit
is parity.
- D13 has one honest shape: a short or truncated artifact reaches the checksum
(claustrum checksum mismatch: …). A connection reset in the body is not a D13
case. It answers
download interrupted after <got>/<total> bytes: <err> on both binaries
(measured on Linux against f6010b97 and 89cb6289). A different text there
is drift. A body that ends before its length is not measured, so do not call a
difference there drift.
- D13 also shows with equal texts. For a download that is not a zstd archive,
with the right checksum, both binaries answer decompressing: …. The reference
reported fetch.bytes 4 and one progress line in the measured row. claustrum reports the full body
size (4096 in the measured row) and two progress lines. That delta is D13, not
drift (Linux VM, f6010b97 and 89cb6289).
- The ldd libc probe bounds ldd itself at 5 s on both binaries (parity since
19f30c46). There is no libc probe off linux at all.
- claustrum made seven changes for 90fca6e6 while the JSON-RPC surface stood still.
A triager who meets one of them and finds the drift check quiet is looking at
parity, not drift. No method, field or error string moved. Three of the seven
still change what a client reads. A reattach now closes the connection it
replaces. A reaped process reads as not running for process.stdin and
process.reattach inside the exit drain. A new worktree no longer inherits the
nine Claude runtime-state names under .claude/. That last one is the likeliest
to be misread. 19f30c46 copies eight of the nine, so a file missing from a
seeded worktree looks like a regression against the previous pin.
REFERENCE-BUILDS.md holds all seven, and all seven are
reconciled. One carries an exception. On the host cleaner's macOS busy
probe, claustrum reads an lsof run it gave up on as busy. The reference side is
not probe-measured. That is DIVERGENCES.md D17, not drift.
D17 is the abandoned run only, not a run that the deadline ends. For both reads, an lsof
command that does not start is parity with 89cb6289 (macOS VM, 3 of 3 runs).
So is a run that writes to stderr and exits 1.
- The 90fca6e6 reconciliation shipped four PRs. A full re-check then found a
seventh change that the first pass had missed. A new value inside an existing
function shows in no symbol list, so a quiet symbol comparison does not prove
that nothing changed. Re-check the whole build after the slices merge.
- A reference daemon that stays alive after its socket path is gone is not drift.
On a Linux VM, the host cleaner of
f6010b97and89cb6289sent no signal to such a daemon. It sent none to its children (rows ST01, ST02, ST04 to ST10, HC16 to HC19). The daemon ended by itself after 11 to 12 minutes (rows ST09 and ST11). The cleaner of claustrum sends none either. On a macOS VM the two references sent none either (rows ST01, ST09, ST10, HC16, HC18). - On Windows a serving process that outlives its SSH session is not drift, and
children that outlive a killed daemon are not drift. A Windows VM measured both
against
f6010b97and89cb6289(rows WJ01 to WJ07 and WN03, and row WJ08 on89cb6289). A later run on that VM measured the same on claustrum (rows WJ01 to WJ08, WN03). A spawned child is in no job on either side, and a kill ends the direct child only (kill forms:89cb6289). Descendants that outlive a kill or a-stopare not drift. - A daemon that accepts no connection for some seconds after its start is not
drift. On Linux and macOS the daemon binds its socket after the reap of its
start. With a recorded child that ignored
SIGTERM, the first connection to89cb6289worked 2.2 to 2.4 s after its start (Linux and macOS VMs). Before that a client gotECONNREFUSED(row D1a) orENOENT(row D1b). In that timedaemon.lockholdspid,roleandnodeonly. claustrum is built to the same order. - Child records and both markers on a socket that is not
run/<id>/rpc.sockare not drift.89cb6289writes them on every socket shape (Linux and macOS rows F1 to F4), and claustrum does too. - Records that stay after a daemon start are not drift in two cases. One start
handles 128 record names (Linux row C1) and ends 64 groups (Linux row C4), on
89cb6289. claustrum is built to it. The rest goes at a later start. - A first process that lives on after a second
process.spawntook itsidis not drift.89cb6289sends it no signal, and a stop of the daemon leaves it alive. Linux and macOS rows A1 to A4 and Windows rows EV and EVInh measure that. The frames of that first process still come on its own connection, under the sameprocessId(Linux row P2 against89cb6289). claustrum is built to it. - A
process.spawnreply that comes 3 to 5 s late is not drift when the spawn supersedes a session process.89cb6289answers after the old process ended (Linux and macOS rows B1 to B3, Windows rows SS and SSInh). claustrum does the same. - An old session process that lives on after a failed session spawn is not drift
in one case: the command of the new spawn does not exist.
89cb6289sends the old process no signal there (Linux rows P3, H1 and H9, macOS row P3, Windows rows SSm-path, SSm-bare and SSm-noext). claustrum is built to it. A command that exists and does not start ends the old process first, on89cb6289and on claustrum (Linux rows H4a to H4c). - A descendant that lives on after
process.killAndWaitor a session supersede is not drift in one case: the process ended on the graceful signal within the grace.89cb6289then sends its group noSIGKILL(Linux rows X2a and X2b, also B1 and H4a to H4c). claustrum is built to it. When the grace runs out, both send the groupSIGKILL(Linux rows B2, B3 and X1). macOS rows X2a to X2c give the same. Windows has no X2 row. - A daemon that runs no login shell before its first
process.spawnis not drift (Linux and macOS rows G1 and G2 against89cb6289). - A first spawn that answers after 4.6 s with a timed-out PATH line is not drift
when a process of the login profile holds the output pipe.
89cb6289does that (Linux rows P13 and P13k), and claustrum is built to it. - A
-servelauncher that gives up after 12 s is not drift. If the daemon child of89cb6289ends before the bind, its launcher exits 1 after 12.06 s (Linux row P7, macOS row P7 and two Windows rows). - A daemon that gets
SIGTERMorSIGINTduring its start, goes on to its bind and then exits 0 is not drift.89cb6289does that (Linux rows P5, H8a to H8c, N4 and N5, macOS row P5), and claustrum is built to it. Its launcher then exits 1 after 12 s (rows N4 and N5). - A daemon that gets
SIGKILLfrom a second daemon during its start is not drift.89cb6289does that when the two starts are 0.05 s apart (Linux row N3). The second daemon waits on the lock, and claustrum is built to it (row H7). - The order of two or more new caller keys in a child environment is not drift.
89cb6289gave three rotations of the request order in 20 spawns (Linux rows P9 and N7). claustrum iterates the decoded map and builds no order of its own. On Windows see D23. - A record or a marker with two blanks in its macOS start text is not accepted.
An older claustrum build wrote that form.
89cb6289and claustrum drop such a record and send no signal (macOS rows TBr and TBe). A child of such an older build is therefore not ended at the next start. - A child of a live daemon that a new daemon ends on macOS is not drift in one
case: the
daemonStartof its record holds two blanks.89cb6289and claustrum then read that daemon as gone (macOS row N6). - These differences on Linux and macOS are known and open. They are not drift.
- At the reap of a start,
89cb6289probes a group right after itsSIGTERM. If the group still answers, it sendsSIGKILL(rows K5, P8k, P8b and C1). claustrum probes after 50 ms. That is DIVERGENCES.md D20. - At a
-stopwith a live child,89cb6289sent the groupSIGKILLand then removeddaemon.token. claustrum removed the token first (row P1). - claustrum logs an exit line for a child that a shutdown kills:
Process <id> exited with code -1, terminated by SIGKILL, signalled at shutdown request.89cb6289logs none (Linux row E3, macOS row A3).5fd08069wrote it on Linux, not on macOS (roundtask003-01). - After a
-stopduring the wait of a supersede, claustrum can log the exit line of the old process and thesupersedesline.89cb6289logged neither (row P1: Linux 1 of 6 runs against 0 of 7, macOS 2 of 2 against 0 of 2). 89cb6289calls unlink on one child record up to three times at a stop or a kill. claustrum calls it once.89cb6289also calls unlink on a record that it never wrote (Linux rows P1, B1, A3, P11a). The files left are the same.- On macOS
89cb6289runs onepsat the daemon start and one per spawn. claustrum runs none at the start and three per spawn (macOS rows G1 and G2). - With a zero-byte
-token-filethe daemon log of89cb6289holdsclaude-ssh: empty token in --token-file. claustrum logsclaustrum: token is empty(row P7 on Linux and macOS, and two Windows rows). - When a frame cannot reach a closed connection,
89cb6289logs[frameSink] write failed, detaching: write unix <socket>->@: use of closed network connection. claustrum logs[frameSink] write failed, detaching: use of closed network connection(Linux row N2c).
- At the reap of a start,
- One difference on Windows is known and open. It is not drift.
- At a stop with one connection,
89cb6289loggedclosed 0 connection(s)in 2 of 9 runs. claustrum loggedclosed 1in 9 of 9. The rate is not measured.
- At a stop with one connection,
- A child that
process.spawnstarts as a shutdown begins is known, not drift.5fd08069left some running (roundtask003-01: Linux 64, macOS 1081, Windows 2). claustrum ends them (roundtask003-04: 0 left). - A child environment block in name order on Windows is not drift.
f6010b97and89cb6289keep the order of the daemon, thenCLAUDE_SSH_DAEMON_CHILD=1(rows WN01a, WN01b). On89cb6289an addedPATHentry and new caller keys come after it (Windows VM rows V2, V5, V9 and V10). The stdout frame ofcmd /c settherefore differs in bytes. That is DIVERGENCES.md D23. - A longer
remote-server.logafter a second daemon started on a live socket on Windows is not drift.89cb6289truncates the file at that start, and claustrum appends to it (rows WN04, WJ04). That is DIVERGENCES.md D21. - The log lines of claustrum keep its level tag and the name
Claustrumin the listening line. Both differ from the reference by design.
Toolchain-induced drift — the Go 1.27 jsonv2 hold¶
Reference drift is not the only way the wire can move. claustrum inherits some of
its frame bytes straight from Go's encoding/json (see
docs/ARCHITECTURE.md → "Inherited wire bytes" and
inherited_encoding_test.go). A change in the Go toolchain claustrum is
built with can therefore move those bytes. The reference does not have to change
at all.
Go 1.27 does exactly that. It enables the jsonv2 GOEXPERIMENT by default
(internal/buildcfg/exp.go sets JSONv2: true), which reimplements
encoding/json's Marshal on the v2 engine. That engine renders an invalid
UTF-8 byte as the literal U+FFFD character (bytes EF BF BD). Go 1.26
and earlier emit the six-ASCII
\ufffd escape, and so did the reference daemon at 5db5e4a. The build pinned
today, 89cb6289, carries a go1.25.14 stamp (go version on the binary), so it
emits the same escape. files.read
puts raw file bytes into the content string. Under Go 1.27, every read of a file
holding a non-UTF-8 byte therefore diverges from the
reference. TestInheritedInvalidUTF8BecomesReplacementChar and
TestInheritedLoneSurrogateBecomesThreeReplacements fail under 1.27 for this
reason. That is the guard working as intended, not a stale expectation.
Current stance: hold the build toolchain at 1.26.x. The hold lives in the
Renovate preset (schubydoo/renovate-config → claustrum.json,
allowedVersions: <1.27 on the go dep). 1.26.x patch and security bumps
still flow, while 1.27+ is blocked. That preset constrains go.mod. The hold
therefore follows a build path automatically only where that path resolves its
toolchain from go.mod. A setup-go step naming its own version sits outside it.
An explicit go-version: 1.26.7 still honors the hold but stops tracking it, and
go-version: stable leaves it altogether. Nothing in this repo detects that.
No lint, no guard and no test reads setup-go inputs. The first signal is
therefore a failing run, at whatever cadence that workflow runs. mutation.yml used
go-version: stable, and it is a weekly cron. Go 1.27.0 went stable, and the
break surfaced up to a week later, on the 2026-08-24 scheduled run. It surfaced
again on 2026-08-31 (run 33387062009) before it was diagnosed. All eight setup-go
steps now use go-version-file: go.mod.
GOEXPERIMENT=nojsonv2 restores the \ufffd
escape under a 1.27 toolchain (measured). It is therefore the compensating knob for
a future constraint that forces the build onto 1.27 before the trigger below fires.
Revisit trigger: if the reference daemon itself rebuilds on Go 1.27, it flips to the literal U+FFFD too. At that point claustrum must follow, not resist. Drop the toolchain hold, and flip the two guard-test expectations to the literal form. Until a drift check (Steps 2–3) shows that the reference moved, holding at 1.26.x is the parity-preserving position. This is a temporary hold, not a divergence, and it carries no D-number.
The sort of the Windows environment block¶
Since Go 1.26, createEnvBlock in syscall sorts the environment block of a
child by name on Windows (envSorted, src/syscall/exec_windows.go). That sort
is the cause of divergence D23. If a Go release changes or
removes the sort, the order of the block changes with it. Read the D23 entry
again after each Go bump.
The symlink-loop text of filepath.EvalSymlinks¶
Both worktree methods refuse a symlink loop in a worktreeRoot
(locationLinkRefusal). A kernel ELOOP catches a long chain. A root that is itself a link in a
loop, or starts a chain of more than 255 links, rests on the text "EvalSymlinks: too many links" of filepath.EvalSymlinks
(isSymlinkLoop). If a Go release rewords it, that
frame changes. The S1, S1b, S1c and S1d cases in
worktree_create_chain_unix_test.go then fail. Check them after each Go bump.
Windows junctions in filepath.EvalSymlinks¶
Two refusals rest on a failed filepath.EvalSymlinks. One is the trust-root test
of the worktree methods (baseRepoWalkFails, worktreecontain.go). The other is
unresolvable (methods_git.go), which git.status applies to baseRepo and
git.list_branches to path. On Windows both depend on the winsymlink GODEBUG
setting, which Go 1.23 turned on. With it on, os.Lstat reports a junction as
neither a symbolic link nor a directory. With it off, a junction is a symbolic link.
With it on, the walk of EvalSymlinks fails at a junction before the last
component. claustrum answers Windows VM rows J1 and J3 with the trust-root refusal
for that reason. With GODEBUG=winsymlink=0 in the daemon environment, the walk
follows the junction instead. claustrum's answers to rows J1 and J3 then flip, and
so do the git.status and git.list_branches answers for <dir>\<junction>\T.
Other code resolves baseRepo through EvalSymlinks too, so other Windows junction
rows can move as well. A go line below 1.23 in go.mod has the same effect. The
git.info root for a path with a junction before its last component rests on the
same failure (rows D03-junction and W09). With the walk following the junction, those
roots can move too. The GIT_COMMON_DIR pin of the trust check for such a path
rests on it as well (walkStart, gitdirtrust.go). With the walk following the
junction, the pin names the repository behind the junction. The git.info frames
of Windows VM cells B-01, B-02, B-07 to B-09, B-15, B-16, B-17k and B-17d then
change. This
entry is derived from the Go source (os/types_windows.go and
path/filepath/symlink.go), not measured. Check those rows after each Go bump that
changes winsymlink.
The git version under the checkout tests¶
With a worktreeRoot, both worktree methods run git worktree list --porcelain -z
(worktreeListCall, worktreeexternal.go). The -z option needs git 2.36 or
later. On an older git that call fails. Both methods then run git worktree list --porcelain (worktreeListTwice). The references on such
a git are not measured. Several tests in
worktree_root_checkout_unix_test.go expect a working call. They skip there
(requireWorktreeListZ), so a skip in CI means a git older than 2.36.
Check them after the git of a CI runner or a test VM changes.
Automating it¶
.github/workflows/upstream-desktop-watch.ymlruns twice daily (cron17 6,18 * * *). It runs one leg for each of Linux, Windows and macOS. Each leg callsscripts/latest-desktop-sha.py --platform <p>to find the SHA that the newest Claude Desktop for that platform pins. That is Step 1, automated, and it needs no out-of-band source. A new pin must meet two tests. It differs fromscripts/UPSTREAM_SHA, and no build heading inREFERENCE-BUILDS.md(a "###<full sha>" line) matches it. A SHA that only the prose mentions does not count. A lagging platform that still pins an older, reconciled build therefore stays quiet. A new pin on any platform makes that leg runcheck-upstream.sh <sha>for the static drift diff and open a single idempotent tracking issue. If that issue is still open, a later leg that finds the same SHA adds a comment to it. A leg skips all downloads for a Desktop version that it already analyzed. Reconciliation (Step 4) stays a human decision.- The platforms ship on separate schedules, so one platform can pin a newer
reference build than the others. On 2026-09-24, Linux Desktop 2.7032.0 pinned
90fca6e6…, and Windows and macOS Desktop 2.9939.2 pinnedf6010b97…. A Linux-only watcher did not see that new build. For Linux, the script reads the APTPackagesindex (.deb). For Windows, it reads the SquirrelRELEASESfile for win32/x64 (-full.nupkg). For macOS, it readsRELEASES.jsonfor darwin/universal (.zip). The script checks the.debby SHA-256 and the.nupkgby SHA-1 and size. The macOS feed publishes no checksum, so TLS is the only integrity check on that download. The script therefore refuses a URL or a redirect that is not HTTPS, for every feed and package. - You can still run
check-upstream.shby hand against any SHA. Examples are a SHA you just found, or a check that a re-published build did not shift.