Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/jaypopat/cf_ai_duet/llms.txt

Use this file to discover all available pages before exploring further.

Duet’s SSH server is built on Charm’s Wish framework, which provides a composable middleware system for building SSH applications.

Server Structure

The server is defined in /internal/server/server.go:
type Server struct {
    addr        string
    hostKeyPath string
    roomManager *room.Manager
    logger      *log.Logger
}

Initialization

The server is created with connection details and a room manager instance:
func New(addr, hostKeyPath, workerURL string) *Server {
    logger := log.NewWithOptions(os.Stderr, log.Options{
        Prefix: "duet",
    })

    var aiClient *ai.Client
    if workerURL != "" {
        aiClient = ai.NewClient(workerURL)
    }

    mgr := room.NewManager(workerURL, aiClient, logger)

    return &Server{
        addr:        addr,
        hostKeyPath: hostKeyPath,
        roomManager: mgr,
        logger:      logger,
    }
}
Key Points:
  • Default address is :2222
  • Host key path defaults to .ssh/id_ed25519 (auto-generated if missing)
  • AI client is optional (only created if worker URL provided)
  • Room manager is shared across all connections

Wish Server Configuration

The server is configured with Wish middleware:
func (s *Server) Start() error {
    srv, err := wish.NewServer(
        wish.WithAddress(s.addr),
        wish.WithHostKeyPath(s.hostKeyPath),
        wish.WithMiddleware(
            bubbletea.Middleware(s.teaHandler),
            logging.Middleware(),
        ),
    )
    if err != nil {
        return fmt.Errorf("failed to create server: %w", err)
    }
    // ...
}

Middleware Stack

Duet uses two middleware layers:
  1. Bubble Tea Middleware: Handles TUI creation for each SSH session
  2. Logging Middleware: Logs connection events and errors
Middleware executes in order from top to bottom. The Bubble Tea middleware is the primary handler.

Bubble Tea Handler

The teaHandler function is called for each new SSH connection:
func (s *Server) teaHandler(sess ssh.Session) (tea.Model, []tea.ProgramOption) {
    username := sess.User()
    if username == "" {
        username = "guest"
    }
    renderer := bubbletea.MakeRenderer(sess)

    pty, _, _ := sess.Pty()

    if pty.Term == "xterm-ghostty" {
        renderer.SetColorProfile(termenv.TrueColor)
    }

    s.logger.Info("final renderer",
        "profile", renderer.ColorProfile(),
        "hasDark", renderer.HasDarkBackground(),
    )
    return ui.New(renderer, s.roomManager, username), []tea.ProgramOption{
        tea.WithAltScreen(),
    }
}

Session Information

  • Username: Extracted from SSH session (defaults to “guest”)
  • Renderer: Created per-session for style rendering
  • PTY Info: Terminal type and color profile detection
  • Alt Screen: Uses alternate screen buffer (terminal state is preserved on exit)

Color Profile Detection

The handler detects terminal capabilities:
if pty.Term == "xterm-ghostty" {
    renderer.SetColorProfile(termenv.TrueColor)
}
This ensures proper color rendering for clients with different terminal emulators.

Lifecycle Management

Graceful Shutdown

The server listens for interrupt signals and shuts down gracefully:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
defer stop()

go func() {
    s.logger.Info("Starting SSH server", "address", s.addr)
    if err := srv.ListenAndServe(); err != nil {
        s.logger.Error("Server error", "error", err)
    }
}()

<-ctx.Done()

s.logger.Info("Shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

return srv.Shutdown(shutdownCtx)
Shutdown Process:
  1. Wait for SIGINT or SIGTERM signal
  2. Log shutdown message
  3. Create 10-second timeout context
  4. Call srv.Shutdown() to close active connections
  5. Return any shutdown errors

Connection Lifecycle

SSH Client Connect

Wish accepts connection

Middleware stack processes session

teaHandler creates Bubble Tea model

tea.NewProgram(model).Run()

Model.Init() → ScreenLaunch

User interacts (create/join room)

User disconnects or quits

Model.cleanup() called

room.LeaveRoom() removes client

SSH session closes

Authentication

Duet uses public key authentication via the host key:
# Start server (generates host key if missing)
./duet -addr :2222 -hostkey .ssh/id_ed25519

# Connect as any user (username becomes display name)
ssh user@localhost -p 2222

Host Key Generation

Wish automatically generates an Ed25519 host key if the file doesn’t exist:
wish.WithHostKeyPath(s.hostKeyPath)
The key is stored at the specified path and reused for subsequent starts.

Per-Session Isolation

Each SSH session gets:
  • Unique Bubble Tea model instance (ui.New(...))
  • Unique client ID (uuid.New().String())
  • Separate event channel for room notifications
  • Independent terminal subscription when joining a room
Shared across sessions:
  • Room Manager (singleton)
  • Terminal instances (one per room, shared by all clients in that room)
  • AI Client (if configured)

Configuration Options

Command-line flags in main.go:
addr := flag.String("addr", ":2222", "SSH server address")
hostKeyPath := flag.String("hostkey", ".ssh/id_ed25519", "Path to SSH host key")
workerURL := flag.String("worker", "", "Duet CF Worker base URL")

Example Usage

# Default configuration
./duet

# Custom port and host key
./duet -addr :3000 -hostkey /etc/duet/host_key

# With AI worker integration
./duet -worker https://duet-worker.example.workers.dev

Error Handling

The server handles errors at multiple levels:
// Server creation errors
if err := srv.ListenAndServe(); err != nil {
    s.logger.Error("Server error", "error", err)
}

// Shutdown errors
if err := srv.Shutdown(shutdownCtx); err != nil {
    return err
}
Client-level errors are handled in the Bubble Tea model’s Update method via ErrorMsg messages.

Security Considerations

  • Host Key: Securely store the host key file with appropriate permissions (0600)
  • No Password Auth: Only public key authentication is supported
  • Workspace Isolation: Each room uses a separate workspace directory
  • PTY Restrictions: Terminal processes run in isolated directories
For production deployments, consider:
  • Rate limiting connections
  • Authentication via SSH keys
  • Network-level access controls
  • Monitoring and logging

Build docs developers (and LLMs) love