Skip to main content

cswap-pin

Keep Claude Code's Remote Control and Artifacts on one account while inference keeps following cswap's account swap.

The problem

cswap swaps the on-disk credential, so everything follows the swap — including two things that are not inference and that you usually want to stay put:

  • Remote Control — a session's owner is fixed at creation by whichever bearer created it. Swap accounts and the phone/web loses the session; stale "ghost" sessions pile up on the old account.
  • Artifacts — owned by the publishing bearer. After a swap a republish 403s and the artifact "disappears" from the account you are logged into.

Claude Code resolves all of these through one credential accessor and has no per-operation token selector, so splitting auth per operation inside one session means intercepting the requests.

How it works

A local MITM forward proxy that swaps the Authorization bearer on exactly the routes whose server-side ownership is decided by it, and passes everything else — /v1/messages above all — through untouched.

claude session
  HTTPS_PROXY ─► cswap pin proxy ──► (whatever HTTPS_PROXY was already set) ──► api.anthropic.com
                   swaps bearer on: /v1/code/sessions*, /v1/sessions/*,
                                    /api/frame/*, /v1/ultrareview/*
                   passes through:  /v1/messages, /api/oauth/usage, everything else
                   NEVER swapped:   .../worker/*, .../client/presence

Inference keeps billing whichever account cswap has swapped onto. Only the claude.ai-side assets are pinned.

Two exceptions inside the pinned prefix are worth naming, because both were learned by breaking them:

  • /worker/* carries the session's own channel credential, not an OAuth bearer. Swapping it makes the server reject every worker call and leaves Remote Control in a reconnect loop.
  • /client/presence is registration, not ownership: it tells the server which process is attached and should receive events. Swapped, the server registers the pinned account while the process actually listening belongs to the active one — so inbound has nobody to reach. It returns 200 either way, which is what made it hard to find.

A wrong guess cannot cost you a session

Route classification used to be a single point of permanent failure. Claude Code treats 401/403/404 as terminal — its SSE transport sets state="closed" and never reconnects — so one misrouted swap ended that session's Remote Control for the life of the process (measured: 26 such responses severed four sessions that were still running hours later).

Since 0.1.1 the proxy holds the response before any byte reaches the client, and when the swap is what was refused it re-sends the request exactly as it arrived. "Wrong about this route" degrades to "this request went out unpinned", which is the failure mode everything else here is already built to tolerate.

Install

uv tool install 'claude-swap[pin]'      # or: pipx install 'claude-swap[pin]'

The pin is an optional extra of claude-swap, not a standalone tool: it reads cswap's account store and rewrites the config cswap already manages. Installing cswap-pin on its own does nothing useful.

On a machine running claude-swap from a checkout, keep it editable. The command above installs the PyPI release and replaces whatever was there, extras included — so running it against an editable install both downgrades the host and drops cswap_pin from the tool env. The daemon already running survives (its code is in memory) but every successor it spawns dies with ModuleNotFoundError, which is invisible until something tries to restart it:

uv tool install --force --editable '.[pin]'     # from the checkout

Upgrading a machine that is already serving

Nothing to do. Install the new version; the running daemon notices its own code changed and replaces itself, on the same port, without dropping anything. Measured across a real code change on a live daemon: 68,168 requests, 0 refused, 0 reset, 0 unanswered, same port, new pid.

refused=0 on its own is not that claim, and it is worth saying because this package spent several releases believing it was. The port is held by a process that outlives the daemon, so during a handover it stays bound and every arrival queues in the backlog: a probe that only counts ConnectionRefusedError is structurally incapable of failing, however long nobody is behind the socket. One machine drained for 30 seconds that way — refused=0 the whole time, and 30 requests died on a 3s timeout with no reply. The numbers above count a request that connects and is never answered as a failure, which is what it is to a session.

This used to need a procedure, and a procedure is not an answer — a deploy is not something someone follows, it is whatever the running code does. Two machines taught that: both moved their port mid-upgrade (53749 → 54264, 36301 → 45357) and stranded every session that had the old number baked in at exec, because the successor came up with no holder above it. Every spawn now lands under one.

Use

cswap pin 2          # RC / artifacts / ultrareview → account 2
cswap pin            # show the current pin
cswap pin --clear    # remove it

The pinned account is re-read per request, so cswap pin <other> takes effect under a live daemon — no session restart. The one thing a re-pin cannot move is a Remote Control session that is already open: the server fixed its owner when it was created, so reconnecting inside it is what mints a new one under the new pin.

The port

Nothing is hardcoded. The first daemon binds port 0 — the OS picks — and records what it got in <cswap-backup>/pin-proxy/proxy.json. Later starts try to reclaim that number and fall back to another ephemeral port if anything else already holds it, so a port you are using is never taken from you.

Reclaiming matters because a running session's HTTPS_PROXY is fixed when it execs: coming back on a different port would leave that session dialling an address nothing answers, and its requests would then go out unpinned rather than fail loudly.

The port outlives the daemon

The socket is bound by a holder — a process that never serves a request. It binds, starts the daemon, and waits. The daemon accepts on that inherited descriptor, so there is no relay and no extra hop: the connection the client makes is the connection the daemon serves.

That is what makes a crash survivable. A planned restart already keeps the port (the outgoing daemon hands its socket down), but a kill -9, an OOM kill or a segfault skips every cooperative step — and an unowned port is permanent for a live session, whose HTTPS_PROXY was fixed at exec. Measured: twelve kill -9s of the daemon while four clients hammered the port — 6,388 requests, 0 refused, same port throughout, a new pid each time. The 41 resets in that run are the killed daemon's own in-flight requests, which a crash must cost; a planned restart costs none.

The reason it is zero rather than small is that the holder never releases the socket between children. It binds once and keeps it; each daemon accepts on the inherited descriptor. So there is no re-acquire to lose, and a connection arriving mid-crash waits in the kernel's backlog instead of being refused. A supervisor that closes and rebinds has a window there by construction, however narrow — a peer measured 1 refusal in 40 requests on that shape.

The holder reads the daemon's exit rather than guessing:

exit meaning what the holder does
0 idle teardown — it meant to go release the port, do not respawn
75 SIGTERM under a holder: a redeploy restart at once, same socket
other killed or crashed restart on a 0.25s → 5s ladder

CSWAP_PIN_SELF_HEAL=off turns every automatic replacement off — the holder's restart above and the self-upgrade below — for when you are debugging the daemon and a respawner fighting you is worse than a dead port. cswap pin --heal and a launch still repair, because those are you asking.

A redeploy is the same story from the other side. Under a holder the daemon does not hand its socket to a successor — it exits 75 and lets the holder put the new code on the socket it already owns. Handing the port out of the holder is what left one machine's pin unwired for 76 minutes while every component reported healthy.

A daemon that is NOT under a holder still hands its socket down, and the successor it starts gets a holder that adopts that socket rather than binding a fresh one. There is no race to lose: the descriptor is already bound and listening. That is what makes the first upgrade onto this version safe as well as every one after it.

A daemon that outlives its holder gets a new one

A holder can die without taking its daemon with it, and nothing looks wrong afterwards: the daemon already holds the socket, so the port keeps answering. What is gone is the property above — every spawn lands under a holder — so the next death takes the port down for good.

The daemon notices by asking a question it was already able to answer. Its CSWAP_PIN_HELD_BY names the holder that started it, and an orphan is reparented to init, so the marker and getppid() disagree the moment the holder dies. Nothing signal-specific: a SIGHUP, a SIGQUIT, a segfault and a targeted kill all land the same way. It then hands over exactly as a code change would, and the successor's holder adopts the socket.

Measured, under load across the whole orphaning: 110,188 requests, 0 refused, 0 reset, same port, one holder afterwards.

Falling through a dead hop

The pin dials through whatever egress proxy the machine already has, and that proxy usually has one behind it. When a hop dies the request has to reach the hop behind it — falling through to a direct dial is not "no proxy" on a machine whose direct route is a TLS-inspecting corporate proxy, it is a 403.

So the pin asks each hop what it chains through, while that hop is still answering — the only moment the answer can be trusted, and the only moment it is free. Measured on one machine: the record named a single hop for a day while that hop's own /health had been naming the next one the entire time, because the question was only ever asked at launch. When the inner hop died, a chain that could have stepped one hop out went direct instead.

A connection is not a thread

An upstream that accepts and never answers used to cost one OS thread per connection, and a client that retries forever opens them faster than they drain. Measured on a 48-core box: 27,491 threads / 44,121 FDs in 40 minutes, load 16,483, rescued by hand.

Connections are multiplexed on one selector instead. Measured with tools/thread_probe.py, idle CONNECT tunnels against a local upstream:

open tunnels before after
50 55 threads 5
150 155 threads 5
300 305 threads 5

A ceiling was tried first and removed: it turns the 257th retry into a refused connection and leaves the coupling in place.

Asking for a specific port

cswap pin --get_port          # what it is serving right now (for scripts)
cswap pin --set_port 41234    # serve there from the next daemon start
cswap pin --set_port 0        # back to dynamic: the kernel picks

A port you set outranks the reclaim above — it is a standing instruction, where the reclaim is only about keeping live sessions attached. It takes effect on the next daemon start, not immediately: moving the port under a running session would strand it, since its HTTPS_PROXY was fixed at exec.

If the port you asked for is taken, the pin serves on another one rather than refusing to start, and says so in pin-proxy/daemon.log.

CSWAP_PIN_PORT is not a setting. The pin writes it into .claude.json as its own marker and Claude Code applies that block at boot, so inside a pinned session it already holds the running daemon's port. Exporting it changes nothing; use --set_port.

Requirements

  • Python 3.10+
  • claude-swap — a peer, not a dependency: this package is loaded by it (see src/cswap_pin/_host.py for the exact surface it borrows)
  • cryptography (installed automatically) for the MITM CA

Running the tests

uv sync --group dev                 # pytest, pytest-xdist, and the host
S="$(mktemp -d)" && HOME="$S" XDG_DATA_HOME="$S/.local/share" \
  uv run python -m pytest tests -q

Redirect HOME and XDG_DATA_HOME. The suite drives real cert dirs, daemon state and config wiring; a run against your own HOME will rewrite ~/.claude.json, publish a test CA into ~/.claude/ca-trust.d/, and touch the account store. tests/conftest.py redirects all of it per test, but the env vars are the belt to that suspenders — they are what the child processes the suite spawns obey.

pytest-xdist is required, not optional. addopts = "-n 4" in pyproject.toml runs the suite on 4 workers (12.2s → ~5.0s, measured; more workers do not help — the floor is the single longest test). A pytest without xdist refuses the flag rather than ignoring it, so the suite will not start.

For a serial repro of a failure, add -n 0: xdist gives no live output and truncates tracebacks it cannot attribute to a worker.

One pytest test runs many case_* methods (run_cases in conftest.py), so 113 collected tests carry 350 cases. A failure names both: Class::case_name.

Why a separate package

Upstream did not want a MITM proxy shipped inside claude-swap itself and asked for a companion distribution exposed through an optional extra. See realiti4/claude-swap#198.

Trust

The proxy generates its own CA to re-sign api.anthropic.com and names it in NODE_EXTRA_CA_CERTS. Node accepts exactly one file there, so an existing CA (a corporate MITM, another local proxy) is merged, never replaced — otherwise the session silently loses trust in every host the other proxy re-signs.

The proxy does not authenticate its callers, deliberately. It listens on 127.0.0.1 only, so the population it could turn away is other processes running as you — and an earlier version did exactly that, with a secret file in the cert dir. That defended against nobody: any process able to reach the port could also read a 0600 file in your own home. What it did cost was real, because a session's HTTPS_PROXY is fixed when it execs and cannot be updated in place: arming the credential instantly 407'd every session that had started before it existed.

So the honest boundary is the loopback interface plus your user account, not a credential. If you share a machine with logins you do not trust, do not run this — the pinned account's token is reachable by anything that can reach the port.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cswap_pin-0.1.54.tar.gz (330.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cswap_pin-0.1.54-py3-none-any.whl (135.3 kB view details)

Uploaded Python 3

File details

Details for the file cswap_pin-0.1.54.tar.gz.

File metadata

  • Download URL: cswap_pin-0.1.54.tar.gz
  • Upload date:
  • Size: 330.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for cswap_pin-0.1.54.tar.gz
Algorithm Hash digest
SHA256 7c6c4e72618a0a4b199d1e8d7d39566ff192689ef9928394322254ae6417efd3
MD5 469f890b0294e1fa8ba6a82d4a4fdcd5
BLAKE2b-256 5b266b9ec193b2e9054ed6d29b809fe9db8891a37f1d88261a1ca19f66ba8598

See more details on using hashes here.

Provenance

The following attestation bundles were made for cswap_pin-0.1.54.tar.gz:

Publisher: publish.yml on codeslake/cswap-pin

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file cswap_pin-0.1.54-py3-none-any.whl.

File metadata

  • Download URL: cswap_pin-0.1.54-py3-none-any.whl
  • Upload date:
  • Size: 135.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for cswap_pin-0.1.54-py3-none-any.whl
Algorithm Hash digest
SHA256 dd85d2a8c4ac996ecfb236b6c11a99125399fa36ec2267ca3ff9a238ffa3c57f
MD5 9a5252943a5846a2f637268e5e966f8e
BLAKE2b-256 47631089c7e3a1075ec6d8c672cfd5b6c9c0831a6eb2e1e95867cc3ad4fe4002

See more details on using hashes here.

Provenance

The following attestation bundles were made for cswap_pin-0.1.54-py3-none-any.whl:

Publisher: publish.yml on codeslake/cswap-pin

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page