diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 54add36baea..c42703e7f57 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15097,6 +15097,14 @@ const docTemplate = `{ "type": "string" } }, + "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": [ + { + "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCallSummary" + } + ] + }, "providers": { "type": "array", "items": { @@ -15115,6 +15123,17 @@ const docTemplate = `{ } } }, + "codersdk.AIBridgeSessionNetworkCallSummary": { + "type": "object", + "properties": { + "blocked": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "codersdk.AIBridgeSessionThreadsResponse": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5687248ce4f..65f9f558b55 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13439,6 +13439,14 @@ "type": "string" } }, + "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": [ + { + "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCallSummary" + } + ] + }, "providers": { "type": "array", "items": { @@ -13457,6 +13465,17 @@ } } }, + "codersdk.AIBridgeSessionNetworkCallSummary": { + "type": "object", + "properties": { + "blocked": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "codersdk.AIBridgeSessionThreadsResponse": { "type": "object", "properties": { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 4754bbbe950..c57d4e70cc6 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1114,6 +1114,15 @@ func AIBridgeSession(row database.ListAIBridgeSessionsRow) codersdk.AIBridgeSess CacheWriteInputTokens: row.CacheWriteInputTokens, }, } + // NetworkCalls is only meaningful when the session passed through Agent + // Firewall. When it did not, leave it nil so the UI renders "Disabled" + // rather than a misleading zero count. + if row.FirewallActive { + session.NetworkCalls = &codersdk.AIBridgeSessionNetworkCallSummary{ + Total: row.NetworkCallsTotal, + Blocked: row.NetworkCallsBlocked, + } + } // Ensure non-nil slices for JSON serialization. if session.Providers == nil { session.Providers = []string{} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 4b91dea30ea..d85cb1c887c 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -4668,7 +4668,7 @@ CREATE INDEX idx_ai_providers_enabled ON ai_providers USING btree (enabled) WHER CREATE INDEX idx_ai_user_daily_spend_effective_group_id_day ON ai_user_daily_spend USING btree (effective_group_id, day); -CREATE INDEX idx_aibridge_interceptions_agent_firewall_session_id ON aibridge_interceptions USING btree (agent_firewall_session_id) WHERE (agent_firewall_session_id IS NOT NULL); +CREATE INDEX idx_aibridge_interceptions_agent_firewall_session_seq ON aibridge_interceptions USING btree (agent_firewall_session_id, agent_firewall_sequence_number) WHERE (agent_firewall_session_id IS NOT NULL); CREATE INDEX idx_aibridge_interceptions_client ON aibridge_interceptions USING btree (client); @@ -4722,7 +4722,7 @@ CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC); CREATE INDEX idx_boundary_logs_captured_at ON boundary_logs USING btree (captured_at); -CREATE INDEX idx_boundary_logs_session_seq ON boundary_logs USING btree (session_id, sequence_number); +CREATE INDEX idx_boundary_logs_session_seq ON boundary_logs USING btree (session_id, sequence_number) INCLUDE (matched_rule); CREATE INDEX idx_chat_debug_runs_chat_started ON chat_debug_runs USING btree (chat_id, started_at DESC); diff --git a/coderd/database/migrations/000550_aibridge_firewall_seq_index.down.sql b/coderd/database/migrations/000550_aibridge_firewall_seq_index.down.sql new file mode 100644 index 00000000000..5a01fd61ecf --- /dev/null +++ b/coderd/database/migrations/000550_aibridge_firewall_seq_index.down.sql @@ -0,0 +1,10 @@ +DROP INDEX IF EXISTS idx_boundary_logs_session_seq; + +CREATE INDEX idx_boundary_logs_session_seq + ON boundary_logs (session_id, sequence_number); + +DROP INDEX IF EXISTS idx_aibridge_interceptions_agent_firewall_session_seq; + +CREATE INDEX idx_aibridge_interceptions_agent_firewall_session_id + ON aibridge_interceptions (agent_firewall_session_id) + WHERE agent_firewall_session_id IS NOT NULL; diff --git a/coderd/database/migrations/000550_aibridge_firewall_seq_index.up.sql b/coderd/database/migrations/000550_aibridge_firewall_seq_index.up.sql new file mode 100644 index 00000000000..3bbadfc3614 --- /dev/null +++ b/coderd/database/migrations/000550_aibridge_firewall_seq_index.up.sql @@ -0,0 +1,15 @@ +-- Replace the session-only index with a composite index on +-- (agent_firewall_session_id, agent_firewall_sequence_number). The sessions +-- list computes each interception's next firewall sequence number to bound the +-- boundary_logs it triggered; the composite index serves that lookup index-only +-- and still covers session-only lookups. +DROP INDEX IF EXISTS idx_aibridge_interceptions_agent_firewall_session_id; + +CREATE INDEX idx_aibridge_interceptions_agent_firewall_session_seq + ON aibridge_interceptions (agent_firewall_session_id, agent_firewall_sequence_number) + WHERE agent_firewall_session_id IS NOT NULL; + +DROP INDEX IF EXISTS idx_boundary_logs_session_seq; + +CREATE INDEX idx_boundary_logs_session_seq + ON boundary_logs (session_id, sequence_number) INCLUDE (matched_rule); diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index ea3213d8ec1..5c1205eeed3 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -1052,6 +1052,9 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeSessions(ctx context.Context, arg Lis &i.CacheWriteInputTokens, &i.LastPrompt, &i.LastActiveAt, + &i.NetworkCallsTotal, + &i.NetworkCallsBlocked, + &i.FirewallActive, ); err != nil { return nil, err } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index dbb5c8c7a6f..e77fd2b5994 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2115,7 +2115,10 @@ SELECT COALESCE(st.cache_read_input_tokens, 0)::bigint AS cache_read_input_tokens, COALESCE(st.cache_write_input_tokens, 0)::bigint AS cache_write_input_tokens, COALESCE(slp.prompt, '') AS last_prompt, - sp.last_active_at AS last_active_at + sp.last_active_at AS last_active_at, + COALESCE(bnc.total, 0)::bigint AS network_calls_total, + COALESCE(bnc.blocked, 0)::bigint AS network_calls_blocked, + COALESCE(sr.firewall_active, false) AS firewall_active FROM session_page sp JOIN @@ -2126,7 +2129,8 @@ LEFT JOIN LATERAL ( (ARRAY_AGG(ai.metadata ORDER BY ai.started_at, ai.id))[1] AS metadata, ARRAY_AGG(DISTINCT ai.provider ORDER BY ai.provider) AS providers, ARRAY_AGG(DISTINCT ai.model ORDER BY ai.model) AS models, - ARRAY_AGG(ai.id) AS interception_ids + ARRAY_AGG(ai.id) AS interception_ids, + BOOL_OR(ai.agent_firewall_session_id IS NOT NULL) AS firewall_active FROM aibridge_interceptions ai WHERE ai.session_id = sp.session_id AND ai.initiator_id = sp.initiator_id @@ -2151,6 +2155,33 @@ LEFT JOIN LATERAL ( ORDER BY up.created_at DESC, up.id DESC LIMIT 1 ) slp ON true +LEFT JOIN LATERAL ( + -- Count Agent Firewall network calls attributed to this session. Each + -- interception marks a point in its firewall session's monotonic sequence + -- stream; the boundary logs it triggered 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 + -- (logged at exactly its sequence number), leaving the agent's other + -- egress. next_seq considers all interceptions in the firewall session so + -- windows never bleed across AI sessions that share one firewall session. + SELECT + COUNT(*)::bigint AS total, + COUNT(*) FILTER (WHERE bl.matched_rule IS NULL)::bigint AS blocked + 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.id = ANY(sr.interception_ids) + AND afi.agent_firewall_session_id IS NOT NULL + AND afi.agent_firewall_sequence_number IS NOT NULL +) bnc ON true ORDER BY sp.last_active_at DESC, sp.session_id DESC @@ -2189,6 +2220,9 @@ type ListAIBridgeSessionsRow struct { CacheWriteInputTokens int64 `db:"cache_write_input_tokens" json:"cache_write_input_tokens"` LastPrompt string `db:"last_prompt" json:"last_prompt"` LastActiveAt time.Time `db:"last_active_at" json:"last_active_at"` + NetworkCallsTotal int64 `db:"network_calls_total" json:"network_calls_total"` + NetworkCallsBlocked int64 `db:"network_calls_blocked" json:"network_calls_blocked"` + FirewallActive bool `db:"firewall_active" json:"firewall_active"` } // Returns paginated sessions with aggregated metadata, token counts, and @@ -2238,6 +2272,9 @@ func (q *sqlQuerier) ListAIBridgeSessions(ctx context.Context, arg ListAIBridgeS &i.CacheWriteInputTokens, &i.LastPrompt, &i.LastActiveAt, + &i.NetworkCallsTotal, + &i.NetworkCallsBlocked, + &i.FirewallActive, ); err != nil { return nil, err } diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index 1c396cc6909..63635b6ae22 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -479,7 +479,10 @@ SELECT COALESCE(st.cache_read_input_tokens, 0)::bigint AS cache_read_input_tokens, COALESCE(st.cache_write_input_tokens, 0)::bigint AS cache_write_input_tokens, COALESCE(slp.prompt, '') AS last_prompt, - sp.last_active_at AS last_active_at + sp.last_active_at AS last_active_at, + COALESCE(bnc.total, 0)::bigint AS network_calls_total, + COALESCE(bnc.blocked, 0)::bigint AS network_calls_blocked, + COALESCE(sr.firewall_active, false) AS firewall_active FROM session_page sp JOIN @@ -490,7 +493,8 @@ LEFT JOIN LATERAL ( (ARRAY_AGG(ai.metadata ORDER BY ai.started_at, ai.id))[1] AS metadata, ARRAY_AGG(DISTINCT ai.provider ORDER BY ai.provider) AS providers, ARRAY_AGG(DISTINCT ai.model ORDER BY ai.model) AS models, - ARRAY_AGG(ai.id) AS interception_ids + ARRAY_AGG(ai.id) AS interception_ids, + BOOL_OR(ai.agent_firewall_session_id IS NOT NULL) AS firewall_active FROM aibridge_interceptions ai WHERE ai.session_id = sp.session_id AND ai.initiator_id = sp.initiator_id @@ -515,6 +519,33 @@ LEFT JOIN LATERAL ( ORDER BY up.created_at DESC, up.id DESC LIMIT 1 ) slp ON true +LEFT JOIN LATERAL ( + -- Count Agent Firewall network calls attributed to this session. Each + -- interception marks a point in its firewall session's monotonic sequence + -- stream; the boundary logs it triggered 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 + -- (logged at exactly its sequence number), leaving the agent's other + -- egress. next_seq considers all interceptions in the firewall session so + -- windows never bleed across AI sessions that share one firewall session. + SELECT + COUNT(*)::bigint AS total, + COUNT(*) FILTER (WHERE bl.matched_rule IS NULL)::bigint AS blocked + 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.id = ANY(sr.interception_ids) + AND afi.agent_firewall_session_id IS NOT NULL + AND afi.agent_firewall_sequence_number IS NOT NULL +) bnc ON true ORDER BY sp.last_active_at DESC, sp.session_id DESC diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index 7b92638ac59..089c2a7e7ab 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -66,8 +66,13 @@ type AIBridgeSession struct { EndedAt *time.Time `json:"ended_at,omitempty" format:"date-time"` Threads int64 `json:"threads"` TokenUsageSummary AIBridgeSessionTokenUsageSummary `json:"token_usage_summary"` - LastPrompt *string `json:"last_prompt,omitempty"` - LastActiveAt time.Time `json:"last_active_at" format:"date-time"` + // NetworkCalls 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". + NetworkCalls *AIBridgeSessionNetworkCallSummary `json:"network_calls,omitempty"` + LastPrompt *string `json:"last_prompt,omitempty"` + LastActiveAt time.Time `json:"last_active_at" format:"date-time"` } type AIBridgeSessionTokenUsageSummary struct { @@ -77,6 +82,14 @@ type AIBridgeSessionTokenUsageSummary struct { CacheWriteInputTokens int64 `json:"cache_write_input_tokens"` } +// AIBridgeSessionNetworkCallSummary aggregates the Agent Firewall network +// calls made during a session. Blocked counts calls denied by the firewall +// allow-list. +type AIBridgeSessionNetworkCallSummary struct { + Total int64 `json:"total"` + Blocked int64 `json:"blocked"` +} + type AIBridgeListSessionsResponse struct { Count int64 `json:"count"` Sessions []AIBridgeSession `json:"sessions"` diff --git a/docs/reference/api/aigateway.md b/docs/reference/api/aigateway.md index 09e869a1693..3d8ed690729 100644 --- a/docs/reference/api/aigateway.md +++ b/docs/reference/api/aigateway.md @@ -121,6 +121,10 @@ Alias: also available at /api/v2/aibridge/sessions for backward compatibility. "models": [ "string" ], + "network_calls": { + "blocked": 0, + "total": 0 + }, "providers": [ "string" ], diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index fcbe267ec20..62584af5e03 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -483,6 +483,10 @@ "models": [ "string" ], + "network_calls": { + "blocked": 0, + "total": 0 + }, "providers": [ "string" ], @@ -598,6 +602,10 @@ "models": [ "string" ], + "network_calls": { + "blocked": 0, + "total": 0 + }, "providers": [ "string" ], @@ -614,21 +622,38 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|-----------------------|----------------------------------------------------------------------------------------|----------|--------------|-------------| -| `client` | string | false | | | -| `ended_at` | string | false | | | -| `id` | string | false | | | -| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | -| `last_active_at` | string | false | | | -| `last_prompt` | string | false | | | -| `metadata` | object | false | | | -| » `[any property]` | any | false | | | -| `models` | array of string | false | | | -| `providers` | array of string | false | | | -| `started_at` | string | false | | | -| `threads` | integer | false | | | -| `token_usage_summary` | [codersdk.AIBridgeSessionTokenUsageSummary](#codersdkaibridgesessiontokenusagesummary) | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------|------------------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client` | string | false | | | +| `ended_at` | string | false | | | +| `id` | string | false | | | +| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | +| `last_active_at` | string | false | | | +| `last_prompt` | string | false | | | +| `metadata` | object | false | | | +| » `[any property]` | any | false | | | +| `models` | array of string | false | | | +| `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". | +| `providers` | array of string | false | | | +| `started_at` | string | false | | | +| `threads` | integer | false | | | +| `token_usage_summary` | [codersdk.AIBridgeSessionTokenUsageSummary](#codersdkaibridgesessiontokenusagesummary) | false | | | + +## codersdk.AIBridgeSessionNetworkCallSummary + +```json +{ + "blocked": 0, + "total": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|---------|----------|--------------|-------------| +| `blocked` | integer | false | | | +| `total` | integer | false | | | ## codersdk.AIBridgeSessionThreadsResponse diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index ccfe716864b..2e791d675a1 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -16,6 +16,7 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -254,6 +255,132 @@ func TestAIBridgeListSessions(t *testing.T) { require.ElementsMatch(t, []string{"claude-4", "gpt-4"}, s4.Models) }) + t.Run("NetworkCalls", func(t *testing.T) { + t.Parallel() + 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() + + makeInterception := func(clientSessionID string, startOffset time.Duration, fw *uuid.UUID, seq int32) { + endedAt := now.Add(startOffset + time.Minute) + params := database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + StartedAt: now.Add(startOffset), + ClientSessionID: sql.NullString{String: clientSessionID, Valid: true}, + } + if fw != nil { + params.AgentFirewallSessionID = uuid.NullUUID{UUID: *fw, Valid: true} + params.AgentFirewallSequenceNumber = sql.NullInt32{Int32: seq, Valid: true} + } + dbgen.AIBridgeInterception(t, db, params, &endedAt) + } + + type logSeed struct { + seq int32 + allowed bool + } + sysCtx := dbauthz.AsSystemRestricted(ctx) + insertLogs := func(fw uuid.UUID, seeds []logSeed) { + params := database.InsertBoundaryLogsParams{ + SessionID: fw, + OwnerID: firstUser.UserID, + } + for _, s := range seeds { + rule := "" + if s.allowed { + rule = "allow example.com" + } + params.ID = append(params.ID, uuid.New()) + params.SequenceNumber = append(params.SequenceNumber, s.seq) + params.CapturedAt = append(params.CapturedAt, now) + params.CreatedAt = append(params.CreatedAt, now) + params.Proto = append(params.Proto, "http") + params.Method = append(params.Method, "GET") + params.Detail = append(params.Detail, "https://example.com") + params.MatchedRule = append(params.MatchedRule, rule) + } + _, err := db.InsertBoundaryLogs(sysCtx, params) + require.NoError(t, err, "insert boundary logs") + } + + fw1, fw2, fw3, fw4 := uuid.New(), uuid.New(), uuid.New(), uuid.New() + + // Sessions A and B share firewall session fw1. A is marked at seq 0, B at + // seq 3, so A's window is (0,3) and B's is (3, +inf). + makeInterception("sess-A", -time.Minute, &fw1, 0) + makeInterception("sess-B", -2*time.Minute, &fw1, 3) + insertLogs(fw1, []logSeed{ + {0, true}, // LLM call for A, excluded + {1, true}, // A egress + {2, false}, // A egress, blocked + {3, true}, // LLM call for B, excluded + {4, true}, // B egress + {5, true}, // B egress + }) + + // Session C spans two firewall sessions (agent restarted): fw2 and fw3. + // Its counts sum across both windows. + makeInterception("sess-C", -3*time.Minute, &fw2, 0) + makeInterception("sess-C", -4*time.Minute, &fw3, 0) + insertLogs(fw2, []logSeed{ + {0, true}, // LLM call, excluded + {1, true}, + {2, true}, + }) + insertLogs(fw3, []logSeed{ + {0, true}, // LLM call, excluded + {1, false}, // blocked + }) + + // Session D never passed through the firewall: NetworkCalls stays nil. + makeInterception("sess-D", -5*time.Minute, nil, 0) + + // Session E is firewall-active but has no logs in range: counts are zero. + makeInterception("sess-E", -6*time.Minute, &fw4, 0) + + //nolint:gocritic // Owner role is irrelevant here. + res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{}) + require.NoError(t, err) + + byID := make(map[string]codersdk.AIBridgeSession, len(res.Sessions)) + for _, s := range res.Sessions { + byID[s.ID] = s + } + + // A: seq 1,2 in (0,3); seq 2 blocked. LLM calls at 0 and 3 excluded. + a := byID["sess-A"] + require.NotNil(t, a.NetworkCalls) + require.EqualValues(t, 2, a.NetworkCalls.Total) + require.EqualValues(t, 1, a.NetworkCalls.Blocked) + + // B: seq 4,5 in (3, +inf); none blocked. No bleed from A's window. + b := byID["sess-B"] + require.NotNil(t, b.NetworkCalls) + require.EqualValues(t, 2, b.NetworkCalls.Total) + require.EqualValues(t, 0, b.NetworkCalls.Blocked) + + // C: fw2 contributes seq 1,2; fw3 contributes seq 1 (blocked). + c := byID["sess-C"] + require.NotNil(t, c.NetworkCalls) + require.EqualValues(t, 3, c.NetworkCalls.Total) + require.EqualValues(t, 1, c.NetworkCalls.Blocked) + + // D: no firewall session, so monitoring was not active. + d := byID["sess-D"] + require.Nil(t, d.NetworkCalls) + + // E: firewall-active but no logs in range. + e := byID["sess-E"] + require.NotNil(t, e.NetworkCalls) + require.EqualValues(t, 0, e.NetworkCalls.Total) + require.EqualValues(t, 0, e.NetworkCalls.Blocked) + }) + t.Run("Pagination", func(t *testing.T) { t.Parallel() client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t)) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b4ee9f1ab0b..28bcda4f94c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -140,10 +140,28 @@ export interface AIBridgeSession { readonly ended_at?: string; readonly threads: number; readonly token_usage_summary: AIBridgeSessionTokenUsageSummary; + /** + * NetworkCalls 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". + */ + readonly network_calls?: AIBridgeSessionNetworkCallSummary; readonly last_prompt?: string; readonly last_active_at: string; } +// From codersdk/aibridge.go +/** + * AIBridgeSessionNetworkCallSummary aggregates the Agent Firewall network + * calls made during a session. Blocked counts calls denied by the firewall + * allow-list. + */ +export interface AIBridgeSessionNetworkCallSummary { + readonly total: number; + readonly blocked: number; +} + // From codersdk/aibridge.go /** * AIBridgeSessionThreadsResponse is the response for GET