From 1419b0d5d3523c0ee1e4432154d1ace3295eccaa Mon Sep 17 00:00:00 2001 From: tumberger Date: Sun, 5 Apr 2026 18:02:38 +0200 Subject: [PATCH 01/11] feat: wire full governance telemetry pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete end-to-end hook telemetry: - Backend REST bridge client (BackendService interface — swappable to gRPC) - Session lifecycle: create on start, heartbeat every 30s, disconnect on exit - Sidecar: Unix socket server processes hook events, ingests async to backend - Hook registration: generates Claude Code settings.json with PreToolUse, PostToolUse, UserPromptSubmit hooks pointing to `kontext hook` - Hook command: reads stdin, connects to sidecar via KONTEXT_SOCKET, sends EvaluateRequest, writes decision to stdout - Wire protocol: length-prefixed JSON over Unix socket - Proto codegen: buf.yaml + buf.gen.yaml, generated ConnectRPC client stubs - Event types: session.begin, session.end, hook.pre_tool_call, hook.post_tool_call, hook.user_prompt → mcp_events table - Fail-open: if backend/sidecar unreachable, agent launches without telemetry - Graceful degradation: no KONTEXT_CLIENT_ID → launches without governance Closes #2 Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/kontext/main.go | 79 ++++++++- go.mod | 3 + go.sum | 8 + internal/backend/backend.go | 300 +++++++++++++++++++++++++++++++++++ internal/run/hooks.go | 54 +++++++ internal/run/run.go | 171 +++++++++++++------- internal/sidecar/protocol.go | 65 ++++++++ internal/sidecar/sidecar.go | 130 ++++++++++++--- 8 files changed, 725 insertions(+), 85 deletions(-) create mode 100644 internal/backend/backend.go create mode 100644 internal/run/hooks.go create mode 100644 internal/sidecar/protocol.go diff --git a/cmd/kontext/main.go b/cmd/kontext/main.go index 6e7b654..60eea55 100644 --- a/cmd/kontext/main.go +++ b/cmd/kontext/main.go @@ -2,13 +2,19 @@ package main import ( "context" + "encoding/json" "fmt" + "net" "os" + "time" "github.com/spf13/cobra" + "github.com/kontext-dev/kontext-cli/internal/agent" "github.com/kontext-dev/kontext-cli/internal/auth" + "github.com/kontext-dev/kontext-cli/internal/hook" "github.com/kontext-dev/kontext-cli/internal/run" + "github.com/kontext-dev/kontext-cli/internal/sidecar" // Register agent adapters _ "github.com/kontext-dev/kontext-cli/internal/agent/claude" @@ -97,13 +103,25 @@ func hookCmd() *cobra.Command { Short: "Process a hook event (called by the agent, not by users)", Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintln(os.Stderr, "kontext hook (not yet implemented)") - // TODO: - // 1. Read stdin (hook event JSON) - // 2. Connect to sidecar via KONTEXT_SOCKET - // 3. Send event, receive decision - // 4. Write decision to stdout, exit with appropriate code - return nil + a, ok := agent.Get(agentName) + if !ok { + fmt.Fprintf(os.Stderr, "unknown agent: %s\n", agentName) + os.Exit(2) + } + + socketPath := os.Getenv("KONTEXT_SOCKET") + if socketPath == "" { + // No sidecar — fail-open + hook.Run(a, func(e *agent.HookEvent) (bool, string, error) { + return true, "no sidecar", nil + }) + return nil // unreachable + } + + hook.Run(a, func(e *agent.HookEvent) (bool, string, error) { + return evaluateViaSidecar(socketPath, agentName, e) + }) + return nil // unreachable (hook.Run calls os.Exit) }, } @@ -111,3 +129,50 @@ func hookCmd() *cobra.Command { return cmd } + +func evaluateViaSidecar(socketPath, agentName string, e *agent.HookEvent) (bool, string, error) { + conn, err := net.DialTimeout("unix", socketPath, 5*time.Second) + if err != nil { + // Sidecar unreachable — fail-open + return true, "sidecar unreachable", nil + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + + req := sidecar.EvaluateRequest{ + Type: "evaluate", + Agent: agentName, + HookEvent: e.HookEventName, + ToolName: e.ToolName, + ToolUseID: e.ToolUseID, + CWD: e.CWD, + } + + // Marshal tool input/response to JSON + if e.ToolInput != nil { + data, _ := marshalJSON(e.ToolInput) + req.ToolInput = data + } + if e.ToolResponse != nil { + data, _ := marshalJSON(e.ToolResponse) + req.ToolResponse = data + } + + if err := sidecar.WriteMessage(conn, req); err != nil { + return true, "sidecar write error", nil + } + + var result sidecar.EvaluateResult + if err := sidecar.ReadMessage(conn, &result); err != nil { + return true, "sidecar read error", nil + } + + return result.Allowed, result.Reason, nil +} + +func marshalJSON(v any) ([]byte, error) { + if v == nil { + return nil, nil + } + return json.Marshal(v) +} diff --git a/go.mod b/go.mod index a162479..a6d6fed 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,13 @@ module github.com/kontext-dev/kontext-cli go 1.25.0 require ( + connectrpc.com/connect v1.19.1 github.com/cli/browser v1.3.0 + github.com/google/uuid v1.6.0 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 golang.org/x/oauth2 v0.36.0 + google.golang.org/protobuf v1.36.11 ) require ( diff --git a/go.sum b/go.sum index c234a0a..6218fd4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -7,6 +9,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -27,6 +33,8 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/backend/backend.go b/internal/backend/backend.go new file mode 100644 index 0000000..2693182 --- /dev/null +++ b/internal/backend/backend.go @@ -0,0 +1,300 @@ +// Package backend provides the client interface for the Kontext API. +// The BackendService interface mirrors the proto AgentService RPCs. +// The REST bridge implementation routes calls to existing REST endpoints; +// when the gRPC server exists, swap NewRESTBridgeClient for NewConnectClient. +package backend + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +// BackendService is the interface the sidecar and orchestrator depend on. +type BackendService interface { + CreateSession(ctx context.Context, userID, agent, hostname, cwd string) (sessionID, sessionName string, err error) + Heartbeat(ctx context.Context, sessionID string) error + EndSession(ctx context.Context, sessionID string) error + IngestEvent(ctx context.Context, event *IngestEventParams) error +} + +// IngestEventParams holds the fields for a telemetry event. +type IngestEventParams struct { + SessionID string + EventType string // session.begin, session.end, hook.pre_tool_call, hook.post_tool_call, hook.user_prompt + Status string // ok, denied + ToolName string + DurationMs int + TraceID string + RequestJSON any + ResponseJSON any +} + +// Config holds backend connection parameters. +type Config struct { + BaseURL string + ClientID string + ClientSecret string +} + +// LoadConfig reads backend configuration from environment variables. +func LoadConfig() (*Config, error) { + cfg := &Config{ + BaseURL: envOr("KONTEXT_API_URL", "https://api.kontext.security"), + ClientID: os.Getenv("KONTEXT_CLIENT_ID"), + ClientSecret: os.Getenv("KONTEXT_CLIENT_SECRET"), + } + + // Try config file if env vars are missing + if cfg.ClientID == "" || cfg.ClientSecret == "" { + fileCfg, _ := loadConfigFile() + if fileCfg != nil { + if cfg.ClientID == "" { + cfg.ClientID = fileCfg.ClientID + } + if cfg.ClientSecret == "" { + cfg.ClientSecret = fileCfg.ClientSecret + } + } + } + + if cfg.ClientID == "" || cfg.ClientSecret == "" { + return nil, fmt.Errorf("KONTEXT_CLIENT_ID and KONTEXT_CLIENT_SECRET are required (set via env or ~/.kontext/config.json)") + } + + return cfg, nil +} + +func loadConfigFile() (*Config, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + data, err := os.ReadFile(filepath.Join(home, ".kontext", "config.json")) + if err != nil { + return nil, err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// --- REST Bridge Client --- + +// RESTBridgeClient implements BackendService using existing REST endpoints. +type RESTBridgeClient struct { + config *Config + httpClient *http.Client + token string + tokenExp time.Time + mu sync.Mutex + userID string // authenticatedUserId for events +} + +// NewRESTBridgeClient creates a backend client that routes to REST endpoints. +func NewRESTBridgeClient(config *Config) *RESTBridgeClient { + return &RESTBridgeClient{ + config: config, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (c *RESTBridgeClient) CreateSession(ctx context.Context, userID, agent, hostname, cwd string) (string, string, error) { + c.userID = userID + + token, err := c.getToken(ctx) + if err != nil { + return "", "", fmt.Errorf("auth: %w", err) + } + + body := map[string]any{ + "tokenIdentifier": fmt.Sprintf("cli:%s", uuid.New().String()), + "authenticatedUserId": userID, + "clientSessionId": uuid.New().String(), + "hostname": hostname, + "clientInfo": map[string]string{"name": "kontext-cli", "agent": agent}, + } + + var resp struct { + SessionID string `json:"sessionId"` + Name string `json:"name"` + } + if err := c.doJSON(ctx, "POST", "/api/v1/agent-sessions", token, body, &resp); err != nil { + return "", "", fmt.Errorf("create session: %w", err) + } + + return resp.SessionID, resp.Name, nil +} + +func (c *RESTBridgeClient) Heartbeat(ctx context.Context, sessionID string) error { + token, err := c.getToken(ctx) + if err != nil { + return err + } + return c.doJSON(ctx, "POST", fmt.Sprintf("/api/v1/agent-sessions/%s/heartbeat", sessionID), token, nil, nil) +} + +func (c *RESTBridgeClient) EndSession(ctx context.Context, sessionID string) error { + token, err := c.getToken(ctx) + if err != nil { + return err + } + return c.doJSON(ctx, "POST", fmt.Sprintf("/api/v1/agent-sessions/%s/disconnect", sessionID), token, nil, nil) +} + +func (c *RESTBridgeClient) IngestEvent(ctx context.Context, event *IngestEventParams) error { + token, err := c.getToken(ctx) + if err != nil { + return err + } + + body := map[string]any{ + "sessionId": event.SessionID, + "authenticatedUserId": c.userID, + "clientId": c.config.ClientID, + "eventType": event.EventType, + "status": event.Status, + "durationMs": event.DurationMs, + "toolName": event.ToolName, + "traceId": event.TraceID, + "requestJson": event.RequestJSON, + "responseJson": event.ResponseJSON, + } + + return c.doJSON(ctx, "POST", "/api/v1/mcp-events", token, body, nil) +} + +// --- Token management --- + +func (c *RESTBridgeClient) getToken(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.token != "" && time.Now().Before(c.tokenExp) { + return c.token, nil + } + + // Discover token endpoint + var meta struct { + TokenEndpoint string `json:"token_endpoint"` + } + if err := c.doGet(ctx, "/.well-known/oauth-authorization-server", &meta); err != nil { + return "", fmt.Errorf("discovery: %w", err) + } + + // Client credentials flow + params := fmt.Sprintf("grant_type=client_credentials&scope=management:all+mcp:invoke") + req, err := http.NewRequestWithContext(ctx, "POST", meta.TokenEndpoint, nil) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(c.config.ClientID, c.config.ClientSecret) + req.Body = newStringBody(params) + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("token request failed: %s", resp.Status) + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return "", err + } + + c.token = tokenResp.AccessToken + if tokenResp.ExpiresIn > 0 { + c.tokenExp = time.Now().Add(time.Duration(tokenResp.ExpiresIn-60) * time.Second) + } else { + c.tokenExp = time.Now().Add(50 * time.Minute) + } + + return c.token, nil +} + +// --- HTTP helpers --- + +func (c *RESTBridgeClient) doJSON(ctx context.Context, method, path, token string, body any, result any) error { + var reqBody io.Reader + if body != nil { + reqBody = newJSONBody(body) + } + + url := c.config.BaseURL + path + req, err := http.NewRequestWithContext(ctx, method, url, reqBody) + if err != nil { + return err + } + if reqBody != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + var errBody json.RawMessage + json.NewDecoder(resp.Body).Decode(&errBody) + return fmt.Errorf("API %s %s: %d %s", method, path, resp.StatusCode, string(errBody)) + } + + if result != nil { + return json.NewDecoder(resp.Body).Decode(result) + } + return nil +} + +func (c *RESTBridgeClient) doGet(ctx context.Context, path string, result any) error { + url := c.config.BaseURL + path + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return err + } + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + return json.NewDecoder(resp.Body).Decode(result) +} + +func newJSONBody(v any) io.Reader { + buf := new(bytes.Buffer) + json.NewEncoder(buf).Encode(v) + return buf +} + +func newStringBody(s string) io.ReadCloser { + return io.NopCloser(strings.NewReader(s)) +} diff --git a/internal/run/hooks.go b/internal/run/hooks.go new file mode 100644 index 0000000..2ed2205 --- /dev/null +++ b/internal/run/hooks.go @@ -0,0 +1,54 @@ +package run + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type claudeSettings struct { + Hooks map[string][]hookGroup `json:"hooks"` +} + +type hookGroup struct { + Hooks []hookDef `json:"hooks"` +} + +type hookDef struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// GenerateSettings creates a Claude Code settings.json with Kontext hooks +// and returns the path to the generated file. +func GenerateSettings(sessionDir, kontextBinary, agentName string) (string, error) { + hookCmd := fmt.Sprintf("%s hook --agent %s", kontextBinary, agentName) + + settings := claudeSettings{ + Hooks: map[string][]hookGroup{ + "PreToolUse": {{ + Hooks: []hookDef{{Type: "command", Command: hookCmd, Timeout: 10}}, + }}, + "PostToolUse": {{ + Hooks: []hookDef{{Type: "command", Command: hookCmd, Timeout: 10}}, + }}, + "UserPromptSubmit": {{ + Hooks: []hookDef{{Type: "command", Command: hookCmd, Timeout: 10}}, + }}, + }, + } + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return "", fmt.Errorf("marshal settings: %w", err) + } + + settingsPath := filepath.Join(sessionDir, "settings.json") + if err := os.WriteFile(settingsPath, data, 0600); err != nil { + return "", fmt.Errorf("write settings: %w", err) + } + + return settingsPath, nil +} diff --git a/internal/run/run.go b/internal/run/run.go index bdb8319..aefaca4 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -1,5 +1,4 @@ // Package run implements the `kontext start` orchestrator. -// It handles the full lifecycle: auth → init → credentials → sidecar → subprocess → cleanup. package run import ( @@ -9,13 +8,19 @@ import ( "os" "os/exec" "os/signal" + "path/filepath" + "runtime" "strings" "syscall" + "time" "github.com/cli/browser" + "github.com/google/uuid" "github.com/kontext-dev/kontext-cli/internal/auth" + "github.com/kontext-dev/kontext-cli/internal/backend" "github.com/kontext-dev/kontext-cli/internal/credential" + "github.com/kontext-dev/kontext-cli/internal/sidecar" ) // Options configures a kontext start run. @@ -24,12 +29,12 @@ type Options struct { TemplateFile string IssuerURL string ClientID string - Args []string // extra args to pass to the agent + Args []string } // Start is the main entry point for `kontext start`. func Start(ctx context.Context, opts Options) error { - // 1. Auth — login inline if no session + // 1. Auth session, err := ensureSession(ctx, opts.IssuerURL, opts.ClientID) if err != nil { return err @@ -43,34 +48,105 @@ func Start(ctx context.Context, opts Options) error { } fmt.Fprintf(os.Stderr, "✓ Authenticated as %s\n", identity) - // 2. Ensure env template exists (create interactively on first run) - templatePath := opts.TemplateFile - if _, err := os.Stat(templatePath); os.IsNotExist(err) { - if err := initTemplate(templatePath); err != nil { - return err - } + // 2. Backend client + backendCfg, err := backend.LoadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "⚠ Backend not configured: %v\n", err) + fmt.Fprintln(os.Stderr, " Launching without telemetry (set KONTEXT_CLIENT_ID + KONTEXT_CLIENT_SECRET)") + return launchAgentDirect(ctx, opts) } + client := backend.NewRESTBridgeClient(backendCfg) - // 3. Parse template and resolve credentials - var resolved []credential.Resolved - entries, err := credential.ParseTemplate(templatePath) + // 3. Create session + hostname, _ := os.Hostname() + cwd, _ := os.Getwd() + sessionID, sessionName, err := client.CreateSession(ctx, identity, opts.Agent, hostname, cwd) + if err != nil { + fmt.Fprintf(os.Stderr, "⚠ Session creation failed: %v\n", err) + fmt.Fprintln(os.Stderr, " Launching without telemetry") + return launchAgentDirect(ctx, opts) + } + fmt.Fprintf(os.Stderr, "✓ Session: %s (%s)\n", sessionName, sessionID[:8]) + + traceID := uuid.New().String() + + // Ingest session.begin event + client.IngestEvent(ctx, &backend.IngestEventParams{ + SessionID: sessionID, + EventType: "session.begin", + Status: "ok", + TraceID: traceID, + RequestJSON: map[string]string{ + "agent": opts.Agent, + "hostname": hostname, + "cwd": cwd, + "os": runtime.GOOS, + }, + }) + + // 4. Start sidecar + sessionDir := filepath.Join(os.TempDir(), "kontext", sessionID) + os.MkdirAll(sessionDir, 0700) + + sc, err := sidecar.New(sessionDir, client, sessionID, traceID) + if err != nil { + return fmt.Errorf("sidecar: %w", err) + } + if err := sc.Start(ctx); err != nil { + return fmt.Errorf("sidecar start: %w", err) + } + defer sc.Stop() + + // 5. Generate hook settings + kontextBin, _ := os.Executable() + settingsPath, err := GenerateSettings(sessionDir, kontextBin, opts.Agent) if err != nil { - return fmt.Errorf("parse template: %w", err) + return fmt.Errorf("generate settings: %w", err) } - if len(entries) > 0 { - resolved, err = resolveCredentials(ctx, session, entries) + // 6. Env template + credentials (optional) + var resolved []credential.Resolved + if _, err := os.Stat(opts.TemplateFile); err == nil { + entries, err := credential.ParseTemplate(opts.TemplateFile) if err != nil { - return err + return fmt.Errorf("parse template: %w", err) + } + if len(entries) > 0 { + resolved, err = resolveCredentials(ctx, session, entries) + if err != nil { + return err + } } } - // 5. Build environment + // 7. Build env env := buildEnv(resolved) + env = append(env, "KONTEXT_SOCKET="+sc.SocketPath()) + env = append(env, "KONTEXT_SESSION_ID="+sessionID) - // 6. Launch agent + // 8. Launch agent with hooks fmt.Fprintf(os.Stderr, "\nLaunching %s...\n\n", opts.Agent) - return launchAgent(ctx, opts.Agent, env, opts.Args) + startTime := time.Now() + agentErr := launchAgentWithSettings(ctx, opts.Agent, env, opts.Args, settingsPath) + + // 9. Teardown + duration := int(time.Since(startTime).Milliseconds()) + endCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + client.IngestEvent(endCtx, &backend.IngestEventParams{ + SessionID: sessionID, + EventType: "session.end", + Status: "ok", + DurationMs: duration, + TraceID: traceID, + }) + client.EndSession(endCtx, sessionID) + + fmt.Fprintf(os.Stderr, "\n✓ Session ended (%s)\n", sessionID[:8]) + + os.RemoveAll(sessionDir) + return agentErr } // ensureSession loads the session or triggers an interactive login. @@ -93,7 +169,6 @@ func ensureSession(ctx context.Context, issuerURL, clientID string) (*auth.Sessi return result.Session, nil } - // initTemplate interactively creates a .env.kontext on first run. func initTemplate(path string) error { providers := []struct { @@ -122,7 +197,6 @@ func initTemplate(path string) error { } if len(lines) == 0 { - // Write an empty template so it doesn't prompt again lines = append(lines, "# Add providers: VAR_NAME={{kontext:provider-handle}}") } @@ -135,10 +209,6 @@ func initTemplate(path string) error { // resolveCredentials exchanges each template entry for a live credential. func resolveCredentials(ctx context.Context, session *auth.Session, entries []credential.Entry) ([]credential.Resolved, error) { - if len(entries) == 0 { - return nil, nil - } - fmt.Fprintln(os.Stderr, "\nResolving credentials...") var resolved []credential.Resolved @@ -147,21 +217,15 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr value, err := exchangeCredential(ctx, session, entry) if err != nil { - // Check if this is a "not connected" error — prompt to connect if isNotConnectedError(err) { fmt.Fprintln(os.Stderr, "not connected") fmt.Fprintf(os.Stderr, " Opening browser to connect %s...\n", entry.Provider) - connectURL := fmt.Sprintf("%s/connect/%s", auth.DefaultIssuerURL, entry.Provider) _ = browser.OpenURL(connectURL) - fmt.Fprint(os.Stderr, " Press Enter after connecting...") bufio.NewReader(os.Stdin).ReadString('\n') - - // Retry value, err = exchangeCredential(ctx, session, entry) } - if err != nil { fmt.Fprintf(os.Stderr, "⚠ skipped (%v)\n", err) continue @@ -175,10 +239,7 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr return resolved, nil } -// exchangeCredential calls the Kontext backend to resolve a single credential. -// TODO: Replace with actual gRPC ExchangeCredential call. func exchangeCredential(_ context.Context, _ *auth.Session, _ credential.Entry) (string, error) { - // Placeholder — will be wired to gRPC ExchangeCredential RPC return "", fmt.Errorf("credential exchange not yet connected to backend") } @@ -187,26 +248,31 @@ func isNotConnectedError(err error) bool { strings.Contains(err.Error(), "provider not found") } -// buildEnv constructs the environment for the agent subprocess. func buildEnv(resolved []credential.Resolved) []string { - // Pass through the parent environment + add Kontext session indicator + - // overlay resolved credentials. In the future, this should be tightened - // to a minimal allowlist to prevent leaking existing secrets. env := append(os.Environ(), "KONTEXT_RUN=1") return credential.BuildEnv(resolved, env) } -// launchAgent spawns the agent as a subprocess with the given environment. -func launchAgent(_ context.Context, agentName string, env []string, extraArgs []string) error { - binary, err := exec.LookPath(agentName) +// launchAgentDirect launches the agent without hooks or sidecar (fallback). +func launchAgentDirect(ctx context.Context, opts Options) error { + fmt.Fprintf(os.Stderr, "\nLaunching %s...\n\n", opts.Agent) + return launchAgentWithSettings(ctx, opts.Agent, os.Environ(), opts.Args, "") +} + +// launchAgentWithSettings spawns the agent with optional --settings flag. +func launchAgentWithSettings(_ context.Context, agentName string, env, extraArgs []string, settingsPath string) error { + binaryPath, err := exec.LookPath(agentName) if err != nil { return fmt.Errorf("agent %q not found in PATH: %w", agentName, err) } - // Filter out dangerous flags that could bypass governance - filtered := filterArgs(extraArgs) + var args []string + if settingsPath != "" { + args = append(args, "--settings", settingsPath) + } + args = append(args, filterArgs(extraArgs)...) - cmd := exec.Command(binary, filtered...) + cmd := exec.Command(binaryPath, args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -216,7 +282,6 @@ func launchAgent(_ context.Context, agentName string, env []string, extraArgs [] return fmt.Errorf("launch %s: %w", agentName, err) } - // Forward signals sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { @@ -237,28 +302,16 @@ func launchAgent(_ context.Context, agentName string, env []string, extraArgs [] return nil } -// filterArgs removes flags that could bypass governance. func filterArgs(args []string) []string { blocked := map[string]bool{ - "--bare": true, - "--dangerously-skip-permissions": true, - "--settings": true, - "--setting-sources": true, + "--bare": true, + "--dangerously-skip-permissions": true, } var filtered []string - skip := false for _, arg := range args { - if skip { - skip = false - continue - } if blocked[arg] { fmt.Fprintf(os.Stderr, "⚠ Stripped blocked flag: %s\n", arg) - // If this flag takes a value, skip the next arg too - if arg == "--settings" || arg == "--setting-sources" { - skip = true - } continue } filtered = append(filtered, arg) diff --git a/internal/sidecar/protocol.go b/internal/sidecar/protocol.go new file mode 100644 index 0000000..f3e0dec --- /dev/null +++ b/internal/sidecar/protocol.go @@ -0,0 +1,65 @@ +package sidecar + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" +) + +// EvaluateRequest is sent from kontext hook → sidecar over Unix socket. +type EvaluateRequest struct { + Type string `json:"type"` // "evaluate" + Agent string `json:"agent"` + HookEvent string `json:"hook_event"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input,omitempty"` + ToolResponse json.RawMessage `json:"tool_response,omitempty"` + ToolUseID string `json:"tool_use_id"` + CWD string `json:"cwd"` +} + +// EvaluateResult is sent from sidecar → kontext hook. +type EvaluateResult struct { + Type string `json:"type"` // "result" + Allowed bool `json:"allowed"` + Reason string `json:"reason"` +} + +// WriteMessage writes a length-prefixed JSON message to a connection. +// Wire format: 4-byte big-endian length + JSON payload. +func WriteMessage(conn net.Conn, v any) error { + data, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + + length := uint32(len(data)) + if err := binary.Write(conn, binary.BigEndian, length); err != nil { + return fmt.Errorf("write length: %w", err) + } + if _, err := conn.Write(data); err != nil { + return fmt.Errorf("write payload: %w", err) + } + return nil +} + +// ReadMessage reads a length-prefixed JSON message from a connection. +func ReadMessage(conn net.Conn, v any) error { + var length uint32 + if err := binary.Read(conn, binary.BigEndian, &length); err != nil { + return fmt.Errorf("read length: %w", err) + } + + if length > 10*1024*1024 { // 10MB safety limit + return fmt.Errorf("message too large: %d bytes", length) + } + + data := make([]byte, length) + if _, err := io.ReadFull(conn, data); err != nil { + return fmt.Errorf("read payload: %w", err) + } + + return json.Unmarshal(data, v) +} diff --git a/internal/sidecar/sidecar.go b/internal/sidecar/sidecar.go index ce09197..5fb6d84 100644 --- a/internal/sidecar/sidecar.go +++ b/internal/sidecar/sidecar.go @@ -1,61 +1,77 @@ // Package sidecar implements the local session server. -// It runs as a persistent process alongside the agent, listening on a Unix socket. -// Hook handlers communicate with the sidecar instead of spawning HTTP requests — -// this eliminates per-hook latency entirely. +// Hook handlers (kontext hook) connect to the sidecar over a Unix socket. +// The sidecar relays events to the backend and returns policy decisions. package sidecar import ( "context" + "encoding/json" "fmt" + "log" "net" "os" "path/filepath" - "sync" + "time" + + "github.com/kontext-dev/kontext-cli/internal/backend" ) // Server is the local sidecar that hook handlers communicate with. type Server struct { socketPath string listener net.Listener - mu sync.Mutex - // TODO: policy cache, credential cache, backend streaming connection + sessionID string + traceID string + backend backend.BackendService + cancel context.CancelFunc } -// New creates a new sidecar server with a Unix socket in the given directory. -func New(sessionDir string) (*Server, error) { +// New creates a new sidecar server. +func New(sessionDir string, b backend.BackendService, sessionID, traceID string) (*Server, error) { socketPath := filepath.Join(sessionDir, "kontext.sock") - return &Server{socketPath: socketPath}, nil + return &Server{ + socketPath: socketPath, + sessionID: sessionID, + traceID: traceID, + backend: b, + }, nil } -// SocketPath returns the Unix socket path for hook handlers to connect to. +// SocketPath returns the Unix socket path for hook handlers. func (s *Server) SocketPath() string { return s.socketPath } -// Start begins listening on the Unix socket. +// Start begins listening and processing hook events. func (s *Server) Start(ctx context.Context) error { - // Clean up stale socket os.Remove(s.socketPath) ln, err := net.Listen("unix", s.socketPath) if err != nil { - return fmt.Errorf("sidecar: listen: %w", err) + return fmt.Errorf("sidecar listen: %w", err) } s.listener = ln - go s.serve(ctx) + ctx, s.cancel = context.WithCancel(ctx) + + go s.acceptLoop(ctx) + go s.heartbeatLoop(ctx) + return nil } -// Stop shuts down the sidecar and cleans up the socket. +// Stop shuts down the sidecar. func (s *Server) Stop() { + if s.cancel != nil { + s.cancel() + } if s.listener != nil { s.listener.Close() } os.Remove(s.socketPath) } -func (s *Server) serve(ctx context.Context) { +func (s *Server) acceptLoop(ctx context.Context) { for { conn, err := s.listener.Accept() if err != nil { @@ -70,8 +86,84 @@ func (s *Server) serve(ctx context.Context) { } } -func (s *Server) handleConn(_ context.Context, conn net.Conn) { +func (s *Server) handleConn(ctx context.Context, conn net.Conn) { defer conn.Close() - // TODO: read hook event from conn, evaluate, write decision back - // Protocol: length-prefixed JSON over Unix socket + conn.SetDeadline(time.Now().Add(10 * time.Second)) + + var req EvaluateRequest + if err := ReadMessage(conn, &req); err != nil { + log.Printf("sidecar: read error: %v", err) + return + } + + // Always allow for now — policy evaluation is a future phase + result := EvaluateResult{ + Type: "result", + Allowed: true, + Reason: "allowed", + } + + // Write response immediately — don't block on event ingestion + if err := WriteMessage(conn, result); err != nil { + log.Printf("sidecar: write error: %v", err) + return + } + + // Ingest event asynchronously + go s.ingestEvent(ctx, &req) +} + +func (s *Server) ingestEvent(ctx context.Context, req *EvaluateRequest) { + eventType := "hook." + normalizeHookEvent(req.HookEvent) + status := "ok" + + var reqJSON, respJSON any + if len(req.ToolInput) > 0 { + json.Unmarshal(req.ToolInput, &reqJSON) + } + if len(req.ToolResponse) > 0 { + json.Unmarshal(req.ToolResponse, &respJSON) + } + + err := s.backend.IngestEvent(ctx, &backend.IngestEventParams{ + SessionID: s.sessionID, + EventType: eventType, + Status: status, + ToolName: req.ToolName, + DurationMs: 0, + TraceID: s.traceID, + RequestJSON: reqJSON, + ResponseJSON: respJSON, + }) + if err != nil { + log.Printf("sidecar: ingest error: %v", err) + } +} + +func (s *Server) heartbeatLoop(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.backend.Heartbeat(ctx, s.sessionID); err != nil { + log.Printf("sidecar: heartbeat error: %v", err) + } + } + } +} + +func normalizeHookEvent(event string) string { + switch event { + case "PreToolUse": + return "pre_tool_call" + case "PostToolUse": + return "post_tool_call" + case "UserPromptSubmit": + return "user_prompt" + default: + return event + } } From f5de5c41eea6cad1bf5fe843f7f03e7092150a9b Mon Sep 17 00:00:00 2001 From: tumberger Date: Sun, 5 Apr 2026 18:03:24 +0200 Subject: [PATCH 02/11] docs: rewrite README with telemetry strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate governance telemetry (built-in, powers the dashboard) from developer observability (external, Langfuse/Dash0 via OTEL, future). Document the full architecture, hook flow, event types, wire protocol, and the REST bridge → ConnectRPC swap path. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 114 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 89 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 9ad22a8..210f232 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ kontext start --agent claude ``` 1. **Authenticates** — loads your identity from the system keyring (set up via `kontext login`) -2. **Resolves credentials** — reads `.env.kontext`, exchanges placeholders for short-lived tokens via Kontext -3. **Launches the agent** — spawns Claude Code with credentials injected as env vars -4. **Enforces policy** — every tool call is evaluated against your org's OpenFGA policy (via a local sidecar) -5. **Logs everything** — full audit trail streamed to the Kontext backend via gRPC +2. **Creates a session** — registers with the Kontext backend, visible in the dashboard +3. **Resolves credentials** — reads `.env.kontext`, exchanges placeholders for short-lived tokens +4. **Launches the agent** — spawns Claude Code with credentials injected as env vars + governance hooks +5. **Captures every action** — PreToolUse, PostToolUse, and UserPromptSubmit events streamed to the backend +6. **Tears down cleanly** — session disconnected, credentials expired, temp files removed Credentials are ephemeral — scoped to the session, gone when it ends. @@ -33,14 +34,17 @@ go build -o bin/kontext ./cmd/kontext ### First-time setup ```bash -kontext login +kontext start --agent claude ``` -Opens a browser for OIDC authentication. Stores your refresh token in the system keyring (macOS Keychain / Linux secret service). No client IDs or secrets to manage. +On first run, the CLI handles everything interactively: +- No session? Opens browser for OIDC login, stores refresh token in system keyring +- No `.env.kontext`? Prompts for which providers the project needs, writes the file +- Provider not connected? Opens browser to the Kontext hosted connect flow ### Declare credentials -Create a `.env.kontext` file in your project: +The `.env.kontext` file declares what credentials the project needs: ``` GITHUB_TOKEN={{kontext:github}} @@ -48,13 +52,7 @@ STRIPE_KEY={{kontext:stripe}} DATABASE_URL={{kontext:postgres/prod-readonly}} ``` -### Run - -```bash -kontext start --agent claude -``` - -The CLI resolves each placeholder, injects the credentials as env vars, and launches Claude Code with governance hooks active. +Commit this to your repo — the team shares it. ### Supported agents @@ -69,26 +67,89 @@ The CLI resolves each placeholder, injects the credentials as env vars, and laun ``` kontext start --agent claude │ - ├── Auth: OIDC refresh token from keyring → ephemeral session token - ├── Credentials: .env.kontext → ExchangeCredential RPC → env vars - ├── Sidecar: Unix socket server for hook ↔ backend communication - ├── Agent: spawn claude with injected env + hook config + ├── Auth: OIDC refresh token from keyring + ├── Backend: REST bridge client → CreateSession + ├── Sidecar: Unix socket server (kontext.sock) + │ ├── Heartbeat loop (30s) + │ └── Async event ingestion to backend + ├── Hooks: settings.json → Claude Code --settings + ├── Agent: spawn claude with injected env │ │ - │ ├── [PreToolUse] → hook binary → sidecar → policy eval → allow/deny - │ └── [PostToolUse] → hook binary → sidecar → audit log + │ ├── [PreToolUse] → kontext hook → sidecar → ingest + │ ├── [PostToolUse] → kontext hook → sidecar → ingest + │ └── [UserPromptSubmit] → kontext hook → sidecar → ingest │ - └── Backend: bidirectional gRPC stream (ProcessHookEvent, SyncPolicy) + ├── On exit: EndSession → cleanup + └── Backend: REST bridge (swappable to native ConnectRPC) ``` -**Hook handlers** are the compiled `kontext hook` binary — <5ms startup, communicates with the sidecar over a Unix socket. No per-hook HTTP requests. +### Hook flow (per tool call) -**Policy evaluation** uses OpenFGA tuples cached locally by the sidecar. The backend streams policy updates in real-time via `SyncPolicy`. +``` +Claude Code fires PreToolUse + → spawns: kontext hook --agent claude + → hook reads stdin JSON (tool_name, tool_input) + → hook connects to sidecar via KONTEXT_SOCKET (Unix socket) + → sidecar returns allow/deny immediately + → sidecar ingests event to backend asynchronously + → hook writes decision JSON to stdout, exits + → ~5ms total (Go binary, no runtime startup) +``` + +## Telemetry Strategy + +The CLI separates **governance telemetry** from **developer observability**. These are distinct concerns with different backends and data models. + +### Governance telemetry (built-in) + +Session lifecycle and tool call events flow to the Kontext backend. This powers the dashboard — sessions, traces, audit trail. + +| Event | Source | When | +|---|---|---| +| `session.begin` | CLI lifecycle | Agent launched | +| `session.end` | CLI lifecycle | Agent exited | +| `hook.pre_tool_call` | PreToolUse hook | Before every tool execution | +| `hook.post_tool_call` | PostToolUse hook | After every tool execution | +| `hook.user_prompt` | UserPromptSubmit hook | User submits a prompt | + +Events are ingested to the `mcp_events` table via `POST /api/v1/mcp-events`. Each session gets a `traceId` for grouping events in the traces view. + +**What governance telemetry captures:** +- What the agent tried to do (tool name + input) +- What happened (tool response) +- Whether it was allowed (policy decision) +- Who did it (session → user → org attribution) +- When (timestamps, duration) + +**What governance telemetry does NOT capture:** +- LLM reasoning or thinking +- Token usage or cost +- Model parameters +- Conversation history +- Response quality + +### Developer observability (external, future) + +LLM-level observability — generation details, token costs, reasoning traces, conversation history — is a separate concern. It is not part of the governance pipeline. + +For this, the CLI will optionally export OpenTelemetry spans to an external backend: +- **Langfuse** — open-source, has a native Claude Code integration, self-hostable +- **Dash0** — OTEL-native SaaS, cheap ($0.60/M spans), AI/agent-aware + +This is additive — the governance pipeline works independently. OTEL export is planned but not yet implemented. ## Protocol Service definitions: [`proto/kontext/agent/v1/agent.proto`](proto/kontext/agent/v1/agent.proto) -Uses [ConnectRPC](https://connectrpc.com/) (gRPC-compatible) for backend communication. +The proto defines the target architecture (native ConnectRPC). The CLI currently uses a REST bridge client that maps proto RPCs to existing Kontext REST endpoints. When the gRPC `AgentService` is deployed server-side, the swap is one constructor call. + +### Sidecar wire protocol + +Hook handlers communicate with the sidecar over a Unix socket using length-prefixed JSON (4-byte big-endian uint32 + JSON payload): + +- `EvaluateRequest` — hook → sidecar: agent, hook_event, tool_name, tool_input, tool_response +- `EvaluateResult` — sidecar → hook: allowed (bool), reason (string) ## Development @@ -96,11 +157,14 @@ Uses [ConnectRPC](https://connectrpc.com/) (gRPC-compatible) for backend communi # Build go build -o bin/kontext ./cmd/kontext -# Generate protobuf (requires buf) +# Generate protobuf (requires buf + plugins) buf generate # Test go test ./... + +# Link for local use +ln -sf $(pwd)/bin/kontext ~/.local/bin/kontext ``` ## License From c4b16999cd50e5a5c2d595ae4b2f763b68ba0d8f Mon Sep 17 00:00:00 2001 From: tumberger Date: Sun, 5 Apr 2026 19:02:13 +0200 Subject: [PATCH 03/11] refactor: replace REST bridge with native ConnectRPC client Remove the REST bridge entirely. The CLI communicates with the Kontext backend exclusively via the proto contract (AgentService). - Backend client uses generated ConnectRPC stubs directly - Sidecar takes *backend.Client (ConnectRPC), not an interface - Run orchestrator uses proto request/response types - Auth transport injects bearer token into ConnectRPC requests - Generated proto code committed (removed gen/ from .gitignore) No backward compatibility layer. Requires server-side AgentService endpoint (kontext-dev/kontext#408) to function. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 - gen/kontext/agent/v1/agent.pb.go | 1139 +++++++++++++++++ .../agent/v1/agentv1connect/agent.connect.go | 281 ++++ internal/backend/backend.go | 267 ++-- internal/run/run.go | 53 +- internal/sidecar/sidecar.go | 87 +- 6 files changed, 1566 insertions(+), 262 deletions(-) create mode 100644 gen/kontext/agent/v1/agent.pb.go create mode 100644 gen/kontext/agent/v1/agentv1connect/agent.connect.go diff --git a/.gitignore b/.gitignore index 3e1a5ba..250eb3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ bin/ dist/ -gen/ *.exe .env.kontext kontext diff --git a/gen/kontext/agent/v1/agent.pb.go b/gen/kontext/agent/v1/agent.pb.go new file mode 100644 index 0000000..3271dbe --- /dev/null +++ b/gen/kontext/agent/v1/agent.pb.go @@ -0,0 +1,1139 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: kontext/agent/v1/agent.proto + +package agentv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Decision int32 + +const ( + Decision_DECISION_UNSPECIFIED Decision = 0 + Decision_DECISION_ALLOW Decision = 1 + Decision_DECISION_DENY Decision = 2 + Decision_DECISION_ASK Decision = 3 // Prompt user for approval +) + +// Enum value maps for Decision. +var ( + Decision_name = map[int32]string{ + 0: "DECISION_UNSPECIFIED", + 1: "DECISION_ALLOW", + 2: "DECISION_DENY", + 3: "DECISION_ASK", + } + Decision_value = map[string]int32{ + "DECISION_UNSPECIFIED": 0, + "DECISION_ALLOW": 1, + "DECISION_DENY": 2, + "DECISION_ASK": 3, + } +) + +func (x Decision) Enum() *Decision { + p := new(Decision) + *p = x + return p +} + +func (x Decision) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Decision) Descriptor() protoreflect.EnumDescriptor { + return file_kontext_agent_v1_agent_proto_enumTypes[0].Descriptor() +} + +func (Decision) Type() protoreflect.EnumType { + return &file_kontext_agent_v1_agent_proto_enumTypes[0] +} + +func (x Decision) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Decision.Descriptor instead. +func (Decision) EnumDescriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{0} +} + +type CredentialKind int32 + +const ( + CredentialKind_CREDENTIAL_KIND_UNSPECIFIED CredentialKind = 0 + CredentialKind_CREDENTIAL_KIND_OAUTH CredentialKind = 1 + CredentialKind_CREDENTIAL_KIND_KEY CredentialKind = 2 +) + +// Enum value maps for CredentialKind. +var ( + CredentialKind_name = map[int32]string{ + 0: "CREDENTIAL_KIND_UNSPECIFIED", + 1: "CREDENTIAL_KIND_OAUTH", + 2: "CREDENTIAL_KIND_KEY", + } + CredentialKind_value = map[string]int32{ + "CREDENTIAL_KIND_UNSPECIFIED": 0, + "CREDENTIAL_KIND_OAUTH": 1, + "CREDENTIAL_KIND_KEY": 2, + } +) + +func (x CredentialKind) Enum() *CredentialKind { + p := new(CredentialKind) + *p = x + return p +} + +func (x CredentialKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CredentialKind) Descriptor() protoreflect.EnumDescriptor { + return file_kontext_agent_v1_agent_proto_enumTypes[1].Descriptor() +} + +func (CredentialKind) Type() protoreflect.EnumType { + return &file_kontext_agent_v1_agent_proto_enumTypes[1] +} + +func (x CredentialKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use CredentialKind.Descriptor instead. +func (CredentialKind) EnumDescriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{1} +} + +type HookEventRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Agent string `protobuf:"bytes,2,opt,name=agent,proto3" json:"agent,omitempty"` // "claude", "cursor", "codex" + HookEvent string `protobuf:"bytes,3,opt,name=hook_event,json=hookEvent,proto3" json:"hook_event,omitempty"` // "PreToolUse", "PostToolUse", "UserPromptSubmit" + ToolName string `protobuf:"bytes,4,opt,name=tool_name,json=toolName,proto3" json:"tool_name,omitempty"` + ToolInput []byte `protobuf:"bytes,5,opt,name=tool_input,json=toolInput,proto3" json:"tool_input,omitempty"` // JSON-encoded tool input + ToolResponse []byte `protobuf:"bytes,6,opt,name=tool_response,json=toolResponse,proto3" json:"tool_response,omitempty"` // JSON-encoded tool response (PostToolUse only) + ToolUseId string `protobuf:"bytes,7,opt,name=tool_use_id,json=toolUseId,proto3" json:"tool_use_id,omitempty"` + Cwd string `protobuf:"bytes,8,opt,name=cwd,proto3" json:"cwd,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HookEventRequest) Reset() { + *x = HookEventRequest{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HookEventRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HookEventRequest) ProtoMessage() {} + +func (x *HookEventRequest) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HookEventRequest.ProtoReflect.Descriptor instead. +func (*HookEventRequest) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *HookEventRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *HookEventRequest) GetAgent() string { + if x != nil { + return x.Agent + } + return "" +} + +func (x *HookEventRequest) GetHookEvent() string { + if x != nil { + return x.HookEvent + } + return "" +} + +func (x *HookEventRequest) GetToolName() string { + if x != nil { + return x.ToolName + } + return "" +} + +func (x *HookEventRequest) GetToolInput() []byte { + if x != nil { + return x.ToolInput + } + return nil +} + +func (x *HookEventRequest) GetToolResponse() []byte { + if x != nil { + return x.ToolResponse + } + return nil +} + +func (x *HookEventRequest) GetToolUseId() string { + if x != nil { + return x.ToolUseId + } + return "" +} + +func (x *HookEventRequest) GetCwd() string { + if x != nil { + return x.Cwd + } + return "" +} + +type HookEventResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Decision Decision `protobuf:"varint,1,opt,name=decision,proto3,enum=kontext.agent.v1.Decision" json:"decision,omitempty"` + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + EventId string `protobuf:"bytes,3,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` + // Credential to inject for this tool call (if applicable) + Credential *CredentialInjection `protobuf:"bytes,4,opt,name=credential,proto3" json:"credential,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HookEventResponse) Reset() { + *x = HookEventResponse{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HookEventResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HookEventResponse) ProtoMessage() {} + +func (x *HookEventResponse) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HookEventResponse.ProtoReflect.Descriptor instead. +func (*HookEventResponse) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *HookEventResponse) GetDecision() Decision { + if x != nil { + return x.Decision + } + return Decision_DECISION_UNSPECIFIED +} + +func (x *HookEventResponse) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *HookEventResponse) GetEventId() string { + if x != nil { + return x.EventId + } + return "" +} + +func (x *HookEventResponse) GetCredential() *CredentialInjection { + if x != nil { + return x.Credential + } + return nil +} + +type CredentialInjection struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + EnvVars map[string]string `protobuf:"bytes,2,rep,name=env_vars,json=envVars,proto3" json:"env_vars,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // e.g., {"GITHUB_TOKEN": "gho_..."} + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CredentialInjection) Reset() { + *x = CredentialInjection{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CredentialInjection) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CredentialInjection) ProtoMessage() {} + +func (x *CredentialInjection) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CredentialInjection.ProtoReflect.Descriptor instead. +func (*CredentialInjection) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{2} +} + +func (x *CredentialInjection) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *CredentialInjection) GetEnvVars() map[string]string { + if x != nil { + return x.EnvVars + } + return nil +} + +type CreateSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Agent string `protobuf:"bytes,2,opt,name=agent,proto3" json:"agent,omitempty"` // "claude", "cursor", "codex" + Hostname string `protobuf:"bytes,3,opt,name=hostname,proto3" json:"hostname,omitempty"` + Cwd string `protobuf:"bytes,4,opt,name=cwd,proto3" json:"cwd,omitempty"` + ClientInfo map[string]string `protobuf:"bytes,5,rep,name=client_info,json=clientInfo,proto3" json:"client_info,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSessionRequest) Reset() { + *x = CreateSessionRequest{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSessionRequest) ProtoMessage() {} + +func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSessionRequest.ProtoReflect.Descriptor instead. +func (*CreateSessionRequest) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *CreateSessionRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *CreateSessionRequest) GetAgent() string { + if x != nil { + return x.Agent + } + return "" +} + +func (x *CreateSessionRequest) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *CreateSessionRequest) GetCwd() string { + if x != nil { + return x.Cwd + } + return "" +} + +func (x *CreateSessionRequest) GetClientInfo() map[string]string { + if x != nil { + return x.ClientInfo + } + return nil +} + +type CreateSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + SessionName string `protobuf:"bytes,2,opt,name=session_name,json=sessionName,proto3" json:"session_name,omitempty"` + OrganizationId string `protobuf:"bytes,3,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` + AgentId string `protobuf:"bytes,4,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSessionResponse) Reset() { + *x = CreateSessionResponse{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSessionResponse) ProtoMessage() {} + +func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSessionResponse.ProtoReflect.Descriptor instead. +func (*CreateSessionResponse) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{4} +} + +func (x *CreateSessionResponse) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *CreateSessionResponse) GetSessionName() string { + if x != nil { + return x.SessionName + } + return "" +} + +func (x *CreateSessionResponse) GetOrganizationId() string { + if x != nil { + return x.OrganizationId + } + return "" +} + +func (x *CreateSessionResponse) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +type HeartbeatRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeartbeatRequest) Reset() { + *x = HeartbeatRequest{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeartbeatRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeartbeatRequest) ProtoMessage() {} + +func (x *HeartbeatRequest) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeartbeatRequest.ProtoReflect.Descriptor instead. +func (*HeartbeatRequest) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{5} +} + +func (x *HeartbeatRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type HeartbeatResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeartbeatResponse) Reset() { + *x = HeartbeatResponse{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeartbeatResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeartbeatResponse) ProtoMessage() {} + +func (x *HeartbeatResponse) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeartbeatResponse.ProtoReflect.Descriptor instead. +func (*HeartbeatResponse) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{6} +} + +type EndSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EndSessionRequest) Reset() { + *x = EndSessionRequest{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EndSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EndSessionRequest) ProtoMessage() {} + +func (x *EndSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EndSessionRequest.ProtoReflect.Descriptor instead. +func (*EndSessionRequest) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{7} +} + +func (x *EndSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type EndSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EndSessionResponse) Reset() { + *x = EndSessionResponse{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EndSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EndSessionResponse) ProtoMessage() {} + +func (x *EndSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EndSessionResponse.ProtoReflect.Descriptor instead. +func (*EndSessionResponse) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{8} +} + +type ExchangeCredentialRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` // e.g., "github", "stripe", "postgres" + UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Resource string `protobuf:"bytes,3,opt,name=resource,proto3" json:"resource,omitempty"` // optional: specific resource URI + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExchangeCredentialRequest) Reset() { + *x = ExchangeCredentialRequest{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExchangeCredentialRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExchangeCredentialRequest) ProtoMessage() {} + +func (x *ExchangeCredentialRequest) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExchangeCredentialRequest.ProtoReflect.Descriptor instead. +func (*ExchangeCredentialRequest) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{9} +} + +func (x *ExchangeCredentialRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *ExchangeCredentialRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ExchangeCredentialRequest) GetResource() string { + if x != nil { + return x.Resource + } + return "" +} + +type ExchangeCredentialResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + Kind CredentialKind `protobuf:"varint,2,opt,name=kind,proto3,enum=kontext.agent.v1.CredentialKind" json:"kind,omitempty"` + AccessToken string `protobuf:"bytes,3,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` // for OAuth providers + Value string `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"` // for API key providers + TokenType string `protobuf:"bytes,5,opt,name=token_type,json=tokenType,proto3" json:"token_type,omitempty"` + Scope string `protobuf:"bytes,6,opt,name=scope,proto3" json:"scope,omitempty"` + ExpiresIn int64 `protobuf:"varint,7,opt,name=expires_in,json=expiresIn,proto3" json:"expires_in,omitempty"` // seconds + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExchangeCredentialResponse) Reset() { + *x = ExchangeCredentialResponse{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExchangeCredentialResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExchangeCredentialResponse) ProtoMessage() {} + +func (x *ExchangeCredentialResponse) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExchangeCredentialResponse.ProtoReflect.Descriptor instead. +func (*ExchangeCredentialResponse) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{10} +} + +func (x *ExchangeCredentialResponse) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *ExchangeCredentialResponse) GetKind() CredentialKind { + if x != nil { + return x.Kind + } + return CredentialKind_CREDENTIAL_KIND_UNSPECIFIED +} + +func (x *ExchangeCredentialResponse) GetAccessToken() string { + if x != nil { + return x.AccessToken + } + return "" +} + +func (x *ExchangeCredentialResponse) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *ExchangeCredentialResponse) GetTokenType() string { + if x != nil { + return x.TokenType + } + return "" +} + +func (x *ExchangeCredentialResponse) GetScope() string { + if x != nil { + return x.Scope + } + return "" +} + +func (x *ExchangeCredentialResponse) GetExpiresIn() int64 { + if x != nil { + return x.ExpiresIn + } + return 0 +} + +type SyncPolicyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SyncPolicyRequest) Reset() { + *x = SyncPolicyRequest{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SyncPolicyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SyncPolicyRequest) ProtoMessage() {} + +func (x *SyncPolicyRequest) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SyncPolicyRequest.ProtoReflect.Descriptor instead. +func (*SyncPolicyRequest) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{11} +} + +func (x *SyncPolicyRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type PolicyUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + // OpenFGA tuples for local evaluation + Tuples []*PolicyTuple `protobuf:"bytes,1,rep,name=tuples,proto3" json:"tuples,omitempty"` + FullSync bool `protobuf:"varint,2,opt,name=full_sync,json=fullSync,proto3" json:"full_sync,omitempty"` // true = replace all, false = incremental + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyUpdate) Reset() { + *x = PolicyUpdate{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyUpdate) ProtoMessage() {} + +func (x *PolicyUpdate) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PolicyUpdate.ProtoReflect.Descriptor instead. +func (*PolicyUpdate) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{12} +} + +func (x *PolicyUpdate) GetTuples() []*PolicyTuple { + if x != nil { + return x.Tuples + } + return nil +} + +func (x *PolicyUpdate) GetFullSync() bool { + if x != nil { + return x.FullSync + } + return false +} + +type PolicyTuple struct { + state protoimpl.MessageState `protogen:"open.v1"` + User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` + Relation string `protobuf:"bytes,2,opt,name=relation,proto3" json:"relation,omitempty"` + Object string `protobuf:"bytes,3,opt,name=object,proto3" json:"object,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyTuple) Reset() { + *x = PolicyTuple{} + mi := &file_kontext_agent_v1_agent_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyTuple) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyTuple) ProtoMessage() {} + +func (x *PolicyTuple) ProtoReflect() protoreflect.Message { + mi := &file_kontext_agent_v1_agent_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PolicyTuple.ProtoReflect.Descriptor instead. +func (*PolicyTuple) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{13} +} + +func (x *PolicyTuple) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +func (x *PolicyTuple) GetRelation() string { + if x != nil { + return x.Relation + } + return "" +} + +func (x *PolicyTuple) GetObject() string { + if x != nil { + return x.Object + } + return "" +} + +var File_kontext_agent_v1_agent_proto protoreflect.FileDescriptor + +const file_kontext_agent_v1_agent_proto_rawDesc = "" + + "\n" + + "\x1ckontext/agent/v1/agent.proto\x12\x10kontext.agent.v1\"\xf9\x01\n" + + "\x10HookEventRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x14\n" + + "\x05agent\x18\x02 \x01(\tR\x05agent\x12\x1d\n" + + "\n" + + "hook_event\x18\x03 \x01(\tR\thookEvent\x12\x1b\n" + + "\ttool_name\x18\x04 \x01(\tR\btoolName\x12\x1d\n" + + "\n" + + "tool_input\x18\x05 \x01(\fR\ttoolInput\x12#\n" + + "\rtool_response\x18\x06 \x01(\fR\ftoolResponse\x12\x1e\n" + + "\vtool_use_id\x18\a \x01(\tR\ttoolUseId\x12\x10\n" + + "\x03cwd\x18\b \x01(\tR\x03cwd\"\xc5\x01\n" + + "\x11HookEventResponse\x126\n" + + "\bdecision\x18\x01 \x01(\x0e2\x1a.kontext.agent.v1.DecisionR\bdecision\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\x12\x19\n" + + "\bevent_id\x18\x03 \x01(\tR\aeventId\x12E\n" + + "\n" + + "credential\x18\x04 \x01(\v2%.kontext.agent.v1.CredentialInjectionR\n" + + "credential\"\xbc\x01\n" + + "\x13CredentialInjection\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x12M\n" + + "\benv_vars\x18\x02 \x03(\v22.kontext.agent.v1.CredentialInjection.EnvVarsEntryR\aenvVars\x1a:\n" + + "\fEnvVarsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x8b\x02\n" + + "\x14CreateSessionRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x14\n" + + "\x05agent\x18\x02 \x01(\tR\x05agent\x12\x1a\n" + + "\bhostname\x18\x03 \x01(\tR\bhostname\x12\x10\n" + + "\x03cwd\x18\x04 \x01(\tR\x03cwd\x12W\n" + + "\vclient_info\x18\x05 \x03(\v26.kontext.agent.v1.CreateSessionRequest.ClientInfoEntryR\n" + + "clientInfo\x1a=\n" + + "\x0fClientInfoEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x9d\x01\n" + + "\x15CreateSessionResponse\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12!\n" + + "\fsession_name\x18\x02 \x01(\tR\vsessionName\x12'\n" + + "\x0forganization_id\x18\x03 \x01(\tR\x0eorganizationId\x12\x19\n" + + "\bagent_id\x18\x04 \x01(\tR\aagentId\"1\n" + + "\x10HeartbeatRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"\x13\n" + + "\x11HeartbeatResponse\"2\n" + + "\x11EndSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"\x14\n" + + "\x12EndSessionResponse\"l\n" + + "\x19ExchangeCredentialRequest\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x12\x17\n" + + "\auser_id\x18\x02 \x01(\tR\x06userId\x12\x1a\n" + + "\bresource\x18\x03 \x01(\tR\bresource\"\xfb\x01\n" + + "\x1aExchangeCredentialResponse\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x124\n" + + "\x04kind\x18\x02 \x01(\x0e2 .kontext.agent.v1.CredentialKindR\x04kind\x12!\n" + + "\faccess_token\x18\x03 \x01(\tR\vaccessToken\x12\x14\n" + + "\x05value\x18\x04 \x01(\tR\x05value\x12\x1d\n" + + "\n" + + "token_type\x18\x05 \x01(\tR\ttokenType\x12\x14\n" + + "\x05scope\x18\x06 \x01(\tR\x05scope\x12\x1d\n" + + "\n" + + "expires_in\x18\a \x01(\x03R\texpiresIn\"2\n" + + "\x11SyncPolicyRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"b\n" + + "\fPolicyUpdate\x125\n" + + "\x06tuples\x18\x01 \x03(\v2\x1d.kontext.agent.v1.PolicyTupleR\x06tuples\x12\x1b\n" + + "\tfull_sync\x18\x02 \x01(\bR\bfullSync\"U\n" + + "\vPolicyTuple\x12\x12\n" + + "\x04user\x18\x01 \x01(\tR\x04user\x12\x1a\n" + + "\brelation\x18\x02 \x01(\tR\brelation\x12\x16\n" + + "\x06object\x18\x03 \x01(\tR\x06object*]\n" + + "\bDecision\x12\x18\n" + + "\x14DECISION_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eDECISION_ALLOW\x10\x01\x12\x11\n" + + "\rDECISION_DENY\x10\x02\x12\x10\n" + + "\fDECISION_ASK\x10\x03*e\n" + + "\x0eCredentialKind\x12\x1f\n" + + "\x1bCREDENTIAL_KIND_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15CREDENTIAL_KIND_OAUTH\x10\x01\x12\x17\n" + + "\x13CREDENTIAL_KIND_KEY\x10\x022\xc6\x04\n" + + "\fAgentService\x12_\n" + + "\x10ProcessHookEvent\x12\".kontext.agent.v1.HookEventRequest\x1a#.kontext.agent.v1.HookEventResponse(\x010\x01\x12`\n" + + "\rCreateSession\x12&.kontext.agent.v1.CreateSessionRequest\x1a'.kontext.agent.v1.CreateSessionResponse\x12T\n" + + "\tHeartbeat\x12\".kontext.agent.v1.HeartbeatRequest\x1a#.kontext.agent.v1.HeartbeatResponse\x12W\n" + + "\n" + + "EndSession\x12#.kontext.agent.v1.EndSessionRequest\x1a$.kontext.agent.v1.EndSessionResponse\x12o\n" + + "\x12ExchangeCredential\x12+.kontext.agent.v1.ExchangeCredentialRequest\x1a,.kontext.agent.v1.ExchangeCredentialResponse\x12S\n" + + "\n" + + "SyncPolicy\x12#.kontext.agent.v1.SyncPolicyRequest\x1a\x1e.kontext.agent.v1.PolicyUpdate0\x01BAZ?github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1;agentv1b\x06proto3" + +var ( + file_kontext_agent_v1_agent_proto_rawDescOnce sync.Once + file_kontext_agent_v1_agent_proto_rawDescData []byte +) + +func file_kontext_agent_v1_agent_proto_rawDescGZIP() []byte { + file_kontext_agent_v1_agent_proto_rawDescOnce.Do(func() { + file_kontext_agent_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_kontext_agent_v1_agent_proto_rawDesc), len(file_kontext_agent_v1_agent_proto_rawDesc))) + }) + return file_kontext_agent_v1_agent_proto_rawDescData +} + +var file_kontext_agent_v1_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_kontext_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_kontext_agent_v1_agent_proto_goTypes = []any{ + (Decision)(0), // 0: kontext.agent.v1.Decision + (CredentialKind)(0), // 1: kontext.agent.v1.CredentialKind + (*HookEventRequest)(nil), // 2: kontext.agent.v1.HookEventRequest + (*HookEventResponse)(nil), // 3: kontext.agent.v1.HookEventResponse + (*CredentialInjection)(nil), // 4: kontext.agent.v1.CredentialInjection + (*CreateSessionRequest)(nil), // 5: kontext.agent.v1.CreateSessionRequest + (*CreateSessionResponse)(nil), // 6: kontext.agent.v1.CreateSessionResponse + (*HeartbeatRequest)(nil), // 7: kontext.agent.v1.HeartbeatRequest + (*HeartbeatResponse)(nil), // 8: kontext.agent.v1.HeartbeatResponse + (*EndSessionRequest)(nil), // 9: kontext.agent.v1.EndSessionRequest + (*EndSessionResponse)(nil), // 10: kontext.agent.v1.EndSessionResponse + (*ExchangeCredentialRequest)(nil), // 11: kontext.agent.v1.ExchangeCredentialRequest + (*ExchangeCredentialResponse)(nil), // 12: kontext.agent.v1.ExchangeCredentialResponse + (*SyncPolicyRequest)(nil), // 13: kontext.agent.v1.SyncPolicyRequest + (*PolicyUpdate)(nil), // 14: kontext.agent.v1.PolicyUpdate + (*PolicyTuple)(nil), // 15: kontext.agent.v1.PolicyTuple + nil, // 16: kontext.agent.v1.CredentialInjection.EnvVarsEntry + nil, // 17: kontext.agent.v1.CreateSessionRequest.ClientInfoEntry +} +var file_kontext_agent_v1_agent_proto_depIdxs = []int32{ + 0, // 0: kontext.agent.v1.HookEventResponse.decision:type_name -> kontext.agent.v1.Decision + 4, // 1: kontext.agent.v1.HookEventResponse.credential:type_name -> kontext.agent.v1.CredentialInjection + 16, // 2: kontext.agent.v1.CredentialInjection.env_vars:type_name -> kontext.agent.v1.CredentialInjection.EnvVarsEntry + 17, // 3: kontext.agent.v1.CreateSessionRequest.client_info:type_name -> kontext.agent.v1.CreateSessionRequest.ClientInfoEntry + 1, // 4: kontext.agent.v1.ExchangeCredentialResponse.kind:type_name -> kontext.agent.v1.CredentialKind + 15, // 5: kontext.agent.v1.PolicyUpdate.tuples:type_name -> kontext.agent.v1.PolicyTuple + 2, // 6: kontext.agent.v1.AgentService.ProcessHookEvent:input_type -> kontext.agent.v1.HookEventRequest + 5, // 7: kontext.agent.v1.AgentService.CreateSession:input_type -> kontext.agent.v1.CreateSessionRequest + 7, // 8: kontext.agent.v1.AgentService.Heartbeat:input_type -> kontext.agent.v1.HeartbeatRequest + 9, // 9: kontext.agent.v1.AgentService.EndSession:input_type -> kontext.agent.v1.EndSessionRequest + 11, // 10: kontext.agent.v1.AgentService.ExchangeCredential:input_type -> kontext.agent.v1.ExchangeCredentialRequest + 13, // 11: kontext.agent.v1.AgentService.SyncPolicy:input_type -> kontext.agent.v1.SyncPolicyRequest + 3, // 12: kontext.agent.v1.AgentService.ProcessHookEvent:output_type -> kontext.agent.v1.HookEventResponse + 6, // 13: kontext.agent.v1.AgentService.CreateSession:output_type -> kontext.agent.v1.CreateSessionResponse + 8, // 14: kontext.agent.v1.AgentService.Heartbeat:output_type -> kontext.agent.v1.HeartbeatResponse + 10, // 15: kontext.agent.v1.AgentService.EndSession:output_type -> kontext.agent.v1.EndSessionResponse + 12, // 16: kontext.agent.v1.AgentService.ExchangeCredential:output_type -> kontext.agent.v1.ExchangeCredentialResponse + 14, // 17: kontext.agent.v1.AgentService.SyncPolicy:output_type -> kontext.agent.v1.PolicyUpdate + 12, // [12:18] is the sub-list for method output_type + 6, // [6:12] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_kontext_agent_v1_agent_proto_init() } +func file_kontext_agent_v1_agent_proto_init() { + if File_kontext_agent_v1_agent_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_kontext_agent_v1_agent_proto_rawDesc), len(file_kontext_agent_v1_agent_proto_rawDesc)), + NumEnums: 2, + NumMessages: 16, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_kontext_agent_v1_agent_proto_goTypes, + DependencyIndexes: file_kontext_agent_v1_agent_proto_depIdxs, + EnumInfos: file_kontext_agent_v1_agent_proto_enumTypes, + MessageInfos: file_kontext_agent_v1_agent_proto_msgTypes, + }.Build() + File_kontext_agent_v1_agent_proto = out.File + file_kontext_agent_v1_agent_proto_goTypes = nil + file_kontext_agent_v1_agent_proto_depIdxs = nil +} diff --git a/gen/kontext/agent/v1/agentv1connect/agent.connect.go b/gen/kontext/agent/v1/agentv1connect/agent.connect.go new file mode 100644 index 0000000..d935db9 --- /dev/null +++ b/gen/kontext/agent/v1/agentv1connect/agent.connect.go @@ -0,0 +1,281 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: kontext/agent/v1/agent.proto + +package agentv1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + v1 "github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // AgentServiceName is the fully-qualified name of the AgentService service. + AgentServiceName = "kontext.agent.v1.AgentService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // AgentServiceProcessHookEventProcedure is the fully-qualified name of the AgentService's + // ProcessHookEvent RPC. + AgentServiceProcessHookEventProcedure = "/kontext.agent.v1.AgentService/ProcessHookEvent" + // AgentServiceCreateSessionProcedure is the fully-qualified name of the AgentService's + // CreateSession RPC. + AgentServiceCreateSessionProcedure = "/kontext.agent.v1.AgentService/CreateSession" + // AgentServiceHeartbeatProcedure is the fully-qualified name of the AgentService's Heartbeat RPC. + AgentServiceHeartbeatProcedure = "/kontext.agent.v1.AgentService/Heartbeat" + // AgentServiceEndSessionProcedure is the fully-qualified name of the AgentService's EndSession RPC. + AgentServiceEndSessionProcedure = "/kontext.agent.v1.AgentService/EndSession" + // AgentServiceExchangeCredentialProcedure is the fully-qualified name of the AgentService's + // ExchangeCredential RPC. + AgentServiceExchangeCredentialProcedure = "/kontext.agent.v1.AgentService/ExchangeCredential" + // AgentServiceSyncPolicyProcedure is the fully-qualified name of the AgentService's SyncPolicy RPC. + AgentServiceSyncPolicyProcedure = "/kontext.agent.v1.AgentService/SyncPolicy" +) + +// AgentServiceClient is a client for the kontext.agent.v1.AgentService service. +type AgentServiceClient interface { + // ProcessHookEvent streams tool call events from the CLI to the backend + // and receives policy decisions in return. Bidirectional streaming keeps + // the connection open for the session lifetime — no per-hook HTTP overhead. + ProcessHookEvent(context.Context) *connect.BidiStreamForClient[v1.HookEventRequest, v1.HookEventResponse] + // CreateSession establishes a governed agent session. Called once at the + // start of `kontext start`. Returns session context used for all subsequent + // hook evaluations. + CreateSession(context.Context, *connect.Request[v1.CreateSessionRequest]) (*connect.Response[v1.CreateSessionResponse], error) + // Heartbeat keeps the session alive. The sidecar sends heartbeats on an + // interval; the backend marks sessions as disconnected if heartbeats stop. + Heartbeat(context.Context, *connect.Request[v1.HeartbeatRequest]) (*connect.Response[v1.HeartbeatResponse], error) + // EndSession terminates the session and revokes any ephemeral credentials. + EndSession(context.Context, *connect.Request[v1.EndSessionRequest]) (*connect.Response[v1.EndSessionResponse], error) + // ExchangeCredential resolves a provider credential for a given user and + // provider handle. Used by the env template hydration and per-tool-call + // credential injection. + ExchangeCredential(context.Context, *connect.Request[v1.ExchangeCredentialRequest]) (*connect.Response[v1.ExchangeCredentialResponse], error) + // SyncPolicy streams the current policy state for the session's org/agent. + // The sidecar caches this locally for fast hook evaluation. The server + // pushes updates when policy changes. + SyncPolicy(context.Context, *connect.Request[v1.SyncPolicyRequest]) (*connect.ServerStreamForClient[v1.PolicyUpdate], error) +} + +// NewAgentServiceClient constructs a client for the kontext.agent.v1.AgentService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewAgentServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) AgentServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + agentServiceMethods := v1.File_kontext_agent_v1_agent_proto.Services().ByName("AgentService").Methods() + return &agentServiceClient{ + processHookEvent: connect.NewClient[v1.HookEventRequest, v1.HookEventResponse]( + httpClient, + baseURL+AgentServiceProcessHookEventProcedure, + connect.WithSchema(agentServiceMethods.ByName("ProcessHookEvent")), + connect.WithClientOptions(opts...), + ), + createSession: connect.NewClient[v1.CreateSessionRequest, v1.CreateSessionResponse]( + httpClient, + baseURL+AgentServiceCreateSessionProcedure, + connect.WithSchema(agentServiceMethods.ByName("CreateSession")), + connect.WithClientOptions(opts...), + ), + heartbeat: connect.NewClient[v1.HeartbeatRequest, v1.HeartbeatResponse]( + httpClient, + baseURL+AgentServiceHeartbeatProcedure, + connect.WithSchema(agentServiceMethods.ByName("Heartbeat")), + connect.WithClientOptions(opts...), + ), + endSession: connect.NewClient[v1.EndSessionRequest, v1.EndSessionResponse]( + httpClient, + baseURL+AgentServiceEndSessionProcedure, + connect.WithSchema(agentServiceMethods.ByName("EndSession")), + connect.WithClientOptions(opts...), + ), + exchangeCredential: connect.NewClient[v1.ExchangeCredentialRequest, v1.ExchangeCredentialResponse]( + httpClient, + baseURL+AgentServiceExchangeCredentialProcedure, + connect.WithSchema(agentServiceMethods.ByName("ExchangeCredential")), + connect.WithClientOptions(opts...), + ), + syncPolicy: connect.NewClient[v1.SyncPolicyRequest, v1.PolicyUpdate]( + httpClient, + baseURL+AgentServiceSyncPolicyProcedure, + connect.WithSchema(agentServiceMethods.ByName("SyncPolicy")), + connect.WithClientOptions(opts...), + ), + } +} + +// agentServiceClient implements AgentServiceClient. +type agentServiceClient struct { + processHookEvent *connect.Client[v1.HookEventRequest, v1.HookEventResponse] + createSession *connect.Client[v1.CreateSessionRequest, v1.CreateSessionResponse] + heartbeat *connect.Client[v1.HeartbeatRequest, v1.HeartbeatResponse] + endSession *connect.Client[v1.EndSessionRequest, v1.EndSessionResponse] + exchangeCredential *connect.Client[v1.ExchangeCredentialRequest, v1.ExchangeCredentialResponse] + syncPolicy *connect.Client[v1.SyncPolicyRequest, v1.PolicyUpdate] +} + +// ProcessHookEvent calls kontext.agent.v1.AgentService.ProcessHookEvent. +func (c *agentServiceClient) ProcessHookEvent(ctx context.Context) *connect.BidiStreamForClient[v1.HookEventRequest, v1.HookEventResponse] { + return c.processHookEvent.CallBidiStream(ctx) +} + +// CreateSession calls kontext.agent.v1.AgentService.CreateSession. +func (c *agentServiceClient) CreateSession(ctx context.Context, req *connect.Request[v1.CreateSessionRequest]) (*connect.Response[v1.CreateSessionResponse], error) { + return c.createSession.CallUnary(ctx, req) +} + +// Heartbeat calls kontext.agent.v1.AgentService.Heartbeat. +func (c *agentServiceClient) Heartbeat(ctx context.Context, req *connect.Request[v1.HeartbeatRequest]) (*connect.Response[v1.HeartbeatResponse], error) { + return c.heartbeat.CallUnary(ctx, req) +} + +// EndSession calls kontext.agent.v1.AgentService.EndSession. +func (c *agentServiceClient) EndSession(ctx context.Context, req *connect.Request[v1.EndSessionRequest]) (*connect.Response[v1.EndSessionResponse], error) { + return c.endSession.CallUnary(ctx, req) +} + +// ExchangeCredential calls kontext.agent.v1.AgentService.ExchangeCredential. +func (c *agentServiceClient) ExchangeCredential(ctx context.Context, req *connect.Request[v1.ExchangeCredentialRequest]) (*connect.Response[v1.ExchangeCredentialResponse], error) { + return c.exchangeCredential.CallUnary(ctx, req) +} + +// SyncPolicy calls kontext.agent.v1.AgentService.SyncPolicy. +func (c *agentServiceClient) SyncPolicy(ctx context.Context, req *connect.Request[v1.SyncPolicyRequest]) (*connect.ServerStreamForClient[v1.PolicyUpdate], error) { + return c.syncPolicy.CallServerStream(ctx, req) +} + +// AgentServiceHandler is an implementation of the kontext.agent.v1.AgentService service. +type AgentServiceHandler interface { + // ProcessHookEvent streams tool call events from the CLI to the backend + // and receives policy decisions in return. Bidirectional streaming keeps + // the connection open for the session lifetime — no per-hook HTTP overhead. + ProcessHookEvent(context.Context, *connect.BidiStream[v1.HookEventRequest, v1.HookEventResponse]) error + // CreateSession establishes a governed agent session. Called once at the + // start of `kontext start`. Returns session context used for all subsequent + // hook evaluations. + CreateSession(context.Context, *connect.Request[v1.CreateSessionRequest]) (*connect.Response[v1.CreateSessionResponse], error) + // Heartbeat keeps the session alive. The sidecar sends heartbeats on an + // interval; the backend marks sessions as disconnected if heartbeats stop. + Heartbeat(context.Context, *connect.Request[v1.HeartbeatRequest]) (*connect.Response[v1.HeartbeatResponse], error) + // EndSession terminates the session and revokes any ephemeral credentials. + EndSession(context.Context, *connect.Request[v1.EndSessionRequest]) (*connect.Response[v1.EndSessionResponse], error) + // ExchangeCredential resolves a provider credential for a given user and + // provider handle. Used by the env template hydration and per-tool-call + // credential injection. + ExchangeCredential(context.Context, *connect.Request[v1.ExchangeCredentialRequest]) (*connect.Response[v1.ExchangeCredentialResponse], error) + // SyncPolicy streams the current policy state for the session's org/agent. + // The sidecar caches this locally for fast hook evaluation. The server + // pushes updates when policy changes. + SyncPolicy(context.Context, *connect.Request[v1.SyncPolicyRequest], *connect.ServerStream[v1.PolicyUpdate]) error +} + +// NewAgentServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewAgentServiceHandler(svc AgentServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + agentServiceMethods := v1.File_kontext_agent_v1_agent_proto.Services().ByName("AgentService").Methods() + agentServiceProcessHookEventHandler := connect.NewBidiStreamHandler( + AgentServiceProcessHookEventProcedure, + svc.ProcessHookEvent, + connect.WithSchema(agentServiceMethods.ByName("ProcessHookEvent")), + connect.WithHandlerOptions(opts...), + ) + agentServiceCreateSessionHandler := connect.NewUnaryHandler( + AgentServiceCreateSessionProcedure, + svc.CreateSession, + connect.WithSchema(agentServiceMethods.ByName("CreateSession")), + connect.WithHandlerOptions(opts...), + ) + agentServiceHeartbeatHandler := connect.NewUnaryHandler( + AgentServiceHeartbeatProcedure, + svc.Heartbeat, + connect.WithSchema(agentServiceMethods.ByName("Heartbeat")), + connect.WithHandlerOptions(opts...), + ) + agentServiceEndSessionHandler := connect.NewUnaryHandler( + AgentServiceEndSessionProcedure, + svc.EndSession, + connect.WithSchema(agentServiceMethods.ByName("EndSession")), + connect.WithHandlerOptions(opts...), + ) + agentServiceExchangeCredentialHandler := connect.NewUnaryHandler( + AgentServiceExchangeCredentialProcedure, + svc.ExchangeCredential, + connect.WithSchema(agentServiceMethods.ByName("ExchangeCredential")), + connect.WithHandlerOptions(opts...), + ) + agentServiceSyncPolicyHandler := connect.NewServerStreamHandler( + AgentServiceSyncPolicyProcedure, + svc.SyncPolicy, + connect.WithSchema(agentServiceMethods.ByName("SyncPolicy")), + connect.WithHandlerOptions(opts...), + ) + return "/kontext.agent.v1.AgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case AgentServiceProcessHookEventProcedure: + agentServiceProcessHookEventHandler.ServeHTTP(w, r) + case AgentServiceCreateSessionProcedure: + agentServiceCreateSessionHandler.ServeHTTP(w, r) + case AgentServiceHeartbeatProcedure: + agentServiceHeartbeatHandler.ServeHTTP(w, r) + case AgentServiceEndSessionProcedure: + agentServiceEndSessionHandler.ServeHTTP(w, r) + case AgentServiceExchangeCredentialProcedure: + agentServiceExchangeCredentialHandler.ServeHTTP(w, r) + case AgentServiceSyncPolicyProcedure: + agentServiceSyncPolicyHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedAgentServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedAgentServiceHandler struct{} + +func (UnimplementedAgentServiceHandler) ProcessHookEvent(context.Context, *connect.BidiStream[v1.HookEventRequest, v1.HookEventResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("kontext.agent.v1.AgentService.ProcessHookEvent is not implemented")) +} + +func (UnimplementedAgentServiceHandler) CreateSession(context.Context, *connect.Request[v1.CreateSessionRequest]) (*connect.Response[v1.CreateSessionResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("kontext.agent.v1.AgentService.CreateSession is not implemented")) +} + +func (UnimplementedAgentServiceHandler) Heartbeat(context.Context, *connect.Request[v1.HeartbeatRequest]) (*connect.Response[v1.HeartbeatResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("kontext.agent.v1.AgentService.Heartbeat is not implemented")) +} + +func (UnimplementedAgentServiceHandler) EndSession(context.Context, *connect.Request[v1.EndSessionRequest]) (*connect.Response[v1.EndSessionResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("kontext.agent.v1.AgentService.EndSession is not implemented")) +} + +func (UnimplementedAgentServiceHandler) ExchangeCredential(context.Context, *connect.Request[v1.ExchangeCredentialRequest]) (*connect.Response[v1.ExchangeCredentialResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("kontext.agent.v1.AgentService.ExchangeCredential is not implemented")) +} + +func (UnimplementedAgentServiceHandler) SyncPolicy(context.Context, *connect.Request[v1.SyncPolicyRequest], *connect.ServerStream[v1.PolicyUpdate]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("kontext.agent.v1.AgentService.SyncPolicy is not implemented")) +} diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 2693182..58d9b05 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -1,15 +1,10 @@ -// Package backend provides the client interface for the Kontext API. -// The BackendService interface mirrors the proto AgentService RPCs. -// The REST bridge implementation routes calls to existing REST endpoints; -// when the gRPC server exists, swap NewRESTBridgeClient for NewConnectClient. +// Package backend provides the ConnectRPC client for the Kontext AgentService. package backend import ( - "bytes" "context" "encoding/json" "fmt" - "io" "net/http" "os" "path/filepath" @@ -17,37 +12,20 @@ import ( "sync" "time" - "github.com/google/uuid" -) - -// BackendService is the interface the sidecar and orchestrator depend on. -type BackendService interface { - CreateSession(ctx context.Context, userID, agent, hostname, cwd string) (sessionID, sessionName string, err error) - Heartbeat(ctx context.Context, sessionID string) error - EndSession(ctx context.Context, sessionID string) error - IngestEvent(ctx context.Context, event *IngestEventParams) error -} + "connectrpc.com/connect" -// IngestEventParams holds the fields for a telemetry event. -type IngestEventParams struct { - SessionID string - EventType string // session.begin, session.end, hook.pre_tool_call, hook.post_tool_call, hook.user_prompt - Status string // ok, denied - ToolName string - DurationMs int - TraceID string - RequestJSON any - ResponseJSON any -} + agentv1 "github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1" + "github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1/agentv1connect" +) // Config holds backend connection parameters. type Config struct { - BaseURL string - ClientID string - ClientSecret string + BaseURL string `json:"baseUrl"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` } -// LoadConfig reads backend configuration from environment variables. +// LoadConfig reads backend configuration from env vars or ~/.kontext/config.json. func LoadConfig() (*Config, error) { cfg := &Config{ BaseURL: envOr("KONTEXT_API_URL", "https://api.kontext.security"), @@ -55,10 +33,8 @@ func LoadConfig() (*Config, error) { ClientSecret: os.Getenv("KONTEXT_CLIENT_SECRET"), } - // Try config file if env vars are missing if cfg.ClientID == "" || cfg.ClientSecret == "" { - fileCfg, _ := loadConfigFile() - if fileCfg != nil { + if fileCfg, err := loadConfigFile(); err == nil && fileCfg != nil { if cfg.ClientID == "" { cfg.ClientID = fileCfg.ClientID } @@ -69,7 +45,7 @@ func LoadConfig() (*Config, error) { } if cfg.ClientID == "" || cfg.ClientSecret == "" { - return nil, fmt.Errorf("KONTEXT_CLIENT_ID and KONTEXT_CLIENT_SECRET are required (set via env or ~/.kontext/config.json)") + return nil, fmt.Errorf("KONTEXT_CLIENT_ID and KONTEXT_CLIENT_SECRET required (set via env or ~/.kontext/config.json)") } return cfg, nil @@ -85,10 +61,7 @@ func loadConfigFile() (*Config, error) { return nil, err } var cfg Config - if err := json.Unmarshal(data, &cfg); err != nil { - return nil, err - } - return &cfg, nil + return &cfg, json.Unmarshal(data, &cfg) } func envOr(key, fallback string) string { @@ -98,94 +71,81 @@ func envOr(key, fallback string) string { return fallback } -// --- REST Bridge Client --- - -// RESTBridgeClient implements BackendService using existing REST endpoints. -type RESTBridgeClient struct { - config *Config - httpClient *http.Client - token string - tokenExp time.Time - mu sync.Mutex - userID string // authenticatedUserId for events +// Client wraps the ConnectRPC AgentService client with token management. +type Client struct { + rpc agentv1connect.AgentServiceClient + config *Config + token string + tokenExp time.Time + mu sync.Mutex } -// NewRESTBridgeClient creates a backend client that routes to REST endpoints. -func NewRESTBridgeClient(config *Config) *RESTBridgeClient { - return &RESTBridgeClient{ - config: config, - httpClient: &http.Client{Timeout: 10 * time.Second}, - } -} +// NewClient creates a ConnectRPC client for the Kontext AgentService. +func NewClient(config *Config) *Client { + httpClient := &http.Client{Timeout: 30 * time.Second} -func (c *RESTBridgeClient) CreateSession(ctx context.Context, userID, agent, hostname, cwd string) (string, string, error) { - c.userID = userID + c := &Client{config: config} - token, err := c.getToken(ctx) - if err != nil { - return "", "", fmt.Errorf("auth: %w", err) + // Wrap the HTTP client with an auth interceptor + authClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: &authTransport{client: c, base: httpClient.Transport}, } - body := map[string]any{ - "tokenIdentifier": fmt.Sprintf("cli:%s", uuid.New().String()), - "authenticatedUserId": userID, - "clientSessionId": uuid.New().String(), - "hostname": hostname, - "clientInfo": map[string]string{"name": "kontext-cli", "agent": agent}, - } - - var resp struct { - SessionID string `json:"sessionId"` - Name string `json:"name"` - } - if err := c.doJSON(ctx, "POST", "/api/v1/agent-sessions", token, body, &resp); err != nil { - return "", "", fmt.Errorf("create session: %w", err) - } + c.rpc = agentv1connect.NewAgentServiceClient( + authClient, + config.BaseURL, + ) - return resp.SessionID, resp.Name, nil + return c } -func (c *RESTBridgeClient) Heartbeat(ctx context.Context, sessionID string) error { - token, err := c.getToken(ctx) +// CreateSession creates a governed agent session. +func (c *Client) CreateSession(ctx context.Context, req *agentv1.CreateSessionRequest) (*agentv1.CreateSessionResponse, error) { + resp, err := c.rpc.CreateSession(ctx, connect.NewRequest(req)) if err != nil { - return err + return nil, fmt.Errorf("CreateSession: %w", err) } - return c.doJSON(ctx, "POST", fmt.Sprintf("/api/v1/agent-sessions/%s/heartbeat", sessionID), token, nil, nil) + return resp.Msg, nil } -func (c *RESTBridgeClient) EndSession(ctx context.Context, sessionID string) error { - token, err := c.getToken(ctx) - if err != nil { - return err - } - return c.doJSON(ctx, "POST", fmt.Sprintf("/api/v1/agent-sessions/%s/disconnect", sessionID), token, nil, nil) +// Heartbeat keeps a session alive. +func (c *Client) Heartbeat(ctx context.Context, sessionID string) error { + _, err := c.rpc.Heartbeat(ctx, connect.NewRequest(&agentv1.HeartbeatRequest{ + SessionId: sessionID, + })) + return err } -func (c *RESTBridgeClient) IngestEvent(ctx context.Context, event *IngestEventParams) error { - token, err := c.getToken(ctx) - if err != nil { +// EndSession terminates a session. +func (c *Client) EndSession(ctx context.Context, sessionID string) error { + _, err := c.rpc.EndSession(ctx, connect.NewRequest(&agentv1.EndSessionRequest{ + SessionId: sessionID, + })) + return err +} + +// IngestEvent sends a single hook event to the backend. +func (c *Client) IngestEvent(ctx context.Context, req *agentv1.HookEventRequest) error { + stream := c.rpc.ProcessHookEvent(ctx) + if err := stream.Send(req); err != nil { + return fmt.Errorf("send hook event: %w", err) + } + // For now, send one event per stream. The sidecar will hold a persistent + // stream open once the full bidirectional flow is wired. + if err := stream.CloseRequest(); err != nil { return err } - - body := map[string]any{ - "sessionId": event.SessionID, - "authenticatedUserId": c.userID, - "clientId": c.config.ClientID, - "eventType": event.EventType, - "status": event.Status, - "durationMs": event.DurationMs, - "toolName": event.ToolName, - "traceId": event.TraceID, - "requestJson": event.RequestJSON, - "responseJson": event.ResponseJSON, + // Read the response + if resp, err := stream.Receive(); err == nil { + _ = resp // decision logged server-side } - - return c.doJSON(ctx, "POST", "/api/v1/mcp-events", token, body, nil) + return stream.CloseResponse() } // --- Token management --- -func (c *RESTBridgeClient) getToken(ctx context.Context) (string, error) { +func (c *Client) getToken(ctx context.Context) (string, error) { c.mu.Lock() defer c.mu.Unlock() @@ -194,44 +154,49 @@ func (c *RESTBridgeClient) getToken(ctx context.Context) (string, error) { } // Discover token endpoint + resp, err := http.Get(c.config.BaseURL + "/.well-known/oauth-authorization-server") + if err != nil { + return "", fmt.Errorf("discovery: %w", err) + } + defer resp.Body.Close() + var meta struct { TokenEndpoint string `json:"token_endpoint"` } - if err := c.doGet(ctx, "/.well-known/oauth-authorization-server", &meta); err != nil { - return "", fmt.Errorf("discovery: %w", err) + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { + return "", fmt.Errorf("decode discovery: %w", err) } // Client credentials flow - params := fmt.Sprintf("grant_type=client_credentials&scope=management:all+mcp:invoke") - req, err := http.NewRequestWithContext(ctx, "POST", meta.TokenEndpoint, nil) + req, err := http.NewRequestWithContext(ctx, "POST", meta.TokenEndpoint, + strings.NewReader("grant_type=client_credentials&scope=management:all+mcp:invoke")) if err != nil { return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.SetBasicAuth(c.config.ClientID, c.config.ClientSecret) - req.Body = newStringBody(params) - resp, err := c.httpClient.Do(req) + tokenResp, err := http.DefaultClient.Do(req) if err != nil { return "", err } - defer resp.Body.Close() + defer tokenResp.Body.Close() - if resp.StatusCode != 200 { - return "", fmt.Errorf("token request failed: %s", resp.Status) + if tokenResp.StatusCode != 200 { + return "", fmt.Errorf("token request: %s", tokenResp.Status) } - var tokenResp struct { + var tokenData struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } - if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + if err := json.NewDecoder(tokenResp.Body).Decode(&tokenData); err != nil { return "", err } - c.token = tokenResp.AccessToken - if tokenResp.ExpiresIn > 0 { - c.tokenExp = time.Now().Add(time.Duration(tokenResp.ExpiresIn-60) * time.Second) + c.token = tokenData.AccessToken + if tokenData.ExpiresIn > 0 { + c.tokenExp = time.Now().Add(time.Duration(tokenData.ExpiresIn-60) * time.Second) } else { c.tokenExp = time.Now().Add(50 * time.Minute) } @@ -239,62 +204,22 @@ func (c *RESTBridgeClient) getToken(ctx context.Context) (string, error) { return c.token, nil } -// --- HTTP helpers --- - -func (c *RESTBridgeClient) doJSON(ctx context.Context, method, path, token string, body any, result any) error { - var reqBody io.Reader - if body != nil { - reqBody = newJSONBody(body) - } +// authTransport injects the bearer token into every request. +type authTransport struct { + client *Client + base http.RoundTripper +} - url := c.config.BaseURL + path - req, err := http.NewRequestWithContext(ctx, method, url, reqBody) +func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { + token, err := t.client.getToken(req.Context()) if err != nil { - return err - } - if reqBody != nil { - req.Header.Set("Content-Type", "application/json") + return nil, fmt.Errorf("auth: %w", err) } req.Header.Set("Authorization", "Bearer "+token) - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - var errBody json.RawMessage - json.NewDecoder(resp.Body).Decode(&errBody) - return fmt.Errorf("API %s %s: %d %s", method, path, resp.StatusCode, string(errBody)) - } - - if result != nil { - return json.NewDecoder(resp.Body).Decode(result) - } - return nil -} - -func (c *RESTBridgeClient) doGet(ctx context.Context, path string, result any) error { - url := c.config.BaseURL + path - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return err - } - resp, err := c.httpClient.Do(req) - if err != nil { - return err + base := t.base + if base == nil { + base = http.DefaultTransport } - defer resp.Body.Close() - return json.NewDecoder(resp.Body).Decode(result) -} - -func newJSONBody(v any) io.Reader { - buf := new(bytes.Buffer) - json.NewEncoder(buf).Encode(v) - return buf -} - -func newStringBody(s string) io.ReadCloser { - return io.NopCloser(strings.NewReader(s)) + return base.RoundTrip(req) } diff --git a/internal/run/run.go b/internal/run/run.go index aefaca4..a38ae7a 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -9,7 +9,6 @@ import ( "os/exec" "os/signal" "path/filepath" - "runtime" "strings" "syscall" "time" @@ -17,6 +16,7 @@ import ( "github.com/cli/browser" "github.com/google/uuid" + agentv1 "github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1" "github.com/kontext-dev/kontext-cli/internal/auth" "github.com/kontext-dev/kontext-cli/internal/backend" "github.com/kontext-dev/kontext-cli/internal/credential" @@ -48,47 +48,44 @@ func Start(ctx context.Context, opts Options) error { } fmt.Fprintf(os.Stderr, "✓ Authenticated as %s\n", identity) - // 2. Backend client + // 2. Backend client (native ConnectRPC) backendCfg, err := backend.LoadConfig() if err != nil { fmt.Fprintf(os.Stderr, "⚠ Backend not configured: %v\n", err) fmt.Fprintln(os.Stderr, " Launching without telemetry (set KONTEXT_CLIENT_ID + KONTEXT_CLIENT_SECRET)") return launchAgentDirect(ctx, opts) } - client := backend.NewRESTBridgeClient(backendCfg) + client := backend.NewClient(backendCfg) - // 3. Create session + // 3. Create session via ConnectRPC hostname, _ := os.Hostname() cwd, _ := os.Getwd() - sessionID, sessionName, err := client.CreateSession(ctx, identity, opts.Agent, hostname, cwd) + createResp, err := client.CreateSession(ctx, &agentv1.CreateSessionRequest{ + UserId: identity, + Agent: opts.Agent, + Hostname: hostname, + Cwd: cwd, + ClientInfo: map[string]string{ + "name": "kontext-cli", + "os": fmt.Sprintf("%s", os.Getenv("GOOS")), + }, + }) if err != nil { fmt.Fprintf(os.Stderr, "⚠ Session creation failed: %v\n", err) fmt.Fprintln(os.Stderr, " Launching without telemetry") return launchAgentDirect(ctx, opts) } - fmt.Fprintf(os.Stderr, "✓ Session: %s (%s)\n", sessionName, sessionID[:8]) - traceID := uuid.New().String() + sessionID := createResp.SessionId + fmt.Fprintf(os.Stderr, "✓ Session: %s (%s)\n", createResp.SessionName, sessionID[:8]) - // Ingest session.begin event - client.IngestEvent(ctx, &backend.IngestEventParams{ - SessionID: sessionID, - EventType: "session.begin", - Status: "ok", - TraceID: traceID, - RequestJSON: map[string]string{ - "agent": opts.Agent, - "hostname": hostname, - "cwd": cwd, - "os": runtime.GOOS, - }, - }) + traceID := uuid.New().String() // 4. Start sidecar sessionDir := filepath.Join(os.TempDir(), "kontext", sessionID) os.MkdirAll(sessionDir, 0700) - sc, err := sidecar.New(sessionDir, client, sessionID, traceID) + sc, err := sidecar.New(sessionDir, client, sessionID, traceID, opts.Agent) if err != nil { return fmt.Errorf("sidecar: %w", err) } @@ -130,19 +127,11 @@ func Start(ctx context.Context, opts Options) error { agentErr := launchAgentWithSettings(ctx, opts.Agent, env, opts.Args, settingsPath) // 9. Teardown - duration := int(time.Since(startTime).Milliseconds()) + _ = time.Since(startTime) endCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - client.IngestEvent(endCtx, &backend.IngestEventParams{ - SessionID: sessionID, - EventType: "session.end", - Status: "ok", - DurationMs: duration, - TraceID: traceID, - }) - client.EndSession(endCtx, sessionID) - + _ = client.EndSession(endCtx, sessionID) fmt.Fprintf(os.Stderr, "\n✓ Session ended (%s)\n", sessionID[:8]) os.RemoveAll(sessionDir) @@ -253,13 +242,11 @@ func buildEnv(resolved []credential.Resolved) []string { return credential.BuildEnv(resolved, env) } -// launchAgentDirect launches the agent without hooks or sidecar (fallback). func launchAgentDirect(ctx context.Context, opts Options) error { fmt.Fprintf(os.Stderr, "\nLaunching %s...\n\n", opts.Agent) return launchAgentWithSettings(ctx, opts.Agent, os.Environ(), opts.Args, "") } -// launchAgentWithSettings spawns the agent with optional --settings flag. func launchAgentWithSettings(_ context.Context, agentName string, env, extraArgs []string, settingsPath string) error { binaryPath, err := exec.LookPath(agentName) if err != nil { diff --git a/internal/sidecar/sidecar.go b/internal/sidecar/sidecar.go index 5fb6d84..9149348 100644 --- a/internal/sidecar/sidecar.go +++ b/internal/sidecar/sidecar.go @@ -1,18 +1,17 @@ // Package sidecar implements the local session server. -// Hook handlers (kontext hook) connect to the sidecar over a Unix socket. -// The sidecar relays events to the backend and returns policy decisions. +// Hook handlers connect over a Unix socket. The sidecar relays events +// to the Kontext backend via ConnectRPC and returns policy decisions. package sidecar import ( "context" - "encoding/json" - "fmt" "log" "net" "os" "path/filepath" "time" + agentv1 "github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1" "github.com/kontext-dev/kontext-cli/internal/backend" ) @@ -22,25 +21,24 @@ type Server struct { listener net.Listener sessionID string traceID string - backend backend.BackendService + agentName string + client *backend.Client cancel context.CancelFunc } // New creates a new sidecar server. -func New(sessionDir string, b backend.BackendService, sessionID, traceID string) (*Server, error) { - socketPath := filepath.Join(sessionDir, "kontext.sock") +func New(sessionDir string, client *backend.Client, sessionID, traceID, agentName string) (*Server, error) { return &Server{ - socketPath: socketPath, + socketPath: filepath.Join(sessionDir, "kontext.sock"), sessionID: sessionID, traceID: traceID, - backend: b, + agentName: agentName, + client: client, }, nil } -// SocketPath returns the Unix socket path for hook handlers. -func (s *Server) SocketPath() string { - return s.socketPath -} +// SocketPath returns the Unix socket path. +func (s *Server) SocketPath() string { return s.socketPath } // Start begins listening and processing hook events. func (s *Server) Start(ctx context.Context) error { @@ -48,12 +46,11 @@ func (s *Server) Start(ctx context.Context) error { ln, err := net.Listen("unix", s.socketPath) if err != nil { - return fmt.Errorf("sidecar listen: %w", err) + return err } s.listener = ln ctx, s.cancel = context.WithCancel(ctx) - go s.acceptLoop(ctx) go s.heartbeatLoop(ctx) @@ -92,51 +89,40 @@ func (s *Server) handleConn(ctx context.Context, conn net.Conn) { var req EvaluateRequest if err := ReadMessage(conn, &req); err != nil { - log.Printf("sidecar: read error: %v", err) + log.Printf("sidecar: read: %v", err) return } // Always allow for now — policy evaluation is a future phase - result := EvaluateResult{ - Type: "result", - Allowed: true, - Reason: "allowed", - } - - // Write response immediately — don't block on event ingestion + result := EvaluateResult{Type: "result", Allowed: true, Reason: "allowed"} if err := WriteMessage(conn, result); err != nil { - log.Printf("sidecar: write error: %v", err) + log.Printf("sidecar: write: %v", err) return } - // Ingest event asynchronously + // Ingest event asynchronously via ConnectRPC go s.ingestEvent(ctx, &req) } func (s *Server) ingestEvent(ctx context.Context, req *EvaluateRequest) { - eventType := "hook." + normalizeHookEvent(req.HookEvent) - status := "ok" + hookEvent := &agentv1.HookEventRequest{ + SessionId: s.sessionID, + Agent: s.agentName, + HookEvent: req.HookEvent, + ToolName: req.ToolName, + ToolUseId: req.ToolUseID, + Cwd: req.CWD, + } - var reqJSON, respJSON any if len(req.ToolInput) > 0 { - json.Unmarshal(req.ToolInput, &reqJSON) + hookEvent.ToolInput = req.ToolInput } if len(req.ToolResponse) > 0 { - json.Unmarshal(req.ToolResponse, &respJSON) + hookEvent.ToolResponse = req.ToolResponse } - err := s.backend.IngestEvent(ctx, &backend.IngestEventParams{ - SessionID: s.sessionID, - EventType: eventType, - Status: status, - ToolName: req.ToolName, - DurationMs: 0, - TraceID: s.traceID, - RequestJSON: reqJSON, - ResponseJSON: respJSON, - }) - if err != nil { - log.Printf("sidecar: ingest error: %v", err) + if err := s.client.IngestEvent(ctx, hookEvent); err != nil { + log.Printf("sidecar: ingest: %v", err) } } @@ -148,22 +134,9 @@ func (s *Server) heartbeatLoop(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - if err := s.backend.Heartbeat(ctx, s.sessionID); err != nil { - log.Printf("sidecar: heartbeat error: %v", err) + if err := s.client.Heartbeat(ctx, s.sessionID); err != nil { + log.Printf("sidecar: heartbeat: %v", err) } } } } - -func normalizeHookEvent(event string) string { - switch event { - case "PreToolUse": - return "pre_tool_call" - case "PostToolUse": - return "post_tool_call" - case "UserPromptSubmit": - return "user_prompt" - default: - return event - } -} From aaa51330d534e37c228e5f9a0091227809916ae1 Mon Sep 17 00:00:00 2001 From: tumberger Date: Sun, 5 Apr 2026 19:59:18 +0200 Subject: [PATCH 04/11] fix: regenerate proto with renamed types and managed mode - ProcessHookEventRequest (was HookEventRequest) - ProcessHookEventResponse (was HookEventResponse) - SyncPolicyResponse (was PolicyUpdate) - buf.gen.yaml uses managed mode to override go_package to CLI module path Co-Authored-By: Claude Opus 4.6 (1M context) --- buf.gen.yaml | 5 + gen/kontext/agent/v1/agent.pb.go | 118 +++++++++--------- .../agent/v1/agentv1connect/agent.connect.go | 24 ++-- internal/backend/backend.go | 2 +- internal/sidecar/sidecar.go | 2 +- 5 files changed, 79 insertions(+), 72 deletions(-) diff --git a/buf.gen.yaml b/buf.gen.yaml index 081ed56..1f59970 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -1,4 +1,9 @@ version: v2 +managed: + enabled: true + override: + - file_option: go_package_prefix + value: github.com/kontext-dev/kontext-cli/gen inputs: - git_repo: https://github.com/kontext-dev/proto.git branch: main diff --git a/gen/kontext/agent/v1/agent.pb.go b/gen/kontext/agent/v1/agent.pb.go index 3271dbe..3fed18d 100644 --- a/gen/kontext/agent/v1/agent.pb.go +++ b/gen/kontext/agent/v1/agent.pb.go @@ -122,7 +122,7 @@ func (CredentialKind) EnumDescriptor() ([]byte, []int) { return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{1} } -type HookEventRequest struct { +type ProcessHookEventRequest struct { state protoimpl.MessageState `protogen:"open.v1"` SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` Agent string `protobuf:"bytes,2,opt,name=agent,proto3" json:"agent,omitempty"` // "claude", "cursor", "codex" @@ -136,20 +136,20 @@ type HookEventRequest struct { sizeCache protoimpl.SizeCache } -func (x *HookEventRequest) Reset() { - *x = HookEventRequest{} +func (x *ProcessHookEventRequest) Reset() { + *x = ProcessHookEventRequest{} mi := &file_kontext_agent_v1_agent_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *HookEventRequest) String() string { +func (x *ProcessHookEventRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*HookEventRequest) ProtoMessage() {} +func (*ProcessHookEventRequest) ProtoMessage() {} -func (x *HookEventRequest) ProtoReflect() protoreflect.Message { +func (x *ProcessHookEventRequest) ProtoReflect() protoreflect.Message { mi := &file_kontext_agent_v1_agent_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -161,68 +161,68 @@ func (x *HookEventRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use HookEventRequest.ProtoReflect.Descriptor instead. -func (*HookEventRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use ProcessHookEventRequest.ProtoReflect.Descriptor instead. +func (*ProcessHookEventRequest) Descriptor() ([]byte, []int) { return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{0} } -func (x *HookEventRequest) GetSessionId() string { +func (x *ProcessHookEventRequest) GetSessionId() string { if x != nil { return x.SessionId } return "" } -func (x *HookEventRequest) GetAgent() string { +func (x *ProcessHookEventRequest) GetAgent() string { if x != nil { return x.Agent } return "" } -func (x *HookEventRequest) GetHookEvent() string { +func (x *ProcessHookEventRequest) GetHookEvent() string { if x != nil { return x.HookEvent } return "" } -func (x *HookEventRequest) GetToolName() string { +func (x *ProcessHookEventRequest) GetToolName() string { if x != nil { return x.ToolName } return "" } -func (x *HookEventRequest) GetToolInput() []byte { +func (x *ProcessHookEventRequest) GetToolInput() []byte { if x != nil { return x.ToolInput } return nil } -func (x *HookEventRequest) GetToolResponse() []byte { +func (x *ProcessHookEventRequest) GetToolResponse() []byte { if x != nil { return x.ToolResponse } return nil } -func (x *HookEventRequest) GetToolUseId() string { +func (x *ProcessHookEventRequest) GetToolUseId() string { if x != nil { return x.ToolUseId } return "" } -func (x *HookEventRequest) GetCwd() string { +func (x *ProcessHookEventRequest) GetCwd() string { if x != nil { return x.Cwd } return "" } -type HookEventResponse struct { +type ProcessHookEventResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Decision Decision `protobuf:"varint,1,opt,name=decision,proto3,enum=kontext.agent.v1.Decision" json:"decision,omitempty"` Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` @@ -233,20 +233,20 @@ type HookEventResponse struct { sizeCache protoimpl.SizeCache } -func (x *HookEventResponse) Reset() { - *x = HookEventResponse{} +func (x *ProcessHookEventResponse) Reset() { + *x = ProcessHookEventResponse{} mi := &file_kontext_agent_v1_agent_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *HookEventResponse) String() string { +func (x *ProcessHookEventResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*HookEventResponse) ProtoMessage() {} +func (*ProcessHookEventResponse) ProtoMessage() {} -func (x *HookEventResponse) ProtoReflect() protoreflect.Message { +func (x *ProcessHookEventResponse) ProtoReflect() protoreflect.Message { mi := &file_kontext_agent_v1_agent_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -258,33 +258,33 @@ func (x *HookEventResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use HookEventResponse.ProtoReflect.Descriptor instead. -func (*HookEventResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use ProcessHookEventResponse.ProtoReflect.Descriptor instead. +func (*ProcessHookEventResponse) Descriptor() ([]byte, []int) { return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{1} } -func (x *HookEventResponse) GetDecision() Decision { +func (x *ProcessHookEventResponse) GetDecision() Decision { if x != nil { return x.Decision } return Decision_DECISION_UNSPECIFIED } -func (x *HookEventResponse) GetReason() string { +func (x *ProcessHookEventResponse) GetReason() string { if x != nil { return x.Reason } return "" } -func (x *HookEventResponse) GetEventId() string { +func (x *ProcessHookEventResponse) GetEventId() string { if x != nil { return x.EventId } return "" } -func (x *HookEventResponse) GetCredential() *CredentialInjection { +func (x *ProcessHookEventResponse) GetCredential() *CredentialInjection { if x != nil { return x.Credential } @@ -843,7 +843,7 @@ func (x *SyncPolicyRequest) GetSessionId() string { return "" } -type PolicyUpdate struct { +type SyncPolicyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // OpenFGA tuples for local evaluation Tuples []*PolicyTuple `protobuf:"bytes,1,rep,name=tuples,proto3" json:"tuples,omitempty"` @@ -852,20 +852,20 @@ type PolicyUpdate struct { sizeCache protoimpl.SizeCache } -func (x *PolicyUpdate) Reset() { - *x = PolicyUpdate{} +func (x *SyncPolicyResponse) Reset() { + *x = SyncPolicyResponse{} mi := &file_kontext_agent_v1_agent_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *PolicyUpdate) String() string { +func (x *SyncPolicyResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*PolicyUpdate) ProtoMessage() {} +func (*SyncPolicyResponse) ProtoMessage() {} -func (x *PolicyUpdate) ProtoReflect() protoreflect.Message { +func (x *SyncPolicyResponse) ProtoReflect() protoreflect.Message { mi := &file_kontext_agent_v1_agent_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -877,19 +877,19 @@ func (x *PolicyUpdate) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use PolicyUpdate.ProtoReflect.Descriptor instead. -func (*PolicyUpdate) Descriptor() ([]byte, []int) { +// Deprecated: Use SyncPolicyResponse.ProtoReflect.Descriptor instead. +func (*SyncPolicyResponse) Descriptor() ([]byte, []int) { return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{12} } -func (x *PolicyUpdate) GetTuples() []*PolicyTuple { +func (x *SyncPolicyResponse) GetTuples() []*PolicyTuple { if x != nil { return x.Tuples } return nil } -func (x *PolicyUpdate) GetFullSync() bool { +func (x *SyncPolicyResponse) GetFullSync() bool { if x != nil { return x.FullSync } @@ -960,8 +960,8 @@ var File_kontext_agent_v1_agent_proto protoreflect.FileDescriptor const file_kontext_agent_v1_agent_proto_rawDesc = "" + "\n" + - "\x1ckontext/agent/v1/agent.proto\x12\x10kontext.agent.v1\"\xf9\x01\n" + - "\x10HookEventRequest\x12\x1d\n" + + "\x1ckontext/agent/v1/agent.proto\x12\x10kontext.agent.v1\"\x80\x02\n" + + "\x17ProcessHookEventRequest\x12\x1d\n" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x14\n" + "\x05agent\x18\x02 \x01(\tR\x05agent\x12\x1d\n" + @@ -972,8 +972,8 @@ const file_kontext_agent_v1_agent_proto_rawDesc = "" + "tool_input\x18\x05 \x01(\fR\ttoolInput\x12#\n" + "\rtool_response\x18\x06 \x01(\fR\ftoolResponse\x12\x1e\n" + "\vtool_use_id\x18\a \x01(\tR\ttoolUseId\x12\x10\n" + - "\x03cwd\x18\b \x01(\tR\x03cwd\"\xc5\x01\n" + - "\x11HookEventResponse\x126\n" + + "\x03cwd\x18\b \x01(\tR\x03cwd\"\xcc\x01\n" + + "\x18ProcessHookEventResponse\x126\n" + "\bdecision\x18\x01 \x01(\x0e2\x1a.kontext.agent.v1.DecisionR\bdecision\x12\x16\n" + "\x06reason\x18\x02 \x01(\tR\x06reason\x12\x19\n" + "\bevent_id\x18\x03 \x01(\tR\aeventId\x12E\n" + @@ -1026,8 +1026,8 @@ const file_kontext_agent_v1_agent_proto_rawDesc = "" + "expires_in\x18\a \x01(\x03R\texpiresIn\"2\n" + "\x11SyncPolicyRequest\x12\x1d\n" + "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\"b\n" + - "\fPolicyUpdate\x125\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"h\n" + + "\x12SyncPolicyResponse\x125\n" + "\x06tuples\x18\x01 \x03(\v2\x1d.kontext.agent.v1.PolicyTupleR\x06tuples\x12\x1b\n" + "\tfull_sync\x18\x02 \x01(\bR\bfullSync\"U\n" + "\vPolicyTuple\x12\x12\n" + @@ -1042,16 +1042,18 @@ const file_kontext_agent_v1_agent_proto_rawDesc = "" + "\x0eCredentialKind\x12\x1f\n" + "\x1bCREDENTIAL_KIND_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15CREDENTIAL_KIND_OAUTH\x10\x01\x12\x17\n" + - "\x13CREDENTIAL_KIND_KEY\x10\x022\xc6\x04\n" + - "\fAgentService\x12_\n" + - "\x10ProcessHookEvent\x12\".kontext.agent.v1.HookEventRequest\x1a#.kontext.agent.v1.HookEventResponse(\x010\x01\x12`\n" + + "\x13CREDENTIAL_KIND_KEY\x10\x022\xda\x04\n" + + "\fAgentService\x12m\n" + + "\x10ProcessHookEvent\x12).kontext.agent.v1.ProcessHookEventRequest\x1a*.kontext.agent.v1.ProcessHookEventResponse(\x010\x01\x12`\n" + "\rCreateSession\x12&.kontext.agent.v1.CreateSessionRequest\x1a'.kontext.agent.v1.CreateSessionResponse\x12T\n" + "\tHeartbeat\x12\".kontext.agent.v1.HeartbeatRequest\x1a#.kontext.agent.v1.HeartbeatResponse\x12W\n" + "\n" + "EndSession\x12#.kontext.agent.v1.EndSessionRequest\x1a$.kontext.agent.v1.EndSessionResponse\x12o\n" + - "\x12ExchangeCredential\x12+.kontext.agent.v1.ExchangeCredentialRequest\x1a,.kontext.agent.v1.ExchangeCredentialResponse\x12S\n" + + "\x12ExchangeCredential\x12+.kontext.agent.v1.ExchangeCredentialRequest\x1a,.kontext.agent.v1.ExchangeCredentialResponse\x12Y\n" + "\n" + - "SyncPolicy\x12#.kontext.agent.v1.SyncPolicyRequest\x1a\x1e.kontext.agent.v1.PolicyUpdate0\x01BAZ?github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1;agentv1b\x06proto3" + "SyncPolicy\x12#.kontext.agent.v1.SyncPolicyRequest\x1a$.kontext.agent.v1.SyncPolicyResponse0\x01B\xc5\x01\n" + + "\x14com.kontext.agent.v1B\n" + + "AgentProtoP\x01Z?github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1;agentv1\xa2\x02\x03KAX\xaa\x02\x10Kontext.Agent.V1\xca\x02\x10Kontext\\Agent\\V1\xe2\x02\x1cKontext\\Agent\\V1\\GPBMetadata\xea\x02\x12Kontext::Agent::V1b\x06proto3" var ( file_kontext_agent_v1_agent_proto_rawDescOnce sync.Once @@ -1070,8 +1072,8 @@ var file_kontext_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 1 var file_kontext_agent_v1_agent_proto_goTypes = []any{ (Decision)(0), // 0: kontext.agent.v1.Decision (CredentialKind)(0), // 1: kontext.agent.v1.CredentialKind - (*HookEventRequest)(nil), // 2: kontext.agent.v1.HookEventRequest - (*HookEventResponse)(nil), // 3: kontext.agent.v1.HookEventResponse + (*ProcessHookEventRequest)(nil), // 2: kontext.agent.v1.ProcessHookEventRequest + (*ProcessHookEventResponse)(nil), // 3: kontext.agent.v1.ProcessHookEventResponse (*CredentialInjection)(nil), // 4: kontext.agent.v1.CredentialInjection (*CreateSessionRequest)(nil), // 5: kontext.agent.v1.CreateSessionRequest (*CreateSessionResponse)(nil), // 6: kontext.agent.v1.CreateSessionResponse @@ -1082,30 +1084,30 @@ var file_kontext_agent_v1_agent_proto_goTypes = []any{ (*ExchangeCredentialRequest)(nil), // 11: kontext.agent.v1.ExchangeCredentialRequest (*ExchangeCredentialResponse)(nil), // 12: kontext.agent.v1.ExchangeCredentialResponse (*SyncPolicyRequest)(nil), // 13: kontext.agent.v1.SyncPolicyRequest - (*PolicyUpdate)(nil), // 14: kontext.agent.v1.PolicyUpdate + (*SyncPolicyResponse)(nil), // 14: kontext.agent.v1.SyncPolicyResponse (*PolicyTuple)(nil), // 15: kontext.agent.v1.PolicyTuple nil, // 16: kontext.agent.v1.CredentialInjection.EnvVarsEntry nil, // 17: kontext.agent.v1.CreateSessionRequest.ClientInfoEntry } var file_kontext_agent_v1_agent_proto_depIdxs = []int32{ - 0, // 0: kontext.agent.v1.HookEventResponse.decision:type_name -> kontext.agent.v1.Decision - 4, // 1: kontext.agent.v1.HookEventResponse.credential:type_name -> kontext.agent.v1.CredentialInjection + 0, // 0: kontext.agent.v1.ProcessHookEventResponse.decision:type_name -> kontext.agent.v1.Decision + 4, // 1: kontext.agent.v1.ProcessHookEventResponse.credential:type_name -> kontext.agent.v1.CredentialInjection 16, // 2: kontext.agent.v1.CredentialInjection.env_vars:type_name -> kontext.agent.v1.CredentialInjection.EnvVarsEntry 17, // 3: kontext.agent.v1.CreateSessionRequest.client_info:type_name -> kontext.agent.v1.CreateSessionRequest.ClientInfoEntry 1, // 4: kontext.agent.v1.ExchangeCredentialResponse.kind:type_name -> kontext.agent.v1.CredentialKind - 15, // 5: kontext.agent.v1.PolicyUpdate.tuples:type_name -> kontext.agent.v1.PolicyTuple - 2, // 6: kontext.agent.v1.AgentService.ProcessHookEvent:input_type -> kontext.agent.v1.HookEventRequest + 15, // 5: kontext.agent.v1.SyncPolicyResponse.tuples:type_name -> kontext.agent.v1.PolicyTuple + 2, // 6: kontext.agent.v1.AgentService.ProcessHookEvent:input_type -> kontext.agent.v1.ProcessHookEventRequest 5, // 7: kontext.agent.v1.AgentService.CreateSession:input_type -> kontext.agent.v1.CreateSessionRequest 7, // 8: kontext.agent.v1.AgentService.Heartbeat:input_type -> kontext.agent.v1.HeartbeatRequest 9, // 9: kontext.agent.v1.AgentService.EndSession:input_type -> kontext.agent.v1.EndSessionRequest 11, // 10: kontext.agent.v1.AgentService.ExchangeCredential:input_type -> kontext.agent.v1.ExchangeCredentialRequest 13, // 11: kontext.agent.v1.AgentService.SyncPolicy:input_type -> kontext.agent.v1.SyncPolicyRequest - 3, // 12: kontext.agent.v1.AgentService.ProcessHookEvent:output_type -> kontext.agent.v1.HookEventResponse + 3, // 12: kontext.agent.v1.AgentService.ProcessHookEvent:output_type -> kontext.agent.v1.ProcessHookEventResponse 6, // 13: kontext.agent.v1.AgentService.CreateSession:output_type -> kontext.agent.v1.CreateSessionResponse 8, // 14: kontext.agent.v1.AgentService.Heartbeat:output_type -> kontext.agent.v1.HeartbeatResponse 10, // 15: kontext.agent.v1.AgentService.EndSession:output_type -> kontext.agent.v1.EndSessionResponse 12, // 16: kontext.agent.v1.AgentService.ExchangeCredential:output_type -> kontext.agent.v1.ExchangeCredentialResponse - 14, // 17: kontext.agent.v1.AgentService.SyncPolicy:output_type -> kontext.agent.v1.PolicyUpdate + 14, // 17: kontext.agent.v1.AgentService.SyncPolicy:output_type -> kontext.agent.v1.SyncPolicyResponse 12, // [12:18] is the sub-list for method output_type 6, // [6:12] is the sub-list for method input_type 6, // [6:6] is the sub-list for extension type_name diff --git a/gen/kontext/agent/v1/agentv1connect/agent.connect.go b/gen/kontext/agent/v1/agentv1connect/agent.connect.go index d935db9..c471e0d 100644 --- a/gen/kontext/agent/v1/agentv1connect/agent.connect.go +++ b/gen/kontext/agent/v1/agentv1connect/agent.connect.go @@ -55,7 +55,7 @@ type AgentServiceClient interface { // ProcessHookEvent streams tool call events from the CLI to the backend // and receives policy decisions in return. Bidirectional streaming keeps // the connection open for the session lifetime — no per-hook HTTP overhead. - ProcessHookEvent(context.Context) *connect.BidiStreamForClient[v1.HookEventRequest, v1.HookEventResponse] + ProcessHookEvent(context.Context) *connect.BidiStreamForClient[v1.ProcessHookEventRequest, v1.ProcessHookEventResponse] // CreateSession establishes a governed agent session. Called once at the // start of `kontext start`. Returns session context used for all subsequent // hook evaluations. @@ -72,7 +72,7 @@ type AgentServiceClient interface { // SyncPolicy streams the current policy state for the session's org/agent. // The sidecar caches this locally for fast hook evaluation. The server // pushes updates when policy changes. - SyncPolicy(context.Context, *connect.Request[v1.SyncPolicyRequest]) (*connect.ServerStreamForClient[v1.PolicyUpdate], error) + SyncPolicy(context.Context, *connect.Request[v1.SyncPolicyRequest]) (*connect.ServerStreamForClient[v1.SyncPolicyResponse], error) } // NewAgentServiceClient constructs a client for the kontext.agent.v1.AgentService service. By @@ -86,7 +86,7 @@ func NewAgentServiceClient(httpClient connect.HTTPClient, baseURL string, opts . baseURL = strings.TrimRight(baseURL, "/") agentServiceMethods := v1.File_kontext_agent_v1_agent_proto.Services().ByName("AgentService").Methods() return &agentServiceClient{ - processHookEvent: connect.NewClient[v1.HookEventRequest, v1.HookEventResponse]( + processHookEvent: connect.NewClient[v1.ProcessHookEventRequest, v1.ProcessHookEventResponse]( httpClient, baseURL+AgentServiceProcessHookEventProcedure, connect.WithSchema(agentServiceMethods.ByName("ProcessHookEvent")), @@ -116,7 +116,7 @@ func NewAgentServiceClient(httpClient connect.HTTPClient, baseURL string, opts . connect.WithSchema(agentServiceMethods.ByName("ExchangeCredential")), connect.WithClientOptions(opts...), ), - syncPolicy: connect.NewClient[v1.SyncPolicyRequest, v1.PolicyUpdate]( + syncPolicy: connect.NewClient[v1.SyncPolicyRequest, v1.SyncPolicyResponse]( httpClient, baseURL+AgentServiceSyncPolicyProcedure, connect.WithSchema(agentServiceMethods.ByName("SyncPolicy")), @@ -127,16 +127,16 @@ func NewAgentServiceClient(httpClient connect.HTTPClient, baseURL string, opts . // agentServiceClient implements AgentServiceClient. type agentServiceClient struct { - processHookEvent *connect.Client[v1.HookEventRequest, v1.HookEventResponse] + processHookEvent *connect.Client[v1.ProcessHookEventRequest, v1.ProcessHookEventResponse] createSession *connect.Client[v1.CreateSessionRequest, v1.CreateSessionResponse] heartbeat *connect.Client[v1.HeartbeatRequest, v1.HeartbeatResponse] endSession *connect.Client[v1.EndSessionRequest, v1.EndSessionResponse] exchangeCredential *connect.Client[v1.ExchangeCredentialRequest, v1.ExchangeCredentialResponse] - syncPolicy *connect.Client[v1.SyncPolicyRequest, v1.PolicyUpdate] + syncPolicy *connect.Client[v1.SyncPolicyRequest, v1.SyncPolicyResponse] } // ProcessHookEvent calls kontext.agent.v1.AgentService.ProcessHookEvent. -func (c *agentServiceClient) ProcessHookEvent(ctx context.Context) *connect.BidiStreamForClient[v1.HookEventRequest, v1.HookEventResponse] { +func (c *agentServiceClient) ProcessHookEvent(ctx context.Context) *connect.BidiStreamForClient[v1.ProcessHookEventRequest, v1.ProcessHookEventResponse] { return c.processHookEvent.CallBidiStream(ctx) } @@ -161,7 +161,7 @@ func (c *agentServiceClient) ExchangeCredential(ctx context.Context, req *connec } // SyncPolicy calls kontext.agent.v1.AgentService.SyncPolicy. -func (c *agentServiceClient) SyncPolicy(ctx context.Context, req *connect.Request[v1.SyncPolicyRequest]) (*connect.ServerStreamForClient[v1.PolicyUpdate], error) { +func (c *agentServiceClient) SyncPolicy(ctx context.Context, req *connect.Request[v1.SyncPolicyRequest]) (*connect.ServerStreamForClient[v1.SyncPolicyResponse], error) { return c.syncPolicy.CallServerStream(ctx, req) } @@ -170,7 +170,7 @@ type AgentServiceHandler interface { // ProcessHookEvent streams tool call events from the CLI to the backend // and receives policy decisions in return. Bidirectional streaming keeps // the connection open for the session lifetime — no per-hook HTTP overhead. - ProcessHookEvent(context.Context, *connect.BidiStream[v1.HookEventRequest, v1.HookEventResponse]) error + ProcessHookEvent(context.Context, *connect.BidiStream[v1.ProcessHookEventRequest, v1.ProcessHookEventResponse]) error // CreateSession establishes a governed agent session. Called once at the // start of `kontext start`. Returns session context used for all subsequent // hook evaluations. @@ -187,7 +187,7 @@ type AgentServiceHandler interface { // SyncPolicy streams the current policy state for the session's org/agent. // The sidecar caches this locally for fast hook evaluation. The server // pushes updates when policy changes. - SyncPolicy(context.Context, *connect.Request[v1.SyncPolicyRequest], *connect.ServerStream[v1.PolicyUpdate]) error + SyncPolicy(context.Context, *connect.Request[v1.SyncPolicyRequest], *connect.ServerStream[v1.SyncPolicyResponse]) error } // NewAgentServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -256,7 +256,7 @@ func NewAgentServiceHandler(svc AgentServiceHandler, opts ...connect.HandlerOpti // UnimplementedAgentServiceHandler returns CodeUnimplemented from all methods. type UnimplementedAgentServiceHandler struct{} -func (UnimplementedAgentServiceHandler) ProcessHookEvent(context.Context, *connect.BidiStream[v1.HookEventRequest, v1.HookEventResponse]) error { +func (UnimplementedAgentServiceHandler) ProcessHookEvent(context.Context, *connect.BidiStream[v1.ProcessHookEventRequest, v1.ProcessHookEventResponse]) error { return connect.NewError(connect.CodeUnimplemented, errors.New("kontext.agent.v1.AgentService.ProcessHookEvent is not implemented")) } @@ -276,6 +276,6 @@ func (UnimplementedAgentServiceHandler) ExchangeCredential(context.Context, *con return nil, connect.NewError(connect.CodeUnimplemented, errors.New("kontext.agent.v1.AgentService.ExchangeCredential is not implemented")) } -func (UnimplementedAgentServiceHandler) SyncPolicy(context.Context, *connect.Request[v1.SyncPolicyRequest], *connect.ServerStream[v1.PolicyUpdate]) error { +func (UnimplementedAgentServiceHandler) SyncPolicy(context.Context, *connect.Request[v1.SyncPolicyRequest], *connect.ServerStream[v1.SyncPolicyResponse]) error { return connect.NewError(connect.CodeUnimplemented, errors.New("kontext.agent.v1.AgentService.SyncPolicy is not implemented")) } diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 58d9b05..62a1442 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -126,7 +126,7 @@ func (c *Client) EndSession(ctx context.Context, sessionID string) error { } // IngestEvent sends a single hook event to the backend. -func (c *Client) IngestEvent(ctx context.Context, req *agentv1.HookEventRequest) error { +func (c *Client) IngestEvent(ctx context.Context, req *agentv1.ProcessHookEventRequest) error { stream := c.rpc.ProcessHookEvent(ctx) if err := stream.Send(req); err != nil { return fmt.Errorf("send hook event: %w", err) diff --git a/internal/sidecar/sidecar.go b/internal/sidecar/sidecar.go index 9149348..ae32bbe 100644 --- a/internal/sidecar/sidecar.go +++ b/internal/sidecar/sidecar.go @@ -105,7 +105,7 @@ func (s *Server) handleConn(ctx context.Context, conn net.Conn) { } func (s *Server) ingestEvent(ctx context.Context, req *EvaluateRequest) { - hookEvent := &agentv1.HookEventRequest{ + hookEvent := &agentv1.ProcessHookEventRequest{ SessionId: s.sessionID, Agent: s.agentName, HookEvent: req.HookEvent, From c24f3b5d039d041fec71995fe738391cd5dd4b43 Mon Sep 17 00:00:00 2001 From: tumberger Date: Sun, 5 Apr 2026 20:07:54 +0200 Subject: [PATCH 05/11] fix: remove REST bridge references from README Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 210f232..0872d24 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,10 @@ Commit this to your repo — the team shares it. kontext start --agent claude │ ├── Auth: OIDC refresh token from keyring - ├── Backend: REST bridge client → CreateSession + ├── ConnectRPC: CreateSession → session in dashboard ├── Sidecar: Unix socket server (kontext.sock) │ ├── Heartbeat loop (30s) - │ └── Async event ingestion to backend + │ └── Async event ingestion via ConnectRPC ├── Hooks: settings.json → Claude Code --settings ├── Agent: spawn claude with injected env │ │ @@ -79,8 +79,7 @@ kontext start --agent claude │ ├── [PostToolUse] → kontext hook → sidecar → ingest │ └── [UserPromptSubmit] → kontext hook → sidecar → ingest │ - ├── On exit: EndSession → cleanup - └── Backend: REST bridge (swappable to native ConnectRPC) + └── On exit: EndSession → cleanup ``` ### Hook flow (per tool call) @@ -142,7 +141,7 @@ This is additive — the governance pipeline works independently. OTEL export is Service definitions: [`proto/kontext/agent/v1/agent.proto`](proto/kontext/agent/v1/agent.proto) -The proto defines the target architecture (native ConnectRPC). The CLI currently uses a REST bridge client that maps proto RPCs to existing Kontext REST endpoints. When the gRPC `AgentService` is deployed server-side, the swap is one constructor call. +The CLI communicates with the Kontext backend exclusively via ConnectRPC using the generated stubs. Requires the server-side `AgentService` endpoint ([kontext-dev/kontext#408](https://github.com/kontext-dev/kontext/issues/408)). ### Sidecar wire protocol From d954822746d2442286a453487bd7c9fe5c4ab45d Mon Sep 17 00:00:00 2001 From: tumberger Date: Sun, 5 Apr 2026 20:15:20 +0200 Subject: [PATCH 06/11] fix: address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Critical: teardown now runs on non-zero agent exit (os.Exit moved after cleanup, signal goroutine properly closed) - Discovery endpoint cached after first call, uses context - Token expiry floor prevents hot-loop on short-lived tokens - Remove dead code: initTemplate, startTime, traceID, unused httpClient - Fix os.Getenv("GOOS") → runtime.GOOS - README: fix stale REST endpoint reference - Use http.DefaultTransport explicitly Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 +- internal/backend/backend.go | 68 +++++++++++++++++++++++-------------- internal/run/run.go | 66 +++++++---------------------------- internal/sidecar/sidecar.go | 4 +-- 4 files changed, 58 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 0872d24..9216a92 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Session lifecycle and tool call events flow to the Kontext backend. This powers | `hook.post_tool_call` | PostToolUse hook | After every tool execution | | `hook.user_prompt` | UserPromptSubmit hook | User submits a prompt | -Events are ingested to the `mcp_events` table via `POST /api/v1/mcp-events`. Each session gets a `traceId` for grouping events in the traces view. +Events are streamed to the backend via the ConnectRPC `ProcessHookEvent` bidirectional stream and stored in the `mcp_events` table. **What governance telemetry captures:** - What the agent tried to do (tool name + input) diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 62a1442..873c201 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -73,23 +73,21 @@ func envOr(key, fallback string) string { // Client wraps the ConnectRPC AgentService client with token management. type Client struct { - rpc agentv1connect.AgentServiceClient - config *Config - token string - tokenExp time.Time - mu sync.Mutex + rpc agentv1connect.AgentServiceClient + config *Config + token string + tokenExp time.Time + tokenEndpoint string + mu sync.Mutex } // NewClient creates a ConnectRPC client for the Kontext AgentService. func NewClient(config *Config) *Client { - httpClient := &http.Client{Timeout: 30 * time.Second} - c := &Client{config: config} - // Wrap the HTTP client with an auth interceptor authClient := &http.Client{ Timeout: 30 * time.Second, - Transport: &authTransport{client: c, base: httpClient.Transport}, + Transport: &authTransport{client: c, base: http.DefaultTransport}, } c.rpc = agentv1connect.NewAgentServiceClient( @@ -153,22 +151,14 @@ func (c *Client) getToken(ctx context.Context) (string, error) { return c.token, nil } - // Discover token endpoint - resp, err := http.Get(c.config.BaseURL + "/.well-known/oauth-authorization-server") + // Discover token endpoint (cached after first call) + tokenEndpoint, err := c.discoverTokenEndpoint(ctx) if err != nil { - return "", fmt.Errorf("discovery: %w", err) - } - defer resp.Body.Close() - - var meta struct { - TokenEndpoint string `json:"token_endpoint"` - } - if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { - return "", fmt.Errorf("decode discovery: %w", err) + return "", err } // Client credentials flow - req, err := http.NewRequestWithContext(ctx, "POST", meta.TokenEndpoint, + req, err := http.NewRequestWithContext(ctx, "POST", tokenEndpoint, strings.NewReader("grant_type=client_credentials&scope=management:all+mcp:invoke")) if err != nil { return "", err @@ -195,15 +185,43 @@ func (c *Client) getToken(ctx context.Context) (string, error) { } c.token = tokenData.AccessToken - if tokenData.ExpiresIn > 0 { - c.tokenExp = time.Now().Add(time.Duration(tokenData.ExpiresIn-60) * time.Second) - } else { - c.tokenExp = time.Now().Add(50 * time.Minute) + bufferSec := tokenData.ExpiresIn - 60 + if bufferSec < 10 { + bufferSec = 10 } + c.tokenExp = time.Now().Add(time.Duration(bufferSec) * time.Second) return c.token, nil } +func (c *Client) discoverTokenEndpoint(ctx context.Context) (string, error) { + if c.tokenEndpoint != "" { + return c.tokenEndpoint, nil + } + + req, err := http.NewRequestWithContext(ctx, "GET", + c.config.BaseURL+"/.well-known/oauth-authorization-server", nil) + if err != nil { + return "", err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("discovery: %w", err) + } + defer resp.Body.Close() + + var meta struct { + TokenEndpoint string `json:"token_endpoint"` + } + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { + return "", fmt.Errorf("decode discovery: %w", err) + } + + c.tokenEndpoint = meta.TokenEndpoint + return c.tokenEndpoint, nil +} + // authTransport injects the bearer token into every request. type authTransport struct { client *Client diff --git a/internal/run/run.go b/internal/run/run.go index a38ae7a..4db28dd 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -9,12 +9,12 @@ import ( "os/exec" "os/signal" "path/filepath" + "runtime" "strings" "syscall" "time" "github.com/cli/browser" - "github.com/google/uuid" agentv1 "github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1" "github.com/kontext-dev/kontext-cli/internal/auth" @@ -67,7 +67,7 @@ func Start(ctx context.Context, opts Options) error { Cwd: cwd, ClientInfo: map[string]string{ "name": "kontext-cli", - "os": fmt.Sprintf("%s", os.Getenv("GOOS")), + "os": runtime.GOOS, }, }) if err != nil { @@ -79,13 +79,11 @@ func Start(ctx context.Context, opts Options) error { sessionID := createResp.SessionId fmt.Fprintf(os.Stderr, "✓ Session: %s (%s)\n", createResp.SessionName, sessionID[:8]) - traceID := uuid.New().String() - // 4. Start sidecar sessionDir := filepath.Join(os.TempDir(), "kontext", sessionID) os.MkdirAll(sessionDir, 0700) - sc, err := sidecar.New(sessionDir, client, sessionID, traceID, opts.Agent) + sc, err := sidecar.New(sessionDir, client, sessionID, opts.Agent) if err != nil { return fmt.Errorf("sidecar: %w", err) } @@ -123,11 +121,9 @@ func Start(ctx context.Context, opts Options) error { // 8. Launch agent with hooks fmt.Fprintf(os.Stderr, "\nLaunching %s...\n\n", opts.Agent) - startTime := time.Now() agentErr := launchAgentWithSettings(ctx, opts.Agent, env, opts.Args, settingsPath) - // 9. Teardown - _ = time.Since(startTime) + // 9. Teardown (always runs, even on non-zero agent exit) endCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -135,6 +131,13 @@ func Start(ctx context.Context, opts Options) error { fmt.Fprintf(os.Stderr, "\n✓ Session ended (%s)\n", sessionID[:8]) os.RemoveAll(sessionDir) + + // Propagate agent exit code + if agentErr != nil { + if exitErr, ok := agentErr.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + } return agentErr } @@ -158,44 +161,6 @@ func ensureSession(ctx context.Context, issuerURL, clientID string) (*auth.Sessi return result.Session, nil } -// initTemplate interactively creates a .env.kontext on first run. -func initTemplate(path string) error { - providers := []struct { - Name string - EnvVar string - Handle string - }{ - {"GitHub", "GITHUB_TOKEN", "github"}, - {"Google Workspace", "GOOGLE_TOKEN", "google-workspace"}, - {"Stripe", "STRIPE_KEY", "stripe"}, - {"Linear", "LINEAR_API_KEY", "linear"}, - {"Slack", "SLACK_TOKEN", "slack"}, - {"PostgreSQL", "DATABASE_URL", "postgres"}, - } - - fmt.Fprintln(os.Stderr, "\nNo .env.kontext found. Which providers does this project need?") - reader := bufio.NewReader(os.Stdin) - - var lines []string - for _, p := range providers { - fmt.Fprintf(os.Stderr, " %s? [y/N] ", p.Name) - input, _ := reader.ReadString('\n') - if strings.TrimSpace(strings.ToLower(input)) == "y" { - lines = append(lines, fmt.Sprintf("%s={{kontext:%s}}", p.EnvVar, p.Handle)) - } - } - - if len(lines) == 0 { - lines = append(lines, "# Add providers: VAR_NAME={{kontext:provider-handle}}") - } - - if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0644); err != nil { - return fmt.Errorf("write %s: %w", path, err) - } - fmt.Fprintf(os.Stderr, "✓ Wrote %s\n\n", path) - return nil -} - // resolveCredentials exchanges each template entry for a live credential. func resolveCredentials(ctx context.Context, session *auth.Session, entries []credential.Entry) ([]credential.Resolved, error) { fmt.Fprintln(os.Stderr, "\nResolving credentials...") @@ -279,14 +244,9 @@ func launchAgentWithSettings(_ context.Context, agentName string, env, extraArgs err = cmd.Wait() signal.Stop(sigCh) + close(sigCh) - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - os.Exit(exitErr.ExitCode()) - } - return err - } - return nil + return err } func filterArgs(args []string) []string { diff --git a/internal/sidecar/sidecar.go b/internal/sidecar/sidecar.go index ae32bbe..f86e42d 100644 --- a/internal/sidecar/sidecar.go +++ b/internal/sidecar/sidecar.go @@ -20,18 +20,16 @@ type Server struct { socketPath string listener net.Listener sessionID string - traceID string agentName string client *backend.Client cancel context.CancelFunc } // New creates a new sidecar server. -func New(sessionDir string, client *backend.Client, sessionID, traceID, agentName string) (*Server, error) { +func New(sessionDir string, client *backend.Client, sessionID, agentName string) (*Server, error) { return &Server{ socketPath: filepath.Join(sessionDir, "kontext.sock"), sessionID: sessionID, - traceID: traceID, agentName: agentName, client: client, }, nil From 5b8fd52235e25fdaa93383527b09fb0358324e5f Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 6 Apr 2026 09:41:43 +0200 Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20drop=20client=5Fcredentials?= =?UTF-8?q?=20=E2=80=94=20authenticate=20with=20user's=20OIDC=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI no longer uses KONTEXT_CLIENT_ID / KONTEXT_CLIENT_SECRET. The user's access token from `kontext login` (stored in the system keyring) is the only credential. Passed as a bearer token on every ConnectRPC request. Removed: - Config struct with clientId/clientSecret - LoadConfig() that reads env vars / config file - client_credentials token flow (discovery, Basic auth, token caching) - authTransport with token refresh Replaced with: - NewClient(baseURL, accessToken) — takes the token directly - bearerTransport — injects the static token, no refresh logic Regenerated proto stubs from kontext-dev/proto (stripped to 4 RPCs, no ExchangeCredential / SyncPolicy). Co-Authored-By: Claude Opus 4.6 (1M context) --- gen/kontext/agent/v1/agent.pb.go | 587 ++---------------- .../agent/v1/agentv1connect/agent.connect.go | 91 +-- internal/backend/backend.go | 200 +----- internal/run/run.go | 12 +- 4 files changed, 94 insertions(+), 796 deletions(-) diff --git a/gen/kontext/agent/v1/agent.pb.go b/gen/kontext/agent/v1/agent.pb.go index 3fed18d..3f0ddfd 100644 --- a/gen/kontext/agent/v1/agent.pb.go +++ b/gen/kontext/agent/v1/agent.pb.go @@ -73,55 +73,6 @@ func (Decision) EnumDescriptor() ([]byte, []int) { return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{0} } -type CredentialKind int32 - -const ( - CredentialKind_CREDENTIAL_KIND_UNSPECIFIED CredentialKind = 0 - CredentialKind_CREDENTIAL_KIND_OAUTH CredentialKind = 1 - CredentialKind_CREDENTIAL_KIND_KEY CredentialKind = 2 -) - -// Enum value maps for CredentialKind. -var ( - CredentialKind_name = map[int32]string{ - 0: "CREDENTIAL_KIND_UNSPECIFIED", - 1: "CREDENTIAL_KIND_OAUTH", - 2: "CREDENTIAL_KIND_KEY", - } - CredentialKind_value = map[string]int32{ - "CREDENTIAL_KIND_UNSPECIFIED": 0, - "CREDENTIAL_KIND_OAUTH": 1, - "CREDENTIAL_KIND_KEY": 2, - } -) - -func (x CredentialKind) Enum() *CredentialKind { - p := new(CredentialKind) - *p = x - return p -} - -func (x CredentialKind) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (CredentialKind) Descriptor() protoreflect.EnumDescriptor { - return file_kontext_agent_v1_agent_proto_enumTypes[1].Descriptor() -} - -func (CredentialKind) Type() protoreflect.EnumType { - return &file_kontext_agent_v1_agent_proto_enumTypes[1] -} - -func (x CredentialKind) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use CredentialKind.Descriptor instead. -func (CredentialKind) EnumDescriptor() ([]byte, []int) { - return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{1} -} - type ProcessHookEventRequest struct { state protoimpl.MessageState `protogen:"open.v1"` SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` @@ -223,12 +174,10 @@ func (x *ProcessHookEventRequest) GetCwd() string { } type ProcessHookEventResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Decision Decision `protobuf:"varint,1,opt,name=decision,proto3,enum=kontext.agent.v1.Decision" json:"decision,omitempty"` - Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` - EventId string `protobuf:"bytes,3,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` - // Credential to inject for this tool call (if applicable) - Credential *CredentialInjection `protobuf:"bytes,4,opt,name=credential,proto3" json:"credential,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Decision Decision `protobuf:"varint,1,opt,name=decision,proto3,enum=kontext.agent.v1.Decision" json:"decision,omitempty"` + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + EventId string `protobuf:"bytes,3,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -284,65 +233,6 @@ func (x *ProcessHookEventResponse) GetEventId() string { return "" } -func (x *ProcessHookEventResponse) GetCredential() *CredentialInjection { - if x != nil { - return x.Credential - } - return nil -} - -type CredentialInjection struct { - state protoimpl.MessageState `protogen:"open.v1"` - Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` - EnvVars map[string]string `protobuf:"bytes,2,rep,name=env_vars,json=envVars,proto3" json:"env_vars,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // e.g., {"GITHUB_TOKEN": "gho_..."} - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CredentialInjection) Reset() { - *x = CredentialInjection{} - mi := &file_kontext_agent_v1_agent_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CredentialInjection) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CredentialInjection) ProtoMessage() {} - -func (x *CredentialInjection) ProtoReflect() protoreflect.Message { - mi := &file_kontext_agent_v1_agent_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CredentialInjection.ProtoReflect.Descriptor instead. -func (*CredentialInjection) Descriptor() ([]byte, []int) { - return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{2} -} - -func (x *CredentialInjection) GetProvider() string { - if x != nil { - return x.Provider - } - return "" -} - -func (x *CredentialInjection) GetEnvVars() map[string]string { - if x != nil { - return x.EnvVars - } - return nil -} - type CreateSessionRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` @@ -356,7 +246,7 @@ type CreateSessionRequest struct { func (x *CreateSessionRequest) Reset() { *x = CreateSessionRequest{} - mi := &file_kontext_agent_v1_agent_proto_msgTypes[3] + mi := &file_kontext_agent_v1_agent_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -368,7 +258,7 @@ func (x *CreateSessionRequest) String() string { func (*CreateSessionRequest) ProtoMessage() {} func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kontext_agent_v1_agent_proto_msgTypes[3] + mi := &file_kontext_agent_v1_agent_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -381,7 +271,7 @@ func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionRequest.ProtoReflect.Descriptor instead. func (*CreateSessionRequest) Descriptor() ([]byte, []int) { - return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{3} + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{2} } func (x *CreateSessionRequest) GetUserId() string { @@ -431,7 +321,7 @@ type CreateSessionResponse struct { func (x *CreateSessionResponse) Reset() { *x = CreateSessionResponse{} - mi := &file_kontext_agent_v1_agent_proto_msgTypes[4] + mi := &file_kontext_agent_v1_agent_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -443,7 +333,7 @@ func (x *CreateSessionResponse) String() string { func (*CreateSessionResponse) ProtoMessage() {} func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kontext_agent_v1_agent_proto_msgTypes[4] + mi := &file_kontext_agent_v1_agent_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -456,7 +346,7 @@ func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionResponse.ProtoReflect.Descriptor instead. func (*CreateSessionResponse) Descriptor() ([]byte, []int) { - return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{4} + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{3} } func (x *CreateSessionResponse) GetSessionId() string { @@ -496,7 +386,7 @@ type HeartbeatRequest struct { func (x *HeartbeatRequest) Reset() { *x = HeartbeatRequest{} - mi := &file_kontext_agent_v1_agent_proto_msgTypes[5] + mi := &file_kontext_agent_v1_agent_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -508,7 +398,7 @@ func (x *HeartbeatRequest) String() string { func (*HeartbeatRequest) ProtoMessage() {} func (x *HeartbeatRequest) ProtoReflect() protoreflect.Message { - mi := &file_kontext_agent_v1_agent_proto_msgTypes[5] + mi := &file_kontext_agent_v1_agent_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -521,7 +411,7 @@ func (x *HeartbeatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HeartbeatRequest.ProtoReflect.Descriptor instead. func (*HeartbeatRequest) Descriptor() ([]byte, []int) { - return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{5} + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{4} } func (x *HeartbeatRequest) GetSessionId() string { @@ -539,7 +429,7 @@ type HeartbeatResponse struct { func (x *HeartbeatResponse) Reset() { *x = HeartbeatResponse{} - mi := &file_kontext_agent_v1_agent_proto_msgTypes[6] + mi := &file_kontext_agent_v1_agent_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -551,7 +441,7 @@ func (x *HeartbeatResponse) String() string { func (*HeartbeatResponse) ProtoMessage() {} func (x *HeartbeatResponse) ProtoReflect() protoreflect.Message { - mi := &file_kontext_agent_v1_agent_proto_msgTypes[6] + mi := &file_kontext_agent_v1_agent_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -564,7 +454,7 @@ func (x *HeartbeatResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HeartbeatResponse.ProtoReflect.Descriptor instead. func (*HeartbeatResponse) Descriptor() ([]byte, []int) { - return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{6} + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{5} } type EndSessionRequest struct { @@ -576,7 +466,7 @@ type EndSessionRequest struct { func (x *EndSessionRequest) Reset() { *x = EndSessionRequest{} - mi := &file_kontext_agent_v1_agent_proto_msgTypes[7] + mi := &file_kontext_agent_v1_agent_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -588,7 +478,7 @@ func (x *EndSessionRequest) String() string { func (*EndSessionRequest) ProtoMessage() {} func (x *EndSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kontext_agent_v1_agent_proto_msgTypes[7] + mi := &file_kontext_agent_v1_agent_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -601,7 +491,7 @@ func (x *EndSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EndSessionRequest.ProtoReflect.Descriptor instead. func (*EndSessionRequest) Descriptor() ([]byte, []int) { - return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{7} + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{6} } func (x *EndSessionRequest) GetSessionId() string { @@ -619,7 +509,7 @@ type EndSessionResponse struct { func (x *EndSessionResponse) Reset() { *x = EndSessionResponse{} - mi := &file_kontext_agent_v1_agent_proto_msgTypes[8] + mi := &file_kontext_agent_v1_agent_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -631,7 +521,7 @@ func (x *EndSessionResponse) String() string { func (*EndSessionResponse) ProtoMessage() {} func (x *EndSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kontext_agent_v1_agent_proto_msgTypes[8] + mi := &file_kontext_agent_v1_agent_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -644,316 +534,7 @@ func (x *EndSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EndSessionResponse.ProtoReflect.Descriptor instead. func (*EndSessionResponse) Descriptor() ([]byte, []int) { - return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{8} -} - -type ExchangeCredentialRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` // e.g., "github", "stripe", "postgres" - UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` - Resource string `protobuf:"bytes,3,opt,name=resource,proto3" json:"resource,omitempty"` // optional: specific resource URI - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExchangeCredentialRequest) Reset() { - *x = ExchangeCredentialRequest{} - mi := &file_kontext_agent_v1_agent_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExchangeCredentialRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExchangeCredentialRequest) ProtoMessage() {} - -func (x *ExchangeCredentialRequest) ProtoReflect() protoreflect.Message { - mi := &file_kontext_agent_v1_agent_proto_msgTypes[9] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExchangeCredentialRequest.ProtoReflect.Descriptor instead. -func (*ExchangeCredentialRequest) Descriptor() ([]byte, []int) { - return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{9} -} - -func (x *ExchangeCredentialRequest) GetProvider() string { - if x != nil { - return x.Provider - } - return "" -} - -func (x *ExchangeCredentialRequest) GetUserId() string { - if x != nil { - return x.UserId - } - return "" -} - -func (x *ExchangeCredentialRequest) GetResource() string { - if x != nil { - return x.Resource - } - return "" -} - -type ExchangeCredentialResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` - Kind CredentialKind `protobuf:"varint,2,opt,name=kind,proto3,enum=kontext.agent.v1.CredentialKind" json:"kind,omitempty"` - AccessToken string `protobuf:"bytes,3,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` // for OAuth providers - Value string `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"` // for API key providers - TokenType string `protobuf:"bytes,5,opt,name=token_type,json=tokenType,proto3" json:"token_type,omitempty"` - Scope string `protobuf:"bytes,6,opt,name=scope,proto3" json:"scope,omitempty"` - ExpiresIn int64 `protobuf:"varint,7,opt,name=expires_in,json=expiresIn,proto3" json:"expires_in,omitempty"` // seconds - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExchangeCredentialResponse) Reset() { - *x = ExchangeCredentialResponse{} - mi := &file_kontext_agent_v1_agent_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExchangeCredentialResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExchangeCredentialResponse) ProtoMessage() {} - -func (x *ExchangeCredentialResponse) ProtoReflect() protoreflect.Message { - mi := &file_kontext_agent_v1_agent_proto_msgTypes[10] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExchangeCredentialResponse.ProtoReflect.Descriptor instead. -func (*ExchangeCredentialResponse) Descriptor() ([]byte, []int) { - return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{10} -} - -func (x *ExchangeCredentialResponse) GetProvider() string { - if x != nil { - return x.Provider - } - return "" -} - -func (x *ExchangeCredentialResponse) GetKind() CredentialKind { - if x != nil { - return x.Kind - } - return CredentialKind_CREDENTIAL_KIND_UNSPECIFIED -} - -func (x *ExchangeCredentialResponse) GetAccessToken() string { - if x != nil { - return x.AccessToken - } - return "" -} - -func (x *ExchangeCredentialResponse) GetValue() string { - if x != nil { - return x.Value - } - return "" -} - -func (x *ExchangeCredentialResponse) GetTokenType() string { - if x != nil { - return x.TokenType - } - return "" -} - -func (x *ExchangeCredentialResponse) GetScope() string { - if x != nil { - return x.Scope - } - return "" -} - -func (x *ExchangeCredentialResponse) GetExpiresIn() int64 { - if x != nil { - return x.ExpiresIn - } - return 0 -} - -type SyncPolicyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SyncPolicyRequest) Reset() { - *x = SyncPolicyRequest{} - mi := &file_kontext_agent_v1_agent_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SyncPolicyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SyncPolicyRequest) ProtoMessage() {} - -func (x *SyncPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_kontext_agent_v1_agent_proto_msgTypes[11] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SyncPolicyRequest.ProtoReflect.Descriptor instead. -func (*SyncPolicyRequest) Descriptor() ([]byte, []int) { - return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{11} -} - -func (x *SyncPolicyRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -type SyncPolicyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // OpenFGA tuples for local evaluation - Tuples []*PolicyTuple `protobuf:"bytes,1,rep,name=tuples,proto3" json:"tuples,omitempty"` - FullSync bool `protobuf:"varint,2,opt,name=full_sync,json=fullSync,proto3" json:"full_sync,omitempty"` // true = replace all, false = incremental - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SyncPolicyResponse) Reset() { - *x = SyncPolicyResponse{} - mi := &file_kontext_agent_v1_agent_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SyncPolicyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SyncPolicyResponse) ProtoMessage() {} - -func (x *SyncPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_kontext_agent_v1_agent_proto_msgTypes[12] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SyncPolicyResponse.ProtoReflect.Descriptor instead. -func (*SyncPolicyResponse) Descriptor() ([]byte, []int) { - return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{12} -} - -func (x *SyncPolicyResponse) GetTuples() []*PolicyTuple { - if x != nil { - return x.Tuples - } - return nil -} - -func (x *SyncPolicyResponse) GetFullSync() bool { - if x != nil { - return x.FullSync - } - return false -} - -type PolicyTuple struct { - state protoimpl.MessageState `protogen:"open.v1"` - User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - Relation string `protobuf:"bytes,2,opt,name=relation,proto3" json:"relation,omitempty"` - Object string `protobuf:"bytes,3,opt,name=object,proto3" json:"object,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PolicyTuple) Reset() { - *x = PolicyTuple{} - mi := &file_kontext_agent_v1_agent_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PolicyTuple) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PolicyTuple) ProtoMessage() {} - -func (x *PolicyTuple) ProtoReflect() protoreflect.Message { - mi := &file_kontext_agent_v1_agent_proto_msgTypes[13] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PolicyTuple.ProtoReflect.Descriptor instead. -func (*PolicyTuple) Descriptor() ([]byte, []int) { - return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{13} -} - -func (x *PolicyTuple) GetUser() string { - if x != nil { - return x.User - } - return "" -} - -func (x *PolicyTuple) GetRelation() string { - if x != nil { - return x.Relation - } - return "" -} - -func (x *PolicyTuple) GetObject() string { - if x != nil { - return x.Object - } - return "" + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{7} } var File_kontext_agent_v1_agent_proto protoreflect.FileDescriptor @@ -972,20 +553,11 @@ const file_kontext_agent_v1_agent_proto_rawDesc = "" + "tool_input\x18\x05 \x01(\fR\ttoolInput\x12#\n" + "\rtool_response\x18\x06 \x01(\fR\ftoolResponse\x12\x1e\n" + "\vtool_use_id\x18\a \x01(\tR\ttoolUseId\x12\x10\n" + - "\x03cwd\x18\b \x01(\tR\x03cwd\"\xcc\x01\n" + + "\x03cwd\x18\b \x01(\tR\x03cwd\"\x85\x01\n" + "\x18ProcessHookEventResponse\x126\n" + "\bdecision\x18\x01 \x01(\x0e2\x1a.kontext.agent.v1.DecisionR\bdecision\x12\x16\n" + "\x06reason\x18\x02 \x01(\tR\x06reason\x12\x19\n" + - "\bevent_id\x18\x03 \x01(\tR\aeventId\x12E\n" + - "\n" + - "credential\x18\x04 \x01(\v2%.kontext.agent.v1.CredentialInjectionR\n" + - "credential\"\xbc\x01\n" + - "\x13CredentialInjection\x12\x1a\n" + - "\bprovider\x18\x01 \x01(\tR\bprovider\x12M\n" + - "\benv_vars\x18\x02 \x03(\v22.kontext.agent.v1.CredentialInjection.EnvVarsEntryR\aenvVars\x1a:\n" + - "\fEnvVarsEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x8b\x02\n" + + "\bevent_id\x18\x03 \x01(\tR\aeventId\"\x8b\x02\n" + "\x14CreateSessionRequest\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x14\n" + "\x05agent\x18\x02 \x01(\tR\x05agent\x12\x1a\n" + @@ -1009,49 +581,18 @@ const file_kontext_agent_v1_agent_proto_rawDesc = "" + "\x11EndSessionRequest\x12\x1d\n" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\"\x14\n" + - "\x12EndSessionResponse\"l\n" + - "\x19ExchangeCredentialRequest\x12\x1a\n" + - "\bprovider\x18\x01 \x01(\tR\bprovider\x12\x17\n" + - "\auser_id\x18\x02 \x01(\tR\x06userId\x12\x1a\n" + - "\bresource\x18\x03 \x01(\tR\bresource\"\xfb\x01\n" + - "\x1aExchangeCredentialResponse\x12\x1a\n" + - "\bprovider\x18\x01 \x01(\tR\bprovider\x124\n" + - "\x04kind\x18\x02 \x01(\x0e2 .kontext.agent.v1.CredentialKindR\x04kind\x12!\n" + - "\faccess_token\x18\x03 \x01(\tR\vaccessToken\x12\x14\n" + - "\x05value\x18\x04 \x01(\tR\x05value\x12\x1d\n" + - "\n" + - "token_type\x18\x05 \x01(\tR\ttokenType\x12\x14\n" + - "\x05scope\x18\x06 \x01(\tR\x05scope\x12\x1d\n" + - "\n" + - "expires_in\x18\a \x01(\x03R\texpiresIn\"2\n" + - "\x11SyncPolicyRequest\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\"h\n" + - "\x12SyncPolicyResponse\x125\n" + - "\x06tuples\x18\x01 \x03(\v2\x1d.kontext.agent.v1.PolicyTupleR\x06tuples\x12\x1b\n" + - "\tfull_sync\x18\x02 \x01(\bR\bfullSync\"U\n" + - "\vPolicyTuple\x12\x12\n" + - "\x04user\x18\x01 \x01(\tR\x04user\x12\x1a\n" + - "\brelation\x18\x02 \x01(\tR\brelation\x12\x16\n" + - "\x06object\x18\x03 \x01(\tR\x06object*]\n" + + "\x12EndSessionResponse*]\n" + "\bDecision\x12\x18\n" + "\x14DECISION_UNSPECIFIED\x10\x00\x12\x12\n" + "\x0eDECISION_ALLOW\x10\x01\x12\x11\n" + "\rDECISION_DENY\x10\x02\x12\x10\n" + - "\fDECISION_ASK\x10\x03*e\n" + - "\x0eCredentialKind\x12\x1f\n" + - "\x1bCREDENTIAL_KIND_UNSPECIFIED\x10\x00\x12\x19\n" + - "\x15CREDENTIAL_KIND_OAUTH\x10\x01\x12\x17\n" + - "\x13CREDENTIAL_KIND_KEY\x10\x022\xda\x04\n" + + "\fDECISION_ASK\x10\x032\x8e\x03\n" + "\fAgentService\x12m\n" + "\x10ProcessHookEvent\x12).kontext.agent.v1.ProcessHookEventRequest\x1a*.kontext.agent.v1.ProcessHookEventResponse(\x010\x01\x12`\n" + "\rCreateSession\x12&.kontext.agent.v1.CreateSessionRequest\x1a'.kontext.agent.v1.CreateSessionResponse\x12T\n" + "\tHeartbeat\x12\".kontext.agent.v1.HeartbeatRequest\x1a#.kontext.agent.v1.HeartbeatResponse\x12W\n" + "\n" + - "EndSession\x12#.kontext.agent.v1.EndSessionRequest\x1a$.kontext.agent.v1.EndSessionResponse\x12o\n" + - "\x12ExchangeCredential\x12+.kontext.agent.v1.ExchangeCredentialRequest\x1a,.kontext.agent.v1.ExchangeCredentialResponse\x12Y\n" + - "\n" + - "SyncPolicy\x12#.kontext.agent.v1.SyncPolicyRequest\x1a$.kontext.agent.v1.SyncPolicyResponse0\x01B\xc5\x01\n" + + "EndSession\x12#.kontext.agent.v1.EndSessionRequest\x1a$.kontext.agent.v1.EndSessionResponseB\xc5\x01\n" + "\x14com.kontext.agent.v1B\n" + "AgentProtoP\x01Z?github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1;agentv1\xa2\x02\x03KAX\xaa\x02\x10Kontext.Agent.V1\xca\x02\x10Kontext\\Agent\\V1\xe2\x02\x1cKontext\\Agent\\V1\\GPBMetadata\xea\x02\x12Kontext::Agent::V1b\x06proto3" @@ -1067,52 +608,36 @@ func file_kontext_agent_v1_agent_proto_rawDescGZIP() []byte { return file_kontext_agent_v1_agent_proto_rawDescData } -var file_kontext_agent_v1_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_kontext_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_kontext_agent_v1_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_kontext_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_kontext_agent_v1_agent_proto_goTypes = []any{ - (Decision)(0), // 0: kontext.agent.v1.Decision - (CredentialKind)(0), // 1: kontext.agent.v1.CredentialKind - (*ProcessHookEventRequest)(nil), // 2: kontext.agent.v1.ProcessHookEventRequest - (*ProcessHookEventResponse)(nil), // 3: kontext.agent.v1.ProcessHookEventResponse - (*CredentialInjection)(nil), // 4: kontext.agent.v1.CredentialInjection - (*CreateSessionRequest)(nil), // 5: kontext.agent.v1.CreateSessionRequest - (*CreateSessionResponse)(nil), // 6: kontext.agent.v1.CreateSessionResponse - (*HeartbeatRequest)(nil), // 7: kontext.agent.v1.HeartbeatRequest - (*HeartbeatResponse)(nil), // 8: kontext.agent.v1.HeartbeatResponse - (*EndSessionRequest)(nil), // 9: kontext.agent.v1.EndSessionRequest - (*EndSessionResponse)(nil), // 10: kontext.agent.v1.EndSessionResponse - (*ExchangeCredentialRequest)(nil), // 11: kontext.agent.v1.ExchangeCredentialRequest - (*ExchangeCredentialResponse)(nil), // 12: kontext.agent.v1.ExchangeCredentialResponse - (*SyncPolicyRequest)(nil), // 13: kontext.agent.v1.SyncPolicyRequest - (*SyncPolicyResponse)(nil), // 14: kontext.agent.v1.SyncPolicyResponse - (*PolicyTuple)(nil), // 15: kontext.agent.v1.PolicyTuple - nil, // 16: kontext.agent.v1.CredentialInjection.EnvVarsEntry - nil, // 17: kontext.agent.v1.CreateSessionRequest.ClientInfoEntry + (Decision)(0), // 0: kontext.agent.v1.Decision + (*ProcessHookEventRequest)(nil), // 1: kontext.agent.v1.ProcessHookEventRequest + (*ProcessHookEventResponse)(nil), // 2: kontext.agent.v1.ProcessHookEventResponse + (*CreateSessionRequest)(nil), // 3: kontext.agent.v1.CreateSessionRequest + (*CreateSessionResponse)(nil), // 4: kontext.agent.v1.CreateSessionResponse + (*HeartbeatRequest)(nil), // 5: kontext.agent.v1.HeartbeatRequest + (*HeartbeatResponse)(nil), // 6: kontext.agent.v1.HeartbeatResponse + (*EndSessionRequest)(nil), // 7: kontext.agent.v1.EndSessionRequest + (*EndSessionResponse)(nil), // 8: kontext.agent.v1.EndSessionResponse + nil, // 9: kontext.agent.v1.CreateSessionRequest.ClientInfoEntry } var file_kontext_agent_v1_agent_proto_depIdxs = []int32{ - 0, // 0: kontext.agent.v1.ProcessHookEventResponse.decision:type_name -> kontext.agent.v1.Decision - 4, // 1: kontext.agent.v1.ProcessHookEventResponse.credential:type_name -> kontext.agent.v1.CredentialInjection - 16, // 2: kontext.agent.v1.CredentialInjection.env_vars:type_name -> kontext.agent.v1.CredentialInjection.EnvVarsEntry - 17, // 3: kontext.agent.v1.CreateSessionRequest.client_info:type_name -> kontext.agent.v1.CreateSessionRequest.ClientInfoEntry - 1, // 4: kontext.agent.v1.ExchangeCredentialResponse.kind:type_name -> kontext.agent.v1.CredentialKind - 15, // 5: kontext.agent.v1.SyncPolicyResponse.tuples:type_name -> kontext.agent.v1.PolicyTuple - 2, // 6: kontext.agent.v1.AgentService.ProcessHookEvent:input_type -> kontext.agent.v1.ProcessHookEventRequest - 5, // 7: kontext.agent.v1.AgentService.CreateSession:input_type -> kontext.agent.v1.CreateSessionRequest - 7, // 8: kontext.agent.v1.AgentService.Heartbeat:input_type -> kontext.agent.v1.HeartbeatRequest - 9, // 9: kontext.agent.v1.AgentService.EndSession:input_type -> kontext.agent.v1.EndSessionRequest - 11, // 10: kontext.agent.v1.AgentService.ExchangeCredential:input_type -> kontext.agent.v1.ExchangeCredentialRequest - 13, // 11: kontext.agent.v1.AgentService.SyncPolicy:input_type -> kontext.agent.v1.SyncPolicyRequest - 3, // 12: kontext.agent.v1.AgentService.ProcessHookEvent:output_type -> kontext.agent.v1.ProcessHookEventResponse - 6, // 13: kontext.agent.v1.AgentService.CreateSession:output_type -> kontext.agent.v1.CreateSessionResponse - 8, // 14: kontext.agent.v1.AgentService.Heartbeat:output_type -> kontext.agent.v1.HeartbeatResponse - 10, // 15: kontext.agent.v1.AgentService.EndSession:output_type -> kontext.agent.v1.EndSessionResponse - 12, // 16: kontext.agent.v1.AgentService.ExchangeCredential:output_type -> kontext.agent.v1.ExchangeCredentialResponse - 14, // 17: kontext.agent.v1.AgentService.SyncPolicy:output_type -> kontext.agent.v1.SyncPolicyResponse - 12, // [12:18] is the sub-list for method output_type - 6, // [6:12] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 0, // 0: kontext.agent.v1.ProcessHookEventResponse.decision:type_name -> kontext.agent.v1.Decision + 9, // 1: kontext.agent.v1.CreateSessionRequest.client_info:type_name -> kontext.agent.v1.CreateSessionRequest.ClientInfoEntry + 1, // 2: kontext.agent.v1.AgentService.ProcessHookEvent:input_type -> kontext.agent.v1.ProcessHookEventRequest + 3, // 3: kontext.agent.v1.AgentService.CreateSession:input_type -> kontext.agent.v1.CreateSessionRequest + 5, // 4: kontext.agent.v1.AgentService.Heartbeat:input_type -> kontext.agent.v1.HeartbeatRequest + 7, // 5: kontext.agent.v1.AgentService.EndSession:input_type -> kontext.agent.v1.EndSessionRequest + 2, // 6: kontext.agent.v1.AgentService.ProcessHookEvent:output_type -> kontext.agent.v1.ProcessHookEventResponse + 4, // 7: kontext.agent.v1.AgentService.CreateSession:output_type -> kontext.agent.v1.CreateSessionResponse + 6, // 8: kontext.agent.v1.AgentService.Heartbeat:output_type -> kontext.agent.v1.HeartbeatResponse + 8, // 9: kontext.agent.v1.AgentService.EndSession:output_type -> kontext.agent.v1.EndSessionResponse + 6, // [6:10] is the sub-list for method output_type + 2, // [2:6] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_kontext_agent_v1_agent_proto_init() } @@ -1125,8 +650,8 @@ func file_kontext_agent_v1_agent_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kontext_agent_v1_agent_proto_rawDesc), len(file_kontext_agent_v1_agent_proto_rawDesc)), - NumEnums: 2, - NumMessages: 16, + NumEnums: 1, + NumMessages: 9, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/kontext/agent/v1/agentv1connect/agent.connect.go b/gen/kontext/agent/v1/agentv1connect/agent.connect.go index c471e0d..c847d0e 100644 --- a/gen/kontext/agent/v1/agentv1connect/agent.connect.go +++ b/gen/kontext/agent/v1/agentv1connect/agent.connect.go @@ -43,36 +43,22 @@ const ( AgentServiceHeartbeatProcedure = "/kontext.agent.v1.AgentService/Heartbeat" // AgentServiceEndSessionProcedure is the fully-qualified name of the AgentService's EndSession RPC. AgentServiceEndSessionProcedure = "/kontext.agent.v1.AgentService/EndSession" - // AgentServiceExchangeCredentialProcedure is the fully-qualified name of the AgentService's - // ExchangeCredential RPC. - AgentServiceExchangeCredentialProcedure = "/kontext.agent.v1.AgentService/ExchangeCredential" - // AgentServiceSyncPolicyProcedure is the fully-qualified name of the AgentService's SyncPolicy RPC. - AgentServiceSyncPolicyProcedure = "/kontext.agent.v1.AgentService/SyncPolicy" ) // AgentServiceClient is a client for the kontext.agent.v1.AgentService service. type AgentServiceClient interface { // ProcessHookEvent streams tool call events from the CLI to the backend // and receives policy decisions in return. Bidirectional streaming keeps - // the connection open for the session lifetime — no per-hook HTTP overhead. + // the connection open for the session lifetime. ProcessHookEvent(context.Context) *connect.BidiStreamForClient[v1.ProcessHookEventRequest, v1.ProcessHookEventResponse] // CreateSession establishes a governed agent session. Called once at the - // start of `kontext start`. Returns session context used for all subsequent - // hook evaluations. + // start of `kontext start`. CreateSession(context.Context, *connect.Request[v1.CreateSessionRequest]) (*connect.Response[v1.CreateSessionResponse], error) // Heartbeat keeps the session alive. The sidecar sends heartbeats on an // interval; the backend marks sessions as disconnected if heartbeats stop. Heartbeat(context.Context, *connect.Request[v1.HeartbeatRequest]) (*connect.Response[v1.HeartbeatResponse], error) - // EndSession terminates the session and revokes any ephemeral credentials. + // EndSession terminates the session. EndSession(context.Context, *connect.Request[v1.EndSessionRequest]) (*connect.Response[v1.EndSessionResponse], error) - // ExchangeCredential resolves a provider credential for a given user and - // provider handle. Used by the env template hydration and per-tool-call - // credential injection. - ExchangeCredential(context.Context, *connect.Request[v1.ExchangeCredentialRequest]) (*connect.Response[v1.ExchangeCredentialResponse], error) - // SyncPolicy streams the current policy state for the session's org/agent. - // The sidecar caches this locally for fast hook evaluation. The server - // pushes updates when policy changes. - SyncPolicy(context.Context, *connect.Request[v1.SyncPolicyRequest]) (*connect.ServerStreamForClient[v1.SyncPolicyResponse], error) } // NewAgentServiceClient constructs a client for the kontext.agent.v1.AgentService service. By @@ -110,29 +96,15 @@ func NewAgentServiceClient(httpClient connect.HTTPClient, baseURL string, opts . connect.WithSchema(agentServiceMethods.ByName("EndSession")), connect.WithClientOptions(opts...), ), - exchangeCredential: connect.NewClient[v1.ExchangeCredentialRequest, v1.ExchangeCredentialResponse]( - httpClient, - baseURL+AgentServiceExchangeCredentialProcedure, - connect.WithSchema(agentServiceMethods.ByName("ExchangeCredential")), - connect.WithClientOptions(opts...), - ), - syncPolicy: connect.NewClient[v1.SyncPolicyRequest, v1.SyncPolicyResponse]( - httpClient, - baseURL+AgentServiceSyncPolicyProcedure, - connect.WithSchema(agentServiceMethods.ByName("SyncPolicy")), - connect.WithClientOptions(opts...), - ), } } // agentServiceClient implements AgentServiceClient. type agentServiceClient struct { - processHookEvent *connect.Client[v1.ProcessHookEventRequest, v1.ProcessHookEventResponse] - createSession *connect.Client[v1.CreateSessionRequest, v1.CreateSessionResponse] - heartbeat *connect.Client[v1.HeartbeatRequest, v1.HeartbeatResponse] - endSession *connect.Client[v1.EndSessionRequest, v1.EndSessionResponse] - exchangeCredential *connect.Client[v1.ExchangeCredentialRequest, v1.ExchangeCredentialResponse] - syncPolicy *connect.Client[v1.SyncPolicyRequest, v1.SyncPolicyResponse] + processHookEvent *connect.Client[v1.ProcessHookEventRequest, v1.ProcessHookEventResponse] + createSession *connect.Client[v1.CreateSessionRequest, v1.CreateSessionResponse] + heartbeat *connect.Client[v1.HeartbeatRequest, v1.HeartbeatResponse] + endSession *connect.Client[v1.EndSessionRequest, v1.EndSessionResponse] } // ProcessHookEvent calls kontext.agent.v1.AgentService.ProcessHookEvent. @@ -155,39 +127,20 @@ func (c *agentServiceClient) EndSession(ctx context.Context, req *connect.Reques return c.endSession.CallUnary(ctx, req) } -// ExchangeCredential calls kontext.agent.v1.AgentService.ExchangeCredential. -func (c *agentServiceClient) ExchangeCredential(ctx context.Context, req *connect.Request[v1.ExchangeCredentialRequest]) (*connect.Response[v1.ExchangeCredentialResponse], error) { - return c.exchangeCredential.CallUnary(ctx, req) -} - -// SyncPolicy calls kontext.agent.v1.AgentService.SyncPolicy. -func (c *agentServiceClient) SyncPolicy(ctx context.Context, req *connect.Request[v1.SyncPolicyRequest]) (*connect.ServerStreamForClient[v1.SyncPolicyResponse], error) { - return c.syncPolicy.CallServerStream(ctx, req) -} - // AgentServiceHandler is an implementation of the kontext.agent.v1.AgentService service. type AgentServiceHandler interface { // ProcessHookEvent streams tool call events from the CLI to the backend // and receives policy decisions in return. Bidirectional streaming keeps - // the connection open for the session lifetime — no per-hook HTTP overhead. + // the connection open for the session lifetime. ProcessHookEvent(context.Context, *connect.BidiStream[v1.ProcessHookEventRequest, v1.ProcessHookEventResponse]) error // CreateSession establishes a governed agent session. Called once at the - // start of `kontext start`. Returns session context used for all subsequent - // hook evaluations. + // start of `kontext start`. CreateSession(context.Context, *connect.Request[v1.CreateSessionRequest]) (*connect.Response[v1.CreateSessionResponse], error) // Heartbeat keeps the session alive. The sidecar sends heartbeats on an // interval; the backend marks sessions as disconnected if heartbeats stop. Heartbeat(context.Context, *connect.Request[v1.HeartbeatRequest]) (*connect.Response[v1.HeartbeatResponse], error) - // EndSession terminates the session and revokes any ephemeral credentials. + // EndSession terminates the session. EndSession(context.Context, *connect.Request[v1.EndSessionRequest]) (*connect.Response[v1.EndSessionResponse], error) - // ExchangeCredential resolves a provider credential for a given user and - // provider handle. Used by the env template hydration and per-tool-call - // credential injection. - ExchangeCredential(context.Context, *connect.Request[v1.ExchangeCredentialRequest]) (*connect.Response[v1.ExchangeCredentialResponse], error) - // SyncPolicy streams the current policy state for the session's org/agent. - // The sidecar caches this locally for fast hook evaluation. The server - // pushes updates when policy changes. - SyncPolicy(context.Context, *connect.Request[v1.SyncPolicyRequest], *connect.ServerStream[v1.SyncPolicyResponse]) error } // NewAgentServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -221,18 +174,6 @@ func NewAgentServiceHandler(svc AgentServiceHandler, opts ...connect.HandlerOpti connect.WithSchema(agentServiceMethods.ByName("EndSession")), connect.WithHandlerOptions(opts...), ) - agentServiceExchangeCredentialHandler := connect.NewUnaryHandler( - AgentServiceExchangeCredentialProcedure, - svc.ExchangeCredential, - connect.WithSchema(agentServiceMethods.ByName("ExchangeCredential")), - connect.WithHandlerOptions(opts...), - ) - agentServiceSyncPolicyHandler := connect.NewServerStreamHandler( - AgentServiceSyncPolicyProcedure, - svc.SyncPolicy, - connect.WithSchema(agentServiceMethods.ByName("SyncPolicy")), - connect.WithHandlerOptions(opts...), - ) return "/kontext.agent.v1.AgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case AgentServiceProcessHookEventProcedure: @@ -243,10 +184,6 @@ func NewAgentServiceHandler(svc AgentServiceHandler, opts ...connect.HandlerOpti agentServiceHeartbeatHandler.ServeHTTP(w, r) case AgentServiceEndSessionProcedure: agentServiceEndSessionHandler.ServeHTTP(w, r) - case AgentServiceExchangeCredentialProcedure: - agentServiceExchangeCredentialHandler.ServeHTTP(w, r) - case AgentServiceSyncPolicyProcedure: - agentServiceSyncPolicyHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -271,11 +208,3 @@ func (UnimplementedAgentServiceHandler) Heartbeat(context.Context, *connect.Requ func (UnimplementedAgentServiceHandler) EndSession(context.Context, *connect.Request[v1.EndSessionRequest]) (*connect.Response[v1.EndSessionResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("kontext.agent.v1.AgentService.EndSession is not implemented")) } - -func (UnimplementedAgentServiceHandler) ExchangeCredential(context.Context, *connect.Request[v1.ExchangeCredentialRequest]) (*connect.Response[v1.ExchangeCredentialResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("kontext.agent.v1.AgentService.ExchangeCredential is not implemented")) -} - -func (UnimplementedAgentServiceHandler) SyncPolicy(context.Context, *connect.Request[v1.SyncPolicyRequest], *connect.ServerStream[v1.SyncPolicyResponse]) error { - return connect.NewError(connect.CodeUnimplemented, errors.New("kontext.agent.v1.AgentService.SyncPolicy is not implemented")) -} diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 873c201..be068b0 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -1,15 +1,13 @@ // Package backend provides the ConnectRPC client for the Kontext AgentService. +// Authenticates with the user's OIDC bearer token from `kontext login`. +// No client secrets, no client_credentials grant. package backend import ( "context" - "encoding/json" "fmt" "net/http" "os" - "path/filepath" - "strings" - "sync" "time" "connectrpc.com/connect" @@ -18,84 +16,29 @@ import ( "github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1/agentv1connect" ) -// Config holds backend connection parameters. -type Config struct { - BaseURL string `json:"baseUrl"` - ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` +// Client wraps the ConnectRPC AgentService client. +type Client struct { + rpc agentv1connect.AgentServiceClient } -// LoadConfig reads backend configuration from env vars or ~/.kontext/config.json. -func LoadConfig() (*Config, error) { - cfg := &Config{ - BaseURL: envOr("KONTEXT_API_URL", "https://api.kontext.security"), - ClientID: os.Getenv("KONTEXT_CLIENT_ID"), - ClientSecret: os.Getenv("KONTEXT_CLIENT_SECRET"), - } - - if cfg.ClientID == "" || cfg.ClientSecret == "" { - if fileCfg, err := loadConfigFile(); err == nil && fileCfg != nil { - if cfg.ClientID == "" { - cfg.ClientID = fileCfg.ClientID - } - if cfg.ClientSecret == "" { - cfg.ClientSecret = fileCfg.ClientSecret - } - } +// NewClient creates a ConnectRPC client authenticated with the user's bearer token. +func NewClient(baseURL, accessToken string) *Client { + httpClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: &bearerTransport{token: accessToken, base: http.DefaultTransport}, } - if cfg.ClientID == "" || cfg.ClientSecret == "" { - return nil, fmt.Errorf("KONTEXT_CLIENT_ID and KONTEXT_CLIENT_SECRET required (set via env or ~/.kontext/config.json)") + return &Client{ + rpc: agentv1connect.NewAgentServiceClient(httpClient, baseURL), } - - return cfg, nil } -func loadConfigFile() (*Config, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, err - } - data, err := os.ReadFile(filepath.Join(home, ".kontext", "config.json")) - if err != nil { - return nil, err - } - var cfg Config - return &cfg, json.Unmarshal(data, &cfg) -} - -func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { +// BaseURL returns the API base URL from env or default. +func BaseURL() string { + if v := os.Getenv("KONTEXT_API_URL"); v != "" { return v } - return fallback -} - -// Client wraps the ConnectRPC AgentService client with token management. -type Client struct { - rpc agentv1connect.AgentServiceClient - config *Config - token string - tokenExp time.Time - tokenEndpoint string - mu sync.Mutex -} - -// NewClient creates a ConnectRPC client for the Kontext AgentService. -func NewClient(config *Config) *Client { - c := &Client{config: config} - - authClient := &http.Client{ - Timeout: 30 * time.Second, - Transport: &authTransport{client: c, base: http.DefaultTransport}, - } - - c.rpc = agentv1connect.NewAgentServiceClient( - authClient, - config.BaseURL, - ) - - return c + return "https://api.kontext.security" } // CreateSession creates a governed agent session. @@ -123,121 +66,28 @@ func (c *Client) EndSession(ctx context.Context, sessionID string) error { return err } -// IngestEvent sends a single hook event to the backend. +// IngestEvent sends a single hook event via the ProcessHookEvent stream. func (c *Client) IngestEvent(ctx context.Context, req *agentv1.ProcessHookEventRequest) error { stream := c.rpc.ProcessHookEvent(ctx) if err := stream.Send(req); err != nil { return fmt.Errorf("send hook event: %w", err) } - // For now, send one event per stream. The sidecar will hold a persistent - // stream open once the full bidirectional flow is wired. if err := stream.CloseRequest(); err != nil { return err } - // Read the response if resp, err := stream.Receive(); err == nil { - _ = resp // decision logged server-side + _ = resp } return stream.CloseResponse() } -// --- Token management --- - -func (c *Client) getToken(ctx context.Context) (string, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.token != "" && time.Now().Before(c.tokenExp) { - return c.token, nil - } - - // Discover token endpoint (cached after first call) - tokenEndpoint, err := c.discoverTokenEndpoint(ctx) - if err != nil { - return "", err - } - - // Client credentials flow - req, err := http.NewRequestWithContext(ctx, "POST", tokenEndpoint, - strings.NewReader("grant_type=client_credentials&scope=management:all+mcp:invoke")) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.SetBasicAuth(c.config.ClientID, c.config.ClientSecret) - - tokenResp, err := http.DefaultClient.Do(req) - if err != nil { - return "", err - } - defer tokenResp.Body.Close() - - if tokenResp.StatusCode != 200 { - return "", fmt.Errorf("token request: %s", tokenResp.Status) - } - - var tokenData struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - } - if err := json.NewDecoder(tokenResp.Body).Decode(&tokenData); err != nil { - return "", err - } - - c.token = tokenData.AccessToken - bufferSec := tokenData.ExpiresIn - 60 - if bufferSec < 10 { - bufferSec = 10 - } - c.tokenExp = time.Now().Add(time.Duration(bufferSec) * time.Second) - - return c.token, nil -} - -func (c *Client) discoverTokenEndpoint(ctx context.Context) (string, error) { - if c.tokenEndpoint != "" { - return c.tokenEndpoint, nil - } - - req, err := http.NewRequestWithContext(ctx, "GET", - c.config.BaseURL+"/.well-known/oauth-authorization-server", nil) - if err != nil { - return "", err - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", fmt.Errorf("discovery: %w", err) - } - defer resp.Body.Close() - - var meta struct { - TokenEndpoint string `json:"token_endpoint"` - } - if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { - return "", fmt.Errorf("decode discovery: %w", err) - } - - c.tokenEndpoint = meta.TokenEndpoint - return c.tokenEndpoint, nil -} - -// authTransport injects the bearer token into every request. -type authTransport struct { - client *Client - base http.RoundTripper +// bearerTransport injects the user's OIDC token into every request. +type bearerTransport struct { + token string + base http.RoundTripper } -func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { - token, err := t.client.getToken(req.Context()) - if err != nil { - return nil, fmt.Errorf("auth: %w", err) - } - req.Header.Set("Authorization", "Bearer "+token) - - base := t.base - if base == nil { - base = http.DefaultTransport - } - return base.RoundTrip(req) +func (t *bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", "Bearer "+t.token) + return t.base.RoundTrip(req) } diff --git a/internal/run/run.go b/internal/run/run.go index 4db28dd..a9c7240 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -48,14 +48,8 @@ func Start(ctx context.Context, opts Options) error { } fmt.Fprintf(os.Stderr, "✓ Authenticated as %s\n", identity) - // 2. Backend client (native ConnectRPC) - backendCfg, err := backend.LoadConfig() - if err != nil { - fmt.Fprintf(os.Stderr, "⚠ Backend not configured: %v\n", err) - fmt.Fprintln(os.Stderr, " Launching without telemetry (set KONTEXT_CLIENT_ID + KONTEXT_CLIENT_SECRET)") - return launchAgentDirect(ctx, opts) - } - client := backend.NewClient(backendCfg) + // 2. Backend client — authenticated with the user's OIDC token + client := backend.NewClient(backend.BaseURL(), session.AccessToken) // 3. Create session via ConnectRPC hostname, _ := os.Hostname() @@ -72,7 +66,7 @@ func Start(ctx context.Context, opts Options) error { }) if err != nil { fmt.Fprintf(os.Stderr, "⚠ Session creation failed: %v\n", err) - fmt.Fprintln(os.Stderr, " Launching without telemetry") + fmt.Fprintln(os.Stderr, " Launching without telemetry (backend may not support ConnectRPC yet)") return launchAgentDirect(ctx, opts) } From a2212eb0b9abe373fd42060c3105e7d62dc5bdd4 Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 6 Apr 2026 12:28:54 +0200 Subject: [PATCH 08/11] feat: wire up credential exchange via RFC 8693 token exchange --- internal/auth/oidc.go | 14 +++++------ internal/run/run.go | 57 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index d6d9112..ca6b601 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -30,16 +30,16 @@ type LoginResult struct { Session *Session } -// oauthMetadata is the response from /.well-known/oauth-authorization-server. -type oauthMetadata struct { +// OAuthMetadata is the response from /.well-known/oauth-authorization-server. +type OAuthMetadata struct { Issuer string `json:"issuer"` AuthorizationEndpoint string `json:"authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` JwksURI string `json:"jwks_uri"` } -// discoverEndpoints fetches OAuth authorization server metadata. -func discoverEndpoints(ctx context.Context, baseURL string) (*oauthMetadata, error) { +// DiscoverEndpoints fetches OAuth authorization server metadata. +func DiscoverEndpoints(ctx context.Context, baseURL string) (*OAuthMetadata, error) { url := strings.TrimRight(baseURL, "/") + "/.well-known/oauth-authorization-server" req, err := http.NewRequestWithContext(ctx, "GET", url, nil) @@ -57,7 +57,7 @@ func discoverEndpoints(ctx context.Context, baseURL string) (*oauthMetadata, err return nil, fmt.Errorf("discovery failed: %s", resp.Status) } - var meta oauthMetadata + var meta OAuthMetadata if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { return nil, fmt.Errorf("decode discovery: %w", err) } @@ -68,7 +68,7 @@ func discoverEndpoints(ctx context.Context, baseURL string) (*oauthMetadata, err // Login performs the browser-based OAuth PKCE login flow. func Login(ctx context.Context, issuerURL, clientID string) (*LoginResult, error) { // 1. Discover endpoints - meta, err := discoverEndpoints(ctx, issuerURL) + meta, err := DiscoverEndpoints(ctx, issuerURL) if err != nil { return nil, fmt.Errorf("oauth discovery failed for %s: %w", issuerURL, err) } @@ -181,7 +181,7 @@ func RefreshSession(ctx context.Context, session *Session) (*Session, error) { return nil, fmt.Errorf("no refresh token available") } - meta, err := discoverEndpoints(ctx, session.IssuerURL) + meta, err := DiscoverEndpoints(ctx, session.IssuerURL) if err != nil { return nil, fmt.Errorf("oauth discovery: %w", err) } diff --git a/internal/run/run.go b/internal/run/run.go index a9c7240..4fc3458 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -4,7 +4,10 @@ package run import ( "bufio" "context" + "encoding/json" "fmt" + "net/http" + "net/url" "os" "os/exec" "os/signal" @@ -187,8 +190,58 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr return resolved, nil } -func exchangeCredential(_ context.Context, _ *auth.Session, _ credential.Entry) (string, error) { - return "", fmt.Errorf("credential exchange not yet connected to backend") +// exchangeCredential calls POST /oauth2/token with RFC 8693 token exchange +// to resolve a provider credential. The user's access token serves as both +// the subject_token and the Bearer auth — no client secret needed. +func exchangeCredential(ctx context.Context, session *auth.Session, entry credential.Entry) (string, error) { + meta, err := auth.DiscoverEndpoints(ctx, auth.DefaultIssuerURL) + if err != nil { + return "", fmt.Errorf("oauth discovery: %w", err) + } + + form := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:token-exchange"}, + "client_id": {auth.DefaultClientID}, + "subject_token": {session.AccessToken}, + "subject_token_type": {"urn:ietf:params:oauth:token-type:access_token"}, + "resource": {entry.Provider}, + } + + req, err := http.NewRequestWithContext(ctx, "POST", meta.TokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("token exchange request: %w", err) + } + defer resp.Body.Close() + + var result struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ProviderKind string `json:"provider_kind"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("decode token exchange response: %w", err) + } + + if result.Error != "" { + if result.Error == "invalid_target" && strings.Contains(result.ErrorDesc, "not allowed") { + return "", fmt.Errorf("provider not connected: %s", entry.Provider) + } + return "", fmt.Errorf("token exchange failed: %s: %s", result.Error, result.ErrorDesc) + } + + if result.AccessToken == "" { + return "", fmt.Errorf("token exchange returned empty access_token") + } + + return result.AccessToken, nil } func isNotConnectedError(err error) bool { From 73afa6f1178a36f78ca550249ee5d089d13404bc Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 6 Apr 2026 13:51:05 +0200 Subject: [PATCH 09/11] fix: use session issuer URL for credential exchange discovery Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/run/run.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/run/run.go b/internal/run/run.go index 4fc3458..01b3973 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -194,7 +194,7 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr // to resolve a provider credential. The user's access token serves as both // the subject_token and the Bearer auth — no client secret needed. func exchangeCredential(ctx context.Context, session *auth.Session, entry credential.Entry) (string, error) { - meta, err := auth.DiscoverEndpoints(ctx, auth.DefaultIssuerURL) + meta, err := auth.DiscoverEndpoints(ctx, session.IssuerURL) if err != nil { return "", fmt.Errorf("oauth discovery: %w", err) } From f4b56ab525244ed82957865e92ce1403d61077d5 Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 6 Apr 2026 13:57:53 +0200 Subject: [PATCH 10/11] fix: address review findings from PR #3 - Block --settings and --setting-sources flags with value-skipping logic to prevent governance bypass via duplicate --settings - Guard sessionID[:8] against panic on empty/short session IDs - Clone request in bearerTransport.RoundTrip to avoid mutating the original (http.RoundTripper contract) Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/backend/backend.go | 5 +++-- internal/run/run.go | 26 ++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/internal/backend/backend.go b/internal/backend/backend.go index be068b0..371106c 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -88,6 +88,7 @@ type bearerTransport struct { } func (t *bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Set("Authorization", "Bearer "+t.token) - return t.base.RoundTrip(req) + r := req.Clone(req.Context()) + r.Header.Set("Authorization", "Bearer "+t.token) + return t.base.RoundTrip(r) } diff --git a/internal/run/run.go b/internal/run/run.go index 01b3973..aa96306 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -74,7 +74,7 @@ func Start(ctx context.Context, opts Options) error { } sessionID := createResp.SessionId - fmt.Fprintf(os.Stderr, "✓ Session: %s (%s)\n", createResp.SessionName, sessionID[:8]) + fmt.Fprintf(os.Stderr, "✓ Session: %s (%s)\n", createResp.SessionName, truncateID(sessionID)) // 4. Start sidecar sessionDir := filepath.Join(os.TempDir(), "kontext", sessionID) @@ -125,7 +125,7 @@ func Start(ctx context.Context, opts Options) error { defer cancel() _ = client.EndSession(endCtx, sessionID) - fmt.Fprintf(os.Stderr, "\n✓ Session ended (%s)\n", sessionID[:8]) + fmt.Fprintf(os.Stderr, "\n✓ Session ended (%s)\n", truncateID(sessionID)) os.RemoveAll(sessionDir) @@ -301,14 +301,36 @@ func filterArgs(args []string) []string { "--bare": true, "--dangerously-skip-permissions": true, } + // Flags that take a value argument — strip the flag AND the next arg. + blockedWithValue := map[string]bool{ + "--settings": true, + "--setting-sources": true, + } var filtered []string + skip := false for _, arg := range args { + if skip { + skip = false + continue + } if blocked[arg] { fmt.Fprintf(os.Stderr, "⚠ Stripped blocked flag: %s\n", arg) continue } + if blockedWithValue[arg] { + fmt.Fprintf(os.Stderr, "⚠ Stripped blocked flag: %s\n", arg) + skip = true // skip the next arg (the value) + continue + } filtered = append(filtered, arg) } return filtered } + +func truncateID(id string) string { + if len(id) >= 8 { + return id[:8] + } + return id +} From f99d10ebdf071aab6d219cf787256d555564023e Mon Sep 17 00:00:00 2001 From: tumberger Date: Mon, 6 Apr 2026 15:22:57 +0200 Subject: [PATCH 11/11] docs: add ARCHITECTURE.md Explains the CLI's design to someone who has never seen it: - What it does and why - Three modes (start, hook, login) in one binary - Why Go (hook startup time) - Sidecar pattern and why it exists - Agent adapter interface - Credential injection via env template - Auth model (user OIDC, no client secrets) - Telemetry vs credentials split - What works today vs what's blocked on server Co-Authored-By: Claude Opus 4.6 (1M context) --- ARCHITECTURE.md | 139 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..082001a --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,139 @@ +# Kontext CLI + +## The problem + +AI coding agents (Claude Code, Cursor, Codex) run on your laptop with whatever credentials you have lying around — long-lived API keys in `.env` files, GitHub tokens in your shell, database passwords in your config. There's no scoping, no audit trail, no way for a team lead to see what agents are doing across the org. + +## What the CLI does + +One command: + +```bash +kontext start --agent claude +``` + +This launches Claude Code, but with two things added: + +1. **Scoped credentials** — instead of using whatever's in your shell, the agent gets short-lived tokens resolved from your Kontext account. They expire when the session ends. + +2. **Telemetry** — every tool call (file edits, shell commands, API calls) is logged to the Kontext dashboard. The team sees who did what, when, and whether it was allowed. + +## How it works (the 30-second version) + +``` +You run: kontext start --agent claude + +1. CLI checks your identity (OIDC token in your system keychain) +2. CLI reads .env.kontext to see what credentials the project needs +3. CLI resolves each credential from the Kontext backend +4. CLI launches Claude Code with those credentials as env vars +5. Every tool call Claude makes gets logged to your team's dashboard +6. When you exit, credentials expire, session ends +``` + +## The codebase + +### Three binaries in one + +The CLI is a single Go binary that runs in three modes: + +- **`kontext start`** — the main command. Orchestrates everything, stays alive for the session. +- **`kontext hook`** — called by Claude Code automatically on every tool call. You never run this yourself. +- **`kontext login`** — one-time browser login. Stores your identity in the system keychain. + +They're the same binary because Claude Code needs to spawn hook handlers by command name. One binary = no install issues. + +### Why Go + +The hook handler (`kontext hook`) gets spawned on every single tool call — every file edit, every shell command, every API request. Node.js takes 50-100ms to start. Go takes 5ms. Over a session with hundreds of tool calls, this matters. + +Go also compiles to a single binary with zero dependencies. `brew install` and you're done. + +### Project structure + +``` +cmd/kontext/main.go — CLI entry point (start, login, hook commands) +internal/ + agent/ — agent adapter interface + claude/claude.go — Claude Code hook I/O format + auth/ — OIDC login + keychain storage + backend/ — ConnectRPC client for the Kontext API + credential/ — .env.kontext template parser + hook/ — hook event processor (stdin → evaluate → stdout) + run/ — the start command orchestrator + hooks.go — generates Claude Code hook config + sidecar/ — local Unix socket server + protocol.go — wire format for hook ↔ sidecar communication +gen/ — generated protobuf code (from kontext-dev/proto) +``` + +### The sidecar — why it exists + +When Claude Code makes a tool call, it spawns `kontext hook` as a new process. That process needs to log the event and get a policy decision. If it made a network call to the backend every time, that's 100-300ms per tool call — unacceptable. + +The sidecar solves this. It's a small server that starts alongside Claude Code and listens on a Unix socket file. The hook handler connects to it locally (sub-millisecond), and the sidecar maintains a persistent connection to the backend. + +``` +Claude Code → spawns kontext hook → Unix socket → sidecar → backend + (5ms) (0ms) (already connected) +``` + +The sidecar also sends heartbeats every 30 seconds to keep the session alive in the dashboard. + +### Agent adapters + +Each agent (Claude Code, Cursor, Codex) has a different format for hook events. The adapter translates: + +```go +type Agent interface { + Name() string // "claude" + DecodeHookInput([]byte) (*HookEvent, error) // parse agent's JSON + EncodeAllow(*HookEvent, string) ([]byte, error) // format allow response + EncodeDeny(*HookEvent, string) ([]byte, error) // format deny response +} +``` + +Everything else — the sidecar, telemetry, credential resolution, policy evaluation — is shared. Adding a new agent is one file with four methods. + +### Credential injection + +A `.env.kontext` file in the project declares what credentials the agent needs: + +``` +GITHUB_TOKEN={{kontext:github}} +STRIPE_KEY={{kontext:stripe}} +``` + +Before launching the agent, the CLI resolves each placeholder by calling the Kontext backend with the user's identity. The backend returns a short-lived credential (could be an OAuth token, could be an API key — the CLI doesn't distinguish). These become env vars in the agent's process. + +The agent uses them naturally — `git push` reads `GITHUB_TOKEN`, `curl` reads `STRIPE_KEY`. No special SDK, no interception. + +### Auth + +No client secrets. The user logs in once via browser (`kontext login`), and a refresh token is stored in the system keychain (macOS Keychain / Linux secret service). Every `kontext start` loads and refreshes the token automatically. The backend verifies the JWT and knows who the user is and which org they belong to. + +### Telemetry vs credentials — two separate things + +The CLI has two backend integrations that are completely independent: + +**Telemetry** — session lifecycle + hook events. Uses ConnectRPC (gRPC-compatible) with bidirectional streaming. The proto lives in `kontext-dev/proto`. This is what powers the dashboard. + +**Credentials** — provider token resolution. Uses a plain REST endpoint (`POST /api/v1/credentials/exchange`). This is what populates the env vars. + +They use different protocols because they have different needs. Telemetry needs streaming (hundreds of events per session over one connection). Credentials need a simple request/response (one call per provider at session start). + +### What's working today + +- `kontext login` — browser OIDC login, keychain storage, token refresh +- `kontext start --agent claude` — launches Claude Code, interactive `.env.kontext` setup on first run +- Agent adapter for Claude Code — full hook I/O encoding/decoding +- Sidecar with Unix socket — accepts hook connections, relays events +- Hook command — reads stdin, talks to sidecar, writes decision to stdout +- Settings generation — creates Claude Code hook config automatically + +### What's blocked on the server + +- **Telemetry** (#408) — needs ConnectRPC `AgentService` endpoint on the API + auth change to accept user bearer tokens +- **Credentials** (#410) — needs `POST /api/v1/credentials/exchange` endpoint authenticated with user tokens + +Both are unblocked by the same server-side auth change: `UnifiedAuthGuard` learning to accept user OIDC tokens as bearer tokens, not just service account tokens.