Files
CHORUS/docs/SEQTHINK-AGE-WRAPPER-IMPLEMENTATION.md
anthonyrawlins 3ce9811826 Implement Beat 1: Sequential Thinking Age-Encrypted Wrapper (Skeleton)
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>
2025-10-13 08:35:43 +11:00

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?