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

@@ -0,0 +1,84 @@
package shell
import (
"errors"
"fmt"
"math/rand/v2"
"sync"
)
type registryEntry struct {
shell Shell
weight int
}
// Registry holds shells with associated weights for random selection.
type Registry struct {
mu sync.RWMutex
entries []registryEntry
}
// NewRegistry returns an empty Registry.
func NewRegistry() *Registry {
return &Registry{}
}
// Register adds a shell with the given weight. Weight must be >= 1 and
// no duplicate names are allowed.
func (r *Registry) Register(shell Shell, weight int) error {
if weight < 1 {
return fmt.Errorf("weight must be >= 1, got %d", weight)
}
r.mu.Lock()
defer r.mu.Unlock()
for _, e := range r.entries {
if e.shell.Name() == shell.Name() {
return fmt.Errorf("shell %q already registered", shell.Name())
}
}
r.entries = append(r.entries, registryEntry{shell: shell, weight: weight})
return nil
}
// Select picks a shell using weighted random selection.
func (r *Registry) Select() (Shell, error) {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.entries) == 0 {
return nil, errors.New("no shells registered")
}
total := 0
for _, e := range r.entries {
total += e.weight
}
pick := rand.IntN(total)
cumulative := 0
for _, e := range r.entries {
cumulative += e.weight
if pick < cumulative {
return e.shell, nil
}
}
// Should never reach here, but return last entry as fallback.
return r.entries[len(r.entries)-1].shell, nil
}
// Get returns a shell by name.
func (r *Registry) Get(name string) (Shell, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, e := range r.entries {
if e.shell.Name() == name {
return e.shell, true
}
}
return nil, false
}