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.
Named-pipe transport (Windows, opt-in)¶
A strictly additive claustrum extension (CT-5) — the reference daemon has no such
transport. It is off by default, and claustrum is byte-for-byte identical to the
reference when it is off. Set -serve -listen-pipe (or listen-pipe = true in
claustrum.conf), and claustrum 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
(notably Python asyncio, whose Unix transports are Unix-loop-only).
- Windows-only. Other platforms ignore it and log a warning.
- Name + discovery. claustrum chooses the name
(
\\.\pipe\claustrum-<random-instance-id>). It publishes that name torpc.pipein the socket's directory (besiderpc.sock/daemon.token). claustrum writes the file atomically before the pipe accepts and before the ready banner, and removes it on graceful shutdown. 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, so a client can never dial a stale name. - Owner-only + local, by two independent mechanisms: an owner-only DACL (SDDL
D:P(A;;GA;;;<current-user-SID>), the named-pipe analogue of the socket's0600) and 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>" — except server.shutdown,
which is not authenticated at all. A shutdown frame stops the daemon whether its
auth member is absent, empty, wrong, or valid, and -stop sends no auth
member. This matches the reference and is load-bearing: the Desktop client 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 with 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, and also logs
[Server] Unauthorized request: method=…, id=….
The server's expected token comes from -token-file (read once at startup, then
unlinked) or -token-fd (read from an open descriptor, forwarded to the
detached child over a pipe — this handoff never touches disk).
No claustrum mode reads CLAUDE_RPC_TOKEN — not -serve, not -bridge, not
-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 (mode 0600, written atomically via a daemon.token-* temp
file + rename), and unlinks it on graceful shutdown. A client can therefore
reconnect to an already-running daemon and re-authenticate after the original
-token-file was unlinked / 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/crash) leaves the file behind, because the daemon removes
it only on the graceful server.shutdown / SIGTERM path.
The fixed name + socket-dir location are the reconnect contract, so they are not
configurable. Two parity caveats match the reference, and claustrum deliberately
does not "fix" them: two daemons that share one directory collide on the file, and
on Windows 0600 is not an owner-only DACL (a Go os.CreateTemp limitation — the
per-user session dir is the confinement).
Daemon startup (-serve)¶
The -serve launcher creates the socket's parent directory if it is missing
(mode 0700). The launcher then does not return until the socket path exists.
It polls every 20 ms, up to a bound of 10 seconds. To confirm readiness it
dials the socket and closes the connection again. A freshly started daemon's log
therefore opens with a New connection from: @ / Connection closed: @ pair from
the launcher's own probe.
It waits for the path to exist, not for a successful dial. 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, confirming 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 ~10.04 s (reference 10.06 s) |
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 condensed out of this reference.)
Daemon log (remote-server.log)¶
The launcher creates remote-server.log in the socket's directory (mode
0600, a fresh file on every start — the launcher unlinks and recreates any
existing log, and does not truncate it in place). The launcher redirects the
daemonized child's stdout and stderr into that file, so the launcher's own streams
stay empty. The first line is the ready banner (no timestamp):
Claustrum remote server listening on /run/user/1000/claude/rpc.sock
2026/07/31 00:17:30 INFO [Server] New connection from: @
If claustrum cannot replace the existing log (a sticky directory that holds another
user's file), it declines the log entirely. The daemon's output then falls back
to inherited stdio. claustrum does not write into a file another user can read.
This is intentional divergence D8 (always-on): the reference truncates a
root-owned, world-writable log and writes into it, while claustrum leaves that file
untouched. 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.
Unlike the socket and daemon.token, claustrum does not remove the log on
graceful shutdown. The log outlives the daemon, so a post-mortem stays readable.
The fixed name and location are the deployment contract, not configurable.
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>}
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), and
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 — field order below is the wire contract.
Error codes¶
| code | meaning |
|---|---|
-32700 |
parse error — malformed JSON line (response id is null) |
-32600 |
Invalid JSON-RPC version — jsonrpc absent or != "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 (e.g. open <path>: no such file or directory); also a recovered handler panic → recovered panic: <v> |
-32003 |
stdin offset gap: offset ahead of applied bytes — process.stdin with an offset past the applied high-water (added in 7c2f88d) |
-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.
| 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 / files.read | stat <path>: <reason> |
-32603 (any stat 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 |
D4 opt-in only |
| files.list | open …: no such file or directory |
-32603 (missing dir) |
| 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 | gzip: … |
in error field (bad gzip) |
| 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: … / mkdir destDir: … / write .synced: … |
in error field |
| files.extract_tar | create <entry>: open <target>: is a directory |
in error field |
| files.extract_tar | mkdir parent <entry>: <os error> |
in error field (prefix is contract) |
| git.status / git.list_branches | <go error> e.g. exit status 128 |
-32603 (git failed, stdout parse) |
| git.status / git.list_branches | signal: killed |
-32603, D5 opt-in only |
| git.worktree_create | branchName is required |
|
| git.worktree_create | not a git repository |
in error, errorCode:"not_a_repo" |
| git.worktree_create | git worktree add failed: <combined output> |
in error, errorCode:"worktree_add_failed" |
| git.worktree_remove | failed to remove worktree: <git output>; manual cleanup also failed: <err> |
in error (only if manual cleanup also fails) |
| git.worktree_remove | worktreePath must not be or contain the home directory: … |
D2, in error |
| git.worktree_remove | git worktree remove timed out after <dur>; no cleanup was attempted, and git may have partially removed the worktree |
D5 opt-in, in error |
| process.spawn | Process ID is required / Command is required |
|
| process.stdin | Invalid base64 data / Process not found / Process not running |
(checked in that order after decode) |
| process.stdin | stdin offset gap: offset ahead of applied bytes |
-32003 |
| process.killAndWait / process.reattach | Process ID is required / Invalid params |
-install reports failures inside the __INSTALL_RESULT__ facts line as
cliError strings, not via exit code — 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>.
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 checks a request in the order parse → auth → version → method → params:
- The daemon validates auth before the
jsonrpcversion. A request that fails both (noauthand a missing/wrongjsonrpc) reports-32001 Unauthorized, not the version error. server.shutdownis the exception. The daemon skips auth for it entirely, so a shutdown frame that is missing bothauthandjsonrpcgets-32600 Invalid JSON-RPC version(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
params→-32602 Invalid params. The daemon checks this after method existence, so an unknown method is-32601regardless. - The daemon accepts an empty
{}and then runs the method's own validation. - Mistyped
params— a wrong field type ("maxBytes":"4","path":123) or a non-object value ("params":"x"/[…]) — is-32602 Invalid params. The daemon does not coerce the value, and it does not ignore the decode error. - The daemon ignores unknown extra fields, with one divergence in how strictly
(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 →-32602(e.g.files.stat {"maxBytes":"{"},git.status {"baseRepo":[1,2]}). The reference binds only the field the specific method reads and ignores the rest, so it runs with defaults. Both daemons ignore a genuinely unknown key (a key in neither struct). 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: exists:false, or a chdir/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 while~/under the sameHOMEdoes not. - The cleaning is lexical, and tilde-form only. With
~/link -> b/c, the reference reads<home>/x.txtfor~/link/../x.txtwhile 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 from
USERPROFILE, not HOME):
| sent | reference replies |
|---|---|
~\a7 |
~\a7 — not expanded; ~\ is not a tilde form |
~/a1 |
<home>\a1 — / rewritten to \ |
~/a4\x\..\w |
<home>\a4\w — \ 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, so when the daemon removes the separator, a
-32603 error frame becomes a success frame. Pinned by ids 15, 16, 18 and 19 of
testdata/socket_tilde_expansion.golden.json (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 one that could not be examined:
- A genuine
ENOENTis the "does not exist" answer in each method's own shape —exists:false,content:"" exists:false, andvalid:falsewitherror:"Path does not exist"respectively. - The daemon reports any other stat failure with the underlying message.
files.statandfiles.readreturn-32603 stat <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).
Methods (19)¶
server.capabilities self-describes the set. Order as returned:
server.ping server.version 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
process.spawn process.stdin process.kill process.killAndWait process.reattach
Reference 7c2f88d added process.killAndWait between process.kill and
process.reattach. That brought the set to 19.
server.*¶
| method | params | result |
|---|---|---|
server.ping |
— | {"pong":true} |
server.version |
— | {"version":"<id>","platform":"<goos>","arch":"<goarch>"} |
server.capabilities |
— | {"version":"<id>","methods":[…19…],"features":["process.stdin.offset"]} |
server.shutdown |
— | no response — the daemon stops and the connection closes |
featuresarray (added7c2f88d) followsmethodsand advertises optional extensions. Its sole entry isprocess.stdin.offset(the resumable/idempotent stdin contract), and it is always present.server.shutdownis not authenticated — see Authentication.
files.* (param: path)¶
files.stat¶
{path} → {"exists","isDir","size","mode":"-rw-r--r--"}
- Missing path → {exists:false,isDir:false,size:0,mode:""}.
files.list¶
{path} → {"entries":[{"name","path","isDir"},…]} (name-sorted)
- The daemon omits hidden entries. It skips any name that begins with .
(.git, .env), which matches the reference.
- The daemon resolves isDir with Stat, so it FOLLOWS symlinks: a symlink to
a directory is isDir:true, and a dangling symlink is isDir:false.
- Missing dir → -32603 open …: no such file or directory.
files.read¶
{path[,maxBytes]} → {"content":"<raw text>","exists":true}
- content is raw text, not base64.
- Missing file → {content:"",exists:false} (not an error).
- A directory → -32602 files.read: path is a directory.
- Size > maxBytes → -32602 files.read: file exceeds maxBytes.
- maxBytes absent, 0, or negative → the cap is 262144 (256 KiB), not
"unlimited". A file of 262144 bytes reads, and a file of 262145 bytes errors.
The daemon honors a positive maxBytes verbatim, above or below the default.
The cap uses the stat size. On linux that size is 0 for every non-regular
kind, so the cap never bounds a FIFO, socket or device on either binary.
- Non-regular files: opt-in guard D4. Off by default (parity): the reference
reads /dev/null as {"content":"","exists":true} and blocks on a writerless
FIFO, and it refuses neither. Set -files-read-regular-only (or the
files-read-regular-only config key), and every non-regular path answers
-32602 files.read: not a regular file — a frame the reference never produces.
The predicate is Mode().IsRegular(). It is whole and not narrowable, because
/dev/null and /dev/zero are indistinguishable by mode. The full measurement
and rationale are in DIVERGENCES.md → D4.
| path | reference = claustrum at the default | with -files-read-regular-only |
|---|---|---|
| CONTROL a regular file | {"content":"…","exists":true} |
(unchanged — guard does not apply) |
CONTROL a regular file over maxBytes |
-32602 files.read: file exceeds maxBytes |
(unchanged) |
| a FIFO, writer paired | {"content":"<bytes written>","exists":true} |
-32602 files.read: not a regular file |
| a FIFO, no writer | no frame until a writer opens | -32602 files.read: not a regular file |
/dev/null |
{"content":"","exists":true} |
-32602 files.read: not a regular file |
a bound AF_UNIX socket |
-32603 open <p>: no such device or address (linux; darwin/amd64 says operation not supported on socket — a per-OS stdlib difference, identical between binaries on each OS) |
-32602 files.read: not a regular file |
an unreadable character device (/dev/console) |
-32603 open <p>: permission denied |
-32602 files.read: not a regular file |
an unreadable block device (/dev/nvme0n1) |
-32603 open <p>: permission denied |
-32602 files.read: not a regular file |
The two device rows assume a non-root daemon, because they are permission
failures. The opted-in column is measured for the FIFO and /dev/null rows.
For the socket row and the two device rows it is entailed by a false
Mode().IsRegular(), and was not run separately.
The default gives up two things. A writerless FIFO parks a request goroutine and
a descriptor until a writer arrives. An unbounded device read (/dev/zero) grows
the daemon until the kernel OOM-kills it. Both are the reference's own behaviour,
and both are measured (forensics condensed out of the committed docs).
files.validate¶
{path} → {"valid":bool,"isDir":bool[,"error"]}
- Missing path → {valid:false,isDir:false,error:"Path does not exist"}.
files.extract_tar¶
{archivePath,destDir} → extracts a gzip tar → {"success":true,"fileCount":<n>}
Side effects — deliberate, not visible in the frame:
1. The daemon wipes destDir (os.RemoveAll) and then recreates it before it
unpacks. Extraction is idempotent and destructive.
2. Entries get owner-only fixed modes: files 0600, dirs 0700. An executable
0755 entry still lands 0600.
3. On success the daemon writes an empty .synced marker at the destDir
root. It does not count that marker in fileCount.
4. The daemon consumes archivePath. Once it opens the archive, it removes the
file on every outcome (success, bad gzip, or unsafe path).
Errors. Unless a line says otherwise, each error goes in the error field with
fileCount:0, which has no omitempty:
- Missing params → -32602 archivePath and destDir are required.
- Non-absolute/root destDir → destDir must be an absolute, non-root path: ….
The daemon rejects this before it opens the archive, so it does not consume
the archive. "Root" is the platform's own notion (/ on Unix; a drive root C:\
or UNC share root \\server\share\ on Windows). The root test and the
filepath.IsAbs test share one branch and one message. Whether the reference
refuses a root destDir at all is not measured. Our own consequence — a
recursive delete of the volume — justifies the guard, not a claim about the
reference, so it is neither parity nor a divergence entry.
- destDir is, or contains, the home directory → destDir must not be or
contain the home directory: …. This is intentional divergence D2: the
reference wipes $HOME on "destDir":"~". The test is containment. The daemon
refuses home and any ancestor of home, and accepts anything under home
(~/.claude/…). See DIVERGENCES.md → D2.
- Bad gzip → gzip: ….
- Zip slip → unsafe path in archive: <entry>. The daemon allows a ../ that
resolves back inside destDir.
- Non-regular/non-directory entry (symlink, hardlink, device, fifo) →
unsupported tar entry type <c>: <entry>. <c> is the tar typeflag char
(symlink=2, hardlink=1).
- Total uncompressed bytes over the opt-in cap → extraction size limit
exceeded. This is not reachable by default, because the cap is 0 (off),
which matches the reference. Intentional divergence D3; see the flags table under
-serve and DIVERGENCES.md → D3.
- clean/mkdir/marker failures → clean destDir: … / mkdir destDir: … /
write .synced: ….
- Target is an existing directory → create <entry>: open <target>: is a
directory.
- The daemon cannot create the parent (e.g. an earlier entry wrote a file where
this entry needs a directory) → mkdir parent <entry>: <os error>. Only the
mkdir parent <entry>: prefix is contract. The tail is the OS's. Both create
<entry>: and mkdir parent <entry>: name the archive entry, not the
resolved target.
git.* (param: path = repo dir; worktree ops use baseRepo)¶
git.info¶
{path} → repo: {"isRepo":true,"repo":"<dir>","branch":"<b>","root":"<abs>","repoSlug":"<owner/repo>","defaultBranch":"<b>"} · non-repo: {"isRepo":false,"repoSlug":"","defaultBranch":""}
- The daemon reads
branchwithsymbolic-ref, so it works on an unborn HEAD (an empty repo gives the init branch name, e.g.master). A detached HEAD givesbranch:"detached:<short-sha>". rootis the absolute repo top-level (git rev-parse --show-toplevel). It stays the same even whenpathis a subdirectory (added by reference7cbfa471).7c2f88daddedrepoSluganddefaultBranch. Both are always present (an empty string when undeterminable), including on the non-repo body.repoSlugisowner/repofromremote.origin.url. The daemon populates it only for a canonicalgithub.comremote. Rules (measured across 42 URL shapes):- Scheme must be
https,http,ssh,git, or absent (scp-like[user@]host:owner/repo).git+ssh://andfile://→"". - Host must equal
github.comcase-insensitively.www.github.com, trailing-dotgithub.com., a port (github.com:443), GitLab, Bitbucket and self-hosted GHE all →"". The daemon strips userinfo. - Path must be exactly two non-empty segments after one optional trailing
/and one optional.git. - Owner: alphanumerics with interior hyphens only (
ac-me,ac--mepass;-acme,acme-,acme_corp,acme.codo not). - Repo: alphanumerics plus
.,_,-. It must not start with-, must not be.or.., and must not end in a lowercase.wiki(the check is case-sensitive and suffix-only, soGIZMO.WIKIand a repo namedwikiare accepted).
- Scheme must be
defaultBranchis whatrefs/remotes/origin/HEADpoints to. It is empty whenrefs/remotes/origin/HEADis unset.
git.status¶
{path} → clean: {"isRepo":true,"clean":true} · dirty: {…,"clean":false,"changes":["M a.txt"," M b.txt","?? new"]}
changesisgit status --porcelainstdout only. Stderr warnings never appear. Lines are verbatim minus the line ending. The two-character XY column is positional, so the leading space of an unstaged-only change is data ("M a.txt"staged vs" M b.txt"unstaged).- The first line is the exception. The daemon trims the whole porcelain blob
before it splits the blob, so only entry 0 loses a leading space.
[" M a1"," M a2"]returns["M a1"," M a2"]. A client that parses by column must handle entry 0 separately. - Non-repo →
{"isRepo":false,"clean":false}(the full shape, unlikegit.info). - A failing git →
-32603that carries the Go error string (exit status 128, not git'sfatal:text). With opt-in D5 the same-32603can carrysignal: killed.
git.list_branches¶
{path} → {"isRepo":true,"branches":[…sorted…]}
- Non-repo → {"isRepo":false,"branches":[]}.
- stdout only. A broken-ref for-each-ref warning must not become a branch.
- A failing for-each-ref → -32603 exit status 128. With opt-in D5 it can carry
signal: killed (see D5 below).
git.worktree_create¶
{baseRepo,branchName,worktreePath[,sourceBranch]} → {"success":true,"path":"<worktreePath>","sourceBranch":"<b>"}
- The repo is baseRepo, not path. When baseRepo is absent, the daemon
uses its cwd repo.
- Missing branchName → -32602 branchName is required.
- The resolved repo is not git → {success:false,error:"not a git
repository",errorCode:"not_a_repo"}. The daemon checks this before the add.
- Other failure → {success:false,error:"git worktree add failed: …",errorCode:"worktree_add_failed"}.
The tail is git's combined output, because the add writes its fatal to stderr
and leaves stdout empty. For example: "git worktree add failed: Preparing
worktree (new branch 'dup')\nfatal: a branch named 'dup' already exists".
- sourceBranch omitted → the source defaults to the repo's current branch, and
the daemon echoes it back. On an unborn HEAD the source resolves empty, the
add infers an orphan branch and succeeds, and the result omits sourceBranch.
Worktree population. git worktree add checks out tracked files only, so the
daemon then seeds the new worktree. The copies are best-effort, and a failure never
fails the request:
- The daemon copies .claude/ recursively and unconditionally. It skips an
absent .claude/ silently.
- .worktreeinclude (repo root, .gitignore syntax) is an include
manifest: git ls-files --others --ignored --exclude-from=.worktreeinclude
(without --exclude-standard). The daemon therefore does not copy a gitignored
file that the manifest does not name. Without the manifest, the daemon copies no
untracked file.
- The daemon skips symlinks. Manifest entries must be plain filenames. The
daemon silently skips a path that git ls-files C-quotes (tab, quote, backslash,
non-ASCII). This is a reference limitation reproduced for parity.
- The copies do not preserve the source mode. The daemon creates them
0666-subject-to-umask, so an executable arrives non-executable and a 0400
source is widened. This matches the reference. Treat the manifest as a way to
name configuration, not secrets or scripts.
- An opted-in -git-timeout (D5) that kills the git ls-files skips every
manifest-selected file, and the reply is still {"success":true} — a silent,
wire-invisible loss. Off by default.
git.worktree_remove¶
{baseRepo,worktreePath[,branchName]} → {"success":true} (lenient)
- The daemon runs
git worktree remove --force. Whenever git exits non-zero — for any reason — the daemon then removesworktreePathitself, recursively, and still answers{"success":true}. This method is therefore a recursive delete of the caller-suppliedworktreePathwhenever git is unhappy (a locked worktree, an ordinary directory, a non-repobaseRepo). That is reference behavior, matched deliberately. TreatworktreePathas a path you ask the daemon to remove, not as a filter. The reply carries{"success":false,"error":"failed to remove worktree: <git output>; manual cleanup also failed: <err>"}only when the manual cleanup also fails. - A request that names a non-existent branch still answers a bare
{"success":true}— hence "lenient". - One input is exempt — claustrum-only hardening D2. The daemon refuses a
worktreePaththat is, or contains, the home directory before git runs:{"success":false,"error":"worktreePath must not be or contain the home directory: …"}. The daemon judges containment after it resolves the path against its own working directory. It therefore also refuses"."/".."when that cwd is home or a descendant of home. You cannot predict the verdict on a relativeworktreePathwithout knowledge of where the daemon started, so send an absolute path. An empty or omittedworktreePathis exempt, becauseos.RemoveAll("")is a no-op. The containment test is the same onefiles.extract_taruses. SeeDIVERGENCES.md→ D2. - The daemon resolves a relative
worktreePathtwice, against different roots. git runs with-C <baseRepo>(repo-relative), and the manual cleanup resolves against the daemon's working directory. The fallback can therefore delete a directory git never looked at. This is parity, and it is alarming. Send an absolute path. - The worktree stays registered. Deletion of the directory does not remove
$GIT_DIR/worktrees/<name>, sogit worktree liststill shows it, and a later create at the same path failsalready registered. Neither binary prunes. gitTimeout(D5) does NOT authorise the deletion, and this whole timeout arm is off by default. When armed it answers{"success":false,"error":"git worktree remove timed out after <dur>; no cleanup was attempted, and git may have partially removed the worktree"}and removes nothing. SeeDIVERGENCES.md→ D5.
process.* (the agent/MCP-hosting core)¶
The client supplies its own id (any string). The daemon delivers output as
id-less stream notifications, and buffers them for a later replay.
process.spawn¶
{id,command[,args][,cwd][,env][,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 that reuses a still-live id succeeds and replaces the registry entry,
like the reference. Divergence: claustrum also kills the now-orphaned previous
process tree. It drops the subscribers first, so no stray frame arrives under the
reused id. This is OS-level only and changes no wire byte. The reference leaves
the old process running.
- wantPid opt-in (CT-1, claustrum-only). 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 (epoch
seconds) captured at spawn, and spawn and reattach return the identical value
for the same process. It is an opaque token for PID-reuse / 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 (omitempty), and the frame is
byte-identical to {"success":true}. An older daemon ignores the unknown param
(tolerant decode). See DIVERGENCES.md → CT-1.
process.stdin¶
{id,data[,offset]} → {"success":true,"applied":<int>[,"duplicate":true]}
- data is base64. The daemon writes it to the child's stdin.
- The checks run in a fixed order (decode → exists → running → offset):
- Invalid base64 → -32602 Invalid base64 data. The daemon returns this
before it looks up the process, so an unknown id with a bad payload still
reports the decode error.
- Unknown id → -32602 Process not found.
- Known but exited → -32602 Process not running.
- offset / applied — the resumable-stdin contract (added 7c2f88d,
advertised as process.stdin.offset). The reply always carries applied: the
cumulative count of stdin bytes accepted for delivery (the high-water mark).
offset is the byte position the caller believes this data starts at. offset
makes stdin idempotent across reconnects:
- absent offset, or offset == applied → the daemon appends, and
applied grows by len(data).
- offset > applied → -32003 stdin offset gap: offset ahead of applied bytes.
This is a hole that would drop input — resend from applied. The daemon
enqueues nothing.
- offset + len(data) <= applied (wholly applied) → no-op. The reply adds
"duplicate":true, applied does not change, and nothing reaches the child.
- partial overlap (offset < applied < offset+len) → the daemon writes only the
fresh tail data[applied-offset:], and applied advances to
offset+len(data). The daemon does not flag this as a duplicate.
applied counts base64-decoded bytes, and it is never omitempty (the daemon
emits it at 0). The daemon drops duplicate when it is false. A legacy client
that never sends offset still works: it always appends.
process.kill¶
{id[,signal]} → {"success":true}
- Best-effort and fire-and-forget. It does not wait for the child to exit
(contrast process.killAndWait).
- How wide the signal reaches depends on the signal, on Unix:
- KILL goes to the whole process group (a negative pid), so the entire
child tree dies.
- Every other signal (TERM, INT, HUP, 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" or
killAndWait with escalate:true.
The split does not apply on Windows. There claustrum terminates the Job Object,
which takes the tree either way.
- Divergence: claustrum 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.
process.killAndWait¶
{id[,signal][,timeoutMs][,escalate]} → {"found":<bool>,"died":<bool>[,"alreadyExited":true][,"escalated":true]}
Added by 7c2f88d. It blocks until the process is gone (up to the grace) and
reports the outcome as a result. An unknown id is not an error:
- Missing id → -32602 Process ID is required. Absent params → -32602 Invalid
params.
- Unknown id → {"found":false,"died":false}.
- Already exited → {"found":true,"died":true,"alreadyExited":true}. The daemon
sends no signal.
- Live process → the daemon sends the graceful signal (default SIGTERM), and
then waits up to the grace:
- timeoutMs sets the grace. A non-positive or absent value gives the
3000 ms default. The daemon honors a positive value verbatim up to a
30000 ms ceiling, and clamps a larger value. timeoutMs:45000 against a
signal-ignoring child therefore answers after ~30 s. The 30000 ceiling is a
black-box bracket (29500, 30500]. It is the only round value in that
bracket, not a measured-exact figure.
- escalate (default true). If the process is still alive after the
grace, true escalates to a process-group SIGKILL, waits up to 7 s
for the reap, and adds "escalated":true. (Measured: timeoutMs:500 against
an unreapable child → the reference replies at 7.51 s.) The daemon sends the
SIGKILL even when the graceful signal already killed the child, because a
grandchild that holds the stdout pipe can keep the drain pending past the
grace. false leaves the process running and reports
{"found":true,"died":false} (no escalated, no SIGKILL), which spares the
tree.
- A process that dies within the grace → {"found":true,"died":true} (no
escalated).
process.reattach¶
{id,fromSeq[,wantPid]} → {"found","running","firstSeq","lastSeq","stdinApplied"}
- A missing or empty id → -32602 Process ID is required, the same frame
spawn and killAndWait document (probed both ways: "id":"" and id
absent).
- The daemon replays buffered frames with seq > fromSeq (exclusive) to this
connection, transfers the frame stream to it, and then returns the result.
- The transfer is exclusive. A reattach does not add a second listener. Any
connection attached before stops receiving frames for that process. This is what
makes a resume safe.
- The cut is by seq, not by wall-clock. The transfer point is the reported
lastSeq. The old connection can still receive a frame <= lastSeq slightly
after the reply, and never one above it. No frame reaches the old connection and
is also absent from the new connection's replay. That is what fromSeq is for.
- Unknown id → {found:false,running:false,firstSeq:0,lastSeq:0,stdinApplied:0}.
- The daemon retains an exited process for ~15 minutes and then drops it,
together with its replay buffer. An id last seen longer ago therefore answers
exactly like an unknown one, and process.kill on it still reports
{"success":true}. The daemon never drops a running process. The sweep runs
on a ~60-second timer and inline on every process.spawn. On the wire, the
retention brackets only to (45 s, 960 s]. The exact 15 min and 60 s are
pointer-class: no wire observable distinguishes them from other values in that
bracket. Read found:false after a long gap as "finished and forgotten", not as
"never existed".
- stdinApplied (added 7c2f88d) is the process's cumulative applied-stdin
byte count (§process.stdin). It is always present after lastSeq. A
reconnecting client resumes stdin from this offset. It is an acknowledgement,
not a delivery receipt. process.stdin returns before the child reads, so the
daemon counts bytes accepted just before exit even though the writer never
delivered them. A client that must know that data arrived confirms it in-band.
- wantPid opt-in (CT-1). With "wantPid":true and the process found, the
reply appends "pid":<int>,"startTime":<number> after stdinApplied. It
reports the same pid and startTime the spawn reported, so a client can confirm
that it reattached to the same process and not to a pid-reuse. The daemon omits
both fields otherwise.
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}
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.- 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 (npm run dev &). The daemon does not forward output that the grandchild writes after the cap, because that write failsEPIPE. Until the daemon emits the frame,process.reattachstill reportsrunning: true. The flag flips with the frame, not with the process. - 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 (the bytes a subscriber would
receive), 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 a new frame would exceed 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 / reconnect mechanism.
Daemon lifecycle (flags)¶
One binary, five modes (-serve, -bridge, -stop, -version, -install).
Everything here is probe-verified against the reference unless it is marked
claustrum-only.
Flags and config keys¶
Every opt-in divergence flag defaults to its zero value = OFF, which is
byte-identical to the reference. Each flag has a matching claustrum.conf key.
Claude Desktop owns the -serve / -install argv (a driver claim — see
ARCHITECTURE.md → Driver claims and their
provenance), so the config
key is the reachable knob. The precedence is: explicit CLI flag > config >
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> |
— | — | token source (read once, unlinked) | -serve |
-token-fd <n> |
— | -1 |
token from an open fd (claustrum-only) | -serve |
-metrics-addr <a> |
metrics-addr |
"" |
Prometheus /metrics (claustrum-only, CT-3) |
-serve |
-wire-log <p> |
wire-log |
"" |
append every JSON-RPC frame to <p> as JSONL (claustrum-only, CT-3) |
-serve |
-wire-log-max-string <n> |
wire-log-max-string |
512 |
bytes kept per string value; 0 = whole payloads |
-serve |
-keep-children |
keep-children |
off | survive restart, POSIX-only (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 |
off | refuse non-regular files.read (D4) |
-serve |
-max-cli-bytes <n> |
max-cli-bytes |
0 |
cap CLI decompress + download (D10) | -install |
-cli-probe-timeout <dur> |
cli-probe-timeout |
0 |
<cli> --version deadline (D11) |
-install |
-cli-download-timeout <dur> |
cli-download-timeout |
0 |
download deadline (D12) | -install |
-libc-probe-timeout <dur> |
libc-probe-timeout |
0 |
ldd --version deadline, linux only (D14) |
-install |
-cli-keep <n> |
— | 3 |
versions to retain on prune | -install |
| — (config only) | version-override |
— | -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>] [-files-read-regular-only]
The binary self-daemonizes (it reparents to init / detaches), extracts the
login-shell PATH (Unix), and then runs the RPC server. On success it prints
Claustrum remote server listening on <socket> to stdout.
Login-shell PATH extraction (Unix) runs $SHELL -l -i -c … when $SHELL is an
executable file. Otherwise it runs the first usable of /bin/zsh, /bin/bash,
/bin/sh (zsh 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.
Token source — required, and checked in the detached child, not in the
launcher:
- Both flags missing → the launcher daemonizes anyway, the child refuses to start,
and the launcher reports its accept timeout after ~10 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, because the reference
exits 1 at ~10 s the same way. A zero-byte -token-file behaves identically.
- The daemon reads the token as a line. It strips one trailing \n/\r\n, and
preserves other surrounding whitespace verbatim.
- A bad -token-file → claustrum: read --token-file: <err>, exit 1.
- -token-fd <n> (claustrum-only) reads from an already-open fd (0 = stdin), so
this handoff never touches disk. The launcher forwards it to the detached
child over an inherited pipe.
Daemonize sentinel (internal; 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, so the launcher would mistake
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 (pinned
by TestSpawnInheritsDaemonChildMarker). claustrum unsets the internal marker
before it spawns.
Claustrum-only extras (off the wire; canonical detail in
DIVERGENCES.md):
- -metrics-addr <a> (CT-3). Prometheus counters at http://<a>/metrics
(connections, spawns/exits, reattaches, stream/stdin bytes). Off by default, with
no listener. It counts only, and has no auth, so bind it to loopback. The
daemon logs a bind failure ([Server] metrics: …), which is non-fatal.
- -wire-log <p> (CT-3). Appends every JSON-RPC frame, both directions, to <p>
as JSONL — a diagnostic side channel that observes already-marshaled bytes, so a
daemon logging emits frames byte-identical to one not. Off by default (no file, no
work). -wire-log-max-string <n> bounds each string value (default 512; 0 keeps
whole payloads, needed to reconstruct a session from stream frames). Credentials
are redacted by key — the auth member and token-like env keys — but a secret
a client embeds inside a payload string is not caught, so redaction is best-effort,
not a guarantee. A capture holds whatever the client sent (files.write,
process.stdin, the spawn env), so it is forced to 0600 on every open (append included) and belongs somewhere
private. Each record carries the frame as a decoded body (structured, per-value
truncated — a normalized view, so field order is not significant) or, at
-wire-log-max-string=0, as raw (the frame verbatim, preserving the field order
and number formatting that is the wire contract). An unopenable path is fatal,
not silent.
- -keep-children (CT-2; POSIX-only). Off by default, so a graceful shutdown
kills the whole child tree. When set, it leaves spawned children running across a
restart, and logs [Server] -keep-children: leaving <n> running child process(es)
alive across shutdown. The new daemon does not re-adopt them, and the
survivors lose their stdio (stdin EOF; a stdout/stderr write gets SIGPIPE, or
EPIPE for a child that ignores SIGPIPE, e.g. Node). It therefore suits only
children that tolerate dead stdio. Windows ignores it and logs a warning
([Server] -keep-children is not supported on Windows …), because the Job Object
terminates children regardless.
- -listen-pipe (CT-5; 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),
-git-timeout (D5) and -files-read-regular-only (D4). Off = parity. Their wire
frames appear in the method sections above (files.extract_tar, git.status /
git.list_branches / git.worktree_remove, files.read). See the flags table and
DIVERGENCES.md.
-bridge — stdio↔socket relay¶
A simple relay — the thing an SSH session attaches to. It adds no auth. The
client that speaks through it supplies "auth" itself. It is strict: a dial
failure is a hard error — claustrum: dial server: <err> on stderr, exit 1.
-stop — ask a running daemon to shut down¶
-stop sends server.shutdown with no auth member, because that method is
not authenticated (see Authentication). It is best-effort: a
missing or unreachable daemon is a silent no-op (exit 0, no output), and -stop
reads and discards any reply. A current daemon sends no reply, because
server.shutdown answers nothing and closes.
-stop unlinks the socket path on every exit path, including when the dial
fails and it reached no daemon. This matches the reference, and it is destructive
on two arms: a stale socket with no listener, and a live foreign
listener. -stop removes a socket path it did not create, so a new client that
dials by path cannot reach that listener afterwards. The listener itself stays
alive. A conditional unlink would be a divergence, so it is a candidate not taken —
recorded under Candidates considered but not
taken.
Upgrading a live daemon. 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.-stopdiscards the reply and 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¶
Intentional divergence: version-override via claustrum.conf (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. If the file sets
version-override to a bare commit SHA (a 40-hex git SHA-1, the string the desktop
client pins; claustrum also accepts 64-hex; anything else is a no-op), the output
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.version /
server.capabilities still report claustrum's own <id>. See
DIVERGENCES.md → CT-3.
-install — ensure the agent CLI¶
claustrum -install -cli-dir <d> -cli-version <v> \
[-cli-url <u> -cli-checksum <sha256>] [-cli-zst <p>] [-cli-keep <n>] \
[-max-cli-bytes <n>] [-cli-probe-timeout <dur>] [-cli-download-timeout <dur>] \
[-libc-probe-timeout <dur>]
-install downloads, verifies, extracts and prunes, and then prints one
__INSTALL_RESULT__<json> facts line (schema in
ARCHITECTURE.md). -install always exits 0. It reports a
failure inside the facts as cliError, not through the exit code. -install
reaches the network only with -cli-url.
cliError catalogue:
cliError |
trigger |
|---|---|
installed cli at <path> is not runnable |
post-extraction --version probe failed (or timed out, D11) |
cli <v> missing and no --cli-url or --cli-zst provided |
cache miss (or a cache-hit probe timeout, D11) with no source flag |
checksum mismatch: expected=<x>, actual=<y> |
-cli-checksum verify failed (-cli-url always; -cli-zst only when supplied — D1) |
opening input: <err> |
-cli-zst read error |
decompressing: <err> |
bad zstd blob (e.g. 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> |
io.Copy transport error (e.g. read tcp …: connection reset by peer) |
download failed: context deadline exceeded (Client.Timeout or context cancellation while reading body) |
D12 download deadline, opt-in |
mkdir cli dir: <err> |
cli-dir uncreatable |
cli version "…" must be a single path component |
D6 hardening |
cli version "…" collides with the install temp sweep |
D7 hardening |
cli version "…" collides with the install download blob |
version starting .blob- |
clearing stale dir at <path>: <err> |
occupied cliPath directory couldn't be removed |
staging file vanished before install: <err> |
a concurrent sweep took the staging file |
Checksum + verify ordering:
- claustrum verifies -cli-checksum on the -cli-url path unconditionally. An
empty checksum still fails.
- Verify happens BEFORE decompress — intentional divergence D13 (always-on,
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 genuine interrupted transfer never reaches the
checksum on claustrum at all (download failed: <transport> vs decompressing:
<transport>). Both binaries fail the install either way. See
DIVERGENCES.md → D13.
- -cli-zst checksum — intentional conditional divergence D1. The reference
never checksum-verifies the local SFTP-upload blob. claustrum verifies it only
when a -cli-checksum is supplied, with the same checksum mismatch error, and
it leaves the source blob intact. An absent or empty checksum stays trusting, so
honest callers are byte-identical. See DIVERGENCES.md → D1.
Opt-in wall-clock bounds — all three are off by default, so a stock claustrum
applies none of them, on linux or anywhere. At the shipped defaults no
claustrum-chosen -install bound applies. Only the stdlib transport clocks
(net.Dialer{Timeout:30s}, TLSHandshakeTimeout:10s) apply, and only on
-cli-url. Off = parity, because the reference showed no deadline at the durations
probed. See DIVERGENCES.md:
- -cli-download-timeout <dur> (D12). 0 = 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> (D11). 0 = no deadline on the <cli> --version
runnability probe, on every platform. When armed, a CLI slower than the deadline
diverges. After extraction it diverges as installed cli at <path> is not
runnable, and claustrum deletes the staged binary. On the cache-hit check it
diverges as a silent reinstall, or, with -cli-url and a timely replacement, as
no cliError at all. It is a threshold, not a hang detector: an honest-but-slow
CLI trips it too. The cached binary survives every failure before the rename.
- -libc-probe-timeout <dur> (D14; linux only). 0 = no deadline on ldd
--version. Off linux the probe never runs. On linux it cannot fire on a host
whose musl loader glob matches, because detectLibcWith returns before it spawns
ldd. Do not confuse it with -cli-probe-timeout. The two names differ only in
their cli/libc prefix, they have the same type, and main's -install arm
resolves them in consecutive statements (pinned by
TestInstallArmWiresEachFlagToItsOwnGlobal). libc build
selection is a driver claim — see
ARCHITECTURE.md.
Opt-in size cap (D10). -max-cli-bytes <n> (or the max-cli-bytes config key)
governs both the decompressed CLI and the download body. 0 = off, which is
parity: the reference took a 600 MiB payload to the runnability check. 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 (claustrum-only):
- D6: must name a single path component. The clearing step is an os.RemoveAll
on filepath.Join(cliDir, cliVersion). A version that escapes the cli-dir
(../victim, or link/1.0.0 through an intermediate symlink) therefore deletes
unrelated data, and the reference destroys the target on both shapes. claustrum
answers cli version "…" must be a single path component and touches nothing. It
refuses ., .., / and \ on every OS. claustrum uses a single-component
check and not lexical containment, because containment accepts link/1.0.0, and
EvalSymlinks would add 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, 1.0.86+build.5 — all measured as accepted).
- D7: must not collide with the orphan sweep. The sweep claims .fetch-* and
*.zst, and it runs after every attempted install. -cli-version .fetch-x or
1.0.zst would therefore install, and the sweep would delete it moments later.
Both binaries finish with an empty cli-dir and no cliError, and report a
success that installed nothing. claustrum now answers cli version "…" collides
with the install temp sweep. The sweep predicate and this check share one
definition.
Staging and cleanup:
- claustrum stages the CLI at <cli-dir>/.fetch-<random> (mode 0600) 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> when the cli-dir
exists. On a first install it lands at $TMPDIR/claustrum-fetch-<random>,
because fetchToFile (install.go) runs before ensureCLI creates the
directory. The .blob- prefix is deliberately different, so that the sweep and
the -cli-keep prune (which counts every non-directory as a version) do not claim
an in-flight blob. That is also why claustrum refuses a -cli-version that starts
with .blob-. The install removes the blob on every path. Only a SIGKILLed
download leaves it behind. No frame changes either way.
- claustrum consumes the -cli-zst blob once decompression succeeds, and not
only on a fully successful install. An extracted CLI that fails the runnability
check still costs the blob. claustrum leaves a blob that is not valid zstd alone.
- claustrum clears an occupied cliPath, and that is not fatal. rename(2)
refuses to replace a non-empty directory, so claustrum removes it first. It
removes it only when cliPath is a directory; a regular file, which an
installed CLI always is, is replaced atomically. If claustrum cannot remove it →
clearing stale dir at <path>: <err>. If the staging file has vanished →
staging file vanished before install: <err>, and cliPath stays untouched. The
end states match the reference for every destination shape (absent, regular
file, non-empty directory).
- The orphan sweep removes .fetch-* and *.zst entries with one os.Remove
per entry. It therefore clears files and empty directories, and leaves a
non-empty .fetch-dir/. Unrelated files survive. The sweep runs whenever an
install was attempted, and the -cli-keep prune runs only on success. claustrum
stages its extract in this same .fetch-* namespace and holds it across the
probe, so a concurrent install can reclaim another install's staging file.
claustrum handles that with a single retry of the stage-verify-rename step,
and does not narrow the sweep.
- claustrum runs ldd only when the musl loader glob does not match. On a host
that carries /lib/ld-musl-*.so.* the marker decides, and no ldd starts.
Behavior shared by every mode¶
- Default socket — when
-socketis omitted, all modes fall back to~/.claude/remote/rpc.sock.-servecreates the parent directory (mode0700) if it is missing, so a bare-serveon a fresh machine works.-bridgeand-stopdo not create it. They fail withconnect: no such file or directorywhen no daemon has run. - No mode given →
claustrum: one of --version/--install/--serve/--bridge/--stop is requiredon stderr, exit2, and no usage dump. An unknown flag gets the stdlibflagerror plus the usage, exit2.
See ARCHITECTURE.md for the -install facts schema and the
deployment lifecycle, DIVERGENCES.md for the full divergence
catalog and rules, and EXAMPLES.md for runnable snippets.