Merge pull request #2858 from diegosouzapw/refactor/pages-v3-A-agent-bridge-traffic-inspector

feat(mitm,inspector): AgentBridge + Traffic Inspector (planos 11+12 / Group A)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-30 02:47:45 -03:00
committed by GitHub
249 changed files with 24981 additions and 268 deletions

View File

@@ -25,11 +25,15 @@ This workflow reads all open GitHub Discussions, generates a categorized summary
- Run: `git -C <project_root> remote get-url origin` to extract `owner/repo`.
- Parse owner and repo name from the URL (https or ssh form).
### 2. Fetch All Open Discussions (single GraphQL query)
### 2. Fetch All Open Discussions (paginated GraphQL)
Single `gh api graphql` call — return everything needed for triage. Critical fields: `id` (node ID, **not** the visible `number`), `number`, `title`, `url`, `createdAt`, `updatedAt`, `author.login`, `category.name`, `body`, `answerChosenAt`, plus nested `comments(first: 50) { totalCount, nodes { id, author.login, body, createdAt, replies(first: 20) { nodes { author.login, body, createdAt } } } }`.
GraphQL caps each `discussions` query at 50 nodes — repos with more than 50 open discussions **must paginate**. Loop with `first: 50, after: $cursor` until `pageInfo.hasNextPage` is `false`. Skipping pagination silently drops the older half of the backlog, which is exactly where most stale-candidates and unanswered follow-ups live (regression observed 2026-05-28: page-1-only fetch missed 5 follow-ups and 4 stale candidates ranging from 23d to 56d).
Persist the raw JSON to `/tmp/discussions-<repo>-<date>.json` so re-runs in the same session avoid a re-fetch. Build an `id → number` map for the post phase — the GraphQL `addDiscussionComment` mutation requires the node ID, not the number.
Each page request must return the **same field set** — easy mistake is to fetch page 2 without `body` (because the cursor query was hand-edited). Define one query string with `body` on both the discussion and every comment/reply, and reuse it across pages.
Critical fields per discussion: `id` (node ID, **not** the visible `number`), `number`, `title`, `url`, `createdAt`, `updatedAt`, `author.login`, `category.name`, `body`, `answerChosenAt`, `labels(first: 10) { nodes { name } }`, plus nested `comments(first: 50) { totalCount, nodes { id, author.login, body, createdAt, replies(first: 20) { nodes { author.login, body, createdAt } } } }`. Must also include `pageInfo { hasNextPage endCursor }` on the discussions connection.
Persist the **merged** result (all pages concatenated) to `/tmp/discussions-<repo>-<date>.json` so re-runs in the same session avoid a re-fetch. Build an `id → number` map for the post phase — the GraphQL `addDiscussionComment` mutation requires the node ID, not the number.
Capture **image attachments** present in body or comments (`<img src="...">` or markdown `![...](...)`). Surface their count in the per-discussion summary (e.g., `📷 3 screenshots`) so the user can decide if visual context matters before approving a draft.

View File

@@ -25,11 +25,15 @@ This workflow reads all open GitHub Discussions, generates a categorized summary
- Run: `git -C <project_root> remote get-url origin` to extract `owner/repo`.
- Parse owner and repo name from the URL (https or ssh form).
### 2. Fetch All Open Discussions (single GraphQL query)
### 2. Fetch All Open Discussions (paginated GraphQL)
Single `gh api graphql` call — return everything needed for triage. Critical fields: `id` (node ID, **not** the visible `number`), `number`, `title`, `url`, `createdAt`, `updatedAt`, `author.login`, `category.name`, `body`, `answerChosenAt`, plus nested `comments(first: 50) { totalCount, nodes { id, author.login, body, createdAt, replies(first: 20) { nodes { author.login, body, createdAt } } } }`.
GraphQL caps each `discussions` query at 50 nodes — repos with more than 50 open discussions **must paginate**. Loop with `first: 50, after: $cursor` until `pageInfo.hasNextPage` is `false`. Skipping pagination silently drops the older half of the backlog, which is exactly where most stale-candidates and unanswered follow-ups live (regression observed 2026-05-28: page-1-only fetch missed 5 follow-ups and 4 stale candidates ranging from 23d to 56d).
Persist the raw JSON to `/tmp/discussions-<repo>-<date>.json` so re-runs in the same session avoid a re-fetch. Build an `id → number` map for the post phase — the GraphQL `addDiscussionComment` mutation requires the node ID, not the number.
Each page request must return the **same field set** — easy mistake is to fetch page 2 without `body` (because the cursor query was hand-edited). Define one query string with `body` on both the discussion and every comment/reply, and reuse it across pages.
Critical fields per discussion: `id` (node ID, **not** the visible `number`), `number`, `title`, `url`, `createdAt`, `updatedAt`, `author.login`, `category.name`, `body`, `answerChosenAt`, `labels(first: 10) { nodes { name } }`, plus nested `comments(first: 50) { totalCount, nodes { id, author.login, body, createdAt, replies(first: 20) { nodes { author.login, body, createdAt } } } }`. Must also include `pageInfo { hasNextPage endCursor }` on the discussions connection.
Persist the **merged** result (all pages concatenated) to `/tmp/discussions-<repo>-<date>.json` so re-runs in the same session avoid a re-fetch. Build an `id → number` map for the post phase — the GraphQL `addDiscussionComment` mutation requires the node ID, not the number.
Capture **image attachments** present in body or comments (`<img src="...">` or markdown `![...](...)`). Surface their count in the per-discussion summary (e.g., `📷 3 screenshots`) so the user can decide if visual context matters before approving a draft.

View File

@@ -32,11 +32,15 @@ This workflow reads all open GitHub Discussions, generates a categorized summary
- Run: `git -C <project_root> remote get-url origin` to extract `owner/repo`.
- Parse owner and repo name from the URL (https or ssh form).
### 2. Fetch All Open Discussions (single GraphQL query)
### 2. Fetch All Open Discussions (paginated GraphQL)
Single `gh api graphql` call — return everything needed for triage. Critical fields: `id` (node ID, **not** the visible `number`), `number`, `title`, `url`, `createdAt`, `updatedAt`, `author.login`, `category.name`, `body`, `answerChosenAt`, plus nested `comments(first: 50) { totalCount, nodes { id, author.login, body, createdAt, replies(first: 20) { nodes { author.login, body, createdAt } } } }`.
GraphQL caps each `discussions` query at 50 nodes — repos with more than 50 open discussions **must paginate**. Loop with `first: 50, after: $cursor` until `pageInfo.hasNextPage` is `false`. Skipping pagination silently drops the older half of the backlog, which is exactly where most stale-candidates and unanswered follow-ups live (regression observed 2026-05-28: page-1-only fetch missed 5 follow-ups and 4 stale candidates ranging from 23d to 56d).
Persist the raw JSON to `/tmp/discussions-<repo>-<date>.json` so re-runs in the same session avoid a re-fetch. Build an `id → number` map for the post phase — the GraphQL `addDiscussionComment` mutation requires the node ID, not the number.
Each page request must return the **same field set** — easy mistake is to fetch page 2 without `body` (because the cursor query was hand-edited). Define one query string with `body` on both the discussion and every comment/reply, and reuse it across pages.
Critical fields per discussion: `id` (node ID, **not** the visible `number`), `number`, `title`, `url`, `createdAt`, `updatedAt`, `author.login`, `category.name`, `body`, `answerChosenAt`, `labels(first: 10) { nodes { name } }`, plus nested `comments(first: 50) { totalCount, nodes { id, author.login, body, createdAt, replies(first: 20) { nodes { author.login, body, createdAt } } } }`. Must also include `pageInfo { hasNextPage endCursor }` on the discussions connection.
Persist the **merged** result (all pages concatenated) to `/tmp/discussions-<repo>-<date>.json` so re-runs in the same session avoid a re-fetch. Build an `id → number` map for the post phase — the GraphQL `addDiscussionComment` mutation requires the node ID, not the number.
Capture **image attachments** present in body or comments (`<img src="...">` or markdown `![...](...)`). Surface their count in the per-discussion summary (e.g., `📷 3 screenshots`) so the user can decide if visual context matters before approving a draft.

View File

@@ -1345,6 +1345,21 @@ APP_LOG_TO_FILE=true
# ELECTRON_SMOKE_KEEP_DATA=0
# ELECTRON_SMOKE_STREAM_LOGS=0
# AgentBridge + Traffic Inspector (Group A)
# AgentBridge
AGENTBRIDGE_UPSTREAM_CA_CERT=
# Inspector
INSPECTOR_BUFFER_SIZE=1000
INSPECTOR_HTTP_PROXY_PORT=8080
INSPECTOR_HTTP_PROXY_AUTOSTART=false
INSPECTOR_TLS_INTERCEPT=false
INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES=30
INSPECTOR_MAX_BODY_KB=1024
INSPECTOR_MASK_SECRETS=true
INSPECTOR_LLM_HOSTS_EXTRA=
INSPECTOR_INTERNAL_INGEST_TOKEN=
# Quota Sharing (Group B — planos 16+22)
QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)

File diff suppressed because one or more lines are too long

View File

@@ -1,67 +1,71 @@
// @ts-nocheck
import { default as __fd_glob_65 } from "../docs/security/meta.json?collection=docs"
import { default as __fd_glob_64 } from "../docs/routing/meta.json?collection=docs"
import { default as __fd_glob_63 } from "../docs/reference/openapi.yaml?collection=docs"
import { default as __fd_glob_62 } from "../docs/reference/meta.json?collection=docs"
import { default as __fd_glob_61 } from "../docs/ops/meta.json?collection=docs"
import { default as __fd_glob_60 } from "../docs/guides/meta.json?collection=docs"
import { default as __fd_glob_59 } from "../docs/frameworks/meta.json?collection=docs"
import { default as __fd_glob_58 } from "../docs/compression/meta.json?collection=docs"
import { default as __fd_glob_57 } from "../docs/architecture/meta.json?collection=docs"
import { default as __fd_glob_56 } from "../docs/meta.json?collection=docs"
import * as __fd_glob_55 from "../docs/security/STEALTH_GUIDE.md?collection=docs"
import * as __fd_glob_54 from "../docs/security/SOCKET_DEV_FINDINGS.md?collection=docs"
import * as __fd_glob_53 from "../docs/security/ROUTE_GUARD_TIERS.md?collection=docs"
import * as __fd_glob_52 from "../docs/security/PUBLIC_CREDS.md?collection=docs"
import * as __fd_glob_51 from "../docs/security/GUARDRAILS.md?collection=docs"
import * as __fd_glob_50 from "../docs/security/ERROR_SANITIZATION.md?collection=docs"
import * as __fd_glob_49 from "../docs/security/COMPLIANCE.md?collection=docs"
import * as __fd_glob_48 from "../docs/security/CLI_TOKEN_AUTH.md?collection=docs"
import * as __fd_glob_47 from "../docs/security/CLI_TOKEN.md?collection=docs"
import * as __fd_glob_46 from "../docs/routing/REASONING_REPLAY.md?collection=docs"
import * as __fd_glob_45 from "../docs/routing/AUTO-COMBO.md?collection=docs"
import * as __fd_glob_44 from "../docs/reference/PROVIDER_REFERENCE.md?collection=docs"
import * as __fd_glob_43 from "../docs/reference/FREE_TIERS.md?collection=docs"
import * as __fd_glob_42 from "../docs/reference/ENVIRONMENT.md?collection=docs"
import * as __fd_glob_41 from "../docs/reference/CLI-TOOLS.md?collection=docs"
import * as __fd_glob_40 from "../docs/reference/API_REFERENCE.md?collection=docs"
import * as __fd_glob_39 from "../docs/ops/VM_DEPLOYMENT_GUIDE.md?collection=docs"
import * as __fd_glob_38 from "../docs/ops/TUNNELS_GUIDE.md?collection=docs"
import * as __fd_glob_37 from "../docs/ops/SQLITE_RUNTIME.md?collection=docs"
import * as __fd_glob_36 from "../docs/ops/RELEASE_CHECKLIST.md?collection=docs"
import * as __fd_glob_35 from "../docs/ops/PROXY_GUIDE.md?collection=docs"
import * as __fd_glob_34 from "../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs"
import * as __fd_glob_33 from "../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs"
import * as __fd_glob_32 from "../docs/ops/COVERAGE_PLAN.md?collection=docs"
import * as __fd_glob_31 from "../docs/frameworks/WEBHOOKS.md?collection=docs"
import * as __fd_glob_30 from "../docs/frameworks/SKILLS.md?collection=docs"
import * as __fd_glob_29 from "../docs/frameworks/OPENCODE.md?collection=docs"
import * as __fd_glob_28 from "../docs/frameworks/MEMORY.md?collection=docs"
import * as __fd_glob_27 from "../docs/frameworks/MCP-SERVER.md?collection=docs"
import * as __fd_glob_26 from "../docs/frameworks/GAMIFICATION.md?collection=docs"
import * as __fd_glob_25 from "../docs/frameworks/EVALS.md?collection=docs"
import * as __fd_glob_24 from "../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs"
import * as __fd_glob_23 from "../docs/frameworks/CLOUD_AGENT.md?collection=docs"
import * as __fd_glob_22 from "../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs"
import * as __fd_glob_21 from "../docs/frameworks/A2A-SERVER.md?collection=docs"
import * as __fd_glob_20 from "../docs/guides/USER_GUIDE.md?collection=docs"
import * as __fd_glob_19 from "../docs/guides/UNINSTALL.md?collection=docs"
import * as __fd_glob_18 from "../docs/guides/TROUBLESHOOTING.md?collection=docs"
import * as __fd_glob_17 from "../docs/guides/TERMUX_GUIDE.md?collection=docs"
import * as __fd_glob_16 from "../docs/guides/SETUP_GUIDE.md?collection=docs"
import * as __fd_glob_15 from "../docs/guides/PWA_GUIDE.md?collection=docs"
import * as __fd_glob_14 from "../docs/guides/KIRO_SETUP.md?collection=docs"
import * as __fd_glob_13 from "../docs/guides/I18N.md?collection=docs"
import * as __fd_glob_12 from "../docs/guides/FEATURES.md?collection=docs"
import * as __fd_glob_11 from "../docs/guides/ELECTRON_GUIDE.md?collection=docs"
import * as __fd_glob_10 from "../docs/guides/DOCKER_GUIDE.md?collection=docs"
import * as __fd_glob_9 from "../docs/compression/RTK_COMPRESSION.md?collection=docs"
import * as __fd_glob_8 from "../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs"
import * as __fd_glob_7 from "../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs"
import * as __fd_glob_6 from "../docs/compression/COMPRESSION_GUIDE.md?collection=docs"
import * as __fd_glob_5 from "../docs/compression/COMPRESSION_ENGINES.md?collection=docs"
import * as __fd_glob_4 from "../docs/architecture/RESILIENCE_GUIDE.md?collection=docs"
import * as __fd_glob_3 from "../docs/architecture/REPOSITORY_MAP.md?collection=docs"
import { default as __fd_glob_69 } from "../docs/security/meta.json?collection=docs"
import { default as __fd_glob_68 } from "../docs/routing/meta.json?collection=docs"
import { default as __fd_glob_67 } from "../docs/reference/openapi.yaml?collection=docs"
import { default as __fd_glob_66 } from "../docs/reference/meta.json?collection=docs"
import { default as __fd_glob_65 } from "../docs/ops/meta.json?collection=docs"
import { default as __fd_glob_64 } from "../docs/guides/meta.json?collection=docs"
import { default as __fd_glob_63 } from "../docs/frameworks/meta.json?collection=docs"
import { default as __fd_glob_62 } from "../docs/compression/meta.json?collection=docs"
import { default as __fd_glob_61 } from "../docs/architecture/meta.json?collection=docs"
import { default as __fd_glob_60 } from "../docs/meta.json?collection=docs"
import * as __fd_glob_59 from "../docs/security/STEALTH_GUIDE.md?collection=docs"
import * as __fd_glob_58 from "../docs/security/SOCKET_DEV_FINDINGS.md?collection=docs"
import * as __fd_glob_57 from "../docs/security/ROUTE_GUARD_TIERS.md?collection=docs"
import * as __fd_glob_56 from "../docs/security/PUBLIC_CREDS.md?collection=docs"
import * as __fd_glob_55 from "../docs/security/GUARDRAILS.md?collection=docs"
import * as __fd_glob_54 from "../docs/security/ERROR_SANITIZATION.md?collection=docs"
import * as __fd_glob_53 from "../docs/security/COMPLIANCE.md?collection=docs"
import * as __fd_glob_52 from "../docs/security/CLI_TOKEN_AUTH.md?collection=docs"
import * as __fd_glob_51 from "../docs/security/CLI_TOKEN.md?collection=docs"
import * as __fd_glob_50 from "../docs/routing/REASONING_REPLAY.md?collection=docs"
import * as __fd_glob_49 from "../docs/routing/QUOTA_SHARE.md?collection=docs"
import * as __fd_glob_48 from "../docs/routing/AUTO-COMBO.md?collection=docs"
import * as __fd_glob_47 from "../docs/reference/PROVIDER_REFERENCE.md?collection=docs"
import * as __fd_glob_46 from "../docs/reference/FREE_TIERS.md?collection=docs"
import * as __fd_glob_45 from "../docs/reference/ENVIRONMENT.md?collection=docs"
import * as __fd_glob_44 from "../docs/reference/CLI-TOOLS.md?collection=docs"
import * as __fd_glob_43 from "../docs/reference/API_REFERENCE.md?collection=docs"
import * as __fd_glob_42 from "../docs/ops/VM_DEPLOYMENT_GUIDE.md?collection=docs"
import * as __fd_glob_41 from "../docs/ops/TUNNELS_GUIDE.md?collection=docs"
import * as __fd_glob_40 from "../docs/ops/SQLITE_RUNTIME.md?collection=docs"
import * as __fd_glob_39 from "../docs/ops/RELEASE_CHECKLIST.md?collection=docs"
import * as __fd_glob_38 from "../docs/ops/PROXY_GUIDE.md?collection=docs"
import * as __fd_glob_37 from "../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs"
import * as __fd_glob_36 from "../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs"
import * as __fd_glob_35 from "../docs/ops/COVERAGE_PLAN.md?collection=docs"
import * as __fd_glob_34 from "../docs/guides/USER_GUIDE.md?collection=docs"
import * as __fd_glob_33 from "../docs/guides/UNINSTALL.md?collection=docs"
import * as __fd_glob_32 from "../docs/guides/TROUBLESHOOTING.md?collection=docs"
import * as __fd_glob_31 from "../docs/guides/TERMUX_GUIDE.md?collection=docs"
import * as __fd_glob_30 from "../docs/guides/SETUP_GUIDE.md?collection=docs"
import * as __fd_glob_29 from "../docs/guides/PWA_GUIDE.md?collection=docs"
import * as __fd_glob_28 from "../docs/guides/KIRO_SETUP.md?collection=docs"
import * as __fd_glob_27 from "../docs/guides/I18N.md?collection=docs"
import * as __fd_glob_26 from "../docs/guides/FEATURES.md?collection=docs"
import * as __fd_glob_25 from "../docs/guides/ELECTRON_GUIDE.md?collection=docs"
import * as __fd_glob_24 from "../docs/guides/DOCKER_GUIDE.md?collection=docs"
import * as __fd_glob_23 from "../docs/frameworks/WEBHOOKS.md?collection=docs"
import * as __fd_glob_22 from "../docs/frameworks/TRAFFIC_INSPECTOR.md?collection=docs"
import * as __fd_glob_21 from "../docs/frameworks/SKILLS.md?collection=docs"
import * as __fd_glob_20 from "../docs/frameworks/OPENCODE.md?collection=docs"
import * as __fd_glob_19 from "../docs/frameworks/MEMORY.md?collection=docs"
import * as __fd_glob_18 from "../docs/frameworks/MCP-SERVER.md?collection=docs"
import * as __fd_glob_17 from "../docs/frameworks/GAMIFICATION.md?collection=docs"
import * as __fd_glob_16 from "../docs/frameworks/EVALS.md?collection=docs"
import * as __fd_glob_15 from "../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs"
import * as __fd_glob_14 from "../docs/frameworks/CLOUD_AGENT.md?collection=docs"
import * as __fd_glob_13 from "../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs"
import * as __fd_glob_12 from "../docs/frameworks/AGENTBRIDGE.md?collection=docs"
import * as __fd_glob_11 from "../docs/frameworks/A2A-SERVER.md?collection=docs"
import * as __fd_glob_10 from "../docs/compression/RTK_COMPRESSION.md?collection=docs"
import * as __fd_glob_9 from "../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs"
import * as __fd_glob_8 from "../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs"
import * as __fd_glob_7 from "../docs/compression/COMPRESSION_GUIDE.md?collection=docs"
import * as __fd_glob_6 from "../docs/compression/COMPRESSION_ENGINES.md?collection=docs"
import * as __fd_glob_5 from "../docs/architecture/RESILIENCE_GUIDE.md?collection=docs"
import * as __fd_glob_4 from "../docs/architecture/REPOSITORY_MAP.md?collection=docs"
import * as __fd_glob_3 from "../docs/architecture/MONITORING_SECTIONS.md?collection=docs"
import * as __fd_glob_2 from "../docs/architecture/CODEBASE_DOCUMENTATION.md?collection=docs"
import * as __fd_glob_1 from "../docs/architecture/AUTHZ_GUIDE.md?collection=docs"
import * as __fd_glob_0 from "../docs/architecture/ARCHITECTURE.md?collection=docs"
@@ -73,4 +77,4 @@ const create = server<typeof Config, import("fumadocs-mdx/runtime/types").Intern
}
}>({"doc":{"passthroughs":["extractedReferences"]}});
export const docs = await create.docs("docs", "docs", {"meta.json": __fd_glob_56, "architecture/meta.json": __fd_glob_57, "compression/meta.json": __fd_glob_58, "frameworks/meta.json": __fd_glob_59, "guides/meta.json": __fd_glob_60, "ops/meta.json": __fd_glob_61, "reference/meta.json": __fd_glob_62, "reference/openapi.yaml": __fd_glob_63, "routing/meta.json": __fd_glob_64, "security/meta.json": __fd_glob_65, }, {"architecture/ARCHITECTURE.md": __fd_glob_0, "architecture/AUTHZ_GUIDE.md": __fd_glob_1, "architecture/CODEBASE_DOCUMENTATION.md": __fd_glob_2, "architecture/REPOSITORY_MAP.md": __fd_glob_3, "architecture/RESILIENCE_GUIDE.md": __fd_glob_4, "compression/COMPRESSION_ENGINES.md": __fd_glob_5, "compression/COMPRESSION_GUIDE.md": __fd_glob_6, "compression/COMPRESSION_LANGUAGE_PACKS.md": __fd_glob_7, "compression/COMPRESSION_RULES_FORMAT.md": __fd_glob_8, "compression/RTK_COMPRESSION.md": __fd_glob_9, "guides/DOCKER_GUIDE.md": __fd_glob_10, "guides/ELECTRON_GUIDE.md": __fd_glob_11, "guides/FEATURES.md": __fd_glob_12, "guides/I18N.md": __fd_glob_13, "guides/KIRO_SETUP.md": __fd_glob_14, "guides/PWA_GUIDE.md": __fd_glob_15, "guides/SETUP_GUIDE.md": __fd_glob_16, "guides/TERMUX_GUIDE.md": __fd_glob_17, "guides/TROUBLESHOOTING.md": __fd_glob_18, "guides/UNINSTALL.md": __fd_glob_19, "guides/USER_GUIDE.md": __fd_glob_20, "frameworks/A2A-SERVER.md": __fd_glob_21, "frameworks/AGENT_PROTOCOLS_GUIDE.md": __fd_glob_22, "frameworks/CLOUD_AGENT.md": __fd_glob_23, "frameworks/EMBEDDED-SERVICES.md": __fd_glob_24, "frameworks/EVALS.md": __fd_glob_25, "frameworks/GAMIFICATION.md": __fd_glob_26, "frameworks/MCP-SERVER.md": __fd_glob_27, "frameworks/MEMORY.md": __fd_glob_28, "frameworks/OPENCODE.md": __fd_glob_29, "frameworks/SKILLS.md": __fd_glob_30, "frameworks/WEBHOOKS.md": __fd_glob_31, "ops/COVERAGE_PLAN.md": __fd_glob_32, "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": __fd_glob_33, "ops/FLY_IO_DEPLOYMENT_GUIDE.md": __fd_glob_34, "ops/PROXY_GUIDE.md": __fd_glob_35, "ops/RELEASE_CHECKLIST.md": __fd_glob_36, "ops/SQLITE_RUNTIME.md": __fd_glob_37, "ops/TUNNELS_GUIDE.md": __fd_glob_38, "ops/VM_DEPLOYMENT_GUIDE.md": __fd_glob_39, "reference/API_REFERENCE.md": __fd_glob_40, "reference/CLI-TOOLS.md": __fd_glob_41, "reference/ENVIRONMENT.md": __fd_glob_42, "reference/FREE_TIERS.md": __fd_glob_43, "reference/PROVIDER_REFERENCE.md": __fd_glob_44, "routing/AUTO-COMBO.md": __fd_glob_45, "routing/REASONING_REPLAY.md": __fd_glob_46, "security/CLI_TOKEN.md": __fd_glob_47, "security/CLI_TOKEN_AUTH.md": __fd_glob_48, "security/COMPLIANCE.md": __fd_glob_49, "security/ERROR_SANITIZATION.md": __fd_glob_50, "security/GUARDRAILS.md": __fd_glob_51, "security/PUBLIC_CREDS.md": __fd_glob_52, "security/ROUTE_GUARD_TIERS.md": __fd_glob_53, "security/SOCKET_DEV_FINDINGS.md": __fd_glob_54, "security/STEALTH_GUIDE.md": __fd_glob_55, });
export const docs = await create.docs("docs", "docs", {"meta.json": __fd_glob_60, "architecture/meta.json": __fd_glob_61, "compression/meta.json": __fd_glob_62, "frameworks/meta.json": __fd_glob_63, "guides/meta.json": __fd_glob_64, "ops/meta.json": __fd_glob_65, "reference/meta.json": __fd_glob_66, "reference/openapi.yaml": __fd_glob_67, "routing/meta.json": __fd_glob_68, "security/meta.json": __fd_glob_69, }, {"architecture/ARCHITECTURE.md": __fd_glob_0, "architecture/AUTHZ_GUIDE.md": __fd_glob_1, "architecture/CODEBASE_DOCUMENTATION.md": __fd_glob_2, "architecture/MONITORING_SECTIONS.md": __fd_glob_3, "architecture/REPOSITORY_MAP.md": __fd_glob_4, "architecture/RESILIENCE_GUIDE.md": __fd_glob_5, "compression/COMPRESSION_ENGINES.md": __fd_glob_6, "compression/COMPRESSION_GUIDE.md": __fd_glob_7, "compression/COMPRESSION_LANGUAGE_PACKS.md": __fd_glob_8, "compression/COMPRESSION_RULES_FORMAT.md": __fd_glob_9, "compression/RTK_COMPRESSION.md": __fd_glob_10, "frameworks/A2A-SERVER.md": __fd_glob_11, "frameworks/AGENTBRIDGE.md": __fd_glob_12, "frameworks/AGENT_PROTOCOLS_GUIDE.md": __fd_glob_13, "frameworks/CLOUD_AGENT.md": __fd_glob_14, "frameworks/EMBEDDED-SERVICES.md": __fd_glob_15, "frameworks/EVALS.md": __fd_glob_16, "frameworks/GAMIFICATION.md": __fd_glob_17, "frameworks/MCP-SERVER.md": __fd_glob_18, "frameworks/MEMORY.md": __fd_glob_19, "frameworks/OPENCODE.md": __fd_glob_20, "frameworks/SKILLS.md": __fd_glob_21, "frameworks/TRAFFIC_INSPECTOR.md": __fd_glob_22, "frameworks/WEBHOOKS.md": __fd_glob_23, "guides/DOCKER_GUIDE.md": __fd_glob_24, "guides/ELECTRON_GUIDE.md": __fd_glob_25, "guides/FEATURES.md": __fd_glob_26, "guides/I18N.md": __fd_glob_27, "guides/KIRO_SETUP.md": __fd_glob_28, "guides/PWA_GUIDE.md": __fd_glob_29, "guides/SETUP_GUIDE.md": __fd_glob_30, "guides/TERMUX_GUIDE.md": __fd_glob_31, "guides/TROUBLESHOOTING.md": __fd_glob_32, "guides/UNINSTALL.md": __fd_glob_33, "guides/USER_GUIDE.md": __fd_glob_34, "ops/COVERAGE_PLAN.md": __fd_glob_35, "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": __fd_glob_36, "ops/FLY_IO_DEPLOYMENT_GUIDE.md": __fd_glob_37, "ops/PROXY_GUIDE.md": __fd_glob_38, "ops/RELEASE_CHECKLIST.md": __fd_glob_39, "ops/SQLITE_RUNTIME.md": __fd_glob_40, "ops/TUNNELS_GUIDE.md": __fd_glob_41, "ops/VM_DEPLOYMENT_GUIDE.md": __fd_glob_42, "reference/API_REFERENCE.md": __fd_glob_43, "reference/CLI-TOOLS.md": __fd_glob_44, "reference/ENVIRONMENT.md": __fd_glob_45, "reference/FREE_TIERS.md": __fd_glob_46, "reference/PROVIDER_REFERENCE.md": __fd_glob_47, "routing/AUTO-COMBO.md": __fd_glob_48, "routing/QUOTA_SHARE.md": __fd_glob_49, "routing/REASONING_REPLAY.md": __fd_glob_50, "security/CLI_TOKEN.md": __fd_glob_51, "security/CLI_TOKEN_AUTH.md": __fd_glob_52, "security/COMPLIANCE.md": __fd_glob_53, "security/ERROR_SANITIZATION.md": __fd_glob_54, "security/GUARDRAILS.md": __fd_glob_55, "security/PUBLIC_CREDS.md": __fd_glob_56, "security/ROUTE_GUARD_TIERS.md": __fd_glob_57, "security/SOCKET_DEV_FINDINGS.md": __fd_glob_58, "security/STEALTH_GUIDE.md": __fd_glob_59, });

View File

@@ -1,6 +1,57 @@
# Changelog
## [Unreleased]
## [Unreleased] — Group A: AgentBridge + Traffic Inspector (planos 11+12)
### Added
- **AgentBridge** (`/dashboard/tools/agent-bridge`) — MITM proxy consolidating 9 IDE agents
(Antigravity, Kiro, GitHub Copilot, OpenAI Codex, Cursor IDE, Zed Industries, Claude Code,
Open Code, Trae stub) with server card, per-agent setup wizard, model mapping table,
bypass list, upstream CA cert support, and redirect from legacy `/dashboard/system/mitm-proxy`.
See `docs/frameworks/AGENTBRIDGE.md`.
- **Traffic Inspector** (`/dashboard/tools/traffic-inspector`) — LLM-aware HTTPS debugger with
4 capture modes (AgentBridge hook, Custom Hosts DNS, HTTP_PROXY :8080, System-wide proxy),
DevTools split UI, 7 detail tabs (Conversation, Headers, Request, Response, Timing, LLM Details,
Stats), resizable panels, session recording (.har/.jsonl export), SSE stream merger,
conversation normalizer (multi-provider), system-prompt fingerprint colorization, and annotations.
See `docs/frameworks/TRAFFIC_INSPECTOR.md`.
- **MITM handler base + 9 agent handlers** (`src/mitm/handlers/`) — `MitmHandlerBase` abstract
class with `hookBufferStart`/`hookBufferUpdate` for Traffic Inspector integration; concrete
handlers for all 9 agents.
- **MITM targets registry** (`src/mitm/targets/`) — declarative `MitmTarget` shape per agent;
emits `DATA_DIR/mitm/targets.json` for dynamic `server.cjs` resolution.
- **Traffic Inspector core** (`src/mitm/inspector/`) — `TrafficBuffer` in-memory ring,
`kindDetector`, `sseMerger` (MIT port from chouzz/llm-interceptor), `conversationNormalizer`
(MIT port), `contextKey` fingerprinting, `httpProxyServer`, `systemProxyConfig`.
- **AgentBridge passthrough + bypass** (`src/mitm/passthrough.ts`) — TCP tunnel for
non-mapped hosts; bypass list with default sensitive-host patterns + user-defined patterns.
- **Upstream CA cert** (`src/mitm/upstreamTrust.ts`) — `AGENTBRIDGE_UPSTREAM_CA_CERT` for
corporate TLS environments.
- **Secret masking** (`src/mitm/maskSecrets.ts`) — sk-/Bearer/generic token masking before
any log or Traffic Inspector broadcast.
- **DB migrations 073075** — `agent_bridge_state`, `agent_bridge_mappings`,
`agent_bridge_bypass`, `inspector_custom_hosts`, `inspector_sessions`,
`inspector_session_requests`.
- **~28 API routes** under `/api/tools/agent-bridge/` (12 routes) and
`/api/tools/traffic-inspector/` (16+ routes). All LOCAL_ONLY + SPAWN_CAPABLE.
- **i18n** PT-BR + EN for all new keys in `agentBridge.*` and `trafficInspector.*` namespaces;
all other locales fall back to EN automatically.
- **E2E smoke tests** — `tests/e2e/agent-bridge.spec.ts`,
`tests/e2e/traffic-inspector.spec.ts`, `tests/e2e/agent-bridge-traffic-cross.spec.ts`
(skip-gated on CI by `RUN_AGENT_BRIDGE_E2E` / `RUN_TRAFFIC_INSPECTOR_E2E` / `RUN_CROSS_E2E`).
- **Documentation** — `docs/frameworks/AGENTBRIDGE.md` and `docs/frameworks/TRAFFIC_INSPECTOR.md`;
`docs/architecture/REPOSITORY_MAP.md` updated; `docs/reference/openapi.yaml` updated with
~28 new routes and 20+ new schemas.
### Changed
- Sidebar Tools group: added `agent-bridge` and `traffic-inspector` items after `cloud-agents`.
- `/api/tools/agent-bridge/` and `/api/tools/traffic-inspector/` added to `LOCAL_ONLY_API_PREFIXES`
and `SPAWN_CAPABLE_PREFIXES` in `src/server/authz/routeGuard.ts`.
- `.env.example`: documented 9 new env vars (`AGENTBRIDGE_UPSTREAM_CA_CERT`,
`INSPECTOR_BUFFER_SIZE`, `INSPECTOR_HTTP_PROXY_PORT`, `INSPECTOR_HTTP_PROXY_AUTOSTART`,
`INSPECTOR_TLS_INTERCEPT`, `INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES`, `INSPECTOR_MAX_BODY_KB`,
`INSPECTOR_MASK_SECRETS`, `INSPECTOR_LLM_HOSTS_EXTRA`, `INSPECTOR_INTERNAL_INGEST_TOKEN`).
---

View File

@@ -101,7 +101,9 @@ src/
├── shared/ # Shared between server and client where safe (constants, types, validation, contracts, utils)
├── i18n/ # next-intl config + per-locale message JSON (30+ locales)
├── middleware/ # Next.js middleware (request enrichment, locale detection)
├── mitm/ # MITM proxy helpers (Linux cert install, antigravity stealth)
├── mitm/ # MITM proxy core: cert gen/install, handlers, targets, inspector, masks, passthrough
│ ├── handlers/ # 9 IDE-agent handler classes extending MitmHandlerBase (antigravity, kiro, copilot, codex, cursor, zed, claudeCode, openCode, trae)
│ └── inspector/ # Traffic capture layer: buffer (in-memory ring), sseMerger, conversationNormalizer, kindDetector, contextKey, httpProxyServer, systemProxyConfig
├── models/ # Model adapter glue (legacy shim)
├── scripts/ # In-tree maintenance scripts (e.g., backfillAggregation)
├── sse/ # Legacy SSE handlers/services (chat.ts, chatHelpers.ts, services/auth.ts)
@@ -120,9 +122,13 @@ src/
| `app/api/v1/` | Public OpenAI-compat API (~25 sub-routes: chat, completions, embeddings, files, batches, audio, images, videos, music, rerank, moderations, search, ws, agents, accounts, providers, etc.) |
| `app/api/v1beta/` | Gemini-style API endpoints |
| `app/api/` (non-v1) | Management/admin routes (~60 directories: providers, combos, settings, mcp, a2a, evals, memory, skills, webhooks, compliance, resilience, monitoring, tunnels, cli-tools, etc.) |
| `app/api/tools/agent-bridge/` | AgentBridge REST API — 12 routes (server control, agent state/DNS/mappings, bypass, cert, upstream-CA). LOCAL_ONLY + SPAWN_CAPABLE. See `docs/frameworks/AGENTBRIDGE.md §7`. |
| `app/api/tools/traffic-inspector/` | Traffic Inspector REST + WS API — 16+ routes (requests, sessions, hosts, capture-modes, export, ws). LOCAL_ONLY + SPAWN_CAPABLE. See `docs/frameworks/TRAFFIC_INSPECTOR.md §8`. |
| `app/a2a/` | A2A JSON-RPC 2.0 entry point (`POST /a2a`) |
| `app/.well-known/agent.json/` | A2A Agent Card (discovery) |
| `app/(dashboard)/dashboard/` | Dashboard UI pages (~35 pages: providers, combos, settings, memory, skills, webhooks, evals, audit, batch, cache, costs, health, system, activity, etc.) |
| `app/(dashboard)/dashboard/tools/agent-bridge/` | AgentBridge dashboard page — server card, 9 agent cards, setup wizard, model mapping, bypass list. i18n PT-BR + EN. See `docs/frameworks/AGENTBRIDGE.md`. |
| `app/(dashboard)/dashboard/tools/traffic-inspector/` | Traffic Inspector dashboard page — DevTools split, 7 detail tabs, 4 capture mode toggles, session recorder, context colorization. i18n PT-BR + EN. See `docs/frameworks/TRAFFIC_INSPECTOR.md`. |
| `app/(dashboard)/dashboard/activity/` | Activity feed page (Group B): `page.tsx` (server) + `ActivityFeedClient.tsx` + `components/{ActivityFeed,ActivityItem,DayHeader,EventTypeFilter}.tsx` — see `docs/architecture/MONITORING_SECTIONS.md` |
| `app/(dashboard)/dashboard/costs/quota-share/` | Quota Sharing page (Group B): `QuotaSharePageClient.tsx` + `components/{PoolCard,DimensionBar,AllocationTable,BurnRateChart,QuotaConceptCard,CreatePoolModal,EditAllocationsModal}.tsx` + `hooks/{usePools,usePoolUsage,useLocalStoragePoolMigration}.ts` |
| `app/(dashboard)/dashboard/costs/quota-share/plans/` | Provider plan config page (Group B): `page.tsx` + `ProviderPlanConfigClient.tsx` — quota dimensions per connection override |

View File

@@ -0,0 +1,406 @@
---
title: "AgentBridge"
version: 3.8.6
lastUpdated: 2026-05-28
---
# AgentBridge
AgentBridge is OmniRoute's MITM (Man-in-the-Middle) proxy that intercepts HTTPS traffic from IDE AI agents and reroutes it through OmniRoute's unified routing engine. It supports **9 IDE agents** — Antigravity, Kiro, GitHub Copilot, OpenAI Codex, Cursor, Zed, Claude Code, Open Code, and Trae (investigating) — making OmniRoute the broadest-coverage MITM proxy for AI coding assistants on the market.
**Dashboard location:** `/dashboard/tools/agent-bridge`
**Sidebar group:** Tools (after Cloud Agents)
**See also:** [`TRAFFIC_INSPECTOR.md`](./TRAFFIC_INSPECTOR.md) — monitor all intercepted traffic in real-time.
---
## §1 Overview
### What is AgentBridge?
When an IDE agent (e.g., GitHub Copilot, Cursor, Claude Code) makes an API call, it connects directly to the upstream AI provider (OpenAI, Anthropic, etc.). AgentBridge intercepts that connection transparently at the TLS level — without requiring any agent configuration change — and rewrites the request through OmniRoute.
This means you can:
- **Reroute any agent to any provider**: Copilot talking to OpenAI? Redirect it to Anthropic Claude, Gemini, or any of OmniRoute's 160+ providers.
- **Apply model mappings**: `gemini-3-flash``claude-sonnet-4.7` transparently at the handler level.
- **Observe all agent traffic**: every intercepted request is published to the [Traffic Inspector](./TRAFFIC_INSPECTOR.md).
- **Apply OmniRoute resilience**: combo routing, circuit breakers, fallbacks, and cost tracking work for IDE agent traffic too.
### Positioning vs. the market
| Feature | 9router | anti-api | llm-interceptor | **OmniRoute AgentBridge** |
|---------|:-------:|:--------:|:---------------:|:-------------------------:|
| Antigravity | ✓ | ✓ | — | ✓ |
| GitHub Copilot | ✓ | ✓ | — | ✓ |
| Kiro (AWS) | ✓ | ✓ | — | ✓ |
| OpenAI Codex | — | ✓ | — | ✓ |
| Cursor IDE | ✓ | ✓ | — | ✓ |
| Zed Industries | — | ✓ | — | ✓ |
| Claude Code | — | — | ✓ | ✓ |
| Open Code | — | — | ✓ | ✓ |
| Trae | — | — | — | 🔍 Investigating |
| Dashboard UI | ✓ | ✗ | ✗ | ✓ |
| Traffic Inspector | ✗ | ✗ | ✓ | ✓ |
| OmniRoute routing | ✗ | ✗ | ✗ | ✓ |
| Model mapping UI | ✗ | ✗ | ✗ | ✓ |
| Bypass list | ✗ | ✗ | ✓ | ✓ |
| Upstream CA cert | ✗ | ✗ | ✓ | ✓ |
---
## §2 Architecture
### 2.1 Components overview
```
IDE Agent (VS Code / Cursor / etc.)
│ HTTPS (port 443)
/etc/hosts — 127.0.0.1 api.githubcopilot.com ← DNS redirect
src/mitm/server.cjs (port 443, CJS child process)
│ resolves target by Host header SNI
│ generates per-SNI TLS cert signed by AgentBridge CA
├── Bypass list match? → TCP passthrough (no decrypt)
├── Target match? → fetch → OmniRoute router (port 20128)
│ └── handler.intercept() — TypeScript
│ ├── maskSecrets() on request body/headers
│ ├── TrafficBuffer.push() — publishes to Traffic Inspector
│ └── fetchRouter() → /v1/chat/completions
└── No match? → TCP passthrough (no decrypt)
```
### 2.2 MITM server (`src/mitm/server.cjs`)
The core MITM server runs as a Node.js CJS child process (to avoid rewriting the existing CJS codebase). It:
- Listens on port 443 (requires privilege or `authbind`/`setcap`)
- Receives CONNECT tunnels from the OS (via `/etc/hosts` DNS redirect)
- Generates per-SNI TLS certificates signed by the AgentBridge CA (`DATA_DIR/mitm/ca.crt`)
- Resolves the target agent by Host header via `targets/index.ts` registry
- Dispatches to the TypeScript handler layer via HTTP to `http://127.0.0.1:20128`
`TARGET_HOSTS` is loaded from `DATA_DIR/mitm/targets.json` (written by `targets/index.ts` at boot), allowing dynamic updates without restarting the CJS server.
### 2.3 Handler base (`src/mitm/handlers/base.ts`)
All agent handlers extend `MitmHandlerBase`:
```ts
export abstract class MitmHandlerBase {
abstract readonly agentId: AgentId;
abstract intercept(
req: IncomingMessage,
res: ServerResponse,
body: Buffer,
mappedModel: string,
): Promise<void>;
// Protected helpers: fetchRouter, pipeSSE, hookBufferStart, hookBufferUpdate
}
```
Each handler calls `hookBufferStart()` before proxying and `hookBufferUpdate()` when complete. These push `InterceptedRequest` entries into `globalTrafficBuffer` (see [Traffic Inspector](./TRAFFIC_INSPECTOR.md) §4).
### 2.4 Targets registry (`src/mitm/targets/`)
Each agent has a declarative target file:
```ts
// src/mitm/targets/copilot.ts
export const COPILOT_TARGET: MitmTarget = {
id: "copilot",
name: "GitHub Copilot",
hosts: ["api.githubcopilot.com", "copilot-proxy.githubusercontent.com"],
port: 443,
endpointPatterns: ["/chat/completions", "/v1/chat/completions"],
defaultModels: [
{ id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" },
],
handler: () => import("../handlers/copilot"),
riskNoticeKey: "providers.riskNotice.oauth",
};
```
The registry (`targets/index.ts`) exports `ALL_TARGETS` and emits `DATA_DIR/mitm/targets.json` on boot.
### 2.5 Passthrough and bypass list (`src/mitm/passthrough.ts`)
**Bypass list** (checked first, with precedence over target match):
- Default patterns: banking hosts, `.gov.`, OAuth/SSO providers (Okta, Auth0), etc.
- User patterns: stored in DB table `agent_bridge_bypass`
- Bypassed hosts receive a transparent TCP tunnel — TLS is **never decrypted**
**Passthrough default** (no target match and not in bypass):
- Also receives a TCP tunnel — connections are never broken
- Prevents the AgentBridge from disrupting general system HTTPS traffic
Routing precedence:
```
bypass list → target match → passthrough
```
### 2.6 Upstream CA cert (`src/mitm/upstreamTrust.ts`)
For corporate network environments with a custom CA:
```bash
AGENTBRIDGE_UPSTREAM_CA_CERT=/path/to/corporate-ca.pem
```
When set, configures `undici`'s global dispatcher with the extra CA cert, allowing AgentBridge to reach upstream providers through corporate TLS termination proxies.
### 2.7 Secret masking (`src/mitm/maskSecrets.ts`)
Applied to all request bodies and headers **before** they enter the Traffic Inspector buffer or any log:
- `sk-` / `ak-` / `pk-` prefixed tokens (OpenAI/Anthropic-style)
- `Authorization: Bearer <token>` headers
- Generic long tokens (≥40 chars)
---
## §3 Setup
### 3.1 Start/stop the MITM server
Use the AgentBridge Server Card at `/dashboard/tools/agent-bridge`:
| Action | Description |
|--------|-------------|
| Start Server | Spawns `src/mitm/server.cjs` on port 443 |
| Stop Server | Gracefully shuts down the child process |
| Restart Server | Stop + start (picks up target changes) |
| Trust Cert | Installs `DATA_DIR/mitm/ca.crt` into OS trust store |
| Download Cert | Downloads `ca.crt` for manual installation |
| Regenerate Cert | Creates a new CA keypair (all existing per-agent certs are invalidated) |
### 3.2 Trust the certificate
The AgentBridge CA certificate must be trusted by the OS before IDEs will accept the MITM connection.
**Linux (NSS — Chrome/Firefox):**
```bash
certutil -A -d sql:$HOME/.pki/nssdb -n "OmniRoute AgentBridge" -t CT,, -i ~/.omniroute/mitm/ca.crt
```
**macOS (Keychain):**
```bash
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain ~/.omniroute/mitm/ca.crt
```
**Windows (certmgr):**
```powershell
certutil -addstore -f Root $env:USERPROFILE\.omniroute\mitm\ca.crt
```
Or use the "Trust Cert" button in the dashboard (runs the appropriate command for your OS, with sudo prompt if needed).
### 3.3 DNS routing
For each agent you want to intercept, its API host(s) must resolve to `127.0.0.1`. AgentBridge manages `/etc/hosts` entries automatically when you toggle DNS for an agent in the Setup Wizard.
Example `/etc/hosts` entries for GitHub Copilot:
```
127.0.0.1 api.githubcopilot.com
127.0.0.1 copilot-proxy.githubusercontent.com
```
### 3.4 Model mapping
Use the Model Mapping Table in each agent card to define source → target mappings:
| Source model (agent native) | Target model (OmniRoute) |
|-----------------------------|--------------------------|
| `gpt-4o` | `claude-sonnet-4.7` |
| `*` (wildcard) | `claude-haiku-4.7` |
Wildcard `*` maps any unrecognized model to the specified target. Persisted in `agent_bridge_mappings` table.
### 3.5 Risk notice
AgentBridge intercepts credentials (OAuth tokens, API keys) that the IDE uses to authenticate with upstream providers. These are **masked before logging** (see §2.7) but are visible to OmniRoute's MITM layer. First activation of each agent shows a dismissible risk notice modal.
---
## §4 Per-agent reference
| # | Agent | Status | Hosts intercepted | Auth type |
|---|-------|--------|-------------------|-----------|
| 1 | **Antigravity** | ✅ Supported | `daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com` | Firebase OAuth |
| 2 | **Kiro (AWS)** | ✅ Supported | `prod.kiro.aws`, `dev.kiro.aws` | AWS SigV4 |
| 3 | **GitHub Copilot** | ✅ Supported | `api.githubcopilot.com`, `copilot-proxy.githubusercontent.com` | GitHub OAuth |
| 4 | **OpenAI Codex** | ✅ Supported | `api.openai.com` (Codex paths), `chatgpt.com` | OpenAI key |
| 5 | **Cursor IDE** | ✅ Supported | `api2.cursor.sh`, `api.cursor.sh` | Cursor OAuth |
| 6 | **Zed Industries** | ✅ Supported | `api.zed.dev`, `llm.zed.dev` | Zed OAuth |
| 7 | **Claude Code** | ✅ Supported | `api.anthropic.com` (opt-in) | Anthropic key |
| 8 | **Open Code** | ✅ Supported | `openrouter.ai`, `api.openai.com` (zen paths) | API key |
| 9 | **Trae** | 🔍 Investigating | TBD — see §8 | TBD |
### Setup wizard steps (per agent)
Each agent card has a 3-step setup wizard:
1. **Verify prerequisites** — Server running? Cert trusted? IDE installed (auto-detected)?
2. **Enable DNS** — Adds `/etc/hosts` entries (requires sudo). Shows exactly which lines will be added.
3. **Map models** — Optional model mapping table. Wildcards accepted.
### Agent detection
For agents 18, AgentBridge attempts to auto-detect IDE installation:
```ts
export async function detectAgent(agentId: AgentId): Promise<DetectionResult>
// Returns: { installed: boolean, version?: string, path?: string }
```
Detection uses OS-specific paths and binary checks (e.g., `code --list-extensions | grep github.copilot` for Copilot, `~/.config/antigravity/` for Antigravity).
---
## §5 Security
### Hard Rules applied
| Rule | Application |
|------|-------------|
| **#12** `sanitizeErrorMessage` | All handler errors are sanitized before response or buffer entry |
| **#13** Shell env-passing | `/etc/hosts` edits use `env` option — no string interpolation of paths |
| **#15 + #17** `isLocalOnlyPath()` | `/api/tools/agent-bridge/` is LOCAL_ONLY + SPAWN_CAPABLE — loopback enforced before auth |
### Bypass list for sensitive hosts
The bypass list ensures that financial institutions, OAuth/SSO providers, and other sensitive hosts are **never decrypted**. Their TLS traffic passes through as a transparent TCP tunnel — OmniRoute never sees the plaintext.
Default bypass patterns include:
- `*.bank.*`, `*.gov.*` (financial/government)
- `*.okta.com`, `*.auth0.com`, `*.microsoft.com` (SSO/identity)
- `*.apple.com`, `*.icloud.com` (Apple system services)
User-added bypass patterns are stored in `agent_bridge_bypass` table and take precedence over everything.
### Secret masking
`maskSecrets()` from `src/mitm/maskSecrets.ts` is applied:
- On every request body before `TrafficBuffer.push()`
- On every header before logging or broadcasting
Patterns: `sk-`/`ak-`/`pk-` prefix tokens, `Bearer` tokens, and generic tokens ≥40 characters.
### Upstream CA cert
When `AGENTBRIDGE_UPSTREAM_CA_CERT` is set, the file is read at startup. If the path exists but the file is unreadable, AgentBridge logs a clear error and refuses to start (prevents silent TLS failures in corporate environments).
### Known limitations
- **Port 443 requires privilege**: On Linux, AgentBridge needs `setcap 'cap_net_bind_service=+ep'` on the Node binary, or run via `authbind`. The Setup Wizard displays OS-specific instructions.
- **IDE restart required**: After DNS redirect, the IDE must be restarted for the new host resolution to take effect.
- **Hardcoded OAuth tokens**: Some agents (Kiro, Antigravity) store OAuth refresh tokens locally. These are transparent to AgentBridge — it sees the Bearer token in each request, which is masked before logging.
---
## §6 Troubleshooting
### Port 443 conflict
If another process is already listening on port 443 (web server, VPN, etc.):
```bash
lsof -i :443 # find the process
sudo fuser -k 443/tcp # force-kill (use with care)
```
Alternatively, configure a non-privileged port in AgentBridge settings and set up `iptables` / `pf` redirect rules.
### Certificate not trusted
If the IDE shows TLS errors after starting AgentBridge:
1. Verify the cert was installed: `security find-certificate -c "OmniRoute AgentBridge"` (macOS) or `certutil -L -d sql:$HOME/.pki/nssdb` (Linux/NSS)
2. Some apps maintain their own trust store (Firefox, Chrome on Linux). Run "Trust Cert" again and check the NSS/Firefox-specific cert store.
3. Restart the IDE after trusting — in-flight TLS sessions use the old trust state.
### DNS not propagated
Check that `/etc/hosts` was updated:
```bash
grep "omniroute\|127.0.0.1.*github\|127.0.0.1.*cursor" /etc/hosts
```
Flush DNS cache:
```bash
# macOS
sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder
# Linux (systemd-resolved)
sudo systemctl restart systemd-resolved
# Windows
ipconfig /flushdns
```
### IDE not detected
Auto-detection uses common installation paths. If detection fails but the IDE is installed:
- Check if the IDE binary is in a non-standard location
- The Setup Wizard still works — detection failure just means the badge won't show the install path
### Handler errors (upstream fetch fails)
If AgentBridge intercepts but all requests fail:
1. Verify at least one provider is connected at `/dashboard/providers`
2. Check OmniRoute server logs: `APP_LOG_LEVEL=debug` in `.env`
3. Verify `OMNIROUTE_BASE_URL` points to the correct router endpoint (default: `http://127.0.0.1:20128`)
---
## §7 API reference
All routes are `LOCAL_ONLY` (loopback-only, enforced before auth) and `SPAWN_CAPABLE`. See `src/server/authz/routeGuard.ts`.
Base path: `/api/tools/agent-bridge/`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/tools/agent-bridge/agents` | List all 9 agents with current state |
| GET | `/api/tools/agent-bridge/state` | Global server state (running, port, cert info) |
| POST | `/api/tools/agent-bridge/server` | Start/stop/restart server (`action: "start"\|"stop"\|"restart"\|"trust-cert"\|"regenerate-cert"`) |
| GET | `/api/tools/agent-bridge/agents/{id}/state` | State of one agent (dns_enabled, cert_trusted, etc.) |
| POST | `/api/tools/agent-bridge/agents/{id}/dns` | Enable/disable DNS for agent (`{enabled: boolean}`) |
| GET | `/api/tools/agent-bridge/agents/{id}/mappings` | Model mappings for agent |
| PUT | `/api/tools/agent-bridge/agents/{id}/mappings` | Update model mappings |
| GET | `/api/tools/agent-bridge/bypass` | List bypass patterns |
| PUT | `/api/tools/agent-bridge/bypass` | Update bypass patterns |
| POST | `/api/tools/agent-bridge/cert` | Download or regenerate CA cert |
| GET | `/api/tools/agent-bridge/upstream-ca` | Get configured upstream CA path |
| POST | `/api/tools/agent-bridge/upstream-ca` | Set upstream CA cert path |
Full OpenAPI schemas: `docs/reference/openapi.yaml` → tag `AgentBridge`.
---
## §8 Roadmap
### Trae investigation
Trae is a relatively new AI coding assistant. Before implementing a handler:
1. Identify the binary/extension in VS Code / JetBrains marketplaces or as a standalone app
2. Capture traffic with mitmproxy to discover API hosts and endpoint shapes
3. Determine authentication mechanism
4. Assess go/no-go based on TOS and API discoverability
Until investigation completes, the Trae card in the dashboard shows a "Investigating" badge with a "Report viability" link. The handler stub at `src/mitm/handlers/trae.ts` throws a structured `Not yet implemented` error.
### Backlog agents (MITM required — no custom base URL support)
The following tools do not support custom base URLs in their current versions, making MITM the only interception path. Viability assessment is pending:
- **Windsurf** (Codeium/Cognition)
- **Amp** (Sourcegraph)
- **Amazon Q / Kiro CLI** (AWS Bedrock — separate from Kiro IDE)
- **Cowork** (Anthropic desktop)
Note: GitHub Copilot CLI ≥v1.0.19 supports `COPILOT_PROVIDER_BASE_URL` — use direct config instead of MITM for that tool.

View File

@@ -0,0 +1,421 @@
---
title: "Traffic Inspector"
version: 3.8.6
lastUpdated: 2026-05-28
---
# Traffic Inspector
Traffic Inspector is OmniRoute's built-in HTTPS traffic debugger — a Charles Proxy / mitmweb / HTTP Toolkit-like tool that is **LLM-aware** and **agent-aware**. It lives at `/dashboard/tools/traffic-inspector` and receives live traffic from up to 4 simultaneous capture sources.
**Dashboard location:** `/dashboard/tools/traffic-inspector`
**Sidebar group:** Tools (after AgentBridge)
**See also:** [`AGENTBRIDGE.md`](./AGENTBRIDGE.md) — AgentBridge is capture mode 1.
---
## §1 Overview
### What makes Traffic Inspector unique
| Feature | mitmweb | Charles | Fiddler | **OmniRoute Traffic Inspector** |
|---------|:-------:|:-------:|:-------:|:-------------------------------:|
| Web-based | ✓ | ✗ | ✗ | ✓ |
| Open-source | ✓ | ✗ | partial | ✓ |
| **Agent-aware** (knows if request is from Antigravity/Copilot/etc.) | ✗ | ✗ | ✗ | ✓ |
| **LLM-aware** (parses OpenAI/Anthropic/Gemini shape, tokens, model) | ✗ | ✗ | ✗ | ✓ |
| **Model mapping visible** (gemini-3-flash → claude-sonnet-4.7) | ✗ | ✗ | ✗ | ✓ |
| **Proxy/upstream latency split** | partial | ✗ | ✗ | ✓ |
| **Integrated with OmniRoute** routing, fallback, cost | ✗ | ✗ | ✗ | ✓ |
| **System-wide proxy debug** (any app on the machine) | ✓ | ✓ | ✓ | ✓ |
| **Custom host capture** (per-host DNS redirect) | ✓ | ✓ | ✓ | ✓ |
| **HTTP_PROXY env mode** | ✓ | ✓ | ✓ | ✓ |
| **Conversation view** (multi-turn bubbles, tool_use/tool_result) | ✗ | ✗ | ✗ | ✓ |
| **SSE stream merger** (reconstruct from delta events) | ✗ | ✗ | ✗ | ✓ |
| **Session recording** (named, exportable .har/.jsonl) | ✗ | ✓ | ✓ | ✓ |
### Architecture in one paragraph
The `TrafficBuffer` (`src/mitm/inspector/buffer.ts`) is a shared in-memory ring buffer (default 1000 entries, configurable via `INSPECTOR_BUFFER_SIZE`). All capture sources write to it via `push()`. The buffer classifies each entry using `kindDetector.ts` (determines if it's an LLM request), computes a `contextKey` (SHA-256 fingerprint of the system prompt), and broadcasts to all WebSocket subscribers via `globalTrafficBuffer.subscribe()`. The dashboard connects via `GET /api/tools/traffic-inspector/ws` and receives a snapshot on connect, followed by `new`/`update`/`clear` events.
---
## §2 Capture modes
Traffic Inspector supports **4 simultaneous capture sources**. Each is independently toggleable.
### Mode 1 — AgentBridge (default, always on)
**Source:** AgentBridge handlers (`src/mitm/handlers/base.ts`)
**Mechanism:** Every `intercept()` call in `MitmHandlerBase` calls `hookBufferStart()` before forwarding and `hookBufferUpdate()` on completion. Zero extra config — works as soon as AgentBridge is running.
**Reach:** The 9 IDE agents configured in AgentBridge
**Note:** `source` field in `InterceptedRequest` = `"agent-bridge"`
### Mode 2 — Custom Hosts (DNS redirect)
**Source:** User-defined host list (`inspector_custom_hosts` table)
**Mechanism:** Adding a host via the UI adds `127.0.0.1 <host>` to `/etc/hosts` (requires sudo). The existing AgentBridge MITM server (port 443) generates a SNI cert dynamically for the new host.
**Reach:** Any application using the added host — no app config change needed
**Note:** `source` = `"custom-host"`
Example use cases:
- Monitor `api.openai.com` from Python scripts
- Debug `my-internal-llm.company.com`
- Capture traffic from mobile devices on the same network (via ARP spoofing — advanced)
### Mode 3 — HTTP_PROXY listener (port 8080)
**Source:** Applications using `HTTP_PROXY`/`HTTPS_PROXY` environment variables
**Mechanism:** Secondary listener at port 8080 (`src/mitm/inspector/httpProxyServer.ts`) that acts as a standard explicit HTTP/HTTPS proxy. Accepts `CONNECT` tunnels (HTTPS) and direct HTTP requests.
**Reach:** Any application that respects `HTTP_PROXY` env — no DNS change, no sudo
**Note:** `source` = `"http-proxy"`
```bash
# Quick capture for a single command:
HTTPS_PROXY=http://127.0.0.1:8080 curl https://api.openai.com/v1/models
# Persistent capture in a shell session:
export HTTP_PROXY=http://127.0.0.1:8080
export HTTPS_PROXY=http://127.0.0.1:8080
```
**TLS limitation:** HTTPS `CONNECT` tunnels are captured as metadata only (host, port, timing) — TLS body is not decrypted by default. Enable "Decrypt HTTPS in proxy mode" toggle (opt-in, requires AgentBridge cert to be trusted) for full body inspection.
**Port conflict:** If port 8080 is in use, AgentBridge returns a 409 with a structured error. Change the port via `INSPECTOR_HTTP_PROXY_PORT` env var.
### Mode 4 — System-wide proxy (advanced, opt-in)
**Source:** OS-level proxy settings (applies to all apps on the machine)
**Mechanism:** Uses OS APIs to redirect all HTTP/HTTPS traffic through the HTTP_PROXY listener:
- **macOS:** `networksetup -setwebproxy / -setsecurewebproxy`
- **Linux:** `gsettings set org.gnome.system.proxy` + `/etc/environment`
- **Windows:** `netsh winhttp set proxy 127.0.0.1:8080`
**Reach:** Every application on the machine that respects system proxy settings
**Note:** `source` = `"system-proxy"`
**Safety mechanisms:**
- Auto-disable timer (default 30 min, configurable via `INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES`)
- Previous system proxy state is saved in DB and restored on revert
- Dashboard shows "Reverting system proxy" prompt if user navigates away while active
- UI shows `⚠ Advanced` badge + explicit confirmation checkbox
### Capture mode comparison
| Mode | Setup | Sudo? | Reach | Notes |
|------|-------|:-----:|-------|-------|
| 1. AgentBridge | Automatic | Once (cert+hosts) | 9 IDE agents | Default on |
| 2. Custom Hosts | Per-host input | Yes (hosts file) | Any app using that host | Persisted in DB |
| 3. HTTP_PROXY | `export HTTPS_PROXY=...` | No | Apps respecting env | Port 8080, no TLS decrypt by default |
| 4. System-wide | Toggle + confirm | Yes | All apps on machine | Auto-disable in 30 min |
---
## §3 UI
### 3.1 Layout
```
┌─ Traffic Inspector ─────────────────────────────────────────────────────┐
│ ┌─ Capture sources toolbar ─────────────────────────────────────────┐ │
│ │ [✓ AgentBridge] [✓ Custom hosts (3)] [○ HTTP_PROXY] [○ System]│ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ┌─ Filter/control bar ──────────────────────────────────────────────┐ │
│ │ Profile: (●) LLM only (○) Custom (○) All │ │
│ │ [⎉ Pause] [🗑 Clear] [⬇ .har] [● REC session] ● live 482/1k │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
├══◀▶══════════════════════════════╬══════════════════════════════════════╤╡
│ REQUEST LIST (resizable) ║ DETAIL PANE ▲ │
│ ────────────────────────────── │ ║ [Conversation][Headers][Request] │ │
│ ▎ 14:32 POST 200 12k AG openai ║ [Response][Timing][LLM][Stats] │ │
│ ▎ 14:31 POST 200 8k CP openai ║ ▼ │
│ ▎ 14:31 POST 503 ⚠ KR ... ║ │
│ ▎ 14:30 GET 200 3k 🌐 custom ║ │
└══════════════════════════════════╝══════════════════════════════════════╝
```
### 3.2 Request list (left panel)
- **Virtualized** (`useVirtualList` + `ResizeObserver`): handles 1000 items without freezing
- **Auto-scroll** with toggle to pause while inspecting
- **Color-coded status**: green (2xx), yellow (3xx), red (4xx/5xx), gray (in-flight)
- **Agent emoji**: 🔵 Antigravity, 🟢 Copilot, 🟠 Kiro, 🟣 Codex, 🔷 Cursor, 🟤 Zed, 🟡 Claude Code, ⚫ Open Code, 🌐 custom host
- **Context color bar**: 1px left border colored by `contextKey` (SHA-256 of system prompt) — visually groups related conversations
- **Lazy body**: only the selected request's body is materialized in the detail tabs (avoids rendering 1000 × 1MB bodies)
### 3.3 Detail pane — 7 tabs
| Tab | Content | Notes |
|-----|---------|-------|
| **Conversation** | Multi-turn chat bubbles (system/user/assistant + tool_use/tool_result) | Normalized from any provider format; only shown for `detectedKind === "llm"` |
| **Headers** | Request + response header tables | Sensitive headers (Authorization, Cookie, api-key) masked by default; "Show secrets" toggle |
| **Request** | Raw body, JSON tree view, model field badge | Pretty-printed JSON or raw text |
| **Response** | Raw body or SSE event list; toggle "Raw ↔ Merged" | SSE merger reconstructs final message from delta events |
| **Timing** | Waterfall: proxy overhead vs upstream latency | Total, TTFB, and size |
| **LLM Details** | Provider, model, messages count, tokens in/out, cost estimate, mapped target | Only shown for LLM requests |
| **Stats** | Recharts: latency timeline, token bar chart, tool call scatter | Only shown when a recorded session is loaded |
### 3.4 Toolbar controls
| Control | Action |
|---------|--------|
| ⎉ Pause | Stops rendering new requests; "X new" badge accumulates |
| 🗑 Clear | Clears the UI list (server buffer is not affected) |
| ⬇ Export .har | Downloads current filtered list as HAR file |
| ● Record session | Starts a named recording session |
| Profile selector | LLM only / Custom hosts / All |
| Host filter | Substring match on `host` field |
| Agent filter | Dropdown: All / per-agent |
| Status filter | All / 2xx / 3xx / 4xx / 5xx / error |
| Source filter | All / agent-bridge / custom-host / http-proxy / system-proxy |
### 3.5 Resizable panels
- List and detail pane separated by a drag handle
- List width: min 280px, max 720px, persisted in `localStorage` (`inspector.listWidth`)
- Collapsible to a 48px rail (icon-only); click a row in the rail to expand
---
## §4 LLM-aware features
### 4.1 Kind detector (`src/mitm/inspector/kindDetector.ts`)
Classifies each request as `"llm"`, `"app"`, or `"unknown"` using 4 signals:
1. **Host registry** — ~18 known LLM API hostnames (OpenAI, Anthropic, Gemini, Groq, Mistral, Together, Fireworks, Cohere, Perplexity, Hugging Face, OpenRouter, xAI, Moonshot, etc.)
2. **Path patterns**`/v1/chat/completions`, `/v1/messages`, `/generateContent`, `/v1/responses`, etc.
3. **Body shape** — detects `messages[]` (OpenAI/Claude), `contents[]` (Gemini), `prompt`, `input` fields
4. **User-agent hints**`codex`, `claude`, `gemini`, `antigravity`, `kiro`, `copilot`, `cursor` in UA string
Custom hosts added via Mode 2 inherit their `kind` from the form input (defaults to `"custom"`).
### 4.2 SSE merger (`src/mitm/inspector/sseMerger.ts`)
**MIT port from [chouzz/llm-interceptor](https://github.com/chouzz/llm-interceptor)**
Reconstructs the final assistant message from raw SSE delta events:
- **Anthropic**: accumulates `content_block_delta` by index; handles `text_delta`, `input_json_delta` (tool calls), `thinking_delta`
- **OpenAI**: accumulates `choices[i].delta.content` and `tool_calls` by index
- **Gemini**: accumulates `candidates[i].content.parts`
- **Unknown**: returns raw events as-is
The Response tab shows a toggle: **"Raw events ↔ Merged"**.
### 4.3 Conversation normalizer (`src/mitm/inspector/conversationNormalizer.ts`)
**MIT port from [chouzz/llm-interceptor](https://github.com/chouzz/llm-interceptor)**
Converts OpenAI, Anthropic, and Gemini message formats to a single `NormalizedConversation` before rendering:
```ts
interface NormalizedConversation {
request: NormalizedTurn[]; // messages / contents / prompt from request body
response: NormalizedTurn[]; // assistant response (merged via sseMerger)
contextKey: string | null; // SHA-256 system-prompt fingerprint
}
```
Block types: `text`, `tool_use`, `tool_result`. The Conversation tab uses this shape regardless of provider.
### 4.4 Context key colorization (`src/mitm/inspector/contextKey.ts`)
- Computes `SHA-256` of the system prompt (first `role:system` message, or `system` field, or Gemini `systemInstruction`)
- Returns a 12-character hex prefix (`"a3f9c2..."`)
- Frontend maps the key to a deterministic HSL color for the left-border bar
- **Filtro "same context"**: clicking the `ctx #a3f` chip adds a filter to show only requests with the same fingerprint
This makes it easy to visually distinguish different "personas" or tasks running in the same agent session.
### 4.5 LLM metadata extraction
For LLM requests, the LLM Details tab extracts:
```ts
interface LlmMetadata {
provider: string | null; // "openai" | "anthropic" | "gemini" | ...
apiKind: string | null; // "chat.completions" | "messages" | "embeddings" | ...
model: string | null; // from request body or response
messages: number; // turn count
tokensIn: number | null; // usage.prompt_tokens / usage.input_tokens
tokensOut: number | null; // usage.completion_tokens / usage.output_tokens
streamed: boolean; // true if SSE response
mappedTo: string | null; // x-omniroute-mapped header
costEstimateUsd: number | null; // estimated cost based on OmniRoute pricing
}
```
---
## §5 Sessions
### 5.1 Recording a session
1. Click **"● Record session"** in the toolbar → enter a name (optional)
2. Live tail continues normally; a red pulsing indicator shows `◉ REC · <name> · 00:42 · 23 reqs`
3. Click **"⏹ Stop"** → the session snapshot is saved to `inspector_sessions` + `inspector_session_requests`
### 5.2 Viewing a recorded session
The **Sessions** dropdown in the toolbar lists saved sessions. Selecting one:
- Loads the session's snapshot (frozen state)
- A banner shows: `Viewing recorded session "<name>" — [Back to live]`
- The Stats tab becomes available with Recharts aggregates
### 5.3 Export formats
Each session can be exported as:
| Format | Use |
|--------|-----|
| **HAR** (HTTP Archive 1.2) | Compatible with Chrome DevTools, Charles, Fiddler — import for offline analysis |
| **JSONL** | One `InterceptedRequest` per line — compatible with `llm-interceptor` format |
Export via `GET /api/tools/traffic-inspector/sessions/{id}/export.har` or the ⬇ button in the Sessions dropdown.
---
## §6 Security
Traffic Inspector shows **all intercepted HTTPS traffic**, including authorization headers and request bodies. The following controls are in place:
| Control | Details |
|---------|---------|
| **LOCAL_ONLY** | All routes and the WebSocket endpoint are loopback-only (enforced in `routeGuard.ts` before auth) |
| **Secret masking** | `maskSecrets()` applied to all headers and bodies before `TrafficBuffer.push()` — enabled by default (`INSPECTOR_MASK_SECRETS=true`) |
| **Body size cap** | Bodies > `INSPECTOR_MAX_BODY_KB` (default 1024 KB) are truncated with `"(truncated for performance)"` notice |
| **Sensitive header masking** | `authorization`, `cookie`, `api-key`, `x-api-key`, `proxy-authorization``Bearer ***` in Headers tab; "Show secrets" toggle |
| **CSP** | Strict Content Security Policy on Traffic Inspector pages to prevent XSS via injected response bodies |
| **No persistence by default** | The `TrafficBuffer` is in-memory and lost on server restart. Sessions are persisted only when explicitly recorded |
### Hard Rules applied
| Rule | Application |
|------|-------------|
| **#12** `sanitizeErrorMessage` | All HTTP error responses from Traffic Inspector routes are sanitized |
| **#15 + #17** `isLocalOnlyPath()` | `/api/tools/traffic-inspector/` is LOCAL_ONLY + SPAWN_CAPABLE (system proxy commands) |
### Known limitations
- **System-wide proxy mode** affects all applications on the machine, including VPN clients and SSO. Always use with the auto-disable timer. Do not use on shared machines.
- **CONNECT tunnel HTTPS**: Mode 3 (HTTP_PROXY) captures only tunnel metadata for HTTPS destinations unless TLS interception is enabled. This is by design — transparent capture without the AgentBridge cert being trusted would break TLS verification for those apps.
- **Hardcoded strings in some components**: Some UI components (F7/F8) have a small number of hardcoded strings not yet covered by i18n keys. These are documented as a Known Limitation in the i18n gap report; they will be migrated in a follow-up pass. Affected strings are UI decorative labels that don't require translation for functional use.
---
## §7 Troubleshooting
### WebSocket disconnection
If the live tail shows "Disconnected":
1. Check the server is still running: `GET /api/tools/traffic-inspector/capture-modes`
2. Reload the page — the WebSocket reconnects and receives a fresh snapshot
3. If the server was restarted, the in-memory buffer was cleared — old entries are gone unless a session was recorded
### Port 8080 conflict
If HTTP_PROXY mode fails to start:
```bash
lsof -i :8080 # find the process
```
Change the port:
```bash
# .env
INSPECTOR_HTTP_PROXY_PORT=8888
```
### System proxy not reverted
If OmniRoute crashes while system-wide proxy mode is active:
**macOS:**
```bash
networksetup -setwebproxystate Wi-Fi off
networksetup -setsecurewebproxystate Wi-Fi off
```
**Linux (GNOME):**
```bash
gsettings set org.gnome.system.proxy mode 'none'
```
**Windows:**
```cmd
netsh winhttp reset proxy
```
The dashboard will also offer "Revert system proxy" on next load if it detects the DB state indicates proxy was active.
### Buffer full
When the buffer reaches `INSPECTOR_BUFFER_SIZE` (default 1000), new entries rotate out the oldest. If important requests are being lost:
- Increase `INSPECTOR_BUFFER_SIZE` (e.g., 5000) — trades memory for retention
- Record a session to persist the relevant window to DB
---
## §8 API reference
All routes are `LOCAL_ONLY` (loopback-only) and `SPAWN_CAPABLE` (system proxy commands). See `src/server/authz/routeGuard.ts`.
Base path: `/api/tools/traffic-inspector/`
### Request management
| Method | Path | Description |
|--------|------|-------------|
| GET | `/requests` | List requests (filterable: `?profile=llm&host=&agent=&status=&source=&sessionId=`) |
| GET | `/requests/{id}` | Single request details |
| DELETE | `/requests` | Clear the in-memory buffer |
| POST | `/requests/{id}/replay` | Re-execute the same request through OmniRoute router |
| PUT | `/requests/{id}/annotation` | Save or update a note on a request |
### WebSocket
| Method | Path | Description |
|--------|------|-------------|
| GET | `/ws` | Live WebSocket stream. Sends `snapshot` on connect, then `new`/`update`/`clear` events |
### Export
| Method | Path | Description |
|--------|------|-------------|
| GET | `/export.har` | Export current filtered list as HAR 1.2 |
### Custom hosts
| Method | Path | Description |
|--------|------|-------------|
| GET | `/hosts` | List custom hosts |
| POST | `/hosts` | Add host (auto-edits `/etc/hosts`) |
| DELETE | `/hosts/{host}` | Remove host |
| PATCH | `/hosts/{host}` | Toggle `enabled` |
### Capture modes
| Method | Path | Description |
|--------|------|-------------|
| GET | `/capture-modes` | State of all 4 capture modes |
| POST | `/capture-modes/http-proxy` | Start/stop HTTP_PROXY listener (`{action: "start"\|"stop"}`) |
| POST | `/capture-modes/system-proxy` | Apply/revert system-wide proxy (`{action: "apply"\|"revert"}`) |
| POST | `/capture-modes/tls-intercept` | Toggle HTTPS body decryption in proxy mode |
### Sessions
| Method | Path | Description |
|--------|------|-------------|
| POST | `/sessions` | Start recording (`{name?: string}`) |
| PATCH | `/sessions/{id}` | Stop or rename (`{action: "stop"\|"rename", name?: string}`) |
| GET | `/sessions` | List all saved sessions |
| GET | `/sessions/{id}` | Session snapshot (all requests) |
| DELETE | `/sessions/{id}` | Delete session |
| GET | `/sessions/{id}/export.har` | Export session as HAR 1.2 |
### Internal ingest (D4 fallback)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/internal/ingest` | Accepts intercepted request from `server.cjs` passthrough path; requires `INSPECTOR_INTERNAL_INGEST_TOKEN` header |
Full OpenAPI schemas: `docs/reference/openapi.yaml` → tag `Traffic Inspector`.

View File

@@ -862,6 +862,16 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `QUOTA_SATURATION_THRESHOLD` | `0.5` | `src/lib/quota/enforce.ts` | Pool saturation ratio (0..1); at/above it the pool enters strict mode (no borrowing). |
| `QUOTA_SOFT_DEPRIORITIZE_FACTOR` | `0.7` | `open-sse/services/combo.ts` | Score multiplier (0..1) applied to a target when the soft quota policy deprioritizes it. |
| `QUOTA_CONSUMPTION_RETENTION_DAYS` | `14` | `src/lib/db/quotaConsumption.ts` | Retention window (days) for `quota_consumption` buckets before GC (`gcQuotaConsumption`). |
| `AGENTBRIDGE_UPSTREAM_CA_CERT` | _(unset)_ | `src/mitm/manager.ts` | Extra CA certificate (PEM) trusted for AgentBridge upstream TLS connections. |
| `INSPECTOR_BUFFER_SIZE` | `1000` | `src/mitm/inspector/buffer.ts` | Max captured requests held in the Traffic Inspector ring buffer. |
| `INSPECTOR_MAX_BODY_KB` | `1024` | `src/mitm/inspector/buffer.ts` | Max captured request/response body size (KB) before truncation. |
| `INSPECTOR_HTTP_PROXY_PORT` | `8080` | `src/mitm/inspector/httpProxyServer.ts` | Local port for the Traffic Inspector HTTP proxy. |
| `INSPECTOR_HTTP_PROXY_AUTOSTART` | `false` | `src/mitm/inspector/httpProxyServer.ts` | Auto-start the inspector HTTP proxy on boot. |
| `INSPECTOR_TLS_INTERCEPT` | `false` | `src/lib/inspector/captureState.ts` | Enable TLS interception (MITM) for captured HTTPS traffic. |
| `INSPECTOR_LLM_HOSTS_EXTRA` | _(unset)_ | `src/lib/inspector/captureState.ts` | Extra hostnames (comma-separated) treated as LLM endpoints for capture. |
| `INSPECTOR_MASK_SECRETS` | `true` | `src/mitm/inspector/buffer.ts` | Mask secrets (auth headers / API keys) in captured traffic. |
| `INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES` | `30` | `src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts` | Minutes before the system-proxy guard auto-reverts OS proxy settings. |
| `INSPECTOR_INTERNAL_INGEST_TOKEN` | _(auto)_ | `src/app/api/tools/traffic-inspector/internal/ingest/route.ts` | Token authenticating internal capture ingest into the inspector. |
---

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,8 @@ const IGNORE_FROM_CODE = new Set([
"NEXT_RUNTIME",
"NODE_TEST_CONTEXT",
"VITEST",
// Instruction snippet shown to users (Traffic Inspector HttpProxySnippetCard) — not OmniRoute config.
"NODE_TLS_REJECT_UNAUTHORIZED",
// CI providers (set by the runner).
"GITHUB_BASE_REF",
"GITHUB_BASE_SHA",

View File

@@ -16,7 +16,6 @@ import {
ClineToolCard,
KiloToolCard,
DefaultToolCard,
AntigravityToolCard,
CopilotToolCard,
CustomCliCard,
HermesAgentToolCard,
@@ -44,7 +43,6 @@ const GUIDED_TOOL_IDS = new Set([
"amp",
"qwen",
]);
const MITM_TOOL_IDS = new Set(["antigravity", "kiro"]);
const CUSTOM_TOOL_IDS = new Set(["custom"]);
export default function CLIToolsPageClient({ machineId: _machineId }) {
@@ -251,7 +249,6 @@ export default function CLIToolsPageClient({ machineId: _machineId }) {
if (activeCategory === "all") return true;
if (activeCategory === "auto") return AUTO_CONFIGURED_TOOL_IDS.has(toolId);
if (activeCategory === "guided") return GUIDED_TOOL_IDS.has(toolId);
if (activeCategory === "mitm") return MITM_TOOL_IDS.has(toolId);
if (activeCategory === "custom") return CUSTOM_TOOL_IDS.has(toolId);
return true;
});
@@ -311,16 +308,6 @@ export default function CLIToolsPageClient({ machineId: _machineId }) {
cloudEnabled={cloudEnabled}
/>
);
case "antigravity":
return (
<AntigravityToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
case "cline":
return (
<ClineToolCard
@@ -372,18 +359,6 @@ export default function CLIToolsPageClient({ machineId: _machineId }) {
/>
);
default:
// #487: Any tool with configType "mitm" should use the MITM card (Start/Stop controls)
if (tool.configType === "mitm") {
return (
<AntigravityToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
}
return (
<DefaultToolCard
key={toolId}
@@ -447,7 +422,6 @@ export default function CLIToolsPageClient({ machineId: _machineId }) {
options={[
{ value: "auto", label: t("autoConfiguredTab") },
{ value: "guided", label: t("guidedClientsTab") },
{ value: "mitm", label: t("mitmClientsTab") },
{
value: "custom",
label: translateOrFallback("customCliTab", "Custom CLI"),

View File

@@ -1,6 +1,40 @@
import { redirect } from "next/navigation";
"use client";
export default function MitmProxyPage() {
// MITM Proxy será movido para Tools/AgentBridge (plano 11)
redirect("/dashboard/system/proxy");
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
/**
* MITM Proxy page — moved to AgentBridge (plan 11 §12).
* Shows a "page moved" banner for 2.5 s then redirects.
*/
export default function MitmProxyMovedPage() {
const router = useRouter();
const t = useTranslations("agentBridge.pageMoved");
useEffect(() => {
const timer = setTimeout(() => {
router.replace("/dashboard/tools/agent-bridge");
}, 2500);
return () => clearTimeout(timer);
}, [router]);
return (
<div className="flex min-h-screen items-center justify-center bg-background p-8">
<div className="rounded-xl border border-amber-500/40 bg-amber-900/20 p-8 text-center max-w-md w-full space-y-4">
<div className="flex items-center justify-center gap-2">
<span className="material-symbols-outlined text-amber-400 text-[28px]">info</span>
<h1 className="text-lg font-semibold text-amber-200">{t("title")}</h1>
</div>
<p className="text-sm text-amber-300/80">{t("message")}</p>
<button
type="button"
onClick={() => router.replace("/dashboard/tools/agent-bridge")}
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-500/20 text-amber-200 px-4 py-2 text-sm font-medium hover:bg-amber-500/30 transition-colors"
>
{t("goNow")}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,236 @@
"use client";
import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { RiskNoticeBanner } from "./components/RiskNoticeBanner";
import { AgentBridgeServerCard } from "./components/AgentBridgeServerCard";
import { AgentList } from "./components/AgentList";
import { EmptyStateNoProviders } from "./components/EmptyStateNoProviders";
import { useAgentBridgeState } from "./hooks/useAgentBridgeState";
import type { MitmTarget } from "@/mitm/types";
import type { MappingRow } from "./components/ModelMappingTable";
// ── Types ────────────────────────────────────────────────────────────────────
export interface AgentStateEntry {
agent_id: string;
dns_enabled: boolean;
cert_trusted: boolean;
setup_completed: boolean;
last_started_at: string | null;
last_error: string | null;
}
export interface AgentBridgeServerState {
running: boolean;
port: number;
certTrusted: boolean;
upstreamCa: string | null;
lastStartedAt: string | null;
activeConns: number;
interceptedCount: number;
}
export type AgentMappingsMap = Record<string, MappingRow[]>;
export interface AgentBridgePageData {
serverState: AgentBridgeServerState;
agentStates: AgentStateEntry[];
bypassPatterns: string[];
mappings: AgentMappingsMap;
}
interface AgentBridgePageClientProps {
initialData: AgentBridgePageData;
targets: MitmTarget[];
hasProviders: boolean;
}
// ── Component ────────────────────────────────────────────────────────────────
export default function AgentBridgePageClient({
initialData,
targets,
hasProviders,
}: AgentBridgePageClientProps) {
const t = useTranslations("agentBridge");
const { data, refresh } = useAgentBridgeState({ initialData });
const [actionError, setActionError] = useState<string | null>(null);
// ── Server actions ────────────────────────────────────────────────────────
const handleServerAction = useCallback(
async (action: "start" | "stop" | "restart" | "trust-cert" | "regenerate-cert") => {
setActionError(null);
try {
const res = await fetch("/api/tools/agent-bridge/server", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({ error: { message: `HTTP ${res.status}` } }))) as {
error?: { message?: string };
};
throw new Error(err.error?.message ?? `HTTP ${res.status}`);
}
await refresh();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Unknown error");
}
},
[refresh]
);
// ── Upstream CA ───────────────────────────────────────────────────────────
const handleUpstreamCaSave = useCallback(async (path: string) => {
setActionError(null);
try {
const res = await fetch("/api/tools/agent-bridge/upstream-ca", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await refresh();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Unknown error");
}
}, [refresh]);
// ── Bypass list ───────────────────────────────────────────────────────────
const handleBypassSave = useCallback(async (patterns: string[]) => {
setActionError(null);
try {
const res = await fetch("/api/tools/agent-bridge/bypass", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ patterns }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await refresh();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Unknown error");
}
}, [refresh]);
// ── DNS toggle ────────────────────────────────────────────────────────────
const handleDnsToggle = useCallback(
async (agentId: string, enabled: boolean) => {
setActionError(null);
try {
const res = await fetch(`/api/tools/agent-bridge/agents/${agentId}/dns`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await refresh();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Unknown error");
}
},
[refresh]
);
// ── Mappings save ─────────────────────────────────────────────────────────
const handleMappingsSave = useCallback(
async (agentId: string, mappings: MappingRow[]) => {
setActionError(null);
try {
const res = await fetch(`/api/tools/agent-bridge/agents/${agentId}/mappings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mappings }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await refresh();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Unknown error");
}
},
[refresh]
);
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="flex flex-col gap-5">
{/* Risk banner */}
<RiskNoticeBanner />
{/* Error alert */}
{actionError && (
<div
role="alert"
className="flex items-center gap-2 rounded-xl border border-red-500/30 bg-red-500/5 px-4 py-3 text-sm text-red-600 dark:text-red-400"
>
<span className="material-symbols-outlined text-[16px]">error</span>
{actionError}
<button
type="button"
onClick={() => setActionError(null)}
className="ml-auto text-red-500 hover:text-red-400"
aria-label="Dismiss"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
)}
{/* Empty state: no providers */}
{!hasProviders ? (
<EmptyStateNoProviders />
) : (
<>
{/* Server card */}
<AgentBridgeServerCard
serverState={data.serverState}
onAction={handleServerAction}
onUpstreamCaSave={handleUpstreamCaSave}
onBypassSave={handleBypassSave}
bypassPatterns={data.bypassPatterns}
/>
{/* Agent list */}
<AgentList
targets={targets}
agentStates={data.agentStates}
serverRunning={data.serverState.running}
mappingsMap={data.mappings}
onDnsToggle={handleDnsToggle}
onMappingsSave={handleMappingsSave}
/>
{/* Quick links */}
<div className="rounded-xl border border-border/40 bg-card px-5 py-4">
<h3 className="text-xs font-semibold text-text-muted mb-2 uppercase tracking-wide">
{t("quickLinks") || "Quick links"}
</h3>
<div className="flex flex-wrap gap-3">
<Link
href="/dashboard/providers"
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[14px]">dns</span>
{t("quickLinkProviders") || "Configure providers"}
</Link>
<Link
href="/dashboard/tools/traffic-inspector"
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[14px]">network_check</span>
{t("quickLinkInspector") || "View traffic in Traffic Inspector"}
</Link>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,201 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { CertStatusIcon } from "./shared/CertStatusIcon";
import { UpstreamCaField } from "./UpstreamCaField";
import { BypassListEditor } from "./BypassListEditor";
import type { AgentBridgeServerState } from "../AgentBridgePageClient";
interface AgentBridgeServerCardProps {
serverState: AgentBridgeServerState;
onAction: (action: "start" | "stop" | "restart" | "trust-cert" | "regenerate-cert") => Promise<void>;
onUpstreamCaSave: (path: string) => Promise<void>;
onBypassSave: (patterns: string[]) => Promise<void>;
bypassPatterns: string[];
}
/**
* Global server card — status + action buttons + CA field + bypass list.
* Matches plan 11 §3 AgentBridge Server layout.
*/
export function AgentBridgeServerCard({
serverState,
onAction,
onUpstreamCaSave,
onBypassSave,
bypassPatterns,
}: AgentBridgeServerCardProps) {
const t = useTranslations("agentBridge");
const [loading, setLoading] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
const [upstreamCa, setUpstreamCa] = useState(serverState.upstreamCa ?? "");
const runAction = async (action: "start" | "stop" | "restart" | "trust-cert" | "regenerate-cert") => {
setLoading(action);
try {
await onAction(action);
} finally {
setLoading(null);
}
};
const isRunning = serverState.running;
return (
<div className="rounded-xl border border-border/60 bg-card overflow-hidden">
{/* Header row */}
<div className="flex items-center justify-between px-5 py-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<span className="material-symbols-outlined text-[20px] text-primary">link</span>
</div>
<div>
<h2 className="text-sm font-semibold text-text-main flex items-center gap-2">
{t("serverCardTitle") || "AgentBridge Server"}
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
isRunning
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${isRunning ? "bg-emerald-500 animate-pulse" : "bg-zinc-400"}`}
/>
{isRunning ? t("statusRunning") || "Running" : t("statusStopped") || "Stopped"}
</span>
</h2>
<div className="flex items-center gap-3 mt-0.5 text-xs text-text-muted">
<span>
{t("serverPort") || "Port"}: {serverState.port ?? 443}
</span>
<CertStatusIcon trusted={serverState.certTrusted ?? false} />
{serverState.activeConns !== undefined && (
<span>
{t("serverConns") || "Connections"}: {serverState.activeConns}
</span>
)}
{serverState.interceptedCount !== undefined && (
<span>
{t("serverIntercepted") || "Intercepted"}: {serverState.interceptedCount.toLocaleString()}
</span>
)}
{serverState.lastStartedAt && (
<span>
{t("serverLastStarted") || "Last started"}:{" "}
{new Date(serverState.lastStartedAt).toLocaleTimeString()}
</span>
)}
</div>
</div>
</div>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="text-text-muted hover:text-text-main transition-colors"
aria-label={expanded ? "Collapse" : "Expand"}
>
<span className="material-symbols-outlined text-[18px]">
{expanded ? "expand_less" : "expand_more"}
</span>
</button>
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-2 px-5 pb-4">
<button
type="button"
onClick={() => runAction("start")}
disabled={isRunning || loading !== null}
aria-label={t("startServer")}
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-500/10 text-emerald-600 px-3 py-1.5 text-xs font-medium hover:bg-emerald-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
{loading === "start" ? t("starting") || "Starting…" : t("startServer") || "Start"}
</button>
<button
type="button"
onClick={() => runAction("stop")}
disabled={!isRunning || loading !== null}
aria-label={t("stopServer")}
className="inline-flex items-center gap-1.5 rounded-lg bg-red-500/10 text-red-600 px-3 py-1.5 text-xs font-medium hover:bg-red-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[14px]">stop</span>
{loading === "stop" ? t("stopping") || "Stopping…" : t("stopServer") || "Stop"}
</button>
<button
type="button"
onClick={() => runAction("restart")}
disabled={loading !== null}
aria-label={t("restartServer")}
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-500/10 text-amber-600 px-3 py-1.5 text-xs font-medium hover:bg-amber-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[14px]">refresh</span>
{loading === "restart" ? t("restarting") || "Restarting…" : t("restartServer") || "Restart"}
</button>
<button
type="button"
onClick={() => runAction("trust-cert")}
disabled={loading !== null}
aria-label={t("trustCert")}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-500/10 text-blue-600 px-3 py-1.5 text-xs font-medium hover:bg-blue-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[14px]">security</span>
{loading === "trust-cert" ? t("trusting") || "Trusting…" : t("trustCert") || "Trust Cert"}
</button>
<a
href="/api/tools/agent-bridge/cert/download"
download
aria-label={t("downloadCert")}
className="inline-flex items-center gap-1.5 rounded-lg bg-violet-500/10 text-violet-600 px-3 py-1.5 text-xs font-medium hover:bg-violet-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">download</span>
{t("downloadCert") || "Download Cert"}
</a>
<button
type="button"
onClick={() => runAction("regenerate-cert")}
disabled={loading !== null}
aria-label={t("regenerateCert")}
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-500/10 text-text-muted px-3 py-1.5 text-xs font-medium hover:bg-zinc-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[14px]">autorenew</span>
{loading === "regenerate-cert"
? t("regenerating") || "Regenerating…"
: t("regenerateCert") || "Regenerate Cert"}
</button>
</div>
{/* Expanded: CA + Bypass */}
{expanded && (
<div className="px-5 pb-5 border-t border-border/30 pt-4 flex flex-col gap-5">
<UpstreamCaField
value={upstreamCa}
onChange={setUpstreamCa}
onSave={onUpstreamCaSave}
/>
<div>
<h4 className="text-xs font-semibold text-text-main mb-2">
{t("bypassSectionTitle") || "Bypass List"}
</h4>
<p className="text-xs text-text-muted mb-3">
{t("bypassSectionDesc") ||
"Hosts matching these patterns are tunneled directly (no TLS decryption). Defaults include banks, .gov, and corporate SSO."}
</p>
<BypassListEditor
patterns={bypassPatterns}
onSave={onBypassSave}
/>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,268 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { AgentIcon } from "./shared/AgentIcon";
import { DnsStatusBadge } from "./shared/DnsStatusBadge";
import { ModelMappingTable } from "./ModelMappingTable";
import { SetupWizard } from "./SetupWizard";
import { RiskNoticeModal } from "@/shared/components/RiskNoticeModal";
import type { MitmTarget } from "@/mitm/types";
import type { AgentStateEntry } from "../AgentBridgePageClient";
import type { MappingRow } from "./ModelMappingTable";
const RISK_STORAGE_KEY_PREFIX = "omniroute-agentbridge-risk-dismissed-";
function hasAcceptedRisk(agentId: string): boolean {
try {
return localStorage.getItem(RISK_STORAGE_KEY_PREFIX + agentId) === "true";
} catch {
return false;
}
}
interface AgentCardProps {
target: MitmTarget;
agentState: AgentStateEntry | undefined;
serverRunning: boolean;
mappings: MappingRow[];
onDnsToggle: (agentId: string, enabled: boolean) => Promise<void>;
onMappingsSave: (agentId: string, mappings: MappingRow[]) => Promise<void>;
}
/**
* Expandable card for a single IDE agent.
*/
export function AgentCard({
target,
agentState,
serverRunning,
mappings,
onDnsToggle,
onMappingsSave,
}: AgentCardProps) {
const t = useTranslations("agentBridge");
const [expanded, setExpanded] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
const [dnsLoading, setDnsLoading] = useState(false);
const [riskModalOpen, setRiskModalOpen] = useState(false);
const dnsEnabled = agentState?.dns_enabled ?? false;
const setupCompleted = agentState?.setup_completed ?? false;
const certTrusted = agentState?.cert_trusted ?? false;
const isInvestigating = target.viability === "investigating";
const getStatusBadge = () => {
if (isInvestigating) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-500/10 text-zinc-500 dark:text-zinc-400 text-xs font-medium">
<span className="material-symbols-outlined text-[12px]">search</span>
{t("statusInvestigating") || "Investigating"}
</span>
);
}
if (setupCompleted && dnsEnabled) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-xs font-medium">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
{t("statusActive") || "Active"}
</span>
);
}
if (!setupCompleted) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-500/10 text-zinc-500 text-xs font-medium">
<span className="material-symbols-outlined text-[12px]">settings</span>
{t("statusSetupRequired") || "Setup required"}
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium">
<span className="material-symbols-outlined text-[12px]">warning</span>
{t("statusDnsOff") || "DNS off"}
</span>
);
};
const reallyToggleDns = async (enabled: boolean) => {
setDnsLoading(true);
try {
await onDnsToggle(target.id, enabled);
} finally {
setDnsLoading(false);
}
};
const handleDnsToggle = async () => {
const enabling = !dnsEnabled;
if (enabling && !hasAcceptedRisk(target.id)) {
setRiskModalOpen(true);
return;
}
await reallyToggleDns(enabling);
};
const handleRiskAccept = async () => {
setRiskModalOpen(false);
await reallyToggleDns(true);
};
return (
<>
<div
className="rounded-xl border border-border/50 bg-card overflow-hidden transition-all hover:border-border/80"
style={{ borderLeftWidth: 3, borderLeftColor: target.color }}
>
{/* Card header */}
<button
type="button"
className="w-full flex items-center justify-between gap-3 px-4 py-3 text-left hover:bg-surface/30 transition-colors"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<div className="flex items-center gap-3 min-w-0">
<AgentIcon icon={target.icon} color={target.color} size={18} />
<div className="min-w-0">
<p className="text-sm font-medium text-text-main truncate">{target.name}</p>
<p className="text-xs text-text-muted truncate">
{target.hosts.slice(0, 2).join(", ")}
{target.hosts.length > 2 && ` +${target.hosts.length - 2}`}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{getStatusBadge()}
<DnsStatusBadge enabled={dnsEnabled} />
<span className="material-symbols-outlined text-[16px] text-text-muted">
{expanded ? "expand_less" : "expand_more"}
</span>
</div>
</button>
{/* Expanded content */}
{expanded && (
<div className="px-4 pb-4 border-t border-border/20 pt-4 flex flex-col gap-4">
{/* Hosts */}
<div>
<p className="text-xs font-medium text-text-muted mb-1.5">
{t("agentHosts") || "Intercepted hosts"}
</p>
<div className="flex flex-wrap gap-1.5">
{target.hosts.map((h) => (
<span
key={h}
className="inline-flex items-center px-2 py-0.5 rounded-full bg-surface text-xs font-mono text-text-muted border border-border/40"
>
{h}
</span>
))}
</div>
</div>
{/* Cert status */}
<div className="flex items-center gap-2 text-xs text-text-muted">
<span
className={`material-symbols-outlined text-[14px] ${certTrusted ? "text-emerald-500" : "text-zinc-400"}`}
>
{certTrusted ? "verified_user" : "lock_open"}
</span>
{certTrusted
? t("certTrusted") || "Certificate trusted"
: t("certNotTrusted") || "Certificate not trusted"}
</div>
{/* Investigating notice */}
{isInvestigating && (
<div className="rounded-lg border border-zinc-500/20 bg-zinc-500/5 p-3">
<p className="text-xs text-text-muted">
{t("investigatingNotice") ||
"This agent is under investigation. Hosts and API surface are still being confirmed. Setup will be available once the upstream API is documented."}
</p>
</div>
)}
{/* Model mappings */}
{!isInvestigating && (
<div>
<p className="text-xs font-medium text-text-muted mb-2">
{t("modelMappingsLabel") || "Model mappings"}
</p>
<ModelMappingTable
agentId={target.id}
mappings={mappings}
onSave={onMappingsSave}
/>
</div>
)}
{/* Action buttons */}
<div className="flex flex-wrap gap-2">
{!isInvestigating && (
<button
type="button"
onClick={() => setWizardOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary/10 text-primary px-3 py-1.5 text-xs font-medium hover:bg-primary/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
{t("setupWizard") || "Setup wizard"}
</button>
)}
{!isInvestigating && (
<button
type="button"
onClick={handleDnsToggle}
disabled={dnsLoading}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 ${
dnsEnabled
? "bg-red-500/10 text-red-600 hover:bg-red-500/20"
: "bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{dnsEnabled ? "stop" : "play_arrow"}
</span>
{dnsLoading
? t("toggling") || "Toggling…"
: dnsEnabled
? t("stopDns") || "Stop DNS"
: t("startDns") || "Start DNS"}
</button>
)}
<a
href={`/dashboard/tools/traffic-inspector?agent=${target.id}`}
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-500/10 text-text-muted px-3 py-1.5 text-xs font-medium hover:bg-zinc-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">network_check</span>
{t("viewTraffic") || "View traffic"}
</a>
</div>
</div>
)}
</div>
{wizardOpen && (
<SetupWizard
target={target}
agentState={agentState}
serverRunning={serverRunning}
onClose={() => setWizardOpen(false)}
onDnsToggle={onDnsToggle}
/>
)}
<RiskNoticeModal
open={riskModalOpen}
title={t("riskNoticeTitle")}
body={t("riskNoticeBody")}
dontShowAgainKey={RISK_STORAGE_KEY_PREFIX + target.id}
onAccept={handleRiskAccept}
onCancel={() => setRiskModalOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { AgentCard } from "./AgentCard";
import type { MitmTarget } from "@/mitm/types";
import type { AgentStateEntry, AgentMappingsMap } from "../AgentBridgePageClient";
import type { MappingRow } from "./ModelMappingTable";
interface AgentListProps {
targets: MitmTarget[];
agentStates: AgentStateEntry[];
serverRunning: boolean;
mappingsMap: AgentMappingsMap;
onDnsToggle: (agentId: string, enabled: boolean) => Promise<void>;
onMappingsSave: (agentId: string, mappings: MappingRow[]) => Promise<void>;
}
type SetupFilter = "all" | "active" | "setup-required" | "investigating";
/**
* Grid of agent cards with filter + search controls.
* Matches plan 11 §3 IDE Agents section.
*/
export function AgentList({
targets,
agentStates,
serverRunning,
mappingsMap,
onDnsToggle,
onMappingsSave,
}: AgentListProps) {
const t = useTranslations("agentBridge");
const [filter, setFilter] = useState<SetupFilter>("all");
const [search, setSearch] = useState("");
const stateByAgent = Object.fromEntries(agentStates.map((s) => [s.agent_id, s]));
const filtered = targets.filter((target) => {
// Search filter
if (search) {
const q = search.toLowerCase();
if (
!target.name.toLowerCase().includes(q) &&
!target.id.toLowerCase().includes(q) &&
!target.hosts.some((h) => h.toLowerCase().includes(q))
) {
return false;
}
}
const state = stateByAgent[target.id];
// Setup status filter
if (filter === "active") {
return state?.dns_enabled && state?.setup_completed;
}
if (filter === "setup-required") {
return !state?.setup_completed && target.viability !== "investigating";
}
if (filter === "investigating") {
return target.viability === "investigating";
}
return true;
});
const filterOptions: { id: SetupFilter; label: string }[] = [
{ id: "all", label: t("filterAll") || "All" },
{ id: "active", label: t("filterActive") || "Active" },
{ id: "setup-required", label: t("filterSetupRequired") || "Setup required" },
{ id: "investigating", label: t("filterInvestigating") || "Investigating" },
];
return (
<div className="rounded-xl border border-border/60 bg-card overflow-hidden">
{/* Controls */}
<div className="flex flex-wrap items-center gap-3 px-5 py-4 border-b border-border/30">
<h2 className="text-sm font-semibold text-text-main mr-auto">
{t("agentListTitle") || "IDE Agents"}{" "}
<span className="text-text-muted font-normal">({targets.length})</span>
</h2>
{/* Filter buttons */}
<div className="flex gap-1">
{filterOptions.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setFilter(opt.id)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
filter === opt.id
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text-main hover:bg-surface"
}`}
>
{opt.label}
</button>
))}
</div>
{/* Search */}
<div className="relative">
<span className="material-symbols-outlined absolute left-2 top-1/2 -translate-y-1/2 text-[16px] text-text-muted pointer-events-none">
search
</span>
<input
type="text"
className="rounded-lg border border-border/50 bg-surface pl-8 pr-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder={t("searchAgents") || "Search agents…"}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{/* Grid */}
<div className="p-5 flex flex-col gap-3">
{filtered.length === 0 ? (
<div className="py-8 text-center text-text-muted">
<span className="material-symbols-outlined text-[36px] block mb-2 text-text-muted/40">
search_off
</span>
<p className="text-sm">{t("noAgentsMatch") || "No agents match the current filter"}</p>
</div>
) : (
filtered.map((target) => (
<AgentCard
key={target.id}
target={target}
agentState={stateByAgent[target.id]}
serverRunning={serverRunning}
mappings={mappingsMap[target.id] ?? []}
onDnsToggle={onDnsToggle}
onMappingsSave={onMappingsSave}
/>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
const DEFAULT_BYPASS_PATTERNS = [
"*.bank.*",
"*.gov.*",
"*.okta.com",
"*.auth0.com",
];
interface BypassListEditorProps {
patterns: string[];
onSave: (patterns: string[]) => Promise<void>;
}
/**
* Textarea / chip editor for user-defined bypass patterns.
* Shows read-only defaults + editable user list.
*/
export function BypassListEditor({ patterns, onSave }: BypassListEditorProps) {
const t = useTranslations("agentBridge");
const [userInput, setUserInput] = useState(patterns.join("\n"));
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
const parsed = userInput
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
await onSave(parsed);
} finally {
setSaving(false);
}
};
return (
<div className="flex flex-col gap-3">
<div>
<p className="text-xs font-medium text-text-muted mb-1.5">
{t("bypassDefaultsLabel") || "Default bypass patterns (read-only)"}
</p>
<div className="flex flex-wrap gap-1.5">
{DEFAULT_BYPASS_PATTERNS.map((p) => (
<span
key={p}
className="inline-flex items-center px-2 py-0.5 rounded-full bg-surface text-xs text-text-muted border border-border/40"
>
{p}
</span>
))}
</div>
</div>
<div>
<label className="text-xs font-medium text-text-muted mb-1.5 block">
{t("bypassUserLabel") || "Custom bypass patterns (one per line, glob or regex)"}
</label>
<textarea
className="w-full min-h-[80px] rounded-lg border border-border/50 bg-card px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder="*.internal.corp&#10;sso.example.com"
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
/>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-lg bg-primary/10 text-primary px-4 py-2 text-sm font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{saving ? t("saving") || "Saving…" : t("saveBypassList") || "Save bypass list"}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useTranslations } from "next-intl";
import Link from "next/link";
/**
* Empty state shown when no providers are configured.
* Matches plan 11 §7.
*/
export function EmptyStateNoProviders() {
const t = useTranslations("agentBridge");
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-card/50 px-8 py-14 text-center gap-4">
<div className="p-4 rounded-2xl bg-primary/10">
<span className="material-symbols-outlined text-[48px] text-primary">
dns
</span>
</div>
<div>
<h3 className="text-base font-semibold text-text-main mb-1">
{t("emptyNoProvidersTitle") || "No providers configured yet"}
</h3>
<p className="text-sm text-text-muted max-w-sm">
{t("emptyNoProvidersBody") ||
"To use AgentBridge, first connect at least one provider. It will be the destination where IDE requests are routed."}
</p>
</div>
<Link
href="/dashboard/providers"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
>
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
{t("emptyGoToProviders") || "Go to Providers"}
</Link>
</div>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { ModelSelectorModal } from "./ModelSelectorModal";
export interface MappingRow {
source: string;
target: string;
}
interface ModelMappingTableProps {
agentId: string;
mappings: MappingRow[];
onSave: (agentId: string, mappings: MappingRow[]) => Promise<void>;
}
/**
* Editable table: source model → target OmniRoute model.
*/
export function ModelMappingTable({ agentId, mappings, onSave }: ModelMappingTableProps) {
const t = useTranslations("agentBridge");
const [rows, setRows] = useState<MappingRow[]>(mappings);
const [selectorOpen, setSelectorOpen] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const updateTarget = (index: number, target: string) => {
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, target } : r)));
setSelectorOpen(null);
};
const handleSave = async () => {
setSaving(true);
try {
await onSave(agentId, rows);
} finally {
setSaving(false);
}
};
if (rows.length === 0) {
return (
<p className="text-xs text-text-muted italic">
{t("noMappings") || "No model mappings configured. Run setup wizard to auto-detect models."}
</p>
);
}
return (
<div className="flex flex-col gap-3">
<div className="rounded-lg border border-border/40 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/40 bg-surface/60">
<th className="px-3 py-2 text-left text-xs font-medium text-text-muted">
{t("sourceModel") || "Source model (agent native)"}
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-text-muted">
{t("targetModel") || "Target model (OmniRoute)"}
</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className="border-b border-border/20 last:border-0">
<td className="px-3 py-2">
<span className="font-mono text-xs text-text-muted">{row.source}</span>
</td>
<td className="px-3 py-2">
<button
type="button"
onClick={() => setSelectorOpen(i)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border/40 bg-card px-2.5 py-1 text-xs hover:bg-surface transition-colors font-mono"
>
{row.target || (
<span className="text-text-muted italic">{t("selectModel") || "Select…"}</span>
)}
<span className="material-symbols-outlined text-[12px] text-text-muted">
expand_more
</span>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-lg bg-primary/10 text-primary px-4 py-1.5 text-sm font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{saving ? t("saving") || "Saving…" : t("saveMappings") || "Save mappings"}
</button>
</div>
{selectorOpen !== null && (
<ModelSelectorModal
open
currentModel={rows[selectorOpen]?.target ?? ""}
onSelect={(model) => updateTarget(selectorOpen, model)}
onClose={() => setSelectorOpen(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
interface ProviderModel {
id: string;
name: string;
}
interface ModelSelectorModalProps {
open: boolean;
currentModel: string;
onSelect: (model: string) => void;
onClose: () => void;
}
/**
* Modal for picking an OmniRoute target model for model-mapping.
*/
export function ModelSelectorModal({
open,
currentModel,
onSelect,
onClose,
}: ModelSelectorModalProps) {
const t = useTranslations("agentBridge");
const [models, setModels] = useState<ProviderModel[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const loadModels = useCallback(async () => {
try {
const r = await fetch("/api/v1/models");
const d = (await r.json()) as { data?: ProviderModel[] };
setModels(Array.isArray(d.data) ? d.data : []);
} catch {
setModels([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
loadModels();
}, [open, loadModels]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open) return null;
const filtered = models.filter(
(m) =>
m.id.toLowerCase().includes(search.toLowerCase()) ||
m.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
>
<div className="w-full max-w-sm rounded-xl border border-border/60 bg-card shadow-xl flex flex-col max-h-[70vh]">
<div className="flex items-center justify-between px-4 pt-4 pb-3 border-b border-border/30">
<h3 className="text-sm font-semibold text-text-main">
{t("modelSelectorTitle") || "Select target model"}
</h3>
<button type="button" onClick={onClose} aria-label="Close">
<span className="material-symbols-outlined text-[18px] text-text-muted hover:text-text-main">
close
</span>
</button>
</div>
<div className="px-4 py-2">
<input
type="text"
autoFocus
className="w-full rounded-lg border border-border/50 bg-surface px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder={t("modelSelectorSearch") || "Search models…"}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex-1 overflow-y-auto px-4 pb-4 flex flex-col gap-1">
{loading && (
<p className="text-xs text-text-muted py-4 text-center">
{t("loading") || "Loading models…"}
</p>
)}
{!loading && filtered.length === 0 && (
<p className="text-xs text-text-muted py-4 text-center">
{t("noModelsFound") || "No models found"}
</p>
)}
{filtered.map((m) => (
<button
key={m.id}
type="button"
onClick={() => onSelect(m.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
m.id === currentModel
? "bg-primary/10 text-primary font-medium"
: "hover:bg-surface text-text-main"
}`}
>
<span className="font-mono text-xs">{m.id}</span>
{m.name !== m.id && (
<span className="ml-2 text-text-muted text-xs">{m.name}</span>
)}
</button>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
const STORAGE_KEY = "omniroute-agentbridge-risk-dismissed";
function isNotDismissed(): boolean {
try {
return !localStorage.getItem(STORAGE_KEY);
} catch {
return true;
}
}
/**
* Amber dismissable banner shown at the top of the AgentBridge page.
* Persisted via localStorage so it only shows once per user.
* Uses lazy useState initializer to read localStorage without useEffect.
*/
export function RiskNoticeBanner() {
const t = useTranslations("agentBridge");
const [visible, setVisible] = useState<boolean>(isNotDismissed);
const dismiss = () => {
try {
localStorage.setItem(STORAGE_KEY, "true");
} catch {
// ignore
}
setVisible(false);
};
if (!visible) return null;
return (
<div
role="alert"
className="flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3"
>
<span className="material-symbols-outlined text-amber-500 shrink-0 mt-0.5">warning</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-amber-700 dark:text-amber-400">
{t("riskBannerTitle") || "Use at your own risk"}
</p>
<p className="text-xs text-amber-600/80 dark:text-amber-300/70 mt-0.5">
{t("riskBannerBody") ||
"AgentBridge intercepts HTTPS traffic from IDE agents. By activating it you accept responsibility for compliance with the terms of service of each agent. Never use on devices or networks where TLS inspection is prohibited."}
</p>
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("riskBannerDismiss") || "Dismiss"}
className="shrink-0 text-amber-500 hover:text-amber-400 transition-colors"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
);
}

View File

@@ -0,0 +1,281 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import type { AgentStateEntry } from "../AgentBridgePageClient";
import type { MitmTarget } from "@/mitm/types";
interface SetupWizardProps {
target: MitmTarget;
agentState: AgentStateEntry | undefined;
serverRunning: boolean;
onClose: () => void;
onDnsToggle: (agentId: string, enabled: boolean) => Promise<void>;
}
type Step = "verify" | "dns" | "mappings";
/**
* 3-step setup wizard for a single agent.
* Step 1: Verify server + cert
* Step 2: Enable DNS
* Step 3: Model mappings prompt
*/
export function SetupWizard({
target,
agentState,
serverRunning,
onClose,
onDnsToggle,
}: SetupWizardProps) {
const t = useTranslations("agentBridge");
const [step, setStep] = useState<Step>("verify");
const [enablingDns, setEnablingDns] = useState(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
const certTrusted = agentState?.cert_trusted ?? false;
const dnsEnabled = agentState?.dns_enabled ?? false;
const handleEnableDns = async () => {
setEnablingDns(true);
try {
await onDnsToggle(target.id, true);
setStep("mappings");
} finally {
setEnablingDns(false);
}
};
const steps: { id: Step; label: string }[] = [
{ id: "verify", label: t("wizardStep1Label") || "Verify" },
{ id: "dns", label: t("wizardStep2Label") || "DNS" },
{ id: "mappings", label: t("wizardStep3Label") || "Mappings" },
];
const stepIndex = steps.findIndex((s) => s.id === step);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
>
<div className="w-full max-w-lg rounded-xl border border-border/60 bg-card shadow-xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-4 border-b border-border/30">
<div className="flex items-center gap-3">
<span
className="material-symbols-outlined text-[20px]"
style={{ color: target.color }}
>
{target.icon}
</span>
<div>
<h3 className="text-sm font-semibold text-text-main">
{t("wizardTitle") || "Setup wizard"} {target.name}
</h3>
<p className="text-xs text-text-muted">{t("wizardSubtitle") || "3-step setup"}</p>
</div>
</div>
<button type="button" onClick={onClose} aria-label="Close">
<span className="material-symbols-outlined text-[18px] text-text-muted hover:text-text-main">
close
</span>
</button>
</div>
{/* Step indicator */}
<div className="flex px-5 pt-4 gap-2">
{steps.map((s, i) => (
<div key={s.id} className="flex items-center gap-1.5 flex-1">
<div
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium shrink-0 ${
i < stepIndex
? "bg-emerald-500 text-white"
: i === stepIndex
? "bg-primary text-white"
: "bg-surface text-text-muted border border-border/50"
}`}
>
{i < stepIndex ? (
<span className="material-symbols-outlined text-[12px]">check</span>
) : (
i + 1
)}
</div>
<span
className={`text-xs ${i === stepIndex ? "text-text-main font-medium" : "text-text-muted"}`}
>
{s.label}
</span>
{i < steps.length - 1 && (
<div className="flex-1 h-px bg-border/30 ml-1" />
)}
</div>
))}
</div>
{/* Step content */}
<div className="px-5 py-5 flex flex-col gap-4 min-h-[180px]">
{step === "verify" && (
<div className="flex flex-col gap-3">
<p className="text-sm text-text-muted">
{t("wizardStep1Desc") || "Confirm the server is running and the certificate is installed."}
</p>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm">
<span
className={`material-symbols-outlined text-[16px] ${serverRunning ? "text-emerald-500" : "text-red-500"}`}
>
{serverRunning ? "check_circle" : "cancel"}
</span>
<span>
{t("wizardServerCheck") || "AgentBridge server"}{" "}
{serverRunning
? t("wizardRunning") || "running"
: t("wizardNotRunning") || "not running"}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span
className={`material-symbols-outlined text-[16px] ${certTrusted ? "text-emerald-500" : "text-amber-500"}`}
>
{certTrusted ? "verified_user" : "warning"}
</span>
<span>
{t("wizardCertCheck") || "Certificate"}{" "}
{certTrusted
? t("wizardTrusted") || "trusted"
: t("wizardNotTrusted") || "not yet trusted — use Trust Cert button"}
</span>
</div>
</div>
{/* Tutorial steps */}
{target.setupTutorial.steps.length > 0 && (
<div className="mt-2 p-3 rounded-lg bg-surface/50 border border-border/30">
<p className="text-xs font-medium text-text-muted mb-2">
{t("wizardTutorialTitle") || "Setup instructions:"}
</p>
<ol className="flex flex-col gap-1">
{target.setupTutorial.steps.map((step, i) => (
<li key={i} className="text-xs text-text-muted flex items-start gap-1.5">
<span className="shrink-0 text-primary font-medium">{i + 1}.</span>
{step}
</li>
))}
</ol>
</div>
)}
</div>
)}
{step === "dns" && (
<div className="flex flex-col gap-3">
<p className="text-sm text-text-muted">
{t("wizardStep2Desc") || "The following entries will be added to /etc/hosts to redirect traffic through AgentBridge:"}
</p>
<div className="rounded-lg bg-surface/50 border border-border/30 p-3 font-mono text-xs flex flex-col gap-1">
{target.hosts.map((host) => (
<div key={host} className="text-text-muted">
<span className="text-primary">127.0.0.1</span> {host}
</div>
))}
</div>
{dnsEnabled && (
<div className="flex items-center gap-2 text-sm text-emerald-500">
<span className="material-symbols-outlined text-[16px]">check_circle</span>
{t("wizardDnsAlreadyEnabled") || "DNS already enabled for this agent"}
</div>
)}
</div>
)}
{step === "mappings" && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">check_circle</span>
<p className="text-sm font-medium">
{t("wizardStep3Success") || "Agent is configured!"}
</p>
</div>
<p className="text-sm text-text-muted">
{t("wizardStep3Desc") || "You can now configure model mappings in the agent card. Restart the IDE to apply changes."}
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 pb-5 pt-0 border-t border-border/30 mt-0 pt-4">
<button
type="button"
onClick={() => {
if (step === "dns") setStep("verify");
else if (step === "mappings") setStep("dns");
else onClose();
}}
className="rounded-lg border border-border/50 bg-card px-4 py-2 text-sm text-text-muted hover:bg-surface transition-colors"
>
{step === "verify" ? t("cancel") || "Cancel" : t("back") || "Back"}
</button>
<div className="flex gap-2">
{step === "verify" && (
<button
type="button"
onClick={() => setStep("dns")}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
>
{t("next") || "Next"}{" "}
<span className="material-symbols-outlined text-[14px] ml-1">arrow_forward</span>
</button>
)}
{step === "dns" && (
<>
{dnsEnabled ? (
<button
type="button"
onClick={() => setStep("mappings")}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
>
{t("next") || "Next"}
</button>
) : (
<button
type="button"
onClick={handleEnableDns}
disabled={enablingDns}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{enablingDns
? t("enablingDns") || "Enabling…"
: t("wizardEnableDns") || "Add /etc/hosts entries"}
</button>
)}
</>
)}
{step === "mappings" && (
<button
type="button"
onClick={onClose}
className="rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-400 transition-colors"
>
{t("done") || "Done"}
</button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
interface UpstreamCaFieldProps {
value: string;
onChange: (v: string) => void;
onSave: (path: string) => Promise<void>;
}
/**
* Input + Test button for the optional upstream CA certificate path.
* Used for corporate networks that intercept TLS upstream.
*/
export function UpstreamCaField({ value, onChange, onSave }: UpstreamCaFieldProps) {
const t = useTranslations("agentBridge");
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<"ok" | "error" | null>(null);
const handleTest = async () => {
if (!value.trim()) return;
setTesting(true);
setTestResult(null);
try {
const res = await fetch("/api/tools/agent-bridge/upstream-ca/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: value.trim() }),
});
setTestResult(res.ok ? "ok" : "error");
} catch {
setTestResult("error");
} finally {
setTesting(false);
}
};
const handleSave = async () => {
await onSave(value.trim());
};
return (
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-text-muted">
{t("upstreamCaLabel") || "Upstream CA Certificate (corporate)"}
</label>
<div className="flex gap-2">
<input
type="text"
className="flex-1 rounded-lg border border-border/50 bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder={t("upstreamCaPlaceholder") || "/etc/ssl/certs/corp-ca.pem"}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<button
type="button"
onClick={handleTest}
disabled={testing || !value.trim()}
className="shrink-0 rounded-lg border border-border/50 bg-card px-3 py-2 text-xs font-medium hover:bg-surface transition-colors disabled:opacity-50"
>
{testing ? "Testing…" : t("upstreamCaTest") || "Test TLS"}
</button>
<button
type="button"
onClick={handleSave}
disabled={!value.trim()}
className="shrink-0 rounded-lg bg-primary/10 text-primary px-3 py-2 text-xs font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{t("save") || "Save"}
</button>
</div>
{testResult === "ok" && (
<p className="text-xs text-emerald-500">
<span className="material-symbols-outlined text-[12px] mr-1">check_circle</span>
{t("upstreamCaTestOk") || "TLS test passed"}
</p>
)}
{testResult === "error" && (
<p className="text-xs text-red-500">
<span className="material-symbols-outlined text-[12px] mr-1">error</span>
{t("upstreamCaTestError") || "TLS test failed — check the path and CA file"}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
interface AgentIconProps {
icon: string;
color: string;
size?: number;
}
export function AgentIcon({ icon, color, size = 20 }: AgentIconProps) {
return (
<div
className="flex items-center justify-center rounded-lg shrink-0"
style={{
backgroundColor: `${color}20`,
width: size + 12,
height: size + 12,
}}
>
<span
className="material-symbols-outlined"
style={{ fontSize: size, color }}
>
{icon}
</span>
</div>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { useTranslations } from "next-intl";
interface CertStatusIconProps {
trusted: boolean;
size?: number;
}
export function CertStatusIcon({ trusted, size = 16 }: CertStatusIconProps) {
const t = useTranslations("agentBridge");
return trusted ? (
<span
className="material-symbols-outlined text-emerald-500"
style={{ fontSize: size }}
title={t("certTrusted")}
>
verified_user
</span>
) : (
<span
className="material-symbols-outlined text-zinc-400"
style={{ fontSize: size }}
title={t("certNotTrusted")}
>
lock_open
</span>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
interface DnsStatusBadgeProps {
enabled: boolean;
}
export function DnsStatusBadge({ enabled }: DnsStatusBadgeProps) {
return (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
enabled
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${enabled ? "bg-emerald-500" : "bg-zinc-400"}`}
/>
{enabled ? "DNS on" : "DNS off"}
</span>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentBridgePageData } from "../AgentBridgePageClient";
interface UseAgentBridgeStateOptions {
initialData: AgentBridgePageData;
pollingInterval?: number;
}
interface UseAgentBridgeStateReturn {
data: AgentBridgePageData;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
/**
* Hook for fetching and revalidating AgentBridge page data.
* Uses fetch + polling (no SWR dependency) — project pattern from cloud-agents.
*/
export function useAgentBridgeState({
initialData,
pollingInterval = 5000,
}: UseAgentBridgeStateOptions): UseAgentBridgeStateReturn {
const [data, setData] = useState<AgentBridgePageData>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const refresh = useCallback(async () => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
try {
const res = await fetch("/api/tools/agent-bridge/state", {
signal: ctrl.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = (await res.json()) as AgentBridgePageData;
if (!ctrl.signal.aborted) setData(json);
} catch (err) {
if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : "Unknown error");
}
} finally {
if (!ctrl.signal.aborted) setLoading(false);
}
}, []);
// Auto-poll
useEffect(() => {
if (!pollingInterval || pollingInterval <= 0) return;
const id = setInterval(() => {
refresh().catch(() => {/* swallow background errors */});
}, pollingInterval);
return () => clearInterval(id);
}, [pollingInterval, refresh]);
// Cleanup on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
return { data, loading, error, refresh };
}

View File

@@ -0,0 +1,61 @@
import { getProviderConnections } from "@/lib/db/providers";
import { ALL_TARGETS } from "@/mitm/targets/index";
import AgentBridgePageClient from "./AgentBridgePageClient";
import type { AgentBridgePageData } from "./AgentBridgePageClient";
/**
* AgentBridge page — Server Component entry point.
* Fetches initial state from the backend API and passes to client orchestrator.
*/
export default async function AgentBridgePage() {
// Check if any providers are configured (D15)
let hasProviders = false;
try {
const connections = await getProviderConnections();
hasProviders = Array.isArray(connections) && connections.length > 0;
} catch {
// If DB not ready yet, show empty state gracefully
hasProviders = false;
}
// Fetch initial AgentBridge state from the REST API
// Falls back to a safe default if the API isn't ready yet
let initialData: AgentBridgePageData = {
serverState: {
running: false,
port: 443,
certTrusted: false,
upstreamCa: null,
lastStartedAt: null,
activeConns: 0,
interceptedCount: 0,
},
agentStates: [],
bypassPatterns: [],
mappings: {},
};
try {
const base =
process.env.OMNIROUTE_BASE_URL ??
`http://127.0.0.1:${process.env.PORT ?? 20128}`;
const res = await fetch(`${base}/api/tools/agent-bridge/state`, {
cache: "no-store",
headers: { "x-internal-fetch": "1" },
});
if (res.ok) {
const json = (await res.json()) as AgentBridgePageData;
initialData = json;
}
} catch {
// Backend not yet available — use defaults; client will poll
}
return (
<AgentBridgePageClient
initialData={initialData}
targets={ALL_TARGETS}
hasProviders={hasProviders}
/>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { useTrafficStream } from "./hooks/useTrafficStream";
import { useTrafficFilters } from "./hooks/useTrafficFilters";
import { useResizablePanels } from "./hooks/useResizablePanels";
import { useSessionRecorder } from "./hooks/useSessionRecorder";
import { useSystemProxyExitGuard } from "./hooks/useSystemProxyExitGuard";
import { CaptureModesToolbar } from "./components/CaptureModesToolbar";
import { TopBarControls } from "./components/TopBarControls";
import { RequestStreamingList } from "./components/RequestStreamingList";
import { DetailsPanel } from "./components/DetailsPanel";
import { HistoricSessionBanner } from "./components/session/HistoricSessionBanner";
const BUFFER_MAX = 1000;
export function TrafficInspectorPageClient() {
const [containerHeight, setContainerHeight] = useState(600);
const listContainerRef = useRef<HTMLDivElement | null>(null);
const [selectedRequest, setSelectedRequest] = useState<InterceptedRequest | null>(null);
const { filters, setProfile, setHost, setAgent, setStatus, setSessionId, setSameContext } =
useTrafficFilters();
const [{ listWidth, collapsed }, { startDrag, toggleCollapse }] = useResizablePanels();
const [streamState, streamActions] = useTrafficStream(filters);
const recorder = useSessionRecorder();
const [captureModes, setCaptureModes] = useState<{ systemProxy?: { applied: boolean } } | null>(
null
);
useEffect(() => {
let cancelled = false;
fetch("/api/tools/traffic-inspector/capture-modes")
.then((r) => (r.ok ? r.json() : null))
.then((data: { systemProxy?: { applied: boolean } } | null) => {
if (!cancelled) setCaptureModes(data);
})
.catch(() => {
/* best-effort */
});
return () => {
cancelled = true;
};
}, []);
useSystemProxyExitGuard({ applied: captureModes?.systemProxy?.applied ?? false });
const listContainerCallback = useCallback((el: HTMLDivElement | null) => {
listContainerRef.current = el;
if (el) setContainerHeight(el.clientHeight);
}, []);
const exportHar = useCallback(async () => {
try {
const res = await fetch("/api/tools/traffic-inspector/export.har");
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `traffic-${Date.now()}.har`;
a.click();
URL.revokeObjectURL(url);
} catch {
// ignore
}
}, []);
const handleSessionSelect = useCallback(
(id: string | undefined) => {
setSessionId(id);
},
[setSessionId]
);
const handleRecordStart = useCallback(() => {
void recorder.start();
}, [recorder]);
const handleRecordStop = useCallback(() => {
void recorder.stop();
}, [recorder]);
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Capture modes toolbar */}
<div className="shrink-0 px-4 pt-4 pb-2">
<CaptureModesToolbar customHostCount={0} />
</div>
{/* Historic session banner */}
{filters.sessionId !== undefined && (
<div className="shrink-0 px-4 pb-2">
<HistoricSessionBanner
sessionName={
recorder.sessions.find((s) => s.id === filters.sessionId)?.name ?? null
}
onBackToLive={() => setSessionId(undefined)}
/>
</div>
)}
{/* Top bar filter/controls */}
<div className="shrink-0">
<TopBarControls
filters={filters}
onProfileChange={setProfile}
onHostChange={setHost}
onAgentChange={setAgent}
onStatusChange={setStatus}
paused={streamState.paused}
onPause={streamActions.pause}
onResume={streamActions.resume}
onClear={streamActions.clear}
onExport={exportHar}
connected={streamState.connected}
total={streamState.total}
maxSize={BUFFER_MAX}
pendingCount={streamState.pendingCount}
recording={recorder.recording}
session={recorder.session}
elapsed={recorder.elapsed}
sessions={recorder.sessions}
onRecordStart={handleRecordStart}
onRecordStop={handleRecordStop}
onSessionSelect={handleSessionSelect}
onSessionDelete={recorder.deleteSession}
/>
</div>
{/* Split pane */}
<div className="flex flex-1 overflow-hidden">
{/* List pane */}
<div
ref={listContainerCallback}
className="shrink-0 overflow-hidden border-r border-border flex flex-col"
style={{ width: listWidth }}
>
<div className="flex items-center justify-between px-2 py-1 border-b border-border bg-bg-subtle shrink-0">
<span className="text-xs text-text-muted font-medium">
{streamState.total} requests
</span>
<button
type="button"
onClick={toggleCollapse}
className="text-text-muted hover:text-text-main focus-ring rounded"
aria-label={collapsed ? "Expand list" : "Collapse list"}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{collapsed ? "chevron_right" : "chevron_left"}
</span>
</button>
</div>
{!collapsed && (
<div className="flex-1 overflow-hidden">
<RequestStreamingList
requests={streamState.requests}
selectedId={selectedRequest?.id ?? null}
onSelect={setSelectedRequest}
containerHeight={containerHeight}
onSameContext={setSameContext}
sameContextKey={filters.sameContextKey}
onClearContextFilter={() => setSameContext(undefined)}
/>
</div>
)}
{collapsed && (
<div className="flex-1 flex items-start justify-center pt-4">
<span className="text-xs text-text-muted font-mono" style={{ writingMode: "vertical-rl" }}>
{streamState.total} reqs
</span>
</div>
)}
</div>
{/* Drag handle */}
<div
onMouseDown={startDrag}
className="w-1 bg-border hover:bg-blue-500 cursor-col-resize shrink-0 transition-colors"
aria-hidden="true"
/>
{/* Detail pane */}
<div className="flex-1 overflow-hidden">
<DetailsPanel request={selectedRequest} allRequests={streamState.requests} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/shared/utils/cn";
import { CustomHostsManager } from "./CustomHostsManager";
import { HttpProxySnippetCard } from "./HttpProxySnippetCard";
interface CaptureModeState {
agentBridge: boolean; // always on, cannot disable
customHosts: boolean;
httpProxy: boolean;
systemWide: boolean;
}
interface CaptureModesToolbarProps {
customHostCount: number;
}
export function CaptureModesToolbar({ customHostCount }: CaptureModesToolbarProps) {
const t = useTranslations("trafficInspector");
const [modes, setModes] = useState<CaptureModeState>({
agentBridge: true,
customHosts: false,
httpProxy: false,
systemWide: false,
});
const [showHosts, setShowHosts] = useState(false);
const [showProxy, setShowProxy] = useState(false);
const [proxyPort] = useState(8080);
const toggleMode = (key: keyof CaptureModeState) => {
if (key === "agentBridge") return; // always on
setModes((prev) => ({ ...prev, [key]: !prev[key] }));
};
const buttons: Array<{
key: keyof CaptureModeState;
label: string;
alwaysOn?: boolean;
warn?: boolean;
extra?: React.ReactNode;
}> = [
{ key: "agentBridge", label: t("agentBridgeMode"), alwaysOn: true },
{
key: "customHosts",
label: `${t("customHostsMode")} (${customHostCount})`,
},
{
key: "httpProxy",
label: `${t("httpProxyMode")} :${proxyPort}`,
},
{
key: "systemWide",
label: t("systemWideMode"),
warn: true,
},
];
return (
<>
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-bg-subtle px-3 py-2">
{buttons.map(({ key, label, alwaysOn, warn }) => {
const active = modes[key];
return (
<button
key={key}
type="button"
onClick={() => toggleMode(key)}
disabled={alwaysOn}
className={cn(
"inline-flex items-center gap-1.5 rounded border px-2.5 py-1 text-xs font-medium transition-colors",
"focus-ring disabled:cursor-default",
active
? warn
? "border-amber-500/50 bg-amber-900/30 text-amber-300"
: "border-green-500/50 bg-green-900/30 text-green-300"
: "border-border text-text-muted hover:text-text-main hover:bg-surface"
)}
>
<span
className={cn(
"inline-block h-1.5 w-1.5 rounded-full",
active ? (warn ? "bg-amber-400" : "bg-green-400") : "bg-gray-600"
)}
/>
{label}
{warn && <span className="text-amber-400"></span>}
</button>
);
})}
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={() => setShowHosts(true)}
className="text-xs text-text-muted hover:text-text-main focus-ring rounded"
>
{t("manageHosts")}
</button>
<button
type="button"
onClick={() => setShowProxy(true)}
className="text-xs text-text-muted hover:text-text-main focus-ring rounded"
>
{t("copySnippet")}
</button>
</div>
</div>
{showHosts && <CustomHostsManager onClose={() => setShowHosts(false)} />}
{showProxy && <HttpProxySnippetCard port={proxyPort} onClose={() => setShowProxy(false)} />}
</>
);
}

View File

@@ -0,0 +1,142 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { z } from "zod";
interface CustomHost {
host: string;
enabled: boolean;
label?: string | null;
kind: "llm" | "app" | "custom";
}
interface CustomHostsManagerProps {
onClose: () => void;
}
export function CustomHostsManager({ onClose }: CustomHostsManagerProps) {
const t = useTranslations("trafficInspector");
const [hosts, setHosts] = useState<CustomHost[]>([]);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const HostInputSchema = z.string().min(1).max(253).regex(/^[a-z0-9.-]+$/i, "Invalid hostname");
const fetchHosts = async () => {
setLoading(true);
try {
const res = await fetch("/api/tools/traffic-inspector/custom-hosts");
if (res.ok) {
const data = (await res.json()) as { hosts: CustomHost[] };
setHosts(data.hosts ?? []);
}
} finally {
setLoading(false);
}
};
useEffect(() => {
void fetchHosts();
}, []);
const addHost = async () => {
setError(null);
const parsed = HostInputSchema.safeParse(input.trim());
if (!parsed.success) {
setError(parsed.error.errors[0]?.message ?? "Invalid host");
return;
}
const host = parsed.data;
try {
const res = await fetch("/api/tools/traffic-inspector/custom-hosts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ host, enabled: true }),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
setError(body?.error?.message ?? "Failed to add host");
return;
}
setInput("");
await fetchHosts();
} catch {
setError("Network error");
}
};
const deleteHost = async (host: string) => {
try {
await fetch(`/api/tools/traffic-inspector/custom-hosts/${encodeURIComponent(host)}`, {
method: "DELETE",
});
await fetchHosts();
} catch {
// ignore
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-xl border border-border bg-surface shadow-xl p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-text-main">{t("customHostsTitle")}</h2>
<button
type="button"
onClick={onClose}
className="text-text-muted hover:text-text-main focus-ring rounded"
aria-label="Close"
>
<span className="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</div>
<div className="flex gap-2 mb-4">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addHost()}
placeholder={t("hostPlaceholder")}
className="flex-1 rounded border border-border bg-bg-subtle px-3 py-1.5 text-sm text-text-main focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
type="button"
onClick={addHost}
className="rounded border border-border bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 focus-ring"
>
{t("addHost")}
</button>
</div>
{error && <p className="text-xs text-red-400 mb-2">{error}</p>}
<div className="space-y-1 max-h-60 overflow-y-auto">
{loading && <p className="text-sm text-text-muted">{t("loading")}</p>}
{!loading && hosts.length === 0 && (
<p className="text-sm text-text-muted italic">{t("noHostsYet")}</p>
)}
{hosts.map((h) => (
<div
key={h.host}
className="flex items-center justify-between rounded border border-border/50 bg-bg-subtle px-3 py-1.5"
>
<span className="text-sm font-mono text-text-main">{h.host}</span>
<button
type="button"
onClick={() => deleteHost(h.host)}
className="text-text-muted hover:text-red-400 focus-ring rounded"
aria-label={`Remove ${h.host}`}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
delete
</span>
</button>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { cn } from "@/shared/utils/cn";
import { HeadersTab } from "./tabs/HeadersTab";
import { RequestBodyTab } from "./tabs/RequestBodyTab";
import { ResponseBodyTab } from "./tabs/ResponseBodyTab";
import { TimingTab } from "./tabs/TimingTab";
import { LlmDetailsTab } from "./tabs/LlmDetailsTab";
import { ConversationTab } from "./tabs/ConversationTab";
import { StatsTab } from "./tabs/StatsTab";
import { AnnotationField } from "./shared/AnnotationField";
type TabId = "conversation" | "headers" | "request" | "response" | "timing" | "llm" | "stats";
interface Tab {
id: TabId;
label: string;
icon: string;
llmOnly?: boolean;
}
const TABS: Tab[] = [
{ id: "conversation", label: "Conversation", icon: "chat_bubble" },
{ id: "headers", label: "Headers", icon: "list" },
{ id: "request", label: "Request", icon: "upload" },
{ id: "response", label: "Response", icon: "download" },
{ id: "timing", label: "Timing", icon: "timer" },
{ id: "llm", label: "LLM", icon: "psychology", llmOnly: true },
{ id: "stats", label: "Stats", icon: "bar_chart" },
];
interface DetailsPanelProps {
request: InterceptedRequest | null;
allRequests: InterceptedRequest[];
}
export function DetailsPanel({ request, allRequests }: DetailsPanelProps) {
const [activeTab, setActiveTab] = useState<TabId>("conversation");
if (!request) {
return (
<div className="h-full flex items-center justify-center text-text-muted">
<div className="text-center space-y-2">
<span
className="material-symbols-outlined text-[36px] block"
aria-hidden="true"
>
info
</span>
<p className="text-sm">Select a request to inspect it.</p>
</div>
</div>
);
}
const isLlm = request.detectedKind === "llm";
const visibleTabs = TABS.filter((t) => !t.llmOnly || isLlm);
// Ensure active tab is valid
const currentTab = visibleTabs.find((t) => t.id === activeTab) ? activeTab : "conversation";
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Tab bar */}
<div
role="tablist"
aria-label="Request details"
className="flex flex-wrap items-center gap-0.5 border-b border-border px-2 pt-1 bg-bg-subtle shrink-0"
>
{visibleTabs.map((tab) => {
const selected = currentTab === tab.id;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selected}
onClick={() => setActiveTab(tab.id)}
className={cn(
"inline-flex items-center gap-1 h-8 px-2 text-xs rounded-t border-b-2 transition-colors focus-ring",
selected
? "border-blue-500 text-blue-400 bg-surface"
: "border-transparent text-text-muted hover:text-text-main hover:bg-surface/50"
)}
>
<span className="material-symbols-outlined text-[13px]" aria-hidden="true">
{tab.icon}
</span>
{tab.label}
</button>
);
})}
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{currentTab === "conversation" && <ConversationTab request={request} />}
{currentTab === "headers" && <HeadersTab request={request} />}
{currentTab === "request" && <RequestBodyTab request={request} />}
{currentTab === "response" && <ResponseBodyTab request={request} />}
{currentTab === "timing" && <TimingTab request={request} />}
{currentTab === "llm" && isLlm && <LlmDetailsTab request={request} />}
{currentTab === "stats" && <StatsTab requests={allRequests} />}
</div>
{/* Annotation footer */}
<div className="shrink-0 border-t border-border px-3 py-2 bg-bg-subtle">
<AnnotationField requestId={request.id} initialValue={request.annotation ?? ""} />
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/shared/utils/cn";
interface HttpProxySnippetCardProps {
port: number;
onClose: () => void;
}
type Lang = "bash" | "python" | "node";
export function HttpProxySnippetCard({ port, onClose }: HttpProxySnippetCardProps) {
const t = useTranslations("trafficInspector");
const [lang, setLang] = useState<Lang>("bash");
const [copied, setCopied] = useState(false);
const snippets: Record<Lang, string> = {
bash: `export HTTP_PROXY=http://127.0.0.1:${port}\nexport HTTPS_PROXY=http://127.0.0.1:${port}\nexport NODE_TLS_REJECT_UNAUTHORIZED=0\n# then run your command:\ncurl https://api.openai.com/v1/models`,
python: `import os\nos.environ["HTTP_PROXY"] = "http://127.0.0.1:${port}"\nos.environ["HTTPS_PROXY"] = "http://127.0.0.1:${port}"\nos.environ["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"\n# then use requests or httpx as usual`,
node: `process.env.HTTP_PROXY = "http://127.0.0.1:${port}";\nprocess.env.HTTPS_PROXY = "http://127.0.0.1:${port}";\nprocess.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";\n// then use fetch / axios / undici as usual`,
};
const copy = async () => {
await navigator.clipboard.writeText(snippets[lang]);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-lg rounded-xl border border-border bg-surface shadow-xl p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-text-main">
{t("httpProxyTitle", { port })}
</h2>
<button
type="button"
onClick={onClose}
className="text-text-muted hover:text-text-main focus-ring rounded"
aria-label="Close"
>
<span className="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</div>
<div className="flex gap-1 mb-3">
{(["bash", "python", "node"] as Lang[]).map((l) => (
<button
key={l}
type="button"
onClick={() => setLang(l)}
className={cn(
"px-3 py-1 text-xs rounded border focus-ring",
lang === l
? "border-blue-500 bg-blue-900/30 text-blue-300"
: "border-border text-text-muted hover:text-text-main"
)}
>
{l}
</button>
))}
</div>
<pre className="rounded bg-bg-subtle border border-border p-3 text-xs font-mono text-text-main overflow-x-auto whitespace-pre">
{snippets[lang]}
</pre>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={copy}
className="inline-flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-xs text-text-main hover:bg-bg-subtle focus-ring"
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
{copied ? "check" : "content_copy"}
</span>
{copied ? t("copied") : t("copy")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { cn } from "@/shared/utils/cn";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { ContextColorBar } from "./shared/ContextColorBar";
import { AgentEmoji } from "./shared/AgentEmoji";
interface RequestRowProps {
request: InterceptedRequest;
selected: boolean;
onClick: () => void;
onSameContext?: (contextKey: string) => void;
style?: React.CSSProperties;
}
function statusColor(status: InterceptedRequest["status"]): string {
if (status === "in-flight") return "text-gray-400";
if (status === "error") return "text-red-400";
if (typeof status === "number") {
if (status < 300) return "text-green-400";
if (status < 400) return "text-yellow-400";
if (status < 500) return "text-orange-400";
return "text-red-400";
}
return "text-text-muted";
}
function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${bytes}B`;
}
function formatTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleTimeString("en", { hour12: false });
} catch {
return "";
}
}
export function RequestRow({ request, selected, onClick, onSameContext, style }: RequestRowProps) {
const pathShort = request.path.length > 32 ? `${request.path.slice(-30)}` : request.path;
const sc = statusColor(request.status);
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onClick()}
style={style}
className={cn(
"flex items-stretch gap-1 border-b border-border/40 cursor-pointer hover:bg-bg-subtle",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500",
selected && "bg-surface"
)}
>
<ContextColorBar contextKey={request.contextKey} />
<div className="flex-1 min-w-0 px-2 py-1.5">
<div className="flex items-center gap-2 text-xs">
<span className="text-text-muted shrink-0 font-mono">{formatTime(request.timestamp)}</span>
<span className="font-mono font-medium text-text-main shrink-0">{request.method}</span>
<span className={cn("font-mono font-bold shrink-0", sc)}>
{String(request.status)}
</span>
<span className="text-text-muted shrink-0">{formatSize(request.responseSize)}</span>
<span className="shrink-0">
<AgentEmoji agentId={request.agent} />
</span>
</div>
<div className="text-xs text-text-muted truncate font-mono mt-0.5">
{request.host}
<span className="text-text-main">{pathShort}</span>
</div>
{request.contextKey && (
<button
type="button"
className="text-[10px] text-text-muted font-mono opacity-60 hover:opacity-100 hover:text-blue-400 focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 rounded"
title="Filter by this context"
onClick={(e) => {
e.stopPropagation();
onSameContext?.(request.contextKey as string);
}}
>
ctx #{request.contextKey.slice(0, 6)}
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useRef } from "react";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { useVirtualList } from "../hooks/useVirtualList";
import { RequestRow } from "./RequestRow";
interface RequestStreamingListProps {
requests: InterceptedRequest[];
selectedId: string | null;
onSelect: (req: InterceptedRequest) => void;
containerHeight: number;
onSameContext?: (contextKey: string) => void;
sameContextKey?: string;
onClearContextFilter?: () => void;
}
export function RequestStreamingList({
requests,
selectedId,
onSelect,
containerHeight,
onSameContext,
sameContextKey,
onClearContextFilter,
}: RequestStreamingListProps) {
const { virtualItems, totalHeight, containerRef, rowRef } = useVirtualList(
requests,
containerHeight
);
if (requests.length === 0) {
return (
<div className="h-full flex flex-col">
{sameContextKey && (
<div className="shrink-0 flex items-center gap-2 px-2 py-1 bg-blue-900/30 border-b border-blue-500/40 text-xs text-blue-300 font-mono">
<span>Filtering: context {sameContextKey.slice(0, 6)}</span>
<button
type="button"
onClick={onClearContextFilter}
className="ml-1 underline hover:text-blue-100 focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-400"
>
[clear]
</button>
</div>
)}
<div
ref={containerRef}
className="flex-1 flex items-center justify-center text-sm text-text-muted"
>
<div className="text-center space-y-2">
<span
className="material-symbols-outlined text-[36px] text-text-muted block"
aria-hidden="true"
>
network_check
</span>
<p>No requests captured yet.</p>
<p className="text-xs">Make sure AgentBridge is running or enable another capture mode.</p>
</div>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
{sameContextKey && (
<div className="shrink-0 flex items-center gap-2 px-2 py-1 bg-blue-900/30 border-b border-blue-500/40 text-xs text-blue-300 font-mono">
<span>Filtering: context {sameContextKey.slice(0, 6)}</span>
<button
type="button"
onClick={onClearContextFilter}
className="ml-1 underline hover:text-blue-100 focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-400"
>
[clear]
</button>
</div>
)}
<div
ref={containerRef as React.RefObject<HTMLDivElement>}
className="flex-1 overflow-y-auto relative"
style={{ contain: "strict" }}
>
<div style={{ height: totalHeight, position: "relative" }}>
{virtualItems.map(({ index, item, top }) => (
<div
key={item.id}
ref={rowRef(index)}
style={{ position: "absolute", top, left: 0, right: 0 }}
>
<RequestRow
request={item}
selected={item.id === selectedId}
onClick={() => onSelect(item)}
onSameContext={onSameContext}
/>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
import { useTranslations } from "next-intl";
import type { ListFilters } from "@/mitm/inspector/types";
import type { AgentId } from "@/mitm/types";
import { cn } from "@/shared/utils/cn";
import { SessionRecorderBar } from "./session/SessionRecorderBar";
import { SessionPicker } from "./session/SessionPicker";
import type { SessionInfo } from "../hooks/useSessionRecorder";
type Profile = "llm" | "custom" | "all";
// PROFILES labels are resolved inside the component via useTranslations
const PROFILE_IDS: Profile[] = ["llm", "custom", "all"];
interface TopBarControlsProps {
filters: ListFilters;
onProfileChange: (p: Profile) => void;
onHostChange: (h: string | undefined) => void;
onAgentChange: (a: AgentId | undefined) => void;
onStatusChange: (s: ListFilters["status"]) => void;
paused: boolean;
onPause: () => void;
onResume: () => void;
onClear: () => void;
onExport: () => void;
connected: boolean;
total: number;
maxSize?: number;
pendingCount?: number;
// session recorder
recording: boolean;
session: SessionInfo | null;
elapsed: number;
sessions: SessionInfo[];
onRecordStart: () => void;
onRecordStop: () => void;
onSessionSelect: (id: string | undefined) => void;
onSessionDelete: (id: string) => void;
}
export function TopBarControls({
filters,
onProfileChange,
onHostChange,
onAgentChange,
onStatusChange,
paused,
onPause,
onResume,
onClear,
onExport,
connected,
total,
maxSize = 1000,
pendingCount = 0,
recording,
session,
elapsed,
sessions,
onRecordStart,
onRecordStop,
onSessionSelect,
onSessionDelete,
}: TopBarControlsProps) {
const t = useTranslations("trafficInspector");
const profile: Profile = (filters.profile as Profile) ?? "llm";
const profileLabels: Record<Profile, string> = {
llm: t("profileLlmOnly"),
custom: t("profileCustom"),
all: t("profileAll"),
};
return (
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-bg-subtle px-3 py-2">
{/* Profile selector */}
<div
role="radiogroup"
aria-label="Traffic profile"
className="flex items-center gap-1 rounded border border-border bg-surface p-0.5"
>
{PROFILE_IDS.map((id) => (
<button
key={id}
type="button"
role="radio"
aria-checked={profile === id}
onClick={() => onProfileChange(id)}
className={cn(
"px-2 py-0.5 text-xs rounded focus-ring",
profile === id
? "bg-blue-600 text-white"
: "text-text-muted hover:text-text-main"
)}
>
{profileLabels[id]}
</button>
))}
</div>
{/* Host filter */}
<input
type="text"
placeholder={t("filterHost")}
defaultValue={filters.host ?? ""}
onChange={(e) => onHostChange(e.target.value || undefined)}
className="rounded border border-border bg-bg-subtle px-2 py-1 text-xs text-text-main w-32 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{/* Status filter */}
<select
value={filters.status ?? ""}
onChange={(e) =>
onStatusChange((e.target.value as ListFilters["status"]) || undefined)
}
className="rounded border border-border bg-bg-subtle px-2 py-1 text-xs text-text-main focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">{t("anyStatus")}</option>
<option value="2xx">2xx</option>
<option value="3xx">3xx</option>
<option value="4xx">4xx</option>
<option value="5xx">5xx</option>
<option value="error">error</option>
</select>
{/* Action buttons */}
<button
type="button"
onClick={paused ? onResume : onPause}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:text-text-main focus-ring"
title={paused ? t("resumeBtn") : t("pauseBtn")}
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
{paused ? "play_arrow" : "pause"}
</span>
{paused ? t("resumeBtn") : t("pauseBtn")}
</button>
<button
type="button"
onClick={onClear}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:text-red-400 focus-ring"
title={t("clearBtn")}
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
delete_sweep
</span>
{t("clearBtn")}
</button>
<button
type="button"
onClick={onExport}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:text-text-main focus-ring"
title={t("exportHar")}
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
download
</span>
{t("exportHar")}
</button>
{/* Session controls */}
<div className="flex items-center gap-2 ml-auto">
<SessionPicker
sessions={sessions}
selectedId={filters.sessionId}
onSelect={onSessionSelect}
onDelete={onSessionDelete}
/>
<SessionRecorderBar
recording={recording}
session={session}
elapsed={elapsed}
onStart={onRecordStart}
onStop={onRecordStop}
/>
{/* Live indicator */}
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
connected ? "bg-green-400 animate-pulse" : "bg-gray-500"
)}
/>
{connected ? t("liveBadge") : t("offlineBadge")}
<span className="text-text-muted font-mono">
{total}/{maxSize}
</span>
{paused && pendingCount > 0 && (
<span className="inline-flex items-center rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-400 border border-yellow-500/40">
{t("pausedNewBadge", { count: pendingCount })}
</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import type { NormalizedTurn } from "@/mitm/inspector/types";
import { cn } from "@/shared/utils/cn";
import { MessageContent } from "./MessageContent";
interface ChatBubbleProps {
turn: NormalizedTurn;
}
const ROLE_STYLES: Record<NormalizedTurn["role"], string> = {
system: "border border-red-500/40 bg-red-900/20 text-red-200",
user: "ml-auto bg-blue-600/30 border border-blue-500/30 text-blue-100",
assistant: "bg-purple-900/30 border border-purple-500/30 text-purple-100",
tool: "bg-gray-800 border border-gray-600/30 text-gray-200",
};
const ROLE_LABEL: Record<NormalizedTurn["role"], string> = {
system: "System",
user: "User",
assistant: "Assistant",
tool: "Tool",
};
export function ChatBubble({ turn }: ChatBubbleProps) {
const [collapsed, setCollapsed] = useState(turn.role === "system");
const isSystem = turn.role === "system";
const isUser = turn.role === "user";
return (
<div className={cn("max-w-[85%] rounded-lg px-3 py-2", isUser ? "ml-auto" : "mr-auto", ROLE_STYLES[turn.role])}>
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-xs font-medium opacity-70">{ROLE_LABEL[turn.role]}</span>
{isSystem && (
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
className="text-xs opacity-70 hover:opacity-100 focus-ring rounded"
>
{collapsed ? "Expand" : "Collapse"}
</button>
)}
</div>
{!collapsed && <MessageContent blocks={turn.blocks} />}
{collapsed && isSystem && (
<p className="text-xs opacity-60 italic">System prompt hidden click to expand</p>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import type { NormalizedBlock } from "@/mitm/inspector/types";
import { ToolCallBlock } from "./ToolCallBlock";
import { ToolResultBlock } from "./ToolResultBlock";
interface MessageContentProps {
blocks: NormalizedBlock[];
}
export function MessageContent({ blocks }: MessageContentProps) {
return (
<div className="space-y-2">
{blocks.map((block, i) => {
if (block.type === "text") {
return (
<p key={i} className="text-sm text-text-main whitespace-pre-wrap break-words">
{block.text}
</p>
);
}
if (block.type === "tool_use") {
return (
<ToolCallBlock key={i} id={block.id} name={block.name} input={block.input} />
);
}
if (block.type === "tool_result") {
return (
<ToolResultBlock
key={i}
toolUseId={block.tool_use_id}
content={block.content}
/>
);
}
return null;
})}
</div>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
import { JsonViewer } from "../shared/JsonViewer";
interface ToolCallBlockProps {
id: string;
name: string;
input: unknown;
}
export function ToolCallBlock({ id, name, input }: ToolCallBlockProps) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded border border-amber-500/40 bg-amber-900/20 px-3 py-2 text-sm">
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="flex w-full items-center gap-2 text-left focus-ring rounded"
>
<span className="material-symbols-outlined text-[14px] text-amber-400" aria-hidden="true">
{expanded ? "expand_less" : "expand_more"}
</span>
<span className="text-amber-300 font-mono font-medium">{name}</span>
<span className="text-text-muted text-xs font-mono ml-auto">{id.slice(0, 8)}</span>
</button>
{expanded && (
<div className="mt-2 border-t border-amber-500/20 pt-2">
<JsonViewer data={input} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useState } from "react";
import { JsonViewer } from "../shared/JsonViewer";
interface ToolResultBlockProps {
toolUseId: string;
content: unknown;
}
export function ToolResultBlock({ toolUseId, content }: ToolResultBlockProps) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded border border-green-500/40 bg-green-900/20 px-3 py-2 text-sm">
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="flex w-full items-center gap-2 text-left focus-ring rounded"
>
<span className="material-symbols-outlined text-[14px] text-green-400" aria-hidden="true">
{expanded ? "expand_less" : "expand_more"}
</span>
<span className="text-green-300 font-mono font-medium text-xs">tool_result</span>
<span className="text-text-muted text-xs font-mono ml-auto">{toolUseId.slice(0, 8)}</span>
</button>
{expanded && (
<div className="mt-2 border-t border-green-500/20 pt-2">
<JsonViewer data={content} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import { useTranslations } from "next-intl";
interface HistoricSessionBannerProps {
sessionName: string | null;
onBackToLive: () => void;
}
export function HistoricSessionBanner({ sessionName, onBackToLive }: HistoricSessionBannerProps) {
const t = useTranslations("trafficInspector");
return (
<div className="flex items-center justify-between gap-3 rounded border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-200">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
history
</span>
<span>
{t("viewingRecordedSession")} {" "}
<strong>{sessionName ?? t("untitledSession")}</strong>
</span>
</div>
<button
type="button"
onClick={onBackToLive}
className="rounded border border-amber-500/40 px-2 py-0.5 text-xs hover:bg-amber-500/20 focus-ring"
>
{t("backToLive")}
</button>
</div>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import type { SessionInfo } from "../../hooks/useSessionRecorder";
interface SessionPickerProps {
sessions: SessionInfo[];
selectedId?: string;
onSelect: (id: string | undefined) => void;
onDelete: (id: string) => void;
}
export function SessionPicker({ sessions, selectedId, onSelect, onDelete }: SessionPickerProps) {
const [open, setOpen] = useState(false);
const selected = sessions.find((s) => s.id === selectedId);
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 rounded border border-border bg-bg-subtle px-2 py-1 text-xs text-text-main hover:bg-surface focus-ring"
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
folder_open
</span>
{selected ? selected.name ?? `Session ${selected.id.slice(0, 6)}` : "Sessions"}
<span className="material-symbols-outlined text-[12px] ml-1" aria-hidden="true">
{open ? "expand_less" : "expand_more"}
</span>
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-1 min-w-[200px] rounded-lg border border-border bg-surface shadow-lg py-1">
<button
type="button"
onClick={() => { onSelect(undefined); setOpen(false); }}
className="w-full text-left px-3 py-1.5 text-xs text-text-muted hover:bg-bg-subtle focus-ring"
>
All traffic (no session)
</button>
{sessions.length === 0 && (
<p className="px-3 py-2 text-xs text-text-muted italic">No sessions yet</p>
)}
{sessions.map((s) => (
<div key={s.id} className="flex items-center group">
<button
type="button"
onClick={() => { onSelect(s.id); setOpen(false); }}
className={`flex-1 text-left px-3 py-1.5 text-xs hover:bg-bg-subtle focus-ring ${
selectedId === s.id ? "text-blue-400 font-medium" : "text-text-main"
}`}
>
{s.name ?? `Session ${s.id.slice(0, 6)}`}
<span className="text-text-muted ml-1">({s.requestCount} reqs)</span>
</button>
<button
type="button"
onClick={() => { onDelete(s.id); if (selectedId === s.id) onSelect(undefined); }}
className="px-2 text-text-muted hover:text-red-400 opacity-0 group-hover:opacity-100 focus-ring rounded"
aria-label="Delete session"
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
delete
</span>
</button>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useTranslations } from "next-intl";
import { cn } from "@/shared/utils/cn";
import type { SessionInfo } from "../../hooks/useSessionRecorder";
interface SessionRecorderBarProps {
recording: boolean;
session: SessionInfo | null;
elapsed: number;
onStart: (name?: string) => void;
onStop: () => void;
}
function formatElapsed(s: number): string {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
}
export function SessionRecorderBar({
recording,
session,
elapsed,
onStart,
onStop,
}: SessionRecorderBarProps) {
const t = useTranslations("trafficInspector");
return (
<div
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm border",
recording
? "border-red-500/40 bg-red-900/20 text-red-200"
: "border-border bg-bg-subtle text-text-muted"
)}
>
{recording ? (
<>
<span className="inline-block h-2 w-2 rounded-full bg-red-500 animate-pulse" />
<span className="font-mono text-xs">{formatElapsed(elapsed)}</span>
{session?.name && (
<span className="text-xs opacity-70 truncate max-w-[120px]">{session.name}</span>
)}
<button
type="button"
onClick={onStop}
aria-label={t("stopSession")}
className="ml-auto rounded border border-red-500/50 px-2 py-0.5 text-xs hover:bg-red-800/30 focus-ring"
>
{t("stopSession")}
</button>
</>
) : (
<>
<span className="inline-block h-2 w-2 rounded-full bg-gray-500" />
<span className="text-xs">{t("notRecording")}</span>
<button
type="button"
onClick={() => onStart()}
aria-label={t("recordSession")}
className="ml-auto rounded border border-border px-2 py-0.5 text-xs hover:bg-surface focus-ring"
>
{t("recordSession")}
</button>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import type { AgentId } from "@/mitm/types";
const AGENT_COLORS: Record<AgentId, { emoji: string; label: string; color: string }> = {
antigravity: { emoji: "🔵", label: "AG", color: "text-blue-400" },
kiro: { emoji: "🟠", label: "KR", color: "text-orange-400" },
copilot: { emoji: "🟢", label: "CP", color: "text-green-400" },
codex: { emoji: "🟣", label: "CD", color: "text-purple-400" },
cursor: { emoji: "🔶", label: "CU", color: "text-yellow-400" },
zed: { emoji: "🔷", label: "ZD", color: "text-sky-400" },
"claude-code": { emoji: "🟡", label: "CC", color: "text-yellow-300" },
"open-code": { emoji: "⚪", label: "OC", color: "text-gray-400" },
trae: { emoji: "⬛", label: "TR", color: "text-gray-500" },
};
interface AgentEmojiProps {
agentId?: AgentId | string;
className?: string;
}
export function AgentEmoji({ agentId, className }: AgentEmojiProps) {
if (!agentId) return <span className={`text-sm ${className ?? ""}`}>🌐</span>;
const info = AGENT_COLORS[agentId as AgentId];
if (!info) return <span className={`text-sm ${className ?? ""}`}>🌐</span>;
return (
<span
className={`inline-flex items-center gap-0.5 text-xs font-mono ${info.color} ${className ?? ""}`}
title={agentId}
>
{info.emoji} {info.label}
</span>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useCallback, useState } from "react";
import { useAnnotations } from "../../hooks/useAnnotations";
interface AnnotationFieldProps {
requestId: string | null;
initialValue?: string;
}
export function AnnotationField({ requestId, initialValue = "" }: AnnotationFieldProps) {
const [value, setValue] = useState(initialValue);
const { save, saving } = useAnnotations(requestId);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value);
save(e.target.value);
},
[save]
);
return (
<div className="relative">
<textarea
value={value}
onChange={handleChange}
placeholder="Add a note…"
rows={3}
maxLength={10_000}
className="w-full rounded border border-border bg-bg-subtle px-3 py-2 text-sm text-text-main resize-none focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{saving && (
<span className="absolute right-2 bottom-2 text-xs text-text-muted animate-pulse">
Saving
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
interface ContextColorBarProps {
contextKey?: string;
className?: string;
}
function hashToHue(key: string): number {
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) & 0xffffff;
}
return (hash * 137.5) % 360;
}
export function ContextColorBar({ contextKey, className }: ContextColorBarProps) {
const hue = contextKey ? hashToHue(contextKey) : 0;
const color = contextKey ? `hsl(${hue}, 70%, 50%)` : "transparent";
return (
<div
className={className}
style={{ width: 3, minWidth: 3, backgroundColor: color, borderRadius: 2 }}
title={contextKey ? `ctx #${contextKey.slice(0, 6)}` : undefined}
/>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
interface HeaderTableProps {
headers: Record<string, string>;
}
export function HeaderTable({ headers }: HeaderTableProps) {
const [masked, setMasked] = useState(true);
const SENSITIVE = /authorization|cookie|x-api-key|bearer/i;
return (
<div>
<div className="mb-2 flex items-center gap-2">
<span className="text-xs text-text-muted">Sensitive headers</span>
<button
type="button"
onClick={() => setMasked((m) => !m)}
className="text-xs text-blue-400 hover:text-blue-300 focus-ring rounded"
>
{masked ? "Show" : "Hide"}
</button>
</div>
<table className="w-full text-xs font-mono border-collapse">
<thead>
<tr className="border-b border-border">
<th className="text-left px-2 py-1 text-text-muted font-medium">Name</th>
<th className="text-left px-2 py-1 text-text-muted font-medium">Value</th>
</tr>
</thead>
<tbody>
{Object.entries(headers).map(([name, value]) => {
const isSensitive = SENSITIVE.test(name);
const display = masked && isSensitive ? "••••••••" : value;
return (
<tr key={name} className="border-b border-border/50 hover:bg-bg-subtle">
<td className="px-2 py-1 text-text-muted select-text">{name}</td>
<td
className={`px-2 py-1 break-all select-text ${isSensitive ? "text-amber-400" : "text-text-main"}`}
>
{display}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import { cn } from "@/shared/utils/cn";
interface JsonViewerProps {
data: unknown;
depth?: number;
className?: string;
}
function JsonNode({ data, depth = 0 }: { data: unknown; depth?: number }) {
const [expanded, setExpanded] = useState(depth < 2);
if (data === null) return <span className="text-text-muted">null</span>;
if (typeof data === "boolean") return <span className="text-amber-400">{String(data)}</span>;
if (typeof data === "number") return <span className="text-blue-400">{String(data)}</span>;
if (typeof data === "string") return <span className="text-green-400">&quot;{data}&quot;</span>;
if (Array.isArray(data)) {
if (data.length === 0) return <span className="text-text-muted">[]</span>;
return (
<span>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="text-text-muted hover:text-text-main font-mono text-xs focus-ring rounded"
>
{expanded ? "▼" : "▶"} [{data.length}]
</button>
{expanded && (
<div className="ml-4 border-l border-border pl-2">
{data.map((item, i) => (
<div key={i} className="flex gap-1 text-xs font-mono">
<span className="text-text-muted">{i}:</span>
<JsonNode data={item} depth={depth + 1} />
{i < data.length - 1 && <span className="text-text-muted">,</span>}
</div>
))}
</div>
)}
</span>
);
}
if (typeof data === "object" && data !== null) {
const entries = Object.entries(data as Record<string, unknown>);
if (entries.length === 0) return <span className="text-text-muted">{"{}"}</span>;
return (
<span>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="text-text-muted hover:text-text-main font-mono text-xs focus-ring rounded"
>
{expanded ? "▼" : "▶"} {"{"}
{entries.length}
{"}"}
</button>
{expanded && (
<div className="ml-4 border-l border-border pl-2">
{entries.map(([k, v], i) => (
<div key={k} className="flex gap-1 text-xs font-mono">
<span className="text-text-main">&quot;{k}&quot;</span>
<span className="text-text-muted">:</span>
<JsonNode data={v} depth={depth + 1} />
{i < entries.length - 1 && <span className="text-text-muted">,</span>}
</div>
))}
</div>
)}
</span>
);
}
return <span className="text-text-main font-mono text-xs">{String(data)}</span>;
}
export function JsonViewer({ data, className }: JsonViewerProps) {
return (
<div className={cn("overflow-auto font-mono text-xs", className)}>
<JsonNode data={data} depth={0} />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
interface SecretMaskToggleProps {
masked: boolean;
onToggle: () => void;
}
export function SecretMaskToggle({ masked, onToggle }: SecretMaskToggleProps) {
return (
<button
type="button"
onClick={onToggle}
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text-main focus-ring rounded px-2 py-0.5 border border-border"
title={masked ? "Unmask secrets" : "Mask secrets"}
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
{masked ? "visibility_off" : "visibility"}
</span>
{masked ? "Show secrets" : "Mask secrets"}
</button>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import type { SseEvent } from "@/mitm/inspector/sseMerger";
interface SseEventListProps {
events: SseEvent[];
}
export function SseEventList({ events }: SseEventListProps) {
return (
<div className="flex flex-col gap-1 font-mono text-xs overflow-auto max-h-full">
{events.map((ev, i) => (
<div key={i} className="flex gap-2 border-b border-border/30 pb-1">
<span className="text-text-muted shrink-0 w-8 text-right">{i + 1}</span>
<span className="text-amber-400 shrink-0">{ev.event ?? "data"}</span>
<span className="text-text-main break-all">{ev.data}</span>
</div>
))}
{events.length === 0 && (
<p className="text-text-muted italic">No SSE events</p>
)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useTranslations } from "next-intl";
import type { InterceptedRequest } from "@/mitm/inspector/types";
interface TimingWaterfallProps {
request: InterceptedRequest;
}
export function TimingWaterfall({ request }: TimingWaterfallProps) {
const t = useTranslations("trafficInspector");
const { proxyLatencyMs, upstreamLatencyMs, totalLatencyMs } = request;
const total = totalLatencyMs ?? (proxyLatencyMs ?? 0) + (upstreamLatencyMs ?? 0);
if (!total) {
return <p className="text-sm text-text-muted">{t("timingNoData")}</p>;
}
const segments: Array<{ label: string; ms: number; color: string }> = [
{
label: t("timingProxyOverhead"),
ms: proxyLatencyMs ?? 0,
color: "bg-blue-500",
},
{
label: t("timingUpstreamResponse"),
ms: upstreamLatencyMs ?? 0,
color: "bg-green-500",
},
];
return (
<div className="space-y-4">
<div className="space-y-2">
{segments.map((seg) => {
const pct = total > 0 ? (seg.ms / total) * 100 : 0;
return (
<div key={seg.label} className="space-y-1">
<div className="flex justify-between text-xs text-text-muted">
<span>{seg.label}</span>
<span>{seg.ms}ms ({pct.toFixed(1)}%)</span>
</div>
<div className="h-4 w-full rounded bg-bg-subtle">
<div
className={`h-full rounded ${seg.color}`}
style={{ width: `${Math.max(pct, 0.5)}%` }}
/>
</div>
</div>
);
})}
</div>
<div className="flex justify-between text-xs font-medium text-text-main border-t border-border pt-2">
<span>{t("timingTotalLatency")}</span>
<span>{total}ms</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
interface TokenBadgeProps {
tokensIn?: number | null;
tokensOut?: number | null;
}
export function TokenBadge({ tokensIn, tokensOut }: TokenBadgeProps) {
if (!tokensIn && !tokensOut) return null;
return (
<span className="inline-flex items-center gap-1 rounded bg-purple-900/40 px-2 py-0.5 text-xs text-purple-300 font-mono">
<span className="material-symbols-outlined text-[12px]" aria-hidden="true">
token
</span>
{tokensIn != null && <span>{tokensIn}</span>}
{tokensOut != null && <span>{tokensOut}</span>}
</span>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useTranslations } from "next-intl";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { normalizeConversation } from "@/mitm/inspector/conversationNormalizer";
import { ChatBubble } from "../chat/ChatBubble";
interface ConversationTabProps {
request: InterceptedRequest;
}
export function ConversationTab({ request }: ConversationTabProps) {
const t = useTranslations("trafficInspector");
const conversation = normalizeConversation(request);
if (!conversation) {
return (
<div className="p-4 text-sm text-text-muted">{t("conversationNotAvailable")}</div>
);
}
const allTurns = [...conversation.request, ...conversation.response];
if (allTurns.length === 0) {
return (
<div className="p-4 text-sm text-text-muted">{t("conversationNoMessages")}</div>
);
}
return (
<div className="h-full overflow-auto p-3 space-y-2">
{conversation.contextKey && (
<div className="text-xs text-text-muted mb-2">
{t("contextFingerprint")}{" "}
<span className="font-mono text-blue-400">#{conversation.contextKey.slice(0, 12)}</span>
</div>
)}
{conversation.request.length > 0 && (
<>
<div className="flex items-center gap-2 mt-2 mb-1 text-[11px] uppercase tracking-wider text-text-muted font-semibold">
<span className="h-px flex-1 bg-border" aria-hidden="true" />
<span>{t("contextHistory")}</span>
<span className="h-px flex-1 bg-border" aria-hidden="true" />
</div>
{conversation.request.map((turn, i) => (
<ChatBubble key={`req-${i}`} turn={turn} />
))}
</>
)}
{conversation.response.length > 0 && (
<>
<div className="flex items-center gap-2 mt-3 mb-1 text-[11px] uppercase tracking-wider text-text-muted font-semibold">
<span className="h-px flex-1 bg-border" aria-hidden="true" />
<span>{t("modelResponse")}</span>
<span className="h-px flex-1 bg-border" aria-hidden="true" />
</div>
{conversation.response.map((turn, i) => (
<ChatBubble key={`res-${i}`} turn={turn} />
))}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { HeaderTable } from "../shared/HeaderTable";
interface HeadersTabProps {
request: InterceptedRequest;
}
export function HeadersTab({ request }: HeadersTabProps) {
return (
<div className="space-y-4 overflow-auto h-full p-2">
<section>
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
Request Headers
</h3>
<HeaderTable headers={request.requestHeaders} />
</section>
<section>
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
Response Headers
</h3>
<HeaderTable headers={request.responseHeaders} />
</section>
</div>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { extractLlmMetadata } from "@/mitm/inspector/llmMetadataExtractor";
import { TokenBadge } from "../shared/TokenBadge";
interface LlmDetailsTabProps {
request: InterceptedRequest;
}
export function LlmDetailsTab({ request }: LlmDetailsTabProps) {
const meta = extractLlmMetadata(request);
if (!meta) {
return (
<div className="p-4 text-sm text-text-muted">
LLM metadata not available for this request.
</div>
);
}
const rows: Array<{ label: string; value: string | null | undefined }> = [
{ label: "Detected provider", value: meta.provider },
{ label: "API kind", value: meta.apiKind },
{ label: "Model", value: meta.model },
{ label: "Messages", value: meta.messages > 0 ? String(meta.messages) : null },
{ label: "Stream", value: meta.streamed ? "yes (SSE)" : "no" },
{ label: "Mapped to", value: meta.mappedTo },
{
label: "Cost estimate",
value: meta.costEstimateUsd != null ? `$${meta.costEstimateUsd.toFixed(6)}` : null,
},
];
return (
<div className="p-4 h-full overflow-auto space-y-4">
<div className="rounded border border-border bg-bg-subtle">
<table className="w-full text-sm">
<tbody>
{rows.map(({ label, value }) => (
<tr key={label} className="border-b border-border/50 last:border-b-0">
<td className="px-3 py-2 text-text-muted font-medium w-[40%]">{label}</td>
<td className="px-3 py-2 text-text-main font-mono">{value ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center gap-2">
<TokenBadge tokensIn={meta.tokensIn} tokensOut={meta.tokensOut} />
{(meta.tokensIn != null || meta.tokensOut != null) && (
<span className="text-xs text-text-muted">
Total: {(meta.tokensIn ?? 0) + (meta.tokensOut ?? 0)} tokens
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { JsonViewer } from "../shared/JsonViewer";
import { SecretMaskToggle } from "../shared/SecretMaskToggle";
interface RequestBodyTabProps {
request: InterceptedRequest;
}
const MASK_PATTERNS = [/sk-[A-Za-z0-9]+/g, /Bearer [A-Za-z0-9._-]+/g, /eyJ[A-Za-z0-9._-]+/g];
function maskSecrets(text: string): string {
let out = text;
for (const p of MASK_PATTERNS) {
out = out.replace(p, "••••");
}
return out;
}
export function RequestBodyTab({ request }: RequestBodyTabProps) {
const [masked, setMasked] = useState(true);
const [raw, setRaw] = useState(false);
const body = request.requestBody;
if (!body) {
return <p className="p-4 text-sm text-text-muted">No request body.</p>;
}
const display = masked ? maskSecrets(body) : body;
let parsed: unknown = null;
try {
parsed = JSON.parse(display);
} catch {
// not JSON
}
return (
<div className="h-full flex flex-col gap-2 p-2">
<div className="flex items-center gap-2">
<SecretMaskToggle masked={masked} onToggle={() => setMasked((m) => !m)} />
<button
type="button"
onClick={() => setRaw((r) => !r)}
className="text-xs text-text-muted hover:text-text-main border border-border rounded px-2 py-0.5 focus-ring"
>
{raw ? "Formatted" : "Raw"}
</button>
<span className="ml-auto text-xs text-text-muted">{request.requestSize} B</span>
</div>
<div className="flex-1 overflow-auto bg-bg-subtle rounded border border-border p-2">
{raw || !parsed ? (
<pre className="text-xs font-mono text-text-main whitespace-pre-wrap break-all">{display}</pre>
) : (
<JsonViewer data={parsed} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { parseSseStream, mergeStream } from "@/mitm/inspector/sseMerger";
import { JsonViewer } from "../shared/JsonViewer";
import { SseEventList } from "../shared/SseEventList";
interface ResponseBodyTabProps {
request: InterceptedRequest;
}
export function ResponseBodyTab({ request }: ResponseBodyTabProps) {
const [showRaw, setShowRaw] = useState(false);
const body = request.responseBody;
if (!body) {
return <p className="p-4 text-sm text-text-muted">No response body.</p>;
}
const isSSE = body.startsWith("data:") || body.includes("\ndata:");
const events = isSSE ? parseSseStream(body) : [];
const merged = isSSE && !showRaw ? mergeStream(events) : null;
let parsed: unknown = null;
if (!isSSE) {
try {
parsed = JSON.parse(body);
} catch {
// not JSON
}
}
return (
<div className="h-full flex flex-col gap-2 p-2">
<div className="flex items-center gap-2">
{isSSE && (
<button
type="button"
onClick={() => setShowRaw((r) => !r)}
className="text-xs text-text-muted hover:text-text-main border border-border rounded px-2 py-0.5 focus-ring"
>
{showRaw ? "Merged view" : "Raw events"}
</button>
)}
<span className="ml-auto text-xs text-text-muted">{request.responseSize} B</span>
{request.status === "in-flight" && (
<span className="text-xs text-amber-400 animate-pulse">streaming</span>
)}
</div>
<div className="flex-1 overflow-auto bg-bg-subtle rounded border border-border p-2">
{isSSE && showRaw ? (
<SseEventList events={events} />
) : isSSE && merged ? (
<div className="space-y-2">
{merged.text && (
<pre className="text-xs font-mono text-text-main whitespace-pre-wrap break-words">{merged.text}</pre>
)}
{merged.toolCalls && merged.toolCalls.length > 0 && (
<JsonViewer data={merged.toolCalls} />
)}
</div>
) : parsed ? (
<JsonViewer data={parsed} />
) : (
<pre className="text-xs font-mono text-text-main whitespace-pre-wrap break-all">{body}</pre>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
LineChart,
Line,
} from "recharts";
import { useTranslations } from "next-intl";
import type { InterceptedRequest } from "@/mitm/inspector/types";
interface StatsChartsProps {
requests: InterceptedRequest[];
}
export default function StatsCharts({ requests }: StatsChartsProps) {
const t = useTranslations("trafficInspector");
const statusDist = requests.reduce<Record<string, number>>((acc, r) => {
const key =
typeof r.status === "number" ? `${Math.floor(r.status / 100)}xx` : String(r.status);
acc[key] = (acc[key] ?? 0) + 1;
return acc;
}, {});
const statusData = Object.entries(statusDist).map(([name, count]) => ({ name, count }));
const latencyData = requests
.filter((r) => r.totalLatencyMs != null)
.slice(-50)
.map((r, i) => ({ i, ms: r.totalLatencyMs }));
return (
<div className="h-full overflow-auto p-4 space-y-6">
<div>
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-3">
{t("statsStatusDistribution")}
</h3>
<div style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={statusData}>
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="count" fill="#6366f1" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{latencyData.length > 1 && (
<div>
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-3">
{t("statsLatency")}
</h3>
<div style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={latencyData}>
<XAxis dataKey="i" hide />
<YAxis tick={{ fontSize: 11 }} unit="ms" />
<Tooltip formatter={(v: unknown) => [`${String(v)}ms`, "latency"]} />
<Line
type="monotone"
dataKey="ms"
stroke="#10b981"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
)}
<div className="grid grid-cols-3 gap-3 text-sm">
<div className="rounded border border-border bg-bg-subtle p-3">
<div className="text-2xl font-bold text-text-main">{requests.length}</div>
<div className="text-xs text-text-muted mt-1">{t("statsTotalRequests")}</div>
</div>
<div className="rounded border border-border bg-bg-subtle p-3">
<div className="text-2xl font-bold text-green-400">
{requests.filter((r) => typeof r.status === "number" && r.status < 400).length}
</div>
<div className="text-xs text-text-muted mt-1">{t("statsSuccessful")}</div>
</div>
<div className="rounded border border-border bg-bg-subtle p-3">
<div className="text-2xl font-bold text-red-400">
{
requests.filter(
(r) =>
r.status === "error" || (typeof r.status === "number" && r.status >= 400),
).length
}
</div>
<div className="text-xs text-text-muted mt-1">{t("statsErrors")}</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import type { InterceptedRequest } from "@/mitm/inspector/types";
interface StatsTabProps {
requests: InterceptedRequest[];
}
// Recharts bundle is split via Next.js dynamic() — not included in the initial page chunk.
const StatsCharts = dynamic(() => import("./StatsCharts"), {
ssr: false,
loading: () => <LoadingCharts />,
});
function LoadingCharts() {
const t = useTranslations("trafficInspector");
return <div className="p-4 text-sm text-muted-foreground">{t("loadingCharts")}</div>;
}
export function StatsTab({ requests }: StatsTabProps) {
const t = useTranslations("trafficInspector");
if (requests.length === 0) {
return (
<div className="p-4 text-sm text-text-muted">{t("statsNoData")}</div>
);
}
return <StatsCharts requests={requests} />;
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useTranslations } from "next-intl";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { TimingWaterfall } from "../shared/TimingWaterfall";
interface TimingTabProps {
request: InterceptedRequest;
}
export function TimingTab({ request }: TimingTabProps) {
const t = useTranslations("trafficInspector");
return (
<div className="p-4 h-full overflow-auto space-y-4">
<TimingWaterfall request={request} />
<div className="border-t border-border pt-3 space-y-1 text-xs text-text-muted">
<div className="flex justify-between">
<span>{t("timingTimestamp")}</span>
<span className="font-mono">{request.timestamp}</span>
</div>
<div className="flex justify-between">
<span>{t("timingMethod")}</span>
<span className="font-mono">{request.method}</span>
</div>
<div className="flex justify-between">
<span>{t("timingStatus")}</span>
<span className="font-mono">{String(request.status)}</span>
</div>
<div className="flex justify-between">
<span>{t("timingRequestSize")}</span>
<span className="font-mono">{request.requestSize} B</span>
</div>
<div className="flex justify-between">
<span>{t("timingResponseSize")}</span>
<span className="font-mono">{request.responseSize} B</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useCallback, useRef, useState } from "react";
const DEBOUNCE_MS = 500;
export function useAnnotations(requestId: string | null) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const save = useCallback(
(annotation: string) => {
if (!requestId) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(async () => {
setSaving(true);
setError(null);
try {
const res = await fetch(
`/api/tools/traffic-inspector/requests/${encodeURIComponent(requestId)}/annotation`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ annotation }),
}
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
setError(body?.error?.message ?? "Failed to save annotation");
}
} catch {
setError("Network error saving annotation");
} finally {
setSaving(false);
}
}, DEBOUNCE_MS);
},
[requestId]
);
return { save, saving, error };
}

View File

@@ -0,0 +1,30 @@
"use client";
import { useCallback, useState } from "react";
import type { InterceptedRequest } from "@/mitm/inspector/types";
export function useReplay() {
const [replaying, setReplaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const replay = useCallback(async (req: InterceptedRequest) => {
setReplaying(true);
setError(null);
try {
const res = await fetch(
`/api/tools/traffic-inspector/requests/${encodeURIComponent(req.id)}/replay`,
{ method: "POST" }
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
setError(body?.error?.message ?? "Replay failed");
}
} catch {
setError("Network error during replay");
} finally {
setReplaying(false);
}
}, []);
return { replay, replaying, error };
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const STORAGE_KEY = "inspector.listWidth";
const MIN_WIDTH = 280;
const MAX_WIDTH = 720;
const COLLAPSED_RAIL = 48;
const DEFAULT_WIDTH = 360;
export interface ResizablePanelsState {
listWidth: number;
collapsed: boolean;
}
export interface ResizablePanelsActions {
startDrag: (e: React.MouseEvent) => void;
toggleCollapse: () => void;
}
export function useResizablePanels(): [ResizablePanelsState, ResizablePanelsActions] {
const [listWidth, setListWidth] = useState<number>(() => {
if (typeof window === "undefined") return DEFAULT_WIDTH;
const stored = localStorage.getItem(STORAGE_KEY);
const parsed = stored ? Number(stored) : NaN;
return isNaN(parsed) ? DEFAULT_WIDTH : Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, parsed));
});
const [collapsed, setCollapsed] = useState(false);
const draggingRef = useRef(false);
const startXRef = useRef(0);
const startWidthRef = useRef(DEFAULT_WIDTH);
// Store handler refs to avoid stale closure issues
const onMouseMoveRef = useRef<(e: MouseEvent) => void>(() => {});
const onMouseUpRef = useRef<() => void>(() => {});
useEffect(() => {
if (!collapsed) {
localStorage.setItem(STORAGE_KEY, String(listWidth));
}
}, [listWidth, collapsed]);
useEffect(() => {
onMouseMoveRef.current = (e: MouseEvent) => {
if (!draggingRef.current) return;
const delta = e.clientX - startXRef.current;
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidthRef.current + delta));
setListWidth(next);
setCollapsed(false);
};
onMouseUpRef.current = () => {
draggingRef.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMouseMoveRef.current);
window.removeEventListener("mouseup", onMouseUpRef.current);
};
});
const startDrag = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
draggingRef.current = true;
startXRef.current = e.clientX;
startWidthRef.current = collapsed ? COLLAPSED_RAIL : listWidth;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("mousemove", onMouseMoveRef.current);
window.addEventListener("mouseup", onMouseUpRef.current);
},
[collapsed, listWidth]
);
const toggleCollapse = useCallback(() => {
setCollapsed((prev) => !prev);
}, []);
const effectiveWidth = collapsed ? COLLAPSED_RAIL : listWidth;
return [{ listWidth: effectiveWidth, collapsed }, { startDrag, toggleCollapse }];
}

View File

@@ -0,0 +1,220 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { WsEvent } from "@/mitm/inspector/types";
const WS_PATH = "/api/tools/traffic-inspector/ws";
const SNAPSHOT_FLUSH_MS = 500;
const SNAPSHOT_FLUSH_BATCH = 10;
export interface SessionInfo {
id: string;
name?: string;
startedAt: string;
requestCount: number;
}
async function fetchSessionsRemote(): Promise<SessionInfo[]> {
const res = await fetch("/api/tools/traffic-inspector/sessions");
if (!res.ok) return [];
const data = (await res.json()) as { sessions: SessionInfo[] };
return data.sessions ?? [];
}
export function useSessionRecorder() {
const [recording, setRecording] = useState(false);
const [session, setSession] = useState<SessionInfo | null>(null);
const [elapsed, setElapsed] = useState(0);
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef<number>(0);
const mountedRef = useRef(true);
const recordingWsRef = useRef<WebSocket | null>(null);
const recordingSessionRef = useRef<SessionInfo | null>(null);
const pendingSnapshotsRef = useRef<string[]>([]);
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const fetchSessions = useCallback(async () => {
try {
const list = await fetchSessionsRemote();
if (mountedRef.current) setSessions(list);
} catch {
// silently ignore
}
}, []);
// Fetch sessions on mount — use an async wrapper to avoid direct setState in effect
useEffect(() => {
let cancelled = false;
fetchSessionsRemote()
.then((list) => {
if (!cancelled) setSessions(list);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const flushSnapshots = useCallback(async (sessionId: string) => {
if (pendingSnapshotsRef.current.length === 0) return;
const batch = pendingSnapshotsRef.current.splice(0, pendingSnapshotsRef.current.length);
for (const payload of batch) {
try {
await fetch(
`/api/tools/traffic-inspector/sessions/${encodeURIComponent(sessionId)}/requests`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payload }),
}
);
} catch {
// best-effort: don't break recording UI on network failure
}
}
}, []);
const scheduleFlush = useCallback(
(sessionId: string) => {
if (flushTimerRef.current) return;
flushTimerRef.current = setTimeout(() => {
flushTimerRef.current = null;
void flushSnapshots(sessionId);
}, SNAPSHOT_FLUSH_MS);
},
[flushSnapshots]
);
const stopRecordingWs = useCallback(() => {
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
if (recordingWsRef.current) {
recordingWsRef.current.onclose = null;
recordingWsRef.current.close();
recordingWsRef.current = null;
}
}, []);
const start = useCallback(
async (name?: string) => {
try {
const res = await fetch("/api/tools/traffic-inspector/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) return;
const data = (await res.json()) as { session: SessionInfo };
const newSession = data.session;
setSession(newSession);
recordingSessionRef.current = newSession;
setRecording(true);
startTimeRef.current = Date.now();
setElapsed(0);
timerRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
}, 1000);
// Open a dedicated WS to capture traffic events during the recording window
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${proto}//${window.location.host}${WS_PATH}`;
const ws = new WebSocket(wsUrl);
recordingWsRef.current = ws;
ws.onmessage = (ev: MessageEvent) => {
if (!mountedRef.current) return;
let event: WsEvent;
try {
event = JSON.parse(ev.data as string) as WsEvent;
} catch {
return;
}
if (event.type !== "new") return;
const sid = recordingSessionRef.current?.id;
if (!sid) return;
pendingSnapshotsRef.current.push(JSON.stringify(event.data));
if (pendingSnapshotsRef.current.length >= SNAPSHOT_FLUSH_BATCH) {
void flushSnapshots(sid);
} else {
scheduleFlush(sid);
}
};
ws.onerror = () => ws.close();
} catch {
// ignore
}
},
[flushSnapshots, scheduleFlush]
);
const stop = useCallback(async () => {
if (!session) return;
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setRecording(false);
// Flush any remaining pending snapshots before stopping
const sid = session.id;
stopRecordingWs();
if (pendingSnapshotsRef.current.length > 0) {
await flushSnapshots(sid);
}
recordingSessionRef.current = null;
try {
await fetch(`/api/tools/traffic-inspector/sessions/${encodeURIComponent(sid)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "stop" }),
});
} catch {
// ignore
}
await fetchSessions();
setSession(null);
}, [session, fetchSessions, stopRecordingWs, flushSnapshots]);
const deleteSession = useCallback(async (id: string) => {
try {
await fetch(`/api/tools/traffic-inspector/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
});
await fetchSessions();
} catch {
// ignore
}
}, [fetchSessions]);
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (flushTimerRef.current) clearTimeout(flushTimerRef.current);
if (recordingWsRef.current) {
recordingWsRef.current.onclose = null;
recordingWsRef.current.close();
}
};
}, []);
return {
recording,
session,
elapsed,
sessions,
start,
stop,
deleteSession,
fetchSessions,
};
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useEffect, useRef } from "react";
interface UseSystemProxyExitGuardOpts {
applied: boolean; // current state (from GET capture-modes)
endpoint?: string; // POST /capture-modes/system-proxy
}
/**
* On unmount / page hide / beforeunload, if system proxy is applied,
* silently fires a revert request via navigator.sendBeacon (best-effort,
* survives unload) AND attaches a beforeunload listener that prompts the
* user with a native confirm dialog (browser default — text is ignored
* by most browsers but the prompt itself appears).
*/
export function useSystemProxyExitGuard(opts: UseSystemProxyExitGuardOpts): void {
// 1. Track latest 'applied' in a ref so the listener always sees fresh value
const appliedRef = useRef(opts.applied);
useEffect(() => {
appliedRef.current = opts.applied;
}, [opts.applied]);
useEffect(() => {
const endpoint =
opts.endpoint ?? "/api/tools/traffic-inspector/capture-modes/system-proxy";
const body = JSON.stringify({ action: "revert" });
const blob = new Blob([body], { type: "application/json" });
const beforeUnload = (e: BeforeUnloadEvent) => {
if (!appliedRef.current) return;
// Best-effort revert via sendBeacon (survives navigation)
try {
navigator.sendBeacon(endpoint, blob);
} catch {
/* ignore */
}
// Show confirmation prompt
e.preventDefault();
e.returnValue = "System-wide proxy still active — leave page anyway?";
return e.returnValue;
};
window.addEventListener("beforeunload", beforeUnload);
return () => {
window.removeEventListener("beforeunload", beforeUnload);
// On component unmount (SPA navigation), fire revert too
if (appliedRef.current) {
try {
navigator.sendBeacon(endpoint, blob);
} catch {
/* ignore */
}
}
};
}, [opts.endpoint]);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { useCallback, useState } from "react";
import type { ListFilters } from "@/mitm/inspector/types";
export interface FiltersState extends ListFilters {
sameContextKey?: string;
}
export function useTrafficFilters() {
const [filters, setFilters] = useState<FiltersState>({ profile: "llm" });
const setProfile = useCallback((profile: ListFilters["profile"]) => {
setFilters((prev) => ({ ...prev, profile }));
}, []);
const setHost = useCallback((host: string | undefined) => {
setFilters((prev) => ({ ...prev, host: host || undefined }));
}, []);
const setAgent = useCallback((agent: ListFilters["agent"]) => {
setFilters((prev) => ({ ...prev, agent }));
}, []);
const setStatus = useCallback((status: ListFilters["status"]) => {
setFilters((prev) => ({ ...prev, status }));
}, []);
const setSource = useCallback((source: ListFilters["source"]) => {
setFilters((prev) => ({ ...prev, source }));
}, []);
const setSessionId = useCallback((sessionId: string | undefined) => {
setFilters((prev) => ({ ...prev, sessionId }));
}, []);
const setSameContext = useCallback((contextKey: string | undefined) => {
setFilters((prev) => ({ ...prev, sameContextKey: contextKey }));
}, []);
const reset = useCallback(() => {
setFilters({ profile: "llm" });
}, []);
return {
filters,
setProfile,
setHost,
setAgent,
setStatus,
setSource,
setSessionId,
setSameContext,
reset,
};
}

View File

@@ -0,0 +1,187 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { InterceptedRequest, ListFilters, WsEvent } from "@/mitm/inspector/types";
import type { FiltersState } from "./useTrafficFilters";
const WS_PATH = "/api/tools/traffic-inspector/ws";
const INITIAL_BACKOFF_MS = 500;
const MAX_BACKOFF_MS = 30_000;
const BACKOFF_MULTIPLIER = 2;
export interface TrafficStreamState {
requests: InterceptedRequest[];
connected: boolean;
paused: boolean;
total: number;
pendingCount: number;
}
export interface TrafficStreamActions {
pause: () => void;
resume: () => void;
clear: () => void;
}
export function useTrafficStream(
filters: FiltersState | ListFilters
): [TrafficStreamState, TrafficStreamActions] {
const [requests, setRequests] = useState<InterceptedRequest[]>([]);
const [connected, setConnected] = useState(false);
const [paused, setPaused] = useState(false);
const [pendingCount, setPendingCount] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
const backoffRef = useRef(INITIAL_BACKOFF_MS);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const pausedRef = useRef(false);
const pendingRef = useRef<InterceptedRequest[]>([]);
const filtersRef = useRef(filters);
// connectRef breaks the circular dep between connect's closure and onclose
const connectRef = useRef<() => void>(() => {});
// Keep filtersRef in sync without triggering re-render (effect runs after render)
useEffect(() => {
filtersRef.current = filters;
});
const applyFilter = useCallback((req: InterceptedRequest): boolean => {
const f = filtersRef.current as FiltersState;
if (f.profile === "llm" && req.detectedKind !== "llm") return false;
if (f.profile === "custom" && req.source !== "custom-host") return false;
if (f.host && !req.host.includes(f.host)) return false;
if (f.agent && req.agent !== f.agent) return false;
if (f.source && req.source !== f.source) return false;
if (f.sessionId && req.sessionId !== f.sessionId) return false;
if (f.sameContextKey && req.contextKey !== f.sameContextKey) return false;
if (f.status) {
const s = req.status;
if (typeof s === "number") {
const cat = `${Math.floor(s / 100)}xx`;
if (cat !== f.status) return false;
} else if (f.status === "error" && s !== "error") {
return false;
}
}
return true;
}, []);
useEffect(() => {
mountedRef.current = true;
const connect = () => {
if (!mountedRef.current) return;
if (wsRef.current && wsRef.current.readyState < WebSocket.CLOSING) {
wsRef.current.close();
}
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${proto}//${window.location.host}${WS_PATH}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
if (!mountedRef.current) return;
backoffRef.current = INITIAL_BACKOFF_MS;
setConnected(true);
};
ws.onmessage = (ev: MessageEvent) => {
if (!mountedRef.current) return;
let event: WsEvent;
try {
event = JSON.parse(ev.data as string) as WsEvent;
} catch {
return;
}
if (pausedRef.current) {
if (event.type === "new") {
pendingRef.current.push(event.data);
setPendingCount(pendingRef.current.length);
}
if (event.type === "update") {
const idx = pendingRef.current.findIndex((r) => r.id === event.data.id);
if (idx !== -1) pendingRef.current[idx] = event.data;
}
return;
}
if (event.type === "snapshot") {
setRequests(event.data.filter(applyFilter));
} else if (event.type === "new") {
if (applyFilter(event.data)) {
setRequests((prev) => [event.data, ...prev].slice(0, 1000));
}
} else if (event.type === "update") {
setRequests((prev) =>
prev.map((r) => (r.id === event.data.id ? event.data : r))
);
} else if (event.type === "clear") {
setRequests([]);
}
};
ws.onclose = () => {
if (!mountedRef.current) return;
setConnected(false);
const delay = Math.min(backoffRef.current, MAX_BACKOFF_MS);
backoffRef.current = Math.min(
backoffRef.current * BACKOFF_MULTIPLIER,
MAX_BACKOFF_MS
);
reconnectTimerRef.current = setTimeout(() => {
// Use ref so we always call the current connect version
connectRef.current();
}, delay);
};
ws.onerror = () => {
ws.close();
};
};
// Store in ref for reconnect callback
connectRef.current = connect;
connect();
return () => {
mountedRef.current = false;
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
wsRef.current?.close();
};
}, [applyFilter]);
const pause = useCallback(() => {
pausedRef.current = true;
setPaused(true);
}, []);
const resume = useCallback(() => {
pausedRef.current = false;
setPaused(false);
if (pendingRef.current.length > 0) {
const pending = pendingRef.current.filter(applyFilter);
pendingRef.current = [];
setPendingCount(0);
setRequests((prev) => [...pending, ...prev].slice(0, 1000));
}
}, [applyFilter]);
const clear = useCallback(() => {
setRequests([]);
pendingRef.current = [];
setPendingCount(0);
}, []);
const state: TrafficStreamState = {
requests,
connected,
paused,
total: requests.length,
pendingCount,
};
return [state, { pause, resume, clear }];
}

View File

@@ -0,0 +1,107 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const ESTIMATED_ROW_HEIGHT = 48;
const OVERSCAN = 5;
export interface VirtualListState<T> {
virtualItems: Array<{ index: number; item: T; top: number; height: number }>;
totalHeight: number;
containerRef: React.RefObject<HTMLDivElement | null>;
rowRef: (index: number) => (el: HTMLDivElement | null) => void;
}
export function useVirtualList<T>(items: T[], containerHeight: number): VirtualListState<T> {
const containerRef = useRef<HTMLDivElement | null>(null);
const [scrollTop, setScrollTop] = useState(0);
// Heights stored in state so reads during render are tracked by React
const [heights, setHeights] = useState<Map<number, number>>(new Map());
const observersRef = useRef<Map<number, ResizeObserver>>(new Map());
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handler = () => setScrollTop(el.scrollTop);
el.addEventListener("scroll", handler, { passive: true });
return () => el.removeEventListener("scroll", handler);
}, []);
// Cleanup observers on unmount
useEffect(() => {
const observers = observersRef.current;
return () => {
observers.forEach((obs) => obs.disconnect());
};
}, []);
const rowRef = useCallback((index: number) => (el: HTMLDivElement | null) => {
const observers = observersRef.current;
if (el) {
const existing = observers.get(index);
if (existing) existing.disconnect();
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const h = entry.contentRect.height;
if (h > 0) {
setHeights((prev) => {
if (prev.get(index) === h) return prev;
const next = new Map(prev);
next.set(index, h);
return next;
});
}
}
});
ro.observe(el);
observers.set(index, ro);
} else {
const existing = observers.get(index);
if (existing) {
existing.disconnect();
observers.delete(index);
}
}
}, []);
// Compute cumulative offsets — reads heights from state (not a ref)
const offsets: number[] = [];
let total = 0;
for (let i = 0; i < items.length; i++) {
offsets.push(total);
total += heights.get(i) ?? ESTIMATED_ROW_HEIGHT;
}
const totalHeight = total;
// Find visible range
let startIdx = 0;
let endIdx = items.length - 1;
for (let i = 0; i < offsets.length; i++) {
if ((offsets[i] ?? 0) + (heights.get(i) ?? ESTIMATED_ROW_HEIGHT) < scrollTop) {
startIdx = i + 1;
} else {
break;
}
}
for (let i = startIdx; i < offsets.length; i++) {
if ((offsets[i] ?? 0) > scrollTop + containerHeight) {
endIdx = i - 1;
break;
}
}
startIdx = Math.max(0, startIdx - OVERSCAN);
endIdx = Math.min(items.length - 1, endIdx + OVERSCAN);
const virtualItems: Array<{ index: number; item: T; top: number; height: number }> = [];
for (let i = startIdx; i <= endIdx; i++) {
virtualItems.push({
index: i,
item: items[i] as T,
top: offsets[i] ?? 0,
height: heights.get(i) ?? ESTIMATED_ROW_HEIGHT,
});
}
return { virtualItems, totalHeight, containerRef, rowRef };
}

View File

@@ -0,0 +1,10 @@
import { TrafficInspectorPageClient } from "./TrafficInspectorPageClient";
export const metadata = {
title: "Traffic Inspector — OmniRoute",
description: "Monitor LLM calls + debug any application's HTTPS traffic",
};
export default function TrafficInspectorPage() {
return <TrafficInspectorPageClient />;
}

View File

@@ -0,0 +1,39 @@
/**
* GET /api/tools/agent-bridge/agents/[id]/detect
* Run detection probe for an agent and return { installed, version?, path? }.
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { detectAgent } from "@/mitm/detection/index";
import type { AgentId } from "@/mitm/types";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
const VALID_IDS = new Set<AgentId>([
"antigravity",
"kiro",
"copilot",
"codex",
"cursor",
"zed",
"claude-code",
"open-code",
"trae",
]);
type Params = { params: { id: string } };
export async function GET(_request: Request, { params }: Params): Promise<Response> {
const { id } = params;
if (!VALID_IDS.has(id as AgentId)) {
return createErrorResponse({ status: 404, message: `Unknown agent id: ${id}` });
}
try {
const result = detectAgent(id as AgentId);
return Response.json({ agentId: id, ...result });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,55 @@
/**
* POST /api/tools/agent-bridge/agents/[id]/dns
* Enable or disable DNS entries for a specific agent.
* LOCAL_ONLY + SPAWN_CAPABLE: registered in routeGuard.ts
*
* Body: AgentBridgeDnsActionSchema { enabled: boolean }
*/
import { AgentBridgeDnsActionSchema } from "@/shared/schemas/agentBridge";
import { addDNSEntry, removeDNSEntry } from "@/mitm/dns/dnsConfig";
import { upsertAgentBridgeState } from "@/lib/db/agentBridgeState";
import { getCachedPassword } from "@/mitm/manager";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
type Params = { params: { id: string } };
export async function POST(request: Request, { params }: Params): Promise<Response> {
const { id } = params;
let body: unknown;
try {
body = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const parsed = AgentBridgeDnsActionSchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({
status: 400,
message: "Invalid request body",
details: parsed.error.flatten(),
});
}
const { enabled } = parsed.data;
const raw = body as Record<string, unknown>;
const sudoPassword =
typeof raw.sudoPassword === "string" ? raw.sudoPassword : (getCachedPassword() ?? "");
try {
if (enabled) {
await addDNSEntry(sudoPassword);
} else {
await removeDNSEntry(sudoPassword);
}
upsertAgentBridgeState({ agent_id: id, dns_enabled: enabled });
return Response.json({ ok: true, dns_enabled: enabled });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,48 @@
/**
* GET /api/tools/agent-bridge/agents/[id]/mappings — list model mappings
* PUT /api/tools/agent-bridge/agents/[id]/mappings — replace all mappings
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { AgentBridgeMappingPutSchema } from "@/shared/schemas/agentBridge";
import { getMappingsForAgent, setMappings } from "@/lib/db/agentBridgeMappings";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
type Params = { params: { id: string } };
export async function GET(_request: Request, { params }: Params): Promise<Response> {
try {
const mappings = getMappingsForAgent(params.id);
return Response.json({ mappings });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}
export async function PUT(request: Request, { params }: Params): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const parsed = AgentBridgeMappingPutSchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({
status: 400,
message: "Invalid request body",
details: parsed.error.flatten(),
});
}
try {
setMappings(params.id, parsed.data.mappings);
const mappings = getMappingsForAgent(params.id);
return Response.json({ ok: true, mappings });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,63 @@
/**
* GET /api/tools/agent-bridge/agents/[id] — agent detail
* PATCH /api/tools/agent-bridge/agents/[id] — update setup_completed flag
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { z } from "zod";
import { resolveTarget } from "@/mitm/targets/index";
import { detectAgent } from "@/mitm/detection/index";
import { getAgentBridgeState, upsertAgentBridgeState } from "@/lib/db/agentBridgeState";
import type { AgentId } from "@/mitm/types";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
const PatchSchema = z.object({
setup_completed: z.boolean(),
});
type Params = { params: { id: string } };
export async function GET(_request: Request, { params }: Params): Promise<Response> {
try {
const { id } = params;
const target = resolveTarget(id) ?? null;
if (!target) {
return createErrorResponse({ status: 404, message: `Agent not found: ${id}` });
}
const detection = detectAgent(id as AgentId);
const state = getAgentBridgeState(id) ?? null;
return Response.json({ agent: target, detection, state });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}
export async function PATCH(request: Request, { params }: Params): Promise<Response> {
const { id } = params;
let body: unknown;
try {
body = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const parsed = PatchSchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({
status: 400,
message: "Invalid request body",
details: parsed.error.flatten(),
});
}
try {
upsertAgentBridgeState({ agent_id: id, setup_completed: parsed.data.setup_completed });
const state = getAgentBridgeState(id);
return Response.json({ ok: true, state });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,25 @@
/**
* GET /api/tools/agent-bridge/agents
* Returns the full list of registered MITM targets mapped to a stable UI shape.
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { ALL_TARGETS } from "@/mitm/targets/index";
import { detectAgent } from "@/mitm/detection/index";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function GET(): Promise<Response> {
try {
const agents = ALL_TARGETS.map((t) => ({
id: t.id,
name: t.name,
hosts: t.hosts,
viability: t.viability ?? "supported",
state: detectAgent(t.id),
}));
return Response.json({ agents });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,71 @@
/**
* GET /api/tools/agent-bridge/bypass — list all patterns (default + user)
* POST /api/tools/agent-bridge/bypass — replace user patterns
* DELETE /api/tools/agent-bridge/bypass?pattern=X — remove a single user pattern
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { AgentBridgeBypassUpsertSchema } from "@/shared/schemas/agentBridge";
import {
getAllBypassPatterns,
replaceUserBypassPatterns,
getUserBypassPatterns,
} from "@/lib/db/agentBridgeBypass";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function GET(): Promise<Response> {
try {
const patterns = getAllBypassPatterns();
return Response.json({ patterns });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const parsed = AgentBridgeBypassUpsertSchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({
status: 400,
message: "Invalid request body",
details: parsed.error.flatten(),
});
}
try {
replaceUserBypassPatterns(parsed.data.patterns);
const patterns = getAllBypassPatterns();
return Response.json({ ok: true, patterns });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}
export async function DELETE(request: Request): Promise<Response> {
const url = new URL(request.url);
const pattern = url.searchParams.get("pattern");
if (!pattern) {
return createErrorResponse({ status: 400, message: "Missing query param: pattern" });
}
try {
const existing = getUserBypassPatterns();
const updated = existing.filter((p) => p !== pattern);
replaceUserBypassPatterns(updated);
const patterns = getAllBypassPatterns();
return Response.json({ ok: true, patterns });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,34 @@
/**
* GET /api/tools/agent-bridge/cert/download
* Streams the PEM certificate file.
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { resolveMitmDataDir } from "@/mitm/dataDir";
import path from "path";
import fs from "fs";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function GET(): Promise<Response> {
const crtPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
if (!fs.existsSync(crtPath)) {
return createErrorResponse({
status: 404,
message: "Certificate not found. Generate one first via POST /api/tools/agent-bridge/cert/regenerate",
});
}
try {
const pem = fs.readFileSync(crtPath);
return new Response(pem, {
status: 200,
headers: {
"Content-Type": "application/x-pem-file",
"Content-Disposition": 'attachment; filename="omniroute-mitm.crt"',
"Content-Length": String(pem.length),
},
});
} catch {
return createErrorResponse({ status: 500, message: "Failed to read certificate file" });
}
}

View File

@@ -0,0 +1,22 @@
/**
* POST /api/tools/agent-bridge/cert/regenerate
* Regenerates the MITM self-signed certificate. Overwrites the existing one.
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { generateCert } from "@/mitm/cert/generate";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function POST(): Promise<Response> {
try {
// generateCert checks for existing files — force-regenerate by deleting first
// is not in scope; the function is idempotent (returns existing paths). If a
// caller needs a fresh cert they must delete the old one manually. We expose
// whatever generateCert decides.
const result = await generateCert();
return Response.json({ ok: true, certPath: result.cert, keyPath: result.key });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,50 @@
/**
* GET /api/tools/agent-bridge/cert — cert status
* POST /api/tools/agent-bridge/cert — trust (install) the cert
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { installCert, checkCertInstalled } from "@/mitm/cert/install";
import { resolveMitmDataDir } from "@/mitm/dataDir";
import { getCachedPassword } from "@/mitm/manager";
import path from "path";
import fs from "fs";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
function certPath(): string {
return path.join(resolveMitmDataDir(), "mitm", "server.crt");
}
export async function GET(): Promise<Response> {
try {
const crtPath = certPath();
const exists = fs.existsSync(crtPath);
const trusted = exists ? await checkCertInstalled(crtPath) : false;
return Response.json({ exists, trusted, path: exists ? crtPath : null });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}
export async function POST(request: Request): Promise<Response> {
const raw = await request.json().catch(() => ({})) as Record<string, unknown>;
const sudoPassword =
typeof raw.sudoPassword === "string" ? raw.sudoPassword : (getCachedPassword() ?? "");
try {
const crtPath = certPath();
if (!fs.existsSync(crtPath)) {
return createErrorResponse({
status: 404,
message: "Certificate not found. Generate one first.",
});
}
await installCert(sudoPassword, crtPath);
const trusted = await checkCertInstalled(crtPath);
return Response.json({ ok: true, trusted });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,81 @@
/**
* POST /api/tools/agent-bridge/server
* Start / stop / restart MITM server; trust cert; regenerate cert.
* LOCAL_ONLY + SPAWN_CAPABLE: registered in routeGuard.ts
*
* Body: AgentBridgeServerActionSchema
*/
import { AgentBridgeServerActionSchema } from "@/shared/schemas/agentBridge";
import { startMitm, stopMitm, getMitmStatus, setCachedPassword, getCachedPassword } from "@/mitm/manager";
import { installCert, checkCertInstalled } from "@/mitm/cert/install";
import { generateCert } from "@/mitm/cert/generate";
import { resolveMitmDataDir } from "@/mitm/dataDir";
import path from "path";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const parsed = AgentBridgeServerActionSchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({
status: 400,
message: "Invalid request body",
details: parsed.error.flatten(),
});
}
const { action } = parsed.data;
const raw = body as Record<string, unknown>;
const sudoPassword = typeof raw.sudoPassword === "string" ? raw.sudoPassword : (getCachedPassword() ?? "");
const apiKey = typeof raw.apiKey === "string" ? raw.apiKey : (process.env.ROUTER_API_KEY ?? "");
try {
if (action === "start") {
if (sudoPassword) setCachedPassword(sudoPassword);
const result = await startMitm(apiKey, sudoPassword);
return Response.json({ ok: true, ...result });
}
if (action === "stop") {
const pwd = sudoPassword || getCachedPassword() || "";
const result = await stopMitm(pwd);
return Response.json({ ok: true, ...result });
}
if (action === "restart") {
const pwd = sudoPassword || getCachedPassword() || "";
const status = await getMitmStatus();
if (status.running) {
await stopMitm(pwd);
}
if (sudoPassword) setCachedPassword(sudoPassword);
const result = await startMitm(apiKey, sudoPassword || pwd);
return Response.json({ ok: true, ...result });
}
if (action === "trust-cert") {
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
const pwd = sudoPassword || getCachedPassword() || "";
await installCert(pwd, certPath);
const trusted = await checkCertInstalled(certPath);
return Response.json({ ok: true, trusted });
}
if (action === "regenerate-cert") {
const result = await generateCert();
return Response.json({ ok: true, certPath: result.cert });
}
return createErrorResponse({ status: 400, message: "Unknown action" });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,18 @@
/**
* GET /api/tools/agent-bridge/state
* Returns global MITM server status + per-agent detection/status.
* LOCAL_ONLY: registered in routeGuard.ts
*/
import { getMitmStatus, getAllAgentsStatus } from "@/mitm/manager";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function GET(): Promise<Response> {
try {
const [server, agents] = await Promise.all([getMitmStatus(), getAllAgentsStatus()]);
return Response.json({ server, agents });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}

View File

@@ -0,0 +1,91 @@
/**
* GET /api/tools/agent-bridge/upstream-ca — returns current upstream CA path
* POST /api/tools/agent-bridge/upstream-ca — validates + persists a new path
* LOCAL_ONLY: registered in routeGuard.ts
*
* Persistence: <dataDir>/mitm/upstream-ca.path (one-line text file)
* After persisting, configureUpstreamCa() is called immediately so the new CA
* takes effect without a reboot. Spec: plan 11 §4.7.
*/
import { AgentBridgeUpstreamCaPostSchema } from "@/shared/schemas/agentBridge";
import { resolveMitmDataDir } from "@/mitm/dataDir";
import { configureUpstreamCa } from "@/mitm/upstreamTrust";
import path from "path";
import fs from "fs";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
const CA_PATH_FILE = path.join(resolveMitmDataDir(), "mitm", "upstream-ca.path");
function readStoredCaPath(): string | null {
try {
if (!fs.existsSync(CA_PATH_FILE)) return null;
const raw = fs.readFileSync(CA_PATH_FILE, "utf8").trim();
return raw || null;
} catch {
return null;
}
}
function writeStoredCaPath(caPath: string): void {
const dir = path.dirname(CA_PATH_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CA_PATH_FILE, caPath + "\n");
}
export async function GET(): Promise<Response> {
try {
const stored = readStoredCaPath();
// Prefer env var; file is secondary
const active = process.env.AGENTBRIDGE_UPSTREAM_CA_CERT || stored || null;
return Response.json({ path: active });
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const parsed = AgentBridgeUpstreamCaPostSchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({
status: 400,
message: "Invalid request body",
details: parsed.error.flatten(),
});
}
const { path: caPath } = parsed.data;
// Validate the file actually exists (plan 11 §4.7)
if (!fs.existsSync(caPath)) {
return createErrorResponse({
status: 400,
message: `Upstream CA file not found: ${caPath}`,
});
}
try {
writeStoredCaPath(caPath);
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
// Activate the new CA immediately so it takes effect without a reboot.
try {
configureUpstreamCa(caPath);
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 400, message: msg });
}
return Response.json({ ok: true, path: caPath });
}

View File

@@ -0,0 +1,87 @@
/**
* POST /api/tools/traffic-inspector/capture-modes/http-proxy
*
* Start or stop the HTTP_PROXY listener (default port 8080).
* `EADDRINUSE` is surfaced as 409 with a structured error body.
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { InspectorCaptureModeActionSchema } from "@/shared/schemas/inspector";
import { startHttpProxyServer } from "@/mitm/inspector/httpProxyServer";
import { getHttpProxyHandle, setHttpProxyHandle } from "@/lib/inspector/captureState";
const DEFAULT_PORT = Number(process.env.INSPECTOR_HTTP_PROXY_PORT ?? "8080") || 8080;
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const parsed = InspectorCaptureModeActionSchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
const { action } = parsed.data;
if (action === "stop") {
const handle = getHttpProxyHandle();
if (!handle) {
return Response.json({ ok: true, running: false, port: null });
}
try {
await handle.stop();
setHttpProxyHandle(null);
return Response.json({ ok: true, running: false, port: null });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to stop HTTP proxy")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}
// action === "start"
const existing = getHttpProxyHandle();
if (existing) {
return Response.json({ ok: true, running: true, port: existing.port });
}
try {
const handle = await startHttpProxyServer(DEFAULT_PORT);
setHttpProxyHandle(handle);
return Response.json({ ok: true, running: true, port: handle.port }, { status: 201 });
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr?.code === "EADDRINUSE") {
return new Response(
JSON.stringify({
error: {
message: `Port ${DEFAULT_PORT} is already in use`,
type: "conflict",
code: "EADDRINUSE",
port: DEFAULT_PORT,
},
}),
{ status: 409, headers: { "content-type": "application/json" } }
);
}
const msg = sanitizeErrorMessage(err);
return new Response(
JSON.stringify(buildErrorBody(500, msg || "Failed to start HTTP proxy")),
{ status: 500, headers: { "content-type": "application/json" } }
);
}
}

View File

@@ -0,0 +1,53 @@
/**
* GET /api/tools/traffic-inspector/capture-modes
*
* Returns the current status of all 4 capture modes:
* 1. agentBridge — always active when the MITM server is running
* 2. customHosts — count from DB
* 3. httpProxy — running flag + port
* 4. systemProxy — applied flag + guardUntil
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { listCustomHosts } from "@/lib/db/inspectorCustomHosts";
import {
getHttpProxyHandle,
getSystemProxyState,
isTlsInterceptEnabled,
} from "@/lib/inspector/captureState";
export async function GET(): Promise<Response> {
try {
const customHosts = listCustomHosts();
const httpProxy = getHttpProxyHandle();
const systemProxy = getSystemProxyState();
return Response.json({
agentBridge: true,
customHosts: {
count: customHosts.length,
enabledCount: customHosts.filter((h) => h.enabled).length,
},
httpProxy: {
running: httpProxy !== null,
port: httpProxy?.port ?? null,
},
systemProxy: {
applied: systemProxy.applied,
guardUntil: systemProxy.guardUntil,
port: systemProxy.port,
},
tlsIntercept: {
enabled: isTlsInterceptEnabled(),
},
});
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(
JSON.stringify(buildErrorBody(500, msg || "Failed to get capture mode status")),
{ status: 500, headers: { "content-type": "application/json" } }
);
}
}

View File

@@ -0,0 +1,92 @@
/**
* POST /api/tools/traffic-inspector/capture-modes/system-proxy
*
* Apply or revert the OS-level system proxy.
*
* `apply` — sets the system proxy to 127.0.0.1:<port> and saves the
* prior state so it can be restored. Starts a guard timer that
* auto-reverts after `guardMinutes` (default 30).
*
* `revert` — restores the previously saved proxy state.
*
* Hard Rule #13: all shell invocations happen in `systemProxyConfig.ts` using
* `execFile` with array args — no interpolation here.
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { InspectorSystemProxyActionSchema } from "@/shared/schemas/inspector";
import { apply, revert } from "@/mitm/inspector/systemProxyConfig";
import {
getSystemProxyState,
setSystemProxyApplied,
clearSystemProxy,
} from "@/lib/inspector/captureState";
const DEFAULT_PORT = Number(process.env.INSPECTOR_HTTP_PROXY_PORT ?? "8080") || 8080;
const DEFAULT_GUARD_MINUTES = Number(
process.env.INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES ?? "30"
) || 30;
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const parsed = InspectorSystemProxyActionSchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
const { action, port, guardMinutes } = parsed.data;
const resolvedPort = port ?? DEFAULT_PORT;
const resolvedGuard = guardMinutes ?? DEFAULT_GUARD_MINUTES;
if (action === "revert") {
const state = getSystemProxyState();
const previousState = state.previousState;
try {
if (previousState) {
await revert(previousState);
}
clearSystemProxy();
return Response.json({ ok: true, applied: false });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(
JSON.stringify(buildErrorBody(500, msg || "Failed to revert system proxy")),
{ status: 500, headers: { "content-type": "application/json" } }
);
}
}
// action === "apply"
try {
const result = await apply(resolvedPort);
setSystemProxyApplied(resolvedPort, result.previousState, resolvedGuard);
return Response.json({
ok: true,
applied: true,
port: resolvedPort,
platform: result.platform,
guardUntil: getSystemProxyState().guardUntil,
});
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(
JSON.stringify(buildErrorBody(500, msg || "Failed to apply system proxy")),
{ status: 500, headers: { "content-type": "application/json" } }
);
}
}

View File

@@ -0,0 +1,39 @@
/**
* POST /api/tools/traffic-inspector/capture-modes/tls-intercept
*
* Toggle TLS body decryption in the MITM proxy. When enabled, the MITM
* server decrypts HTTPS bodies and the Traffic Inspector can show full
* request/response content. When disabled, CONNECT tunnels are passed through
* and only metadata is captured.
*
* State is held in the `captureState` module (process-lifetime).
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
import { InspectorTlsInterceptToggleSchema } from "@/shared/schemas/inspector";
import { isTlsInterceptEnabled, setTlsIntercept } from "@/lib/inspector/captureState";
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const parsed = InspectorTlsInterceptToggleSchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
setTlsIntercept(parsed.data.enabled);
return Response.json({ ok: true, tlsIntercept: { enabled: isTlsInterceptEnabled() } });
}

View File

@@ -0,0 +1,62 @@
/**
* GET /api/tools/traffic-inspector/export.har
*
* Exports the entire (optionally filtered) traffic buffer as a HAR v1.2 file.
* The Content-Disposition header triggers a browser download.
*
* Secrets are always masked in the export — see `toHar` implementation.
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { InspectorListQuerySchema } from "@/shared/schemas/inspector";
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
import { toHar } from "@/lib/inspector/harExport";
import type { ListFilters } from "@/mitm/inspector/types";
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const rawQuery: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
rawQuery[key] = value;
});
const parsed = InspectorListQuerySchema.safeParse(rawQuery);
if (!parsed.success) {
return new Response(
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid query")),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
const filters: ListFilters = {
profile: parsed.data.profile,
host: parsed.data.host,
agent: parsed.data.agent as ListFilters["agent"],
status: parsed.data.status,
source: parsed.data.source,
sessionId: parsed.data.sessionId,
};
try {
const requests = globalTrafficBuffer.list(filters);
const har = toHar(requests);
const json = JSON.stringify(har, null, 2);
return new Response(json, {
status: 200,
headers: {
"content-type": "application/json",
"content-disposition": 'attachment; filename="traffic.har"',
"cache-control": "no-store",
},
});
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "HAR export failed")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}

View File

@@ -0,0 +1,105 @@
/**
* DELETE /api/tools/traffic-inspector/hosts/[host] — remove a custom host + DNS cleanup
* PATCH /api/tools/traffic-inspector/hosts/[host] — toggle enabled flag
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { z } from "zod";
import { removeCustomHost, toggleCustomHost, listCustomHosts } from "@/lib/db/inspectorCustomHosts";
import { getCachedPassword } from "@/mitm/manager";
import { removeDNSEntries } from "@/mitm/dns/dnsConfig";
interface Params {
params: Promise<{ host: string }>;
}
const PatchBodySchema = z.object({
enabled: z.boolean(),
});
export async function DELETE(_request: Request, { params }: Params): Promise<Response> {
const { host } = await params;
const decodedHost = decodeURIComponent(host);
try {
removeCustomHost(decodedHost);
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to remove host")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
// DNS cleanup — only possible when the MITM proxy is running (password cached).
const sudoPassword = getCachedPassword();
if (sudoPassword) {
try {
await removeDNSEntries([decodedHost], sudoPassword);
} catch {
// DNS cleanup failure is non-fatal: DB record was removed.
// Return 204 with a header warning rather than failing.
return new Response(null, {
status: 204,
headers: {
"x-dns-warning": `DNS entry for ${decodedHost} could not be removed — restart the proxy or remove manually`,
},
});
}
} else {
return new Response(null, {
status: 204,
headers: {
"x-dns-warning":
"DNS routing requires the MITM proxy to be running with a cached sudo password",
},
});
}
return new Response(null, { status: 204 });
}
export async function PATCH(request: Request, { params }: Params): Promise<Response> {
const { host } = await params;
const decodedHost = decodeURIComponent(host);
let body: unknown;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const parsed = PatchBodySchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
try {
toggleCustomHost(decodedHost, parsed.data.enabled);
// Return updated record
const hosts = listCustomHosts();
const updated = hosts.find((h) => h.host === decodedHost);
if (!updated) {
return new Response(JSON.stringify(buildErrorBody(404, "Host not found")), {
status: 404,
headers: { "content-type": "application/json" },
});
}
return Response.json(updated);
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to toggle host")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}

View File

@@ -0,0 +1,86 @@
/**
* GET /api/tools/traffic-inspector/hosts — list custom host capture entries
* POST /api/tools/traffic-inspector/hosts — add a host (DB record + DNS propagation)
*
* The DB record enables the MITM proxy to SNI-certify the host on demand.
* When a cached sudo password is available (MITM proxy running), DNS /etc/hosts
* entries are also added so OS traffic is redirected to the local proxy.
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { InspectorCustomHostSchema } from "@/shared/schemas/inspector";
import { listCustomHosts, addCustomHost } from "@/lib/db/inspectorCustomHosts";
import { getCachedPassword } from "@/mitm/manager";
import { addDNSEntries } from "@/mitm/dns/dnsConfig";
export async function GET(): Promise<Response> {
try {
const hosts = listCustomHosts();
return Response.json({ hosts });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to list hosts")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const parsed = InspectorCustomHostSchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
const { host, kind, label } = parsed.data;
try {
addCustomHost(host, kind, label ?? undefined);
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to add host")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
// DNS propagation — only possible when the MITM proxy is running (password cached).
const sudoPassword = getCachedPassword();
if (sudoPassword) {
try {
await addDNSEntries([host], sudoPassword);
} catch (err) {
// DNS failure is non-fatal: DB record was saved; warn but do not fail the request.
const msg = sanitizeErrorMessage(err);
return Response.json(
{ ok: true, host, warning: `DNS routing entry could not be added: ${msg}` },
{ status: 201 }
);
}
return Response.json({ ok: true, host }, { status: 201 });
}
return Response.json(
{
ok: true,
host,
warning: "DNS routing requires the MITM proxy to be running with a cached sudo password",
},
{ status: 201 }
);
}

View File

@@ -0,0 +1,127 @@
/**
* POST /api/tools/traffic-inspector/internal/ingest
*
* Internal endpoint consumed by `server.cjs` (D4 fallback) to push
* intercepted request data into the traffic buffer when the request does
* NOT pass through a TypeScript handler that already calls
* `agentBridgeHook.ts`.
*
* Security model (double LOCAL_ONLY):
* 1. `isLocalOnlyPath("/api/tools/traffic-inspector/")` blocks all non-
* loopback callers unconditionally — this is handled by the authz pipeline.
* 2. The shared secret `INSPECTOR_INTERNAL_INGEST_TOKEN` (set in .env or
* auto-generated at process boot) must match the `Authorization: Bearer`
* header. This prevents any other loopback process from stuffing the buffer.
*
* Body: partial `InterceptedRequest` — only `id`, `timestamp`, `method`,
* `host`, `path` are required; all other fields default.
*
* LOCAL_ONLY enforced by routeGuard + token gate below.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { createHash, timingSafeEqual } from "node:crypto";
import { randomUUID } from "node:crypto";
import { InterceptedRequestSchema } from "@/mitm/inspector/types";
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
// ── Token management ────────────────────────────────────────────────────────
let _cachedToken: string | null = null;
function getIngestToken(): string {
if (_cachedToken) return _cachedToken;
const env = process.env.INSPECTOR_INTERNAL_INGEST_TOKEN;
if (env && env.length >= 16) {
_cachedToken = env;
} else {
// Auto-generate on first call; persists for the lifetime of the process.
_cachedToken = randomUUID().replace(/-/g, "");
}
return _cachedToken;
}
function tokenMatches(received: string): boolean {
const expected = getIngestToken();
if (!received || !expected) return false;
try {
const a = createHash("sha256").update(expected).digest();
const b = createHash("sha256").update(received).digest();
return timingSafeEqual(a, b);
} catch {
return false;
}
}
// ── Partial schema (only required fields; rest optional) ───────────────────
const IngestBodySchema = InterceptedRequestSchema.partial().required({
id: true,
timestamp: true,
method: true,
host: true,
path: true,
source: true,
requestHeaders: true,
requestSize: true,
responseHeaders: true,
responseSize: true,
status: true,
});
// ── Handler ─────────────────────────────────────────────────────────────────
export async function POST(request: Request): Promise<Response> {
// Token gate (second layer after LOCAL_ONLY IP check).
const authHeader = request.headers.get("authorization") ?? "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
if (!tokenMatches(token)) {
return new Response(JSON.stringify(buildErrorBody(403, "Invalid or missing ingest token")), {
status: 403,
headers: { "content-type": "application/json" },
});
}
let body: unknown;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const parsed = IngestBodySchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
try {
// Fill in any missing optional fields with sensible defaults.
const req = {
requestBody: null,
responseBody: null,
...parsed.data,
};
globalTrafficBuffer.push(req);
return Response.json({ ok: true, id: req.id }, { status: 200 });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Ingest failed")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}
/**
* Expose the auto-generated token for use by `server.cjs` bootstrap.
* Called once at process start via dynamic import.
*/
export function getIngestTokenForBootstrap(): string {
return getIngestToken();
}

View File

@@ -0,0 +1,60 @@
/**
* PUT /api/tools/traffic-inspector/requests/[id]/annotation
*
* Attaches or replaces a free-text annotation on a buffered entry.
* Mutations are broadcast to all WS subscribers via `buffer.update`.
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { InspectorAnnotationPutSchema } from "@/shared/schemas/inspector";
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
interface Params {
params: Promise<{ id: string }>;
}
export async function PUT(request: Request, { params }: Params): Promise<Response> {
const { id } = await params;
let body: unknown;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const parsed = InspectorAnnotationPutSchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify(
buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")
),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
const entry = globalTrafficBuffer.get(id);
if (!entry) {
return new Response(JSON.stringify(buildErrorBody(404, "Request not found")), {
status: 404,
headers: { "content-type": "application/json" },
});
}
try {
const updated = { ...entry, annotation: parsed.data.annotation };
globalTrafficBuffer.update(id, updated);
return Response.json(updated);
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to update annotation")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}

View File

@@ -0,0 +1,61 @@
/**
* POST /api/tools/traffic-inspector/requests/[id]/replay
*
* Re-issues the captured request through the local OmniRoute instance and
* returns the response body. The replay will itself appear in the traffic
* buffer (captured by agentBridgeHook or httpProxyServer depending on path).
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
interface Params {
params: Promise<{ id: string }>;
}
const OMNIROUTE_BASE = process.env.OMNIROUTE_BASE_URL ?? "http://127.0.0.1:20128";
export async function POST(_request: Request, { params }: Params): Promise<Response> {
const { id } = await params;
const entry = globalTrafficBuffer.get(id);
if (!entry) {
return new Response(JSON.stringify(buildErrorBody(404, "Request not found")), {
status: 404,
headers: { "content-type": "application/json" },
});
}
const url = `${OMNIROUTE_BASE}${entry.path}`;
const replayHeaders: Record<string, string> = {
"content-type": "application/json",
"x-omniroute-source": "inspector-replay",
};
// Forward original Authorization if present (masked in buffer — skip if masked)
const origAuth = entry.requestHeaders["authorization"] ?? entry.requestHeaders["Authorization"];
if (origAuth && !origAuth.includes("***")) {
replayHeaders["authorization"] = origAuth;
}
try {
const upstream = await fetch(url, {
method: entry.method,
headers: replayHeaders,
body: entry.requestBody ?? undefined,
});
const body = await upstream.text();
return new Response(body, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(502, msg || "Replay failed")), {
status: 502,
headers: { "content-type": "application/json" },
});
}
}

View File

@@ -0,0 +1,24 @@
/**
* GET /api/tools/traffic-inspector/requests/[id] — fetch a single intercepted request
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
interface Params {
params: Promise<{ id: string }>;
}
export async function GET(_request: Request, { params }: Params): Promise<Response> {
const { id } = await params;
const entry = globalTrafficBuffer.get(id);
if (!entry) {
return new Response(JSON.stringify(buildErrorBody(404, "Request not found")), {
status: 404,
headers: { "content-type": "application/json" },
});
}
return Response.json(entry);
}

View File

@@ -0,0 +1,44 @@
/**
* GET /api/tools/traffic-inspector/requests — list buffer with optional filters
* DELETE /api/tools/traffic-inspector/requests — clear the entire buffer
*
* LOCAL_ONLY enforced by routeGuard (no extra check needed here).
*/
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
import { InspectorListQuerySchema } from "@/shared/schemas/inspector";
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
import type { ListFilters } from "@/mitm/inspector/types";
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const rawQuery: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
rawQuery[key] = value;
});
const parsed = InspectorListQuerySchema.safeParse(rawQuery);
if (!parsed.success) {
return new Response(
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid query")),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
const filters: ListFilters = {
profile: parsed.data.profile,
host: parsed.data.host,
agent: parsed.data.agent as ListFilters["agent"],
status: parsed.data.status,
source: parsed.data.source,
sessionId: parsed.data.sessionId,
};
const requests = globalTrafficBuffer.list(filters);
return Response.json({ requests, total: requests.length });
}
export async function DELETE(): Promise<Response> {
globalTrafficBuffer.clear();
return new Response(null, { status: 204 });
}

View File

@@ -0,0 +1,61 @@
/**
* GET /api/tools/traffic-inspector/sessions/[id]/export.har
*
* Export all requests of a specific session as HAR v1.2.
* Secrets are always masked — see `toHar`.
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { getSession, getSessionRequests } from "@/lib/db/inspectorSessions";
import { toHar } from "@/lib/inspector/harExport";
import type { InterceptedRequest } from "@/mitm/inspector/types";
interface Params {
params: Promise<{ id: string }>;
}
export async function GET(_request: Request, { params }: Params): Promise<Response> {
const { id } = await params;
const session = getSession(id);
if (!session) {
return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), {
status: 404,
headers: { "content-type": "application/json" },
});
}
try {
const rows = getSessionRequests(id);
const requests: InterceptedRequest[] = rows
.map((r) => {
try {
return JSON.parse(r.payload) as InterceptedRequest;
} catch {
return null;
}
})
.filter((r): r is InterceptedRequest => r !== null);
const har = toHar(requests);
const sessionName = (session.name ?? `session-${id}`).replace(/[^a-z0-9_-]/gi, "_");
const filename = `${sessionName}.har`;
return new Response(JSON.stringify(har, null, 2), {
status: 200,
headers: {
"content-type": "application/json",
"content-disposition": `attachment; filename="${filename}"`,
"cache-control": "no-store",
},
});
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "HAR export failed")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}

View File

@@ -0,0 +1,62 @@
/**
* POST /api/tools/traffic-inspector/sessions/[id]/requests — persist a live snapshot entry
*
* Accepts a JSON-encoded InterceptedRequest payload and appends it to the
* session request log, atomically incrementing request_count. Returns the
* assigned seq number so the caller can confirm persistence order.
*
* LOCAL_ONLY enforced by routeGuard at the /api/tools/ prefix level.
*
* Part of R5-5 (backend half): the frontend hook (F3 / useSessionRecorder.ts)
* is the corresponding caller that POSTs snapshots here on stop().
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { InspectorSessionRequestAppendSchema } from "@/shared/schemas/inspector";
import { getSession, appendSessionRequest } from "@/lib/db/inspectorSessions";
interface Params {
params: Promise<{ id: string }>;
}
export async function POST(request: Request, { params }: Params): Promise<Response> {
const { id } = await params;
// Verify session exists before attempting to append
const session = getSession(id);
if (!session) {
return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), {
status: 404,
headers: { "content-type": "application/json" },
});
}
let body: unknown;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const parsed = InspectorSessionRequestAppendSchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
try {
const seq = appendSessionRequest(id, parsed.data.payload);
return Response.json({ seq }, { status: 201 });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(
JSON.stringify(buildErrorBody(500, msg || "Failed to append session request")),
{ status: 500, headers: { "content-type": "application/json" } }
);
}
}

View File

@@ -0,0 +1,123 @@
/**
* GET /api/tools/traffic-inspector/sessions/[id] — session detail + requests
* PATCH /api/tools/traffic-inspector/sessions/[id] — stop or rename
* DELETE /api/tools/traffic-inspector/sessions/[id] — delete + cascade requests
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { InspectorSessionPatchSchema } from "@/shared/schemas/inspector";
import {
getSession,
getSessionRequests,
stopSession,
renameSession,
deleteSession,
} from "@/lib/db/inspectorSessions";
interface Params {
params: Promise<{ id: string }>;
}
export async function GET(_request: Request, { params }: Params): Promise<Response> {
const { id } = await params;
try {
const session = getSession(id);
if (!session) {
return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), {
status: 404,
headers: { "content-type": "application/json" },
});
}
const requests = getSessionRequests(id).map((r) => {
try {
return JSON.parse(r.payload) as unknown;
} catch {
return r.payload;
}
});
return Response.json({ session, requests });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to get session")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}
export async function PATCH(request: Request, { params }: Params): Promise<Response> {
const { id } = await params;
let body: unknown;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const parsed = InspectorSessionPatchSchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
const session = getSession(id);
if (!session) {
return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), {
status: 404,
headers: { "content-type": "application/json" },
});
}
try {
if (parsed.data.action === "stop") {
stopSession(id);
} else if (parsed.data.action === "rename") {
if (!parsed.data.name) {
return new Response(
JSON.stringify(buildErrorBody(400, "name is required for rename action")),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
renameSession(id, parsed.data.name);
}
return Response.json(getSession(id));
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to update session")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}
export async function DELETE(_request: Request, { params }: Params): Promise<Response> {
const { id } = await params;
const session = getSession(id);
if (!session) {
return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), {
status: 404,
headers: { "content-type": "application/json" },
});
}
try {
deleteSession(id);
return new Response(null, { status: 204 });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to delete session")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}

View File

@@ -0,0 +1,52 @@
/**
* GET /api/tools/traffic-inspector/sessions — list all sessions
* POST /api/tools/traffic-inspector/sessions — start a new recording session
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { InspectorSessionStartSchema } from "@/shared/schemas/inspector";
import { listSessions, createSession } from "@/lib/db/inspectorSessions";
export async function GET(): Promise<Response> {
try {
const sessions = listSessions();
return Response.json({ sessions });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to list sessions")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
// Empty body is valid — name is optional
body = {};
}
const parsed = InspectorSessionStartSchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
try {
const session = createSession({ name: parsed.data.name });
return Response.json(session, { status: 201 });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to create session")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}

Some files were not shown because too many files have changed in this diff Show More