package pi import ( "bytes" "context" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "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"` } type ModelOption struct { Provider string `json:"provider"` ID string `json:"id"` } type piOptions struct { Providers []string `json:"providers"` Models []ModelOption `json:"models"` Reasoning []string `json:"reasoning"` } type settingsFileSnapshot struct { Exists bool `json:"exists"` RawBase64 string `json:"rawBase64"` } var internalIdentityHeaders = []string{ "-H", "x-thoth-principal-issuer: thothctl", "-H", "x-thoth-principal-subject: thothctl-maintenance", "-H", "x-thoth-principal-display-name: Thothctl maintenance", "-H", "x-thoth-is-admin: 1", } // Configure changes the backend's real installation settings through a core-side helper. It // deliberately has no secret or endpoint input: external endpoints remain Compose-owned. func Configure(ctx context.Context, runner Runner, value Defaults) error { if !choicePattern.MatchString(value.Provider) || !choicePattern.MatchString(value.Model) { return errors.New("provider and model must be supported identifiers") } before, err := renderedCore(ctx, runner) if err != nil { return err } options, err := configurationOptions(ctx, runner) if err != nil { return err } found := false for _, model := range options.Models { if model.Provider == value.Provider && model.ID == value.Model { found = true } } if !found { return errors.New("provider/model is not in Pi options") } thinkingFound := false for _, reasoning := range options.Reasoning { if reasoning == value.Thinking { thinkingFound = true } } if !thinkingFound { return errors.New("thinking is not in Pi options") } old, err := captureSettingsFile(ctx, runner) if err != nil { return err } oldEffective, err := readEffectiveSettings(ctx, runner) if err != nil { return err } restore := func(cause error) error { if restoreErr := restoreSettingsFile(context.Background(), runner, old); restoreErr != nil { return fmt.Errorf("%w; previous Pi settings restoration could not be verified: %w", cause, restoreErr) } restoredEffective, restoreErr := readEffectiveSettings(context.Background(), runner) if restoreErr != nil || !bytes.Equal(restoredEffective, oldEffective) { return fmt.Errorf("%w; previous effective Pi settings could not be verified: recovery required", cause) } return cause } result, err := writeDefaults(ctx, runner, value) if err != nil { return restore(commandError("Pi installation settings write", result, err)) } settings, err := readEffectiveSettings(ctx, runner) if err != nil { return restore(err) } var saved Defaults if json.Unmarshal(settings, &saved) != nil || saved.Provider != value.Provider || saved.Model != value.Model || saved.Thinking != value.Thinking { return restore(errors.New("Pi installation settings read-back did not match requested provider, model, and thinking")) } after, err := renderedCore(ctx, runner) if err != nil { return restore(err) } if before.ConfigurationSHA != after.ConfigurationSHA { return restore(errors.New("external endpoint configuration changed while configuring Pi")) } return nil } func ConfigurationOptions(ctx context.Context, runner Runner) ([]ModelOption, error) { options, err := configurationOptions(ctx, runner) if err != nil { return nil, err } return options.Models, nil } func configurationOptions(ctx context.Context, runner Runner) (piOptions, error) { args := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...) args = append(args, "http://127.0.0.1:8787/pi-management/options") result, err := runCompose(ctx, runner, args...) if err != nil { return piOptions{}, commandError("Pi options check", result, err) } var payload piOptions if json.Unmarshal([]byte(result.Stdout), &payload) != nil || len(payload.Providers) == 0 || len(payload.Models) == 0 || len(payload.Reasoning) == 0 { return piOptions{}, errors.New("Pi options response is invalid or empty") } providers := make(map[string]bool, len(payload.Providers)) for _, provider := range payload.Providers { if !choicePattern.MatchString(provider) || providers[provider] { return piOptions{}, errors.New("Pi options response contains an invalid provider") } providers[provider] = true } models := make(map[string]bool, len(payload.Models)) for _, option := range payload.Models { key := option.Provider + "\x00" + option.ID if !providers[option.Provider] || !choicePattern.MatchString(option.ID) || models[key] { return piOptions{}, errors.New("Pi options response contains an invalid provider/model") } models[key] = true } reasoning := make(map[string]bool, len(payload.Reasoning)) for _, value := range payload.Reasoning { if (value != "low" && value != "medium" && value != "high") || reasoning[value] { return piOptions{}, errors.New("Pi options response contains an invalid reasoning choice") } reasoning[value] = true } return payload, nil } func writeDefaults(ctx context.Context, runner Runner, value Defaults) (compose.Result, error) { return runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--provider", value.Provider, "--model", value.Model, "--thinking", value.Thinking) } func captureSettingsFile(ctx context.Context, runner Runner) (settingsFileSnapshot, error) { result, err := runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--snapshot") if err != nil { return settingsFileSnapshot{}, commandError("Pi installation settings snapshot", result, err) } var snapshot settingsFileSnapshot if json.Unmarshal([]byte(result.Stdout), &snapshot) != nil { return settingsFileSnapshot{}, errors.New("Pi installation settings snapshot is invalid") } raw, decodeErr := base64.StdEncoding.DecodeString(snapshot.RawBase64) if decodeErr != nil || base64.StdEncoding.EncodeToString(raw) != snapshot.RawBase64 || (!snapshot.Exists && len(raw) != 0) { return settingsFileSnapshot{}, errors.New("Pi installation settings snapshot is invalid") } return snapshot, nil } func restoreSettingsFile(ctx context.Context, runner Runner, snapshot settingsFileSnapshot) error { payload, err := json.Marshal(snapshot) if err != nil { return errors.New("Pi installation settings snapshot could not be encoded") } args := []string{"compose", "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--restore"} result, restoreErr := runner.Run(ctx, args, bytes.NewReader(payload)) verified, verifyErr := captureSettingsFile(ctx, runner) if restoreErr != nil { cause := commandError("Pi installation settings restore", result, restoreErr) if verifyErr == nil && verified == snapshot { return recoveryRequired("previous Pi settings bytes were restored but durability was not acknowledged", cause) } return cause } if verifyErr == nil && verified == snapshot { return nil } return errors.New("Pi installation settings restore did not reproduce the exact prior file state") } func readEffectiveSettings(ctx context.Context, runner Runner) ([]byte, error) { args := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...) args = append(args, "http://127.0.0.1:8787/settings") result, err := runCompose(ctx, runner, args...) if err != nil { return nil, commandError("Pi installation settings read-back", result, err) } var settings map[string]json.RawMessage if json.Unmarshal([]byte(result.Stdout), &settings) != nil || settings == nil { return nil, errors.New("Pi installation settings read-back is invalid") } canonical, err := json.Marshal(settings) if err != nil { return nil, errors.New("Pi installation settings read-back could not be normalized") } return canonical, nil } // Runner is the narrow, shell-free command boundary shared with thothctl. type Runner interface { Run(context.Context, []string, io.Reader) (compose.Result, error) } // Status reports the image-bundled Pi version without using a host Pi executable. func Status(ctx context.Context, runner Runner) (string, error) { result, err := runCompose(ctx, runner, "exec", "-T", "core", "pi", "--version") if err != nil { return "", commandError("Pi version check", result, err) } version := strings.TrimSpace(result.Stdout) if version == "" { return "", errors.New("Pi version check returned no version") } return version, nil } // Doctor verifies the installation-side invariants Pi needs before an update. func Doctor(ctx context.Context, runner Runner) error { if _, err := renderedCore(ctx, runner); err != nil { return err } actual, err := Status(ctx, runner) if err != nil { return err } expected, label, err := expectedVersions(ctx, runner) if err != nil { return err } if actual != expected || actual != label { return errors.New("Pi version does not match the image PI_VERSION and io.thothii.pi.version contract") } for _, check := range [][]string{ {"exec", "-T", "core", "sh", "-ceu", "test -w /home/thoth/.pi"}, {"exec", "-T", "core", "sh", "-ceu", "test -r /home/thoth/.pi/agent/auth.json"}, {"exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/health"}, } { result, err := runCompose(ctx, runner, check...) if err != nil { return commandError("Pi preflight check", result, err) } } return Test(ctx, runner) } func expectedVersions(ctx context.Context, runner Runner) (string, string, error) { environment, err := runCompose(ctx, runner, "exec", "-T", "core", "sh", "-ceu", `printf '%s\n' "${PI_VERSION:-}"`) if err != nil { return "", "", commandError("Pi expected-version check", environment, err) } container, err := runCompose(ctx, runner, "ps", "-q", "core") if err != nil || strings.TrimSpace(container.Stdout) == "" { return "", "", commandError("Pi image-label check", container, err) } label, err := runner.Run(ctx, []string{"inspect", "--format", `{{ index .Config.Labels "io.thothii.pi.version" }}`, strings.TrimSpace(container.Stdout)}, nil) if err != nil { return "", "", commandError("Pi image-label check", label, err) } expectedValue, labelValue := strings.TrimSpace(environment.Stdout), strings.TrimSpace(label.Stdout) if expectedValue == "" || labelValue == "" { return "", "", errors.New("Pi image expected-version contract is empty") } return expectedValue, labelValue, nil } // Test retains the direct image-version signal, then delegates all Pi configuration/provider smoke // validation to core's dedicated, admin-only Pi Management endpoint. func Test(ctx context.Context, runner Runner) error { if _, err := Status(ctx, runner); err != nil { return err } args := append([]string{"exec", "-T", "core", "curl", "-fsS", "-X", "POST"}, internalIdentityHeaders...) args = append(args, "http://127.0.0.1:8787/pi-management/test") smoke, err := runCompose(ctx, runner, args...) if err != nil { return commandError("Pi smoke check", smoke, err) } var smokePayload struct { Ready bool `json:"ready"` } if json.Unmarshal([]byte(smoke.Stdout), &smokePayload) != nil || !smokePayload.Ready { return errors.New("Pi smoke response is not ready") } return nil } func renderedCore(ctx context.Context, runner Runner) (Image, error) { result, err := runCompose(ctx, runner, "config", "--format", "json") if err != nil { return Image{}, commandError("Compose configuration check", result, err) } var document map[string]any if err := json.Unmarshal([]byte(result.Stdout), &document); err != nil { return Image{}, errors.New("Compose returned invalid rendered configuration") } services, ok := document["services"].(map[string]any) if !ok { return Image{}, errors.New("rendered Compose configuration has no services") } core, ok := services["core"].(map[string]any) reference, _ := core["image"].(string) if !ok || reference == "" { return Image{}, errors.New("rendered Compose configuration has no core image") } environment, _ := core["environment"].(map[string]any) endpoint, exists := environment["THT_LLM_URL"].(string) if !exists || strings.TrimSpace(endpoint) == "" { return Image{}, errors.New("THT_LLM_URL must be configured before Pi lifecycle operations") } // Lifecycle overrides intentionally replace only core.image. Normalize that field so the // non-secret configuration digest continues to detect endpoint/mount/configuration drift. core["image"] = "" normalized, err := json.Marshal(document) if err != nil { return Image{}, errors.New("Compose configuration could not be normalized") } digest := sha256.Sum256(normalized) return Image{Reference: reference, ConfigurationSHA: fmt.Sprintf("%x", digest[:])}, nil } func runCompose(ctx context.Context, runner Runner, args ...string) (compose.Result, error) { return runner.Run(ctx, append([]string{"compose"}, args...), nil) } func commandError(label string, result compose.Result, err error) error { if result.ExitCode != 0 { return commandFailure{message: fmt.Sprintf("%s failed (exit %d)", label, result.ExitCode), exitCode: result.ExitCode} } return commandFailure{message: fmt.Sprintf("%s failed", label)} } type commandFailure struct { message string exitCode int } func (e commandFailure) Error() string { return e.message } // ExitCode exposes a Docker child exit code without exposing its output. func (e commandFailure) ExitCode() int { return e.exitCode }