Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
f64b2fd
feat: Add the WritesDestination capability
huang195 Oct 7, 2026
b9b50f4
feat: Add pctx.Redirect for WritesDestination plugins
huang195 Oct 8, 2026
6f0a7d3
feat: Record the requested host on redirected session events
huang195 Oct 8, 2026
66ebe17
feat: Refuse plugins whose capability the listener cannot honor
huang195 Oct 8, 2026
33ad6c2
feat: Build each chain for the listener that runs it
huang195 Oct 8, 2026
3af1032
fix: Refuse reloads that change the mtls block
huang195 Oct 8, 2026
25e112b
feat: Honor redirects in the forward proxy
huang195 Oct 8, 2026
0efa308
feat: Show a redirected request's two hosts in agentop's detail pane
huang195 Oct 8, 2026
0ba879b
docs: Document WritesDestination, pctx.Redirect and requestedHost
huang195 Oct 8, 2026
21340cb
test: Keep requestedHost in the summary projection
huang195 Oct 8, 2026
3b923eb
test: Pin the scheme change of a redirect to a plaintext target
huang195 Oct 8, 2026
6f2764b
fix: Apply the redirect target Redirect validated, not pctx.Host
huang195 Oct 8, 2026
0613764
docs: Say what a redirect does not do, and correct the mtls and liste…
huang195 Oct 8, 2026
2a3f686
docs: Gate a redirect's credentials on Redirected() alone
huang195 Oct 8, 2026
8aeab0a
fix: Quote no part of a refused Redirect target in its error
huang195 Oct 8, 2026
4152d14
feat: Add the inference router's config rules
huang195 Oct 8, 2026
73787ef
feat: Add the inference-router plugin
huang195 Oct 8, 2026
42196c1
fix: Keep a server URL's credentials out of its parse error
huang195 Oct 8, 2026
1f5c31d
feat: Ship inference-router in cortex's full profile
huang195 Oct 8, 2026
e079b0d
fix: Always redirect routed requests, so the key goes only to the server
huang195 Oct 8, 2026
42107cf
fix: Pin a session by where it actually went
huang195 Oct 8, 2026
d97db10
docs: Say how the inference router redirects and pins, as it now does
huang195 Oct 8, 2026
cbc5a5f
feat: Add a line-preserving editor for a plugin's config block
huang195 Oct 8, 2026
90134b9
feat: Write plugin config changes and wait for the proxy to reload
huang195 Oct 8, 2026
6ad399f
feat: Add agentop server, listing servers and checking Claude Code
huang195 Oct 8, 2026
e5fa72a
fix: Refuse line breaks the YAML parser counts but the editor would not
huang195 Oct 8, 2026
32a0dea
fix: Restore the config file when the proxy refuses the change
huang195 Oct 8, 2026
44d7789
fix: Refuse a real NEL, and test the line-break guards against source…
huang195 Oct 8, 2026
d17d803
fix: Keep URL user info out of the router's URL errors
huang195 Oct 8, 2026
7a8355a
fix: Check every way Claude Code picks a model, and print no URL cred…
huang195 Oct 8, 2026
06c6594
fix: Keep a URL's query and fragment out of errors and checks
huang195 Oct 8, 2026
b33a079
feat: Add agentop server add, remove, use and reset
huang195 Oct 8, 2026
1a88d33
fix: Quote no part of a server URL in an error
huang195 Oct 8, 2026
0c1e716
docs: Document agentop server and switching Claude Code's server
huang195 Oct 8, 2026
fa31505
fix: Show nothing of a server URL the router refuses
huang195 Oct 8, 2026
ace106a
fix: Check and quote the server name in agentop server use and remove
huang195 Oct 8, 2026
7d42d58
docs: Name the hand-offs and preconditions agentop server depends on
huang195 Oct 8, 2026
4381380
fix: Keep a running session the router has not seen on the server it …
huang195 Oct 8, 2026
da9a431
fix: Put the config back only as agentop wrote it, and say when the p…
huang195 Oct 8, 2026
7c3ea3d
fix: Say what agentop server's changes do to running sessions, and wh…
huang195 Oct 8, 2026
c60cfff
docs: Say how the router holds a running session, and which keys and …
huang195 Oct 8, 2026
92f42b0
fix: Count only a request to a server as a session's history
huang195 Oct 8, 2026
3629518
docs: Say that only a request to a server places a session, and what …
huang195 Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ operator-facing TUI over the session API, and the component the root README lead
with. See [`cmd/agentop/README.md`](cmd/agentop/README.md) for flags and keybindings.

**Plugins are all opt-in.** Each one lives in a `cmd/*/plugins_<name>.go` file
gated by `//go:build include_plugin_<name>` (15 such files in
gated by `//go:build include_plugin_<name>` (16 such files in
`cmd/cortex/`); `main.go` imports no plugin package directly, so a build
with no `-tags` registers no plugins at all and rejects every config it is handed.
Tag sets come from [`scripts/profile-tags`](scripts/profile-tags/), one profile per
Expand Down Expand Up @@ -581,6 +581,7 @@ Every event on `/v1/sessions/{id}` and `/v1/events` carries:
open and a close saying 200. An opaque CONNECT that could not be dialed
reports the same situation one layer down as `tunnelReason: "dial-failed"` with
`error.kind: "dial_failed"`.
- `requestedHost` — present only when a plugin redirected the request (`pctx.Redirect`): the host the client asked for. `host` is where the request actually went, and usage, the cost ledger and pricing all follow `host`. agentop's detail pane shows both on a `redirected:` line.
- `tunnel`, `tunnelReason`, `bytesUp`, `bytesDown` — an opaque CONNECT (or transparent-redirect) tunnel records two rows sharing a `requestId`: the open (`phase: "request"`, `tunnelReason` saying why the bytes stayed opaque) and, when the tunnel ends, the close (`phase: "response"`). The close carries the CONNECT's own `statusCode` — 200, or 502 with the dial error in `error` when the destination could not be reached (`tunnelReason: "dial-failed"`) — plus `durationMs` for how long the tunnel stayed open and the bytes it carried each way (up = client to destination). It is not the destination's status: that travels inside the client's end-to-end TLS. A bridged tunnel's open is recorded with its first decrypted request — in that request's session, directly before it, stamped with its time — and records no close, because the request carries its own response; a bridged tunnel that recorded no request gets its open and a close when it ends. Tunnel rows are kept out of `/v1/usage`: a tunnel's lifetime is not a request latency.
- `httpMethod`, `httpPath` — the HTTP verb and path, so a request no parser recognized is still identifiable rather than showing only a host. Distinct from the `method` inside `a2a` / `mcp`, which is a protocol method name. On an opaque tunnel `httpMethod` is `CONNECT` and `httpPath` is absent — opaque bytes carry no request line. The path is query-stripped and percent-decoded, so query-borne credentials never reach the timeline, but a secret in a path *segment* (a bot token, a webhook path) does survive on this unauthenticated surface — worth knowing before exporting events off-box.

Expand Down Expand Up @@ -616,7 +617,7 @@ The authbridge binary watches its config file (`/etc/authbridge/config.yaml`) vi

**What reloads:** any plugin list change (add/remove/reorder) and any plugin `config:` subtree edit.

**What doesn't reload (pod restart required):** `mode`, any `listener.*` address, and the session store parameters (`session.ttl`, `session.max_events`, `session.max_sessions`, and the `session.archive` block).
**What doesn't reload (pod restart required):** `mode`, any `listener.*` address, the top-level `mtls` block, and the session store parameters (`session.ttl`, `session.max_events`, `session.max_sessions`, and the `session.archive` block).

**Bad YAML stays safe:** if Load/Validate/Build/Start fails, the active pipeline keeps serving and the error is exposed on `/reload/status` with `reloads_failed` incremented. The pod never goes unhealthy from a bad edit.

Expand Down Expand Up @@ -683,9 +684,21 @@ can talk to this authbridge. Per-caller policy / SPIFFE allowlists are
out of scope; the trust bundle IS the policy. Plugins that want
per-caller decisions read `pctx.PeerCert` and check the URI SAN.

**Hot-reload boundary:** mTLS config (`mtls.mode`, cert paths) requires a
pod restart to apply, matching the existing rule for `listener.*`
addresses. Plugin-pipeline config keeps its own hot-reload behavior.
**Hot-reload boundary:** mTLS config requires a pod restart to apply,
matching the existing rule for `listener.*` addresses. A reload that adds the
`mtls` block, removes it, or changes `mtls.mode` — the only field the block
carries — is refused rather than half-applied: the active pipeline keeps
serving, `reloads_failed` increments, and `/reload/status` names `mtls.*`
among the fields that need a restart. Legacy `cert_file` / `key_file` /
`bundle_file` keys are dropped at load, so editing them is neither applied nor
refused. The inbound TLS listener and, under `strict`, the forward proxy's
mTLS dialer are built once at boot, so no reload can reach them. The outbound
chain's redirect check (`forwardproxy.Support`) refuses a `WritesDestination`
plugin whenever an `mtls:` block is configured: under `strict` the dialer
would nest TLS inside TLS; under `permissive` there is no dialer, so the
refusal is broader than strictly needed and fails closed. Refusing the reload
keeps that check matched to the mtls config the listeners were built from.
Plugin-pipeline config keeps its own hot-reload behavior.

### envoy-sidecar mTLS

Expand Down
92 changes: 92 additions & 0 deletions cmd/agentop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,98 @@ service status` fails, the line says it could not check rather than guessing.
hand, what OpenCode needs to trust Cortex's CA, what Cortex records for it, what was
verified, and its known issues.

## Choosing an inference server (`agentop server`)

With several LiteLLM servers, each with its own URL and key, `agentop server`
chooses which one an agent's **new** sessions use, through the
[`inference-router`](../../docs/plugin-catalog.md#inference-router) plugin. A
session stays on the server it started on, including one already running when its
agent is first routed, until the proxy restarts: a restarted proxy treats every
session it sees as a new one. Routing is opt-in per agent: until an agent is given
a server, its traffic goes where the agent sends it.

```sh
agentop server add ete https://ete-litellm.example.com # asks for the key; it does not echo
agentop server add glm https://glm-litellm.example.com
agentop server use glm --agent claude-code # new claude-code sessions → glm
agentop server # the servers, and two checks
agentop server reset --agent claude-code # stop routing claude-code
agentop server remove glm # refused while an agent uses it
```

With Claude Code's `ANTHROPIC_BASE_URL` at `ete`, the listing after `use` reads:

```
ete ete-litellm.example.com uses Claude Code's names
glm glm-litellm.example.com uses Claude Code's names claude-code

✓ Claude Code points at ete (~/.claude/settings.json)
✓ Claude Code asks for Claude's own model names (~/.claude/settings.json)
```

- **`add <name> <url>`** reads the key at a prompt that does not echo, or from
stdin with `--key-stdin`, which is refused when stdin is a terminal, where the key
would echo as typed. It is never an argument, so it never reaches shell
history. A name that exists is replaced only after a yes (`--yes` skips the
question), which is how a key is rotated; with no terminal to ask on, nothing is
written. A server on a host another server already has, on any port, is
refused. The first `add` creates the plugin's entry, last in the outbound
pipeline. A key containing `$` is refused: the config loader would read it as an
environment variable.
- **`use <name> --agent <agent>`** routes the agent's new sessions to the server,
including an agent that has not run yet. **`reset --agent <agent>`** stops
routing it. Either way a session already running stays where it is if it has sent
a request to one of the servers since the proxy started, which is how the router
knows where it is — unless the proxy has since dropped it from memory, which keeps
the most recently used sessions (100 by default), before the router pinned it.
- **`remove <name>`** refuses while an agent is routed to the server, and names
the command that takes the agent off it; it also refuses the last server. A
session that started on a removed server gets a 503 asking for a new session
until the server is added back, or until the proxy restarts, which forgets
which server each session started on.
- **`agentop server`** lists each server's host (its whole URL when it is plain
`http`), its model mapping and the agents routed to it, then checks
`~/.claude/settings.json`: that `ANTHROPIC_BASE_URL` names one of the servers,
without which nothing is routed, and that neither the `model` setting nor a
model variable names a model other than Claude's (see
[Switching inference servers](../../docs/agents/claude-code.md#switching-inference-servers)).
It reads that one file, so it cannot see a project's settings, a file passed
with `claude --settings`, or a variable set in the shell. When the router's entry
has `on_error: observe`, which records what it would route and routes nothing, or
`on_error: off`, which stops it running, the listing says so first, and `use` and
`add` say so too.

Two things the router needs that `agentop server` does not check. It routes only
requests it can read: a plain `http` request sent through the proxy, or an `https`
one the TLS bridge decrypts, which needs the bridge on and the port the agent
connects to in `tls_bridge.ports`, 443 and 8443 unless set. An agent whose base URL
is `https` on another port, such as `:4000`, reaches the proxy as an opaque
`CONNECT` tunnel and is never routed, although the check above, which matches by
host alone, shows ✓ for it; a server on such a port can still be where routed
requests go. And a server is `scheme://host[:port]` only: a routed request keeps
its own path, so an agent whose base URL has a path needs that same path on every
server.

Every change is written to `~/.cortex/config.yaml`, keeping its comments except
those on the lines the change replaces or removes, every line of a removed block
among them, and returns once the proxy has reloaded it; nothing restarts. If the
proxy refuses the reload, the command puts the file back as it was, prints the
proxy's error and exits 1, and the proxy keeps the configuration it had. If the
proxy stops answering before it reports the reload, the command puts the file back
the same way, says the proxy stopped answering, and exits 1. A file someone edited
while the proxy was reloading is not put back but left as found, with an error
saying so. If the proxy reports neither a reload nor a refusal within 30 seconds,
the command exits 1 and leaves the file as written. With no proxy running the file is still written, and the change applies at
the next start, to every session from then: a proxy that starts knows where no
session is. Nothing is written unless the result loads as a Cortex config and
passes the router's own rules; what only the running proxy can check, such as
whether its build includes the router, is what a refused reload reports. A change
that changes nothing writes nothing. `--config PATH` points every form at another
config, with the stats address read from it: that is how to try this against a
second Cortex without touching the one every session uses. Flags go after the
action. Exit codes: 0 done, or nothing to do; 1 refused or failed; 2 a usage error,
a malformed name, agent or URL included; 3 a replacement declined.

## Panes

The UI has these panes. `Enter` drills in; `Esc` backs out.
Expand Down
Loading
Loading