Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
| **Semantic convergence** | char → Jaccard → embedding cosine cascade for Self-Refine, with LRU/TTL cache and quality regression detection. |
| **Production-ready** | gRPC + TLS 1.3, JWT + RBAC, AES-256-GCM, rate limiting, audit logging, 50+ Prometheus metrics. |
| **Kubernetes-native** | Operator with 17 CRDs and an autonomous AIOps pipeline (54+ remediation actions), SLO monitoring, post-mortems. |
| **Extensible** | Plugins with Ed25519 signature verification, multi-registry skills (skills.sh, ClawHub, ChatCLI.dev), lifecycle hooks, MCP client (stdio + SSE). |
| **Extensible** | Plugins with Ed25519 signature verification, multi-registry skills (skills.sh, ClawHub, ChatCLI.dev), lifecycle hooks, MCP client (stdio, SSE, HTTP + OAuth). |

---

Expand Down Expand Up @@ -528,7 +528,7 @@ Credentials are stored with **AES-256-GCM** at `~/.chatcli/auth-profiles.json`.
| Feature | Description |
|---|---|
| **Native tool calling** | Native APIs from OpenAI, Anthropic, Bedrock, Google, ZAI, MiniMax, Moonshot, OpenRouter. `ephemeral` cache for Anthropic. Automatic XML fallback for providers without native support. |
| **MCP (Model Context Protocol)** | Client via stdio and SSE for expanded context. Server (`chatcli mcp-server`) exposes the FULL surface: every built-in tool with read-only annotations, the agent/coder loops with per-call provider/model routing and quality-harness toggles, provider discovery, and all installed skills served as MCP prompts. Exposure policy via `CHATCLI_MCP_TOOLS` (all/safe/allowlist). ACP server (`chatcli acp`) with chat/agent/coder session modes, live streaming and cancellation, for editors (Zed) and agent-to-agent use. |
| **MCP (Model Context Protocol)** | Client via stdio, SSE, and streamable HTTP for expanded context. Remote (HTTP/SSE) servers gated behind OAuth 2.1 are supported end-to-end: on a `401` the client discovers the authorization server (RFC 9728/8414), registers dynamically (RFC 7591), runs a PKCE browser flow, and refreshes tokens transparently (stored AES-256-GCM in the encrypted auth store). Authorize with `/mcp login <server>`, or let the agent call the `@mcp-login` tool when a call reports "authorization required". Server (`chatcli mcp-server`) exposes the FULL surface: every built-in tool with read-only annotations, the agent/coder loops with per-call provider/model routing and quality-harness toggles, provider discovery, and all installed skills served as MCP prompts. Exposure policy via `CHATCLI_MCP_TOOLS` (all/safe/allowlist). ACP server (`chatcli acp`) with chat/agent/coder session modes, live streaming and cancellation, for editors (Zed) and agent-to-agent use. |
| **Chat Gateway** | Runs as a messaging daemon (Telegram, Slack, Discord, WhatsApp, webhook): each message runs through the agent loop and progress is streamed back to the chat. Voice messages are transcribed (local-first whisper) and answered in voice by default (`CHATCLI_GATEWAY_VOICE_REPLY=auto\|always\|never`); each conversation controls it by asking in natural language ("answer me in audio" / "stop sending audio") via the `@voice` tool, with the preference persisted. |
| **Embedded voice (TTS)** | `CHATCLI_TTS_PROVIDER=embedded` — offline Kokoro neural voice, no API key and no cgo: downloads the sherpa-onnx engine + model once (~150MB) and works the same on Linux/macOS/Windows. Routes pt-BR/English by reply language (`CHATCLI_TTS_VOICE=bm_george`, `CHATCLI_TTS_VOICE_PT=pm_alex`); the other backends (say/espeak, self-hosted, OpenAI/Groq/Gemini) remain available. |
| **Embedded transcription (STT)** | Offline multilingual Whisper via sherpa-onnx, no API key and no cgo — and the automatic fallback: with nothing configured, the gateway downloads the engine + an ONNX model once (~200MB for `base`; `CHATCLI_TRANSCRIPTION_MODEL=tiny\|base\|small\|…`) at startup and transcribes voice notes auto-detecting the spoken language. OGG/Opus voice notes (Telegram/WhatsApp) decode in pure Go — no ffmpeg needed; only residual formats (mp3/m4a) require ffmpeg, and the gateway preflight + `/gateway status` warn with your platform's install command. `CHATCLI_TRANSCRIPTION_PROVIDER=embedded` forces it over the other backends (local whisper CLI, self-hosted, Groq/OpenAI), which remain available. |
Expand Down Expand Up @@ -565,7 +565,7 @@ chatcli/
lessonq/ Reflexion durable queue (WAL + worker pool + DLQ)
workers/ 14 agents + dispatcher + FileLockManager
hooks/ Lifecycle events (shell/webhook)
mcp/ MCP client (stdio + SSE)
mcp/ MCP client (stdio, SSE, HTTP + OAuth)
plugins/ Plugin manager + signature verification
scheduler/ Chronos — durable scheduler (WAL + cron + DAG + daemon)
condition/ 10 evaluators (shell, http, k8s, docker, tcp, llm, ...)
Expand Down
6 changes: 3 additions & 3 deletions README_PT.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
| **Convergência semântica** | Cascade char → Jaccard → embedding cosine para Self-Refine, com cache LRU/TTL e quality regression detection. |
| **Production-ready** | gRPC + TLS 1.3, JWT + RBAC, AES-256-GCM, rate limiting, audit logging, 50+ métricas Prometheus. |
| **Kubernetes-native** | Operador com 17 CRDs e pipeline AIOps autônomo (54+ ações de remediação), SLO monitoring, post-mortems. |
| **Extensível** | Plugins com verificação Ed25519, skills multi-registry (skills.sh, ClawHub, ChatCLI.dev), hooks de lifecycle, MCP client (stdio + SSE). |
| **Extensível** | Plugins com verificação Ed25519, skills multi-registry (skills.sh, ClawHub, ChatCLI.dev), hooks de lifecycle, MCP client (stdio, SSE, HTTP + OAuth). |

---

Expand Down Expand Up @@ -527,7 +527,7 @@ Credenciais armazenadas com **AES-256-GCM** em `~/.chatcli/auth-profiles.json`.
| Feature | Descrição |
|---|---|
| **Tool calling nativo** | APIs nativas de OpenAI, Anthropic, Bedrock, Google, ZAI, MiniMax, Moonshot, OpenRouter. Cache `ephemeral` para Anthropic. XML fallback automático para providers sem suporte nativo. |
| **MCP (Model Context Protocol)** | Client via stdio e SSE para contexto expandido. Server (`chatcli mcp-server`) expõe chat, agent, coder e built-in tools; modo ACP (`chatcli acp`) para editores. |
| **MCP (Model Context Protocol)** | Client via stdio, SSE e streamable HTTP para contexto expandido. Servidores remotos (HTTP/SSE) protegidos por OAuth 2.1 têm suporte fim-a-fim: num `401` o client descobre o authorization server (RFC 9728/8414), registra-se dinamicamente (RFC 7591), roda o fluxo PKCE no navegador e renova tokens de forma transparente (guardados com AES-256-GCM no cofre de auth criptografado). Autorize com `/mcp login <servidor>`, ou deixe o agente chamar a tool `@mcp-login` quando uma chamada reportar "autorização necessária". Server (`chatcli mcp-server`) expõe chat, agent, coder e built-in tools; modo ACP (`chatcli acp`) para editores. |
| **Chat Gateway** | Roda como daemon de mensageria (Telegram, Slack, Discord, WhatsApp, webhook): cada mensagem passa pelo agent loop e o progresso é transmitido de volta ao chat. Mensagens de voz são transcritas (whisper local-first) e respondidas em voz por padrão (`CHATCLI_GATEWAY_VOICE_REPLY=auto\|always\|never`); cada conversa controla isso pedindo em linguagem natural ("responde em áudio" / "para de mandar áudio") via tool `@voice`, com preferência persistida. |
| **Voz embarcada (TTS)** | `CHATCLI_TTS_PROVIDER=embedded` — voz neural Kokoro offline, sem API key e sem cgo: baixa o engine sherpa-onnx + modelo uma única vez (~150MB) e funciona igual em Linux/macOS/Windows. Roteia pt-BR/inglês por idioma da resposta (`CHATCLI_TTS_VOICE=bm_george`, `CHATCLI_TTS_VOICE_PT=pm_alex`); demais backends (say/espeak, self-hosted, OpenAI/Groq/Gemini) seguem disponíveis. |
| **Transcrição embarcada (STT)** | Whisper multilíngue offline via sherpa-onnx, sem API key e sem cgo — é o fallback automático: sem nada configurado, o gateway baixa o engine + modelo ONNX uma única vez (~200MB no `base`; `CHATCLI_TRANSCRIPTION_MODEL=tiny\|base\|small\|…`) já no startup e transcreve notas de voz detectando o idioma falado. Voice notes OGG/Opus (Telegram/WhatsApp) são decodificadas em puro Go — sem ffmpeg; só formatos residuais (mp3/m4a) pedem ffmpeg, e o preflight do gateway + `/gateway status` avisam com o comando de instalação da sua plataforma. `CHATCLI_TRANSCRIPTION_PROVIDER=embedded` força-o sobre outros backends (whisper CLI local, self-hosted, Groq/OpenAI), que seguem disponíveis. |
Expand Down Expand Up @@ -564,7 +564,7 @@ chatcli/
lessonq/ Reflexion durable queue (WAL + worker pool + DLQ)
workers/ 14 agentes + dispatcher + FileLockManager
hooks/ Lifecycle events (shell/webhook)
mcp/ MCP client (stdio + SSE)
mcp/ MCP client (stdio, SSE, HTTP + OAuth)
plugins/ Plugin manager + signature verification
scheduler/ Chronos — scheduler durável (WAL + cron + DAG + daemon)
condition/ 10 evaluators (shell, http, k8s, docker, tcp, llm, ...)
Expand Down
119 changes: 119 additions & 0 deletions auth/reuse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* ChatCLI - Command Line Interface for LLM interaction
* Copyright (c) 2024 Edilson Freitas
* License: Apache-2.0
*/
package auth

import (
"context"
"net/http"
"net/url"
"strings"
"time"

"github.com/diillson/chatcli/utils"
"go.uber.org/zap"
)

// This file exposes a minimal, stable surface over otherwise-internal OAuth
// primitives so subsystems outside the provider logins (notably MCP OAuth,
// whose authorization/token endpoints are DISCOVERED at runtime rather than
// hard-coded per provider) can reuse the exact same, already-hardened
// building blocks instead of re-implementing them. Names are deliberately
// distinct from the internal helpers they wrap (not just case variants).

// browserLauncher is the function OpenInBrowser delegates to. It is a package
// variable (defaulting to the real cross-platform opener) purely so tests can
// substitute a stub and avoid actually launching a browser.
var browserLauncher = openBrowser

// OpenInBrowser opens the user's default browser at rawURL using the same
// cross-platform launch logic the provider login flows use. Returns an error
// when no browser could be launched so the caller can fall back to printing
// the URL.
func OpenInBrowser(rawURL string) error {
return browserLauncher(rawURL)
}

// TokenExchangeRequest holds the form parameters for an OAuth token exchange.
// It is a concrete type (rather than a free-form map) so the exported surface
// stays type-safe; empty fields are omitted from the request body.
type TokenExchangeRequest struct {
GrantType string
ClientID string
Code string
CodeVerifier string
RedirectURI string
RefreshToken string
Resource string
Scope string
}

// pairs returns the non-empty (key, value) parameters in a stable order.
func (r TokenExchangeRequest) pairs() [][2]string {
all := [][2]string{
{"grant_type", r.GrantType},
{"client_id", r.ClientID},
{"code", r.Code},
{"code_verifier", r.CodeVerifier},
{"redirect_uri", r.RedirectURI},
{"refresh_token", r.RefreshToken},
{"resource", r.Resource},
{"scope", r.Scope},
}
out := make([][2]string, 0, len(all))
for _, kv := range all {
if kv[1] != "" {
out = append(out, kv)
}
}
return out
}

func (r TokenExchangeRequest) toPayload() map[string]any {
m := make(map[string]any, 8)
for _, kv := range r.pairs() {
m[kv[0]] = kv[1]
}
return m
}

func (r TokenExchangeRequest) toForm() url.Values {
form := url.Values{}
for _, kv := range r.pairs() {
form.Set(kv[0], kv[1])
}
return form
}

// ExchangeToken performs an OAuth token exchange with a JSON body against
// tokenURL and returns the parsed token response. Used by providers that
// accept a JSON token request (Anthropic/OpenAI-style).
func ExchangeToken(ctx context.Context, logger *zap.Logger, tokenURL string, req TokenExchangeRequest) (*OAuthTokenResponse, error) {
return exchangeOAuthToken(ctx, logger, tokenURL, req.toPayload())
}

// ExchangeTokenForm performs an OAuth token exchange with an
// application/x-www-form-urlencoded body — the encoding RFC 6749 §4.1.3
// mandates for the token endpoint and what standards-compliant authorization
// servers (e.g. AWS, Cognito, Keycloak) require. This is the correct exchange
// for MCP OAuth, whose authorization servers are generic OAuth 2.1 endpoints
// rather than the JSON-accepting LLM-provider endpoints.
func ExchangeTokenForm(ctx context.Context, logger *zap.Logger, tokenURL string, req TokenExchangeRequest) (*OAuthTokenResponse, error) {
hc := utils.NewHTTPClient(logger, 30*time.Second)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(req.toForm().Encode()))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
httpReq.Header.Set("Accept", "application/json")
return doTokenExchange(hc, httpReq)
}

// TokenExpiryMilli converts an OAuth expires_in (seconds) into an absolute
// epoch-millisecond expiry, applying the shared 5-minute safety margin so all
// credentials refresh consistently ahead of the real upstream expiry.
func TokenExpiryMilli(expiresIn int64) int64 {
return calcExpiresAtMilli(expiresIn)
}
159 changes: 159 additions & 0 deletions auth/reuse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/*
* ChatCLI - tests for the reusable OAuth surface
* Copyright (c) 2024 Edilson Freitas
* License: Apache-2.0
*/
package auth

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"go.uber.org/zap"
)

func TestOpenInBrowser(t *testing.T) {
old := browserLauncher
t.Cleanup(func() { browserLauncher = old })
var got string
browserLauncher = func(u string) error { got = u; return nil }
if err := OpenInBrowser("http://127.0.0.1/x"); err != nil {
t.Fatalf("OpenInBrowser: %v", err)
}
if got != "http://127.0.0.1/x" {
t.Fatalf("launcher got %q", got)
}
}

func TestTokenExpiryMilli(t *testing.T) {
if TokenExpiryMilli(3600) <= 0 {
t.Fatalf("expected positive expiry")
}
// Zero/negative expires_in falls back to a default future expiry.
if TokenExpiryMilli(0) <= 0 {
t.Fatalf("expected fallback expiry for 0")
}
}

func TestExchangeTokenOmitsEmptyFields(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["scope"]; ok {
t.Errorf("empty scope must be omitted, got %v", body["scope"])
}
if body["grant_type"] != "authorization_code" {
t.Errorf("grant_type = %v", body["grant_type"])
}
if body["code"] != "abc" {
t.Errorf("code = %v", body["code"])
}
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "at", "expires_in": 3600, "token_type": "Bearer",
})
}))
defer srv.Close()

tr, err := ExchangeToken(context.Background(), zap.NewNop(), srv.URL, TokenExchangeRequest{
GrantType: "authorization_code",
ClientID: "c1",
Code: "abc",
})
if err != nil {
t.Fatalf("ExchangeToken: %v", err)
}
if tr.AccessToken != "at" {
t.Fatalf("access token = %q", tr.AccessToken)
}
}

func TestExchangeTokenForm(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
t.Errorf("content-type = %q, want form-urlencoded", ct)
}
_ = r.ParseForm()
if r.PostForm.Get("grant_type") != "authorization_code" {
t.Errorf("grant_type = %q", r.PostForm.Get("grant_type"))
}
if r.PostForm.Get("code") != "abc" || r.PostForm.Get("resource") != "https://x/mcp" {
t.Errorf("missing form fields: %v", r.PostForm)
}
if _, ok := r.PostForm["scope"]; ok {
t.Errorf("empty scope must be omitted")
}
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "at", "expires_in": 3600})
}))
defer srv.Close()

tr, err := ExchangeTokenForm(context.Background(), zap.NewNop(), srv.URL, TokenExchangeRequest{
GrantType: "authorization_code",
ClientID: "c1",
Code: "abc",
RedirectURI: "http://127.0.0.1:8765/callback",
Resource: "https://x/mcp",
})
if err != nil {
t.Fatalf("ExchangeTokenForm: %v", err)
}
if tr.AccessToken != "at" {
t.Fatalf("access token = %q", tr.AccessToken)
}
}

// TestNewOAuthTokenProviderWithRefresh proves the custom refresh function is
// the one used (not the built-in provider switch), and that a Token() call on
// an expired credential triggers it.
func TestNewOAuthTokenProviderWithRefresh(t *testing.T) {
called := 0
refresh := func(_ context.Context, cred *AuthProfileCredential, _ *zap.Logger) (*AuthProfileCredential, error) {
called++
cred.Access = "refreshed"
cred.Expires = TokenExpiryMilli(3600)
return cred, nil
}
cred := &AuthProfileCredential{
CredType: CredentialOAuth,
Provider: ProviderMCP,
Access: "stale",
Refresh: "r1",
Expires: 1, // already expired → forces refresh on first Token()
}
p := NewOAuthTokenProviderWithRefresh(cred, "", "test", refresh, zap.NewNop())
defer p.Close()

tok, err := p.Token(context.Background())
if err != nil {
t.Fatalf("Token: %v", err)
}
if tok != "refreshed" || called == 0 {
t.Fatalf("custom refresh not used: tok=%q called=%d", tok, called)
}
if p.Mode() != AuthModeOAuth {
t.Fatalf("mode = %v", p.Mode())
}
if p.Provider() != ProviderMCP {
t.Fatalf("provider = %v", p.Provider())
}
}

// A nil refresh function must fall back to the built-in RefreshOAuth without
// panicking; a non-refreshable credential (no refresh token) just serves its
// current token.
func TestNewOAuthTokenProviderNilRefreshFallback(t *testing.T) {
cred := &AuthProfileCredential{
CredType: CredentialOAuth,
Provider: ProviderMCP,
Access: "tok",
// no Refresh / Expires → no background loop, no refresh attempted
}
p := NewOAuthTokenProviderWithRefresh(cred, "", "test", nil, zap.NewNop())
defer p.Close()
tok, err := p.Token(context.Background())
if err != nil || tok != "tok" {
t.Fatalf("Token = %q err=%v", tok, err)
}
}
Loading
Loading