This commit completes Beat 1 of the SequentialThinkingForCHORUS implementation, providing a functional plaintext skeleton for the age-encrypted wrapper. ## Deliverables ### 1. Main Wrapper Entry Point - `cmd/seqthink-wrapper/main.go`: HTTP server on :8443 - Configuration loading from environment variables - Graceful shutdown handling - MCP server readiness checking with timeout ### 2. MCP Client Package - `pkg/seqthink/mcpclient/client.go`: HTTP client for MCP server - Communicates with MCP server on localhost:8000 - Health check endpoint - Tool call endpoint with 120s timeout ### 3. Proxy Server Package - `pkg/seqthink/proxy/server.go`: HTTP handlers for wrapper - Health and readiness endpoints - Tool call proxy (plaintext for Beat 1) - SSE endpoint placeholder - Metrics endpoint integration ### 4. Observability Package - `pkg/seqthink/observability/logger.go`: Structured logging with zerolog - `pkg/seqthink/observability/metrics.go`: Prometheus metrics - Counters for requests, errors, decrypt/encrypt failures, policy denials - Request duration histogram ### 5. Docker Infrastructure - `deploy/seqthink/Dockerfile`: Multi-stage build - `deploy/seqthink/entrypoint.sh`: Startup orchestration - `deploy/seqthink/mcp_stub.py`: Minimal MCP server for testing ### 6. Build System Integration - Updated `Makefile` with `build-seqthink` target - Uses GOWORK=off and -mod=mod for clean builds - `docker-seqthink` target for container builds ## Testing Successfully builds with: ``` make build-seqthink ``` Binary successfully starts and waits for MCP server connection. ## Next Steps Beat 2 will add: - Age encryption/decryption (pkg/seqthink/ageio) - Content-Type: application/age enforcement - SSE streaming with encrypted frames - Golden tests for crypto round-trips 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1091 lines
28 KiB
Markdown
1091 lines
28 KiB
Markdown
# Sequential Thinking Age-Encrypted Wrapper - Implementation Guide
|
|
|
|
**Date**: 2025-10-13
|
|
**Status**: Ready for Implementation
|
|
**Project**: SequentialThinkingForCHORUS
|
|
**Priority**: High - Blocking agent intelligence improvements
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
This document provides the **complete implementation plan** for the age-encrypted Sequential Thinking MCP wrapper as specified in the SequentialThinkingForCHORUS issue #1. This wrapper enables secure, production-grade deployment of Sequential Thinking capabilities while maintaining confidentiality through end-to-end encryption.
|
|
|
|
**Two-Part Solution**:
|
|
1. **Part A**: Build the `seqthink-age` container (age-encrypted wrapper)
|
|
2. **Part B**: Integrate CHORUS agents with the encrypted wrapper
|
|
|
|
---
|
|
|
|
## Part A: seqthink-age Container Implementation
|
|
|
|
### Repository Structure
|
|
|
|
```
|
|
seqthink-age/
|
|
├── cmd/
|
|
│ └── agewrap/
|
|
│ └── main.go # Wrapper entry point
|
|
├── pkg/
|
|
│ ├── ageio/
|
|
│ │ ├── crypto.go # SealFrame/OpenFrame + gzip
|
|
│ │ ├── crypto_test.go
|
|
│ │ └── testdata/ # Golden test vectors
|
|
│ ├── policy/
|
|
│ │ ├── kaching.go # JWT verification
|
|
│ │ ├── scope.go # Scope checking
|
|
│ │ └── policy_test.go
|
|
│ ├── proxy/
|
|
│ │ ├── handlers.go # HTTP handlers
|
|
│ │ ├── sse.go # SSE streaming
|
|
│ │ ├── health.go # Health checks
|
|
│ │ └── proxy_test.go
|
|
│ ├── mcpclient/
|
|
│ │ ├── client.go # Loopback MCP caller
|
|
│ │ └── client_test.go
|
|
│ └── observability/
|
|
│ ├── metrics.go # Prometheus metrics
|
|
│ ├── logging.go # Structured logging
|
|
│ └── pprof.go # Optional profiling
|
|
├── deploy/
|
|
│ ├── Dockerfile # Multi-stage build
|
|
│ ├── entrypoint.sh # Process orchestration
|
|
│ └── compose.example.yml # Local dev stack
|
|
├── secrets/ # .gitignored
|
|
│ ├── age_identity.key.example
|
|
│ └── age_recipients.txt.example
|
|
├── test/
|
|
│ ├── integration/
|
|
│ │ └── e2e_test.go
|
|
│ └── fixtures/
|
|
│ └── jwt_samples.json
|
|
├── docs/
|
|
│ ├── API.md # API documentation
|
|
│ ├── SECURITY.md # Security model
|
|
│ └── WHOOSH_INTEGRATION.md # WHOOSH client guide
|
|
├── .github/
|
|
│ └── workflows/
|
|
│ └── ci.yml # Build, test, sign, push
|
|
├── Makefile
|
|
├── go.mod
|
|
├── go.sum
|
|
├── README.md
|
|
└── LICENSE
|
|
```
|
|
|
|
---
|
|
|
|
## Beat 1: Skeleton & Plaintext Correct
|
|
|
|
### Implementation Tasks
|
|
|
|
#### 1.1 Repository Setup
|
|
|
|
```bash
|
|
# Create repository structure
|
|
mkdir -p seqthink-age/{cmd/agewrap,pkg/{ageio,policy,proxy,mcpclient,observability},deploy,test/{integration,fixtures},docs,.github/workflows}
|
|
|
|
# Initialize Go module
|
|
cd seqthink-age
|
|
go mod init github.com/chorus-services/seqthink-age
|
|
|
|
# Add dependencies
|
|
go get github.com/gorilla/mux
|
|
go get github.com/rs/zerolog
|
|
go get github.com/prometheus/client_golang/prometheus
|
|
go get filippo.io/age
|
|
go get github.com/golang-jwt/jwt/v5
|
|
```
|
|
|
|
#### 1.2 Main Entry Point
|
|
|
|
**File**: `cmd/agewrap/main.go`
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/chorus-services/seqthink-age/pkg/mcpclient"
|
|
"github.com/chorus-services/seqthink-age/pkg/proxy"
|
|
"github.com/chorus-services/seqthink-age/pkg/observability"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type Config struct {
|
|
Port string
|
|
MCPLocalURL string
|
|
LogLevel string
|
|
MaxBodyMB int
|
|
HealthTimeout time.Duration
|
|
ShutdownTimeout time.Duration
|
|
}
|
|
|
|
func loadConfig() *Config {
|
|
return &Config{
|
|
Port: getEnv("PORT", "8443"),
|
|
MCPLocalURL: getEnv("MCP_LOCAL", "http://127.0.0.1:8000"),
|
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
|
MaxBodyMB: getEnvInt("MAX_BODY_MB", 4),
|
|
HealthTimeout: 5 * time.Second,
|
|
ShutdownTimeout: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
cfg := loadConfig()
|
|
|
|
// Initialize observability
|
|
observability.InitLogger(cfg.LogLevel)
|
|
metrics := observability.InitMetrics()
|
|
|
|
log.Info().
|
|
Str("port", cfg.Port).
|
|
Str("mcp_url", cfg.MCPLocalURL).
|
|
Msg("Starting seqthink-age wrapper")
|
|
|
|
// Create MCP client
|
|
mcpClient := mcpclient.New(cfg.MCPLocalURL)
|
|
|
|
// Wait for MCP server to be ready
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
if err := waitForMCP(ctx, mcpClient); err != nil {
|
|
log.Fatal().Err(err).Msg("MCP server not ready")
|
|
}
|
|
|
|
log.Info().Msg("✅ MCP server ready")
|
|
|
|
// Create proxy server
|
|
proxyServer := proxy.NewServer(proxy.ServerConfig{
|
|
MCPClient: mcpClient,
|
|
Metrics: metrics,
|
|
MaxBodyMB: cfg.MaxBodyMB,
|
|
})
|
|
|
|
// Setup HTTP server
|
|
srv := &http.Server{
|
|
Addr: ":" + cfg.Port,
|
|
Handler: proxyServer.Handler(),
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 90 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
// Start server in goroutine
|
|
go func() {
|
|
log.Info().Str("addr", srv.Addr).Msg("🚀 Wrapper listening")
|
|
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
|
log.Fatal().Err(err).Msg("HTTP server failed")
|
|
}
|
|
}()
|
|
|
|
// Wait for shutdown signal
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
|
<-sigChan
|
|
|
|
log.Info().Msg("🛑 Shutting down gracefully...")
|
|
|
|
// Graceful shutdown
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
|
defer shutdownCancel()
|
|
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
log.Error().Err(err).Msg("Shutdown error")
|
|
}
|
|
|
|
log.Info().Msg("✅ Shutdown complete")
|
|
}
|
|
|
|
func waitForMCP(ctx context.Context, client *mcpclient.Client) error {
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("timeout waiting for MCP server")
|
|
case <-ticker.C:
|
|
if err := client.Health(ctx); err == nil {
|
|
return nil
|
|
}
|
|
log.Debug().Msg("Waiting for MCP server...")
|
|
}
|
|
}
|
|
}
|
|
|
|
func getEnv(key, defaultVal string) string {
|
|
if val := os.Getenv(key); val != "" {
|
|
return val
|
|
}
|
|
return defaultVal
|
|
}
|
|
|
|
func getEnvInt(key string, defaultVal int) int {
|
|
// Implementation
|
|
return defaultVal
|
|
}
|
|
```
|
|
|
|
#### 1.3 MCP Client
|
|
|
|
**File**: `pkg/mcpclient/client.go`
|
|
|
|
```go
|
|
package mcpclient
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
)
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
type ToolRequest struct {
|
|
Tool string `json:"tool"`
|
|
Payload map[string]interface{} `json:"payload"`
|
|
}
|
|
|
|
type ToolResponse struct {
|
|
Result interface{} `json:"result"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func New(baseURL string) *Client {
|
|
return &Client{
|
|
baseURL: baseURL,
|
|
httpClient: &http.Client{
|
|
Timeout: 60 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *Client) Health(ctx context.Context) error {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/health", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("health check failed: status %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) CallTool(ctx context.Context, req *ToolRequest) (*ToolResponse, error) {
|
|
jsonData, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/mcp/tool", bytes.NewReader(jsonData))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("http request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("tool call failed: status %d, body: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var toolResp ToolResponse
|
|
if err := json.Unmarshal(body, &toolResp); err != nil {
|
|
return nil, fmt.Errorf("unmarshal response: %w", err)
|
|
}
|
|
|
|
return &toolResp, nil
|
|
}
|
|
```
|
|
|
|
#### 1.4 Proxy Handlers (Plaintext Phase)
|
|
|
|
**File**: `pkg/proxy/server.go`
|
|
|
|
```go
|
|
package proxy
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/chorus-services/seqthink-age/pkg/mcpclient"
|
|
"github.com/chorus-services/seqthink-age/pkg/observability"
|
|
"github.com/gorilla/mux"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type ServerConfig struct {
|
|
MCPClient *mcpclient.Client
|
|
Metrics *observability.Metrics
|
|
MaxBodyMB int
|
|
}
|
|
|
|
type Server struct {
|
|
config ServerConfig
|
|
router *mux.Router
|
|
}
|
|
|
|
func NewServer(cfg ServerConfig) *Server {
|
|
s := &Server{
|
|
config: cfg,
|
|
router: mux.NewRouter(),
|
|
}
|
|
|
|
s.setupRoutes()
|
|
return s
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.router
|
|
}
|
|
|
|
func (s *Server) setupRoutes() {
|
|
// Health endpoints
|
|
s.router.HandleFunc("/health", s.handleHealth).Methods("GET")
|
|
s.router.HandleFunc("/ready", s.handleReady).Methods("GET")
|
|
|
|
// MCP proxy endpoints
|
|
s.router.HandleFunc("/mcp/tool", s.handleToolCall).Methods("POST")
|
|
s.router.HandleFunc("/mcp/sse", s.handleSSE).Methods("GET")
|
|
|
|
// Metrics (optional)
|
|
if s.config.Metrics != nil {
|
|
s.router.Handle("/metrics", s.config.Metrics.Handler())
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Check MCP server health
|
|
if err := s.config.MCPClient.Health(ctx); err != nil {
|
|
log.Warn().Err(err).Msg("MCP health check failed")
|
|
http.Error(w, "MCP server unhealthy", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ready"})
|
|
}
|
|
|
|
func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Limit body size
|
|
r.Body = http.MaxBytesReader(w, r.Body, int64(s.config.MaxBodyMB*1024*1024))
|
|
|
|
// Read request body
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Failed to read request body")
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse as MCP tool request
|
|
var toolReq mcpclient.ToolRequest
|
|
if err := json.Unmarshal(body, &toolReq); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to parse tool request")
|
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Call MCP server
|
|
resp, err := s.config.MCPClient.CallTool(ctx, &toolReq)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("MCP tool call failed")
|
|
http.Error(w, "Tool execution failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
|
// SSE implementation (placeholder for Beat 1)
|
|
http.Error(w, "SSE not yet implemented", http.StatusNotImplemented)
|
|
}
|
|
```
|
|
|
|
#### 1.5 Dockerfile & Entrypoint
|
|
|
|
**File**: `deploy/Dockerfile`
|
|
|
|
```dockerfile
|
|
# Build stage
|
|
FROM golang:1.22-alpine AS builder
|
|
|
|
WORKDIR /build
|
|
|
|
# Copy go mod files
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
|
|
# Copy source
|
|
COPY . .
|
|
|
|
# Build wrapper
|
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
|
-ldflags="-w -s" \
|
|
-o agewrap \
|
|
./cmd/agewrap
|
|
|
|
# Runtime stage
|
|
FROM debian:stable-slim
|
|
|
|
# Install MCP server (placeholder - adjust based on actual MCP distribution)
|
|
RUN apt-get update && apt-get install -y \
|
|
ca-certificates \
|
|
curl \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# Copy wrapper binary
|
|
COPY --from=builder /build/agewrap /usr/local/bin/agewrap
|
|
|
|
# Copy entrypoint
|
|
COPY deploy/entrypoint.sh /entrypoint.sh
|
|
RUN chmod +x /entrypoint.sh
|
|
|
|
# Create non-root user
|
|
RUN groupadd -g 1000 seqthink && \
|
|
useradd -u 1000 -g seqthink -s /bin/bash -d /home/seqthink -m seqthink
|
|
|
|
# Runtime directories
|
|
RUN mkdir -p /run/secrets /tmp/seqthink && \
|
|
chown -R seqthink:seqthink /tmp/seqthink
|
|
|
|
USER seqthink
|
|
|
|
EXPOSE 8443
|
|
|
|
ENTRYPOINT ["/entrypoint.sh"]
|
|
```
|
|
|
|
**File**: `deploy/entrypoint.sh`
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
echo "🚀 Starting Sequential Thinking Wrapper"
|
|
|
|
# Start MCP server on loopback
|
|
echo "📡 Starting MCP server on 127.0.0.1:8000..."
|
|
sequentialthinking --transport=http --port=8000 --bind=127.0.0.1 &
|
|
MCP_PID=$!
|
|
|
|
# Wait for MCP to be ready
|
|
echo "⏳ Waiting for MCP server..."
|
|
for i in {1..30}; do
|
|
if curl -sf http://127.0.0.1:8000/health > /dev/null 2>&1; then
|
|
echo "✅ MCP server ready"
|
|
break
|
|
fi
|
|
if [ $i -eq 30 ]; then
|
|
echo "❌ MCP server failed to start"
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
# Start age wrapper
|
|
echo "🔐 Starting age wrapper on :${PORT:-8443}..."
|
|
exec agewrap
|
|
```
|
|
|
|
#### 1.6 Makefile
|
|
|
|
**File**: `Makefile`
|
|
|
|
```makefile
|
|
.PHONY: all build test lint clean image
|
|
|
|
# Variables
|
|
IMAGE_NAME ?= anthonyrawlins/seqthink-age
|
|
IMAGE_TAG ?= latest
|
|
GOBIN ?= $(shell go env GOPATH)/bin
|
|
|
|
all: lint test build
|
|
|
|
build:
|
|
@echo "Building agewrap..."
|
|
go build -o bin/agewrap ./cmd/agewrap
|
|
|
|
test:
|
|
@echo "Running tests..."
|
|
go test -v -race -cover ./...
|
|
|
|
lint:
|
|
@echo "Running linters..."
|
|
golangci-lint run ./...
|
|
|
|
clean:
|
|
@echo "Cleaning..."
|
|
rm -rf bin/
|
|
|
|
image:
|
|
@echo "Building Docker image..."
|
|
docker build -f deploy/Dockerfile -t $(IMAGE_NAME):$(IMAGE_TAG) .
|
|
|
|
image-push:
|
|
@echo "Pushing image..."
|
|
docker push $(IMAGE_NAME):$(IMAGE_TAG)
|
|
|
|
run-local:
|
|
@echo "Running locally..."
|
|
MCP_LOCAL=http://127.0.0.1:8000 PORT=8443 go run ./cmd/agewrap
|
|
|
|
compose-up:
|
|
docker-compose -f deploy/compose.example.yml up --build
|
|
|
|
compose-down:
|
|
docker-compose -f deploy/compose.example.yml down
|
|
```
|
|
|
|
### Beat 1 Acceptance Criteria
|
|
|
|
- [ ] `make build` compiles successfully
|
|
- [ ] `docker build` creates image
|
|
- [ ] Container starts MCP server on loopback
|
|
- [ ] Container starts wrapper on :8443
|
|
- [ ] `/health` endpoint returns 200
|
|
- [ ] POST to `/mcp/tool` with plaintext JSON returns tool result
|
|
- [ ] Logs are structured JSON
|
|
- [ ] Graceful shutdown works
|
|
|
|
---
|
|
|
|
## Beat 2: Crypto Envelope (Age + Gzip)
|
|
|
|
### Implementation Tasks
|
|
|
|
#### 2.1 Age Crypto Implementation
|
|
|
|
**File**: `pkg/ageio/crypto.go`
|
|
|
|
```go
|
|
package ageio
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
|
|
"filippo.io/age"
|
|
)
|
|
|
|
// SealFrame encrypts and compresses plaintext
|
|
func SealFrame(plaintext []byte, recipients []age.Recipient) ([]byte, error) {
|
|
// 1. Gzip compress
|
|
var compressed bytes.Buffer
|
|
gzipWriter := gzip.NewWriter(&compressed)
|
|
if _, err := gzipWriter.Write(plaintext); err != nil {
|
|
return nil, fmt.Errorf("gzip write: %w", err)
|
|
}
|
|
if err := gzipWriter.Close(); err != nil {
|
|
return nil, fmt.Errorf("gzip close: %w", err)
|
|
}
|
|
|
|
// 2. Age encrypt
|
|
var encrypted bytes.Buffer
|
|
encWriter, err := age.Encrypt(&encrypted, recipients...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("age encrypt: %w", err)
|
|
}
|
|
|
|
if _, err := encWriter.Write(compressed.Bytes()); err != nil {
|
|
return nil, fmt.Errorf("encrypt write: %w", err)
|
|
}
|
|
|
|
if err := encWriter.Close(); err != nil {
|
|
return nil, fmt.Errorf("encrypt close: %w", err)
|
|
}
|
|
|
|
return encrypted.Bytes(), nil
|
|
}
|
|
|
|
// OpenFrame decrypts and decompresses ciphertext
|
|
func OpenFrame(ciphertext []byte, identities []age.Identity) ([]byte, error) {
|
|
// 1. Age decrypt
|
|
decReader, err := age.Decrypt(bytes.NewReader(ciphertext), identities...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("age decrypt: %w", err)
|
|
}
|
|
|
|
// 2. Gunzip decompress
|
|
gzipReader, err := gzip.NewReader(decReader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gzip reader: %w", err)
|
|
}
|
|
defer gzipReader.Close()
|
|
|
|
plaintext, err := io.ReadAll(gzipReader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decompress: %w", err)
|
|
}
|
|
|
|
return plaintext, nil
|
|
}
|
|
```
|
|
|
|
**File**: `pkg/ageio/keys.go`
|
|
|
|
```go
|
|
package ageio
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"filippo.io/age"
|
|
)
|
|
|
|
// LoadIdentities loads age identities from file
|
|
func LoadIdentities(path string) ([]age.Identity, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open identity file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
return age.ParseIdentities(f)
|
|
}
|
|
|
|
// LoadRecipients loads age recipients from file
|
|
func LoadRecipients(path string) ([]age.Recipient, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open recipients file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
var recipients []age.Recipient
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
recipient, err := age.ParseX25519Recipient(line)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse recipient: %w", err)
|
|
}
|
|
|
|
recipients = append(recipients, recipient)
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("scan recipients: %w", err)
|
|
}
|
|
|
|
return recipients, nil
|
|
}
|
|
```
|
|
|
|
#### 2.2 Update Proxy for Encryption
|
|
|
|
**File**: `pkg/proxy/server.go` (additions)
|
|
|
|
```go
|
|
type Server struct {
|
|
config ServerConfig
|
|
router *mux.Router
|
|
identities []age.Identity // NEW
|
|
recipients []age.Recipient // NEW
|
|
}
|
|
|
|
func NewServer(cfg ServerConfig) (*Server, error) {
|
|
s := &Server{
|
|
config: cfg,
|
|
router: mux.NewRouter(),
|
|
}
|
|
|
|
// Load age keys if configured
|
|
if cfg.AgeIdentPath != "" {
|
|
identities, err := ageio.LoadIdentities(cfg.AgeIdentPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load identities: %w", err)
|
|
}
|
|
s.identities = identities
|
|
}
|
|
|
|
if cfg.AgeRecipsPath != "" {
|
|
recipients, err := ageio.LoadRecipients(cfg.AgeRecipsPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load recipients: %w", err)
|
|
}
|
|
s.recipients = recipients
|
|
}
|
|
|
|
s.setupRoutes()
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Check content type if encryption is enabled
|
|
if s.identities != nil {
|
|
contentType := r.Header.Get("Content-Type")
|
|
if contentType != "application/age" {
|
|
http.Error(w, "Content-Type must be application/age", http.StatusUnsupportedMediaType)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Read encrypted body
|
|
r.Body = http.MaxBytesReader(w, r.Body, int64(s.config.MaxBodyMB*1024*1024))
|
|
encryptedBody, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Decrypt if encryption enabled
|
|
var plaintext []byte
|
|
if s.identities != nil {
|
|
plaintext, err = ageio.OpenFrame(encryptedBody, s.identities)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Decryption failed")
|
|
s.config.Metrics.RecordDecryptFailure()
|
|
http.Error(w, "Decryption failed", http.StatusBadRequest)
|
|
return
|
|
}
|
|
} else {
|
|
plaintext = encryptedBody
|
|
}
|
|
|
|
// Parse tool request
|
|
var toolReq mcpclient.ToolRequest
|
|
if err := json.Unmarshal(plaintext, &toolReq); err != nil {
|
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Call MCP
|
|
resp, err := s.config.MCPClient.CallTool(ctx, &toolReq)
|
|
if err != nil {
|
|
http.Error(w, "Tool execution failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Marshal response
|
|
responseJSON, err := json.Marshal(resp)
|
|
if err != nil {
|
|
http.Error(w, "Failed to marshal response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Encrypt response if encryption enabled
|
|
var responseBody []byte
|
|
if s.recipients != nil {
|
|
responseBody, err = ageio.SealFrame(responseJSON, s.recipients)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Encryption failed")
|
|
http.Error(w, "Encryption failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/age")
|
|
} else {
|
|
responseBody = responseJSON
|
|
w.Header().Set("Content-Type", "application/json")
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(responseBody)
|
|
}
|
|
```
|
|
|
|
### Beat 2 Acceptance Criteria
|
|
|
|
- [ ] Age encrypt/decrypt golden tests pass
|
|
- [ ] Wrapper enforces `Content-Type: application/age`
|
|
- [ ] End-to-end: encrypted POST → encrypted response
|
|
- [ ] Decryption failures return 400 with proper logging
|
|
- [ ] Metrics track decrypt successes/failures
|
|
|
|
---
|
|
|
|
*[Continuing with Beats 3-6 would follow similar detailed patterns...]*
|
|
|
|
---
|
|
|
|
## Part B: CHORUS Agent Integration
|
|
|
|
### B.1 CHORUS AI Provider Extension
|
|
|
|
**File**: `pkg/ai/seqthink_provider.go`
|
|
|
|
```go
|
|
package ai
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
)
|
|
|
|
// SeqThinkProvider wraps age-encrypted Sequential Thinking
|
|
type SeqThinkProvider struct {
|
|
config ProviderConfig
|
|
httpClient *http.Client
|
|
recipients []age.Recipient
|
|
identities []age.Identity
|
|
jwt string // KACHING JWT for policy
|
|
}
|
|
|
|
type SeqThinkRequest struct {
|
|
Tool string `json:"tool"`
|
|
Payload SeqThinkPayload `json:"payload"`
|
|
Policy SeqThinkPolicy `json:"policy"`
|
|
TS time.Time `json:"ts"`
|
|
Nonce string `json:"nonce"`
|
|
}
|
|
|
|
type SeqThinkPayload struct {
|
|
Objective string `json:"objective"`
|
|
MaxDepth int `json:"max_depth"`
|
|
Reflect bool `json:"reflect"`
|
|
}
|
|
|
|
type SeqThinkPolicy struct {
|
|
Caller string `json:"caller"`
|
|
License string `json:"license"` // KACHING JWT
|
|
Scope []string `json:"scope"`
|
|
}
|
|
|
|
func (p *SeqThinkProvider) ExecuteTask(ctx context.Context, request *TaskRequest) (*TaskResponse, error) {
|
|
startTime := time.Now()
|
|
|
|
// Build Sequential Thinking request
|
|
seqReq := SeqThinkRequest{
|
|
Tool: "sequentialthinking",
|
|
Payload: SeqThinkPayload{
|
|
Objective: p.formatTaskAsObjective(request),
|
|
MaxDepth: 10,
|
|
Reflect: true,
|
|
},
|
|
Policy: SeqThinkPolicy{
|
|
Caller: request.AgentID,
|
|
License: p.jwt,
|
|
Scope: []string{"sequentialthinking.run"},
|
|
},
|
|
TS: time.Now(),
|
|
Nonce: generateNonce(),
|
|
}
|
|
|
|
// Marshal to JSON
|
|
plaintext, err := json.Marshal(seqReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
// Encrypt with age
|
|
encrypted, err := ageio.SealFrame(plaintext, p.recipients)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encrypt request: %w", err)
|
|
}
|
|
|
|
// POST to wrapper
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", p.config.Endpoint+"/mcp/tool", bytes.NewReader(encrypted))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
httpReq.Header.Set("Content-Type", "application/age")
|
|
|
|
resp, err := p.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("http request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Read encrypted response
|
|
encryptedResp, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
// Decrypt response
|
|
decrypted, err := ageio.OpenFrame(encryptedResp, p.identities)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypt response: %w", err)
|
|
}
|
|
|
|
// Parse thinking result
|
|
var thinkingResult struct {
|
|
Thoughts []ThoughtStep `json:"thoughts"`
|
|
Conclusion string `json:"conclusion"`
|
|
}
|
|
|
|
if err := json.Unmarshal(decrypted, &thinkingResult); err != nil {
|
|
return nil, fmt.Errorf("unmarshal response: %w", err)
|
|
}
|
|
|
|
// Convert to TaskResponse with structured reasoning
|
|
return &TaskResponse{
|
|
Success: true,
|
|
TaskID: request.TaskID,
|
|
AgentID: request.AgentID,
|
|
Provider: "seqthink",
|
|
Response: thinkingResult.Conclusion,
|
|
StructuredReasoning: &StructuredReasoning{
|
|
Thoughts: thinkingResult.Thoughts,
|
|
Confidence: 0.9, // Extract from thinking result
|
|
},
|
|
StartTime: startTime,
|
|
EndTime: time.Now(),
|
|
Duration: time.Since(startTime),
|
|
}, nil
|
|
}
|
|
```
|
|
|
|
### B.2 Configuration
|
|
|
|
**File**: `config/agent.yaml` (add seqthink provider)
|
|
|
|
```yaml
|
|
ai_providers:
|
|
# Existing providers...
|
|
|
|
seqthink:
|
|
type: "seqthink"
|
|
endpoint: "http://seqthink-age:8443"
|
|
|
|
# Age encryption
|
|
age_ident_path: "/run/secrets/age_identity.key"
|
|
age_recips_path: "/run/secrets/age_recipients.txt"
|
|
|
|
# KACHING authentication
|
|
kaching_jwt_path: "/run/secrets/kaching_jwt"
|
|
|
|
# Sequential thinking config
|
|
max_depth: 15
|
|
enable_reflection: true
|
|
min_complexity: 7 # Only use for complex tasks
|
|
|
|
role_model_mapping:
|
|
roles:
|
|
architect:
|
|
provider: "seqthink" # Always use Sequential Thinking for architects
|
|
fallback_provider: "resetdata"
|
|
```
|
|
|
|
---
|
|
|
|
## Timeline & Resource Estimates
|
|
|
|
### Beat-by-Beat Timeline
|
|
|
|
| Beat | Tasks | Duration | Resources |
|
|
|------|-------|----------|-----------|
|
|
| Beat 1 | Skeleton & plaintext | 2 days | 1 Go dev |
|
|
| Beat 2 | Age crypto | 2 days | 1 Go dev + security review |
|
|
| Beat 3 | Policy/JWT | 2 days | 1 Go dev |
|
|
| Beat 4 | Observability | 2 days | 1 Go dev |
|
|
| Beat 5 | CI/CD | 1 day | 1 DevOps |
|
|
| Beat 6 | WHOOSH integration | 2 days | 1 Go dev |
|
|
| **Total** | | **11 days** | |
|
|
|
|
### CHORUS Integration Timeline
|
|
|
|
| Task | Duration | Dependencies |
|
|
|------|----------|--------------|
|
|
| SeqThink provider | 2 days | Beat 2 complete |
|
|
| CHORUS config | 1 day | Provider complete |
|
|
| Integration testing | 2 days | All complete |
|
|
| **Total** | **5 days** | |
|
|
|
|
**Grand Total**: ~16 days (3 weeks with buffer)
|
|
|
|
---
|
|
|
|
## Success Criteria
|
|
|
|
### seqthink-age Container
|
|
|
|
- [ ] All Beat 1-6 acceptance criteria met
|
|
- [ ] Image signed with cosign
|
|
- [ ] SBOM generated
|
|
- [ ] Security scan passes (Trivy)
|
|
- [ ] No plaintext visible in tcpdump
|
|
- [ ] Load test: 100 rps, p99 < 200ms overhead
|
|
- [ ] WHOOSH demo: encrypted plan generation
|
|
|
|
### CHORUS Integration
|
|
|
|
- [ ] Architect agents use Sequential Thinking
|
|
- [ ] Complex tasks (complexity >= 7) trigger SeqThink
|
|
- [ ] Structured reasoning stored in TaskResponse
|
|
- [ ] Fallback to ResetData works on failures
|
|
- [ ] E2E test: council creates task with reasoning trace
|
|
|
|
---
|
|
|
|
## Next Steps
|
|
|
|
1. **Create `seqthink-age` repository** in GITEA
|
|
2. **Implement Beat 1** (skeleton)
|
|
3. **Implement Beat 2** (encryption)
|
|
4. **Continue through Beats 3-6**
|
|
5. **Integrate with CHORUS** agents
|
|
6. **Deploy to production** swarm
|
|
|
|
Ready to proceed with implementation?
|