Files
paliad/internal/services/paliadin_remote_test.go
m 97a412498d feat(t-paliad-155): real Claude SKILL.md + per-user tmux session
Move Paliadin's persona + response protocol from a tmux-keystroke-injected
system prompt into a real Claude skill at ~/.claude/skills/paliadin/SKILL.md
(repo source: scripts/skills/paliadin/SKILL.md, install script:
scripts/install-paliadin-skill). Claude's skill router auto-matches the
[PALIADIN:<uuid>] envelope on every turn, so the protocol contract
survives /clear, fresh sessions, and pane restarts — root-cause fix for
the post-/clear stuck-spinner that triggered this task.

Per-user tmux session keying: each Paliad user gets a session named
<prefix>-<userid8> (first 8 hex chars of UUID). One persistent session
per user, conversation history accumulates per visit, ResetSession kills
the session entirely. Health-check cache becomes per-session.

Service-side simplifications:
- paliadin_prompt.go (paliadinSystemPrompt) deleted; trailer parser stays
  in paliadin.go.
- paliadin_remote.go: ensureBootstrapped removed; healthGate takes a
  session arg + caches per-key; ResetSession derives session from UserID
  and shells out to 'reset <session>'.
- paliadin.go (LocalPaliadinService): per-user pane cache, ensurePane
  takes UserID, no more in-process system-prompt send.
- Paliadin interface: ResetSession now takes UserID.

Shim refactor (scripts/paliadin-shim):
- All verbs accept the tmux session as their first positional arg.
- 'bootstrap' verb removed (skill replaces it).
- 'reset' kills the named session via tmux kill-session.
- Session name validated against [A-Za-z0-9_.-]{1,64}.

Env var rename: PALIADIN_TMUX_SESSION -> PALIADIN_SESSION_PREFIX (semantic
shift from literal session name to per-user prefix); CLAUDE.md updated.

Tests cover per-session health caching, session-name derivation,
ResetSession kill-session shape, and health-cache eviction on reset.
2026-05-08 12:42:57 +02:00

302 lines
11 KiB
Go

package services
import (
"context"
"errors"
"fmt"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
)
// testSession is the per-user session name we pass into healthGate /
// callShim from tests. The shape mirrors what RunTurn would derive for
// a real user.
const testSession = "paliad-paliadin-deadbeef"
// Tests for the remote-Paliadin backend. Every test bypasses exec via
// the callShimHook field — no real ssh is ever invoked, no DB rows are
// written. Tests that would need DB I/O (audit row insert/complete on
// RunTurn) are not in scope here; paliad's test suite has no sqlx mock
// and the existing paliadin_test.go only covers pure functions.
func TestNewRemotePaliadinService_Defaults(t *testing.T) {
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{
SSHHost: "100.99.98.203",
// SSHPort + SSHUser intentionally left zero/empty
})
if s.cfg.SSHPort != 22022 {
t.Errorf("SSHPort default = %d; want 22022 (Tailscale-SSH bypass port)", s.cfg.SSHPort)
}
if s.cfg.SSHUser != "m" {
t.Errorf("SSHUser default = %q; want %q", s.cfg.SSHUser, "m")
}
if s.cfg.SSHHost != "100.99.98.203" {
t.Errorf("SSHHost not preserved: %q", s.cfg.SSHHost)
}
}
func TestNewRemotePaliadinService_HonoursOverrides(t *testing.T) {
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{
SSHHost: "10.0.0.1",
SSHPort: 2222,
SSHUser: "alice",
})
if s.cfg.SSHPort != 2222 {
t.Errorf("SSHPort override lost: %d", s.cfg.SSHPort)
}
if s.cfg.SSHUser != "alice" {
t.Errorf("SSHUser override lost: %q", s.cfg.SSHUser)
}
}
func TestClassifySSHError(t *testing.T) {
cases := []struct {
name string
err error
want string
}{
{"nil", nil, ""},
{"explicit ErrMRiverUnreachable", ErrMRiverUnreachable, "mriver_unreachable"},
{"wrapped ErrMRiverUnreachable", fmt.Errorf("foo: %w", ErrMRiverUnreachable), "mriver_unreachable"},
{"context deadline", context.DeadlineExceeded, "timeout"},
{"shim run-turn timeout (exit 124)", errors.New("ssh run-turn …: exit status 124 (stderr: response timeout)"), "timeout"},
{"connection refused", errors.New("ssh health: dial: Connection refused"), "mriver_unreachable"},
{"connection timed out", errors.New("ssh health: Connection timed out"), "mriver_unreachable"},
{"permission denied", errors.New("ssh: Permission denied (publickey)"), "shim_auth_failed"},
{"unknown", errors.New("ssh: some other failure"), "shim_error"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := classifySSHError(c.err)
if got != c.want {
t.Errorf("classifySSHError(%v) = %q; want %q", c.err, got, c.want)
}
})
}
}
func TestHealthGate_CachesOnSuccess(t *testing.T) {
var calls int32
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
atomic.AddInt32(&calls, 1)
if len(args) != 2 || args[0] != "health" || args[1] != testSession {
t.Errorf("unexpected callShim args: %v", args)
}
return []byte("ok\n"), nil
}
for i := 0; i < 5; i++ {
if err := s.healthGate(context.Background(), testSession); err != nil {
t.Fatalf("healthGate iteration %d: %v", i, err)
}
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("expected 1 callShim call (cached); got %d", got)
}
}
func TestHealthGate_RetriesAfterFailure(t *testing.T) {
var calls int32
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
atomic.AddInt32(&calls, 1)
return nil, errors.New("ssh: Connection refused")
}
for i := 0; i < 3; i++ {
err := s.healthGate(context.Background(), testSession)
if !errors.Is(err, ErrMRiverUnreachable) {
t.Errorf("iteration %d: err %v; want wrapping ErrMRiverUnreachable", i, err)
}
}
// Failed health is NOT cached — every call re-probes.
if got := atomic.LoadInt32(&calls); got != 3 {
t.Errorf("expected 3 callShim calls (no caching on failure); got %d", got)
}
}
func TestHealthGate_RejectsUnexpectedReply(t *testing.T) {
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
return []byte("not-ok"), nil
}
err := s.healthGate(context.Background(), testSession)
if !errors.Is(err, ErrMRiverUnreachable) {
t.Errorf("err = %v; want wrap of ErrMRiverUnreachable for non-ok reply", err)
}
}
func TestHealthGate_PerSessionCache(t *testing.T) {
// Two sessions must each get their own probe — caching is per-key,
// not global.
var calls int32
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
atomic.AddInt32(&calls, 1)
return []byte("ok"), nil
}
if err := s.healthGate(context.Background(), "paliad-paliadin-aaaaaaaa"); err != nil {
t.Fatalf("session A first probe: %v", err)
}
if err := s.healthGate(context.Background(), "paliad-paliadin-bbbbbbbb"); err != nil {
t.Fatalf("session B first probe: %v", err)
}
if err := s.healthGate(context.Background(), "paliad-paliadin-aaaaaaaa"); err != nil {
t.Fatalf("session A second probe: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Errorf("expected 2 callShim calls (1 per session, A reuses cache on 3rd); got %d", got)
}
}
func TestHealthGate_CacheExpires(t *testing.T) {
var calls int32
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
atomic.AddInt32(&calls, 1)
return []byte("ok"), nil
}
if err := s.healthGate(context.Background(), testSession); err != nil {
t.Fatalf("first probe: %v", err)
}
// Force the cached timestamp to expire.
s.healthMu.Lock()
s.health[testSession] = healthCacheEntry{ok: true, checkedAt: time.Now().Add(-11 * time.Second)}
s.healthMu.Unlock()
if err := s.healthGate(context.Background(), testSession); err != nil {
t.Fatalf("second probe (expired cache): %v", err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Errorf("expected 2 callShim calls (cache expired between); got %d", got)
}
}
func TestSessionNameFor_PerUser(t *testing.T) {
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
a := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
b := uuid.MustParse("bbbbbbbb-1111-2222-3333-444444444444")
if got := s.sessionNameFor(a); got != "paliad-paliadin-aaaaaaaa" {
t.Errorf("session A = %q; want paliad-paliadin-aaaaaaaa", got)
}
if got := s.sessionNameFor(b); got != "paliad-paliadin-bbbbbbbb" {
t.Errorf("session B = %q; want paliad-paliadin-bbbbbbbb", got)
}
if s.sessionNameFor(a) == s.sessionNameFor(b) {
t.Error("distinct user IDs collapsed to the same session")
}
}
func TestSessionNameFor_HonoursPrefix(t *testing.T) {
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{
SSHHost: "x",
SessionPrefix: "custom",
})
a := uuid.MustParse("12345678-1111-2222-3333-444444444444")
if got := s.sessionNameFor(a); got != "custom-12345678" {
t.Errorf("session = %q; want custom-12345678", got)
}
}
func TestResetSession_KillsPerUserSession(t *testing.T) {
var captured []string
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
captured = append([]string(nil), args...)
return []byte("ok"), nil
}
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
if err := s.ResetSession(context.Background(), uid); err != nil {
t.Fatalf("ResetSession: %v", err)
}
want := []string{"reset", "paliad-paliadin-aaaaaaaa"}
if len(captured) != 2 || captured[0] != want[0] || captured[1] != want[1] {
t.Errorf("callShim args = %v; want %v", captured, want)
}
}
func TestResetSession_DropsHealthCache(t *testing.T) {
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) { return []byte("ok"), nil }
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
session := s.sessionNameFor(uid)
// Warm the cache.
if err := s.healthGate(context.Background(), session); err != nil {
t.Fatalf("warm: %v", err)
}
if _, ok := s.health[session]; !ok {
t.Fatal("cache should be warm")
}
if err := s.ResetSession(context.Background(), uid); err != nil {
t.Fatalf("ResetSession: %v", err)
}
if _, ok := s.health[session]; ok {
t.Error("ResetSession must drop the per-session health cache")
}
}
func TestRemotePaliadin_ImplementsPaliadin(t *testing.T) {
// Compile-time check is in paliadin_remote.go; this test makes the
// failure mode obvious if someone accidentally drops a method.
var _ Paliadin = (*RemotePaliadinService)(nil)
var _ Paliadin = (*LocalPaliadinService)(nil)
var _ Paliadin = (*DisabledPaliadinService)(nil)
}
func TestDisabledPaliadinService(t *testing.T) {
s := NewDisabledPaliadinService(nil, nil)
if _, err := s.RunTurn(context.Background(), TurnRequest{}); !errors.Is(err, ErrPaliadinDisabled) {
t.Errorf("RunTurn error = %v; want ErrPaliadinDisabled", err)
}
if err := s.ResetSession(context.Background(), uuid.Nil); !errors.Is(err, ErrPaliadinDisabled) {
t.Errorf("ResetSession error = %v; want ErrPaliadinDisabled", err)
}
}
func TestCallShim_SSHArgvShape(t *testing.T) {
// Verify the ssh argv we'd construct includes the bypass-port flag,
// the key + known_hosts paths, and the verb after `--`. We don't
// actually exec ssh — we set callShimHook so callShim never reaches
// the exec path; this test just guards the constructor wiring.
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{
SSHHost: "100.99.98.203",
SSHPort: 22022,
SSHUser: "m",
SSHKeyPath: "/tmp/k",
KnownHostsPath: "/tmp/kh",
})
var captured []string
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
captured = append([]string(nil), args...)
return []byte("ok"), nil
}
_, _ = s.callShim(context.Background(), "health")
if len(captured) != 1 || captured[0] != "health" {
t.Errorf("callShim forwarded args = %v; want [health]", captured)
}
}
func TestCallShim_StderrSurfacesInError(t *testing.T) {
// When the real exec path fails, callShim wraps stderr into the
// returned error so classifySSHError can pattern-match. Simulate
// that contract via the hook.
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
return nil, errors.New("ssh health: exit status 1 (stderr: Permission denied (publickey))")
}
_, err := s.callShim(context.Background(), "health")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "Permission denied") {
t.Errorf("error should preserve stderr: %v", err)
}
if classifySSHError(err) != "shim_auth_failed" {
t.Errorf("classifier should pick up Permission denied; got %q", classifySSHError(err))
}
}