Skip to content

fix(security): poll usage only with the operator's own credential - #3863

Merged
chopratejas merged 6 commits into
mainfrom
fix/01-F16-poller-uses-operator-credential
Oct 2, 2026
Merged

chopratejas merged 6 commits into
mainfrom
fix/01-F16-poller-uses-operator-credential

Conversation

@chopratejas

@chopratejas chopratejas commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Two background usage pollers spent callers' credentials (finding 01-F16):

  • The Claude subscription tracker adopted any caller's OAuth Authorization: Bearer as the account to poll (notify_active stored it and _maybe_poll used it).
  • The Codex /wham/usage refresher polled with any caller's bearer and ChatGPT-Account-Id.

On a proxy shared by several callers, a network caller's credential was therefore sent on a request they never made, and their subscription usage appeared on the operator's dashboard.

What changes

  • New headroom/subscription/credential_policy.py with is_local_operator_connection(). It is true only for a direct loopback peer with no Forwarded, X-Real-IP or X-Forwarded-* header (any of the family, including -Proto and -Host alone). It fails closed on a missing peer. Trusted-gateway CIDRs do not count, because a gateway fronts other principals.
  • SubscriptionTracker.notify_active(token, *, from_local_operator=False) always marks the tracker active, but stores the bearer for polling only when from_local_operator is true.
  • maybe_schedule_usage_poll(headers, *, from_local_operator=False) returns without polling unless from_local_operator is true.
  • Both flags default to the safe answer, so a new call site cannot adopt a credential by accident. The Anthropic handler and the Codex WebSocket relay pass is_local_operator_connection(...).

Unchanged: _maybe_poll ordering, and the fallback to the operator's own credential (CLAUDE_CODE_OAUTH_TOKEN or the Claude Code credentials file) when no bearer has been learned. The Copilot quota poller already uses only the host's own GitHub token.

Behaviour change

  • Usage pollers never poll with a network caller's credential. A bearer learned from traffic comes only from a direct local caller; otherwise the pollers use the operator-configured credential as before.
  • A proxy fronted by a gateway on the same host no longer learns a token from gateway traffic. Set CLAUDE_CODE_OAUTH_TOKEN there if you want the subscription window.
  • A single developer running Claude Code on the same machine is unaffected, including when the OAuth token lives in the macOS Keychain rather than a file.

Tests

tests/test_subscription_poll_credential_policy.py:

  • The policy: IPv4 and IPv6 loopback callers are the local operator; LAN peers, a missing peer and each forwarding header are not, including X-Forwarded-Proto alone and X-Forwarded-Host alone.
  • The tracker: a network caller marks activity but is never adopted; a local operator's bearer is adopted; a foreign bearer never reaches a usage poll.
  • The Codex trigger is not driven by a network caller.
  • End to end through the real /v1/messages handler: a LAN caller's bearer is never adopted.

The tracker, foreign-bearer, Codex and handler tests fail on main. Existing tracker and Codex tests that assert adoption now pass from_local_operator=True, so they still test what they meant to.

Manual run: headroom proxy --host 0.0.0.0 with no Claude credentials on the host and the usage URL pointed at a local capture server. On main, a LAN caller's bearer was used for polling. On this branch only the loopback operator's bearer was used.

Not tested: a live Codex ChatGPT session end to end (the trigger is covered by unit tests).

Merge order

Part of a set. Suggested order: #3852 → #3891 → #3849 → #3860 → #3862 → #3863. This PR goes last. #3891 stops the proxy token being forwarded upstream; this PR stops the pollers adopting any foreign bearer. They touch different files.

Revised 2026-10-01 after self-review: removed the HEADROOM_SUBSCRIPTION_TRAFFIC_TOKEN setting and reverted the poll re-ordering, so the change is limited to which callers' bearers are stored.
Revised 2026-10-01 after review: the local-operator check now rejects every X-Forwarded-* header, not just X-Forwarded-For, so a gateway sending only -Proto or -Host fails closed.

🤖 Generated with Claude Code


Devin Review

The Claude subscription tracker adopted any caller's OAuth bearer as the
account to poll, and the Codex /wham/usage refresher polled with any caller's
bearer and ChatGPT account id. On a shared proxy that spent a network caller's
credential on a request they never made and published their usage on the
operator's dashboard (01-F16).

New headroom.subscription.credential_policy decides who may drive a poll:
the operator-configured credential (CLAUDE_CODE_OAUTH_TOKEN or the proxy
user's Claude Code credentials file) always wins; a bearer learned from
traffic is adopted only from the local operator - a direct loopback peer with
no forwarding headers; HEADROOM_SUBSCRIPTION_TRAFFIC_TOKEN=off disables
learning from traffic entirely. Network callers still mark the tracker active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

PR governance

This PR does not yet satisfy the required template fields:

  • Missing required section Description.
  • Missing required section Type of Change.
  • Missing required section Changes Made.
  • Missing required section Testing.
  • Missing required section Real Behavior Proof.
  • Missing required section Runtime Rollout Safety.
  • Missing required section Review Readiness.
  • Check I have performed a self-review before requesting human review.
  • Check This PR is ready for human review or convert the PR back to draft.

Please update the PR body, or move the PR back to draft while it is still in progress.

@github-actions github-actions Bot added the status: needs author action Pull request body or readiness checklist still needs author updates label Sep 29, 2026
@codecov-commenter

codecov-commenter commented Sep 29, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 90.00000% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
headroom/subscription/credential_policy.py 85.00% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@github-actions github-actions Bot added the status: ci failing Required or reported CI checks are failing label Sep 30, 2026

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The local-operator predicate does not fail closed for all forwarding evidence supported by this proxy. On the exact head, a loopback connection with only X-Forwarded-Proto: https returns True, and one with only X-Forwarded-Host: public.example also returns True. Both headers are first-class inputs in headroom/proxy/forwarded_headers.py; a same-host gateway can emit either while omitting X-Forwarded-For, so a tenant bearer can still be adopted as the operator credential. Please reject at least the complete supported X-Forwarded-* triple (and add proto-only/host-only tests), or centralize the decision on the existing forwarding-header policy so future supported forwarding headers cannot reopen this boundary. The direct reproductions are is_local_operator_connection(loopback_conn_with_x_forwarded_proto) is True and the same for x-forwarded-host.

@github-actions github-actions Bot added the status: needs rebase Pull request branch is behind the base branch on files it also changes label Oct 1, 2026
chopratejas and others added 2 commits October 1, 2026 09:26
Adopting a bearer only from a direct loopback caller is the whole 01-F16
fix. HEADROOM_SUBSCRIPTION_TRAFFIC_TOKEN=off was a knob nobody asked
for, and re-ordering _maybe_poll so a credentials file outranks the
local operator's live session changed which account a single developer
sees without closing anything: a network caller's bearer is no longer
stored, and fetch(None) already falls back to the operator credential.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chopratejas and others added 2 commits October 1, 2026 15:13
The local-operator predicate only rejected Forwarded, X-Forwarded-For and
X-Real-IP, so a same-host gateway sending only X-Forwarded-Proto or
X-Forwarded-Host still let a tenant bearer be adopted as the polled
credential. Reject the whole X-Forwarded-* family so future forwarding
headers cannot reopen the boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chopratejas

Copy link
Copy Markdown
Collaborator Author

Thanks, good catch. Fixed in 739ce9b: is_local_operator_connection now treats Forwarded, X-Real-IP and any X-Forwarded-* header (prefix match, case-insensitive) as forwarding evidence, so proto-only, host-only, port-only and any future X-Forwarded-* header fail closed. Added proto-only, host-only, port-only and a real Starlette Headers case to test_network_forwarded_or_unknown_callers_are_not; those four fail on the previous head. Also merged current main.

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 2d764f3. Local-operator detection now fails closed for Forwarded, X-Real-IP, and the entire X-Forwarded-* family, including mixed-case Starlette headers; network/forwarded callers mark activity without donating their bearer to the poller. Local result: 66 focused tests passed and Ruff clean; one handler lifecycle test was blocked only by this workstation's unavailable Rust extension, while exact-head hosted test/CodeQL checks are green.

@github-actions github-actions Bot added the status: needs rebase Pull request branch is behind the base branch on files it also changes label Oct 2, 2026

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +40 to +53
client = getattr(conn, "client", None)
host = getattr(client, "host", None) if client is not None else None
if not isinstance(host, str) or not is_loopback_host(host):
return False
headers = getattr(conn, "headers", None)
if headers is not None:
try:
for name in headers.keys():
name = name.lower()
if name in _FORWARDING_HEADERS or name.startswith(_FORWARDING_HEADER_PREFIX):
return False
except Exception:
return False
return True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Loopback callers can replace the operator account

A non-operator reaching loopback without forwarding headers passes is_local_operator_connection and supplies the token used for usage polling. Loopback and header absence do not establish caller identity, so another local process can replace the dashboard's account.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

JerrettDavis pushed a commit that referenced this pull request Oct 2, 2026
## Why

`OAuth2Middleware` rewrote `Authorization` on every HTTP request
(findings 03-F4 and 03-F5). Routes the proxy answers itself (`/health`,
`/stats*`, `/metrics`, `/v1/compress*`, extension `/ext/*` routes and
the rest of the management surface) never go upstream. Yet each one
minted a token when the cache was cold, and an unreachable IdP turned
health probes and management calls into 502. Separately, the startup log
printed the full token URL, which can carry a query string or embedded
credentials, and the mint log printed the scope list.

## What changes

- The bearer is minted and injected only for requests that go upstream.
`LOCAL_ROUTE_PATHS` and `LOCAL_ROUTE_PREFIXES` list what the proxy
serves itself; a `/p/<project>/` prefix is stripped first with the
core's `split_project_path`. Everything else, including the provider
passthrough catch-all, is treated as upstream, so a new provider route
needs no change here. Only a new local route does.
- Startup log: `scheme://host` of the token URL instead of the full URL.
Mint log: the scope count instead of the list.
- Version 0.1.1 in both `pyproject.toml` and `__version__`. Plugin
`CHANGELOG.md` and README updated.

Inbound authentication is not this plugin's job. From headroom-ai 0.40,
extension middleware runs inside the `HEADROOM_PROXY_TOKEN` gate
(#3847), so an unauthenticated request never reaches it, and a client
may authenticate with `Authorization: Bearer <proxy token>` before this
plugin replaces that header with the upstream bearer.

## Behaviour change / upgrade notes

- Requires headroom-ai >= 0.40. The README says so.
- Requests to local routes no longer carry a minted bearer. Nothing used
it, but a log line or metric keyed on `Authorization` for those paths
will change.

## Tests

`plugins/headroom-oauth2/tests/test_scope_and_proxy_token.py`:
- Every local route, including a project-prefixed one, passes through
with `Authorization` untouched and no mint.
- Upstream routes, including a project-prefixed route, the passthrough
catch-all and `/`, get the minted bearer.
- The install log line carries only `scheme://host`.
- End to end through `create_app` from a non-loopback client with an
unreachable IdP: `/health` is 200; `/stats` with the proxy-token bearer
is 200; `/stats` with no credential is 401; an upstream route with the
proxy-token bearer gets 502 `upstream_auth_error` from oauth2, not 401
from the gate.
- End to end with a local IdP: the proxy-token bearer passes the gate
and is replaced by the minted bearer, `x-headroom-proxy-token` is not
added, and one mint happens. A request with no credential is refused
with 401 and costs no mint.

This plugin has no CI job yet (only `plugins/headroom-agent-hooks` is
path-filtered in), so these tests were run locally.

## Merge order

Part of a set. Suggested order: #3852 → #3891 → #3849 → #3860 → #3862 →
#3863. This PR is independent of the others; the plugin only needs a
core >= 0.40.

## Follow-ups

- Add a path-filtered CI job for `plugins/headroom-oauth2`.

Revised 2026-10-01 after self-review: removed the plugin's own
proxy-token handling (now done by the core gate) and the
`HEADROOM_OAUTH2_LOCAL_PATHS` setting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@chopratejas
chopratejas merged commit 65e63c0 into main Oct 2, 2026
45 of 46 checks passed
JerrettDavis added a commit that referenced this pull request Oct 5, 2026
…3860)

## Why

The proxy's request and token rate limits could be walked around by any
caller the proxy cannot authenticate (finding 01-F3). Each provider
handler keyed its buckets on a client-supplied header:

- **OpenAI:** an HMAC of the full `Authorization` / `api-key` value,
else one shared `"default"` bucket. Rotating the header gave a fresh
bucket per request, and all callers without a credential shared one
bucket.
- **Anthropic:** the first 16 characters of the key plus the peer. That
is mostly the common `sk-ant-api03-` prefix.
- **Gemini:** the first 20 characters of `x-goog-api-key`, with no peer
at all.

## What changes

- New `headroom/proxy/rate_limit_identity.py` with one rule for the
Anthropic, OpenAI and Gemini handlers. `rate_limit_identity()` returns a
plain string key:
- Every key starts with the peer from `resolve_client_ip`, which honours
`X-Forwarded-For` only from `HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS`
peers. IPv6 peers are grouped by `/64`; an IPv4-mapped IPv6 address
counts as the IPv4 peer.
- **Trusted** requests get one bucket per provider credential:
`peer:<addr>|cred:<hmac>`, an HMAC of the whole credential with a
process-local key, so bucket names hold no credential material. A
request is trusted if it presented a valid `HEADROOM_PROXY_TOKEN` or
came directly from loopback. A trusted-gateway peer is trusted to supply
the client address, not to vouch that the caller authenticated, so a
forwarded request without the token is untrusted and keyed by the
forwarded client address. A request from a gateway address that carries
no `X-Forwarded-For` is judged as a direct request, so a loopback
gateway CIDR does not change how direct localhost callers are keyed.
Distinct principals behind one address keep separate limits, as #3364
intended.
- **Untrusted** requests (no token, remote caller) are charged per peer
(`peer:<addr>`). The credential header is ignored.
- The security gate sets `request.state.proxy_authenticated = True` when
a caller presents a valid proxy token.
- The per-handler key code is removed (`_openai_rate_limit_key` and the
Anthropic and Gemini inline keys).

`rate_limiter.py` is unchanged, and so are token-limit semantics.

## Behaviour change

Requests the proxy cannot authenticate (no `HEADROOM_PROXY_TOKEN`,
remote caller) are now limited per client address. Changing the API-key
header no longer gives a new allowance. Authenticated and
direct-loopback callers keep one allowance per provider credential.
Behind a gateway, set `HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS` so each
forwarded client address gets its own limit; to keep one allowance per
credential there, callers must also send the proxy token.

The limiter is quota smoothing and abuse control for unauthenticated
callers. It is not a security boundary for authenticated callers, and
this PR does not make it one.

## Tests

- `tests/test_proxy/test_rate_limit_identity.py`: untrusted keys ignore
the credential; trusted keys are per credential and include the peer;
the whole credential is used, not a prefix; bucket names carry no
credential material; loopback is trusted; a request relayed by a
trusted-gateway peer (including one on loopback) is not trusted without
the proxy token; with `127.0.0.0/8` configured as a gateway, direct
loopback callers stay per credential, and a relayed request with an
unusable `X-Forwarded-For` still counts as relayed; IPv6 `/64` grouping;
IPv4-mapped addresses.
- `tests/test_proxy_openai_rate_limit_key.py`, end to end through
`create_app` with `--rpm 1`: authenticated and loopback callers with
distinct keys do not share a bucket; an unauthenticated remote caller
who rotates `api-key` or `Authorization`, or drops the credential, gets
429 on the second request; two different unauthenticated peers do not
share a bucket; through a trusted gateway (`10.0.0.2`, fixed
`X-Forwarded-For: 203.0.113.9`) a caller rotating `api-key` gets 429,
distinct forwarded clients do not share, and token-authenticated callers
stay per credential; with `127.0.0.0/8` as the gateway CIDR, two direct
loopback callers with different keys both pass while a caller relayed
through it with a fixed forwarded address gets 429 on the second key.
The unauthenticated cases fail on `main`.
- Existing rate-limiter and TPM suites pass unchanged.

Manual run: `headroom proxy --host 0.0.0.0 --rpm 2` with six requests
from a LAN address, each with a different `Authorization` key. On `main`
all six reach the upstream. On this branch without a proxy token, the
third and later get 429. With the proxy token sent, all six pass, one
bucket per credential.

Not tested: the Rust `headroom-proxy` binary, which has its own limiter.

## Merge order / conflicts

Part of a set. Suggested order: #3852 → #3891 → #3849 → #3860 → #3862 →
#3863. #3852 makes the untrusted case rare by refusing token-less
non-loopback binds; this PR makes it safe when an operator opts out of
that.

Textual conflict: #3862 adds an import next to the one this PR adds in
`handlers/anthropic.py` and `handlers/openai.py`. Whichever merges
second keeps both imports.

Revised 2026-10-01 after self-review: `rate_limiter.py` is back to
`main`; the separate trusted/untrusted pools, per-owner cap and overflow
bucket were removed.
Revised 2026-10-02 after review: gateway membership only drops the
credential from the key when the gateway actually forwarded a client
address, so direct localhost callers keep per-credential buckets under a
loopback gateway CIDR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/headroomlabs-ai/headroom/pull/3860"><picture><source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=4"><img
src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=4"
alt="Devin Review"></picture></a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
JerrettDavis added a commit that referenced this pull request Oct 5, 2026
#3852)

## Why

Four related gaps in the proxy's network gate (findings 01-F1, 01-F2,
01-F5, 01-F9):

- **Open bind.** A non-loopback bind with no `HEADROOM_PROXY_TOKEN` only
logged a warning, then served the `/v1/*` relay, with the operator's
upstream credentials, to every peer that could reach the port.
- **Missing peer address.** `is_loopback_host(None)` returned `True`. A
Unix-domain socket, an ASGI adapter that leaves `scope["client"]` unset,
or a hand-built request therefore turned off the token gate, the
`/admin` and `/debug` guards and the WebSocket gate.
- **Operator data routes.** `/stats-history` (including CSV export),
`/quota`, `/subscription-window` and `/metrics` were served to any
network caller.
- **WebSocket loopback exemption** checked only the peer IP, not the
`Host` header, so it had no DNS-rebinding defence.

## What changes

- New `headroom/proxy/bind_policy.py`. A non-loopback bind (`0.0.0.0`,
`::`, a LAN address or a hostname) with no token is refused at three
places: `create_app` (embedders and the multi-worker factory),
`run_server` (one exit with code 2 before uvicorn forks) and the
`headroom proxy` CLI (a clear error before the banner, exit code 1). An
absent host means uvicorn's default `127.0.0.1`.
- `HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1` acknowledges an open bind when
the runtime already restricts reachability, such as a container
published on `127.0.0.1`. The `proxy_open_bind` warning is still logged
on that path, and the banner says the bind is open.
- `is_loopback_host(None)` now returns `False` (fail closed), matching
`identity.py`.
- `/stats-history`, `/quota` and `/subscription-window` answer only: a
non-loopback caller that passed the token gate, a loopback caller (peer
and `Host` header both loopback), a trusted dashboard client, or, under
an acknowledged token-less bind, the container's own default-gateway TCP
peer with a loopback `Host` (the host browser reaching a
`127.0.0.1`-published container). Everyone else gets 404, as with
`/admin`. Settings writes keep their stricter rule with no token
short-cut.
- `/metrics` answers a non-loopback caller that passed the token gate, a
loopback caller, a connecting peer in
`HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS` (whatever it forwards), or a
resolved client in the dashboard CIDRs. For CIDR peers a hostname `Host`
is fine, so Prometheus scrapers work; a cross-origin browser request is
still refused.
- The token short-cut applies only to non-loopback requests. The gate
exempts loopback peers without checking `Host`, so a loopback request
has not proven it holds the token and must pass the loopback `Host`
check. This blocks a DNS-rebound browser on the operator's machine.
- The `/dashboard` shell stays ungated. It is a static template, like
the already public `/dashboard/static` assets. Gating it would break the
dashboard for containers published on host loopback, where the peer is
the bridge gateway and `Host` is `localhost`. The data routes behind it
are gated.
- The WebSocket loopback exemption requires a loopback peer and a
loopback `Host` header. A loopback peer with a foreign `Host` can still
connect with the token.
- Launchers that always publish on `127.0.0.1` set the acknowledgement
themselves: `headroom install` (docker preset), `scripts/install.sh` and
`scripts/install.ps1` (through one helper that adds the acknowledgement
only together with the `127.0.0.1` publication), and
`docker/docker-compose.native.yml`.
- Docs: `proxy.mdx` documents the new variable and which routes the
token also gates; `docker-install` (docs and wiki) and the
`docker-compose.yml` comment show the acknowledgement in the
loopback-published example. `e2e/docker-bind-security.sh` sets it and
checks that the same launch without it refuses to start.

## Behaviour change (breaking)

- `headroom proxy --host 0.0.0.0`, or `HEADROOM_HOST` set to any
non-loopback address, without `HEADROOM_PROXY_TOKEN` now refuses to
start. The error names both remedies. Set a token, bind loopback, or set
`HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1` when the port is already
confined. Anyone running the image by hand with `--host 0.0.0.0` and no
token must add one of these.
- Without a token, `/stats-history`, `/quota`, `/subscription-window`
and `/metrics` return 404 to non-loopback callers outside a trusted
CIDR. Prometheus scrapers on another host must be in
`HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS` or send the token.
- Code that relied on a request with no peer address being treated as
loopback now gets the non-loopback path.
- The default loopback run is unchanged.

## Tests

- `tests/test_proxy_bind_policy.py`: the refuse/allow matrix
(non-loopback, loopback, unset host, token, acknowledgement values), the
warning on the acknowledged path, `create_app`, the `run_server` exit
before uvicorn, and the CLI (refusal, `HEADROOM_HOST` from env,
acknowledged start, offline banner, start with a token).
- `tests/test_proxy_loopback_gating.py`: each data route against
non-loopback, loopback, trusted-CIDR and token callers; the dashboard
shell stays reachable; the host of an acknowledged, loopback-published
container reads the data routes while another bridge peer, a forwarded
gateway address and a foreign `Host` do not; CSV export is not served to
network callers; `/metrics` across loopback, both CIDR lists,
out-of-range peers, cross-origin browsers and token callers, including a
trusted gateway relaying a scraper outside its own range. A regression
test shows that a token on the proxy does not exempt a loopback peer
from the `Host` check; it fails without the fix.
- `tests/test_proxy_hardening.py`: the HTTP gate with no peer address,
WebSocket `Host` cases, `require_loopback` and `/debug` with no peer.
- Installer tests: `headroom install` adds the acknowledgement, and the
shell and PowerShell wrappers add it only together with the loopback
publication. Launcher-level tests start the proxy with the environment
and `--host` that `build_runtime_command()` and
`docker-compose.native.yml` produce and check that the host browser
(bridge-gateway peer) gets `/stats-history` and another container does
not.
- Tests that bound `0.0.0.0` for unrelated reasons now set a token or
the acknowledgement. Tests that read operator routes use an explicit
loopback peer.

On `main`, 14 of the gating and hardening tests fail and the bind-policy
module does not exist. Manual runs: token-less `--host 0.0.0.0` exits
with the refusal from both the CLI and `run_server`; with the
acknowledgement, the data routes are 404 to a LAN peer and to a loopback
peer with a foreign `Host`; with a token, a LAN peer gets 401 without it
and 200 with it; the default loopback bind starts as before.

Not tested: a real Unix-domain-socket deployment and a real Prometheus
scrape (both covered by tests only).

## Merge order

Part of a set. Suggested order: #3852 → #3891 → #3849 → #3860 → #3862 →
#3863. This PR goes first: it is the gate the others assume, and it is
the release-note event.

## Not in this PR

- Enforcing the token for loopback callers when one is configured.

Revised 2026-10-02 after review: `/metrics` matches gateway CIDRs
against the TCP peer, and the operator data routes serve the host of a
loopback-published container (bridge-gateway peer under the launchers'
bind acknowledgement) so the default Docker dashboard works.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/headroomlabs-ai/headroom/pull/3852"><picture><source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=4"><img
src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=4"
alt="Devin Review"></picture></a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
JerrettDavis added a commit that referenced this pull request Oct 5, 2026
…al (#3862)

## Why

On a proxy shared by several callers, the semantic response cache was
keyed only by request content and upstream (#3349). A second caller who
sent the identical request with a different provider key was served the
completion generated for the first caller, under the first caller's key,
and never reached the upstream. That discloses completions across
tenants (finding 01-F15).

## What changes

- `compute_cache_partition()` in
`headroom/proxy/semantic_cache_key_policy.py`: an HMAC-SHA256 under a
per-process random key over the caller's credential and account-selector
headers plus the authenticated principal. Headers counted: every header
the shared credential-header rule
(`internal_header_policy.is_credential_header`) matches, which covers
`authorization`, `proxy-authorization`, `cookie` and `x-api-key`, and
any header whose name ends in `api-key`, `key`, `token`, `secret`,
`account`, `account-id`, `organization` or `project` (so `x-api-key`,
`api-key`, `x-goog-api-key`, `chatgpt-account-id`,
`openai-organization`, `openai-project` and so on). Matching by name
shape keeps new providers partitioned by default. Cookie is used as a
whole value; it is only ever HMAC input. `x-headroom-*` headers
(including the proxy's own token, which the security gate has already
removed) and idempotency keys are excluded. A request with no credential
and no principal gets the shared `anon` partition.
- The partition is an HMAC, not a plain hash, so it cannot be used to
confirm a guessed credential.
- `identity.resolve_authenticated_principal()` returns a principal only
when an identity resolver is installed (`set_identity_resolver`). The
OSS default identity is the same for every network caller and can be
chosen by header on loopback, so it is not treated as a principal.
- If an installed identity resolver raises or returns no principal, the
request fails closed (`resolve_authenticated_principal()` raises
`UnresolvedPrincipalError` for an empty result):
`compute_request_cache_partition()` logs a warning and returns `None`,
and both handlers skip the response cache for that request (no lookup,
no store). It never falls back to the credential-only partition, which
tenants on one operator key would share. With no resolver installed, the
credential-only partition is unchanged.
- Both handlers compute the partition only when the response cache can
be used (cache enabled and non-streaming), so streaming and
cache-disabled requests do not call the identity resolver.
- `partition` is a required keyword on `SemanticCache.get`, `set` and
`_compute_key`, and on `compute_semantic_cache_key`. A future call site
that forgets it fails immediately instead of silently sharing.
- The `/v1/messages` and `/v1/chat/completions` handlers compute the
partition once, next to the existing cache-key snapshot, and reuse it at
store time so lookup and store agree.

Other caches in the request path are not changed. The OpenAI Responses
unit cache and the CCR retrieval store are content-addressed, so a hit
requires already holding the content. CCR entries are not bound to a
caller; that is a separate issue. The `/v1/compress` pipeline cache
holds no user content, and the SDK `SemanticCache` is in-process for one
caller.

## Behaviour change

The proxy response cache no longer shares entries between callers that
present different provider credentials or, with an identity resolver
installed, different principals.

- One developer with one key: same hit rate.
- Deployments where callers send no provider credential and the proxy
supplies the operator's key: same hit rate; they share the `anon`
partition.
- Shared proxy with per-caller keys: entries are no longer shared
between keys. That is the fix.
- Compatible upstream gateways that authenticate by `Cookie` or
`Proxy-Authorization`: entries are no longer shared between sessions.
- Callers whose only credential is a proxy-token `Authorization` header,
or deployments using oauth2, share one partition unless an identity
resolver is installed, because core cannot tell them apart.

## Tests

`tests/test_proxy_response_cache_partition.py`:
- The partition function: differs per credential and is stable for one
credential; every credential and account header shape partitions; other
headers do not fragment the cache; no credential and no principal gives
the shared partition; it is keyed, not a plain hash; a principal
separates callers sharing one credential.
- `SemanticCache` refuses a call without a partition and keeps
partitions apart.
- End to end through the real Anthropic and OpenAI handlers: a second
caller is never served the first caller's cached response, whether the
credential is `x-api-key`/`Authorization`, `Cookie` or
`Proxy-Authorization`. The `Cookie` and `Proxy-Authorization` cases fail
without the latest change. With an identity resolver installed, callers
on one operator key are partitioned. These end-to-end tests fail on
`main`.
- A resolver that raises, or returns an empty principal, yields no
partition, and end to end (both handlers) callers on one operator key
never share a cached response and nothing is stored. These fail without
the fail-closed change. With no resolver installed, the partition is the
credential-only one.
- With the response cache disabled, neither handler calls the identity
resolver.

Existing cache tests now pass a fixed partition. The buffered-stream CCR
test derives the same partition the handler computes.

Manual run: two callers send the identical `/v1/messages` request with
different `x-api-key` values through a proxy in front of a capture
upstream. On `main` the second caller gets the first caller's answer and
only the first key reaches the upstream. On this branch each caller gets
its own answer, and a repeat by the first caller is still a cache hit.

Not tested: multi-worker deployments (the cache and its key are per
process, as before).

## Merge order / conflicts

Part of a set. Suggested order: #3852 → #3891 → #3849 → #3860 → #3862 →
#3863.

Textual conflict: #3860 adds an import next to the one this PR adds in
`handlers/anthropic.py` and `handlers/openai.py`. Whichever merges
second keeps both imports.

Revised 2026-10-04 after review: forwarded `Cookie` and
`Proxy-Authorization` headers are now partition input (via the shared
credential-header rule), so cookie-authenticated callers no longer share
the anonymous partition.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/headroomlabs-ai/headroom/pull/3862"><picture><source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=4"><img
src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=4"
alt="Devin Review"></picture></a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
JerrettDavis added a commit that referenced this pull request Oct 6, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.40.0](v0.39.1...v0.40.0)
(2026-10-06)


### ⚠ BREAKING CHANGES

* **proxy:** read HEADROOM_LICENSE and make usage reporting opt-in
([#3857](#3857))
* drop the crewai extra to remove chromadb from the lockfile
([#3870](#3870))

### Features

* **compress:** add per-message compression diagnostics
([#3058](#3058))
([fef99cc](fef99cc))
* **compress:** live-agent densify mode, lossless and cache-safe
([#1402](#1402))
([84f0e84](84f0e84))
* **dashboard:** add CO₂ Saved card to dashboard and /stats API
([#1369](#1369))
([2bc9419](2bc9419))
* **dashboard:** give savings metrics one canonical home
([#3320](#3320))
([6af7efe](6af7efe))
* **learn:** add agy (Antigravity CLI) as an analysis backend
([#3939](#3939))
([4ba231a](4ba231a))
* **live-zone:** wire the SourceCode and PlainText dispatch arms
([#3227](#3227))
([49f69be](49f69be))
* **memory:** persist TrafficLearner pending evidence across restarts +
expose learner stats
([#3104](#3104))
([4257ed4](4257ed4))
* **proxy:** add x-headroom-keep-last-turns header for per-request
context trimming (issue
[#2858](#2858))
([#3059](#3059))
([246162d](246162d))
* **proxy:** route Gemini plugin traffic through native transforms
([#2697](#2697))
([0ad996a](0ad996a))
* **proxy:** serve on a Unix domain socket (--uds)
([#3151](#3151))
([e7b3baf](e7b3baf))
* **wrap:** add headroom wrap bob for IBM Bob CLI
([#3801](#3801))
([0b7dab4](0b7dab4))
* **wrap:** extend reduce-at-source quiet defaults to telemetry/nag
banners
([#2550](#2550))
([3a3a465](3a3a465))


### Bug Fixes

* **anthropic:** preserve failed CCR continuations
([#3843](#3843))
([94bb055](94bb055))
* **backends/anyllm:** map Anthropic tool_choice none to OpenAI none
([#3963](#3963))
([d722a64](d722a64))
* **backends/litellm:** align streaming usage tokens with the
non-streaming path
([#2688](#2688))
([90a68e0](90a68e0))
* **backends/litellm:** keep the upstream 4xx status on send_message
errors
([#3944](#3944))
([e0e41cd](e0e41cd))
* **backends/litellm:** map Anthropic tool_choice none to OpenAI none
([#2689](#2689))
([57a5708](57a5708))
* **backends/litellm:** match the caller-key Bearer scheme
case-insensitively (RFC 7235)
([#3965](#3965))
([848d7a0](848d7a0))
* **backends/litellm:** report requested model in OpenAI streaming
chunks
([#2690](#2690))
([8538a83](8538a83))
* **cache:** keep prefix lineage across a replaced system tail
([#3934](#3934))
([16eaec1](16eaec1))
* **ccr:** answer headroom_retrieve on the direct chat path instead of
forwarding it
([#3816](#3816))
([126e144](126e144))
* **ccr:** inject headroom_retrieve before the prefix is warm, not after
([#3810](#3810))
([bb8c285](bb8c285))
* **ccr:** only proactively expand compressions present in the
requesting conversation
([#3924](#3924))
([9b9a883](9b9a883))
* **ccr:** unwrap Hermes batched tool_call so headroom_retrieve stays
exempt
([#3839](#3839))
([2f07668](2f07668))
* **ci:** clear dependency audit and gate Docker publishing
([#3984](#3984))
([67ce7d0](67ce7d0))
* **ci:** keep hard watchdog out of pytest shards
([#3845](#3845))
([2527431](2527431))
* **ci:** tolerate missing Docker cache blobs
([#3945](#3945))
([540f4da](540f4da))
* clarify CCR marker content preservation
([#3175](#3175))
([005a4e1](005a4e1))
* **cli:** honor CODEX_HOME and align wrap/init/doctor with the live
proxy port
([#3855](#3855))
([5bf6612](5bf6612))
* **cli:** strip unmarked headroom_memory TOML table before injecting
([#3490](#3490))
([08dda4c](08dda4c))
* **codex:** explain why wrapped Codex runs without the shared server
([#3899](#3899))
([1982b3a](1982b3a))
* **codex:** preserve remote compaction in init config
([#3410](#3410))
([6df4a96](6df4a96))
* **codex:** resolve per-turn project context
([#2636](#2636))
([0396ea2](0396ea2))
* **codex:** strip stale compression framing on the Responses subpath
passthrough
([#3792](#3792))
([0eba65c](0eba65c))
* **copilot:** defer keychain auth lookup
([#2739](#2739))
([81a8a28](81a8a28))
* **copilot:** read timezone-naive token expiry as UTC, not host local
time ([#3216](#3216))
([2b2dc1b](2b2dc1b))
* **copilot:** warn when a VS Code profile cannot see the proxy settings
([#3919](#3919))
([7b68fc2](7b68fc2))
* **deps:** patch brace-expansion in wrap E2E lockfile
([#3873](#3873))
([5e0435d](5e0435d))
* **deps:** patch urllib3 and Next.js advisories
([#3896](#3896))
([573e385](573e385))
* **doctor:** recognize Azure Foundry routing for Claude Code
([#1339](#1339))
([d5318ac](d5318ac))
* **grok:** route CLI traffic to api.x.ai on shared proxies
([#2693](#2693))
([ef1c528](ef1c528))
* honor configured port in container startup and healthcheck
([#2436](#2436))
([1b7977c](1b7977c))
* **install/apply:** add --no-rate-limit flag, persist in proxy_args
([#1350](#1350))
([#1365](#1365))
([31d0344](31d0344))
* **install:** parse Windows Task Scheduler XML output
([#3830](#3830))
([c32a4f4](c32a4f4))
* **install:** replace the launchd wrapper with the proxy listener
([#3224](#3224))
([44db755](44db755))
* **install:** use safe model backends for persistent services
([#3646](#3646))
([7287589](7287589))
* **integrations:** record metric timestamps in UTC, not naive local
time ([#3117](#3117))
([76ef2c3](76ef2c3))
* **kompress:** degrade when a native dep is installed but unloadable
([#3133](#3133))
([8f3d677](8f3d677))
* **kompress:** pin ModernBERT tokenizer and encoder revisions
([#3808](#3808))
([2de5828](2de5828))
* **learn:** drop the echoed prompt from a failed cli's error
([#3928](#3928))
([f00d425](f00d425))
* **learn:** keep CLAUDE.local.md out of git via .git/info/exclude
([#3108](#3108))
([c072251](c072251))
* **learn:** keep transcript-derived content inside the managed block
([#3850](#3850))
([c46e74d](c46e74d))
* **learn:** merge git worktree sessions into the repo's project
([#3854](#3854))
([3fdf18e](3fdf18e))
* **learn:** preserve stable traffic pattern items
([#2293](#2293))
([1631cde](1631cde))
* **learn:** recognize escaped persisted pattern IDs
([#3951](#3951))
([2e74f06](2e74f06))
* **learn:** run the claude-cli analysis with hooks disabled
([#3926](#3926))
([2a4d34c](2a4d34c))
* **learn:** run the claude-cli analysis with no tools
([#3892](#3892))
([db90b93](db90b93))
* **log_compressor:** keep and name pytest short-summary failures
([#3828](#3828))
([d90b320](d90b320))
* make Headroom work behind Zscaler and other TLS-inspecting networks
([#3831](#3831))
([66258c4](66258c4))
* **mcp:** bound version-detection git subprocess
([#3038](#3038))
([0712e04](0712e04))
* **mcp:** escape control characters when rendering TOML server blocks
([#3964](#3964))
([a96154f](a96154f))
* **mcp:** keep other apps' tables when replacing the Codex/Grok MCP
span ([#3877](#3877))
([d0fd56e](d0fd56e))
* **mcp:** resolve opencode.jsonc for MCP registration
([#2496](#2496))
([f824a27](f824a27))
* **memory:** align project routing with wrap headers
([#3603](#3603))
([b1b005a](b1b005a))
* **memory:** avoid injecting tools into tool-free requests
([#3677](#3677))
([46755b5](46755b5))
* **memory:** close SQLite connections in memory/fts5/graph adapters
([#3153](#3153))
([231a627](231a627))
* **memory:** handle list-shaped system content in inline memory
injection
([#3794](#3794))
([717527b](717527b))
* **memory:** ignore leading cd prefix when pairing Bash error
recoveries
([#3776](#3776))
([143a38d](143a38d))
* **memory:** pin LF on memory writers and guard the learn-writer
newline contract
([#3706](#3706))
([b10dd8d](b10dd8d))
* **oauth2:** mint and inject only on requests that go upstream
([#3849](#3849))
([f977d52](f977d52))
* **offline:** make HEADROOM_OFFLINE a real air-gap via one chokepoint
([#3729](#3729))
([119d1a1](119d1a1))
* **opencode:** hide spawned Windows console windows
([#3743](#3743))
([2157400](2157400))
* **opencode:** route only LLM traffic through Headroom
([#3884](#3884))
([d75eecd](d75eecd))
* **output-savings:** seed the holdout key on the whole first user
message
([#3209](#3209))
([117ff72](117ff72))
* **parser:** stop counting HTML comments twice in waste signals
([#3942](#3942))
([d0e9c4c](d0e9c4c))
* **parser:** whitespace waste signal always reported zero
([#1102](#1102))
([f19bc9a](f19bc9a))
* **plugin:** resolve hook CLI through plugin-root launcher
([#3053](#3053))
([ef7605f](ef7605f))
* **plugins/openclaw:** return messages the proxy did not change exactly
as they came in
([#3826](#3826))
([d1ad189](d1ad189))
* prevent HF tokenizer downloads in offline mode
([#3783](#3783))
([d503c57](d503c57))
* **pricing:** add Claude Sonnet 5.5 / Opus 5.5 / Fable 5.1, correct
Sonnet 5 rates
([#3841](#3841))
([0d99c56](0d99c56))
* protect file reads in chained shell commands
([#2668](#2668))
([ffc3599](ffc3599))
* **providers/vertex:** Vertex route multi-region locations
([#3802](#3802))
([a9c1ac5](a9c1ac5))
* **proxy/anthropic:** keep tool_result blocks first when neutralizing
headroom_retrieve history
([#3874](#3874))
([ccd9fff](ccd9fff))
* **proxy/batch:** honor x-headroom-bypass on the batch paths
([#2570](#2570))
([9b26a49](9b26a49))
* **proxy:** add same-origin check to /v1/retrieve/tool_call
([#3955](#3955))
([2297b50](2297b50))
* **proxy:** bill the whole prompt on the gateway path so savings read
true ([#3632](#3632))
([befdb52](befdb52))
* **proxy:** carry safeguard_results through SSE resynthesis
([#3958](#3958))
([f37ef59](f37ef59))
* **proxy:** classify Pi Codex Responses alias
([#2583](#2583))
([a493f55](a493f55))
* **proxy:** do not cache error replies delivered as http 200
([#3930](#3930))
([1132a54](1132a54))
* **proxy:** drop a tool_reference naming the search tool itself
([#3172](#3172))
([b73adaa](b73adaa))
* **proxy:** F3 — per-tenant TOIN learning key
([#404](#404))
([f90a56b](f90a56b))
* **proxy:** forward operator-listed guarded upstreams through a proxy
([#3804](#3804))
([4b7e5d2](4b7e5d2))
* **proxy:** freeze and replay the forwarded prefix on Gemini paths
([#3394](#3394))
([#3865](#3865))
([dd84321](dd84321))
* **proxy:** gate the raw request body, not just its Content-Length
header
([#3338](#3338))
([c946b6b](c946b6b))
* **proxy:** hard watchdog that dumps and exits when a native call
seizes the GIL
([#3180](#3180))
([79daeb5](79daeb5))
* **proxy:** inject memory context past a trailing system message
([#3948](#3948))
([3948dbe](3948dbe))
* **proxy:** keep cache_control outside content blocks where the Chat
Completions client put it
([#3895](#3895))
([a6d6c14](a6d6c14))
* **proxy:** keep exception text and the proxy token out of client
output
([#3851](#3851))
([861e94d](861e94d))
* **proxy:** keep the newest user message verbatim in cache-mode delta
compression
([#3923](#3923))
([613ae92](613ae92))
* **proxy:** key rate limits by peer unless the caller authenticated
([#3860](#3860))
([6fb7cad](6fb7cad))
* **proxy:** kill the image worker a timed-out call abandons
([#3940](#3940))
([793bb85](793bb85))
* **proxy:** label plain OpenAI chat traffic openai, not custom
([#3912](#3912))
([7df8bd8](7df8bd8))
* **proxy:** log response_content_length in proxy_inbound_response
([#2701](#2701))
([cfa479a](cfa479a))
* **proxy:** preserve Bedrock body-limit error dialect
([#3871](#3871))
([ffc6edb](ffc6edb))
* **proxy:** preserve Windows service when Rust core is blocked
([#2989](#2989))
([eaa16d9](eaa16d9))
* **proxy:** quarantine compression only once half the pool is stuck
([#3932](#3932))
([be2b205](be2b205))
* **proxy:** read HEADROOM_LICENSE and make usage reporting opt-in
([#3857](#3857))
([7460389](7460389))
* **proxy:** reap wrap-spawned proxies once no wrap clients remain
([#3202](#3202))
([4227bd2](4227bd2))
* **proxy:** redact upstream error detail and add opt-in /metrics
loopback gate
([#2589](#2589))
([a05717f](a05717f))
* **proxy:** refuse token-less non-loopback binds, gate operator routes
([#3852](#3852))
([ee731d7](ee731d7))
* **proxy:** reset the cc-switch upstream when Claude Official is
selected
([#3166](#3166))
([fed7281](fed7281))
* **proxy:** run extension middleware inside the security gate and body
ceiling
([#3847](#3847))
([1854fd7](1854fd7))
* **proxy:** run injected memory tools server-side on streaming turns
([#3947](#3947))
([1cf4966](1cf4966))
* **proxy:** share one compression deadline across a Responses request
([#3938](#3938))
([4d27d02](4d27d02))
* **proxy:** skip pricing lookup for passthrough:* models
([#2585](#2585))
([f87848c](f87848c))
* **proxy:** stop headroom logger from suppressing propagation to
stdout/stderr
([#3096](#3096))
([f78e66f](f78e66f))
* **relevance:** bound segment size by max_chars
([#2518](#2518))
([0ef7b2a](0ef7b2a))
* **reporting:** price the savings tile instead of fabricating $0.00
([#3821](#3821))
([f519fa8](f519fa8))
* **router:** fall back to built-ins when an external compressor passes
through
([#3893](#3893))
([2c4b20f](2c4b20f))
* **router:** keep ccr_retrieve exemption through orchestrator wrappers
([#3915](#3915))
([6151ed1](6151ed1))
* **rust-proxy:** forward request paths verbatim and pin the rustls
provider
([#3853](#3853))
([9d98ea5](9d98ea5))
* **savings:** record tool-schema dollars disjointly beside the folded
headline
([#3170](#3170))
([c719d4a](c719d4a))
* **savings:** stop scoring unobserved strata against the global mean
([#3128](#3128))
([f864525](f864525))
* **sdk:** keep tool names on Vercel tool-result parts through the
OpenAI round trip
([#3883](#3883))
([b715671](b715671))
* **sdk:** preserve Gemini media parts in message conversion instead of
dropping the turn
([#3882](#3882))
([038c923](038c923))
* **sdk:** stop JSON-wrapping structured tool_result content in the
Anthropic adapter
([#3797](#3797))
([2e4a60a](2e4a60a))
* **search:** stop a context line's body from becoming its line marker
([#3788](#3788))
([f3f2e00](f3f2e00))
* **security:** create memory stores and other state files owner-only
([#3848](#3848))
([5119b6e](5119b6e))
* **security:** exempt only GET health probes from the proxy token
([#3921](#3921))
([d99779d](d99779d))
* **security:** partition the response cache by credential and principal
([#3862](#3862))
([ee6cfcc](ee6cfcc))
* **security:** poll usage only with the operator's own credential
([#3863](#3863))
([65e63c0](65e63c0))
* **security:** stop forwarding the proxy token to upstream providers
([#3891](#3891))
([0147cf0](0147cf0))
* **settings:** report live proxy configuration
([#3177](#3177))
([58b1454](58b1454))
* **smart-crusher:** recurse at adaptive array limit
([#3770](#3770))
([7790bde](7790bde))
* **storage:** page JSONL queries after sorting
([#3872](#3872))
([91237ca](91237ca))
* **subscription:** match the Bearer scheme case-insensitively for the
Codex usage poll (RFC 7235)
([#3966](#3966))
([fe88461](fe88461))
* **subscription:** poll with the refreshed credentials-file OAuth token
([#3916](#3916))
([2dc9fc2](2dc9fc2))
* surface Codex responses traffic in dashboard
([#399](#399))
([ecc4967](ecc4967))
* **telemetry:** label custom-base chat upstreams from a fixed provider
set ([#3759](#3759))
([f0ec2bb](f0ec2bb))
* **thinking:** don't read the model's date suffix as its minor version
([#3791](#3791))
([afaaaa8](afaaaa8))
* **transforms/kompress:** preserve line boundaries and tabular output
in Kompress
([#3119](#3119))
([fe2ed2b](fe2ed2b))
* **transforms:** judge a Codex exec envelope read by its output
([#3878](#3878))
([922924e](922924e))
* **transforms:** judge the code_aware Kompress fallback in tokens
([#3881](#3881))
([6326965](6326965))
* **transforms:** keep record-bearing JSON out of Kompress
([#3673](#3673))
([#3880](#3880))
([8dbbd1d](8dbbd1d))
* **wrap:** never reuse a non-Headroom listener on the proxy port
([#3799](#3799))
([3ffa57e](3ffa57e))
* **wrap:** never reuse a proxy with incompatible routing config
([#3201](#3201))
([f69e246](f69e246))
* **wrap:** preserve pre-set ANTHROPIC_BASE_URL as proxy upstream
([#1358](#1358))
([bb1ab6a](bb1ab6a))
* **wrap:** scope Serena's MCP registration to the wrapped project
([#2787](#2787))
([#2992](#2992))
([8cfeb69](8cfeb69))
* **wrap:** set ANTHROPIC_HOST so `wrap goose` actually proxies
Anthropic
([#2619](#2619))
([c07fad0](c07fad0))
* **wrap:** strip -dev from running proxy version in restart check
([#3200](#3200))
([bd0296b](bd0296b))


### Performance Improvements

* add savings audit output
([#1211](#1211))
([9a72bc0](9a72bc0))
* **compression/code:** avoid a UTF-8 copy per code block in
byte-to-char mapping
([#3169](#3169))
([d5e5534](d5e5534))
* **image:** OCR and SigLIP each image once, not once per turn
([#3941](#3941))
([a8561fb](a8561fb))
* **memory:** bound traffic-learner _persisted_ids with the dedup window
([#3341](#3341))
([7e73438](7e73438))
* **metrics:** cap inbound request-path cardinality to bound memory
([#3340](#3340))
([88a1f4e](88a1f4e))
* **proxy:** lighter request path: alias the message snapshot, keep the
token cache on re-counts, decompress off the event loop
([#3909](#3909))
([c873d13](c873d13))
* **tokenizer:** memoize OpenAI token counts like the Anthropic counter
([#3168](#3168))
([0a2c80d](0a2c80d))


### Dependencies

* bump pyjwt 2.13.0 -&gt; 2.15.1 for CVE-2026-102274
([#3861](#3861))
([37b9c46](37b9c46))
* Bump source-map-js from 1.2.1 to 1.2.2 in /docs
([#3986](#3986))
([e06631a](e06631a))
* Bump source-map-js from 1.2.1 to 1.2.2 in /plugins/openclaw
([#3988](#3988))
([4be83b3](4be83b3))
* Bump source-map-js from 1.2.1 to 1.2.2 in /plugins/opencode
([#3987](#3987))
([478dd9e](478dd9e))
* Bump source-map-js from 1.2.1 to 1.2.2 in /sdk/typescript
([#3985](#3985))
([62cde35](62cde35))
* bump the npm-minor-patch group across 2 directories with 11 updates
([#3903](#3903))
([afe4f4e](afe4f4e))
* bump the npm-minor-patch group across 3 directories with 11 updates
([#3910](#3910))
([ff1a0d6](ff1a0d6))
* bump webpki-roots from 0.26.11 to 1.0.8
([#3905](#3905))
([fee53b4](fee53b4))
* drop the crewai extra to remove chromadb from the lockfile
([#3870](#3870))
([59b8cef](59b8cef))


### Code Refactoring

* **ccr:** read the expansion query through extract_user_query
([#2707](#2707))
([dcec845](dcec845))
* **wrap:** generate goose/openhands/openclaude from a WrapTarget
registry
([#3800](#3800))
([de4cf7e](de4cf7e))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview — 2d764f3e Deployed Oct 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: needs author action Pull request body or readiness checklist still needs author updates status: needs rebase Pull request branch is behind the base branch on files it also changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants