fix: harden thothctl pi lifecycle
This commit is contained in:
@@ -7,11 +7,94 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
|
||||
)
|
||||
|
||||
var choicePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
|
||||
|
||||
type Defaults struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Thinking string `json:"thinking"`
|
||||
LLMURL string `json:"llm_url"`
|
||||
}
|
||||
|
||||
// Configure validates and atomically stores only local non-secret Pi defaults.
|
||||
func Configure(ctx context.Context, runner Runner, path string, value Defaults) error {
|
||||
if !choicePattern.MatchString(value.Provider) || !choicePattern.MatchString(value.Model) {
|
||||
return errors.New("provider and model must be supported identifiers")
|
||||
}
|
||||
if value.Thinking != "low" && value.Thinking != "medium" && value.Thinking != "high" {
|
||||
return errors.New("thinking must be low, medium, or high")
|
||||
}
|
||||
u, err := url.Parse(value.LLMURL)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return errors.New("LLM endpoint must be an http(s) URL without credentials, query, or fragment")
|
||||
}
|
||||
models, err := runCompose(ctx, runner, "exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/models")
|
||||
if err != nil {
|
||||
return commandError("Pi options check", models, err)
|
||||
}
|
||||
var payload struct {
|
||||
Models []struct {
|
||||
Provider string `json:"provider"`
|
||||
ID string `json:"id"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if json.Unmarshal([]byte(models.Stdout), &payload) != nil || len(payload.Models) == 0 {
|
||||
return errors.New("Pi options response is invalid or empty")
|
||||
}
|
||||
found := false
|
||||
for _, model := range payload.Models {
|
||||
if model.Provider == value.Provider && model.ID == value.Model {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("provider/model is not in Pi options")
|
||||
}
|
||||
return writeJSON(path, value)
|
||||
}
|
||||
|
||||
func writeJSON(path string, value any) error {
|
||||
contents, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contents = append(contents, '\n')
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return errors.New("could not create Pi configuration directory")
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(path), ".pi-defaults-*.tmp")
|
||||
if err != nil {
|
||||
return errors.New("could not write Pi configuration")
|
||||
}
|
||||
name := temporary.Name()
|
||||
defer os.Remove(name)
|
||||
if err = temporary.Chmod(0o600); err == nil {
|
||||
_, err = temporary.Write(contents)
|
||||
}
|
||||
if err == nil {
|
||||
err = temporary.Sync()
|
||||
}
|
||||
if closeErr := temporary.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err == nil {
|
||||
err = os.Rename(name, path)
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("could not atomically write Pi configuration")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Runner is the narrow, shell-free command boundary shared with thothctl.
|
||||
type Runner interface {
|
||||
Run(context.Context, []string, io.Reader) (compose.Result, error)
|
||||
|
||||
Reference in New Issue
Block a user