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/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. diff --git a/README.md b/README.md index 9ad22a8..9216a92 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,88 @@ 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 + ├── ConnectRPC: CreateSession → session in dashboard + ├── Sidecar: Unix socket server (kontext.sock) + │ ├── Heartbeat loop (30s) + │ └── Async event ingestion via ConnectRPC + ├── 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 ``` -**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 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) +- 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 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 + +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 +156,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 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/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/gen/kontext/agent/v1/agent.pb.go b/gen/kontext/agent/v1/agent.pb.go new file mode 100644 index 0000000..3f0ddfd --- /dev/null +++ b/gen/kontext/agent/v1/agent.pb.go @@ -0,0 +1,666 @@ +// 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 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" + 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 *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 *ProcessHookEventRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessHookEventRequest) ProtoMessage() {} + +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)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessHookEventRequest.ProtoReflect.Descriptor instead. +func (*ProcessHookEventRequest) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *ProcessHookEventRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *ProcessHookEventRequest) GetAgent() string { + if x != nil { + return x.Agent + } + return "" +} + +func (x *ProcessHookEventRequest) GetHookEvent() string { + if x != nil { + return x.HookEvent + } + return "" +} + +func (x *ProcessHookEventRequest) GetToolName() string { + if x != nil { + return x.ToolName + } + return "" +} + +func (x *ProcessHookEventRequest) GetToolInput() []byte { + if x != nil { + return x.ToolInput + } + return nil +} + +func (x *ProcessHookEventRequest) GetToolResponse() []byte { + if x != nil { + return x.ToolResponse + } + return nil +} + +func (x *ProcessHookEventRequest) GetToolUseId() string { + if x != nil { + return x.ToolUseId + } + return "" +} + +func (x *ProcessHookEventRequest) GetCwd() string { + if x != nil { + return x.Cwd + } + return "" +} + +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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +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 *ProcessHookEventResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessHookEventResponse) ProtoMessage() {} + +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)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessHookEventResponse.ProtoReflect.Descriptor instead. +func (*ProcessHookEventResponse) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *ProcessHookEventResponse) GetDecision() Decision { + if x != nil { + return x.Decision + } + return Decision_DECISION_UNSPECIFIED +} + +func (x *ProcessHookEventResponse) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *ProcessHookEventResponse) GetEventId() string { + if x != nil { + return x.EventId + } + return "" +} + +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[2] + 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[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 CreateSessionRequest.ProtoReflect.Descriptor instead. +func (*CreateSessionRequest) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{2} +} + +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[3] + 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[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 CreateSessionResponse.ProtoReflect.Descriptor instead. +func (*CreateSessionResponse) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{3} +} + +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[4] + 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[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 HeartbeatRequest.ProtoReflect.Descriptor instead. +func (*HeartbeatRequest) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{4} +} + +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[5] + 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[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 HeartbeatResponse.ProtoReflect.Descriptor instead. +func (*HeartbeatResponse) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{5} +} + +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[6] + 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[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 EndSessionRequest.ProtoReflect.Descriptor instead. +func (*EndSessionRequest) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{6} +} + +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[7] + 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[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 EndSessionResponse.ProtoReflect.Descriptor instead. +func (*EndSessionResponse) Descriptor() ([]byte, []int) { + return file_kontext_agent_v1_agent_proto_rawDescGZIP(), []int{7} +} + +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\"\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" + + "\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\"\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\"\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*]\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\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.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" + +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, 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 + (*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 + 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() } +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: 1, + NumMessages: 9, + 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..c847d0e --- /dev/null +++ b/gen/kontext/agent/v1/agentv1connect/agent.connect.go @@ -0,0 +1,210 @@ +// 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" +) + +// 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. + ProcessHookEvent(context.Context) *connect.BidiStreamForClient[v1.ProcessHookEventRequest, v1.ProcessHookEventResponse] + // CreateSession establishes a governed agent session. Called once at the + // 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. + EndSession(context.Context, *connect.Request[v1.EndSessionRequest]) (*connect.Response[v1.EndSessionResponse], 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.ProcessHookEventRequest, v1.ProcessHookEventResponse]( + 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...), + ), + } +} + +// 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] +} + +// ProcessHookEvent calls kontext.agent.v1.AgentService.ProcessHookEvent. +func (c *agentServiceClient) ProcessHookEvent(ctx context.Context) *connect.BidiStreamForClient[v1.ProcessHookEventRequest, v1.ProcessHookEventResponse] { + 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) +} + +// 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. + ProcessHookEvent(context.Context, *connect.BidiStream[v1.ProcessHookEventRequest, v1.ProcessHookEventResponse]) error + // CreateSession establishes a governed agent session. Called once at the + // 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. + EndSession(context.Context, *connect.Request[v1.EndSessionRequest]) (*connect.Response[v1.EndSessionResponse], 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...), + ) + 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) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedAgentServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedAgentServiceHandler struct{} + +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")) +} + +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")) +} 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/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/backend/backend.go b/internal/backend/backend.go new file mode 100644 index 0000000..371106c --- /dev/null +++ b/internal/backend/backend.go @@ -0,0 +1,94 @@ +// 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" + "fmt" + "net/http" + "os" + "time" + + "connectrpc.com/connect" + + agentv1 "github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1" + "github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1/agentv1connect" +) + +// Client wraps the ConnectRPC AgentService client. +type Client struct { + rpc agentv1connect.AgentServiceClient +} + +// 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}, + } + + return &Client{ + rpc: agentv1connect.NewAgentServiceClient(httpClient, baseURL), + } +} + +// BaseURL returns the API base URL from env or default. +func BaseURL() string { + if v := os.Getenv("KONTEXT_API_URL"); v != "" { + return v + } + return "https://api.kontext.security" +} + +// 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 nil, fmt.Errorf("CreateSession: %w", err) + } + return resp.Msg, 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 +} + +// 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 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) + } + if err := stream.CloseRequest(); err != nil { + return err + } + if resp, err := stream.Receive(); err == nil { + _ = resp + } + return stream.CloseResponse() +} + +// bearerTransport injects the user's OIDC token into every request. +type bearerTransport struct { + token string + base http.RoundTripper +} + +func (t *bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + r := req.Clone(req.Context()) + r.Header.Set("Authorization", "Bearer "+t.token) + return t.base.RoundTrip(r) +} 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..aa96306 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -1,21 +1,29 @@ // Package run implements the `kontext start` orchestrator. -// It handles the full lifecycle: auth → init → credentials → sidecar → subprocess → cleanup. package run import ( "bufio" "context" + "encoding/json" "fmt" + "net/http" + "net/url" "os" "os/exec" "os/signal" + "path/filepath" + "runtime" "strings" "syscall" + "time" "github.com/cli/browser" + 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" + "github.com/kontext-dev/kontext-cli/internal/sidecar" ) // Options configures a kontext start run. @@ -24,12 +32,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 +51,91 @@ 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 — authenticated with the user's OIDC token + client := backend.NewClient(backend.BaseURL(), session.AccessToken) + + // 3. Create session via ConnectRPC + hostname, _ := os.Hostname() + cwd, _ := os.Getwd() + createResp, err := client.CreateSession(ctx, &agentv1.CreateSessionRequest{ + UserId: identity, + Agent: opts.Agent, + Hostname: hostname, + Cwd: cwd, + ClientInfo: map[string]string{ + "name": "kontext-cli", + "os": runtime.GOOS, + }, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "⚠ Session creation failed: %v\n", err) + fmt.Fprintln(os.Stderr, " Launching without telemetry (backend may not support ConnectRPC yet)") + return launchAgentDirect(ctx, opts) } - // 3. Parse template and resolve credentials - var resolved []credential.Resolved - entries, err := credential.ParseTemplate(templatePath) + sessionID := createResp.SessionId + fmt.Fprintf(os.Stderr, "✓ Session: %s (%s)\n", createResp.SessionName, truncateID(sessionID)) + + // 4. Start sidecar + sessionDir := filepath.Join(os.TempDir(), "kontext", sessionID) + os.MkdirAll(sessionDir, 0700) + + sc, err := sidecar.New(sessionDir, client, sessionID, opts.Agent) if err != nil { - return fmt.Errorf("parse template: %w", err) + return fmt.Errorf("sidecar: %w", err) } + if err := sc.Start(ctx); err != nil { + return fmt.Errorf("sidecar start: %w", err) + } + defer sc.Stop() - if len(entries) > 0 { - resolved, err = resolveCredentials(ctx, session, entries) + // 5. Generate hook settings + kontextBin, _ := os.Executable() + settingsPath, err := GenerateSettings(sessionDir, kontextBin, opts.Agent) + if err != nil { + return fmt.Errorf("generate settings: %w", err) + } + + // 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) + agentErr := launchAgentWithSettings(ctx, opts.Agent, env, opts.Args, settingsPath) + + // 9. Teardown (always runs, even on non-zero agent exit) + endCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _ = client.EndSession(endCtx, sessionID) + fmt.Fprintf(os.Stderr, "\n✓ Session ended (%s)\n", truncateID(sessionID)) + + os.RemoveAll(sessionDir) + + // Propagate agent exit code + if agentErr != nil { + if exitErr, ok := agentErr.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + } + return agentErr } // ensureSession loads the session or triggers an interactive login. @@ -93,52 +158,8 @@ 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 { - // Write an empty template so it doesn't prompt again - 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) { - if len(entries) == 0 { - return nil, nil - } - fmt.Fprintln(os.Stderr, "\nResolving credentials...") var resolved []credential.Resolved @@ -147,21 +168,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,11 +190,58 @@ 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") +// 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, session.IssuerURL) + 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 { @@ -187,26 +249,29 @@ 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) +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, "") +} + +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 +281,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() { @@ -227,23 +291,20 @@ func launchAgent(_ context.Context, agentName string, env []string, 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 } -// 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, + } + // 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 @@ -255,13 +316,21 @@ func filterArgs(args []string) []string { } 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 + } + 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 +} 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..f86e42d 100644 --- a/internal/sidecar/sidecar.go +++ b/internal/sidecar/sidecar.go @@ -1,61 +1,72 @@ // 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 connect over a Unix socket. The sidecar relays events +// to the Kontext backend via ConnectRPC and returns policy decisions. package sidecar import ( "context" - "fmt" + "log" "net" "os" "path/filepath" - "sync" + "time" + + agentv1 "github.com/kontext-dev/kontext-cli/gen/kontext/agent/v1" + "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 + agentName string + client *backend.Client + cancel context.CancelFunc } -// New creates a new sidecar server with a Unix socket in the given directory. -func New(sessionDir string) (*Server, error) { - socketPath := filepath.Join(sessionDir, "kontext.sock") - return &Server{socketPath: socketPath}, nil +// New creates a new sidecar server. +func New(sessionDir string, client *backend.Client, sessionID, agentName string) (*Server, error) { + return &Server{ + socketPath: filepath.Join(sessionDir, "kontext.sock"), + sessionID: sessionID, + agentName: agentName, + client: client, + }, nil } -// SocketPath returns the Unix socket path for hook handlers to connect to. -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 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 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 +81,60 @@ 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: %v", err) + return + } + + // Always allow for now — policy evaluation is a future phase + result := EvaluateResult{Type: "result", Allowed: true, Reason: "allowed"} + if err := WriteMessage(conn, result); err != nil { + log.Printf("sidecar: write: %v", err) + return + } + + // Ingest event asynchronously via ConnectRPC + go s.ingestEvent(ctx, &req) +} + +func (s *Server) ingestEvent(ctx context.Context, req *EvaluateRequest) { + hookEvent := &agentv1.ProcessHookEventRequest{ + SessionId: s.sessionID, + Agent: s.agentName, + HookEvent: req.HookEvent, + ToolName: req.ToolName, + ToolUseId: req.ToolUseID, + Cwd: req.CWD, + } + + if len(req.ToolInput) > 0 { + hookEvent.ToolInput = req.ToolInput + } + if len(req.ToolResponse) > 0 { + hookEvent.ToolResponse = req.ToolResponse + } + + if err := s.client.IngestEvent(ctx, hookEvent); err != nil { + log.Printf("sidecar: ingest: %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.client.Heartbeat(ctx, s.sessionID); err != nil { + log.Printf("sidecar: heartbeat: %v", err) + } + } + } }