Metadata-Version: 2.4
Name: sharerouter-capture
Version: 0.1.0
Summary: Measure and compress what your coding agent sends to an LLM, without handing over its credentials
Author: ShareRouter
License-Expression: MIT
Project-URL: Homepage, https://gitlab.com/sharerouter/sharerouter_capture
Project-URL: Repository, https://gitlab.com/sharerouter/sharerouter_capture
Project-URL: Issues, https://gitlab.com/sharerouter/sharerouter_capture/-/issues
Keywords: llm,proxy,compression,tokens,codex,claude-code,cursor,mitm
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Testing
Classifier: Topic :: System :: Networking :: Monitoring
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: h2>=4
Requires-Dist: websockets>=12
Dynamic: license-file

# ShareRouter Capture

`sharerouter-capture` is a local wrapper for capturing model CLI traffic while keeping the user's native auth on the same machine.

## Install

The commands below assume `sharerouter-capture` is on your `PATH`. Editable install,
so the shim follows the working tree and a `git pull` needs no reinstall:

```bash
uv tool install --editable /path/to/compressor-ai/sharerouter_capture
```

That puts the executable in `~/.local/bin`. Verify with `sharerouter-capture doctor`,
which prints the active profile without touching it. To upgrade after changing
`pyproject.toml`, add `--force`; to remove, `uv tool uninstall sharerouter-capture`.

`pip install -e .` works too, but installs into whichever environment is active and
so is easy to lose track of. Without any install, run it as a module — see the end
of this file.

## Disconnecting

```bash
sharerouter-capture logout
```

Removes the stored credential and then lists, with sizes, what it did NOT remove: the
local CA's private key, the installed hooks, and any captured traffic — which is your own
prompts and source code. `logout` means "stop being connected"; throwing that away is a
different decision and needs `--purge`:

```bash
sharerouter-capture logout --purge
```

Nothing outside the capture home is touched either way, and the CA was never added to the
system keychain, so there is no system trust to withdraw.

**`connect` overwrites the whole profile, not just the key.** `--control-plane`
defaults to `https://api.sharerouter.ai`, so running plain `connect` against a local
backend silently repoints it at production and re-enables upstream TLS verification.
For a local backend pass both:

```bash
sharerouter-capture connect --workspace-key "sr_xxxxx" --control-plane http://127.0.0.1:8180
```

Check `sharerouter-capture doctor` afterwards — it prints the control plane in use.

## Usage

The user-facing path stays short:

```bash
sharerouter-capture connect --workspace-key "sr_xxxxx"
sharerouter-capture run -- cursor-agent "Reply with exactly: cursor proxied native auth ok"
sharerouter-capture run -- codex exec "Reply with exactly: codex captured ok"
sharerouter-capture run -- claude "Reply with exactly: claude captured ok"
sharerouter-capture run -- grok -p "Reply with exactly: grok captured ok"
```

## Desktop apps

One verb for both. `--gui` says you mean the desktop app of that name:

```bash
sharerouter-capture run --gui -- codex     # the Codex desktop app
sharerouter-capture run -- codex           # the codex CLI
sharerouter-capture run --gui -- "/Applications/Some App.app/Contents/MacOS/Some App"
```

The flag is not decoration. macOS filesystems are case-insensitive, so
`which("Codex")` returns the `codex` CLI — capitalisation cannot carry the
distinction, and something explicit has to. A path inside a `.app` is recognised as a
desktop app on its own; `--cli` forces CLI handling for one that isn't.

Names are matched against each bundle's own `Info.plist`, not a table of ours: the
Codex desktop app ships as `ChatGPT.app` with the identifier `com.openai.codex`, and
`--gui -- codex` finds it through that identifier.

### Codex desktop app — routed, not intercepted

Measured, not assumed: this app makes **no** model requests from Chromium. It spawns
its bundled `codex` binary as an app-server over stdio
(`Contents/Resources/codex ... app-server`), and that child does the model traffic.
Proxying Chromium would therefore capture only telemetry — and would trip Chromium's
certificate pinning on Google hosts along the way.

So it is routed the same way the codex CLI is, with no interception anywhere. The app
has no argv of ours to add `-c` overrides to, and it writes its own settings into
`config.toml`, so editing that file would fight the app. Instead `CODEX_CLI_PATH` —
the app's own hook for choosing the binary — points at a small generated shim that
execs the app's real binary with one override added. The user's `CODEX_HOME` is
untouched and native ChatGPT auth is unchanged.

That override is `openai_base_url`, codex's own root-level setting for the model
endpoint, and **not** a provider of ours. Declaring `model_provider` also worked, but
it hid the entire chat history: threads are stored with the provider that created
them (`threads.model_provider` in the state DB, indexed) and both the app and `codex
resume` list only the active provider's. Measured on one machine: 25 threads listed
under the built-in provider, 0 under ours. Keeping the built-in provider also keeps
the user's configured model instead of one we pick, and needs no wire-API or auth
flags of our own — the built-in provider already has the right ones.

Threads created while an older build declared a custom provider stay hidden until
that value changes, in the state DB **and** in the session's `rollout-*.jsonl`
(`session_meta.model_provider`), which `thread/list` scans to repair metadata.

Launching is quiet on purpose. The app's console does not reach the terminal, and
only its failures reach `~/.sharerouter/capture/gui.log` — unfiltered it was 101 KB
after twelve seconds of one launch, which would fill a disk on an app left running.
Measured on one launch: 26 of 808 lines kept, 5.8 KB of 291 KB. The filter is
case-sensitive on purpose (`errorCode=null` appears on nearly every routed response,
so matching "error" loosely keeps 244 lines of nothing) and it also keeps
`stdio_transport_spawned`, the one line that records which binary the app spawned.
The file rotates to `gui.log.1` past 1 MB. On this path there is no proxy to keep
alive, so the launch detaches and hands the shell straight back.

**Quit the app before launching it here.** It is single-instance: a second launch is
handed to the process already running ("Opening in existing browser session") and the
new one exits at once, so the environment `run` just set is discarded and the running
instance decides whether anything is captured. `run` reads that state first and says
which case you are in — `already running under capture` and it does nothing, or it
refuses and asks you to quit. It never claims a capture it did not set up.

The bundle on disk is called **ChatGPT.app** even though the app is Codex; the
identifier is `com.openai.codex` and OpenAI ships it under that file name. Nothing
here renames anything — the only file this tool writes is the shim under
`~/.sharerouter/capture/`. Output names both, so a resolution never reads as a swap:

```
desktop app: codex → ChatGPT.app  ·  com.openai.codex
```

Verified end to end on `com.openai.codex` build 26.721.81911 (app-server
0.146.0-alpha.3.1): the app's own log reported
`stdio_transport_spawned executablePath=~/.sharerouter/capture/codex_app_server_shim.sh`,
the app-server ran with `-c openai_base_url="…/token/…"`, its thread list returned the
25 existing threads, and a real turn through it produced one `relay.request` — agent
`codex`, 26.0% input compression.

### Other Chromium / Electron apps

A packaged Electron app is not a CLI. Measured: it ignores
`NODE_OPTIONS --require` outright, so the preload hook that captures the CLI agents
never loads, and its network goes through Chromium, which reads neither
`NODE_EXTRA_CA_CERTS` nor `SSL_CERT_FILE` — it uses the system trust store.

So nothing is installed. Chromium gets two switches: `--proxy-server` pointing at the
capture proxy, and `--ignore-certificate-errors-spki-list` carrying the SHA-256 of our
CA's public key. Chromium then trusts **exactly that one key**, for **that launch
only**, and keeps validating every other certificate normally. Nothing enters the
system keychain and no other application is affected. This is deliberately not
`--ignore-certificate-errors`, which turns validation off wholesale.

Verified with headless Chromium: every request, `https://example.com` included, was
decrypted and read by the proxy, and the keychain was still clean afterwards.

Two things to know:

- **Never launch through `open`.** `open` hands the launch to launchd, which starts
  the app in its own environment and drops both the switches and the environment.
  `run` refuses an `open` target for this reason.
- **The app must forward unknown arguments to Chromium.** Most Electron apps do.
  One that parses argv strictly will ignore the switches, and then nothing is
  captured — the proxy will simply see no traffic rather than fail loudly. Google's
  own hosts are pinned and will refuse the local CA; that shows up in the log as
  `not_captured`, which is accurate.

## Certificates: the CA stays on this machine

The signing key is **never** distributed and the backend holds no CA at all.
`ensure_ca()` generates a CA per machine on first use, into
`~/.sharerouter/capture/ca.key` with mode `0600`; it is referenced by path only and
appears in no request payload. `ensure_host_cert` serves the leaf **and** the CA, so a
client that pins our public key sees the full chain — an SPKI allowlist only matches
certificates actually presented.

Signing centrally instead would be strictly worse, so it is deliberately not done:

- One CA whose private key we hold, trusted on every user's machine, turns a single
  breach of ours into the ability to impersonate any site to all of them. A
  per-machine CA confines that blast radius to one laptop.
- Minting leaves on demand would send us a CSR per visited hostname — a live browsing
  log for hosts we otherwise never learn about.
- It would put a network round trip in the TLS handshake path, so `run` would stop
  working offline or on a flaky link.

## In the agent's own status line (Claude Code)

While `run -- claude` is going, the bottom of Claude Code carries one figure: how much
input this window's requests were compressed by, coloured on the same scale as the
bars in `stats`, right-aligned on the row above the agent's own hints.

```
  input compression 46.0% of 23.1K sent
 ⏸ manual mode on · ← for agents
```

Left edge, in the same column as the hints under it: Claude Code gives us a row of our
own there, and pushing the line to the right only made the two rows look unrelated.
Cursor, which has no spare row, is the case that still needs the far edge — see below.

It is named the way the `stats` header names it, and it carries the count it was
measured on. A bare ratio invites "of what?" — and 46% off a thousand tokens and 46%
off a million are the same percentage and not the same result. Only the percentage is
coloured; the words and the count stay grey.

**Tokens are summed, percentages are not averaged.** The figure is
`(input − compressed) / input` over every model request this window made, so a large
request counts for more than a small one. On one measured window — three requests of
576, 17 940 and 4 581 input tokens — that is 46.0%, while the mean of the three
per-request percentages (0.0, 58.7, 1.9) would have said 20.2%. Same requests, and one
of those two numbers is not a compression ratio.

Non-model traffic the proxy relayed — telemetry, plugin checks — is counted separately
and kept out of the ratio; it has nothing compressible in it, and including it dragged
the headline down for work the pipeline was never asked to do.

**This window, not the workspace.** Several agent windows can share one API key, so
neither time nor key can tell them apart: each `run` mints a launch id, every report it
makes carries it in a header, and the figure comes from
`/api/capture/stats?launch=<id>`. A day total would be a different claim wearing the
same percent sign. The ratio is all that is shown — token counts are in `stats`, and a
second set of numbers on that row would compete with the agent's own.

A window that has not sent anything says `no requests yet` rather than `0.0%`, which
would read as "compression did nothing", and an unreachable backend says so. Both in
grey, so nothing dim ever looks like a measurement.

**An absence of data is cached for one second, a measurement for five.** They shared a
TTL once, and the window's first request landed inside it: the agent re-ran the command
when the turn finished, got the cached "no requests yet", and had no reason to ask
again — so a window that had just compressed a request went on claiming it had sent
none. A measurement can be five seconds old without misleading anyone; the absence of
one cannot. The row still only refreshes when the agent decides to re-render it, which
is its call, not ours — the guarantee here is that the next render tells the truth.

Nothing of the user's is edited: Claude Code's `statusLine` is passed for that launch
only, with `--settings`, so `~/.claude/settings.json` is untouched and nothing is left
behind afterwards. A `--settings` of their own is left alone, and a `statusLine` they
already configured is **run first** and kept on the left — ours takes the right edge,
or stands down if the row is too narrow for both. The command is re-run on every
render, so the report is cached per launch: ~40 ms and no network per render.

**The agent's own hints row cannot be shared.** `statusLine` takes a `type` and a
`command` and nothing else — no position — and the binary has no setting that hides or
moves the `⏸ manual mode on` row. Ours therefore sits directly above it.

Writing into that row from outside is possible and is not done, for reasons that are
mechanical rather than aesthetic. The agent owns the terminal: it repaints with
absolute cursor moves (`ESC[41;1H` and friends — 44 of them in 26 idle seconds of one
measured session) and it repaints on every keystroke, spinner tick and stream chunk. A
second writer on the same tty has no lock, so our bytes can land between two bytes of
one of its escape sequences and corrupt the frame; and whatever survives is erased by
the next repaint, which is the flicker. Doing it properly means owning the terminal
instead — running the agent in a pty and reserving a row, which is what tmux does, and
then rewriting the agent's absolute cursor moves so they cannot address the reserved
row. That is a terminal emulator, and a fragile one for any agent that paints
differently. If you want the number on a single reserved row today, run the agent
inside tmux and put `sharerouter-capture stats --json` output in tmux's status line:
tmux already owns the terminal and reserves that row correctly.

Three details of the row are measured, not documented, and pinned by tests: it is
indented two columns, leading whitespace is trimmed (a no-break space counts as
whitespace to JavaScript's `trim()`, so padding alone vanishes), and ANSI
cursor-movement escapes are stripped while colour codes pass through. Right alignment
therefore uses a zero-width space as an anchor, then no-break padding.

### cursor-agent

Nothing to install. `run -- cursor-agent` puts the entry in place and takes it out again
when the run ends, so the figure appears without anyone configuring anything and the
config is as it was afterwards.

cursor-agent has a status line of its own kind — `{type, command, padding,
updateIntervalMs, timeoutMs}` in `cli-config.json` — but, unlike Claude Code, no
per-launch override, so that file is the only way into its footer. Which makes the
restore the important part: cursor rewrites that file itself while it runs (model choice,
approval mode, its own caches), so only the `statusLine` key is touched on the way out —
writing back a copy taken before the launch would silently undo whatever changed during
the session. A status line configured by someone else is left alone and ours is not added
at all; one appearing mid-run wins too.

`sharerouter-capture status-line` prints where the figure comes from for each agent, and
clears the entry if a run was killed before it could. Nobody needs to run it.

```
  Composer 2.5 Fast · 6.3%                              input compression 55.2% of 1.4K sent
```

**Cursor's own footer is rebuilt, not taken.** Measured: with a `statusLine` configured,
cursor-agent stops painting `Composer 2.5 Fast · 6.3%` and paints ours in its place —
trading the model and the context percentage for a compression figure would be a bad
deal. Its payload carries both, so they go back on the left. That payload also states
`render_width_chars`, which is what the right edge is measured against here, and what
tells cursor's payload apart from Claude Code's: a status line the user configured for
Claude is never run under cursor, where it would print one agent's state inside another.

### Menu bar (macOS)

Every `run` also puts the figure in the menu bar, beside the clock, for as long as the
agent is running:

```
                                                    ⌁ 23%   🔋  Wi-Fi  Thu 01:24
   ┌────────────────────────────────────────────────────────────────┐
   │  Running now · 2 windows · 22.7% together                      │
   │    codex      ██────────   22.7%  ·   91.2K sent  ·     5 req  │
   │    claude     ··········  no requests yet                      │
   │  Today                                                         │
   │    all agents ████──────   35.6%  ·    2.5M sent  ·   181 req  │
   │    cursor     ███████───   66.2%  ·   44.3K sent  ·    13 req  │
   │  Open ShareRouter console                                      │
   └────────────────────────────────────────────────────────────────┘
```

The request count is there because a total without one is misread. That 91.2K was five
model calls in a single desktop turn — 4.4K, 13.1K, 25.2K, 15.3K and 33.2K input tokens —
since each call resends the agent's instructions, its tool definitions and the history so
far. Shown as "91.2K sent" alone it looks like one impossibly large request.

Colour belongs to the bar, on the scale `stats` already uses — green over 50%, blue over
25%, amber below, grey for nothing — muted a quarter towards grey, because at full
saturation in a small menu a scale reads as a row of alerts. The figures themselves stay in
the label colour: they are readings, and a coloured number competes with the bar for the
same job. A window that has sent nothing gets a dotted track rather than an empty bar, which
a measured zero would look like.

Getting colour into a menu took one non-obvious thing: AppKit dims a **disabled** item's
title even when the attributed string names a colour, which is why every figure first came
out grey as though switched off. The rows are enabled instead, with `autoenablesItems` off
so nothing is clickable, and they draw at full strength. System colours rather than fixed
ones, since the menu is drawn in whichever appearance the user runs.

**Two agents at once:** the title is the windows **together**, summed over tokens like every
other figure here. Picking one would make it depend on which agent started first, and
averaging the percentages would let a 1K window count as much as a 100K one — 10% of 100K
and 90% of 1K is 10.8% together, not 50%. The header says how many windows and the menu
lists each one, so the aggregate is never the only thing on offer.

This is the answer for the desktop apps, whose own UI has no place for us: Codex's message
list is its own React tree, reachable only by attaching a debugger and rewriting its DOM —
fragile, a security downgrade, and not ours to edit. A status item sits beside them
instead, needs no permissions, and serves every agent the same way. The title carries the
running window when there is one, because a menu bar has room for one number and the one
worth having is "is it working right now".

Nothing to install and nothing to run: `run` starts it and it follows the agent's own
pid, which is what makes it work for a desktop launch — the wrapper hands the shell back
immediately, so the indicator cannot be a child that dies with it.

It is a ~110-line Swift file compiled on first use into `~/.sharerouter/capture/indicator`
(~110 KB, no Python dependencies; a prebuilt binary in a wheel would arrive unsigned and
Gatekeeper-quarantined). Without Xcode command line tools there is simply no indicator and
nothing is said about it — the same number is in `stats --watch` and the console, so this
is a missing convenience, not a failure. The Swift process draws strings and nothing else:
it holds no key, opens no socket and parses no protocol, and is fed one JSON line per
refresh over stdin, so an update is a write rather than a restart and closing the pipe is
how it knows to go.

### codex and grok: at the end of the run, or live in a second pane

Neither has a status line to write into. codex has a `tui.status_line`, but it selects
from its own fixed items (`account`, `model`, `reasoning_effort`, `usage`) and takes no
text of ours. grok has none at all — its whole `[ui]` config is one option,
`max_thoughts_width`; its hooks are lifecycle scripts whose output is not a footer, and
its announcements channel carries xAI's own notices through an undocumented internal
shape, which is not somewhere our numbers belong. Painting into either TUI from outside
would mean two writers on one tty with no lock, and every frame they paint erasing ours.

A one-shot run therefore reports as it finishes, which is where `-p` and `exec` users
were already looking. An **interactive** session shows nothing until it exits, so for that
there is a second pane:

```bash
sharerouter-capture stats --watch     # redraws every 2s; --watch 5 for slower
```

```
  RUNNING NOW
    grok     ██████████████────────   64.1%     16.4K of   25.6K  ·  3 req

  Past 24 hours ...
```

`RUNNING NOW` is the window that is actually on screen, not the day's total: each `run`
leaves a marker naming its launch, which is the only way a second terminal can tell one
window from another. Several windows are several rows, and two of the same agent are told apart
by the directory each was started in — `claude · compressor-ai` beside `claude · grigory` —
added only when it disambiguates, since one window needs no explaining. The marker is removed when the run ends, and one left behind by a
killed wrapper is pruned on sight — its pid is checked, not trusted, because a stale row
would describe a window nobody is looking at.

So they get the same figure once, after the agent exits and the terminal is ours again:

```
sharerouter · input compression 48.6% of 19.1K sent · 1 request
```

For `codex exec` and `grok -p` that lands directly under the answer, and every agent
gets it — cursor and claude included, so a finished run always ends the same way.
It goes to stderr and only to a terminal, so piping an agent's output into a file or a
test gains nothing of ours. A run that captured nothing says so — silence there is
indistinguishable from capture quietly not working. Claude Code shows the figure live
and gets this line too, so every run ends with the same sentence.

### How one window is told from another

Per-window scoping needs the launch identified, and the agents reach ShareRouter in
three different ways, so it is carried three ways:

| Path | Agent | How the launch travels |
|---|---|---|
| Local proxy | claude | `x-sharerouter-capture-launch` on the report |
| Node hook | cursor | the same header, from the hook's own posts |
| Base URL, no interception | codex, grok | `/token/<key>/l/<launch>/…` in the URL |

codex and grok dial the relay themselves, so there is no request of ours to put a header
on — the id has to be in the URL they were configured with. The backend serves both URL
shapes, so an older capture keeps working; the id is bounded and alphanumeric, stored on
the event and compared, never executed.

## Statistics

```bash
sharerouter-capture stats          # or: statistics
sharerouter-capture stats --json   # the raw report
```

Measured input compression for the key in your profile, over the past day, week and
month, broken down by agent. Counts and percentages together: a percentage alone
hides whether it acted on a thousand tokens or a million, and a token count alone
says nothing about how much of the request was compressed away.

```
  Past 24 hours  ███████████·············  45.9%   216.3K saved of 470.8K in 255 requests
    claude     ███████·········  45.2%    133.2K of  294.9K · 142 req
    grok       ██████████······  61.8%     39.6K of   64.0K · 8 req
```

Everything is summed from the relay's own `relay.request` events for that key —
nothing modelled, nothing extrapolated. A window with no traffic says "no requests"
rather than showing 0%, which would read as "compressed nothing". Output is not
compressed and is not counted.

What happens under the hood:

- `connect` stores a ShareRouter workspace profile in `~/.sharerouter/capture/config.json`.
- `run` starts a local proxy on a random loopback port and injects proxy/CA environment variables only into the child process.
- HTTPS `CONNECT` traffic **to model endpoints** is terminated locally with a generated
  local CA, forwarded upstream, and mirrored to ShareRouter as redacted metadata. Every
  other host is tunnelled byte-for-byte and never decrypted: interception used to be
  attempted on every connection, and `github.com` refusing the local CA took the agent's
  request down with the handshake. The set is
  `anthropic.com`, `api.openai.com`, `chatgpt.com`, `api.x.ai`, `cursor.sh`; add to it
  with `SHAREROUTER_CAPTURE_MITM_HOSTS=host.example` for a model provider we do not know,
  since a model endpoint that is merely tunnelled would never be compressed.
- Sensitive request and response headers are redacted before events are sent.
- Native model traffic is classified as `cursor`, `codex`, `claude`, `grok`, `openai`, or `unknown`.
- Generation requests are tagged with `provider_protocol`, for example `cursor_connect_proto`, `openai_responses`, `anthropic_messages`, or `xai_responses`.
- Request and response bodies are not persisted raw by default in control-plane events, but SHA-256 fingerprints and redacted JSON previews are included when possible. That lets ShareRouter verify the intercepted body is byte-identical to the upstream body.
- If the control plane is unavailable, events are spooled locally in `events_spool.jsonl`; the user command still runs.

Fallback capture without changing `base_url`:

- `codex`: run the Codex command through `sharerouter-capture run -- ...`. Codex keeps its native auth/config; ShareRouter sees the OpenAI Responses request shape when the client honors proxy/CA environment variables.
- `claude`: run Claude Code through `sharerouter-capture run -- ...`. Claude keeps its native auth/config; ShareRouter sees Anthropic Messages traffic when the client honors proxy/CA environment variables.
- `grok`: run Grok Build through `sharerouter-capture run -- ...`. Grok keeps browser-auth or `XAI_API_KEY` auth; ShareRouter sees xAI Responses or chat-completions traffic when the client honors proxy/CA environment variables.
- Node-based clients also get a preload hook through `NODE_OPTIONS`, so direct `http`, `https`, `http2`, and `fetch` calls are mirrored even when proxy-only capture misses details.

For local development without installing:

```bash
cd sharerouter_capture
python3 -m sharerouter_capture connect --workspace-key "sr_xxxxx"
python3 -m sharerouter_capture run -- python3 -c "print('wrapped')"
```

Run the smoke test:

```bash
cd sharerouter_capture
python3 tests/smoke_test.py
```

The smoke test starts a local HTTPS upstream and a local mock control plane, sends real HTTPS requests through the capture proxy, verifies the upstream response, verifies that the captured event redacts secrets, and verifies that Codex-style `/v1/responses`, Claude-style `/v1/messages`, and Grok/xAI-style `/v1/responses` request body hashes match the upstream body hashes.
