Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ Every event on `/v1/sessions/{id}` and `/v1/events` carries:
- `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.
- `inference.requestedModel` — present only when a plugin changed the model (`pctx.SetRequestModel`): the model the client asked for. `inference.model` is the model the request was sent for, which settlement prices; agentop's detail pane shows both on a `model:` line.
- `bytesUp`, `bytesDown` — **body** bytes, one direction per row: a request row carries `bytesUp`, the body **as forwarded** (so a `tool-prune`-style rewrite is reflected, since the count is taken after the pipeline ran), and a response row carries `bytesDown`, the body **as it came back from the destination** — not as delivered, because extproc records the row before it emits a response mutator's replacement. Headers are not counted. A tunnel close is the one row carrying both. **Zero means the listener counted nothing, not that the body was empty**, and there is no third value for "unknown": every listener buffers bodies only when a plugin asks, so an unbuffered request reports zero. A response relayed straight through is counted, not zero — both proxies record the row after the copy (#1251's fix), so the bytes they relayed through their own process are on it. The one arm that still reports ↓0 is extproc with a body-free pipeline: it asked Envoy for body mode NONE, so the bytes it never saw are bytes it asked not to see — `core/listener/parity`'s `TestDivergence_InboundResponseBodyCountWhenNoPluginReadsIt` pins that disagreement with its reason. agentop renders a zero side blank rather than as `0B`, and that is why its BYTES column can be trusted as a size when it shows one. Everywhere else the three listeners count the same thing for the same response, which took some doing: extproc sums the chunks Envoy hands it, while the proxies count off the upstream body at one choke point each, so every arm — buffered, block-by-block SSE, or chunk copy — reports the same figure. `core/listener/internal/bodycount` and `core/listener/parity`'s bytes fixtures hold that line.
- `tunnel`, `tunnelReason` — 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, in `bytesUp`/`bytesDown`, the bytes it carried each way (up = client to destination; opaque bytes have no body/header split, so these are everything that crossed). 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. `tunnelReason: "dest-private"` with a 403 close and `error.kind: "destination_private"` is the egress guard refusing the destination before any dial — never confused with `dial-failed`, which attempted.
- `tunnel`, `tunnelReason` — 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 and no TLS bridge could take the connection (`tunnelReason: "dial-failed"`; where the bridge is enabled for the port the CONNECT is answered 200 first, because a redirect can still apply, and a bridge that then declines closes with that 200 and the dial error) — plus `durationMs` for how long the tunnel stayed open and, in `bytesUp`/`bytesDown`, the bytes it carried each way (up = client to destination; opaque bytes have no body/header split, so these are everything that crossed). 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. `tunnelReason: "dest-private"` with a 403 close and `error.kind: "destination_private"` is the egress guard refusing the destination before any dial — never confused with `dial-failed`, which attempted.
- `dialAddr` — the address the egress guard checked and the proxy dialed, `"ip:port"`, on a tunnel's open row when `listener.egress_guard` is on. With `host` it is the audit trail against DNS rebinding: under the guard the proxy resolves once, checks that answer, and dials exactly it, so the two fields together say "asked for the name, reached this address". Absent when the guard is off and on rows that dialed nothing.
- `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.
- `subagent` — present on rows from a Claude Code subagent or agent-team teammate: the `X-Claude-Code-Agent-Id` the request carried. For a subagent that is also the `<id>` in `~/.claude/projects/<dir>/<session>/subagents/agent-<id>.meta.json`; for a teammate the join is unverified. Resolved by `Context.Subagent`, pinned with the User-Agent by `ResolveClient` so no plugin can re-file a row, and **refused rather than sanitised** when unusable (control runes, over 128 bytes), because it is a join key and a cleaned-up id joins nothing. It does not move the row: a subagent's requests stay in their parent's session, so the header must not be added to `session.id_headers`. **It names an agent, it does not classify the row:** `inference.agentRole` stays the field that says whether a row is a subagent's, and a teammate's row is `agentRole: main` with `subagent` set — Claude Code writes `cc_is_subagent=true` only for `agentType: subagent`.
Expand Down
16 changes: 8 additions & 8 deletions core/listener/forwardproxy/bridge_program_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ func TestBridgeProgram_AStaleProcessDoesNotStopItsProgram(t *testing.T) {
for range 2 {
tl := discardTunnel()
tl.program = &staleProg
sc.server.bridgeServe(rejectingClient(t), sc.target, hostOnly(sc.target), tl)
sc.server.bridgeServe(rejectingClient(t), sc.target, hostOnly(sc.target), tl, false)
}
if sc.engine.Programs.Contains(claude) {
t.Fatal("the stale process's refusals were recorded against its program, which a fresh claude shares")
Expand Down Expand Up @@ -267,7 +267,7 @@ func TestBridgeServe_RecordsANamedProgramsRefusalAgainstTheProgram(t *testing.T)
tl := discardTunnel()
tl.program = &prog

s.bridgeServe(rejectingClient(t), authority, host, tl)
s.bridgeServe(rejectingClient(t), authority, host, tl, false)
if !s.TLSBridge.Programs.Contains(prog.Key()) {
t.Error("the refusal was not recorded against the program")
}
Expand All @@ -287,7 +287,7 @@ func TestBridgeServe_RecordsAStaleProcesssRefusalAgainstTheProcess(t *testing.T)
tl := discardTunnel()
tl.program = &prog

s.bridgeServe(rejectingClient(t), authority, host, tl)
s.bridgeServe(rejectingClient(t), authority, host, tl, false)
if !s.TLSBridge.Programs.Contains(prog.ProcessKey()) {
t.Error("a process older than the CA did not have its refusal recorded against the process")
}
Expand Down Expand Up @@ -321,7 +321,7 @@ func TestBridgeServe_WithoutEvidenceOfStalenessRecordsAgainstTheProgram(t *testi
tl := discardTunnel()
tl.program = &prog

s.bridgeServe(rejectingClient(t), authority, hostOnly(authority), tl)
s.bridgeServe(rejectingClient(t), authority, hostOnly(authority), tl, false)
if !s.TLSBridge.Programs.Contains(prog.Key()) {
t.Error("the refusal was not recorded against the program")
}
Expand All @@ -346,7 +346,7 @@ func TestBridgeServe_RejectionLogNamesTheAgent(t *testing.T) {
logbuf.Reset()
tl := discardTunnel()
tl.program = &prog
s.bridgeServe(rejectingClient(t), authority, hostOnly(authority), tl)
s.bridgeServe(rejectingClient(t), authority, hostOnly(authority), tl, false)
got := logbuf.String()
if !strings.Contains(got, "program="+exePython) {
t.Fatalf("the rejection line does not name the program:\n%s", got)
Expand All @@ -367,7 +367,7 @@ func TestBridgeServe_UnnamedTunnelRecordsAgainstTheHost(t *testing.T) {
s.TLSBridge.Programs = tlsbridge.NewProgramSkipSet()
host := hostOnly(authority)

s.bridgeServe(rejectingClient(t), authority, host, discardTunnel())
s.bridgeServe(rejectingClient(t), authority, host, discardTunnel(), false)
if !s.TLSBridge.Skip.Contains(host) {
t.Error("an unnamed tunnel's refusal was not recorded against the host")
}
Expand All @@ -381,7 +381,7 @@ func TestBridgeServe_ProgramWithoutProgramMemoryFallsBackToTheHost(t *testing.T)
tl := discardTunnel()
tl.program = &tlsbridge.Program{Exe: exeHelm}

s.bridgeServe(rejectingClient(t), authority, host, tl)
s.bridgeServe(rejectingClient(t), authority, host, tl, false)
if !s.TLSBridge.Skip.Contains(host) {
t.Error("with no program memory the refusal was not recorded against the host")
}
Expand All @@ -400,7 +400,7 @@ func TestBridgeServe_ASuccessClearsTheProgramsEntry(t *testing.T) {
tl.program = &prog

// bridgeServe blocks serving the decrypted connection, so run it and wait.
go s.bridgeServe(trustingClient(t, s.TLSBridge.CAPEM), authority, host, tl)
go s.bridgeServe(trustingClient(t, s.TLSBridge.CAPEM), authority, host, tl, false)
eventually(t, func() bool {
return !s.TLSBridge.Programs.Contains(prog.Key()) && !s.TLSBridge.Programs.Contains(prog.ProcessKey())
}, "a completed handshake to clear the program's entry and the process's")
Expand Down
Loading
Loading