// Package actuator provides SSH-based skill procedure execution for the Oikos // Phase 3 actuator loop. It runs stored skill procedures over SSH with a // restricted key, classifies SSH errors into retryable/fatal/timeout, and // supports step-by-step procedure verification. package actuator import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net" "strings" "sync" "time" "golang.org/x/crypto/ssh" ) // ─── Procedure types ────────────────────────────────────────────────────── // Procedure represents a parsed skill procedure from JSON config. type Procedure struct { Steps []Step `json:"steps"` } // Step is a single step within a procedure. type Step struct { Runner string `json:"runner"` // "shell", "script", "verify" Target string `json:"target,omitempty"` // hostname/IP (empty = local) Command string `json:"command"` // shell command or script path TimeoutS int `json:"timeout_s,omitempty"` // per-step timeout in seconds } // SSHResult holds the outcome of an SSH execution. type SSHResult struct { Output string `json:"output"` Duration time.Duration `json:"duration"` Verified bool `json:"verified"` Err error `json:"error,omitempty"` } // ─── Error classification ───────────────────────────────────────────────── // SSHErrorClass categorises SSH errors. type SSHErrorClass int const ( SSHErrorUnknown SSHErrorClass = iota SSHErrorNetwork // dial/connect timeout — retryable SSHErrorAuth // auth failure — fatal SSHErrorTimeout // command timed out SSHErrorRemote // remote command returned non-zero SSHErrorOther // other non-retryable ) func (c SSHErrorClass) String() string { switch c { case SSHErrorNetwork: return "network" case SSHErrorAuth: return "auth" case SSHErrorTimeout: return "timed_out" case SSHErrorRemote: return "remote" case SSHErrorOther: return "other" default: return "unknown" } } // classifySSHError maps an SSH error to a class for retry/fatal decisions. func classifySSHError(err error) SSHErrorClass { if err == nil { return SSHErrorOther } if errors.Is(err, context.DeadlineExceeded) { return SSHErrorTimeout } var netErr net.Error if errors.As(err, &netErr) { if netErr.Timeout() { return SSHErrorNetwork } return SSHErrorNetwork } if strings.Contains(err.Error(), "unable to authenticate") || strings.Contains(err.Error(), "no supported methods remain") || strings.Contains(err.Error(), "ssh: handshake failed") || strings.Contains(err.Error(), "publickey") || strings.Contains(err.Error(), "permission denied") { return SSHErrorAuth } var exitErr *ssh.ExitError if errors.As(err, &exitErr) { return SSHErrorRemote } return SSHErrorOther } // ─── SSH execution ──────────────────────────────────────────────────────── // SSHConfig holds connection parameters for SSH sessions. type SSHConfig struct { Host string Port int User string KeyPath string Timeout time.Duration } // ExecuteProcedure runs a complete procedure over SSH, step by step. // Returns the combined result, duration, and verified status. // // Context cancellation aborts the running session. Returns the last // successfully completed step's output on partial failure. func ExecuteProcedure( ctx context.Context, cfg SSHConfig, proc Procedure, ) SSHResult { start := time.Now() // Parse the SSH key signer, err := LoadSigner(cfg.KeyPath) if err != nil { return SSHResult{ Err: err, Duration: time.Since(start), Verified: false, } } client, err := Dial(ctx, DialOptions{ Host: cfg.Host, Port: cfg.Port, User: cfg.User, Signer: signer, Timeout: cfg.Timeout, }) if err != nil { class := classifySSHError(err) return SSHResult{ Err: fmt.Errorf("ssh dial (%s): %w", class, err), Duration: time.Since(start), Verified: false, } } defer client.Close() // Execute each step in sequence var lastOutput string verified := true for i, step := range proc.Steps { // Check context before each step if ctx.Err() != nil { return SSHResult{ Output: lastOutput, Duration: time.Since(start), Verified: false, Err: fmt.Errorf("cancelled before step %d: %w", i, ctx.Err()), } } timeout := time.Duration(step.TimeoutS) * time.Second if timeout <= 0 { timeout = 30 * time.Second } stepCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() output, err := runSSHCommand(stepCtx, client, step.Command) if err != nil { class := classifySSHError(err) // Verify steps that fail are not counted as verified failures if step.Runner == "verify" { verified = false } // Non-verify step failure is a real failure if step.Runner != "verify" { return SSHResult{ Output: lastOutput, Duration: time.Since(start), Err: fmt.Errorf("step %d (%s) failed (%s): %w", i, step.Runner, class, err), Verified: false, } } } lastOutput = output slog.Debug("ssh step completed", "step", i, "runner", step.Runner, "duration", time.Since(start).Round(time.Millisecond), ) } return SSHResult{ Output: lastOutput, Duration: time.Since(start), Verified: verified, } } // runSSHCommand executes a single command over an established SSH session via // the shared RunCombinedOutput primitive (context-aware abort + combined output). func runSSHCommand(ctx context.Context, client *ssh.Client, command string) (string, error) { out, err := RunCombinedOutput(ctx, client, command) return string(out), err } // ─── Procedure parsing ──────────────────────────────────────────────────── // ParseProcedure deserialises a JSON procedure (from skill.procedure). func ParseProcedure(data []byte) (Procedure, error) { var proc Procedure if err := json.Unmarshal(data, &proc); err != nil { return Procedure{}, fmt.Errorf("parse procedure: %w", err) } return proc, nil } // ─── Global SSH client options ──────────────────────────────────────────── var ( mu sync.Mutex // defaultSSHTimeout is the default dial timeout for SSH connections. defaultSSHTimeout = 10 * time.Second ) // SetDefaultSSHTimeout overrides the default SSH dial timeout. Not safe for // concurrent use during active execution. func SetDefaultSSHTimeout(d time.Duration) { mu.Lock() defer mu.Unlock() defaultSSHTimeout = d }