feat: add shell interface, registry, and bash shell emulator

Implement Phase 1.4: replaces the hardcoded banner/timeout stub with a
proper shell system. Adds a Shell interface with weighted registry for
shell selection, a RecordingChannel wrapper (pass-through for now, prep
for Phase 2.3 replay), and a bash-like shell with fake filesystem,
terminal line reader, and command handling (pwd, ls, cd, cat, whoami,
hostname, id, uname, exit). Sessions now log command/output pairs to
the store and record the shell name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 20:24:48 +01:00
parent ae9924ffbb
commit 8189a108d1
17 changed files with 1503 additions and 41 deletions

View File

@@ -1,11 +1,13 @@
package server
import (
"bytes"
"context"
"log/slog"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -109,6 +111,10 @@ func TestIntegrationSSHConnect(t *testing.T) {
{Username: "root", Password: "toor"},
},
},
Shell: config.ShellConfig{
Hostname: "ubuntu-server",
Banner: "Welcome to Ubuntu 22.04.3 LTS\r\n\r\n",
},
LogLevel: "debug",
}
@@ -152,7 +158,7 @@ func TestIntegrationSSHConnect(t *testing.T) {
time.Sleep(50 * time.Millisecond)
}
// Test static credential login.
// Test static credential login with shell interaction.
t.Run("static_cred", func(t *testing.T) {
clientCfg := &ssh.ClientConfig{
User: "root",
@@ -172,6 +178,62 @@ func TestIntegrationSSHConnect(t *testing.T) {
t.Fatalf("new session: %v", err)
}
defer session.Close()
// Request PTY and shell.
if err := session.RequestPty("xterm", 80, 40, ssh.TerminalModes{}); err != nil {
t.Fatalf("request pty: %v", err)
}
stdin, err := session.StdinPipe()
if err != nil {
t.Fatalf("stdin pipe: %v", err)
}
var output bytes.Buffer
session.Stdout = &output
if err := session.Shell(); err != nil {
t.Fatalf("shell: %v", err)
}
// Wait for the prompt, then send commands.
time.Sleep(500 * time.Millisecond)
stdin.Write([]byte("pwd\r"))
time.Sleep(200 * time.Millisecond)
stdin.Write([]byte("whoami\r"))
time.Sleep(200 * time.Millisecond)
stdin.Write([]byte("exit\r"))
// Wait for session to end.
session.Wait()
out := output.String()
if !strings.Contains(out, "Welcome to Ubuntu") {
t.Errorf("output should contain banner, got: %s", out)
}
if !strings.Contains(out, "/root") {
t.Errorf("output should contain /root from pwd, got: %s", out)
}
if !strings.Contains(out, "root") {
t.Errorf("output should contain 'root' from whoami, got: %s", out)
}
// Verify session logs were recorded.
if len(store.SessionLogs) < 2 {
t.Errorf("expected at least 2 session logs, got %d", len(store.SessionLogs))
}
// Verify session was created with shell name.
var foundBash bool
for _, s := range store.Sessions {
if s.ShellName == "bash" {
foundBash = true
break
}
}
if !foundBash {
t.Error("expected a session with shell_name='bash'")
}
})
// Test wrong password is rejected.