From 8e01527135554e874111790578e2454d93647562 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Wed, 22 Jul 2026 15:40:31 +0000 Subject: [PATCH 1/5] feat: add network calls list to AI session threads API The threads API returned only a network call summary. Add a per-call list so the session detail can render individual Agent Firewall network calls. ListAIBridgeSessionNetworkCalls reuses the same sequence-number windowing as the summary and includes all protocols, so the list length and blocked count match the summary counts. Co-Authored-By: Claude Opus 4.8 (1M context) --- coderd/apidoc/docs.go | 40 +++++++++++ coderd/apidoc/swagger.json | 40 +++++++++++ coderd/database/db2sdk/db2sdk.go | 18 +++++ coderd/database/dbauthz/dbauthz.go | 7 ++ coderd/database/dbauthz/dbauthz_test.go | 6 ++ coderd/database/dbmetrics/querymetrics.go | 8 +++ coderd/database/dbmock/dbmock.go | 15 ++++ coderd/database/querier.go | 11 +++ coderd/database/queries.sql.go | 84 +++++++++++++++++++++++ coderd/database/queries/aibridge.sql | 37 ++++++++++ codersdk/aibridge.go | 24 ++++++- docs/reference/api/aigateway.md | 12 ++++ docs/reference/api/schemas.md | 41 +++++++++++ enterprise/coderd/aibridge.go | 18 ++++- enterprise/coderd/aibridge_test.go | 31 +++++++++ site/src/api/typesGenerated.ts | 31 +++++++++ 16 files changed, 421 insertions(+), 2 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3ce8e342ab0..4f573b2ac29 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15586,6 +15586,39 @@ const docTemplate = `{ } } }, + "codersdk.AIBridgeSessionNetworkCall": { + "type": "object", + "properties": { + "allowed": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "detail": { + "description": "Detail is protocol-specific: the full URL for http, the hostname for dns,\nthe path for fs.", + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "matched_rule": { + "description": "MatchedRule is the allow-list rule that permitted the call. Nil when the\ncall was blocked.", + "type": "string" + }, + "method": { + "type": "string" + }, + "proto": { + "type": "string" + }, + "sequence_number": { + "type": "integer" + } + } + }, "codersdk.AIBridgeSessionNetworkCallSummary": { "type": "object", "properties": { @@ -15634,6 +15667,13 @@ const docTemplate = `{ "type": "string" } }, + "network_call_logs": { + "description": "NetworkCallLogs is the chronological list of individual network calls made\nduring the session, capped server-side. Empty when the session did not\npass through Agent Firewall.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCall" + } + }, "network_calls": { "description": "NetworkCalls summarizes the Agent Firewall network calls made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", "allOf": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 92eecc3601c..d46cc47782b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13880,6 +13880,39 @@ } } }, + "codersdk.AIBridgeSessionNetworkCall": { + "type": "object", + "properties": { + "allowed": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "detail": { + "description": "Detail is protocol-specific: the full URL for http, the hostname for dns,\nthe path for fs.", + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "matched_rule": { + "description": "MatchedRule is the allow-list rule that permitted the call. Nil when the\ncall was blocked.", + "type": "string" + }, + "method": { + "type": "string" + }, + "proto": { + "type": "string" + }, + "sequence_number": { + "type": "integer" + } + } + }, "codersdk.AIBridgeSessionNetworkCallSummary": { "type": "object", "properties": { @@ -13928,6 +13961,13 @@ "type": "string" } }, + "network_call_logs": { + "description": "NetworkCallLogs is the chronological list of individual network calls made\nduring the session, capped server-side. Empty when the session did not\npass through Agent Firewall.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCall" + } + }, "network_calls": { "description": "NetworkCalls summarizes the Agent Firewall network calls made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", "allOf": [ diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 75deb23053d..fef6a1f0b6e 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1154,6 +1154,7 @@ func AIBridgeSessionThreads( userPrompts []database.AIBridgeUserPrompt, modelThoughts []database.AIBridgeModelThought, topDomains []database.GetAIBridgeSessionTopDomainsRow, + networkCalls []database.ListAIBridgeSessionNetworkCallsRow, ) codersdk.AIBridgeSessionThreadsResponse { // Index subresources by interception ID. tokensByInterception := make(map[uuid.UUID][]database.AIBridgeTokenUsage, len(interceptions)) @@ -1262,6 +1263,23 @@ func AIBridgeSessionThreads( // from the last row processed. resp.NetworkDomainCount = d.TotalDomains } + for _, nc := range networkCalls { + call := codersdk.AIBridgeSessionNetworkCall{ + ID: nc.ID, + SequenceNumber: nc.SequenceNumber, + Proto: nc.Proto, + Method: nc.Method, + Detail: nc.Detail, + // A matched allow-list rule means the call was permitted. + Allowed: nc.MatchedRule.Valid, + CreatedAt: nc.CreatedAt, + } + if nc.MatchedRule.Valid { + rule := nc.MatchedRule.String + call.MatchedRule = &rule + } + resp.NetworkCallLogs = append(resp.NetworkCallLogs, call) + } return resp } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index e20aa3a972c..37d1a6a31f9 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -6836,6 +6836,13 @@ func (q *querier) ListAIBridgeModels(ctx context.Context, arg database.ListAIBri return q.db.ListAuthorizedAIBridgeModels(ctx, arg, prep) } +func (q *querier) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.ListAIBridgeSessionNetworkCallsRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil { + return nil, err + } + return q.db.ListAIBridgeSessionNetworkCalls(ctx, arg) +} + func (q *querier) ListAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams) ([]database.ListAIBridgeSessionThreadsRow, error) { prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e4e4264ff65..1a383155bf2 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6975,6 +6975,12 @@ func (s *MethodTestSuite) TestAIBridge() { check.Args(params).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.GetAIBridgeSessionTopDomainsRow{}) })) + s.Run("ListAIBridgeSessionNetworkCalls", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + params := database.ListAIBridgeSessionNetworkCallsParams{SessionID: "sess", Limit: 100} + db.EXPECT().ListAIBridgeSessionNetworkCalls(gomock.Any(), params).Return([]database.ListAIBridgeSessionNetworkCallsRow{}, nil).AnyTimes() + check.Args(params).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.ListAIBridgeSessionNetworkCallsRow{}) + })) + s.Run("ListAIBridgeTokenUsagesByInterceptionIDs", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { ids := []uuid.UUID{{1}} db.EXPECT().ListAIBridgeTokenUsagesByInterceptionIDs(gomock.Any(), ids).Return([]database.AIBridgeTokenUsage{}, nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index c87d7b8dba5..41eb14b5417 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -4777,6 +4777,14 @@ func (m queryMetricsStore) ListAIBridgeModels(ctx context.Context, arg database. return r0, r1 } +func (m queryMetricsStore) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.ListAIBridgeSessionNetworkCallsRow, error) { + start := time.Now() + r0, r1 := m.s.ListAIBridgeSessionNetworkCalls(ctx, arg) + m.queryLatencies.WithLabelValues("ListAIBridgeSessionNetworkCalls").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAIBridgeSessionNetworkCalls").Inc() + return r0, r1 +} + func (m queryMetricsStore) ListAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams) ([]database.ListAIBridgeSessionThreadsRow, error) { start := time.Now() r0, r1 := m.s.ListAIBridgeSessionThreads(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 1d2c128e998..5f5351469b0 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -8936,6 +8936,21 @@ func (mr *MockStoreMockRecorder) ListAIBridgeModels(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeModels", reflect.TypeOf((*MockStore)(nil).ListAIBridgeModels), ctx, arg) } +// ListAIBridgeSessionNetworkCalls mocks base method. +func (m *MockStore) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.ListAIBridgeSessionNetworkCallsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAIBridgeSessionNetworkCalls", ctx, arg) + ret0, _ := ret[0].([]database.ListAIBridgeSessionNetworkCallsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAIBridgeSessionNetworkCalls indicates an expected call of ListAIBridgeSessionNetworkCalls. +func (mr *MockStoreMockRecorder) ListAIBridgeSessionNetworkCalls(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeSessionNetworkCalls", reflect.TypeOf((*MockStore)(nil).ListAIBridgeSessionNetworkCalls), ctx, arg) +} + // ListAIBridgeSessionThreads mocks base method. func (m *MockStore) ListAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams) ([]database.ListAIBridgeSessionThreadsRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index db572c970db..c53fea62ae9 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1255,6 +1255,17 @@ type sqlcQuerier interface { ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Context, arg ListAIBridgeInterceptionsTelemetrySummariesParams) ([]ListAIBridgeInterceptionsTelemetrySummariesRow, error) ListAIBridgeModelThoughtsByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeModelThought, error) ListAIBridgeModels(ctx context.Context, arg ListAIBridgeModelsParams) ([]string, error) + // Returns the individual Agent Firewall network calls made during an AI + // session, ordered chronologically. All protocols are included so the row + // count matches the network_calls summary in ListAIBridgeSessions. + // + // Windowing mirrors that summary and GetAIBridgeSessionTopDomains: each + // interception's boundary logs fall in the open interval (this seq, next + // interception's seq) within the same firewall session. The exclusive lower + // bound drops the interception's own LLM-provider call. next_seq considers all + // interceptions in the firewall session so windows never bleed across AI + // sessions that share one firewall session. + ListAIBridgeSessionNetworkCalls(ctx context.Context, arg ListAIBridgeSessionNetworkCallsParams) ([]ListAIBridgeSessionNetworkCallsRow, error) // Returns all interceptions belonging to paginated threads within a session. // Threads are paginated by (started_at, thread_id) cursor. ListAIBridgeSessionThreads(ctx context.Context, arg ListAIBridgeSessionThreadsParams) ([]ListAIBridgeSessionThreadsRow, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e385038f618..7e7988a387b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2024,6 +2024,90 @@ func (q *sqlQuerier) ListAIBridgeModels(ctx context.Context, arg ListAIBridgeMod return items, nil } +const listAIBridgeSessionNetworkCalls = `-- name: ListAIBridgeSessionNetworkCalls :many +SELECT + bl.id, + bl.sequence_number, + bl.proto, + bl.method, + bl.detail, + bl.matched_rule, + bl.created_at +FROM aibridge_interceptions afi +LEFT JOIN LATERAL ( + SELECT MIN(nxt.agent_firewall_sequence_number) AS next_seq + FROM aibridge_interceptions nxt + WHERE nxt.agent_firewall_session_id = afi.agent_firewall_session_id + AND nxt.agent_firewall_sequence_number > afi.agent_firewall_sequence_number +) w ON true +JOIN boundary_logs bl + ON bl.session_id = afi.agent_firewall_session_id + AND bl.sequence_number > afi.agent_firewall_sequence_number + AND (w.next_seq IS NULL OR bl.sequence_number < w.next_seq) +WHERE afi.session_id = $1::text + AND afi.ended_at IS NOT NULL + AND afi.agent_firewall_session_id IS NOT NULL + AND afi.agent_firewall_sequence_number IS NOT NULL +ORDER BY bl.created_at ASC, bl.sequence_number ASC +LIMIT COALESCE(NULLIF($2::integer, 0), 100) +` + +type ListAIBridgeSessionNetworkCallsParams struct { + SessionID string `db:"session_id" json:"session_id"` + Limit int32 `db:"limit_" json:"limit_"` +} + +type ListAIBridgeSessionNetworkCallsRow struct { + ID uuid.UUID `db:"id" json:"id"` + SequenceNumber int32 `db:"sequence_number" json:"sequence_number"` + Proto string `db:"proto" json:"proto"` + Method string `db:"method" json:"method"` + Detail string `db:"detail" json:"detail"` + MatchedRule sql.NullString `db:"matched_rule" json:"matched_rule"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +// Returns the individual Agent Firewall network calls made during an AI +// session, ordered chronologically. All protocols are included so the row +// count matches the network_calls summary in ListAIBridgeSessions. +// +// Windowing mirrors that summary and GetAIBridgeSessionTopDomains: each +// interception's boundary logs fall in the open interval (this seq, next +// interception's seq) within the same firewall session. The exclusive lower +// bound drops the interception's own LLM-provider call. next_seq considers all +// interceptions in the firewall session so windows never bleed across AI +// sessions that share one firewall session. +func (q *sqlQuerier) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg ListAIBridgeSessionNetworkCallsParams) ([]ListAIBridgeSessionNetworkCallsRow, error) { + rows, err := q.db.QueryContext(ctx, listAIBridgeSessionNetworkCalls, arg.SessionID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAIBridgeSessionNetworkCallsRow + for rows.Next() { + var i ListAIBridgeSessionNetworkCallsRow + if err := rows.Scan( + &i.ID, + &i.SequenceNumber, + &i.Proto, + &i.Method, + &i.Detail, + &i.MatchedRule, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAIBridgeSessionThreads = `-- name: ListAIBridgeSessionThreads :many WITH paginated_threads AS ( SELECT diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index a99730ba2eb..4a8ac24dd57 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -618,6 +618,43 @@ FROM domains ORDER BY count DESC, domain ASC LIMIT COALESCE(NULLIF(@limit_::integer, 0), 5); +-- name: ListAIBridgeSessionNetworkCalls :many +-- Returns the individual Agent Firewall network calls made during an AI +-- session, ordered chronologically. All protocols are included so the row +-- count matches the network_calls summary in ListAIBridgeSessions. +-- +-- Windowing mirrors that summary and GetAIBridgeSessionTopDomains: each +-- interception's boundary logs fall in the open interval (this seq, next +-- interception's seq) within the same firewall session. The exclusive lower +-- bound drops the interception's own LLM-provider call. next_seq considers all +-- interceptions in the firewall session so windows never bleed across AI +-- sessions that share one firewall session. +SELECT + bl.id, + bl.sequence_number, + bl.proto, + bl.method, + bl.detail, + bl.matched_rule, + bl.created_at +FROM aibridge_interceptions afi +LEFT JOIN LATERAL ( + SELECT MIN(nxt.agent_firewall_sequence_number) AS next_seq + FROM aibridge_interceptions nxt + WHERE nxt.agent_firewall_session_id = afi.agent_firewall_session_id + AND nxt.agent_firewall_sequence_number > afi.agent_firewall_sequence_number +) w ON true +JOIN boundary_logs bl + ON bl.session_id = afi.agent_firewall_session_id + AND bl.sequence_number > afi.agent_firewall_sequence_number + AND (w.next_seq IS NULL OR bl.sequence_number < w.next_seq) +WHERE afi.session_id = @session_id::text + AND afi.ended_at IS NOT NULL + AND afi.agent_firewall_session_id IS NOT NULL + AND afi.agent_firewall_sequence_number IS NOT NULL +ORDER BY bl.created_at ASC, bl.sequence_number ASC +LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100); + -- name: ListAIBridgeSessionThreads :many -- Returns all interceptions belonging to paginated threads within a session. -- Threads are paginated by (started_at, thread_id) cursor. diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index df400665245..b20032ed2f7 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -172,6 +172,24 @@ type AIBridgeSessionNetworkDomain struct { Count int64 `json:"count"` } +// AIBridgeSessionNetworkCall is a single Agent Firewall network call made +// during a session. Allowed reports whether the firewall allow-list matched; +// when false the call was blocked. +type AIBridgeSessionNetworkCall struct { + ID uuid.UUID `json:"id" format:"uuid"` + SequenceNumber int32 `json:"sequence_number"` + Proto string `json:"proto"` + Method string `json:"method"` + // Detail is protocol-specific: the full URL for http, the hostname for dns, + // the path for fs. + Detail string `json:"detail"` + Allowed bool `json:"allowed"` + // MatchedRule is the allow-list rule that permitted the call. Nil when the + // call was blocked. + MatchedRule *string `json:"matched_rule,omitempty"` + CreatedAt time.Time `json:"created_at" format:"date-time"` +} + type AIBridgeListSessionsResponse struct { Count int64 `json:"count"` Sessions []AIBridgeSession `json:"sessions"` @@ -202,7 +220,11 @@ type AIBridgeSessionThreadsResponse struct { // domains, used to render a "+N more" overflow beyond the listed domains. NetworkTopDomains []AIBridgeSessionNetworkDomain `json:"network_top_domains,omitempty"` NetworkDomainCount int64 `json:"network_domain_count,omitempty"` - Threads []AIBridgeThread `json:"threads"` + // NetworkCallLogs is the chronological list of individual network calls made + // during the session, capped server-side. Empty when the session did not + // pass through Agent Firewall. + NetworkCallLogs []AIBridgeSessionNetworkCall `json:"network_call_logs,omitempty"` + Threads []AIBridgeThread `json:"threads"` } // AIBridgeSessionThreadsTokenUsage represents aggregated token usage diff --git a/docs/reference/api/aigateway.md b/docs/reference/api/aigateway.md index 935309c4812..30c170b9061 100644 --- a/docs/reference/api/aigateway.md +++ b/docs/reference/api/aigateway.md @@ -195,6 +195,18 @@ Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward com "models": [ "string" ], + "network_call_logs": [ + { + "allowed": true, + "created_at": "2019-08-24T14:15:22Z", + "detail": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "matched_rule": "string", + "method": "string", + "proto": "string", + "sequence_number": 0 + } + ], "network_calls": { "blocked": 0, "total": 0 diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index e1d7560090e..341da91d99b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -639,6 +639,34 @@ | `threads` | integer | false | | | | `token_usage_summary` | [codersdk.AIBridgeSessionTokenUsageSummary](#codersdkaibridgesessiontokenusagesummary) | false | | | +## codersdk.AIBridgeSessionNetworkCall + +```json +{ + "allowed": true, + "created_at": "2019-08-24T14:15:22Z", + "detail": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "matched_rule": "string", + "method": "string", + "proto": "string", + "sequence_number": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------|---------|----------|--------------|---------------------------------------------------------------------------------------------| +| `allowed` | boolean | false | | | +| `created_at` | string | false | | | +| `detail` | string | false | | Detail is protocol-specific: the full URL for http, the hostname for dns, the path for fs. | +| `id` | string | false | | | +| `matched_rule` | string | false | | Matched rule is the allow-list rule that permitted the call. Nil when the call was blocked. | +| `method` | string | false | | | +| `proto` | string | false | | | +| `sequence_number` | integer | false | | | + ## codersdk.AIBridgeSessionNetworkCallSummary ```json @@ -691,6 +719,18 @@ "models": [ "string" ], + "network_call_logs": [ + { + "allowed": true, + "created_at": "2019-08-24T14:15:22Z", + "detail": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "matched_rule": "string", + "method": "string", + "proto": "string", + "sequence_number": 0 + } + ], "network_calls": { "blocked": 0, "total": 0 @@ -794,6 +834,7 @@ | `metadata` | object | false | | | | » `[any property]` | any | false | | | | `models` | array of string | false | | | +| `network_call_logs` | array of [codersdk.AIBridgeSessionNetworkCall](#codersdkaibridgesessionnetworkcall) | false | | Network call logs is the chronological list of individual network calls made during the session, capped server-side. Empty when the session did not pass through Agent Firewall. | | `network_calls` | [codersdk.AIBridgeSessionNetworkCallSummary](#codersdkaibridgesessionnetworkcallsummary) | false | | Network calls summarizes the Agent Firewall network calls made during the session. A nil value means the session did not pass through Agent Firewall, so network call monitoring was not active, which the UI surfaces as "Disabled". | | `network_domain_count` | integer | false | | | | `network_top_domains` | array of [codersdk.AIBridgeSessionNetworkDomain](#codersdkaibridgesessionnetworkdomain) | false | | Network top domains lists the most contacted destination hosts, ordered by call count descending. NetworkDomainCount is the total number of distinct domains, used to render a "+N more" overflow beyond the listed domains. | diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index a428c31b7a0..c141517b290 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -47,6 +47,10 @@ const ( // maxAISpendExportPeriod bounds an explicit AI spend export window to at // most 31 days, matching the maximum length of the monthly default period. maxAISpendExportPeriod = 31 * 24 * time.Hour + // aiBridgeSessionNetworkCallsLimit caps the per-session network call list + // returned with session threads. The header count still reflects the full + // summary total, so the UI surfaces truncation when a session exceeds this. + aiBridgeSessionNetworkCallsLimit = 100 ) // errInvalidCursor is returned when a pagination cursor does not @@ -370,6 +374,7 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques userPrompts []database.AIBridgeUserPrompt modelThoughts []database.AIBridgeModelThought topDomains []database.GetAIBridgeSessionTopDomainsRow + networkCalls []database.ListAIBridgeSessionNetworkCallsRow ) err = api.Database.InTx(func(db database.Store) error { // Validate cursor IDs before querying threads. The SQL @@ -448,6 +453,17 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques return xerrors.Errorf("get session top domains: %w", err) } + // List the session's individual network calls. Scoped by session ID + // (not the page) so the list reflects the whole session, consistent + // with the network call summary. + networkCalls, err = db.ListAIBridgeSessionNetworkCalls(ctx, database.ListAIBridgeSessionNetworkCallsParams{ + SessionID: sessionIDParam, + Limit: aiBridgeSessionNetworkCallsLimit, + }) + if err != nil { + return xerrors.Errorf("list session network calls: %w", err) + } + return nil }, &database.TxOptions{ Isolation: sql.LevelRepeatableRead, @@ -469,7 +485,7 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques return } - resp := db2sdk.AIBridgeSessionThreads(session, threadRows, tokenUsages, toolUsages, userPrompts, modelThoughts, topDomains) + resp := db2sdk.AIBridgeSessionThreads(session, threadRows, tokenUsages, toolUsages, userPrompts, modelThoughts, topDomains, networkCalls) httpapi.Write(ctx, rw, http.StatusOK, resp) } diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 1541174af8d..5a268107157 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -1815,6 +1815,36 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { require.Equal(t, "api.github.com", res.NetworkTopDomains[0].Domain) require.EqualValues(t, 3, res.NetworkTopDomains[0].Count) require.EqualValues(t, 2, res.NetworkDomainCount) + + // The per-call list covers the same window as the summary (all protos, + // LLM call at seq 0 excluded), ordered chronologically by sequence. + require.Len(t, res.NetworkCallLogs, 5) + gotSeqs := make([]int32, len(res.NetworkCallLogs)) + blocked := 0 + for i, c := range res.NetworkCallLogs { + gotSeqs[i] = c.SequenceNumber + if !c.Allowed { + blocked++ + } + } + require.Equal(t, []int32{1, 2, 3, 4, 5}, gotSeqs) + // List length and blocked count agree with the summary counts. + require.EqualValues(t, res.NetworkCalls.Total, len(res.NetworkCallLogs)) + require.EqualValues(t, res.NetworkCalls.Blocked, blocked) + + // The blocked npm call (seq 3) has no matched rule; allowed calls do. + npm := res.NetworkCallLogs[2] + require.Equal(t, int32(3), npm.SequenceNumber) + require.Equal(t, "https://registry.npmjs.org/lodash", npm.Detail) + require.False(t, npm.Allowed) + require.Nil(t, npm.MatchedRule) + + gh := res.NetworkCallLogs[0] + require.True(t, gh.Allowed) + require.NotNil(t, gh.MatchedRule) + + // Non-http protocols are included in the list (unlike top domains). + require.Equal(t, "dns", res.NetworkCallLogs[4].Proto) }) t.Run("NetworkSharedFirewallSessionNoBleed", func(t *testing.T) { @@ -1895,6 +1925,7 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { require.Nil(t, res.NetworkCalls) require.Empty(t, res.NetworkTopDomains) require.EqualValues(t, 0, res.NetworkDomainCount) + require.Empty(t, res.NetworkCallLogs) }) t.Run("ThreadsWithAgenticActions", func(t *testing.T) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d6f7e516f40..f231b5ac302 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -151,6 +151,31 @@ export interface AIBridgeSession { readonly last_active_at: string; } +// From codersdk/aibridge.go +/** + * AIBridgeSessionNetworkCall is a single Agent Firewall network call made + * during a session. Allowed reports whether the firewall allow-list matched; + * when false the call was blocked. + */ +export interface AIBridgeSessionNetworkCall { + readonly id: string; + readonly sequence_number: number; + readonly proto: string; + readonly method: string; + /** + * Detail is protocol-specific: the full URL for http, the hostname for dns, + * the path for fs. + */ + readonly detail: string; + readonly allowed: boolean; + /** + * MatchedRule is the allow-list rule that permitted the call. Nil when the + * call was blocked. + */ + readonly matched_rule?: string; + readonly created_at: string; +} + // From codersdk/aibridge.go /** * AIBridgeSessionNetworkCallSummary aggregates the Agent Firewall network @@ -205,6 +230,12 @@ export interface AIBridgeSessionThreadsResponse { */ readonly network_top_domains?: readonly AIBridgeSessionNetworkDomain[]; readonly network_domain_count?: number; + /** + * NetworkCallLogs is the chronological list of individual network calls made + * during the session, capped server-side. Empty when the session did not + * pass through Agent Firewall. + */ + readonly network_call_logs?: readonly AIBridgeSessionNetworkCall[]; readonly threads: readonly AIBridgeThread[]; } From 7524237f7e234b87ab5b0af9278b5cdab8cdc5f7 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 23 Jul 2026 10:04:04 +0000 Subject: [PATCH 2/5] test: verify network_call_logs windowing and truncation; refactor db2sdk params Extend the windowing tests to assert the per-call list agrees with the summary: len(NetworkCallLogs) == Total, blocked count == summary Blocked, correct sequence ordering, and no cross-session bleed when two AI sessions share a firewall session. Add a truncation test seeding 105 calls that asserts the list caps at 100 while the summary Total stays authoritative. Refactor db2sdk.AIBridgeSessionThreads to take a named-field params struct, removing the transposable adjacent TopDomains/NetworkCalls positional args. Refs AIGOV-464 Co-Authored-By: Claude Opus 4.8 (1M context) --- coderd/database/db2sdk/db2sdk.go | 34 ++++++--- enterprise/coderd/aibridge.go | 11 ++- enterprise/coderd/aibridge_test.go | 111 +++++++++++++++++++++++------ 3 files changed, 125 insertions(+), 31 deletions(-) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index fef6a1f0b6e..7a96f1110a2 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1146,16 +1146,30 @@ func AIBridgeSession(row database.ListAIBridgeSessionsRow) codersdk.AIBridgeSess // into the threads response. It groups interceptions into threads, builds // agentic actions from tool usages and model thoughts, and aggregates // token usage with metadata. -func AIBridgeSessionThreads( - session database.ListAIBridgeSessionsRow, - interceptions []database.ListAIBridgeSessionThreadsRow, - tokenUsages []database.AIBridgeTokenUsage, - toolUsages []database.AIBridgeToolUsage, - userPrompts []database.AIBridgeUserPrompt, - modelThoughts []database.AIBridgeModelThought, - topDomains []database.GetAIBridgeSessionTopDomainsRow, - networkCalls []database.ListAIBridgeSessionNetworkCallsRow, -) codersdk.AIBridgeSessionThreadsResponse { +// AIBridgeSessionThreadsParams groups the session row and its subresources for +// AIBridgeSessionThreads. Named fields avoid transposing the several adjacent +// slice arguments (notably TopDomains and NetworkCalls) at the call site. +type AIBridgeSessionThreadsParams struct { + Session database.ListAIBridgeSessionsRow + Interceptions []database.ListAIBridgeSessionThreadsRow + TokenUsages []database.AIBridgeTokenUsage + ToolUsages []database.AIBridgeToolUsage + UserPrompts []database.AIBridgeUserPrompt + ModelThoughts []database.AIBridgeModelThought + TopDomains []database.GetAIBridgeSessionTopDomainsRow + NetworkCalls []database.ListAIBridgeSessionNetworkCallsRow +} + +func AIBridgeSessionThreads(p AIBridgeSessionThreadsParams) codersdk.AIBridgeSessionThreadsResponse { + session := p.Session + interceptions := p.Interceptions + tokenUsages := p.TokenUsages + toolUsages := p.ToolUsages + userPrompts := p.UserPrompts + modelThoughts := p.ModelThoughts + topDomains := p.TopDomains + networkCalls := p.NetworkCalls + // Index subresources by interception ID. tokensByInterception := make(map[uuid.UUID][]database.AIBridgeTokenUsage, len(interceptions)) for _, tu := range tokenUsages { diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index c141517b290..70f9f523720 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -485,7 +485,16 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques return } - resp := db2sdk.AIBridgeSessionThreads(session, threadRows, tokenUsages, toolUsages, userPrompts, modelThoughts, topDomains, networkCalls) + resp := db2sdk.AIBridgeSessionThreads(db2sdk.AIBridgeSessionThreadsParams{ + Session: session, + Interceptions: threadRows, + TokenUsages: tokenUsages, + ToolUsages: toolUsages, + UserPrompts: userPrompts, + ModelThoughts: modelThoughts, + TopDomains: topDomains, + NetworkCalls: networkCalls, + }) httpapi.Write(ctx, rw, http.StatusOK, resp) } diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 5a268107157..e661dde4a39 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -1761,6 +1761,34 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { require.Equal(t, "api.github.com", res.NetworkTopDomains[0].Domain) require.EqualValues(t, 4, res.NetworkTopDomains[0].Count) require.EqualValues(t, 2, res.NetworkDomainCount) + + // The per-call list spans the same window as the summary (all protos, + // seq 0 LLM call excluded), ordered chronologically. Its length and + // blocked count agree with the summary counts. + gotSeqs := make([]int32, len(res.NetworkCallLogs)) + blocked := 0 + for i, c := range res.NetworkCallLogs { + gotSeqs[i] = c.SequenceNumber + if !c.Allowed { + blocked++ + } + } + require.Equal(t, []int32{1, 2, 3, 4, 5, 6}, gotSeqs) + require.EqualValues(t, res.NetworkCalls.Total, len(res.NetworkCallLogs)) + require.EqualValues(t, res.NetworkCalls.Blocked, blocked) + + // The blocked npm call (seq 3, index 2) has no matched rule; allowed + // calls do. + npm := res.NetworkCallLogs[2] + require.Equal(t, int32(3), npm.SequenceNumber) + require.Equal(t, "https://registry.npmjs.org/lodash", npm.Detail) + require.False(t, npm.Allowed) + require.Nil(t, npm.MatchedRule) + require.True(t, res.NetworkCallLogs[0].Allowed) + require.NotNil(t, res.NetworkCallLogs[0].MatchedRule) + + // Non-http protocols appear in the list (unlike top domains): seq 5 dns. + require.Equal(t, "dns", res.NetworkCallLogs[4].Proto) }) t.Run("NetworkMultipleInterceptions", func(t *testing.T) { @@ -1816,9 +1844,9 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { require.EqualValues(t, 3, res.NetworkTopDomains[0].Count) require.EqualValues(t, 2, res.NetworkDomainCount) - // The per-call list covers the same window as the summary (all protos, - // LLM call at seq 0 excluded), ordered chronologically by sequence. - require.Len(t, res.NetworkCallLogs, 5) + // The per-call list spans both windows in chronological order, and its + // length and blocked count agree with the summary (three-way agreement + // across summary, top domains, and list). gotSeqs := make([]int32, len(res.NetworkCallLogs)) blocked := 0 for i, c := range res.NetworkCallLogs { @@ -1827,24 +1855,9 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { blocked++ } } - require.Equal(t, []int32{1, 2, 3, 4, 5}, gotSeqs) - // List length and blocked count agree with the summary counts. + require.Equal(t, []int32{1, 2, 3, 6, 7}, gotSeqs) require.EqualValues(t, res.NetworkCalls.Total, len(res.NetworkCallLogs)) require.EqualValues(t, res.NetworkCalls.Blocked, blocked) - - // The blocked npm call (seq 3) has no matched rule; allowed calls do. - npm := res.NetworkCallLogs[2] - require.Equal(t, int32(3), npm.SequenceNumber) - require.Equal(t, "https://registry.npmjs.org/lodash", npm.Detail) - require.False(t, npm.Allowed) - require.Nil(t, npm.MatchedRule) - - gh := res.NetworkCallLogs[0] - require.True(t, gh.Allowed) - require.NotNil(t, gh.MatchedRule) - - // Non-http protocols are included in the list (unlike top domains). - require.Equal(t, "dns", res.NetworkCallLogs[4].Proto) }) t.Run("NetworkSharedFirewallSessionNoBleed", func(t *testing.T) { @@ -1889,12 +1902,18 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { {13, "http", "https://registry.npmjs.org/b3", false}, // B's window (10,+inf), blocked }) - // Session A sees only its own two calls (seqs 1, 2), not B's. + // Session A sees only its own two calls (seqs 1, 2), not B's, in both + // the summary and the per-call list. resA, err := client.AIBridgeGetSessionThreads(ctx, "sess-a", uuid.Nil, uuid.Nil, 0) require.NoError(t, err) require.NotNil(t, resA.NetworkCalls) require.EqualValues(t, 2, resA.NetworkCalls.Total) require.EqualValues(t, 1, resA.NetworkCalls.Blocked) + seqsA := make([]int32, len(resA.NetworkCallLogs)) + for i, c := range resA.NetworkCallLogs { + seqsA[i] = c.SequenceNumber + } + require.Equal(t, []int32{1, 2}, seqsA) // Session B sees only its own three calls (seqs 11, 12, 13), not A's. resB, err := client.AIBridgeGetSessionThreads(ctx, "sess-b", uuid.Nil, uuid.Nil, 0) @@ -1902,6 +1921,58 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { require.NotNil(t, resB.NetworkCalls) require.EqualValues(t, 3, resB.NetworkCalls.Total) require.EqualValues(t, 1, resB.NetworkCalls.Blocked) + seqsB := make([]int32, len(resB.NetworkCallLogs)) + for i, c := range resB.NetworkCallLogs { + seqsB[i] = c.SequenceNumber + } + require.Equal(t, []int32{11, 12, 13}, seqsB) + }) + + t.Run("NetworkCallsTruncated", func(t *testing.T) { + t.Parallel() + // The per-call list is capped server-side (currently 100 rows) while the + // summary total reflects the whole session. When a session exceeds the + // cap the list is truncated but the summary total stays authoritative. + db, ps := dbtestutil.NewDB(t) + opts := aibridgeOpts(t) + opts.Options.Database = db + opts.Options.Pubsub = ps + client, _, firstUser := coderdenttest.NewWithDatabase(t, opts) + ctx := testutil.Context(t, testutil.WaitLong) + + now := dbtime.Now() + fw := uuid.New() + + endedAt := now.Add(time.Minute) + dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + Provider: "anthropic", + Model: "claude-sonnet-4-20250514", + StartedAt: now, + ClientSessionID: sql.NullString{String: "trunc-net", Valid: true}, + AgentFirewallSessionID: uuid.NullUUID{UUID: fw, Valid: true}, + AgentFirewallSequenceNumber: sql.NullInt32{Int32: 0, Valid: true}, + }, &endedAt) + + // 105 allowed HTTP calls at seqs 1..105, all in the interception's + // window (0, +inf). + const total = 105 + seeds := make([]boundaryLogSeed, 0, total) + for seq := int32(1); seq <= total; seq++ { + seeds = append(seeds, boundaryLogSeed{seq, "http", "https://api.github.com/x", true}) + } + seedBoundaryLogs(t, db, fw, firstUser.UserID, now, seeds) + + res, err := client.AIBridgeGetSessionThreads(ctx, "trunc-net", uuid.Nil, uuid.Nil, 0) + require.NoError(t, err) + + // The summary reflects all 105 calls; the list is capped at 100. + require.NotNil(t, res.NetworkCalls) + require.EqualValues(t, total, res.NetworkCalls.Total) + require.Len(t, res.NetworkCallLogs, 100) + // The cap keeps the earliest calls in chronological order. + require.EqualValues(t, 1, res.NetworkCallLogs[0].SequenceNumber) + require.EqualValues(t, 100, res.NetworkCallLogs[99].SequenceNumber) }) t.Run("NetworkSummaryDisabled", func(t *testing.T) { From e3b9474cf32006290f6a6e29edbe4e3f3aff800c Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 28 Jul 2026 13:02:38 +0000 Subject: [PATCH 3/5] perf(coderd/database): bound network-call list window with a sentinel --- coderd/database/querier.go | 4 +++- coderd/database/queries.sql.go | 8 +++++--- coderd/database/queries/aibridge.sql | 8 +++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c53fea62ae9..f100c24b474 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1264,7 +1264,9 @@ type sqlcQuerier interface { // interception's seq) within the same firewall session. The exclusive lower // bound drops the interception's own LLM-provider call. next_seq considers all // interceptions in the firewall session so windows never bleed across AI - // sessions that share one firewall session. + // sessions that share one firewall session, and falls back to the maximum + // sequence_number for the last interception so the window stays an + // index-satisfiable range. ListAIBridgeSessionNetworkCalls(ctx context.Context, arg ListAIBridgeSessionNetworkCallsParams) ([]ListAIBridgeSessionNetworkCallsRow, error) // Returns all interceptions belonging to paginated threads within a session. // Threads are paginated by (started_at, thread_id) cursor. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7e7988a387b..656943780d5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2035,7 +2035,7 @@ SELECT bl.created_at FROM aibridge_interceptions afi LEFT JOIN LATERAL ( - SELECT MIN(nxt.agent_firewall_sequence_number) AS next_seq + SELECT COALESCE(MIN(nxt.agent_firewall_sequence_number), 2147483647) AS next_seq FROM aibridge_interceptions nxt WHERE nxt.agent_firewall_session_id = afi.agent_firewall_session_id AND nxt.agent_firewall_sequence_number > afi.agent_firewall_sequence_number @@ -2043,7 +2043,7 @@ LEFT JOIN LATERAL ( JOIN boundary_logs bl ON bl.session_id = afi.agent_firewall_session_id AND bl.sequence_number > afi.agent_firewall_sequence_number - AND (w.next_seq IS NULL OR bl.sequence_number < w.next_seq) + AND bl.sequence_number < w.next_seq WHERE afi.session_id = $1::text AND afi.ended_at IS NOT NULL AND afi.agent_firewall_session_id IS NOT NULL @@ -2076,7 +2076,9 @@ type ListAIBridgeSessionNetworkCallsRow struct { // interception's seq) within the same firewall session. The exclusive lower // bound drops the interception's own LLM-provider call. next_seq considers all // interceptions in the firewall session so windows never bleed across AI -// sessions that share one firewall session. +// sessions that share one firewall session, and falls back to the maximum +// sequence_number for the last interception so the window stays an +// index-satisfiable range. func (q *sqlQuerier) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg ListAIBridgeSessionNetworkCallsParams) ([]ListAIBridgeSessionNetworkCallsRow, error) { rows, err := q.db.QueryContext(ctx, listAIBridgeSessionNetworkCalls, arg.SessionID, arg.Limit) if err != nil { diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index 4a8ac24dd57..4dc7cfc2eaa 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -628,7 +628,9 @@ LIMIT COALESCE(NULLIF(@limit_::integer, 0), 5); -- interception's seq) within the same firewall session. The exclusive lower -- bound drops the interception's own LLM-provider call. next_seq considers all -- interceptions in the firewall session so windows never bleed across AI --- sessions that share one firewall session. +-- sessions that share one firewall session, and falls back to the maximum +-- sequence_number for the last interception so the window stays an +-- index-satisfiable range. SELECT bl.id, bl.sequence_number, @@ -639,7 +641,7 @@ SELECT bl.created_at FROM aibridge_interceptions afi LEFT JOIN LATERAL ( - SELECT MIN(nxt.agent_firewall_sequence_number) AS next_seq + SELECT COALESCE(MIN(nxt.agent_firewall_sequence_number), 2147483647) AS next_seq FROM aibridge_interceptions nxt WHERE nxt.agent_firewall_session_id = afi.agent_firewall_session_id AND nxt.agent_firewall_sequence_number > afi.agent_firewall_sequence_number @@ -647,7 +649,7 @@ LEFT JOIN LATERAL ( JOIN boundary_logs bl ON bl.session_id = afi.agent_firewall_session_id AND bl.sequence_number > afi.agent_firewall_sequence_number - AND (w.next_seq IS NULL OR bl.sequence_number < w.next_seq) + AND bl.sequence_number < w.next_seq WHERE afi.session_id = @session_id::text AND afi.ended_at IS NOT NULL AND afi.agent_firewall_session_id IS NOT NULL From 3918352eb27626dd4a18560ee5279b88743740e1 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Mon, 3 Aug 2026 08:28:40 +0000 Subject: [PATCH 4/5] refactor: reuse AgentFirewallLog and raise network call cap to 1000 --- coderd/apidoc/docs.go | 37 +----------- coderd/apidoc/swagger.json | 37 +----------- coderd/database/db2sdk/db2sdk.go | 48 +++++++++------- coderd/database/dbauthz/dbauthz.go | 2 +- coderd/database/dbauthz/dbauthz_test.go | 6 +- coderd/database/dbmetrics/querymetrics.go | 2 +- coderd/database/dbmock/dbmock.go | 4 +- coderd/database/querier.go | 13 ++++- coderd/database/queries.sql.go | 45 ++++++--------- coderd/database/queries/aibridge.sql | 24 ++++---- codersdk/aibridge.go | 28 ++------- docs/reference/api/aigateway.md | 4 +- docs/reference/api/schemas.md | 70 +++++++---------------- enterprise/coderd/agentfirewall.go | 27 +-------- enterprise/coderd/aibridge.go | 5 +- enterprise/coderd/aibridge_test.go | 19 +++--- site/src/api/typesGenerated.ts | 33 ++--------- 17 files changed, 131 insertions(+), 273 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4f573b2ac29..ebdc4de4cb1 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15586,39 +15586,6 @@ const docTemplate = `{ } } }, - "codersdk.AIBridgeSessionNetworkCall": { - "type": "object", - "properties": { - "allowed": { - "type": "boolean" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "detail": { - "description": "Detail is protocol-specific: the full URL for http, the hostname for dns,\nthe path for fs.", - "type": "string" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "matched_rule": { - "description": "MatchedRule is the allow-list rule that permitted the call. Nil when the\ncall was blocked.", - "type": "string" - }, - "method": { - "type": "string" - }, - "proto": { - "type": "string" - }, - "sequence_number": { - "type": "integer" - } - } - }, "codersdk.AIBridgeSessionNetworkCallSummary": { "type": "object", "properties": { @@ -15668,10 +15635,10 @@ const docTemplate = `{ } }, "network_call_logs": { - "description": "NetworkCallLogs is the chronological list of individual network calls made\nduring the session, capped server-side. Empty when the session did not\npass through Agent Firewall.", + "description": "NetworkCallLogs is the chronological list of individual network calls made\nduring the session, holding the earliest calls up to a server-side cap.\nNetworkCalls remains authoritative for whole-session totals, so a shorter\nlist than NetworkCalls.Total means the list was truncated. Empty when the\nsession did not pass through Agent Firewall.", "type": "array", "items": { - "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCall" + "$ref": "#/definitions/codersdk.AgentFirewallLog" } }, "network_calls": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d46cc47782b..2dedb93ab79 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13880,39 +13880,6 @@ } } }, - "codersdk.AIBridgeSessionNetworkCall": { - "type": "object", - "properties": { - "allowed": { - "type": "boolean" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "detail": { - "description": "Detail is protocol-specific: the full URL for http, the hostname for dns,\nthe path for fs.", - "type": "string" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "matched_rule": { - "description": "MatchedRule is the allow-list rule that permitted the call. Nil when the\ncall was blocked.", - "type": "string" - }, - "method": { - "type": "string" - }, - "proto": { - "type": "string" - }, - "sequence_number": { - "type": "integer" - } - } - }, "codersdk.AIBridgeSessionNetworkCallSummary": { "type": "object", "properties": { @@ -13962,10 +13929,10 @@ } }, "network_call_logs": { - "description": "NetworkCallLogs is the chronological list of individual network calls made\nduring the session, capped server-side. Empty when the session did not\npass through Agent Firewall.", + "description": "NetworkCallLogs is the chronological list of individual network calls made\nduring the session, holding the earliest calls up to a server-side cap.\nNetworkCalls remains authoritative for whole-session totals, so a shorter\nlist than NetworkCalls.Total means the list was truncated. Empty when the\nsession did not pass through Agent Firewall.", "type": "array", "items": { - "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCall" + "$ref": "#/definitions/codersdk.AgentFirewallLog" } }, "network_calls": { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 7a96f1110a2..7d77626eca3 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1142,10 +1142,6 @@ func AIBridgeSession(row database.ListAIBridgeSessionsRow) codersdk.AIBridgeSess return session } -// AIBridgeSessionThreads converts session metadata and thread interceptions -// into the threads response. It groups interceptions into threads, builds -// agentic actions from tool usages and model thoughts, and aggregates -// token usage with metadata. // AIBridgeSessionThreadsParams groups the session row and its subresources for // AIBridgeSessionThreads. Named fields avoid transposing the several adjacent // slice arguments (notably TopDomains and NetworkCalls) at the call site. @@ -1157,9 +1153,13 @@ type AIBridgeSessionThreadsParams struct { UserPrompts []database.AIBridgeUserPrompt ModelThoughts []database.AIBridgeModelThought TopDomains []database.GetAIBridgeSessionTopDomainsRow - NetworkCalls []database.ListAIBridgeSessionNetworkCallsRow + NetworkCalls []database.BoundaryLog } +// AIBridgeSessionThreads converts session metadata and thread interceptions +// into the threads response. It groups interceptions into threads, builds +// agentic actions from tool usages and model thoughts, and aggregates +// token usage with metadata. func AIBridgeSessionThreads(p AIBridgeSessionThreadsParams) codersdk.AIBridgeSessionThreadsResponse { session := p.Session interceptions := p.Interceptions @@ -1277,24 +1277,32 @@ func AIBridgeSessionThreads(p AIBridgeSessionThreadsParams) codersdk.AIBridgeSes // from the last row processed. resp.NetworkDomainCount = d.TotalDomains } - for _, nc := range networkCalls { - call := codersdk.AIBridgeSessionNetworkCall{ - ID: nc.ID, - SequenceNumber: nc.SequenceNumber, - Proto: nc.Proto, - Method: nc.Method, - Detail: nc.Detail, - // A matched allow-list rule means the call was permitted. - Allowed: nc.MatchedRule.Valid, - CreatedAt: nc.CreatedAt, + resp.NetworkCallLogs = AgentFirewallLogs(networkCalls) + return resp +} + +// AgentFirewallLogs converts boundary logs to their SDK representation. +// Allowed is derived from MatchedRule being non-NULL. +func AgentFirewallLogs(logs []database.BoundaryLog) []codersdk.AgentFirewallLog { + results := make([]codersdk.AgentFirewallLog, 0, len(logs)) + for _, l := range logs { + bl := codersdk.AgentFirewallLog{ + ID: l.ID, + SessionID: l.SessionID, + SequenceNumber: l.SequenceNumber, + Allowed: l.MatchedRule.Valid, + CreatedAt: l.CreatedAt, + Proto: l.Proto, + Method: l.Method, + Detail: l.Detail, + CapturedAt: &l.CapturedAt, } - if nc.MatchedRule.Valid { - rule := nc.MatchedRule.String - call.MatchedRule = &rule + if l.MatchedRule.Valid { + bl.MatchedRule = &l.MatchedRule.String } - resp.NetworkCallLogs = append(resp.NetworkCallLogs, call) + results = append(results, bl) } - return resp + return results } func buildAIBridgeThread( diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 37d1a6a31f9..e0eab762a8a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -6836,7 +6836,7 @@ func (q *querier) ListAIBridgeModels(ctx context.Context, arg database.ListAIBri return q.db.ListAuthorizedAIBridgeModels(ctx, arg, prep) } -func (q *querier) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.ListAIBridgeSessionNetworkCallsRow, error) { +func (q *querier) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.BoundaryLog, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil { return nil, err } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 1a383155bf2..2cb8a82191d 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6976,9 +6976,9 @@ func (s *MethodTestSuite) TestAIBridge() { })) s.Run("ListAIBridgeSessionNetworkCalls", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - params := database.ListAIBridgeSessionNetworkCallsParams{SessionID: "sess", Limit: 100} - db.EXPECT().ListAIBridgeSessionNetworkCalls(gomock.Any(), params).Return([]database.ListAIBridgeSessionNetworkCallsRow{}, nil).AnyTimes() - check.Args(params).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.ListAIBridgeSessionNetworkCallsRow{}) + params := database.ListAIBridgeSessionNetworkCallsParams{SessionID: "sess", Limit: 1000} + db.EXPECT().ListAIBridgeSessionNetworkCalls(gomock.Any(), params).Return([]database.BoundaryLog{}, nil).AnyTimes() + check.Args(params).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.BoundaryLog{}) })) s.Run("ListAIBridgeTokenUsagesByInterceptionIDs", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 41eb14b5417..7ef3f158110 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -4777,7 +4777,7 @@ func (m queryMetricsStore) ListAIBridgeModels(ctx context.Context, arg database. return r0, r1 } -func (m queryMetricsStore) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.ListAIBridgeSessionNetworkCallsRow, error) { +func (m queryMetricsStore) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.BoundaryLog, error) { start := time.Now() r0, r1 := m.s.ListAIBridgeSessionNetworkCalls(ctx, arg) m.queryLatencies.WithLabelValues("ListAIBridgeSessionNetworkCalls").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 5f5351469b0..03088ef9a84 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -8937,10 +8937,10 @@ func (mr *MockStoreMockRecorder) ListAIBridgeModels(ctx, arg any) *gomock.Call { } // ListAIBridgeSessionNetworkCalls mocks base method. -func (m *MockStore) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.ListAIBridgeSessionNetworkCallsRow, error) { +func (m *MockStore) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.BoundaryLog, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListAIBridgeSessionNetworkCalls", ctx, arg) - ret0, _ := ret[0].([]database.ListAIBridgeSessionNetworkCallsRow) + ret0, _ := ret[0].([]database.BoundaryLog) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f100c24b474..ef684c940ce 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1256,8 +1256,12 @@ type sqlcQuerier interface { ListAIBridgeModelThoughtsByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeModelThought, error) ListAIBridgeModels(ctx context.Context, arg ListAIBridgeModelsParams) ([]string, error) // Returns the individual Agent Firewall network calls made during an AI - // session, ordered chronologically. All protocols are included so the row - // count matches the network_calls summary in ListAIBridgeSessions. + // session, ordered chronologically. All protocols are included, unlike + // GetAIBridgeSessionTopDomains which considers only HTTP egress, so the list + // covers the same events the network_calls summary in ListAIBridgeSessions + // counts. The list is capped at @limit_ rows, so its length equals the summary + // total only for sessions at or below the cap. The summary stays authoritative + // for whole-session totals. // // Windowing mirrors that summary and GetAIBridgeSessionTopDomains: each // interception's boundary logs fall in the open interval (this seq, next @@ -1267,7 +1271,10 @@ type sqlcQuerier interface { // sessions that share one firewall session, and falls back to the maximum // sequence_number for the last interception so the window stays an // index-satisfiable range. - ListAIBridgeSessionNetworkCalls(ctx context.Context, arg ListAIBridgeSessionNetworkCallsParams) ([]ListAIBridgeSessionNetworkCallsRow, error) + // created_at leads because a session can span several firewall sessions, whose + // sequence numbers are independent streams. id breaks remaining ties so the row + // that lands on the limit boundary is stable across identical requests. + ListAIBridgeSessionNetworkCalls(ctx context.Context, arg ListAIBridgeSessionNetworkCallsParams) ([]BoundaryLog, error) // Returns all interceptions belonging to paginated threads within a session. // Threads are paginated by (started_at, thread_id) cursor. ListAIBridgeSessionThreads(ctx context.Context, arg ListAIBridgeSessionThreadsParams) ([]ListAIBridgeSessionThreadsRow, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 656943780d5..b22a2fef0d0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2025,14 +2025,7 @@ func (q *sqlQuerier) ListAIBridgeModels(ctx context.Context, arg ListAIBridgeMod } const listAIBridgeSessionNetworkCalls = `-- name: ListAIBridgeSessionNetworkCalls :many -SELECT - bl.id, - bl.sequence_number, - bl.proto, - bl.method, - bl.detail, - bl.matched_rule, - bl.created_at +SELECT bl.id, bl.session_id, bl.sequence_number, bl.captured_at, bl.created_at, bl.proto, bl.method, bl.detail, bl.matched_rule, bl.owner_id FROM aibridge_interceptions afi LEFT JOIN LATERAL ( SELECT COALESCE(MIN(nxt.agent_firewall_sequence_number), 2147483647) AS next_seq @@ -2048,8 +2041,8 @@ WHERE afi.session_id = $1::text AND afi.ended_at IS NOT NULL AND afi.agent_firewall_session_id IS NOT NULL AND afi.agent_firewall_sequence_number IS NOT NULL -ORDER BY bl.created_at ASC, bl.sequence_number ASC -LIMIT COALESCE(NULLIF($2::integer, 0), 100) +ORDER BY bl.created_at ASC, bl.sequence_number ASC, bl.id ASC +LIMIT COALESCE(NULLIF($2::integer, 0), 1000) ` type ListAIBridgeSessionNetworkCallsParams struct { @@ -2057,19 +2050,13 @@ type ListAIBridgeSessionNetworkCallsParams struct { Limit int32 `db:"limit_" json:"limit_"` } -type ListAIBridgeSessionNetworkCallsRow struct { - ID uuid.UUID `db:"id" json:"id"` - SequenceNumber int32 `db:"sequence_number" json:"sequence_number"` - Proto string `db:"proto" json:"proto"` - Method string `db:"method" json:"method"` - Detail string `db:"detail" json:"detail"` - MatchedRule sql.NullString `db:"matched_rule" json:"matched_rule"` - CreatedAt time.Time `db:"created_at" json:"created_at"` -} - // Returns the individual Agent Firewall network calls made during an AI -// session, ordered chronologically. All protocols are included so the row -// count matches the network_calls summary in ListAIBridgeSessions. +// session, ordered chronologically. All protocols are included, unlike +// GetAIBridgeSessionTopDomains which considers only HTTP egress, so the list +// covers the same events the network_calls summary in ListAIBridgeSessions +// counts. The list is capped at @limit_ rows, so its length equals the summary +// total only for sessions at or below the cap. The summary stays authoritative +// for whole-session totals. // // Windowing mirrors that summary and GetAIBridgeSessionTopDomains: each // interception's boundary logs fall in the open interval (this seq, next @@ -2079,23 +2066,29 @@ type ListAIBridgeSessionNetworkCallsRow struct { // sessions that share one firewall session, and falls back to the maximum // sequence_number for the last interception so the window stays an // index-satisfiable range. -func (q *sqlQuerier) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg ListAIBridgeSessionNetworkCallsParams) ([]ListAIBridgeSessionNetworkCallsRow, error) { +// created_at leads because a session can span several firewall sessions, whose +// sequence numbers are independent streams. id breaks remaining ties so the row +// that lands on the limit boundary is stable across identical requests. +func (q *sqlQuerier) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg ListAIBridgeSessionNetworkCallsParams) ([]BoundaryLog, error) { rows, err := q.db.QueryContext(ctx, listAIBridgeSessionNetworkCalls, arg.SessionID, arg.Limit) if err != nil { return nil, err } defer rows.Close() - var items []ListAIBridgeSessionNetworkCallsRow + var items []BoundaryLog for rows.Next() { - var i ListAIBridgeSessionNetworkCallsRow + var i BoundaryLog if err := rows.Scan( &i.ID, + &i.SessionID, &i.SequenceNumber, + &i.CapturedAt, + &i.CreatedAt, &i.Proto, &i.Method, &i.Detail, &i.MatchedRule, - &i.CreatedAt, + &i.OwnerID, ); err != nil { return nil, err } diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index 4dc7cfc2eaa..546fef5d84d 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -620,8 +620,12 @@ LIMIT COALESCE(NULLIF(@limit_::integer, 0), 5); -- name: ListAIBridgeSessionNetworkCalls :many -- Returns the individual Agent Firewall network calls made during an AI --- session, ordered chronologically. All protocols are included so the row --- count matches the network_calls summary in ListAIBridgeSessions. +-- session, ordered chronologically. All protocols are included, unlike +-- GetAIBridgeSessionTopDomains which considers only HTTP egress, so the list +-- covers the same events the network_calls summary in ListAIBridgeSessions +-- counts. The list is capped at @limit_ rows, so its length equals the summary +-- total only for sessions at or below the cap. The summary stays authoritative +-- for whole-session totals. -- -- Windowing mirrors that summary and GetAIBridgeSessionTopDomains: each -- interception's boundary logs fall in the open interval (this seq, next @@ -631,14 +635,7 @@ LIMIT COALESCE(NULLIF(@limit_::integer, 0), 5); -- sessions that share one firewall session, and falls back to the maximum -- sequence_number for the last interception so the window stays an -- index-satisfiable range. -SELECT - bl.id, - bl.sequence_number, - bl.proto, - bl.method, - bl.detail, - bl.matched_rule, - bl.created_at +SELECT bl.* FROM aibridge_interceptions afi LEFT JOIN LATERAL ( SELECT COALESCE(MIN(nxt.agent_firewall_sequence_number), 2147483647) AS next_seq @@ -654,8 +651,11 @@ WHERE afi.session_id = @session_id::text AND afi.ended_at IS NOT NULL AND afi.agent_firewall_session_id IS NOT NULL AND afi.agent_firewall_sequence_number IS NOT NULL -ORDER BY bl.created_at ASC, bl.sequence_number ASC -LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100); +-- created_at leads because a session can span several firewall sessions, whose +-- sequence numbers are independent streams. id breaks remaining ties so the row +-- that lands on the limit boundary is stable across identical requests. +ORDER BY bl.created_at ASC, bl.sequence_number ASC, bl.id ASC +LIMIT COALESCE(NULLIF(@limit_::integer, 0), 1000); -- name: ListAIBridgeSessionThreads :many -- Returns all interceptions belonging to paginated threads within a session. diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index b20032ed2f7..04b54744ca4 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -172,24 +172,6 @@ type AIBridgeSessionNetworkDomain struct { Count int64 `json:"count"` } -// AIBridgeSessionNetworkCall is a single Agent Firewall network call made -// during a session. Allowed reports whether the firewall allow-list matched; -// when false the call was blocked. -type AIBridgeSessionNetworkCall struct { - ID uuid.UUID `json:"id" format:"uuid"` - SequenceNumber int32 `json:"sequence_number"` - Proto string `json:"proto"` - Method string `json:"method"` - // Detail is protocol-specific: the full URL for http, the hostname for dns, - // the path for fs. - Detail string `json:"detail"` - Allowed bool `json:"allowed"` - // MatchedRule is the allow-list rule that permitted the call. Nil when the - // call was blocked. - MatchedRule *string `json:"matched_rule,omitempty"` - CreatedAt time.Time `json:"created_at" format:"date-time"` -} - type AIBridgeListSessionsResponse struct { Count int64 `json:"count"` Sessions []AIBridgeSession `json:"sessions"` @@ -221,10 +203,12 @@ type AIBridgeSessionThreadsResponse struct { NetworkTopDomains []AIBridgeSessionNetworkDomain `json:"network_top_domains,omitempty"` NetworkDomainCount int64 `json:"network_domain_count,omitempty"` // NetworkCallLogs is the chronological list of individual network calls made - // during the session, capped server-side. Empty when the session did not - // pass through Agent Firewall. - NetworkCallLogs []AIBridgeSessionNetworkCall `json:"network_call_logs,omitempty"` - Threads []AIBridgeThread `json:"threads"` + // during the session, holding the earliest calls up to a server-side cap. + // NetworkCalls remains authoritative for whole-session totals, so a shorter + // list than NetworkCalls.Total means the list was truncated. Empty when the + // session did not pass through Agent Firewall. + NetworkCallLogs []AgentFirewallLog `json:"network_call_logs,omitempty"` + Threads []AIBridgeThread `json:"threads"` } // AIBridgeSessionThreadsTokenUsage represents aggregated token usage diff --git a/docs/reference/api/aigateway.md b/docs/reference/api/aigateway.md index 30c170b9061..18d1f862e38 100644 --- a/docs/reference/api/aigateway.md +++ b/docs/reference/api/aigateway.md @@ -198,13 +198,15 @@ Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward com "network_call_logs": [ { "allowed": true, + "captured_at": "2019-08-24T14:15:22Z", "created_at": "2019-08-24T14:15:22Z", "detail": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "matched_rule": "string", "method": "string", "proto": "string", - "sequence_number": 0 + "sequence_number": 0, + "session_id": "1ffd059c-17ea-40a8-8aef-70fd0307db82" } ], "network_calls": { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 341da91d99b..be3e275762b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -639,34 +639,6 @@ | `threads` | integer | false | | | | `token_usage_summary` | [codersdk.AIBridgeSessionTokenUsageSummary](#codersdkaibridgesessiontokenusagesummary) | false | | | -## codersdk.AIBridgeSessionNetworkCall - -```json -{ - "allowed": true, - "created_at": "2019-08-24T14:15:22Z", - "detail": "string", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "matched_rule": "string", - "method": "string", - "proto": "string", - "sequence_number": 0 -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|-------------------|---------|----------|--------------|---------------------------------------------------------------------------------------------| -| `allowed` | boolean | false | | | -| `created_at` | string | false | | | -| `detail` | string | false | | Detail is protocol-specific: the full URL for http, the hostname for dns, the path for fs. | -| `id` | string | false | | | -| `matched_rule` | string | false | | Matched rule is the allow-list rule that permitted the call. Nil when the call was blocked. | -| `method` | string | false | | | -| `proto` | string | false | | | -| `sequence_number` | integer | false | | | - ## codersdk.AIBridgeSessionNetworkCallSummary ```json @@ -722,13 +694,15 @@ "network_call_logs": [ { "allowed": true, + "captured_at": "2019-08-24T14:15:22Z", "created_at": "2019-08-24T14:15:22Z", "detail": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "matched_rule": "string", "method": "string", "proto": "string", - "sequence_number": 0 + "sequence_number": 0, + "session_id": "1ffd059c-17ea-40a8-8aef-70fd0307db82" } ], "network_calls": { @@ -825,25 +799,25 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------|------------------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `client` | string | false | | | -| `ended_at` | string | false | | | -| `id` | string | false | | | -| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | -| `metadata` | object | false | | | -| » `[any property]` | any | false | | | -| `models` | array of string | false | | | -| `network_call_logs` | array of [codersdk.AIBridgeSessionNetworkCall](#codersdkaibridgesessionnetworkcall) | false | | Network call logs is the chronological list of individual network calls made during the session, capped server-side. Empty when the session did not pass through Agent Firewall. | -| `network_calls` | [codersdk.AIBridgeSessionNetworkCallSummary](#codersdkaibridgesessionnetworkcallsummary) | false | | Network calls summarizes the Agent Firewall network calls made during the session. A nil value means the session did not pass through Agent Firewall, so network call monitoring was not active, which the UI surfaces as "Disabled". | -| `network_domain_count` | integer | false | | | -| `network_top_domains` | array of [codersdk.AIBridgeSessionNetworkDomain](#codersdkaibridgesessionnetworkdomain) | false | | Network top domains lists the most contacted destination hosts, ordered by call count descending. NetworkDomainCount is the total number of distinct domains, used to render a "+N more" overflow beyond the listed domains. | -| `page_ended_at` | string | false | | | -| `page_started_at` | string | false | | | -| `providers` | array of string | false | | | -| `started_at` | string | false | | | -| `threads` | array of [codersdk.AIBridgeThread](#codersdkaibridgethread) | false | | | -| `token_usage_summary` | [codersdk.AIBridgeSessionThreadsTokenUsage](#codersdkaibridgesessionthreadstokenusage) | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------|------------------------------------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client` | string | false | | | +| `ended_at` | string | false | | | +| `id` | string | false | | | +| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | +| `metadata` | object | false | | | +| » `[any property]` | any | false | | | +| `models` | array of string | false | | | +| `network_call_logs` | array of [codersdk.AgentFirewallLog](#codersdkagentfirewalllog) | false | | Network call logs is the chronological list of individual network calls made during the session, holding the earliest calls up to a server-side cap. NetworkCalls remains authoritative for whole-session totals, so a shorter list than NetworkCalls.Total means the list was truncated. Empty when the session did not pass through Agent Firewall. | +| `network_calls` | [codersdk.AIBridgeSessionNetworkCallSummary](#codersdkaibridgesessionnetworkcallsummary) | false | | Network calls summarizes the Agent Firewall network calls made during the session. A nil value means the session did not pass through Agent Firewall, so network call monitoring was not active, which the UI surfaces as "Disabled". | +| `network_domain_count` | integer | false | | | +| `network_top_domains` | array of [codersdk.AIBridgeSessionNetworkDomain](#codersdkaibridgesessionnetworkdomain) | false | | Network top domains lists the most contacted destination hosts, ordered by call count descending. NetworkDomainCount is the total number of distinct domains, used to render a "+N more" overflow beyond the listed domains. | +| `page_ended_at` | string | false | | | +| `page_started_at` | string | false | | | +| `providers` | array of string | false | | | +| `started_at` | string | false | | | +| `threads` | array of [codersdk.AIBridgeThread](#codersdkaibridgethread) | false | | | +| `token_usage_summary` | [codersdk.AIBridgeSessionThreadsTokenUsage](#codersdkaibridgesessionthreadstokenusage) | false | | | ## codersdk.AIBridgeSessionThreadsTokenUsage diff --git a/enterprise/coderd/agentfirewall.go b/enterprise/coderd/agentfirewall.go index 928a99e0d99..2773031f25c 100644 --- a/enterprise/coderd/agentfirewall.go +++ b/enterprise/coderd/agentfirewall.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" @@ -109,30 +110,6 @@ func (api *API) agentFirewallSessionLogs(rw http.ResponseWriter, r *http.Request } httpapi.Write(ctx, rw, http.StatusOK, codersdk.AgentFirewallSessionLogsResponse{ - Results: agentFirewallLogsFromDB(dbLogs), + Results: db2sdk.AgentFirewallLogs(dbLogs), }) } - -// agentFirewallLogsFromDB converts database boundary logs to SDK -// representation. Allowed is derived from MatchedRule being non-NULL. -func agentFirewallLogsFromDB(dbLogs []database.BoundaryLog) []codersdk.AgentFirewallLog { - results := make([]codersdk.AgentFirewallLog, 0, len(dbLogs)) - for _, l := range dbLogs { - bl := codersdk.AgentFirewallLog{ - ID: l.ID, - SessionID: l.SessionID, - SequenceNumber: l.SequenceNumber, - Allowed: l.MatchedRule.Valid, - CreatedAt: l.CreatedAt, - Proto: l.Proto, - Method: l.Method, - Detail: l.Detail, - CapturedAt: &l.CapturedAt, - } - if l.MatchedRule.Valid { - bl.MatchedRule = &l.MatchedRule.String - } - results = append(results, bl) - } - return results -} diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index 70f9f523720..06e305b7fec 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -50,7 +50,8 @@ const ( // aiBridgeSessionNetworkCallsLimit caps the per-session network call list // returned with session threads. The header count still reflects the full // summary total, so the UI surfaces truncation when a session exceeds this. - aiBridgeSessionNetworkCallsLimit = 100 + // Sessions past the cap need pagination to see the remainder. + aiBridgeSessionNetworkCallsLimit = 1000 ) // errInvalidCursor is returned when a pagination cursor does not @@ -374,7 +375,7 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques userPrompts []database.AIBridgeUserPrompt modelThoughts []database.AIBridgeModelThought topDomains []database.GetAIBridgeSessionTopDomainsRow - networkCalls []database.ListAIBridgeSessionNetworkCallsRow + networkCalls []database.BoundaryLog ) err = api.Database.InTx(func(db database.Store) error { // Validate cursor IDs before querying threads. The SQL diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index e661dde4a39..cba71e26178 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -1930,9 +1930,9 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { t.Run("NetworkCallsTruncated", func(t *testing.T) { t.Parallel() - // The per-call list is capped server-side (currently 100 rows) while the - // summary total reflects the whole session. When a session exceeds the - // cap the list is truncated but the summary total stays authoritative. + // The per-call list is capped server-side while the summary total + // reflects the whole session. When a session exceeds the cap the list is + // truncated but the summary total stays authoritative. db, ps := dbtestutil.NewDB(t) opts := aibridgeOpts(t) opts.Options.Database = db @@ -1954,9 +1954,10 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { AgentFirewallSequenceNumber: sql.NullInt32{Int32: 0, Valid: true}, }, &endedAt) - // 105 allowed HTTP calls at seqs 1..105, all in the interception's - // window (0, +inf). - const total = 105 + // Allowed HTTP calls at seqs 1..total, all in the interception's + // window (0, +inf), seeded past the server-side cap. + const listCap = 1000 + const total = listCap + 5 seeds := make([]boundaryLogSeed, 0, total) for seq := int32(1); seq <= total; seq++ { seeds = append(seeds, boundaryLogSeed{seq, "http", "https://api.github.com/x", true}) @@ -1966,13 +1967,13 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { res, err := client.AIBridgeGetSessionThreads(ctx, "trunc-net", uuid.Nil, uuid.Nil, 0) require.NoError(t, err) - // The summary reflects all 105 calls; the list is capped at 100. + // The summary reflects every call; the list stops at the cap. require.NotNil(t, res.NetworkCalls) require.EqualValues(t, total, res.NetworkCalls.Total) - require.Len(t, res.NetworkCallLogs, 100) + require.Len(t, res.NetworkCallLogs, listCap) // The cap keeps the earliest calls in chronological order. require.EqualValues(t, 1, res.NetworkCallLogs[0].SequenceNumber) - require.EqualValues(t, 100, res.NetworkCallLogs[99].SequenceNumber) + require.EqualValues(t, listCap, res.NetworkCallLogs[listCap-1].SequenceNumber) }) t.Run("NetworkSummaryDisabled", func(t *testing.T) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f231b5ac302..85652e506e0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -151,31 +151,6 @@ export interface AIBridgeSession { readonly last_active_at: string; } -// From codersdk/aibridge.go -/** - * AIBridgeSessionNetworkCall is a single Agent Firewall network call made - * during a session. Allowed reports whether the firewall allow-list matched; - * when false the call was blocked. - */ -export interface AIBridgeSessionNetworkCall { - readonly id: string; - readonly sequence_number: number; - readonly proto: string; - readonly method: string; - /** - * Detail is protocol-specific: the full URL for http, the hostname for dns, - * the path for fs. - */ - readonly detail: string; - readonly allowed: boolean; - /** - * MatchedRule is the allow-list rule that permitted the call. Nil when the - * call was blocked. - */ - readonly matched_rule?: string; - readonly created_at: string; -} - // From codersdk/aibridge.go /** * AIBridgeSessionNetworkCallSummary aggregates the Agent Firewall network @@ -232,10 +207,12 @@ export interface AIBridgeSessionThreadsResponse { readonly network_domain_count?: number; /** * NetworkCallLogs is the chronological list of individual network calls made - * during the session, capped server-side. Empty when the session did not - * pass through Agent Firewall. + * during the session, holding the earliest calls up to a server-side cap. + * NetworkCalls remains authoritative for whole-session totals, so a shorter + * list than NetworkCalls.Total means the list was truncated. Empty when the + * session did not pass through Agent Firewall. */ - readonly network_call_logs?: readonly AIBridgeSessionNetworkCall[]; + readonly network_call_logs?: readonly AgentFirewallLog[]; readonly threads: readonly AIBridgeThread[]; } From 5413c1fae8f04846bd101336b52196eb49bb4283 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Mon, 3 Aug 2026 08:49:22 +0000 Subject: [PATCH 5/5] refactor(coderd/database/db2sdk): drop redundant local variables in AIBridgeSessionThreads --- coderd/database/db2sdk/db2sdk.go | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 7d77626eca3..445f0222424 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1163,28 +1163,22 @@ type AIBridgeSessionThreadsParams struct { func AIBridgeSessionThreads(p AIBridgeSessionThreadsParams) codersdk.AIBridgeSessionThreadsResponse { session := p.Session interceptions := p.Interceptions - tokenUsages := p.TokenUsages - toolUsages := p.ToolUsages - userPrompts := p.UserPrompts - modelThoughts := p.ModelThoughts - topDomains := p.TopDomains - networkCalls := p.NetworkCalls // Index subresources by interception ID. tokensByInterception := make(map[uuid.UUID][]database.AIBridgeTokenUsage, len(interceptions)) - for _, tu := range tokenUsages { + for _, tu := range p.TokenUsages { tokensByInterception[tu.InterceptionID] = append(tokensByInterception[tu.InterceptionID], tu) } toolsByInterception := make(map[uuid.UUID][]database.AIBridgeToolUsage, len(interceptions)) - for _, tu := range toolUsages { + for _, tu := range p.ToolUsages { toolsByInterception[tu.InterceptionID] = append(toolsByInterception[tu.InterceptionID], tu) } promptsByInterception := make(map[uuid.UUID][]database.AIBridgeUserPrompt, len(interceptions)) - for _, up := range userPrompts { + for _, up := range p.UserPrompts { promptsByInterception[up.InterceptionID] = append(promptsByInterception[up.InterceptionID], up) } thoughtsByInterception := make(map[uuid.UUID][]database.AIBridgeModelThought, len(interceptions)) - for _, mt := range modelThoughts { + for _, mt := range p.ModelThoughts { thoughtsByInterception[mt.InterceptionID] = append(thoughtsByInterception[mt.InterceptionID], mt) } @@ -1222,7 +1216,7 @@ func AIBridgeSessionThreads(p AIBridgeSessionThreadsParams) codersdk.AIBridgeSes // Aggregate session-level token usage metadata from all token // usages in the session (not just the page). - sessionTokenMeta := aggregateTokenMetadata(tokenUsages) + sessionTokenMeta := aggregateTokenMetadata(p.TokenUsages) resp := codersdk.AIBridgeSessionThreadsResponse{ ID: session.SessionID, @@ -1268,7 +1262,7 @@ func AIBridgeSessionThreads(p AIBridgeSessionThreadsParams) codersdk.AIBridgeSes Blocked: session.NetworkCallsBlocked, } } - for _, d := range topDomains { + for _, d := range p.TopDomains { resp.NetworkTopDomains = append(resp.NetworkTopDomains, codersdk.AIBridgeSessionNetworkDomain{ Domain: d.Domain, Count: d.Count, @@ -1277,7 +1271,7 @@ func AIBridgeSessionThreads(p AIBridgeSessionThreadsParams) codersdk.AIBridgeSes // from the last row processed. resp.NetworkDomainCount = d.TotalDomains } - resp.NetworkCallLogs = AgentFirewallLogs(networkCalls) + resp.NetworkCallLogs = AgentFirewallLogs(p.NetworkCalls) return resp }