0.29.1 — review-fix round on E3: RunOutput, sshKeyPath fallback, RunStreaming consolidation, signer cache, stderr in errors
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

This commit is contained in:
2026-08-08 23:01:10 +02:00
parent 75c0848a6f
commit 7236c46e5c
8 changed files with 88 additions and 249 deletions

View File

@@ -7,11 +7,11 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"regexp"
"runtime"
@@ -19,6 +19,7 @@ import (
"strings"
"time"
"github.com/dtoro/oikos/internal/actuator"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen"
@@ -981,28 +982,46 @@ func allowlistedScript(name string) bool {
return scriptNameRe.MatchString(name)
}
// sshExec runs a command on a remote host over crypto/ssh via the shared
// actuator primitives. It replaced a fork of `os/exec ssh` so the scheduler,
// the MCP execution path, and the actuator share one dial/run/host-key
// implementation (plan E3). The host key is verified through the centralized
// actuator.HostKeyCallback seam. ctx bounds the running command; timeout
// bounds the dial.
func sshExec(ctx context.Context, host, port, user, cmd string, timeout time.Duration) ([]byte, error) {
args := []string{
"-o", "ConnectTimeout=" + strconv.Itoa(int(timeout.Seconds())),
"-o", "StrictHostKeyChecking=no",
"-o", "BatchMode=yes",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
}
if sshKeyPath != "" {
args = append(args, "-i", sshKeyPath)
}
if port != "" && port != "22" {
args = append(args, "-p", port)
}
args = append(args, "-l", user, host, cmd)
c := exec.CommandContext(ctx, "ssh", args...)
out, err := c.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return nil, fmt.Errorf("ssh %s: %v (stderr: %s)", host, err, string(ee.Stderr))
keyPath := sshKeyPath
if keyPath == "" {
// Preserve the old os/exec-ssh behavior of deferring to a default
// key when no explicit OIKOS_SSH_KEY_PATH is configured: the system
// ssh binary used the agent / ~/.ssh; crypto/ssh has no agent wiring,
// so fall back to SSH_KEY_PATH then ~/.ssh/id_rsa.
keyPath = os.Getenv("SSH_KEY_PATH")
if keyPath == "" {
keyPath = os.Getenv("HOME") + "/.ssh/id_rsa"
}
}
signer, err := actuator.LoadSigner(keyPath)
if err != nil {
return nil, fmt.Errorf("ssh %s: %v", host, err)
}
p := 22
if port != "" {
if n, parseErr := strconv.Atoi(port); parseErr == nil && n > 0 {
p = n
}
}
client, err := actuator.Dial(ctx, actuator.DialOptions{
Host: host, Port: p, User: user, Signer: signer, Timeout: timeout,
})
if err != nil {
return nil, fmt.Errorf("ssh %s: %v", host, err)
}
defer client.Close()
// RunOutput (stdout-only) — the scheduler parses check output as JSON or
// matches it literally, so stderr must not be merged in (RunCombinedOutput
// is for the live-run display path in mcp/httpapi).
out, err := actuator.RunOutput(ctx, client, cmd)
if err != nil {
return nil, fmt.Errorf("ssh %s: %v", host, err)
}
return out, nil