diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index 3c1b5de6..95fb5eaa 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -1,10 +1,8 @@ name: gitleaks -# Block PRs that introduce hardcoded credentials. Runs the upstream gitleaks -# action against the diff (PR) or full history (push to main). False -# positives in the working tree are caught by the gitleaks-action's own -# allowlist mechanism — extend .gitleaks.toml at repo root rather than -# editing this workflow. +# Block PRs that introduce hardcoded credentials. Runs the gitleaks CLI +# directly (rather than gitleaks-action) because the action requires a +# paid license on GitHub Orgs. Same scanner, same rules, same .gitleaks.toml. on: pull_request: @@ -14,22 +12,54 @@ on: permissions: contents: read - pull-requests: read jobs: scan: runs-on: ubuntu-latest + env: + GITLEAKS_VERSION: '8.21.2' steps: - name: Checkout uses: actions/checkout@v4 with: - # Full history needed so gitleaks can scan all new commits in a PR. - fetch-depth: 0 + fetch-depth: 0 # Full history so PR-diff scanning works. - - name: Run gitleaks - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Treat any high-confidence finding as a hard fail. - GITLEAKS_ENABLE_UPLOAD_ARTIFACT: 'true' - GITLEAKS_ENABLE_SUMMARY: 'true' + - name: Install gitleaks + run: | + set -euo pipefail + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xz -C /tmp gitleaks + sudo install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks + gitleaks version + + - name: Run gitleaks (PR diff) + if: github.event_name == 'pull_request' + run: | + gitleaks detect \ + --source . \ + --redact \ + --verbose \ + --no-banner \ + --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" + + - name: Run gitleaks (push) + if: github.event_name == 'push' + run: | + set -euo pipefail + BEFORE="${{ github.event.before }}" + AFTER="${{ github.sha }}" + # New-branch push: GH sends 40 zeros for `before`. Diff against + # main's merge-base instead so we only scan commits unique to the + # branch — fast and matches the gitleaks-action default. + if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then + git fetch --no-tags --depth=1 origin main:refs/remotes/origin/main 2>/dev/null || true + if git rev-parse --verify origin/main >/dev/null 2>&1; then + BEFORE=$(git merge-base origin/main "$AFTER" 2>/dev/null || echo "") + fi + fi + if [ -n "$BEFORE" ] && [ "$BEFORE" != "$AFTER" ]; then + gitleaks detect --source . --redact --verbose --no-banner --log-opts="${BEFORE}..${AFTER}" + else + # Couldn't establish a range — full scan as fallback. + gitleaks detect --source . --redact --verbose --no-banner + fi diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index a4e13927..fda0e3fd 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -692,55 +692,46 @@ class AgentManager: if not active_lines and not available_lines: return None + # Static preamble first (kept byte-identical across users so it caches), + # then the per-session server list. Worked-example uses generic + # placeholders so a Pro Anthropic prompt-cache hit isn't broken by + # one user's connector names differing from another's. sections = [""] sections.append( - "MCP servers are gated: the model cannot call any MCP tool until " - "the user approves an MCPActivate request for that server. To use " - "a server below, first call MCPSearch (to confirm the right server " - "for the task), then call MCPActivate(server_name) — the user will " - "be prompted to approve activation. After approval, the server's " - "tools (`mcp____`) become callable on the next turn." + "MCP servers are gated: their tools are uncallable until the user " + "approves an MCPActivate request. To use one below, call MCPSearch " + "(if unsure which) then MCPActivate(server_name); after approval the " + "server's tools (`mcp____`) become callable next turn." ) sections.append("") - sections.append("## CRITICAL behavioral rules (follow these exactly)") + sections.append("## Rules") sections.append( - "1. If the user's request implies an integration listed below " - "(email, calendar, slack, notion, etc.) and that server is NOT " - "in the Active section, your FIRST tool call MUST be MCPSearch " - "or MCPActivate. Do NOT call any other tool that looks like an " - "auth/login helper (e.g. `mcp__*__authenticate`, " - "`mcp__claude_ai_*__authenticate`) — those are legacy shims " - "and will not work. Always go through MCPActivate." + "1. If the user's request needs a server below that isn't Active, " + "your FIRST tool call must be MCPSearch or MCPActivate. Ignore any " + "`mcp__*__authenticate` helpers — those are legacy shims; always go " + "through MCPActivate." ) sections.append( - "2. After MCPActivate returns, end your turn cleanly. The " - "system will automatically run a follow-up turn with the " - "newly-activated tools available — you do NOT need the user " - "to re-prompt. Just stop and let the next turn fire." + "2. After MCPActivate returns, end the turn — a follow-up turn fires " + "automatically with the new tools available." ) sections.append( - "3. Do not ask the user 'should I activate X?' before calling " - "MCPActivate — MCPActivate already triggers an explicit user " - "approval prompt via the standard tool-approval UI. Asking " - "again wastes a round-trip." + "3. Don't ask 'should I activate X?' first — MCPActivate already " + "triggers an approval prompt." ) sections.append("") - sections.append("## Worked example") + sections.append("## Example") sections.append( - "User: \"check my email\"\n" - "Active MCPs: (none)\n" - "Available MCPs: google-workspace, microsoft-365\n" - "→ Your first tool call is `MCPActivate(server_name=\"google-workspace\", reason=\"checking inbox\")`.\n" - " After it returns, end your turn. Next turn, call " - "`mcp__google-workspace__query_gmail_emails(...)` to actually " - "fetch the email." + "User asks for email; no email server is Active. First tool call: " + "`MCPActivate(server_name=\"\", reason=\"...\")`. End " + "turn. Next turn: call the activated server's email tool." ) sections.append("") if active_lines: - sections.append("Active (already approved this session — tools callable now):") + sections.append("Active (callable now):") sections.extend(active_lines) if available_lines: - sections.append("\nAvailable (installed but not yet activated):") + sections.append("\nAvailable (not yet activated):") sections.extend(available_lines) sections.append("") return "\n".join(sections) @@ -2045,6 +2036,13 @@ class AgentManager: try: level = getattr(session, "thinking_level", "auto") or "auto" + # Trivially short prompts ("hi", "thanks") don't benefit from + # 5-30s of hidden reasoning. Override per-turn only — session + # setting is untouched so the UI pill keeps reflecting the + # user's choice. + _prompt_len = len((prompt or "").strip()) + if 0 < _prompt_len < 50 and level != "off": + level = "off" # gc/gemini-3* without Antigravity 400s every multi-step turn # on thoughtSignature continuity. Force-disable thinking. if ( diff --git a/backend/apps/agents/mcp_meta_server.py b/backend/apps/agents/mcp_meta_server.py index bdf9ef09..887388b4 100644 --- a/backend/apps/agents/mcp_meta_server.py +++ b/backend/apps/agents/mcp_meta_server.py @@ -35,11 +35,9 @@ TOOLS = [ { "name": "MCPList", "description": ( - "List the MCP servers installed on this machine. Returns one entry " - "per server with its name, one-sentence purpose, and current " - "activation status (active/available). Costs almost nothing — the " - "registry is a flat list, no schemas. Use this when you want a " - "broad survey before picking a server." + "List installed MCP servers (name, one-sentence purpose, " + "active/available status). Cheap. Use for a broad survey before " + "picking a server." ), "inputSchema": { "type": "object", @@ -50,18 +48,16 @@ TOOLS = [ { "name": "MCPSearch", "description": ( - "Find MCP servers relevant to a query. Returns the top matches " - "ranked by description match against the query (e.g. 'email', " - "'calendar', 'spreadsheet'). Use this BEFORE MCPActivate when you " - "are not sure which server to enable. The server's tools are NOT " - "callable yet — you still need MCPActivate after picking one." + "Rank MCP servers by relevance to a query. Use before MCPActivate " + "when unsure which server fits. Tools are NOT callable until you " + "also MCPActivate." ), "inputSchema": { "type": "object", "properties": { "query": { "type": "string", - "description": "Free-form description of what you need (e.g. 'send email', 'read inbox', 'post to slack').", + "description": "What you need (e.g. 'send email', 'post to slack').", }, }, "required": ["query"], @@ -71,24 +67,21 @@ TOOLS = [ { "name": "MCPActivate", "description": ( - "Request activation of an MCP server for this session. The user is " - "prompted via the standard tool-approval UI; on approve, the " - "server's tools become callable on the NEXT turn (the current turn " - "ends after this call). On deny, the server stays unavailable and " - "you should ask the user how to proceed. Always call MCPSearch or " - "MCPList first to confirm the server name — invalid names return " - "a list of valid alternatives instead of activating." + "Request activation of an MCP server for this session. Triggers a " + "user approval prompt; on approve the server's tools become callable " + "next turn. Always confirm the server name via MCPList/MCPSearch first — " + "invalid names return alternatives instead of activating." ), "inputSchema": { "type": "object", "properties": { "server_name": { "type": "string", - "description": "Sanitized server name as returned by MCPList/MCPSearch (e.g. 'gmail', 'slack', 'discord').", + "description": "Sanitized name from MCPList/MCPSearch (e.g. 'gmail', 'slack').", }, "reason": { "type": "string", - "description": "One-sentence explanation of why you need this server, shown to the user in the approval prompt to help them decide.", + "description": "Why you need it — shown to the user in the approval prompt.", }, }, "required": ["server_name"], diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index 57d25352..a52e6218 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -494,6 +494,24 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, return out; }, [modelSearch, allModelOptions.grouped, capFilters, ctxIdx, costIdx]); + // Footer summary — counts reflect what the user actually has access to + // right now (post-filter, post-credentials). + const pickerSummary = useMemo(() => { + let total = 0, free = 0, reasoning = 0, subscription = 0, apiKey = 0, paid = 0, longContext = 0; + for (const ms of Object.values(filteredModelGroups)) { + for (const m of ms as any[]) { + total += 1; + if (m.reasoning) reasoning += 1; + if ((m.context_window ?? 0) >= 1_000_000) longContext += 1; + if (m.billing_kind === 'free') free += 1; + else if (m.billing_kind === 'subscription') subscription += 1; + else if (m.billing_kind === 'api_key') apiKey += 1; + else if (m.billing_kind === 'paid') paid += 1; + } + } + return { total, free, reasoning, subscription, apiKey, paid, longContext }; + }, [filteredModelGroups]); + // Recents materialised against current catalog so removed models drop out. const recentMaterialised = useMemo(() => { const flatByValue = new Map(allModelOptions.flat.map((m) => [m.value, m])); @@ -2009,11 +2027,64 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, px: 1.25, py: 0.5, fontSize: '0.65rem', color: c.text.ghost, display: 'flex', justifyContent: 'space-between', - pointerEvents: 'none', + gap: 1, }} > - Type to search · Esc to close - {Object.values(filteredModelGroups).reduce((sum, ms) => sum + (ms as any[]).length, 0)} models + + Type to search · Esc to close + + {(() => { + const breakdown: Array<[string, number]> = ([ + ['Free', pickerSummary.free], + ['Subscription', pickerSummary.subscription], + ['API key', pickerSummary.apiKey], + ['Pay-per-use', pickerSummary.paid], + ['Reasoning', pickerSummary.reasoning], + ['1M+ context', pickerSummary.longContext], + ] as Array<[string, number]>).filter(([, n]) => n > 0); + const breakdownTooltip = breakdown.length > 0 ? ( + + + {pickerSummary.total} model{pickerSummary.total === 1 ? '' : 's'} available + + + {breakdown.map(([label, n]) => ( + + {label} + {n} + + ))} + + + ) : null; + return ( + + + {pickerSummary.total} model{pickerSummary.total === 1 ? '' : 's'} + + + ); + })()}